--- title: "08-用户登录与登出业务深度解析" created: 2025-12-12 aliases: - 用户登录与登出业务深度解析 tags: - 项目 --- # 用户登录与登出业务深度解析 ## 视频 ![[image-87e2ff39.png]] ## **引子:登录与注册的本质区别** 在分析登录逻辑之前,我们需要先理解一个关键问题:**登录和注册面临的挑战是完全不同的**。 ![[登录与注册的本质区别-cf254933.jpg]] ## **Step 1:业务理解** ### **登录业务的核心诉求** ![[登录业务理解-8f1177dd.jpg]] ### **分库分表带来的挑战** 这里有一个很重要的技术背景需要理解: ![[登录业务中分库分表的分片键选择问题-150279c6.jpg]] ## **Step 2:源码解析** ### **整体架构一览** ![[登录系统整体架构-2964f2f2.jpg]] ### **相关源码** 1. 网关核心执行逻辑 (Gateway Core) 这个方法通常在网关的全局过滤器(GlobalFilter)中调用。 ```java /** * 网关核心执行逻辑 * 作用:拦截请求,进行签名验证、解密、Token解析,并将解析出的 userId 透传给下游。 */ private Map doExecute(String originalBody, ServerWebExchange exchange){ log.info("current thread verify: {}", Thread.currentThread().getName()); ServerHttpRequest request = exchange.getRequest(); // 1. 准备请求体数据 String requestBody = originalBody; Map bodyContent = new HashMap<>(32); // 将 JSON 字符串转为 Map,方便后续提取 code、body 等字段 if (StringUtil.isNotEmpty(originalBody)) { bodyContent = JSON.parseObject(originalBody, Map.class); } String code = null; // 渠道编码 (如: IOS, ANDROID, H5) String token; // 用户 Token String userId = null; // 解析出的用户 ID String url = request.getPath().value(); // 检查是否开启“免签模式” (通常用于内部调试或特定后门,生产环境通常关闭) String noVerify = request.getHeaders().getFirst(NO_VERIFY); boolean allowNormalAccess = gatewayProperty.isAllowNormalAccess(); // 安全拦截:如果配置不允许普通访问,且请求试图绕过验证,则直接抛异常 if ((!allowNormalAccess) && (VERIFY_VALUE.equals(noVerify))) { throw new DaMaiFrameException(BaseCode.ONLY_SIGNATURE_ACCESS_IS_ALLOWED); } // 2. 核心校验流程 // checkParameter: 基础参数非空检查 // skipCheckParameter: 判断当前 URL 是否在“白名单”中(如支付宝回调接口不需要验签) if (checkParameter(originalBody, noVerify) && !skipCheckParameter(url)) { String encrypt = request.getHeaders().getFirst(ENCRYPT); // 从请求体获取渠道 Code (Client ID) code = bodyContent.get(CODE); // 从 Header 获取 Token token = request.getHeaders().getFirst(TOKEN); // 【关键】根据 Code 获取该渠道的密钥配置 (公钥、私钥、Token密钥) GetChannelDataVo channelDataVo = channelDataService.getChannelDataByCode(code); // 3. 业务数据解密 (如果启用了 V2 加密) // 这里的 BUSINESS_BODY 是真正传给业务服务的参数,网关层负责解密 if (StringUtil.isNotEmpty(encrypt) && V2.equals(encrypt)) { // 使用渠道的 SecretKey 进行 AES/RSA 解密 String decrypt = RsaTool.decrypt(bodyContent.get(BUSINESS_BODY), channelDataVo.getDataSecretKey()); // 将解密后的明文放回 Map,准备传给下游 bodyContent.put(BUSINESS_BODY, decrypt); } // 4. RSA 签名验证 (防篡改) // 使用渠道的公钥 (SignPublicKey) 验证请求参数签名 boolean checkFlag = RsaSignTool.verifyRsaSign256(bodyContent, channelDataVo.getSignPublicKey()); if (!checkFlag) { throw new DaMaiFrameException(BaseCode.RSA_SIGN_ERROR); } // 5. Token 校验策略 // 判断当前 URL 是否需要强制登录 (Protected Resources) // 注意:skipCheckToken 返回 false 代表“不能跳过” -> “必须检查” boolean skipCheckTokenResult = skipCheckToken(url); // 如果是受保护接口,但没有 Token -> 抛异常 if (!skipCheckTokenResult && StringUtil.isEmpty(token)) { ArgumentError argumentError = new ArgumentError(); argumentError.setArgumentName(token); argumentError.setMessage("token参数为空"); List argumentErrorList = new ArrayList<>(); argumentErrorList.add(argumentError); throw new ArgumentException(BaseCode.ARGUMENT_EMPTY.getCode(), argumentErrorList); } // 如果是受保护接口 -> 强制解析 Token 获取 UserId if (!skipCheckTokenResult) { // 调用 TokenService 进行 JWT 解析 + Redis 校验 UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret()); userId = userVo.getId(); } // 6. 柔性 Token 解析 (Soft Check) // 场景:节目详情页 (Program Detail)。 // 这是一个公开接口(skipCheckTokenResult=true),但如果用户带了 Token, // 我们希望解析出 userId,以便查询“用户是否收藏了该节目”等个性化信息。 if (StringUtil.isEmpty(userId) && checkNeedUserId(url) && StringUtil.isNotEmpty(token)) { // 这里通常建议加 try-catch,因为非强制接口 Token 失效不应阻断请求,降级为游客即可 UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret()); userId = userVo.getId(); } // 更新 requestBody 为真正的业务参数 (可能经过了解密) requestBody = bodyContent.get(BUSINESS_BODY); } // 7. API 限流 (Rate Limiting) // 防止接口被刷 apiRestrictService.apiRestrict(userId, url, request); // 8. 构造结果 Map // 这个 Map 会被后续的 Filter 使用,通常会将 userId 放入 Request Header 传给下游微服务 Map map = new HashMap<>(4); map.put(REQUEST_BODY, requestBody); if (StringUtil.isNotEmpty(code)) { map.put(CODE, code); } if (StringUtil.isNotEmpty(userId)) { map.put(USER_ID, userId); } return map; } /** * 判断是否跳过 Token 检查 * 逻辑:如果 URL 在配置的 checkTokenPaths (受保护路径) 中,则返回 false (不跳过 -> 检查)。 * 否则返回 true (跳过 -> 游客可访问)。 */ public boolean skipCheckToken(String url){ for (String checkTokenPath : gatewayProperty.getCheckTokenPaths()) { PathMatcher matcher = new AntPathMatcher(); // 如果当前 URL 匹配到了配置中的受保护路径 if (matcher.match(checkTokenPath, url)) { return false; // 匹配到了,说明是受保护资源,不能跳过检查 } } return true; // 没匹配到,说明是公开资源,可以跳过检查 } ``` 2. Token 解析业务 (Token Service) `TokenService` 实现了**双重校验机制**:不仅校验 JWT 签名,还校验 Redis 会话状态。 ```java @Component public class TokenService { @Autowired private RedisCache redisCache; // 纯 JWT 解析:从 Token 字符串中提取 userId public String parseToken(String token, String tokenSecret){ String userStr = TokenUtil.parseToken(token, tokenSecret); if (StringUtil.isNotEmpty(userStr)) { return JSONObject.parseObject(userStr).getString("userId"); } return null; } /** * 获取用户信息 (强校验) * 1. 解析 JWT 获取 userId。 * 2. 查询 Redis 确认会话有效。 */ public UserVo getUser(String token, String code, String tokenSecret){ UserVo userVo = null; // 1. 第一步:解析 Token String userId = parseToken(token, tokenSecret); // 2. 第二步:Redis 二次校验 (Server-side Session Check) // 为什么有了 JWT 还要查 Redis? // 因为 JWT 一旦签发无法撤销。如果用户登出、改密码或被封禁,必须通过 Redis 删除 Key 来强制 Token 失效。 // Key 结构:USER_LOGIN:CHANNEL_CODE:USER_ID if (StringUtil.isNotEmpty(userId)) { userVo = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId), UserVo.class); } // 3. 如果 Redis 中没有用户信息,抛出“用户未登录”异常 return Optional.ofNullable(userVo).orElseThrow(() -> new DaMaiFrameException(BaseCode.LOGIN_USER_NOT_EXIST)); } } ``` 3. 配置类 (Gateway Property) 定义了哪些接口需要保护,哪些接口可以公开访问。 ```java @Data @Component public class GatewayProperty { // 需要 API 限流的路径 @Value("${api.limit.paths:#{null}}") private String[] apiRestrictPaths; /** * 【核心配置】受保护路径列表 (Protected Paths) * 这里列出的所有接口,都必须携带有效的 Token 才能访问。 * 例如:下单、添加购票人、修改用户信息等。 */ @Value("${skip.check.token.paths:/**/program/order/create/v1, ..., /**/ticket/user/add, ...}") private String[] checkTokenPaths; @Value("${skip.check.parmeter.paths:/**/alipay/notify}") private String[] checkSkipParmeterPaths; @Value("${allow.normal.access:true}") private boolean allowNormalAccess; // 需要尝试提取 userId 的路径(公开但可个性化),如节目详情页 @Value("${userId.paths:/**/program/detail,/**/program/detail/v1,/**/program/detail/v2}") private String[] userIdPaths; } ``` 4. 控制层 (Controller) 这部分比较简单,主要是接收 HTTP 请求并转发给 Service 层。 ```java // Controller 层注解,标识这是一个 RESTful 接口控制器 @RestController @RequestMapping("/user/login") // 假设的路径,根据上下文推断 public class UserLoginController { @Autowired private UserService userService; // Swagger/OpenAPI 注解,用于生成接口文档 @Operation(summary = "登录") // 接收 POST 请求 @PostMapping(value = "/login") // @Valid 开启参数校验,@RequestBody 接收 JSON 请求体 public ApiResponse login(@Valid @RequestBody UserLoginDto userLoginDto) { // 调用 Service 层的 login 方法,并将结果封装为统一响应对象返回 return ApiResponse.ok(userService.login(userLoginDto)); } } ``` 5.业务层 (Service) - 核心逻辑 ```java /** * 登录业务逻辑核心方法 * @param userLoginDto 登录入参(包含手机号/邮箱、密码、渠道Code等) * @return 用户信息(包含Token和UserId) * */ public UserLoginVo login(UserLoginDto userLoginDto) { UserLoginVo userLoginVo = new UserLoginVo(); // 获取渠道Code (用于区分不同端,如App、小程序,可能对应不同的Token密钥) String code = userLoginDto.getCode(); String mobile = userLoginDto.getMobile(); String email = userLoginDto.getEmail(); String password = userLoginDto.getPassword(); // 1. 基础校验:手机号和邮箱不能同时为空,至少需要一种登录方式 if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) { throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST); } Long userId; // 2. 分支逻辑:判断是手机号登录还是邮箱登录 if (StringUtil.isNotEmpty(mobile)) { // ============ 手机号登录流程 ============ // A. 防暴力破解检查:从 Redis 获取该手机号的登录失败次数 String errorCountStr = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR, mobile), String.class); // 如果错误次数超过阈值(ERROR_COUNT_THRESHOLD),直接抛出异常,阻断登录 if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) { throw new DaMaiFrameException(BaseCode.MOBILE_ERROR_COUNT_TOO_MANY); } // B. 查映射表:UserMobile // 解释:在大用户量场景下,User表通常按 userId 分库分表。 // 为了支持手机号登录,需要一张 UserMobile 表作为索引,将 mobile 映射到 userId。 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class) .eq(UserMobile::getMobile, mobile); UserMobile userMobile = userMobileMapper.selectOne(queryWrapper); // C. 账号不存在处理 if (Objects.isNull(userMobile)) { // 记录错误次数到 Redis,防刷 redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1); // 设置由错误计数的过期时间(例如1分钟内限制登录) redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1,TimeUnit.MINUTES); throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY); } // 获取真正的 userId userId = userMobile.getUserId(); } else { // ============ 邮箱登录流程 (逻辑同手机号) ============ // A. 防暴力破解检查 String errorCountStr = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR, email), String.class); if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) { throw new DaMaiFrameException(BaseCode.EMAIL_ERROR_COUNT_TOO_MANY); } // B. 查映射表:UserEmail LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserEmail.class) .eq(UserEmail::getEmail, email); UserEmail userEmail = userEmailMapper.selectOne(queryWrapper); // C. 账号不存在处理 if (Objects.isNull(userEmail)) { redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1); redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1,TimeUnit.MINUTES); throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST); } userId = userEmail.getUserId(); } // 3. 验证密码 // 使用获取到的 userId 和前端传来的 password 查询主表 User LambdaQueryWrapper queryUserWrapper = Wrappers.lambdaQuery(User.class) .eq(User::getId, userId) .eq(User::getPassword, password); // 注意:实际生产中密码应加密比对 User user = userMapper.selectOne(queryUserWrapper); // 密码错误 if (Objects.isNull(user)) { // 这里其实也可以增加密码错误的计数逻辑,代码中省略了 throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR); } // 4. 保存登录态到服务端 (Redis) // 这是一个关键点:虽然使用了JWT,但这里依然把用户信息存入了Redis。 // 目的:为了实现 Token 的可撤销(退出登录、强制下线)以及存储一些不方便放在 Token 里的敏感信息。 redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()), user, tokenExpireTime, TimeUnit.MINUTES); // 5. 生成 JWT Token 并返回 userLoginVo.setUserId(userId); // getChannelDataByCode(code).getTokenSecret():根据不同的渠道获取对应的加密密钥,增加安全性 userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret())); return userLoginVo; } // 辅助方法:构建 Token public String createToken(Long userId, String tokenSecret){ Map map = new HashMap<>(4); map.put("userId", userId); // 使用 uidGenerator 生成唯一 jti,将 userId 放入 payload,设置过期时间 return TokenUtil.createToken(String.valueOf(uidGenerator.getUid()), JSON.toJSONString(map), tokenExpireTime * 60 * 1000, tokenSecret); } ``` 6. 工具类 (TokenUtil) 基于 JJWT 库封装的 JWT 生成和解析工具。 ```java @Slf4j public class TokenUtil { // 签名算法:HMAC using SHA-256 private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; /** * 创建 JWT Token * @param id Token 的唯一标识 (jti) * @param info Token 的主体内容 (Subject),通常存放用户基础信息的 JSON 串 * @param ttlMillis 过期时间 (毫秒) * @param tokenSecret 签名密钥 * @return 加密后的 Token 字符串 */ public static String createToken(String id, String info, long ttlMillis, String tokenSecret) { long nowMillis = System.currentTimeMillis(); JwtBuilder builder = Jwts.builder() .setId(id) // 设置 jti (JWT ID) .setIssuedAt(new Date(nowMillis)) // 设置签发时间 (iat) .setSubject(info) // 设置主体数据 (sub),这里存的是 userId 的 Map JSON .signWith(SIGNATURE_ALGORITHM, tokenSecret); // 设置签名算法和密钥 if (ttlMillis >= 0) { // 设置过期时间 (exp) builder.setExpiration(new Date(nowMillis + ttlMillis)); } // 压缩并生成最终字符串 return builder.compact(); } /** * 解析 JWT Token * @param token 前端传来的 Token 字符串 * @param tokenSecret 解析需要的密钥 * @return Token 中的 Subject 内容 (即用户信息的 JSON) */ public static String parseToken(String token, String tokenSecret) { try { return Jwts.parser() .setSigningKey(tokenSecret) // 设置解析密钥 .parseClaimsJws(token) // 解析 Token .getBody() .getSubject(); // 获取 Subject 部分 } catch (ExpiredJwtException jwtException) { // 捕获 Token 过期异常,打印日志并抛出业务异常 log.error("parseToken error", jwtException); throw new DaMaiFrameException(BaseCode.TOKEN_EXPIRE); } } // Main 方法用于本地测试生成和解析 public static void main(String[] args) { String tokenSecret = "CSYZWECHAT"; // 测试密钥 JSONObject jsonObject = new JSONObject(); jsonObject.put("001key", "001value"); // 生成 Token String token1 = TokenUtil.createToken("1", jsonObject.toJSONString(), 10000, tokenSecret); System.out.println("token:" + token1); // 解析 Token String subject = TokenUtil.parseToken(token1, tokenSecret); System.out.println("解析token后的值:" + subject); } } ``` ### **设计亮点深度解读** #### **亮点一:分库分表下的索引表设计** 这是整个登录系统最精妙的设计之一。理解为什么需要这样设计: ![[分库分表的查询难题与解决方案-14c621b3.jpg]] 代码体现: ```java // 手机号登录:先查索引表 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class) .eq(UserMobile::getMobile, mobile); UserMobile userMobile = userMobileMapper.selectOne(queryWrapper); // 获取真正的 userId userId = userMobile.getUserId(); // 再用 userId 查主表(命中分片键) LambdaQueryWrapper queryUserWrapper = Wrappers.lambdaQuery(User.class) .eq(User::getId, userId) .eq(User::getPassword, password); User user = userMapper.selectOne(queryUserWrapper); ``` #### **亮点二:JWT + Redis 双重会话管理** 这个设计解决了纯 JWT 和纯 Session 各自的痛点: ![[JWT + Redis 双重会话管理-b0d74a59.jpg]] 代码体现: ```java // 登录时:同时生成 JWT 和存储 Redis // 1. 存入 Redis redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()), user, tokenExpireTime, TimeUnit.MINUTES); // 2. 生成 JWT userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret())); // 网关验证时:两者都要检查 public UserVo getUser(String token, String code, String tokenSecret) { // 1. 解析 JWT 获取 userId String userId = parseToken(token, tokenSecret); // 2. 检查 Redis 是否存在登录态 if (StringUtil.isNotEmpty(userId)) { userVo = redisCache.get(RedisKeyBuild.createRedisKey( RedisKeyManage.USER_LOGIN, code, userId), UserVo.class); } // 3. Redis 不存在则拒绝 return Optional.ofNullable(userVo) .orElseThrow(() -> new DaMaiFrameException(BaseCode.LOGIN_USER_NOT_EXIST)); } ``` [[05-JWT + Redis 双重会话管理 学习笔记|JWT + Redis 双重会话管理 学习笔记]] #### **亮点三:多端隔离与动态密钥** ![[多端隔离设计方案-5c81ae68.jpg]] **代码体现:** ```java // 根据渠道 code 获取对应的密钥 private GetChannelDataVo getChannelDataByCode(String code) { // 先从 Redis 查 GetChannelDataVo channelDataVo = getChannelDataByRedis(code); if (Objects.isNull(channelDataVo)) { // 调用 base-data 服务查询 channelDataVo = getChannelDataByClient(code); // 放到 Redis 中缓存 setChannelDataRedis(code, channelDataVo); } return channelDataVo; } // 使用渠道密钥生成 Token userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret())); ``` #### **亮点四:网关层统一验证** ![[网关层统一验证架构-396c1696.jpg]] 代码体现: ```java // 网关验证核心逻辑 private Map doExecute(String originalBody, ServerWebExchange exchange) { // ... // 判断是否需要验证 Token boolean skipCheckTokenResult = skipCheckToken(url); if (!skipCheckTokenResult && StringUtil.isEmpty(token)) { // 需要 Token 但没传,报错 throw new ArgumentException(BaseCode.ARGUMENT_EMPTY.getCode(), argumentErrorList); } if (!skipCheckTokenResult) { // 验证 Token 并获取用户信息 UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret()); userId = userVo.getId(); } // 将 userId 传递给下游 map.put(USER_ID, userId); return map; } // 路径匹配逻辑 public boolean skipCheckToken(String url) { for (String skipCheckTokenPath : gatewayProperty.getCheckTokenPaths()) { PathMatcher matcher = new AntPathMatcher(); if (matcher.match(skipCheckTokenPath, url)) { return false; // 匹配到需要验证的路径 } } return true; // 不在列表中,跳过验证 } ``` #### **亮点五:防暴力破解机制** ![[防暴力破解机制-872b1a59.jpg]] ### **完整登录流程梳理** ![[完整登录流程梳理-6ba76302.jpg]] ### **后续请求的验证流程** ![[后续请求的验证流程-9a683185.jpg]] ### **可取之处总结** | **设计点** | **解决的问题** | **实现方式** | | --- | --- | --- | | **索引表设计** | 分库分表下的非分片键查询 | UserMobile/UserEmail 表映射到 userId | | **JWT + Redis** | JWT 无法主动失效的问题 | Redis 存储登录态,双重验证 | | **多端隔离** | 不同端的安全策略独立 | 渠道 code 区分,独立密钥 | | **网关统一验证** | 验证逻辑集中,下游信任 | 网关层解析 Token,传递 userId | | **防暴力破解** | 账号撞库、密码爆破 | Redis 错误计数 + 锁定机制 | | **路径白名单** | 灵活控制哪些接口需要登录 | 配置化的路径匹配 | ### **待优化点** **1.密码错误不计数** - **现状:只有"账号不存在"才增加错误计数** - **风险:密码暴力破解无限制** - **建议:密码错误也要计数** **2.登录成功不清零** - **现状:登录成功后错误计数仍残留影响:用户可能被误伤** - **建议:登录成功后清零错误计数** **3.登录态无续期** - **现状:Redis 过期时间固定,不会续期** - **影响:活跃用户也会被迫重新登录** - **建议:活跃时刷新过期时间** ![[当前登录逻辑的问题-138b8330.jpg]] > 💡 总结:这套登录系统的设计是比较成熟的,尤其是索引表解决分库分表查询问题、JWT+Redis 双重会话管理、网关层统一验证这几个设计亮点值得学习。不足之处主要在于防暴力破解机制不够完善(密码错误不计数),以及缺少登录态续期机制。整体来说,架构设计是合理的,细节可以继续优化。 ## **Step 3:方案评估与改进** ### **改进方案设计** ![[登录防护改进方案-27f8042d.jpg]] ## **Step 4:改进后的代码实现** ### **改进后的登录逻辑** ```java /** * 用户登录(改进版) * 包含:参数校验、防暴力破解、分表查询、密码验证、登录态管理 */ public UserLoginVo login(UserLoginDto userLoginDto) { UserLoginVo userLoginVo = new UserLoginVo(); String code = userLoginDto.getCode(); // 渠道编码 (App, H5等) String mobile = userLoginDto.getMobile(); String email = userLoginDto.getEmail(); String password = userLoginDto.getPassword(); // ========== 1. 基础参数校验 ========== if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) { throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST); } // 确定本次登录使用的标识(Key)和对应的 Redis 错误计数类型 // 策略模式思想:统一处理手机号和邮箱的差异 String loginKey = StringUtil.isNotEmpty(mobile) ? mobile : email; RedisKeyManage errorKeyType = StringUtil.isNotEmpty(mobile) ? RedisKeyManage.LOGIN_USER_MOBILE_ERROR : RedisKeyManage.LOGIN_USER_EMAIL_ERROR; // 构造 Redis Key,例如: "login:error:mobile:13800138000" String errorCountKey = RedisKeyBuild.createRedisKey(errorKeyType, loginKey); // ========== 2. 防暴力破解检查 (Pre-check) ========== // 在查库之前先查 Redis,如果已经被锁,直接抛异常,保护数据库 String errorCountStr = redisCache.get(errorCountKey, String.class); int currentErrorCount = StringUtil.isNotEmpty(errorCountStr) ? Integer.parseInt(errorCountStr) : 0; // 阈值检查:例如允许错误 5 次 if (currentErrorCount >= ERROR_COUNT_THRESHOLD) { // 抛出 "错误次数过多,请稍后再试" throw new DaMaiFrameException(BaseCode.LOGIN_ERROR_COUNT_TOO_MANY); } // ========== 3. 查询用户 ID (分表查询) ========== Long userId; try { // 调用封装好的私有方法,根据 mobile 或 email 查 UserMobile/UserEmail 表 userId = getUserIdByLoginKey(mobile, email); } catch (DaMaiFrameException e) { // 【关键安全改进】:账号不存在时,也要增加错误计数! // 原因:防止黑客通过“账号不存在”和“密码错误”的响应时间或提示差异,来通过枚举探测哪些手机号已注册。 incrementErrorCount(errorCountKey); throw e; } // ========== 4. 验证密码 (核心认证) ========== // 使用查到的 userId 和前端传来的 password 查询主表 LambdaQueryWrapper queryUserWrapper = Wrappers.lambdaQuery(User.class) .eq(User::getId, userId) .eq(User::getPassword, password); // 生产环境建议使用 BCrypt 等加密比对,不要明文查询 User user = userMapper.selectOne(queryUserWrapper); if (Objects.isNull(user)) { // 【关键安全改进】:密码错误也要增加错误计数! // 原始代码这里漏掉了计数,会导致攻击者可以无限次尝试密码而不被锁定。 incrementErrorCount(errorCountKey); throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR); } // ========== 5. 登录成功处理 ========== // 【体验优化】:登录成功,立即清零错误计数 // 否则用户输错4次后登录成功,下次再输错1次就会被锁,体验不好。 redisCache.del(errorCountKey); // 6. 保存服务端登录态 (Session/Token State) // Key: user:login:CHANNEL_CODE:USER_ID // 作用:用于强制下线、判断多端登录等 redisCache.set( RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, user.getId()), user, tokenExpireTime, // 例如 30 分钟 TimeUnit.MINUTES ); // 7. 生成并返回 Token userLoginVo.setUserId(userId); // 使用对应渠道的密钥生成 JWT userLoginVo.setToken(createToken(user.getId(), getChannelDataByCode(code).getTokenSecret())); return userLoginVo; } ``` ### 辅助私有方法 (Private Helpers) 提取公共逻辑,让主流程代码干净清爽。 ```java /** * 辅助方法:增加错误计数并设置锁定时间 * 策略:Redis Incr 原子操作 * 过期时间:ERROR_LOCK_MINUTES (例如 10分钟),意味着限制 10 分钟内不能登录 */ private void incrementErrorCount(String errorCountKey) { // 计数 + 1 redisCache.incrBy(errorCountKey, 1); // 每次错误都重置过期时间,或者只在第一次设置(取决于业务策略) // 这里采用:每次错误都刷新锁定窗口 redisCache.expire(errorCountKey, ERROR_LOCK_MINUTES, TimeUnit.MINUTES); } /** * 辅助方法:根据登录账号获取 userId * 封装了分表查询的逻辑 */ private Long getUserIdByLoginKey(String mobile, String email) { if (StringUtil.isNotEmpty(mobile)) { // 查手机号映射表 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class) .eq(UserMobile::getMobile, mobile); UserMobile userMobile = userMobileMapper.selectOne(queryWrapper); if (Objects.isNull(userMobile)) { throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY); } return userMobile.getUserId(); } else { // 查邮箱映射表 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserEmail.class) .eq(UserEmail::getEmail, email); UserEmail userEmail = userEmailMapper.selectOne(queryWrapper); if (Objects.isNull(userEmail)) { throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST); } return userEmail.getUserId(); } } ``` ### 用户缓存续期方案 (Refresh Token Strategy) 这个方法通常不在登录接口调用,而是在**网关过滤器 (Gateway Filter)** 或 **拦截器 (Interceptor)** 中调用。 **原理**: 类似于 Session 的滑动过期机制。只要用户在操作(请求接口),就自动延长 Token/Redis 的有效期,避免用户在使用过程中突然被踢出。 ```java /** * 刷新用户登录态 * 场景:每次鉴权通过后调用 * 策略:惰性续期 (Lazy Refresh) —— 只有当剩余时间不足时才续期,减少 Redis 写操作压力 */ public void refreshLoginState(String code, Long userId) { // 构造 Redis Key String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId); // 1. 检查 Key 是否存在 if (redisCache.hasKey(loginKey)) { // 2. 获取剩余存活时间 (TTL) Long ttl = redisCache.getExpire(loginKey, TimeUnit.MINUTES); // 3. 续期判定逻辑 // 假设过期时间是 30 分钟。 // 如果 ttl < 15 分钟 (tokenExpireTime / 2),说明用户活跃,给它续满到 30 分钟。 // 如果 ttl > 15 分钟,说明刚登录不久或刚续过,这次不操作 Redis,节省性能。 if (ttl != null && ttl < tokenExpireTime / 2) { redisCache.expire(loginKey, tokenExpireTime, TimeUnit.MINUTES); } } } ``` ### 总结优化点 1. **安全性提升**: - **防爆破闭环**:账号不存在、密码错误都会触发计数,且计数器 Key 统一,攻击者无法区分是账号不对还是密码不对。 2. **性能/体验平衡**: - **滑动续期**:`refreshLoginState` 中的 `ttl < threshold` 判断非常精髓,避免了每次 HTTP 请求都去写 Redis(写操作比读操作重),只有在必要时才写。 3. **代码质量**: - **SRP (单一职责)**:主方法只关注流程控制,查库细节和计数细节下沉到私有方法。 - **可读性**:消除了原始代码中大量的 `if-else` 嵌套。 ## **Step 5:登出逻辑分析** ### **当前登出实现** ```java public Boolean logout(UserLogoutDto userLogoutDto) { String userStr = TokenUtil.parseToken(userLogoutDto.getToken(),getChannelDataByCode(userLogoutDto.getCode()) .getTokenSecret()); if (StringUtil.isEmpty(userStr)) { throw new DaMaiFrameException(BaseCode.USER_EMPTY); } String userId = JSONObject.parseObject(userStr).getString("userId"); redisCache.del(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,userLogoutDto.getCode(),userId)); return true; } ``` ### **登出逻辑分析** ![[登出逻辑分析-ad5d65bc.jpg]] ### **登出优化建议** ```java /** * 登出(改进版) */ public void logout(UserLogoutDto userLogoutDto) { String code = userLogoutDto.getCode(); Long userId = userLogoutDto.getId(); // 直接删除登录态,不需要查数据库 // 因为即使用户不存在,删除一个不存在的 key 也不会报错 String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId); redisCache.del(loginKey); // 可选:记录登出日志 log.info("用户登出: userId={}, code={}", userId, code); } /** * 踢人下线(管理功能) */ public void kickOut(Long userId, String code) { String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId); redisCache.del(loginKey); log.info("用户被踢下线: userId={}, code={}", userId, code); } /** * 全渠道登出(所有设备下线) */ public void logoutAllChannels(Long userId) { // 假设有多个渠道:PC、App、小程序等 List channels = Arrays.asList("0001", "0002", "0003"); for (String code : channels) { String loginKey = RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN, code, userId); redisCache.del(loginKey); } log.info("用户全渠道登出: userId={}", userId); } ``` ## **完整的登录登出流程图** ![[完整的登录登出流程图-7f1640c0.jpg]] ## **核心总结** ### **登录 vs 注册 的防护策略对比** | **维度** | **注册** | **登录** | | --- | --- | --- | | **面向对象** | 未知用户、新账号 | 已知用户、已注册账号 | | **核心风险** | 缓存穿透、批量注册 | 暴力破解、字典攻击 | | **防护重点** | 防"未知 key"穿透 | 防"已知 key"暴力破解 | | **技术手段** | 布隆过滤器 + 验证码 + 限流 | 错误次数限制 + 账号锁定 | | **计数维度** | 全局请求数 | 单账号错误次数 | ### **当前实现的问题与改进** | **问题** | **影响** | **改进方案** | | --- | --- | --- | | 密码错误不计数 | 暴力破解无限制 | 密码错误也要增加错误计数 | | 登录成功不清零 | 用户可能被误伤 | 登录成功清零错误计数 | | 缓存不续期 | 活跃用户需重登 | 添加缓存续期机制 | ### **JWT + Redis 的设计意图** | **纯 JWT** | **JWT + Redis** | | --- | --- | | 无状态,无法主动失效 | 服务端可控 | | 无法踢人下线 | 支持踢人下线 | | 无法单设备登录 | 支持单设备登录 | | Token 泄露风险高 | 可主动使 Token 失效 | --- > *💡 **设计思想**:登录和注册虽然都是用户入口功能,但面临的安全挑战完全不同。**注册要防"外来者"(未知的恶意请求),登录要防"内鬼"(针对已知账号的攻击)**。理解这个本质区别,才能设计出正确的防护策略。* --- **企业级项目导航**:⬅️ [[07-敏感数据保护:展示脱敏与加密存储深度解析|07-敏感数据保护:展示脱敏与加密存储深度解析]] | 08-用户登录与登出业务深度解析 | ➡️ [[09-高并发业务设计思维框架|09-高并发业务设计思维框架]]