Skip to content

06 - 认证鉴权

知识图谱

HTTP Request

    │  Header: authorization: {token}

┌────────────────────────────────────────────────────┐
│ RefreshTokenInterceptor (order=0, 匹配 /**)        │
│                                                    │
│  1. 从请求头获取 token                              │
│  2. 无 token → 放行(不设 UserHolder)               │
│  3. 有 token → Redis HGETALL login:token:{token}   │
│  4. 无数据 → 放行                                   │
│  5. 有数据 → Map → UserDTO → UserHolder.saveUser() │
│  6. 刷新 token TTL (36000 min)                     │
│  7. 放行                                           │
│                                                    │
│  afterCompletion: UserHolder.removeUser()           │
└────────────────────┬───────────────────────────────┘


┌────────────────────────────────────────────────────┐
│ LoginInterceptor (order=1, 排除公开路径)             │
│                                                    │
│  排除: /shop/** /voucher/** /shop-type/**          │
│        /upload/** /blog/hot /user/code /user/login │
│                                                    │
│  1. 检查 UserHolder.getUser()                      │
│  2. null → 返回 401                                │
│  3. 非 null → 放行                                  │
└────────────────────────────────────────────────────┘

核心代码走读

双拦截器配置

文件: src/main/java/com/hmdp/config/MvcConfig.java

java
@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 第一层: Token 刷新 (所有路径)
    registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
            .addPathPatterns("/**")
            .order(0);  // 最先执行

    // 第二层: 登录校验 (排除公开路径)
    registry.addInterceptor(new LoginInterceptor())
            .excludePathPatterns(
                "/shop/**", "/voucher/**", "/shop-type/**",
                "/upload/**", "/blog/hot", "/user/code", "/user/login"
            ).order(1);  // 后执行
}

RefreshTokenInterceptor

文件: src/main/java/com/hmdp/utils/RefreshTokenInterceptor.java

java
@Override
public boolean preHandle(HttpServletRequest request, ...) {
    // 1. 获取 token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
        return true;  // 无 token → 放行, 让 LoginInterceptor 决定
    }

    // 2. 从 Redis Hash 获取用户数据
    String key = LOGIN_USER_KEY + token;  // login:token:{token}
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
    if (userMap.isEmpty()) {
        return true;  // token 过期或无效
    }

    // 3. Map → UserDTO → ThreadLocal
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    UserHolder.saveUser(userDTO);

    // 4. 刷新 token TTL
    stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // LOGIN_USER_TTL = 36000L, 单位 MINUTES → 36000 分钟 = 600 小时 = 25 天
    return true;
}

@Override
public void afterCompletion(...) {
    UserHolder.removeUser();  // 清理 ThreadLocal, 防止内存泄漏
}

登录注册流程

文件: src/main/java/com/hmdp/service/impl/UserServiceImpl.java

java
// ========== 发送验证码 ==========
public Result sendCode(String phone, HttpSession session, HttpServletRequest request) {
    // 1. 校验手机号格式
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }

    // 2. 三层限流防刷
    // 2.1 锁: 1分钟内不能重复发送
    String phoneGetCodeLock = stringRedisTemplate.opsForValue()
            .get(GET_CODE_LOCK + phone);
    if (phoneGetCodeLock != null) {
        return Result.fail("获取验证码过快,请稍后重试!");
    }

    // 2.2 手机号黑名单: 400次/24h
    String blacklistPhoneCount = stringRedisTemplate.opsForValue()
            .get(GET_CODE_BLACKLIST_PHONE + phone);
    int getCodePhoneCount = (blacklistPhoneCount != null) ?
            Integer.parseInt(blacklistPhoneCount) : 0;
    if (getCodePhoneCount >= 400) {
        return Result.fail("该手机号已被限制!");
    }

    // 2.3 IP黑名单: 300次/24h
    String blacklistIpAddrCount = stringRedisTemplate.opsForValue()
            .get(GET_CODE_BLACKLIST_IP_ADDR + clientIpAddr);
    int getCodeIpAddrCount = (blacklistIpAddrCount != null) ?
            Integer.parseInt(blacklistIpAddrCount) : 0;
    if (getCodeIpAddrCount >= 300) {
        return Result.fail("您的IP已被限制!");
    }

    // 3. 更新限流计数器
    stringRedisTemplate.opsForValue().set(
        GET_CODE_BLACKLIST_PHONE + phone,
        String.valueOf(getCodePhoneCount + 1),
        REFRESH_BLACKLIST_TTL, TimeUnit.HOURS);  // 24小时
    stringRedisTemplate.opsForValue().set(
        GET_CODE_LOCK + phone, "1",
        GET_CODE_LOCK_TTL, TimeUnit.MINUTES);    // 1分钟

    // 4. 生成验证码
    String code = "123123"; // ⚠️ 硬编码! 开发遗留

    // 5. 存入 Redis (2分钟过期)
    stringRedisTemplate.opsForValue().set(
        LOGIN_CODE_KEY + phone, code,
        LOGIN_CODE_TTL, TimeUnit.MINUTES);

    return Result.ok();
}

// ========== 登录 ==========
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
    // 2. 校验验证码 (Redis 中存的 vs 用户输入的)
    String cacheCode = stringRedisTemplate.opsForValue()
            .get(LOGIN_CODE_KEY + phone);
    if (cacheCode == null || !cacheCode.equals(code)) {
        return Result.fail("验证码错误");
    }

    // 3. 查询或创建用户
    User user = query().eq("phone", phone).one();
    if (user == null) {
        user = createUserWithPhone(phone);
    }

    // 4. 生成 Token → 存入 Redis Hash
    String token = UUID.randomUUID().toString(true);
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, ...);

    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.HOURS);
    // ⚠️ 注意: 这里用 TimeUnit.HOURS, 而 RefreshTokenInterceptor 用 MINUTES

    return Result.ok(token);
}

Bitmap 签到

java
// 签到
public Result sign() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    String key = USER_SIGN_KEY + userId +
            now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    int dayOfMonth = now.getDayOfMonth();
    // SETBIT sign:{userId}:202403 14 1  (第15天签到)
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok();
}

// 统计连续签到天数
public Result signCount() {
    // BITFIELD sign:{userId}:202403 GET u15 0
    // 获取从第0位开始的15个位(本月前15天的签到记录)
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create()
                .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                .valueAt(0)
    );
    Long num = result.get(0);

    // 从最低位开始数连续的1
    int count = 0;
    while (true) {
        if ((num & 1) == 0) break;  // 遇到0停止
        count++;
        num >>>= 1;  // 无符号右移
    }
    return Result.ok(count);
}

面试 Q&A

Q1: 为什么用双拦截器?一个不行吗?

单拦截器的问题:如果 LoginInterceptor 排除了 /shop/**,那么用户访问商铺页面时不会执行拦截器,Token TTL 不会刷新。用户可能在浏览商铺时 Token 过期。

双拦截器设计

  • RefreshTokenInterceptor(order=0):匹配所有路径,负责 Token 刷新和 UserHolder 填充
  • LoginInterceptor(order=1):排除公开路径,只负责鉴权

这样用户访问任何页面(包括公开页面)都会刷新 Token,不会因为"只看不操作"而被登出。

追问:如果把刷新逻辑放在 LoginInterceptor 里,排除路径设为空呢?

不行。排除路径为空意味着所有请求都需要登录,未登录用户连商铺列表都看不了。

双拦截器的精妙在于:Token 刷新和权限校验解耦。第一层"尽力刷新"(有 Token 就刷新,没有也放行),第二层"严格校验"(保护路径必须登录)。

Q2: Token TTL 有个不一致的地方,你注意到了吗?

是的,这是一个隐蔽的 Bug:

位置常量单位实际时长
login() 创建 TokenLOGIN_USER_TTL = 36000TimeUnit.HOURS36000小时 ≈ 4年
RefreshTokenInterceptor 刷新LOGIN_USER_TTL = 36000TimeUnit.MINUTES36000分钟 ≈ 25天

同一个常量在两处用了不同的 TimeUnit!创建时 Token 能活 4 年,但每次刷新后 TTL 被缩短为 25 天。

更合理的做法:统一为 TimeUnit.MINUTES 配合一个合理的值(如 30 分钟或 7 天)。

Q3: 为什么用 Redis Hash 存 Token 而不是 String?

Hash 优势

  1. 部分字段读取:可以 HGET 单个字段,不需要反序列化整个对象
  2. 部分字段更新:可以 HSET 更新单个字段
  3. 内存效率:小 Hash(< 512 字段)使用 ziplist 编码,比 JSON String 更省内存

String 优势

  1. 整体读写更简单(序列化/反序列化一步到位)
  2. 不需要处理类型转换(Hash 的 value 都是 String)

项目选 Hash 是合理的——UserDTO 只有 3 个字段(id, phone, nickName),很适合 Hash。

追问:BeanUtil.beanToMap 那段有什么作用?

java
Map&lt;String, Object&gt; userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create()
                .setIgnoreNullValue(true)
                .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

关键是 setFieldValueEditor:把所有字段值转为 String。因为 StringRedisTemplate 的 Hash 只接受 &lt;String, String&gt;,UserDTO 的 id 是 Long 类型,不转会报 ClassCastException。

Q4: ThreadLocal 有什么需要注意的?

内存泄漏风险:Tomcat 使用线程池,线程被复用。如果不在请求结束时清理 ThreadLocal,前一个用户的数据可能"泄漏"给下一个请求。

项目中通过 afterCompletion 调用 UserHolder.removeUser() 来清理。但有个风险:如果 preHandle 中抛异常,afterCompletion 仍然会执行(Spring MVC 保证),所以不会泄漏。

追问:如果用 @Async 异步方法,能拿到 UserHolder 的数据吗?

不能@Async 会在新线程中执行,而 ThreadLocal 是线程隔离的。

解决方案:

  1. 在调用异步方法前取出 userId 作为参数传入
  2. 使用 InheritableThreadLocal(但线程池场景下仍有问题)
  3. 使用 TransmittableThreadLocal(阿里开源,专门解决线程池传递问题)

Q5: Bitmap 签到的空间效率怎么样?

极致高效

  • 每天 1 bit,一个月 31 bits ≈ 4 bytes
  • 一个用户一年:12 keys × 4 bytes = 48 bytes
  • 100 万用户一年:48 MB

对比数据库方案:每天一行记录,一个用户一年 365 行,100 万用户 3.65 亿行。

追问:连续签到天数怎么算的?

BITFIELD GET u{n} 0 取出本月前 n 天的签到位图(作为十进制数),然后从最低位(今天)开始逐位与1做"与运算":

假设签到位图: 1110110 (第1,2,3,5,6天签到, 第4天未签到)
反转后从低位算: 0110111

0110111 & 1 = 1 → count=1, 右移
011011  & 1 = 1 → count=2, 右移
01101   & 1 = 1 → count=3, 右移
0110    & 1 = 0 → 中断! 返回 count=3

踩坑点

踩坑点说明面试官考察意图
Token TTL 单位不一致login() 用 HOURS, interceptor 用 MINUTES细节观察力
验证码硬编码code = "123123" 开发遗留安全意识
限流计数器非原子GET + 判断 + SET 不是原子操作并发安全
ThreadLocal 清理必须在 afterCompletion 中 removeUser内存泄漏
IP 获取方式request.getRemoteAddr() 在反向代理后获取的是代理IP运维知识

加分回答

  • 提到 request.getRemoteAddr() 应改为 request.getHeader("X-Forwarded-For")(Nginx 场景)
  • 分析限流计数器的并发问题:两个请求同时 GET 到 count=399,都判断 < 400,都 SET 为 400 → 实际发了 401 次。可用 Redis INCR 原子操作替代
  • 提到可以用 Redis + Lua 实现滑动窗口限流(比固定窗口更精确)
  • 讨论 Token 方案 vs JWT:Token 需要存储但可以服务端主动失效,JWT 无状态但无法主动踢人

关联文档