From 220f719c38efa4db36c877a43ea25ec20a3ec50e Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Thu, 29 May 2025 12:41:21 +0800 Subject: [PATCH] =?UTF-8?q?wip:=20=E4=B8=B4=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-wechat-login.md | 609 ++++++++++++++++++++++++++++++++ src/api/auth.ts | 177 +++++----- src/pages/login/index.vue | 384 +++++++++++++------- src/static/images/login-bg.svg | 27 +- src/store/modules/user.store.ts | 36 +- src/styles/theme.scss | 4 + src/utils/auth.ts | 30 ++ src/utils/request.ts | 230 +++++++++--- 8 files changed, 1209 insertions(+), 288 deletions(-) create mode 100644 README-wechat-login.md diff --git a/README-wechat-login.md b/README-wechat-login.md new file mode 100644 index 0000000..71f1465 --- /dev/null +++ b/README-wechat-login.md @@ -0,0 +1,609 @@ +# 微信登录实现指南 + +本文档提供基于 wx-java-sdk 的微信授权登录后端实现方案,包括会话管理和接口设计。 + +## 技术方案概述 + +微信登录流程采用基于会话管理的方式,避免每次都从微信服务端获取授权: + +1. 前端通过微信 SDK 获取登录凭证(code)和手机号加密数据 +2. 后端接收凭证和加密数据,与微信服务器交互获取用户信息 +3. 后端创建或更新用户信息,并生成会话令牌(token)返回给前端 +4. 前端存储令牌,后续请求时携带令牌进行身份验证 +5. 令牌过期时,后端自动使用刷新令牌获取新的访问令牌 + +## 后端依赖 + +```xml + + + com.github.binarywang + weixin-java-miniapp + 4.5.0 + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + +``` + +## 后端配置 + +```yaml +# application.yml +wx: + miniapp: + appid: ${WX_MINIAPP_APPID} # 微信小程序 appId + secret: ${WX_MINIAPP_SECRET} # 微信小程序 appSecret + token: ${WX_MINIAPP_TOKEN} # 微信小程序消息服务器配置的 token + aesKey: ${WX_MINIAPP_AES_KEY} # 微信小程序消息服务器配置的 EncodingAESKey + msgDataFormat: JSON # 消息格式,XML 或者 JSON + +# JWT 配置 +jwt: + secret: ${JWT_SECRET_KEY} # JWT 密钥 + access-token-expiration: 86400 # 访问令牌过期时间(秒),默认1天 + refresh-token-expiration: 604800 # 刷新令牌过期时间(秒),默认7天 +``` + +## 微信服务配置类 + +```java +@Configuration +@EnableConfigurationProperties(WxMaProperties.class) +public class WxMaConfiguration { + private final WxMaProperties properties; + private static final Map maServices = new HashMap<>(); + + @Autowired + public WxMaConfiguration(WxMaProperties properties) { + this.properties = properties; + } + + @Bean + public WxMaService wxMaService() { + WxMaService service = new WxMaServiceImpl(); + WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); + config.setAppid(properties.getAppid()); + config.setSecret(properties.getSecret()); + config.setToken(properties.getToken()); + config.setAesKey(properties.getAesKey()); + config.setMsgDataFormat(properties.getMsgDataFormat()); + service.setWxMaConfig(config); + maServices.put(properties.getAppid(), service); + return service; + } + + public static WxMaService getMaService(String appid) { + return maServices.get(appid); + } +} +``` + +## 微信用户表设计 + +```sql +CREATE TABLE `wx_user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `open_id` varchar(128) NOT NULL COMMENT '微信开放ID', + `union_id` varchar(128) DEFAULT NULL COMMENT '微信开放平台unionid', + `session_key` varchar(128) DEFAULT NULL COMMENT '会话密钥', + `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', + `avatar_url` varchar(256) DEFAULT NULL COMMENT '头像', + `phone` varchar(32) DEFAULT NULL COMMENT '手机号', + `gender` tinyint(1) DEFAULT NULL COMMENT '性别(0:未知 1:男 2:女)', + `country` varchar(64) DEFAULT NULL COMMENT '国家', + `province` varchar(64) DEFAULT NULL COMMENT '省份', + `city` varchar(64) DEFAULT NULL COMMENT '城市', + `language` varchar(64) DEFAULT NULL COMMENT '语言', + `is_new_user` tinyint(1) DEFAULT '1' COMMENT '是否新用户(0:否 1:是)', + `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间', + `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_open_id` (`open_id`), + KEY `idx_union_id` (`union_id`), + KEY `idx_phone` (`phone`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信用户表'; +``` + +## 控制器层实现 + +```java +@Slf4j +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final WxMaService wxMaService; + private final UserService userService; + private final JwtService jwtService; + + /** + * 微信登录(简单版) + */ + @PostMapping("/wechat/login") + public R wechatLogin(@RequestBody WechatLoginRequest request) { + try { + // 获取微信用户信息 + WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode()); + String openid = sessionResult.getOpenid(); + String sessionKey = sessionResult.getSessionKey(); + + // 查询或创建用户 + User user = userService.getOrCreateWxUser(openid, sessionKey); + + // 生成令牌 + String accessToken = jwtService.generateAccessToken(user.getId()); + String refreshToken = jwtService.generateRefreshToken(user.getId()); + + // 构建返回结果 + LoginResult result = new LoginResult(); + result.setAccessToken(accessToken); + result.setRefreshToken(refreshToken); + result.setTokenType("Bearer"); + result.setExpiresIn(jwtService.getAccessTokenExpiration()); + result.setNewUser(user.getIsNewUser()); + result.setProfileComplete(userService.isUserProfileComplete(user)); + + return R.ok(result); + } catch (WxErrorException e) { + log.error("微信登录失败", e); + return R.fail("微信登录失败: " + e.getMessage()); + } + } + + /** + * 微信小程序手机号登录 + */ + @PostMapping("/wechat/phone-login") + public R wechatPhoneLogin(@RequestBody WechatPhoneLoginRequest request) { + try { + // 获取微信用户信息 + WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode()); + String openid = sessionResult.getOpenid(); + String sessionKey = sessionResult.getSessionKey(); + + // 解密手机号 + String phoneNumber = null; + + // 新版本获取手机号 + if (request.getPhoneCode() != null) { + WxMaPhoneNumberInfo phoneInfo = wxMaService.getPhoneNoInfo(request.getPhoneCode()); + phoneNumber = phoneInfo.getPhoneNumber(); + } + // 旧版本获取手机号 + else if (request.getEncryptedData() != null && request.getIv() != null) { + WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService() + .getPhoneNoInfo(sessionKey, request.getEncryptedData(), request.getIv()); + phoneNumber = phoneInfo.getPhoneNumber(); + } + + if (phoneNumber == null) { + return R.fail("获取手机号失败"); + } + + // 查询或创建用户并更新手机号 + User user = userService.getOrCreateWxUserWithPhone(openid, sessionKey, phoneNumber); + + // 生成令牌 + String accessToken = jwtService.generateAccessToken(user.getId()); + String refreshToken = jwtService.generateRefreshToken(user.getId()); + + // 构建返回结果 + LoginResult result = new LoginResult(); + result.setAccessToken(accessToken); + result.setRefreshToken(refreshToken); + result.setTokenType("Bearer"); + result.setExpiresIn(jwtService.getAccessTokenExpiration()); + result.setNewUser(user.getIsNewUser()); + result.setProfileComplete(userService.isUserProfileComplete(user)); + + return R.ok(result); + } catch (WxErrorException e) { + log.error("微信手机号登录失败", e); + return R.fail("微信手机号登录失败: " + e.getMessage()); + } + } + + /** + * 检查会话有效性 + */ + @GetMapping("/check-session") + public R checkSession(HttpServletRequest request) { + String token = jwtService.getTokenFromRequest(request); + boolean isValid = jwtService.validateToken(token); + + SessionValidResult result = new SessionValidResult(); + result.setValid(isValid); + + return R.ok(result); + } + + /** + * 刷新令牌 + */ + @PostMapping("/refresh-token") + public R refreshToken(@RequestBody RefreshTokenRequest request) { + try { + String refreshToken = request.getRefreshToken(); + if (!jwtService.validateRefreshToken(refreshToken)) { + return R.fail("刷新令牌无效或已过期"); + } + + Long userId = jwtService.getUserIdFromToken(refreshToken); + String newAccessToken = jwtService.generateAccessToken(userId); + + RefreshTokenResult result = new RefreshTokenResult(); + result.setAccessToken(newAccessToken); + result.setExpiresIn(jwtService.getAccessTokenExpiration()); + + return R.ok(result); + } catch (Exception e) { + log.error("刷新令牌失败", e); + return R.fail("刷新令牌失败"); + } + } + + /** + * 登出 + */ + @PostMapping("/logout") + public R logout(HttpServletRequest request) { + // 此处可以将token加入黑名单 + // 在真实场景中,可以将token存入Redis黑名单,并设置过期时间 + return R.ok(); + } +} +``` + +## JWT服务实现 + +```java +@Service +@RequiredArgsConstructor +public class JwtService { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + /** + * 生成访问令牌 + */ + public String generateAccessToken(Long userId) { + return generateToken(userId, accessTokenExpiration, "access"); + } + + /** + * 生成刷新令牌 + */ + public String generateRefreshToken(Long userId) { + return generateToken(userId, refreshTokenExpiration, "refresh"); + } + + /** + * 生成令牌 + */ + private String generateToken(Long userId, long expiration, String type) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration * 1000); + + return Jwts.builder() + .setSubject(userId.toString()) + .setIssuedAt(now) + .setExpiration(expiryDate) + .claim("type", type) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 从请求中获取令牌 + */ + public String getTokenFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + /** + * 验证令牌 + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 验证刷新令牌 + */ + public boolean validateRefreshToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + return "refresh".equals(claims.get("type")); + } catch (Exception e) { + return false; + } + } + + /** + * 从令牌中获取用户ID + */ + public Long getUserIdFromToken(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + return Long.parseLong(claims.getSubject()); + } + + /** + * 获取签名密钥 + */ + private Key getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 获取访问令牌过期时间(秒) + */ + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } +} +``` + +## 用户服务实现 + +```java +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final WxUserMapper wxUserMapper; + + /** + * 获取或创建微信用户 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public User getOrCreateWxUser(String openid, String sessionKey) { + // 查询用户 + WxUser wxUser = wxUserMapper.selectByOpenId(openid); + + // 如果用户不存在,创建新用户 + if (wxUser == null) { + wxUser = new WxUser(); + wxUser.setOpenId(openid); + wxUser.setSessionKey(sessionKey); + wxUser.setIsNewUser(true); + wxUser.setLastLoginTime(new Date()); + wxUserMapper.insert(wxUser); + } else { + // 更新会话密钥和登录时间 + wxUser.setSessionKey(sessionKey); + wxUser.setLastLoginTime(new Date()); + wxUserMapper.updateById(wxUser); + } + + // 将 WxUser 转换为 User + return convertToUser(wxUser); + } + + /** + * 获取或创建带手机号的微信用户 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public User getOrCreateWxUserWithPhone(String openid, String sessionKey, String phone) { + // 先检查是否有该手机号的用户 + WxUser wxUserByPhone = wxUserMapper.selectByPhone(phone); + + // 如果存在该手机号用户但openid不同,可能是用户换了微信号,更新openid + if (wxUserByPhone != null && !openid.equals(wxUserByPhone.getOpenId())) { + wxUserByPhone.setOpenId(openid); + wxUserByPhone.setSessionKey(sessionKey); + wxUserByPhone.setLastLoginTime(new Date()); + wxUserByPhone.setIsNewUser(false); + wxUserMapper.updateById(wxUserByPhone); + return convertToUser(wxUserByPhone); + } + + // 查询用户 + WxUser wxUser = wxUserMapper.selectByOpenId(openid); + + // 如果用户不存在,创建新用户 + if (wxUser == null) { + wxUser = new WxUser(); + wxUser.setOpenId(openid); + wxUser.setSessionKey(sessionKey); + wxUser.setPhone(phone); + wxUser.setIsNewUser(true); + wxUser.setLastLoginTime(new Date()); + wxUserMapper.insert(wxUser); + } else { + // 更新会话密钥、手机号和登录时间 + wxUser.setSessionKey(sessionKey); + wxUser.setPhone(phone); + wxUser.setLastLoginTime(new Date()); + wxUserMapper.updateById(wxUser); + } + + // 将 WxUser 转换为 User + return convertToUser(wxUser); + } + + /** + * 判断用户信息是否完整 + */ + @Override + public boolean isUserProfileComplete(User user) { + if (user == null) { + return false; + } + + return StringUtils.hasText(user.getNickname()) + && StringUtils.hasText(user.getAvatar()) + && StringUtils.hasText(user.getPhone()); + } + + /** + * 将 WxUser 转换为 User + */ + private User convertToUser(WxUser wxUser) { + if (wxUser == null) { + return null; + } + + User user = new User(); + user.setId(wxUser.getId()); + user.setOpenId(wxUser.getOpenId()); + user.setUnionId(wxUser.getUnionId()); + user.setNickname(wxUser.getNickname()); + user.setAvatar(wxUser.getAvatarUrl()); + user.setPhone(wxUser.getPhone()); + user.setGender(wxUser.getGender()); + user.setIsNewUser(wxUser.getIsNewUser()); + + return user; + } +} +``` + +## 网关过滤器实现 (可选) + +如果使用了Spring Cloud Gateway,可以添加JWT验证过滤器: + +```java +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter implements GlobalFilter { + + private final JwtService jwtService; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + + // 白名单路径,不需要token验证 + if (isWhiteListPath(request.getPath().toString())) { + return chain.filter(exchange); + } + + // 获取token + String token = getTokenFromRequest(request); + if (token == null) { + return onError(exchange, "未授权", HttpStatus.UNAUTHORIZED); + } + + // 验证token + if (!jwtService.validateToken(token)) { + return onError(exchange, "token无效或已过期", HttpStatus.UNAUTHORIZED); + } + + // 获取用户ID并设置到请求头 + Long userId = jwtService.getUserIdFromToken(token); + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-ID", userId.toString()) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + private boolean isWhiteListPath(String path) { + List whiteList = Arrays.asList( + "/api/v1/auth/login", + "/api/v1/auth/wechat/login", + "/api/v1/auth/wechat/mini-login", + "/api/v1/auth/wechat/phone-login", + "/api/v1/auth/refresh-token" + ); + + return whiteList.stream().anyMatch(path::startsWith); + } + + private String getTokenFromRequest(ServerHttpRequest request) { + List authHeaders = request.getHeaders().get("Authorization"); + if (authHeaders != null && !authHeaders.isEmpty()) { + String auth = authHeaders.get(0); + if (auth.startsWith("Bearer ")) { + return auth.substring(7); + } + } + return null; + } + + private Mono onError(ServerWebExchange exchange, String message, HttpStatus status) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(status); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + Map result = new HashMap<>(); + result.put("code", status.value()); + result.put("message", message); + + byte[] bytes = new ObjectMapper().writeValueAsBytes(result); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } +} +``` + +## 安全建议 + +1. 生产环境中使用HTTPS保护API通信 +2. 敏感信息(如SessionKey)不要存储在前端 +3. 为JWT密钥使用足够强度的随机字符串 +4. 实现令牌黑名单机制处理注销和令牌泄露情况 +5. 考虑实现令牌自动续期机制 +6. 定期清理过期的会话记录 +7. 记录关键操作的审计日志 + +## 测试 + +使用Postman或其他API测试工具测试以下接口: + +1. `/api/v1/auth/wechat/login` - 微信简单登录 +2. `/api/v1/auth/wechat/phone-login` - 微信手机号登录 +3. `/api/v1/auth/check-session` - 检查会话有效性 +4. `/api/v1/auth/refresh-token` - 刷新令牌 +5. `/api/v1/auth/logout` - 登出 diff --git a/src/api/auth.ts b/src/api/auth.ts index 13adfc6..d9e2866 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,126 +1,115 @@ import request from "@/utils/request"; +const AUTH_BASE_URL = "/api/v1/auth"; + +export interface LoginData { + username: string; + password: string; +} + +export interface WxLoginData { + code: string; + encryptedData?: string; + iv?: string; + phoneCode?: string; +} + +export interface LoginResult { + accessToken: string; + refreshToken?: string; + tokenType: string; + expiresIn: number; + isNewUser?: boolean; + isProfileComplete?: boolean; +} + const AuthAPI = { /** - * 登录接口 - * - * @param username 用户名 - * @param password 密码 - * @returns 返回 token + * 账号密码登录 + * @param data 登录表单数据 + * @returns 登录结果 */ - login(data: LoginFormData): Promise { + login(data: LoginData): Promise { return request({ - url: "/api/v1/auth/login", + url: `${AUTH_BASE_URL}/login`, method: "POST", - data: data, - header: { - "Content-Type": "application/x-www-form-urlencoded", - }, + data, }); }, /** - * 微信登录接口 - * - * @param code 微信登录code - * @returns 返回 token + * 微信登录 (基础版) + * @param code 微信登录凭证 + * @returns 登录结果 */ wechatLogin(code: string): Promise { return request({ - url: "/api/v1/auth/wechat-login", + url: `${AUTH_BASE_URL}/wechat/login`, method: "POST", data: { code }, - header: { - "Content-Type": "application/x-www-form-urlencoded", - }, }); }, /** - * 微信小程序登录接口(增强版) - * - * @param data 微信登录数据 - * @returns 返回 token 和用户信息 + * 微信小程序增强登录 (获取手机号) + * @param data 包含code, encryptedData, iv等的登录数据 + * @returns 登录结果 */ - wechatMiniLogin(data: WechatMiniLoginData): Promise { - return request({ - url: "/api/v1/auth/wechat-mini-login", + wechatMiniLogin(data: WxLoginData): Promise { + return request({ + url: `${AUTH_BASE_URL}/wechat/mini-login`, method: "POST", - data: data, - header: { - "Content-Type": "application/json", - }, + data, }); }, /** - * 登出接口 + * 微信一键登录 (通过手机号) + * @param data 包含code和phoneCode的登录数据 + * @returns 登录结果 */ - logout(): Promise { - return request({ - url: "/api/v1/auth/logout", - method: "DELETE", + wechatPhoneLogin(data: WxLoginData): Promise { + return request({ + url: `${AUTH_BASE_URL}/wechat/phone-login`, + method: "POST", + data, + }); + }, + + /** + * 检查会话有效性 + * @returns 会话是否有效 + */ + checkSession(): Promise<{ valid: boolean }> { + return request<{ valid: boolean }>({ + url: `${AUTH_BASE_URL}/check-session`, + method: "GET", + }); + }, + + /** + * 登出 + * @returns 登出结果 + */ + logout(): Promise { + return request({ + url: `${AUTH_BASE_URL}/logout`, + method: "POST", + }); + }, + + /** + * 刷新令牌 + * @param refreshToken 刷新令牌 + * @returns 新的访问令牌 + */ + refreshToken(refreshToken: string): Promise<{ accessToken: string; expiresIn: number }> { + return request<{ accessToken: string; expiresIn: number }>({ + url: `${AUTH_BASE_URL}/refresh-token`, + method: "POST", + data: { refreshToken }, }); }, }; export default AuthAPI; - -/** 登录响应 */ -export interface LoginResult { - /** 访问token */ - accessToken: string; - /** token 类型 */ - tokenType?: string; -} - -export interface LoginFormData { - username: string; - password: string; -} - -/** 微信小程序登录数据 */ -export interface WechatMiniLoginData { - /** 微信登录code */ - code: string; - /** 用户信息(可选) */ - userInfo?: { - /** 昵称 */ - nickName?: string; - /** 头像URL */ - avatarUrl?: string; - /** 性别 */ - gender?: number; - /** 国家 */ - country?: string; - /** 省份 */ - province?: string; - /** 城市 */ - city?: string; - }; - /** 手机号授权数据(可选) */ - phoneData?: { - /** 手机号授权code */ - code: string; - /** 加密数据 */ - encryptedData?: string; - /** 初始向量 */ - iv?: string; - }; -} - -/** 微信登录结果 */ -export interface WechatLoginResult extends LoginResult { - /** 是否为新用户 */ - isNewUser?: boolean; - /** 用户信息是否完整 */ - isProfileComplete?: boolean; - /** 用户基本信息 */ - userInfo?: { - userId?: number; - username?: string; - nickname?: string; - avatar?: string; - mobile?: string; - }; -} diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue index 6143f89..01c8b1c 100644 --- a/src/pages/login/index.vue +++ b/src/pages/login/index.vue @@ -3,46 +3,48 @@ - - - 有来开源 - 专注于构建高效开发的应用解决方案 + + 您好,欢迎回来 + 登录您的账号,开始愉快的旅程 -