用户登录与登出业务深度解析

视频

image-87e2ff39

引子:登录与注册的本质区别

在分析登录逻辑之前,我们需要先理解一个关键问题:登录和注册面临的挑战是完全不同的

登录与注册的本质区别-cf254933

Step 1:业务理解

登录业务的核心诉求

登录业务理解-8f1177dd

分库分表带来的挑战

这里有一个很重要的技术背景需要理解:

登录业务中分库分表的分片键选择问题-150279c6

Step 2:源码解析

整体架构一览

登录系统整体架构-2964f2f2

相关源码

  1. 网关核心执行逻辑 (Gateway Core)

这个方法通常在网关的全局过滤器(GlobalFilter)中调用。

/**
 * 网关核心执行逻辑
 * 作用:拦截请求,进行签名验证、解密、Token解析,并将解析出的 userId 透传给下游。
 */
private Map<String,String> doExecute(String originalBody, ServerWebExchange exchange){
    log.info("current thread verify: {}", Thread.currentThread().getName());
    ServerHttpRequest request = exchange.getRequest();

    // 1. 准备请求体数据
    String requestBody = originalBody;
    Map<String, String> 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<ArgumentError> 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<String,String> 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; // 没匹配到,说明是公开资源,可以跳过检查
}
  1. Token 解析业务 (Token Service)

TokenService 实现了双重校验机制:不仅校验 JWT 签名,还校验 Redis 会话状态。

@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));
    }
}
  1. 配置类 (Gateway Property)

定义了哪些接口需要保护,哪些接口可以公开访问。

@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;
}
  1. 控制层 (Controller)

这部分比较简单,主要是接收 HTTP 请求并转发给 Service 层。

// 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<UserLoginVo> login(@Valid @RequestBody UserLoginDto userLoginDto) {
        // 调用 Service 层的 login 方法,并将结果封装为统一响应对象返回
        return ApiResponse.ok(userService.login(userLoginDto));
    }
}

5.业务层 (Service) - 核心逻辑

/**
 * 登录业务逻辑核心方法
 * @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<UserMobile> 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<UserEmail> 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<User> 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<String,Object> 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);
}
  1. 工具类 (TokenUtil)

基于 JJWT 库封装的 JWT 生成和解析工具。

@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

代码体现:

// 手机号登录:先查索引表
LambdaQueryWrapper<UserMobile> queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
        .eq(UserMobile::getMobile, mobile);
UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);

// 获取真正的 userId
userId = userMobile.getUserId();

// 再用 userId 查主表(命中分片键)
LambdaQueryWrapper<User> 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

代码体现:

// 登录时:同时生成 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));
}

JWT + Redis 双重会话管理 学习笔记

亮点三:多端隔离与动态密钥

多端隔离设计方案-5c81ae68

代码体现:

// 根据渠道 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

代码体现:

// 网关验证核心逻辑
private Map<String,String> 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

完整登录流程梳理

完整登录流程梳理-6ba76302

后续请求的验证流程

后续请求的验证流程-9a683185

可取之处总结

设计点 解决的问题 实现方式
索引表设计 分库分表下的非分片键查询 UserMobile/UserEmail 表映射到 userId
JWT + Redis JWT 无法主动失效的问题 Redis 存储登录态,双重验证
多端隔离 不同端的安全策略独立 渠道 code 区分,独立密钥
网关统一验证 验证逻辑集中,下游信任 网关层解析 Token,传递 userId
防暴力破解 账号撞库、密码爆破 Redis 错误计数 + 锁定机制
路径白名单 灵活控制哪些接口需要登录 配置化的路径匹配

待优化点

1.密码错误不计数

  • 现状:只有"账号不存在"才增加错误计数
  • 风险:密码暴力破解无限制
  • 建议:密码错误也要计数

2.登录成功不清零

  • 现状:登录成功后错误计数仍残留影响:用户可能被误伤
  • 建议:登录成功后清零错误计数

3.登录态无续期

  • 现状:Redis 过期时间固定,不会续期
  • 影响:活跃用户也会被迫重新登录
  • 建议:活跃时刷新过期时间
当前登录逻辑的问题-138b8330

💡 总结:这套登录系统的设计是比较成熟的,尤其是索引表解决分库分表查询问题、JWT+Redis 双重会话管理、网关层统一验证这几个设计亮点值得学习。不足之处主要在于防暴力破解机制不够完善(密码错误不计数),以及缺少登录态续期机制。整体来说,架构设计是合理的,细节可以继续优化。

Step 3:方案评估与改进

改进方案设计

登录防护改进方案-27f8042d

Step 4:改进后的代码实现

改进后的登录逻辑

/**
 * 用户登录(改进版)
 * 包含:参数校验、防暴力破解、分表查询、密码验证、登录态管理
 */
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<User> 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)

提取公共逻辑,让主流程代码干净清爽。

/**
 * 辅助方法:增加错误计数并设置锁定时间
 * 策略: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<UserMobile> 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<UserEmail> 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 的有效期,避免用户在使用过程中突然被踢出。

/**
 * 刷新用户登录态
 * 场景:每次鉴权通过后调用
 * 策略:惰性续期 (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:登出逻辑分析

当前登出实现

   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

登出优化建议

/**
 * 登出(改进版)
 */
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<String> 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

核心总结

登录 vs 注册 的防护策略对比

维度 注册 登录
面向对象 未知用户、新账号 已知用户、已注册账号
核心风险 缓存穿透、批量注册 暴力破解、字典攻击
防护重点 防"未知 key"穿透 防"已知 key"暴力破解
技术手段 布隆过滤器 + 验证码 + 限流 错误次数限制 + 账号锁定
计数维度 全局请求数 单账号错误次数

当前实现的问题与改进

问题 影响 改进方案
密码错误不计数 暴力破解无限制 密码错误也要增加错误计数
登录成功不清零 用户可能被误伤 登录成功清零错误计数
缓存不续期 活跃用户需重登 添加缓存续期机制

JWT + Redis 的设计意图

纯 JWT JWT + Redis
无状态,无法主动失效 服务端可控
无法踢人下线 支持踢人下线
无法单设备登录 支持单设备登录
Token 泄露风险高 可主动使 Token 失效

💡 设计思想:登录和注册虽然都是用户入口功能,但面临的安全挑战完全不同。注册要防"外来者"(未知的恶意请求),登录要防"内鬼"(针对已知账号的攻击)。理解这个本质区别,才能设计出正确的防护策略。


企业级项目导航:⬅️ 07-敏感数据保护:展示脱敏与加密存储深度解析 | 08-用户登录与登出业务深度解析 | ➡️ 09-高并发业务设计思维框架