采云台 · M1 员工与登录认证 · 深度分析
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M1 相关内容
依据:backend/cai-yun-tai实际源码逐行核验;mvn -o -DskipTests compile退出码 0
一句话定位:M1 的核心设计是「无状态 JWT + Redis 白名单」——用一点点有状态换取”退出即刻失效”,并且让 JWT 只携带empId、不携带任何权限信息。但有两处必须主动承认的缺口:JWT 密钥是硬编码的弱值itcast/itheima(泄露即可伪造 token),以及管理端登录把明文密码打进了日志。
零、先澄清四个事实(避免讲错)
| # | 事实 | 说明 |
|---|---|---|
| 1 | 前端确实调用了登出接口 | 《Redis 改造复核清单》第 E 条曾记录”前端未接入登出接口”——我核实代码,已经修复了:admin-ui/console.js:207 有 await api.post('/employee/logout'),staff-ui/app.js:223 有 await api.post('/user/logout'),而且都是 try/catch 包住、后端失败也不阻塞本地清理(写法正确)。不要再拿旧文档说”前端没接登出” |
| 2 | 项目没有任何”修改密码”功能 | 全项目无 editPassword / updatePassword 接口。PasswordEditDTO 存在但全项目零引用,是死代码;PasswordEditFailedException 也是死异常类。所以”改密码后旧 token 失效”这个场景在当前项目里不存在(不是没做,是这个功能本身就没有) |
| 3 | User 表已废弃,JwtClaimsConstant.USER_ID 也是死常量 | 员工端 JWT 的 claims 统一用 empId(JwtClaimsConstant.USER_ID 全项目零引用)。这是改造的痕迹:原项目用户端用 userId(微信用户表) |
| 4 | JWT 的有效期是 2 小时(7200000 ms) | application.yml 里 admin-ttl: 7200000 / user-ttl: 7200000。这是”降权/禁用需要实时查库”的直接原因——如果权限信息放在 JWT 里,2 小时的窗口太长了(见 M4 的分析) |
一、业务场景:为什么是”双端 + 三角色 + 一张表”
1.1 两条完全独立的登录链路
【管理端】POST /admin/employee/login
Body: {username, password} ← JSON
用途:采购专员 / 行政管理员登录后台
准入:checkLogin 通过 + role != EMPLOYEE
签发:adminSecretKey = "itcast",TTL 7200000 ms
请求头:token
【员工端】POST /user/user/login
Body: {username, password} ← 同样的 DTO
用途:普通员工提报申领单
准入:checkLogin 通过(任意在职员工)
签发:userSecretKey = "itheima",TTL 7200000 ms
请求头:authentication
注意路径的怪异之处:员工端登录接口是 POST /user/user/login——/user/user 这个双 user 前缀是历史遗留:原项目这里是微信登录(/user/user/login 用 code 换 openid),改造时为了前端不用改接口路径,保留了原路径(UserController 的 javadoc 明确写了”路径保持 /user/user/login 不变,前端只需换端口,接口无需调整”)。
1.2 两个独立的 JWT 密钥,为什么不是一套
| 方案 | 优点 | 缺点 |
|---|---|---|
| A. 两端独立密钥(当前) | 即使一端密钥泄露,另一端仍然安全;两端 token 无法互相冒用(管理端 token 拿到员工端验签会失败);符合”按信任域划分密钥”的原则 | 两套配置;密钥轮换要改两处 |
B. 共用一套密钥,靠 claims 里的 role/type 区分 | 配置简单 | 一个域泄露即全泄露;需要额外校验 claim 防止跨端冒用,容易漏 |
当前的方案 A 明显更稳,而且实现上很自然——JwtProperties 里就是两组字段(adminSecretKey/adminTtl/adminTokenName 和 userSecretKey/userTtl/userTokenName)。
1.3 请求头名也不同
- 管理端:
token - 员工端:
authentication
这也是隔离的一部分:即使攻击者拿到了管理端 token,把它放到 authentication 头里打员工端接口——验签密钥不同,直接失败。双密钥 + 双请求头形成两道独立隔离。
1.4 为什么”登录成功要写 Redis”
这是 M1 最核心的设计。痛点:JWT 是自包含、无状态、不可撤销的——签发之后服务端没有任何手段让它提前失效。用户点了”退出”,只是前端把 token 从 localStorage 删掉,那个 token 在服务端仍然有效,谁拿到它就能继续用,直到 2 小时后自然过期。
做法:登录成功 → 把 token 写进 Redis 白名单(TTL 与 JWT 一致);每个请求 → 拦截器多校验一次”这个 token 还在白名单里吗”;登出 → 删掉这个 key。
无状态 JWT + Redis 白名单
─────────────────────────────────────────────────────────────
验签通过 → 身份可信 验签通过 + key 存在 → 身份可信
登出后旧 token 依然有效 ❌ 登出后 key 被删 → 401 ✅
二、技术实现:五个组件
2.1 JwtUtil(无状态工具类)
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
JwtBuilder builder = Jwts.builder()
.setClaims(claims) // ① 先放自定义 claim
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // ② 再签名
.setExpiration(exp); // ③ 最后设过期
return builder.compact();
}
public static Claims parseJWT(String secretKey, String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token).getBody();
return claims;
}【三个必须能讲的细节】
① setClaims(claims) 为什么必须放在最前面?
源码里有一行注释解释得很准确(第 31 行):
如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给 builder 的 claim 赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
因为 setClaims() 的实现是整体替换 claims 对象(this.claims = claims),不是往现有 map 里 put。所以如果先 setExpiration(exp) 再 setClaims(...),exp 会被彻底丢掉 —— 生成的 JWT 就永不过期了。
这是一个非常隐蔽的坑:漏掉 exp 不会报错,token 正常签发、正常验签通过,只是永远有效。而 parseClaimsJws 对没有 exp 的 token 也是放行的。
② 同时用 SignatureAlgorithm 和 setSigningKey(byte[]) 会怎样?
JJWT 0.9.1 里 signWith(SignatureAlgorithm, byte[]) 内部会做 alg 头的推导;但 parseJWT 用的是 setSigningKey(byte[]) 这个旧 API。这个组合在 0.9.1 下能正常工作,但属于”新旧 API 混用”,而且在跨版本升级时不兼容(JJWT 0.10+ 要求用 Keys.hmacShaKeyFor() 并且 parserBuilder())。
面试可以主动提的点:
「我用的是 JJWT 0.9.1,
parseJWT用的是Jwts.parser().setSigningKey(byte[])这个旧 API。它能用,但这个版本不做alg头的强校验——这是 JWT 的经典风险点(算法混淆,比如把alg改成none,或者换成非对称算法来诱导服务端用公钥当 HMAC 密钥)。安全上更稳的做法是:显式限制允许的算法(parseClaimsJws时用Jwts.parserBuilder().setSigningKey(key)并指定SignatureAlgorithm.HS256),并且升级到 JJWT 0.11+。我没有做这个升级。」
③ 生成的 JWT 里有什么?
{
"empId": 5,
"exp": 1759200000
}iat、jti、iss、aud 都没有——setIssuedAt 从未调用。
没有 jti 的直接后果(这是 M1 的一个具体留白):因为键是 login:token:{完整JWT},而 JWT 本身充当了唯一标识。如果加了 jti,键就可以写成 login:token:{jti} —— 键长从约 150-200 字符降到 36 字符(UUID),而且Redis 键里不会暴露 JWT 的 payload(JWT 的 payload 只是 Base64,不是加密)。
2.2 JwtProperties(配置绑定)
@Component
@ConfigurationProperties(prefix = "sky.jwt")
@Data
public class JwtProperties {
private String adminSecretKey;
private long adminTtl;
private String adminTokenName;
private String userSecretKey;
private long userTtl;
private String userTokenName;
}配合 application.yml:
sky:
jwt:
admin-secret-key: itcast # ⚠️ 弱密钥,且硬编码在仓库里
admin-ttl: 7200000 # 2 小时
admin-token-name: token
user-secret-key: itheima # ⚠️ 弱密钥,且硬编码在仓库里
user-ttl: 7200000
user-token-name: authentication⚠️ 这是一个必须主动指出的问题:itcast / itheima 是原教学项目留下的默认示例值,字面意思就是”传智播客”和”黑马程序员”。它们现在以明文形式提交在代码仓库里,而且在 application.yml(不是 application-dev.yml)里——意味着所有环境共用同一套密钥。
风险链条:
源码/配置泄露 → 拿到 adminSecretKey = "itcast"
↓
可以伪造任意 empId 的合法 JWT(HS256,签名能通过)
↓
但还需要过一次 Redis 白名单:login:token:{伪造的token} 必须存在
↓
所以要成功伪造,必须【恰好拿一个真实存在过的 token 字符串作为载体】——
也就是说:要么拿到一个有效 token,要么从 Redis 里读出 key 拼接
结论:Redis 白名单确实给伪造增加了实质难度(这是一个”意外的收益”),但密钥泄露本身仍然是必须修的——正确做法是:
- 密钥放进环境变量或配置中心(
${JWT_ADMIN_SECRET}),不进仓库; - 用足够长的高熵随机串(≥ 256 bit,HS256 要求密钥不短于摘要长度);
- 支持密钥轮换(
JwtProperties改成支持密钥列表,验签时逐个尝试)。
2.3 Redis 白名单(三处配合)
【写】登录成功后(admin/EmployeeController:70-74、user/UserController:71-75)
stringRedisTemplate.opsForValue().set(
RedisKeyConstant.LOGIN_TOKEN_PREFIX + token, // login:token:{完整JWT}
employee.getId().toString(), // 值 = 员工 id
jwtProperties.getAdminTtl(), // 7200000
TimeUnit.MILLISECONDS); // ★ 单位是毫秒,与 JWT 一致
【读】每次请求(两个拦截器的 preHandle)
String loginKey = RedisKeyConstant.LOGIN_TOKEN_PREFIX + token;
String loginValue = stringRedisTemplate.opsForValue().get(loginKey);
if (loginValue == null) { response.setStatus(401); return false; }
【删】登出(admin:99、user:98)
stringRedisTemplate.delete(RedisKeyConstant.LOGIN_TOKEN_PREFIX + token);
三个设计要点:
| 要点 | 说明 |
|---|---|
TTL 用毫秒、和 JWT 的 exp 对齐 | 两边都是 7200000 ms。如果 Redis TTL 比 JWT 长,会留下”JWT 已过期但 key 还在”的垃圾键(无害,浪费内存);如果比 JWT 短,会出现”JWT 还有效但被 401 拒绝”(用户被提前登出)。当前是对齐的,这是正确的做法 |
值是 employeeId,但读取时没有用 | loginValue 拿到后就丢了——只判断了 key 是否存在(== null),从不比对值。所以值存什么其实无所谓。这是一个”存了但不校验”的留白,见 M1-5 |
| 键是完整 JWT(约 150-200 字符) | 偏长;且 JWT 的 payload 是 Base64(不是加密),所以 Redis 的 key 列表里直接暴露了 empId 和过期时间。如果用 jti 作键就能避免 |
2.4 登录失败计数(Redis INCR + TTL)
private static final int MAX_LOGIN_FAIL = 5; // 第 34 行
public Employee checkLogin(EmployeeLoginDTO dto) {
String username = dto.getUsername();
String password = dto.getPassword();
String failKey = RedisKeyConstant.LOGIN_FAIL_PREFIX + username; // login:fail:{username}
// ① 先查计数,达阈值直接拒绝(不查库、不比密码)
String failCountStr = stringRedisTemplate.opsForValue().get(failKey);
if (failCountStr != null && Integer.parseInt(failCountStr) >= MAX_LOGIN_FAIL) {
throw new AccountLockedException(MessageConstant.LOGIN_FAILED_TOO_MANY);
}
// ② 查库
Employee employee = employeeMapper.getByUsername(username);
if (employee == null) {
incrLoginFail(failKey); // ★ 账号不存在也计数
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
// ③ 密码比对(无盐 MD5)
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
incrLoginFail(failKey);
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
// ④ 账号状态
if (StatusConstant.DISABLE.equals(employee.getStatus())) {
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
// ⑤ 成功 → 清空计数
stringRedisTemplate.delete(failKey);
return employee;
}
private void incrLoginFail(String failKey) {
Long fails = stringRedisTemplate.opsForValue().increment(failKey, 1);
if (fails != null && fails == 1) { // ★ 只在首次自增时设过期
stringRedisTemplate.expire(failKey, 15, TimeUnit.MINUTES);
}
}【四个必须能讲的细节】
① 为什么 INCR 返回 1 时才设 EXPIRE?
因为 EXPIRE 是”重新设置”过期时间,不是”如果没有才设置”。如果每次 INCR 都跟着 EXPIRE,那么只要攻击者持续尝试,这个键的 15 分钟 TTL 会被无限刷新 → 计数永远不会归零 → 账号被永久锁定(直到 Redis 重启)。
这既是一个可用性 bug,也是一个攻击面:攻击者只要每 14 分钟试一次错密码,就能让某个账号永远登不上。
只在首次创建时设一次,才能保证”从第一次失败开始算 15 分钟窗口”。这个模式项目里用了两次(另一处是 M3 的单号 INCR order:number:{date},只在 seq == 1 时 EXPIRE 2 天),风格是一致的。
② 为什么先查计数、再查库?
为了省资源、也为了避免撞库打到数据库。达到阈值时直接抛异常,连数据库都不查。5 次失败后,后续请求只需要一次 Redis GET。
③ 为什么用 StringRedisTemplate 而不是 RedisTemplate?
因为 INCR 要求值是纯字符串数字。如果用泛型 RedisTemplate(Value 走 JDK 序列化),存进去的是 \xac\xed\x00\x05... 开头的二进制,INCR 会直接报错说值不是整数。这是被命令约束逼出来的选型,不是随意选的。
④ 登录成功要 DELETE 计数。 否则”失败 4 次 + 成功登录”之后,第 5 次失败就会被锁——用户体验上是错的(他刚成功登录过,说明密码是对的)。当前实现清空了计数,是对的。
2.5 BaseContext(两个 ThreadLocal)
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>(); // id
public static ThreadLocal<String> roleThreadLocal = new ThreadLocal<>(); // role
public static void clear() { // ← 两个拦截器的 afterCompletion 调用
threadLocal.remove();
roleThreadLocal.remove();
}这是 M4 的内容,但在 M1 里必须提一句:项目的两个拦截器都正确实现了 afterCompletion 并在其中调用 BaseContext.clear()。而原项目是缺失的——它定义了 removeCurrentId() 但从未调用。拦截器里的注释明确留档:
「这是原项目遗漏的一环(原代码只写了 remove 方法却没有调用)。」
三、完整代码执行链路
★★★ 链路 1:管理端登录(POST /admin/employee/login)
POST /admin/employee/login
Body: {"username":"purchaser","password":"123456"}
┌─【第 0 层】★ 拦截器【不生效】
│ WebMvcConfiguration.addInterceptors:
│ admin 拦截器 addPathPatterns("/admin/**")
│ .excludePathPatterns("/admin/employee/login") ← ★ 唯一的豁免路径
│ ※ 如果不豁免,就会变成"要先登录才能调用登录接口"的死循环
│
├─【第 1 层】EmployeeController.login(EmployeeLoginDTO) 第 56 行
│ ├─ log.info("员工登录:{}", employeeLoginDTO) ★★ 见 M1-1:整个 DTO 被打印
│ │ → 明文密码进入日志:"员工登录:EmployeeLoginDTO(username=purchaser, password=123456)"
│ └─ employeeService.login(dto)
│
├─【第 2 层】EmployeeServiceImpl.login(dto) 第 49 行
│ ├─ checkLogin(dto) ← 共用校验(见下面展开)
│ └─ if (RoleConstant.EMPLOYEE.equals(employee.getRole()))
│ throw new AccountLockedException(NO_ADMIN_PERMISSION)
│ ★ 端隔离:普通员工不允许登录管理端
│ ⚠️ 复用了 AccountLockedException,异常类语义与消息不匹配(M4-8)
│
│ ┌── checkLogin(dto) 展开(第 77-111 行)───────────────────┐
│ │ ① failKey = "login:fail:purchaser" │
│ │ ② Redis GET failKey │
│ │ 计数 >= 5 → throw AccountLockedException( │
│ │ "登录失败次数过多,请15分钟后再试") │
│ │ ③ employeeMapper.getByUsername("purchaser") ← MySQL │
│ │ null → Redis INCR failKey → throw AccountNotFound │
│ │ ④ password = DigestUtils.md5DigestAsHex(password) │
│ │ != employee.getPassword() │
│ │ → Redis INCR failKey → throw PasswordErrorException│
│ │ ⑤ status == DISABLE → throw AccountLockedException(锁定) │
│ │ ⑥ Redis DEL failKey ← 成功清空计数 │
│ │ ⑦ return employee │
│ └──────────────────────────────────────────────────────────┘
│
├─【第 3 层】★ 签发 JWT EmployeeController 第 62-67 行
│ Map<String,Object> claims = new HashMap<>();
│ claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); ← ★ 只放 empId
│ String token = JwtUtil.createJWT(
│ jwtProperties.getAdminSecretKey(), // "itcast"
│ jwtProperties.getAdminTtl(), // 7200000
│ claims);
│ ★ JWT 内容是 {"empId":5,"exp":...} —— 【不含任何权限信息】
│
├─【第 4 层】★ 写 Redis 白名单 第 70-74 行
│ stringRedisTemplate.opsForValue().set(
│ "login:token:" + token, // 键 = 完整 JWT(约 150-200 字符)
│ employee.getId().toString(), // 值 = 员工 id(后续不校验)
│ 7200000L, TimeUnit.MILLISECONDS); // ★ 与 JWT 的 exp 严格对齐
│
├─【第 5 层】组装 EmployeeLoginVO 第 76-84 行
│ .id / .userName / .name / .role / .deptId
│ .deptName(departmentService.getNameById(deptId)) ← 额外一次 MySQL 查询(M6)
│ .token(token)
│
└─【第 6 层】Result.success(EmployeeLoginVO)
⚠️ 响应体里有 token,前端存 localStorage(不是 Cookie → 所以不需要 CSRF 防护)
这条链路里 Redis 出现 3 次、MySQL 出现 3 次:
| 序 | 操作 | 位置 |
|---|---|---|
| MySQL ① | employeeMapper.getByUsername(username) | checkLogin 第 89 行 |
| Redis ① | GET login:fail:{username} | checkLogin 第 83 行 |
| Redis ② | INCR / DEL login:fail:{username} | 第 114 / 109 行 |
| MySQL ② | departmentMapper.getById(deptId)(拿部门名) | EmployeeController 第 82 行 |
| Redis ③ | SET login:token:{token} | EmployeeController 第 70 行 |
链路 2:员工端登录(POST /user/user/login)
POST /user/user/login
Body: {"username":"zhangsan","password":"123456"}
↓ 拦截器豁免:user 拦截器 excludePathPatterns("/user/user/login")
(同时还豁免了 "/user/shop/status"——那是采购开关查询,未登录也能看)
↓ UserController.login 第 59 行
├─ log.info("员工采购端登录:{}", employeeLoginDTO.getUsername()) ★ 只打用户名(比管理端正确)
├─ employeeService.staffLogin(dto) 第 67 行
│ └─ return checkLogin(dto) ← 与登录共用的校验,【不做角色检查】
│ ★ 任意在职员工都能登录员工端
├─ claims.put(JwtClaimsConstant.EMP_ID, employee.getId()) ← 统一用 empId(不是 userId)
├─ JwtUtil.createJWT(userSecretKey="itheima", userTtl=7200000, claims)
├─ Redis SET login:token:{token} → empId,TTL 7200000 ms
└─ Result.success(EmployeeLoginVO) ← 结构与管理端完全一样,靠 role 区分前端可用功能
两端共用 checkLogin,差异只在那一个 if。
★★★ 链路 3:每个已认证请求的校验(认证的核心,2 次 Redis + 1 次 MySQL)
GET /admin/goods/page
Header: token = {JWT}
↓ JwtTokenAdminInterceptor.preHandle() 第 49 行
├─【1】handler instanceof HandlerMethod ?
│ 不是(静态资源等)→ return true 直接放行
│ ※ 注意:静态资源虽然放行,但它【不会清除 ThreadLocal】——
│ 所以 afterCompletion 仍然会被调用(见第 7 步)
├─【2】token = request.getHeader("token")
├─【3】Claims claims = JwtUtil.parseJWT("itcast", token)
│ ├─ 验签(HS256)失败 → SignatureException → catch → 401
│ └─ exp 已过期 → ExpiredJwtException → catch → 401
├─【4】empId = Long.valueOf(claims.get("empId").toString())
├─【5】★ Redis GET login:token:{token} ← 第 67-68 行
│ 返回 null(已登出/被踢/TTL 到期)→ response.setStatus(401); return false
│ ⚠️ 只判空,【不比对 loginValue 与 empId 是否一致】(M1-5)
├─【6】★ employee = employeeMapper.getById(empId) ← MySQL,每次请求都查(M4-6)
│ ├─ employee == null → 401
│ ├─ role == "EMPLOYEE" → 401 ← 端隔离
│ └─ status == DISABLE → 401 ← 账号停用即时失效
├─【7】BaseContext.setCurrentId(empId)
│ BaseContext.setCurrentRole(employee.getRole())
└─ return true
↓ ... 控制器执行 ...
↓ afterCompletion() 第 98-100 行
BaseContext.clear() ← ★ 无论成功/失败/异常都执行
关键设计:JWT 只携带 empId,role 和 status 每次从数据库读。 这让降权和禁用都能立即生效(代价是每次请求一次主键查询)。
★★★ 链路 4:“退出即失效”的完整实现(M1 最值得讲的链路)
【写】登录 → Redis SET login:token:{JWT} = empId, TTL 7200000ms
↓
【读】每个请求 → Redis GET login:token:{JWT}
不存在 → 401
↓
【删】登出 → Redis DEL login:token:{JWT}
三个入口让 token 失效:
| # | 场景 | 机制 | 生效速度 |
|---|---|---|---|
| 1 | 用户主动登出 | POST /admin/employee/logout → DEL login:token:{token} | 立即 |
| 2 | 账号被禁用 | 管理员 POST /admin/employee/status/0 改 status → 下次请求拦截器查库发现 DISABLE → 401 | 立即(下次请求) |
| 3 | 员工被删除 | 拦截器 employee == null → 401 | 立即(下次请求) |
| 4 | TTL 自然到期 | Redis key 过期 + JWT exp 到期(两者对齐) | 2 小时 |
⚠️ 一个重要的”未实现”:没有”把某个人的所有会话一起踢下线”的能力。因为键是 login:token:{token},以 token 为维度、不是以人为维度。所以:
- 同一个账号在 3 台设备登录 → Redis 里有 3 个
login:token:*键; - 想”强制这个人在所有设备下线”,需要遍历
login:token:*逐个比对值(empId) —— 而这正是KEYS的用法,成本高、还会阻塞 Redis; - 当前的”禁用账号”能间接达到这个效果(拦截器查库发现
DISABLE),但前提是那个账号被禁用,不是”临时踢下线”。
正确设计:值双向索引,或用 login:user:{empId} → Set of jti 记录这个人的所有会话。当前未实现。
链路 5:登录失败与锁定
第 1 次错密码 → Redis INCR login:fail:zhangsan → 返回 1 → 因 returns==1 → EXPIRE 15 分钟
第 2 次错密码 → INCR → 2 (不刷新 TTL)
第 3 次错密码 → INCR → 3
第 4 次错密码 → INCR → 4
第 5 次错密码 → INCR → 5
第 6 次(对密码)→ GET → "5" >= 5 → throw AccountLockedException
"登录失败次数过多,请15分钟后再试"
★ 注意:第 6 次【连密码都不比对、数据库都不查】
等待 15 分钟(TTL 到期,键自动消失)
→ 重新可登录
【成功路径】第 4 次失败后对密码 → DEL login:fail:zhangsan → 计数归零
【无 TTL 刷新的保障】因为 EXPIRE 只在 INCR 返回 1 时执行,
所以攻击者持续尝试【不会】延长锁定时间
行为特征总结:
| 行为 | 是否正确 |
|---|---|
| 阈值 5 次、锁 15 分钟 | ✅ 合理 |
只在首次 INCR 设 TTL(避免 TTL 被无限刷新) | ✅ 正确且重要 |
| 登录成功清空计数 | ✅ 正确 |
| 达到阈值时不查库直接拒绝 | ✅ 省资源、防撞库 |
| 账号不存在也计数 | ⚠️ 见 M1-8:会污染 Redis、无法区分”撞库”和”用户名输错” |
| 与 IP 无关,只按用户名 | ⚠️ 见 M1-7:可被用作 DoS(故意锁别人的账号) |
| 无验证码 | ⚠️ 阈值 5 次能兜住,但体验差(密码记错 5 次就要等 15 分钟) |
四、为什么这么设计
4.1 为什么用”JWT + Redis 白名单”而不是纯 JWT 或纯 Session
【业务场景】 前后端分离,服务端要能识别”这个请求是谁发的”,同时用户点了”退出”要真的退出。
【三个方案的完整对比】
| 方案 | 实现 | 优点 | 缺点 | 本项目 |
|---|---|---|---|---|
| A. 纯 JWT(无白名单) | 签发后不管,只验签 + 查 exp | 完全无状态,服务端零存储、水平扩展无压力 | 无法撤销:登出、改密码、禁用账号后旧 token 在有效期内依然可用(本项目 TTL 是 2 小时);JWT payload 无法反查登录记录 | ❌ 原方案,已改造 |
| B. JWT + Redis 白名单(当前) | 登录写 Redis,每个请求校验 key 存在 | 可以撤销(登出、禁用立即生效);保留了 JWT”自包含、不查库就能拿到身份”的好处(虽然本项目为了权限实时性还是查了库) | 变成有状态了:Redis 挂了所有会话失效(fail-close,安全但影响可用性);每次请求多一次 Redis 往返;key 数量 = 在线会话数(有 TTL,不会无限增长) | ✅ 选它 |
| C. Redis 黑名单 | 只把”被撤销的 token”写 Redis,TTL = 剩余有效期 | Redis 里只存被撤销的,正常会话零存储;写入量远小于白名单 | 登出前必须有 jti(否则键还是长 JWT);Redis 挂了等于所有撤销失效(fail-open,危险);首次校验时”不存在”是正常态,所以只能靠查不到来放行——如果 Redis 挂了就完全放行 | ⚠️ 未使用。收益是不存正常会话,但 fail-open 的特性对安全场景不利 |
| D. 纯 Session(Cookie + 服务端 Session) | 传统方案 | 天然可撤销、多设备管理方便 | 前后端分离下跨域 Cookie 麻烦;要处理 Session 共享(多实例);本项目的 JWT 已经在了,改造面大 | ⚠️ 未使用 |
| E. 短 TTL JWT + Refresh Token | access token 5-15 分钟,refresh token 长期存服务端 | 兼顾”无状态”和”可撤销”:access token 短到”来不及被滥用”,refresh token 可撤销 | 前端要处理自动续期(401 → 调 refresh → 重试原请求);服务端仍要存 refresh token;复杂度明显上升 | ⚠️ 未实现。这是”如果要上生产我会怎么做”的答案 |
【为什么选 B 不选 A】
「纯 JWT 的根本问题是不可撤销。用户点了退出,只是前端删掉了本地存的那份,服务端没有任何手段让它失效——那个 token 在 2 小时内谁拿到都能用。同样,“把某个员工禁用”或者”降权”也拦不住他手里那张旧 token。对一个内部管理系统来说,‘禁用账号要立即生效’是硬需求,所以必须引入服务端状态。
选白名单而不是黑名单,是因为失败方向不同:白名单是”查不到就拒绝”(fail-close)——Redis 挂了,所有请求 401,安全但影响可用性;黑名单是”查不到就放行”(fail-open)——Redis 挂了,所有被撤销的 token 都复活了,这是安全问题。在安全和可用性之间,我选安全。
【为什么选 B 不选 E(短 TTL + Refresh Token)】
「Refresh Token 方案确实更好——access token 短到 5-15 分钟,“来不及被滥用”,同时 refresh token 可以撤销。但它的代价是前端要处理自动续期:401 之后调 refresh 接口、拿到新 token、重试原请求,还要处理”多个并发请求同时 401 导致 refresh 风暴”(要加单飞锁)。对一个内部系统的课程项目,这个复杂度我认为超过收益。而且我的 TTL 是 2 小时,配合白名单已经能覆盖”登出/禁用立即生效”这个核心诉求。
如果上生产、对接外部用户,我会改用 Refresh Token + 短 access token。
4.2 为什么”把 token 写 Redis”不算”把 JWT 变成 Session”
面试官可能会质疑:“你这不是把无状态 JWT 又变回有状态 Session 了吗?那还不如直接用 Session。”
【准确的回答 —— 这段要背】
「我承认引入 Redis 白名单之后,这个方案确实是有状态的了,这是有意的取舍,不是没想清楚。但它和传统 Session 有三个实质区别:
| 维度 | 传统 Session | 当前方案(JWT + 白名单) |
|---|---|---|
| 服务端存了什么 | 存完整的会话数据(用户对象、权限、任意业务状态),Session ID 只是一个指针 | 只存一个映射(token → empId),不含任何业务数据。身份信息(empId)在 token 里自包含,不需要从 Redis 读 payload |
| 多实例/跨域 | 需要 Session 共享(Redis/粘性会话)或独立 Session 服务 | 天然无问题:所有实例验签用同一个密钥,Redis 是共享的。不需要粘性会话 |
| 失败模式 | 服务端 Session 丢 → 用户全部掉线且无法恢复(会话数据没了) | 白名单丢 → 401,但用户重新登录就能拿回一个等价的新 token,因为身份信息(empId/role)不在 token 里、在数据库里 |
最关键的一点是第三个:我的 JWT 里只放了
empId,role和status都是每次从数据库读的。所以这个 token 本身不承载任何不可再生的信息——它就是一个”身份指针 + 防篡改签名”。丢掉它没有任何损失,重新签发一个就行。 这就是它和 Session 的本质区别:Session 是有状态的数据载体,而我的 token 是有状态的校验凭证。所以我觉得这个取舍是合理的:用一点点服务端状态,换到了”可撤销”;但因为 token 不承载业务数据,所以没有背回 Session 的全部成本。」
4.3 为什么”登录成功才清空失败计数”
【反例】 如果不在登录成功时 DELETE login:fail:{username}:
第 1 次错密码 → 计数 1
第 2 次错密码 → 计数 2
第 3 次【对密码】→ 登录成功,计数仍是 2
第 4 次错密码 → 计数 3
第 5 次错密码 → 计数 4
第 6 次错密码 → 计数 5
第 7 次【对密码】→ GET 返回 "5" → 拒绝!"登录失败次数过多"
这是错的:用户刚刚成功登录过(证明他知道正确密码),却因为几次更早的失败被锁。
【当前实现是正确的】 登录成功走 stringRedisTemplate.delete(failKey)(第 109 行),把计数归零。语义上这是对的:“失败计数”的意思是”连续失败次数”,一旦成功就应该重置。
顺带一个更细的设计点:计数的重置应该放在哪一步?
当前是放在 checkLogin 的最后(密码正确 + 账号状态正常之后)。顺序是对的——如果一个密码正确的用户账号被禁用了(status == DISABLE),不应该清空计数(因为这次登录并没有真正成功)。代码里 delete 在第 109 行、status 校验在第 104-106 行,所以禁用账号不会清计数——这是正确的顺序。
4.4 为什么登录页面不做参数校验(当前项目的留白)
【业务场景】 用户提交 {"username":null, "password":null} 或者只传 username。
【当前实现的事实】
public Employee checkLogin(EmployeeLoginDTO dto) {
String username = dto.getUsername();
String password = dto.getPassword();
String failKey = RedisKeyConstant.LOGIN_FAIL_PREFIX + username; // ← username 为 null
...
password = DigestUtils.md5DigestAsHex(password.getBytes()); // ← NPE!三个问题:
username == null→failKey = "login:fail:null",然后employeeMapper.getByUsername(null)返回 null → 计到login:fail:null上。功能上不会崩,但所有没传用户名的请求共享同一个计数键——等于攻击者可以用它来”消耗”一个全局的计数槽;password == null→password.getBytes()直接 NPE →NullPointerException→GlobalExceptionHandler不处理 NPE(它只声明了BaseException和SQLIntegrityConstraintViolationException)→ HTTP 500;- 空字符串:
username = ""→failKey = "login:fail:",同样共享;password = ""→ MD5 空串d41d8cd98f00b204e9800998ecf8427e,正常比对、正常失败。
【项目完全没有参数校验框架】 依赖里没有 spring-boot-starter-validation,全项目没有任何 @NotNull / @NotBlank / @Valid。所有校验都是手写 if,而登录这个方法恰好漏了。
【为什么这是问题(面试怎么讲)】
「这个项目的参数校验全靠手写
if,没有引入spring-boot-starter-validation。登录接口恰好漏了一处——password为 null 时password.getBytes()会直接 NPE,而全局异常处理器只处理BaseException和 SQL 异常,NPE 不在其中,会返回 HTTP 500。这是**“400 的语义返回了 500”**,属于错误处理不规范。更规范的做法是引入 validation:DTO 上标
@NotBlank(message = "用户名不能为空"),Controller 参数加@Valid,然后全局异常处理器里捕获MethodArgumentNotValidException→ 返回统一的 “参数错误” 提示。这样一套机制覆盖所有接口的参数校验,而不是每个方法手写if(手写必然有漏的,登录这里就是证明)。」
4.5 为什么管理端登录打印了整个 DTO(这是一个真实缺陷)
// admin/EmployeeController:57
log.info("员工登录:{}", employeeLoginDTO);EmployeeLoginDTO 用 @Data 生成了 toString(),所以日志里是:
员工登录:EmployeeLoginDTO(username=purchaser, password=123456)
明文密码进入了应用日志。 而员工端登录写的是:
// user/UserController:60
log.info("员工采购端登录:{}", employeeLoginDTO.getUsername()); ← ★ 只打用户名,正确两个同类接口,一个打整个对象、一个只打用户名——说明员工端那处是有意规避的,管理端这处是漏了。
【为什么这是实质问题,而不是”小题大做”】
- 应用日志通常会被采集到日志平台(ELK、Splunk)并长期保存、多人有读权限(运维、开发、安全审计);
- 日志的访问控制通常比数据库弱——数据库可能有严格的权限和审计,日志往往”谁都能 grep”;
- 用户密码在多个系统复用是常态,泄露一个内部系统的密码可能影响其他系统;
- 合规角度(等保、ISO 27001)明确要求日志不得记录明文口令。
【修法】 只打印用户名(照抄员工端那行),或者用 @JsonIgnore / @ToString.Exclude 让密码不出现在 toString() 里。
面试标准答法:
「管理端登录这里我把整个 DTO 打进了日志,
@Data生成的toString()包含密码字段,所以明文密码进了日志——这是一个真实的问题。员工端登录我写的是只打username,管理端这处漏了。日志的风险不比数据库小:它通常会被采集到日志平台长期保存、多人可读,访问控制往往比数据库弱。而且用户常在多个系统复用密码。所以”日志不打敏感信息”是一条硬规矩。
修法有两个:一是只打印用户名(照抄员工端那行),二是用 Lombok 的
@ToString.Exclude标在password字段上,让toString()根本不含它——后者更稳,因为它防的是”以后有人在别处也打印这个 DTO”。」
4.6 为什么登出是从请求头取 token 而不是从参数
@PostMapping("/logout")
public Result<String> logout(HttpServletRequest request) {
String token = request.getHeader(jwtProperties.getAdminTokenName());
if (token != null && !token.isEmpty()) {
stringRedisTemplate.delete(RedisKeyConstant.LOGIN_TOKEN_PREFIX + token);
}
return Result.success();
}【三个设计点】
- 从请求头取:因为登出请求已经带了 token 头(它经过拦截器,说明头里有有效 token)。不需要额外的参数,也不会让客户端有机会传别人的 token;
- 判空后才删:
if (token != null && !token.isEmpty())。如果头里没 token,直接返回成功——语义上”你要登出,但你本来就没登录,那也算登出成功”(幂等); - 只删自己那一个 token:所以一个人无法登出别人(他得知道别人的 token,而他拿不到)。
【与拦截器的关系】 登出接口没有豁免拦截器(只有 login 被豁免),所以它必须携带有效 token 才能调用。这里有个有意思的顺序问题:
请求到达 → 拦截器 preHandle:验签 + 查白名单 → 通过(因为 token 还有效)
↓
Controller.logout():DEL login:token:{token}
↓
afterCompletion:BaseContext.clear()
校验和删除在同一个请求里,顺序是对的——先验证”这个 token 确实是有效的登录态”(拦截器),再删(Controller)。如果拦截器不拦登出接口,任何人都能传一个 token 字符串去删别人的会话(虽然要猜到完整 JWT,但那是”不必要的暴露面”)。
这一点值得在面试里主动讲:登出接口没有豁免拦截器,是一个正确的安全设计。
五、M1 复习优先级文件清单(第五部分 · M1 切片)
M1 的代码散在 6 个文件里,但每个文件要看的都很短。总计约 35 分钟。
A 类:必须能背、能默写、能手画时序
| 优先级 | 文件 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | EmployeeServiceImpl.java 第 49-118 行(login / staffLogin / checkLogin / incrLoginFail) | 登录校验 5 步 + 失败计数只在 INCR 返回 1 时设 TTL + 成功清计数 | M1 最核心的业务逻辑,70 行。要能逐句解释 |
| P0 | JwtUtil.java(58 行) | createJWT 的三步顺序(setClaims 必须在最前)+ parseJWT 的旧 API | setClaims 的顺序是隐蔽坑,讲出来是加分 |
| P0 | JwtTokenAdminInterceptor.java 第 49-101 行 | 认证 7 步(含 2 次 Redis/1 次 MySQL)+ afterCompletion 清 ThreadLocal | 认证全流程,必须能画 |
| P0 | EmployeeController.java 第 54-102 行 | 登录(签发 JWT + 写 Redis)+ 登出(删 Redis)+ log.info 打印整个 DTO(缺陷) | 白名单的写/删两端都在这里 |
| P0 | application.yml 的 sky.jwt 段 | admin-secret-key: itcast / user-secret-key: itheima / TTL 7200000 / 两个请求头名 | 必须知道密钥是弱值且硬编码——这是主动承认的点 |
| P0 | RedisKeyConstant.java | LOGIN_TOKEN_PREFIX = "login:token:"、LOGIN_FAIL_PREFIX = "login:fail:" | 两个 key 的设计 |
B 类:知道流程即可
| 文件 / 位置 | 看什么 |
|---|---|
| UserController.java 第 57-101 行 | 与管理端同构,对比两个 log.info(一个打整个 DTO、一个只打 username) |
| JwtProperties.java | @ConfigurationProperties(prefix="sky.jwt") 绑定 6 个字段 |
| BaseContext.java | 两个 ThreadLocal + clear()(M4 已详述) |
| WebMvcConfiguration.java 第 40-50 行 | 拦截器豁免路径(/admin/employee/login、/user/user/login、/user/shop/status) |
| EmployeeMapper.java | getByUsername / getById / countByUsername(零引用) |
sql/regress_api_test.sh | 第 2 节(员工不能登管理端)、第 5 节(未登录 401)、第 14 节(员工 token 调管理端 401) |
C 类:不用看
EmployeeLoginDTO/EmployeeLoginVO/Employee实体(Lombok 数据类)RoleConstant/PasswordConstant/JwtClaimsConstant/MessageConstant(常量类)- 5 个登录相关异常类(知道都
extends RuntimeException即可)
35 分钟复习顺序
1. JwtUtil 全 58 行 ← 6 分钟,重点是 setClaims 的顺序
2. EmployeeServiceImpl 第 77-118 行 ← 10 分钟,checkLogin 5 步 + incrLoginFail 的 TTL 逻辑
3. EmployeeController 第 54-102 行 ← 8 分钟,签发 + 写 Redis + 登出删 Redis
4. JwtTokenAdminInterceptor 第 49-101 行 ← 6 分钟,认证 7 步
5. application.yml 的 sky.jwt 段 ← 2 分钟,背下"密钥是 itcast/itheima,TTL 2 小时"
6. 自己讲一遍"为什么 JWT 要配 Redis 白名单、为什么不用黑名单" ← 3 分钟
六、M1 面试追问链(10 层)
追问链 1:JWT 与白名单(M1 最核心,必问)
【面试官问题 1】
你的登录认证是怎么做的?
【推荐回答】
双端独立的 JWT + Redis 白名单。
登录时校验账号密码,通过后用 HS256 签一个 JWT,claims 里只放 empId,然后把这个 token 写进 Redis 白名单(键是 login:token:{token},值是员工 id,TTL 和 JWT 的有效期严格对齐,都是 2 小时)。每个请求由拦截器校验:验签、查 exp、查 Redis 白名单是否存在、查库确认账号没被停用且角色有资格进这个端,然后把 empId 和 role 写进 BaseContext。登出就是把 Redis 里那个键删掉。
管理端和员工端用两套密钥、两个请求头:管理端是 itcast + token 头,员工端是 itheima + authentication 头。
【回答关键词】 HS256 · claims 只放 empId · Redis 白名单 · TTL 与 exp 对齐 · 双密钥双请求头 · 拦截器
【可能继续追问】 JWT 不是无状态的吗?你为什么还要存 Redis?
【面试官问题 2】
JWT 不是无状态的吗?你为什么要存 Redis?这不是把无状态又变回有状态了吗?
【推荐回答】
是的,我承认引入白名单之后确实是有状态了,这是有意的取舍。 原因是纯 JWT 无法撤销——用户点了退出,只是前端删掉了本地那份,服务端没有任何手段让它失效,那个 token 在 2 小时内谁拿到都能用。同样,“禁用某个员工”也拦不住他手里那张旧 token。
但它和传统 Session 有三个实质区别:
第一,服务端存的东西不同。 传统 Session 存的是完整的会话数据(用户对象、权限、各种业务状态),Session ID 只是个指针;我的 Redis 里只存一个映射(token → empId),不含任何业务数据——身份信息在 token 里自包含,不需要从 Redis 读。
第二,多实例部署没问题。 所有实例验签用同一个密钥、Redis 是共享的,不需要粘性会话,也不需要 Session 共享方案。
第三,也是最关键的——失败模式不同。 我的 JWT 里只放了 empId,role 和 status 都是每次从数据库读的。所以这个 token 本身不承载任何不可再生的信息,它就是一个”身份指针 + 防篡改签名”。白名单丢了,用户重新登录就能拿回一个等价的新 token;而传统 Session 的数据丢了,会话就彻底无法恢复了。
所以这个取舍是:用一点点服务端状态换”可撤销”,但因为 token 不承载业务数据,没有背回 Session 的全部成本。
【回答关键词】 承认变成有状态 · 存映射而非会话数据 · 无粘性会话需求 · token 不承载不可再生信息 · 失败模式不同
【可能继续追问】 为什么用白名单不用黑名单?
【面试官问题 3】
为什么用白名单而不是黑名单?黑名单不需要存正常会话,不是更省吗?
【推荐回答】
黑名单确实更省——它只在用户登出时写入被撤销的 token,正常会话在服务端零存储。但我选白名单,原因是【失败方向不同】。
白名单是 fail-close:Redis 一旦挂了或数据丢了,所有请求都查不到 key,全部 401。结果是”所有人都登不进来”,影响可用性,但不会造成越权。
黑名单是 fail-open:判断逻辑是”这个 token 在黑名单里吗?查不到就放行”。Redis 挂了之后,所有被撤销的 token 全部复活。而”撤销”恰恰是在做安全操作(登出、禁用),这时候放行等于安全机制失效。
在安全和可用性之间,我选安全。 而且黑名单还有一个额外的实现前提:必须先有 jti——因为黑名单的键也必须是 token 的唯一标识,否则键还是那条 150 多个字符的完整 JWT。而我没有给 JWT 加 jti。
【回答关键词】 fail-close vs fail-open · 撤销是安全操作、放行等于失效 · 黑名单也需要 jti · 安全优先于可用性
【可能继续追问】 Redis 挂了是不是就等于所有人都被登出了?
【面试官问题 4】
那 Redis 挂了,是不是所有人都被登出了?
【推荐回答】
是的,这是白名单方案的直接代价,我认。 Redis 挂掉或者数据丢失,所有 login:token:* 键都不在了 → 拦截器查不到 → 全部 401 → 所有在线用户被强制重新登录。
但有三点让这个后果是可接受的:
第一,不会导致越权或数据错误——失败方向是”拒绝服务”,不是”错误放行”。用户重新登录就恢复了。
第二,没有不可逆的损失。 因为 token 里只有 empId,没有承载任何”只能从 token 拿到”的信息,所以重新登录签发一个等价的新 token 即可,不存在会话数据丢失的问题。
第三,影响面是”登录态”,不是”业务数据”。 我的预算、单据、申领车数据都在 MySQL 里,Redis 只承担登录态、防重、限流、缓存这些可重建的东西。所以 Redis 挂掉不会造成资金或业务数据错误。
如果要提升可用性,可以上 Redis 哨兵或集群;但即使做了高可用,也应该接受”Redis 完全不可用时所有会话失效”这个语义——因为另一种选择(fail-open)是安全问题。
【回答关键词】 承认全部登出 · 失败方向是拒绝服务 · token 无不可再生信息 · Redis 只承担可重建的数据 · 不做降级是安全决策
【可能继续追问】 你这个 token 的 TTL 是多久?为什么?
【面试官问题 5】
TTL 设置成 2 小时,这个时间合理吗?
【推荐回答】
2 小时对内部管理系统是可以接受的,但我要说清它的取舍。
TTL 短的好处是安全:token 被滥用的窗口小;坏处是体验:用户 2 小时就得重新登录一次。
而且我这个 TTL 有一个具体的不足:没有滑动续期。 也就是说,用户就算一直在操作,从签发时刻起满 2 小时也一定会被登出,不会因为活跃而延长。体验上是”用着用着突然被踢出去”。
没有做续期是因为要保持”JWT 的 exp 和 Redis 的 TTL 严格对齐”这个不变式——一旦续期,两边都要更新,而 JWT 是签好之后不可修改的(改了就破坏签名),所以续期只能”重新签发一个新 token”,那就变成 Refresh Token 的模型了。
如果要做更好的方案,我会改成 Refresh Token:access token 短到 5-15 分钟,refresh token 长期存在服务端且可以撤销。这样既安全(access token 窗口极小)又体验好(静默续期)。代价是前端要处理自动续期——401 之后调 refresh、拿新 token、重试原请求,还要处理”多个并发请求同时 401 导致 refresh 风暴”(要加单飞锁)。对内部系统的课程项目,这个复杂度我认为超过收益。
【回答关键词】 2 小时的理由 · 无滑动续期(会被强制踢出) · JWT 签名不可改所以续期=重签 · Refresh Token 方案 · 复杂度取舍
【可能继续追问】 你登出之后,旧 token 立刻就失效了吗?
【面试官问题 6】
用户点登出之后,那个旧 token 立刻就失效了吗?
【推荐回答】
立刻就失效了,而且有三个层面都能让它失效。
一是登出:logout 接口把 login:token:{token} 从 Redis 删掉,下一个带这个 token 的请求会被拦截器返回 401。前端也确实调用了这个接口——我核实过代码,管理端和员工端都在 doLogout 里先 await 登出请求,而且用 try/catch 包住,后端失败也不阻塞本地状态清理,这个写法是对的。
二是禁用账号:管理员改 status = 0 之后,因为拦截器每次请求都查库,下一个请求就会被 401 拒绝。不需要等 token 过期。
三是删除员工:拦截器查库返回 null,同样 401。
但有一个能力我没有实现,要主动说:我没法”强制某个人在所有设备上一起下线”。因为我的 Redis 键是 login:token:{token}——以 token 为维度,不是以人为维度。同一个账号在 3 台设备登录,Redis 里就是 3 个独立的键。想”踢掉这个人的所有会话”,得遍历 login:token:* 逐个比对值(empId),那是 KEYS 的用法,成本高还阻塞 Redis。
要支持这个能力,得把键设计成双向索引,比如再加一个 login:user:{empId} → 该用户所有 token 的 Set。当前没做。不过”禁用账号”能间接达到效果,只是那不是”临时踢下线”,而是”封号”。
【回答关键词】 登出即刻失效 · 前端确实调了(带 try/catch) · 禁用/删除也立即失效 · 无法按人踢所有会话(键以 token 为维度) · 双向索引方案
【可能继续追问】 你这个 key 用完整 JWT,有没有问题?
【面试官问题 7】
Redis 的 key 直接用完整的 JWT,这样合适吗?
【推荐回答】
不太合适,有三个问题,我要说清。
第一是键太长:JWT 大概 150-200 个字符,Redis 里 key 本身占内存。虽然量级不大(会话数),但没有必要。
第二是信息暴露:JWT 的 payload 只是 Base64 编码,不是加密。所以 redis-cli --scan 出来的 key 列表里,直接能读出 empId 和过期时间。任何能访问 Redis 的人(或者从 RDB/AOF 备份文件、从慢日志里)都能解析出这些信息。
第三是不支持多设备管理——这是上面提到的那个能力缺口。
正确做法是给 JWT 加 jti(唯一 id,UUID),键写成 login:token:{jti}:键长从 150 降到 36且不暴露 payload,同时为黑名单/多会话管理打下基础。
注意:JwtUtil 里根本没有 setId(),也没有 setIssuedAt(),所以现在的 JWT 只有 empId 和 exp 两个 claim。加 jti 是个很小的改动,但会改变 token 的生成逻辑,需要同步改签发和校验两端。我没做。
【回答关键词】 键太长 · payload 是 Base64 不是加密 → Redis key 列表暴露 empId · 无多设备管理能力 · 加 jti 的方案 · 承认没做
【可能继续追问】 那你的 JWT 密钥是怎么配的?
【面试官问题 8】
JWT 的密钥是怎么管理的?安全吗?
【推荐回答】
不安全,这是一个明确的缺陷,我得承认。
密钥是 itcast(管理端)和 itheima(员工端)——这是原教学项目留下的默认示例值,字面意思就是”传智播客”和”黑马程序员”。而且它们明文提交在 application.yml 里(不是 application-dev.yml),意味着所有环境共用同一套密钥。
风险链条是:源码或配置泄露 → 拿到密钥 → 可以伪造任意 empId 的合法签名 JWT(HS256 是对称加密,验签和签发用同一个密钥)。
不过这里有一个”意外的收益”值得说清:Redis 白名单给伪造增加了实质难度。因为拦截器不只验签,还要查 login:token:{token} 是否存在。所以攻击者光有密钥还不够——他还得有一个真实存在过的 token 字符串作为载体。这让攻击从”有密钥就够了”变成”有密钥 + 拿到一个有效 token 才行”。但这只是提高了门槛,密钥泄露本身仍然是必须修的。
正确做法是:① 密钥放进环境变量或配置中心(${JWT_ADMIN_SECRET}),不进仓库;② 用足够长的高熵随机串(HS256 要求密钥长度不低于摘要长度,也就是 ≥ 32 字节);③ 支持密钥轮换(验签时按列表逐个尝试,这样换密钥时旧 token 还能过渡)。这三条我都没做。
【回答关键词】 弱密钥 + 硬编码 + 全环境共用 · 对称加密 → 泄露即可伪造 · Redis 白名单意外提高了伪造门槛 · 环境变量 + 高熵 + 密钥轮换 · 都没做
【可能继续追问】 你说白名单能兜住伪造,那我拿到一个有效 token 后能做什么?
【面试官问题 9】
如果攻击者拿到了一个有效的 token,配合泄露的密钥,他能做什么?
【推荐回答】
他能把那个 token 里的 empId 改掉、重新签名,但改完的 token 会过不了白名单,所以拿不到别人的身份。
具体推一遍:
攻击者手里的东西:① 密钥 "itcast" ② 一个有效 token{empId:5, exp:...}
他想做的事: 改成 token{empId:1, exp:...} —— 冒充管理员(empId=1)
步骤:
用密钥重新签名 → 签名验证能通过 ✅
但拦截器接着查 Redis:GET login:token:{改后的token}
→ ★ 这个键不存在(Redis 里的键是【原始 token 字符串】)
→ 401 ❌
所以关键点是:Redis 的键是”完整的 token 字符串”,攻击者改任何一个字符,键就对不上了。 这是白名单方案在这个具体场景下的一个额外好处——它把”签名可信”变成了”签名可信 且 这个 token 是我签发过的”。
但我要指出两处让这个防护不够严密的地方:
第一,拦截器读到了 loginValue 但没有用它。 代码是:
String loginValue = stringRedisTemplate.opsForValue().get(loginKey);
if (loginValue == null) { response.setStatus(401); return false; }拿到值之后直接判断 null,从不比对 loginValue 和 JWT 里的 empId 是否一致。 如果比对一下(if (!loginValue.equals(empId.toString())) → 401),就能多一层”token 内容和白名单记录必须匹配”的校验。现在这层是缺失的。
第二,我在前面提过 JJWT 0.9.1 的旧 API 不严格校验 alg 头,这属于算法层面的风险(算法混淆),需要显式限制允许的算法。
【回答关键词】 改 empId 重签 → 白名单键对不上 → 401 · 键是完整 token 字符串所以防护有效 · 但 loginValue 读到没校验(真实缺口) · 加上值比对能加固 · JJWT 0.9.1 的 alg 校验问题
【可能继续追问】 那这个 loginValue 不校验会造成什么实际后果?
【面试官问题 10】
loginValue 不校验,会造成什么实际后果?
【回答回答】
在当前代码下,实际后果很小,但它是一个”防御纵深”的缺失,我要如实说。
为什么实际后果小:loginValue 是登录时写的 employee.getId().toString(),而 Redis 的键又是完整的、由这个员工 id 签发的 token。两者本来就应该是匹配的——因为键和值是在同一个 set 调用里、由同一份数据产生的。所以在正常流程下,值永远和 token 里的 empId 一致,不校验也不会出错。
它有价值的地方是”防御异常路径”:
- 如果代码后来被改动,写白名单的地方用了别的值(比如误写成
username); - 如果有人手工操作 Redis(
SET login:token:xxx 1); - 如果出现数据错乱(比如键值对拼接时出 bug,把 A 的 token 配上了 B 的 id)。
这些情况下,比对值能立刻发现”这个会话的状态坏了”并拒绝,而不比对就完全依赖”键本身是正确的”这一个假设。
所以严格说,这里存了一个值却不校验,是”浪费了一半的防御”——要么就把值也校验、要么就别存值(只存一个 "1" 表示存在)。当前是”存了但不用”,属于设计上的半成品。
【回答关键词】 正常流程下值必然一致(同一份数据写入)· 价值在防御异常路径(代码改动/手工操作/数据错乱)· “存了不用”是半成品 · 要么校验要么别存
【可能继续追问】 (通常转向登录失败计数或密码安全)
追问链 2:登录失败锁定(6 层)
【面试官问题 1】
登录失败次数是怎么限制的?
【推荐回答】
用 Redis 计数。键是 login:fail:{username},每次密码错误或者账号不存在就 INCR 一次。只在 INCR 返回 1(也就是这个键第一次被创建)的时候设置 15 分钟的过期时间。 达到 5 次就直接拒绝登录——这时候连数据库都不查,直接返回”登录失败次数过多,请15分钟后再试”。 登录成功会把这个键删掉,计数归零。
【回答关键词】 login:fail:{username} · INCR · 阈值 5 · TTL 15 分钟 · 达阈值不查库 · 成功清计数
【可能继续追问】 为什么只在 INCR 返回 1 的时候设过期?
【面试官问题 2】
为什么只在 INCR 返回 1 的时候设置过期时间?每次 INCR 都设一下不是更保险?
【推荐回答】
每次 INCR 都设过期是一个 bug。
因为 EXPIRE 的语义是”重新设置过期时间”,不是”如果没有才设置”。如果每次失败都 EXPIRE 15 分钟,那么只要攻击者持续尝试,这个键的 TTL 就会被无限刷新 → 计数永远不会归零 → 那个账号被永久锁定(直到 Redis 重启或者人工删键)。
这既是一个可用性 bug,也是一个攻击面:攻击者只要每 14 分钟试一次错密码,就能让某个账号一直登不上,而且他自己几乎不用付出成本。
只在首次创建时设一次,才符合”从第一次失败开始算 15 分钟窗口”的语义。这个模式我在项目里用了两次——另一处是申领单号的 INCR(order:number:{yyyyMMdd}),也是只在 seq == 1 时设 2 天过期。所以风格是一致的。
【回答关键词】 EXPIRE 是覆盖不是新建 · 每次设 = TTL 被无限刷新 = 永久锁定 · 攻击者每 14 分钟试一次即可维持锁定 · 项目里两处用同一模式
【可能继续追问】 那这个方案能防住暴力破解吗?
【面试官问题 3】
那这个方案能防住暴力破解吗?
【推荐回答】
能挡住”单账号的在线暴力破解”,但挡不住几类攻击,我要说清边界。
能挡住的:针对一个账号疯狂猜密码——5 次就锁 15 分钟,一天最多试 480 次左右,对密码空间来说是杯水车薪。
挡不住的,有四类:
第一,撞库(用一个泄露的密码库去试大量不同账号)。 因为我的计数是按 username 维度的,攻击者对 10000 个账号各试 1 次,每个账号的计数都只有 1,完全不会被锁。这是最严重的一类,而我没有做 IP 维度或全局限流。
第二,DoS/账号恶意锁定。 因为只按用户名计、没有 CAPTCHA,攻击者只要对某个他知道存在的账号故意输 5 次错密码,就能把这个员工锁 15 分钟。反复执行就是持续的拒绝服务。这是”按用户名锁定”这个策略的固有副作用。
第三,密码本身太弱。 我用的是无盐 MD5,而且新建员工的默认密码是硬编码的 123456(PasswordConstant.DEFAULT_PASSWORD)。演示数据里三个账号的密码哈希完全相同(都是 123456 的 MD5 e10adc3949ba59abbe56e057f20f883e)——库一旦泄露,弱密码几乎瞬间被彩虹表还原。
第四,账号不存在也计数,所以攻击者可以用不存在的用户名去刷 Redis,塞进大量垃圾键(虽然有 15 分钟 TTL 会自动清理,但在 15 分钟内可以塞很多)。
要做得更严谨,我会加:① IP 维度的计数和限流(防撞库);② 验证码(在连续失败后引入,防 DoS 和自动化);③ 密码改用 BCrypt(无盐 MD5 必须换);④ 更完善的做法是”IP + 用户名 双维度、渐进式延迟”——而不是一刀切地锁定账号。
【回答关键词】 能防单账号在线爆破 · 按用户名计数 → 挡不住撞库(核心缺陷) · 可被恶意锁号(DoS)· 无盐 MD5 + 默认密码 123456 · 不存在用户也计数 · 改进方向:IP 维度 + 验证码 + BCrypt + 渐进延迟
【可能继续追问】 你说的”账号不存在也计数”是什么意思?
【面试官问题 4】
账号不存在也计数,这有什么问题?
【推荐回答】
代码是这样的:
Employee employee = employeeMapper.getByUsername(username);
if (employee == null) {
incrLoginFail(failKey); // ← ★ 账号不存在也 INCR
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}这么写有一个安全上的好处:如果我”账号不存在就不计数”,那么攻击者可以通过是否被锁定来反推”这个用户名到底存不存在”——这就成了一个用户名枚举的漏洞。所以计数是防枚举的,这个思路是对的。
但它有两个实际问题:
第一,污染 Redis。 任何不存在的用户名都会创建一个 login:fail:{username} 键。攻击者可以用随机生成的用户名批量刷,在不存在的用户名上塞进大量键。虽然有 15 分钟 TTL 会自动清理,但 15 分钟内可以造成可观的内存占用,也可能把有用的键挤出去(取决于 Redis 的淘汰策略——项目没有配置 maxmemory-policy,用的是默认的 noeviction,那在内存打满时会直接写失败,更糟)。
第二,它让”连续输错自己用户名”和”被撞库”在监控上无法区分。 而且用户体验上有点怪:用户把自己的用户名打错 5 次,会看到”登录失败次数过多”,但他其实只是在拼错自己的账号。
更细的设计是:对不存在的账号用更轻的惩罚(比如不计入硬锁定、只做延迟),或者把”密码错误”和”账号不存在”的计数分开,再或者统一返回”账号或密码错误”(不区分是哪个错了)——这样既防枚举又不会让”打错用户名”触发账号锁定。
注意我当前实现里 ACCOUNT_NOT_FOUND(“账号不存在”)和 PASSWORD_ERROR(“密码错误”)是分开的两个提示,这本身就泄露了”这个用户名是否存在”——所以其实我这里防枚举做了一半:计数防了(攻击者不能通过锁定状态反推),但错误消息没防(直接告诉你账号不存在)。这是不一致的。
【回答关键词】 计数防枚举(思路对) · 但污染 Redis + 无 maxmemory 配置更糟 · 无法区分”打错用户名”和”撞库” · 错误消息仍泄露账号存在性(防枚举只做了一半) · 统一提示”账号或密码错误”
【可能继续追问】 那密码应该怎么存?
【面试官问题 5】
你的密码是怎么存的?
【推荐回答】
无盐 MD5,这是一个明确的缺陷。
代码是 DigestUtils.md5DigestAsHex(password.getBytes())——无盐、无迭代、单次哈希。两个问题:
一是 MD5 已经彻底不安全,GPU 每秒能算几十亿次,暴力破解成本极低;
二是无盐意味着相同密码得到相同哈希,可以直接用彩虹表反查。我的演示数据里三个账号密码都是 123456,哈希值完全相同(e10adc3949ba59abbe56e057f20f883e)——这本身就是”无盐”的直接证据,库一泄露弱密码瞬间还原。
修法是换 BCrypt:自带随机盐(同一个密码每次哈希结果都不同)、可配置计算成本(strength 参数让每次哈希耗时几十到几百毫秒)。抗暴力破解的核心就是”慢”——MD5 快到可以暴力枚举,BCrypt 慢到枚举不可行。
但换算法有一个实际障碍:存量数据兼容。 库里已经存了 MD5 的值,直接换会导致所有人登录不上。可行的迁移方案是双算法并存、登录时渐进升级:
if (storedHash.startsWith("$2a$") || storedHash.startsWith("$2b$")) {
// BCrypt 哈希 → 走 BCrypt 校验
matches = BCrypt.checkpw(rawPassword, storedHash);
} else {
// MD5 哈希 → 走 MD5 校验
matches = md5(rawPassword).equals(storedHash);
if (matches) {
// ★ 校验通过,顺便把密码升级成 BCrypt 存回去
employeeMapper.updatePassword(employee.getId(), BCrypt.hashpw(rawPassword, BCrypt.gensalt()));
}
}这样用户无感知、逐步迁移完成,迁移期间两种格式都能登录。这个迁移我没做。
另外还有一个相关的问题:新建员工的默认密码是硬编码的 123456(PasswordConstant.DEFAULT_PASSWORD),而且项目没有任何”修改密码”的接口(PasswordEditDTO 存在但零引用)。所以新建的员工永远改不了密码——这本身也是一个功能缺失。
【回答关键词】 无盐 MD5 + 单次哈希 · 演示数据哈希相同即证据 · BCrypt = 随机盐 + 慢 · 存量迁移:按前缀判断 + 登录时升级 · 默认密码 123456 且无改密接口
【可能继续追问】 那登录这里还有什么安全考虑?
【面试官问题 6】
登录这里还有其他安全问题吗?
【回答回答】
有一个我确实漏了:管理端登录把明文密码打进了日志。
// admin/EmployeeController:57
log.info("员工登录:{}", employeeLoginDTO);EmployeeLoginDTO 用 @Data 生成了 toString(),所以日志里是 EmployeeLoginDTO(username=purchaser, password=123456)。明文密码进入了应用日志。
而员工端登录我写的是:
// user/UserController:60
log.info("员工采购端登录:{}", employeeLoginDTO.getUsername()); // 只打用户名两个同类接口,一个打整个对象、一个只打用户名——说明员工端那处是有意规避的,管理端这处漏了。
为什么这是实质问题:应用日志通常会被采集到日志平台并长期保存,而且日志的访问控制往往比数据库弱(数据库有权限和审计,日志经常”谁都能 grep”)。再加上用户在多个系统复用密码很常见,一个内部系统的日志泄露可能连带影响其他系统。合规上(等保、ISO 27001)明确要求日志不得记录明文口令。
修法有两个:① 只打印用户名(照抄员工端那行);② 用 Lombok 的 @ToString.Exclude 标在 EmployeeLoginDTO.password 上,让 toString() 根本不含密码字段。我更倾向第二种,因为它防的是”以后有人在别处也打印这个 DTO”——从源头让密码不可能被打印出来。
【回答关键词】 主动承认 · @Data 的 toString() 含密码 · 对比员工端只打 username · 日志的访问控制弱于数据库 + 长期保存 · 修法:只打用户名 / @ToString.Exclude(更稳)
【可能继续追问】 (通常转向 JWT 的技术细节)
追问链 3:JWT 技术细节(5 层)
【面试官问题 1】
JWT 的结构是什么?里面放了什么?
【推荐回答】
JWT 是三段 Base64 用点号连起来:Header.Payload.Signature。
- Header:
{"alg":"HS256","typ":"JWT"}——签名算法和类型; - Payload:我的里面只有两个 claim——
empId和exp。没有iat、没有jti、没有iss、没有aud,也没有任何权限信息(没有role); - Signature:
HMACSHA256(base64(header) + "." + base64(payload), secretKey)。
关键认知是:Payload 只是 Base64 编码,不是加密。 任何人拿到 token 都能解出 empId 和过期时间——所以 JWT 里绝对不能放敏感信息。这也是为什么我说 Redis 用完整 JWT 当键有问题(key 列表会暴露 payload)。
【回答关键词】 Header.Payload.Signature · 只有 empId + exp · Base64 不是加密 · 不放敏感信息 · 不放权限信息
【可能继续追问】 签名是怎么生成的?为什么改不了?
【面试官问题 2】
签名是怎么生成的?为什么说 token 改不了?
【推荐回答】
签名是 HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), 密钥)。
改不了的原因是”雪崩效应”:只要 payload 里任何一位变化,重新计算出的 HMAC 就完全不同。服务端验签时会用自己的密钥重新算一遍,和 token 里带的签名比对——不一致就拒绝。而攻击者没有密钥,算不出正确的签名。
这就是”防篡改”的来源。注意它是防篡改,不是防泄露——payload 谁都能读,只是改不了。
但这里有一个关键前提:密钥必须保密。 而我项目的密钥是硬编码的 itcast / itheima,已经泄露了。所以严格说,“防篡改”这个性质在当前项目里已经被破坏了——唯一还在起作用的是 Redis 白名单(改过的 token 字符串对不上白名单的键)。这一点我必须说清楚,不能声称”JWT 防篡改所以安全”。
【回答关键词】 HMACSHA256 + 密钥 · 雪崩效应 → 改一位签名全变 · 防篡改 ≠ 防泄露 · 但密钥已泄露,防篡改性质已被破坏 · 只剩白名单在兜
【可能继续追问】 那 setClaims 的顺序为什么重要?
【面试官问题 3】
我看你 createJWT 里 setClaims 放在最前面,这个顺序有讲究吗?
【推荐回答】
有,而且是必须的,源码里我也留了注释。
setClaims() 的实现是整体替换 claims 对象(this.claims = claims),不是往现有 map 里 put。所以:
.setClaims(claims) // ① 先设置自定义 claims
.signWith(signatureAlgorithm, secretKey)
.setExpiration(exp) // ③ 再设过期时间如果反过来写:
.setExpiration(exp) // ① 设了 exp
.setClaims(claims) // ② ★ 整体替换 → exp 被彻底丢掉!结果是生成的 JWT 里【没有 exp 字段】,也就是永不过期。 而且这个错误完全静默:token 能正常签发、验签能通过、parseClaimsJws 对没有 exp 的 token 也是放行的。直到有人注意到”这个 token 用了半年还有效”才会发现。
这是一个非常隐蔽的安全坑,我觉得值得主动讲——因为很多用 JJWT 的人不知道 setClaims 是替换而不是合并。
【回答关键词】 setClaims 是整体替换不是合并 · 顺序反了 exp 会静默丢失 · 永不过期的 token 且不报错 · 隐蔽的安全坑
【可能继续追问】 你的 JWT 有没有 jti?为什么需要?
【面试官问题 4】
你的 JWT 里有 jti 吗?为什么需要它?
【推荐回答】
没有,JwtUtil 里完全没有调用 setId()(也没有 setIssuedAt())。所以我的 JWT 只有 empId 和 exp 两个 claim。而 jti 缺失正是我 Redis 键必须用完整 token 的直接原因。
jti 的作用是给每个 token 一个唯一标识(UUID),它能解决三个问题:
第一,让 Redis 键变短且不泄露信息。 现在键是 login:token:{150多个字符的完整JWT},key 列表里能直接读出 payload 的 empId 和过期时间(Base64 不是加密)。有了 jti 就可以写成 login:token:{36字符的UUID}。
第二,为黑名单方案打基础。 黑名单的本质是”记录某个 token 被撤销了”,那就必须有 token 的唯一标识。没有 jti 就只能用完整 token 字符串当键,又长又暴露。
第三,支持”按 Session 管理”。 有了 jti 才能实现”查看我登录了哪些设备、单独踢掉某一台”。
所以加 jti 是一个很小的改动、收益却很明确的优化——但我没做。 因为它会改变 token 的生成逻辑,签发端和校验端都要同步改,而且已签发的旧 token 会全部失效(键的格式变了),需要一次强制重新登录。这类”需要用户重新登录”的改动,我倾向和其他不兼容改动一起做,而不是单独发一次。
【回答关键词】 无 jti/iat · jti 的三个作用(短键/黑名单基础/多设备管理)· 改动小但需强制重登 · 为什么没单独做
【可能继续追问】 那你的 token 是怎么校验的?
【面试官问题 5】
parseJWT 是怎么实现的?有没有什么风险?
【推荐回答】
public static Claims parseJWT(String secretKey, String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token).getBody();
return claims;
}它做两件事:① 用密钥重新计算 HMAC 并和 token 里的签名比对(验签);② 校验 exp 是否过期。任一失败就抛异常(SignatureException / ExpiredJwtException),被拦截器的 catch 捕获后返回 401。
风险在于我用的是 JJWT 0.9.1 的旧 API(Jwts.parser().setSigningKey(byte[])),而这个版本对 alg 头的校验不严格。 这是 JWT 的一个经典攻击面——算法混淆:
- 攻击者把
alg改成none,某些库会跳过验签(JJWT 0.9.1 对此有防护,但旧版本历史上出过问题); - 或者把
alg从 HS256 改成 RS256,诱导服务端用公钥充当 HMAC 的密钥来做对称验签——如果库实现不当,攻击者用公钥就能伪造签名。
加固的做法是:① 升级到 JJWT 0.11+,用 Jwts.parserBuilder().setSigningKey(Keys.hmacShaKeyFor(key));② 显式指定允许的算法,不给服务端”根据 token 的 alg 头自适应”的机会;③ 校验 iss / aud(我的 JWT 里根本没有这两个 claim,所以也没有校验)。
另外还有一个我明确没做的:没有校验 iat(签发时间)。如果密钥泄露后轮换了,攻击者仍能用旧密钥签发的、尚未过期的 token——因为我没有”这个 token 是什么时候签的”这个信息。这也是 jti + 密钥轮换要一起考虑的事。
【回答关键词】 验签 + 验 exp · JJWT 0.9.1 旧 API · 算法混淆风险 · 加固:升级 + 显式限制算法 + 校验 iss/aud · 也没校验 iat
【可能继续追问】 (通常收尾或转向”为什么不用 Spring Security”)
七、M1 容易被质疑的地方(严格视角,不强行合理化)
| # | 问题 | 面试官为什么可能质疑 | 当前代码实际情况 | 我应该怎么解释 | 是否建议修改 |
|---|---|---|---|---|---|
| M1-1 | ★ 管理端登录把明文密码打进日志 | ”日志里能不能看到密码?” | ✅ 成立,可验证。admin/EmployeeController:57 是 log.info("员工登录:{}", employeeLoginDTO),@Data 的 toString() 包含 password。而员工端 user/UserController:60 只打 getUsername() —— 说明员工端有意规避、管理端漏了 | 主动承认。「日志的访问控制通常比数据库弱、会被采集并长期保存,用户又常在多系统复用密码。修法:① 只打用户名;② 更稳的是给 password 加 @ToString.Exclude,从源头让密码不可能被打印。」 | 建议修(优先级高,一行改动,真实泄露) |
| M1-2 | ★ JWT 密钥是硬编码的弱值 | ”密钥怎么管理的?” | ✅ 成立。application.yml 里 admin-secret-key: itcast / user-secret-key: itheima——教学项目默认示例值,明文提交在主配置文件(不是 dev),所有环境共用 | 主动承认。「HS256 是对称加密,密钥泄露即可伪造任意 empId 的合法签名 token。但 Redis 白名单意外提高了门槛——改过的 token 字符串对不上白名单的键,所以还需要”有一个真实存在过的 token 作为载体”。不过这只是提高门槛,密钥泄露本身必须修:① 放环境变量/配置中心;② 用 ≥32 字节的高熵随机串;③ 支持密钥轮换。」 | 建议修(优先级最高) |
| M1-3 | password 为 null 时 NPE → HTTP 500 | ”不传密码会怎样?” | ✅ 成立。checkLogin 里 password.getBytes() 直接解引用;GlobalExceptionHandler 只处理 BaseException 和 SQL 异常,不处理 NPE → HTTP 500 | 「这是”400 的语义返回了 500”。项目没有任何参数校验框架(无 spring-boot-starter-validation、无 @NotBlank/@Valid),全靠手写 if,而登录这里漏了。正确做法是引入 validation:DTO 标 @NotBlank、Controller 加 @Valid、全局处理器捕获 MethodArgumentNotValidException 返回”参数错误」。这样一套机制覆盖所有接口,而不是逐个手写(手写必然有漏,这里就是证明)。」 | 建议修(引入 validation,或至少判空) |
| M1-4 | 登录失败锁定只按用户名,挡不住撞库 | ”攻击者用一万个账号各试一次呢?” | ✅ 成立。计数键是 login:fail:{username},没有 IP 维度、没有全局维度。攻击者每个账号只失败 1 次,所有计数都是 1,完全不触发锁定 | 「按用户名计数能防单账号在线爆破(5 次锁 15 分钟),但挡不住撞库——这是最严重的一类。也没有验证码,所以还能被恶意锁号(对已知存在的账号故意错 5 次就是 15 分钟拒绝服务)。改进方向:IP + 用户名双维度、连续失败后引入验证码、以及渐进式延迟而不是一刀切锁账号。」 | 建议加 IP 维度 + 验证码 |
| M1-5 | Redis 白名单读了值但不校验 | ”你存了 employeeId 这个值,用上了吗?” | ✅ 成立。拦截器 String loginValue = ...get(loginKey); if (loginValue == null) → 401——只判空,从不比对 loginValue 与 JWT 里的 empId | 「正常流程下值必然一致(键和值在同一份数据上写入),所以不校验也不会出错。它的价值是防御异常路径:代码被改动、有人手工操作 Redis、数据错乱时,比对值能立刻发现”会话状态坏了”。现在等于存了值却不用,是”浪费了一半的防御”——要么校验、要么干脆只存 "1"。」 | 建议修(加一行 !loginValue.equals(empId.toString()) 判定) |
| M1-6 | 白名单键用完整 JWT(150-200 字符) | “为什么用整个 token 当 key?“ | ⚠️ 成立。login:token:{完整JWT}。JWT 的 payload 只是 Base64 不是加密,所以 redis-cli --scan 输出的 key 列表直接暴露 empId 和过期时间 | 「三个问题:键太长、payload 暴露(Base64 不是加密)、无法支持多设备管理(键以 token 为维度、不以人为维度,所以做不到”把某人所有会话一起踢下线”)。正确做法是给 JWT 加 jti,键写成 login:token:{jti}——键长降到 36 字符、不暴露 payload、还为黑名单和多会话管理打基础。加 jti 会改变 token 生成逻辑、导致已签发 token 失效,需要强制重新登录,所以我没单独做。」 | 建议加 jti(和其他不兼容改动一起做) |
| M1-7 | 无”按人踢下线”的能力 | ”能把某个人的所有设备都踢掉吗?” | ✅ 成立。键以 token 为维度,同一账号 3 台设备 = 3 个独立键。想按人踢就得遍历 login:token:* 比对值——正是 KEYS 的用法 | 「做不到。“禁用账号”能间接达到效果(拦截器每次查库,status=DISABLE → 401),但那是”封号”、不是”临时踢下线”。要支持得做成双向索引:再加 login:user:{empId} → 该用户所有 jti 的 Set,退一个删一个、踢人删全部。未实现。」 | 可选(取决于业务是否需要) |
| M1-8 | 账号不存在也计数会污染 Redis | ”用随机用户名刷会怎样?“ | ⚠️ 成立。employee == null 时也 incrLoginFail(failKey)。攻击者用随机用户名可批量创建 login:fail:* 键 | 「写这个逻辑的好处是防用户名枚举——如果”不存在就不计数”,攻击者能通过”是否被锁”反推用户名是否存在。但它的代价是攻击者能用不存在的用户名刷 Redis。虽然有 15 分钟 TTL 会清理,但 15 分钟内能塞很多。更糟的是项目没有配置 maxmemory-policy(Redis 默认 noeviction),内存打满时新写入直接失败——那会波及登录态白名单、单号 INCR、防重复提交等所有 Redis 功能。这是一个真实的连带风险。」 | 建议修(maxmemory-policy 必须配) |
| M1-9 | 防枚举只做了一半:错误消息仍泄露账号存在性 | ”提示’账号不存在’和’密码错误’有什么区别?” | ✅ 成立。AccountNotFoundException(“账号不存在”)和 PasswordErrorException(“密码错误”)是分开的提示,直接告诉调用方这个用户名是否存在 | 「计数逻辑防了枚举(不能通过锁定状态反推),但错误消息没防——这两处不一致。规范做法是统一返回”账号或密码错误”,不区分是哪个错了。这样攻击者无法用登录接口验证一个用户名是否存在。」 | 建议统一提示 |
| M1-10 | 密码无盐 MD5 | ”密码怎么存的?” | ✅ 成立。DigestUtils.md5DigestAsHex(password.getBytes()),无盐、无迭代。演示数据三个账号密码都是 123456、哈希完全相同(e10adc...f883e) | 「无盐 MD5 的两个问题:MD5 已彻底不安全(GPU 每秒几十亿次)、无盐导致相同密码同哈希(彩虹表可反查)——演示数据的哈希相同就是这个问题的直接证据。修法是换 BCrypt(随机盐 + 可配成本,“慢”才抗暴力)。存量迁移用双算法并存:按哈希前缀($2a$/$2b$)判断走哪种校验,MD5 校验通过时顺便把密码升级成 BCrypt 存回去,用户无感知。没做。」 | 建议修(BCrypt + 渐进迁移) |
| M1-11 | 无修改密码功能,默认密码硬编码 123456 | ”员工怎么改密码?” | ✅ 成立。全项目无 editPassword/updatePassword 接口;PasswordEditDTO 存在但零引用;PasswordEditFailedException 也是死异常;新建员工密码硬编码为 PasswordConstant.DEFAULT_PASSWORD = "123456" | 「没有改密码接口,所以新建员工的默认密码永远是 123456,改不了。这既是一个功能缺失,也是一个安全缺口(默认弱密码 + 无法修改)。合理的做法是:新员工首次登录强制改密,并且密码用 BCrypt 存。」 | 建议补功能 |
| M1-12 | JWT 用的是 JJWT 0.9.1 旧 API,alg 校验不严 | ”JWT 算法混淆攻击你有防护吗?“ | ⚠️ 成立。Jwts.parser().setSigningKey(byte[]) 是 0.9.x 的旧 API;没有显式限制允许的算法;没有校验 iss/aud/iat | 「JJWT 0.9.1 属于较老版本,没有显式限制 alg。JWT 的经典攻击面是算法混淆(把 alg 改成 none,或把 HS256 换成 RS256 诱导服务端用公钥做 HMAC 密钥)。加固做法:升级到 0.11+、用 parserBuilder().setSigningKey(Keys.hmacShaKeyFor(key))、显式指定只接受 HS256,并校验 iss/aud。这些都没做。」 | 建议升级(配合密钥管理一起改) |
| M1-13 | setClaims 顺序写错会静默产生永不过期的 token | ”如果顺序写反了会怎样?“ | ⚠️ 当前代码顺序是对的(setClaims 在最前,源码注释也解释了)。但这是一个很容易被改错的点——setClaims 是整体替换而非合并 | 「这个顺序必须是 setClaims 在最前,因为它是整体替换 claims 对象。如果先 setExpiration 再 setClaims,exp 会被彻底丢掉,生成的 JWT 永不过期,而且完全不报错。当前实现是对的、注释也写了,但这是一个”以后有人重构时很容易踩”的坑。更稳的写法是用 claim(key, value) 逐个加,而不是 setClaims 整体替换。」 | 可选(建议改成 claim() 逐个设置) |
| M1-14 | 死代码:3 个死异常类 + 2 个零引用成员 | ”这个类/方法是干什么的?” | ✅ 成立。零引用:LoginFailedException、PasswordEditFailedException、UserNotLoginException(异常类);JwtClaimsConstant.USER_ID(常量,员工端已统一用 empId);EmployeeMapper.countByUsername(方法,用户名唯一性靠 DB 唯一索引);PasswordEditDTO(整个类零引用) | 「这些是原项目或改造过程的遗留。UserNotLoginException 尤其有误导性——它看起来像”未登录”的专用异常,但代码里抛的是 BaseException(见 RoleAspect),所以它是死类。应该清理,否则读代码的人会以为存在这套异常。同理 JwtClaimsConstant.USER_ID 会让人以为用户端用 userId——实际员工端已经统一改成 empId。」 | 建议清理(尤其有误导性的死类) |
明确不是问题、但可能被问的点
| 点 | 回答 |
|---|---|
为什么登录/登出接口在 /admin/** 下却不需要角色注解 | 正确。login 被拦截器 excludePathPatterns 豁免(否则会”要先登录才能登录”的死循环);logout 不豁免但也不加 @RequireRole——因为登出只删自己请求头里的那个 token,没有越权危害。而且登出不豁免拦截器是一个正确的安全设计:它保证”传进来的 token 必须是有效登录态”才能删 |
为什么员工端登录路径是 /user/user/login(双 user) | 历史遗留。原项目这里是微信登录(/user/user/login 用 code 换 openid),改造时为了让前端不用改接口路径而保留了原路径(UserController javadoc 明确说明)。功能正确,只是命名不好看 |
| 为什么两端用不同密钥和不同请求头 | 按信任域划分。一端密钥泄露不影响另一端;token 无法跨端冒用。明显比”共用一套密钥 + 靠 claim 区分”更稳 |
| TTL 为什么是 2 小时而不是 30 分钟或 7 天 | 可用性与安全的折中。更短(30 分钟)体验差、用户频繁重登;更长(7 天)泄露窗口太大。2 小时对内部管理系统是常见选择。真正的改进是 Refresh Token(access token 5-15 分钟 + 可撤销的 refresh token),本项目未实现 |
Redis TTL 和 JWT exp 都是 7200000,是有意的吗 | 是有意对齐的。如果 Redis TTL 比 JWT 长 → 留下”JWT 已过期但 key 还在”的垃圾键(无害、浪费内存);如果比 JWT 短 → 出现”JWT 还有效却被 401 拒绝”(用户被提前登出)。对齐是正确的做法 |
| 登录成功后清空失败计数,顺序对不对 | 顺序是正确的。delete 在密码校验通过且账号状态校验通过之后(第 109 行在 104-106 行的状态检查之后)。所以密码正确但账号被禁用时不会清空计数——这是对的,因为那次登录并没有真正成功 |
| 前端登出接入了吗 | 接入了。admin-ui/console.js:207 和 staff-ui/app.js:223 都在 doLogout 里 await 登出请求,并且用 try/catch 包住、失败也继续清本地状态(写法正确)。旧文档说”前端未接入”是修复前的状态 |
| 为什么不用 Spring Security | 见 M4 的追问链 3 第 4 层。核心是:它的强项(会话管理、CSRF、表单登录、UserDetailsService)本项目一个都用不上;而它有两个独立的认证链要配,复杂度明显上升。唯一真正想要的是 BCryptPasswordEncoder——那个可以单独引入 crypto 模块。 |
八、M1 关键数字与事实速查(面试前扫一眼)
| 事实 | 值 / 位置 |
|---|---|
| 端 | 2 个:管理端 /admin/**(头 token)、员工端 /user/**(头 authentication) |
| 密钥 | admin-secret-key: itcast、user-secret-key: itheima——弱值 + 硬编码在主配置里 + 全环境共用 ⚠️ |
| TTL | 都是 7200000 ms = 2 小时(无滑动续期) |
| JWT 内容 | 只有 empId 和 exp;无 role、无 jti、无 iat、无 iss/aud ✅(纯身份凭证,权限每次查库) |
| 算法 | HS256(JwtUtil,JJWT 0.9.1 旧 API) |
| 白名单 | login:token:{完整JWT} → empId,TTL = JWT 的 exp(严格对齐) |
| 白名单失败行为 | fail-close(Redis 挂 → 全部 401)——安全优先,是有意的 |
| 登录校验 5 步 | ① 查失败计数 → ② 查库 → ③ MD5 比对 → ④ 状态校验 → ⑤ 清计数 |
| 失败锁定 | login:fail:{username},INCR,阈值 5 次,TTL 15 分钟且只在 INCR 返回 1 时设置(防 TTL 被无限刷新)· 达阈值不查库直接拒 |
| 密码 | 无盐 MD5(DigestUtils.md5DigestAsHex)· 默认密码硬编码 123456 · 无修改密码接口 |
| 端隔离 | EmployeeServiceImpl.login:53 拒绝 EMPLOYEE 登录管理端(复用 AccountLockedException,语义不匹配) |
| 拦截器豁免 | /admin/employee/login、/user/user/login、/user/shop/status |
| 每次请求开销 | 2 次 Redis(白名单)+ 1 次 MySQL(拿实时 role/status) |
| ThreadLocal | BaseContext 两个(id + role),afterCompletion 调 clear()(原项目漏了调用) |
| 失效的 4 个入口 | 登出(立即)· 禁用账号(下次请求)· 删除员工(下次请求)· TTL 到期(2 小时) |
| 无法做到 | 按人踢掉所有设备(键以 token 为维度,无双向索引) |
| 已知缺口 | ① 明文密码进日志 ② 密钥弱且硬编码 ③ 密码为 null → NPE/500 ④ 只按用户名限流、挡不住撞库、可被恶意锁号 ⑤ loginValue 读了不校验 ⑥ 键用完整 JWT(暴露 payload、无法多设备管理)⑦ 账号不存在也计数会污染 Redis(且无 maxmemory-policy)⑧ 错误消息泄露账号存在性 ⑨ 无盐 MD5 ⑩ 无改密接口 ⑪ JJWT 0.9.1 无 alg 限制 ⑫ 死代码(3 个死异常 + USER_ID + countByUsername + PasswordEditDTO) |
| 回归验证 | regress_api_test.sh 第 2 节(员工不能登管理端)· 第 5 节(未登录 401)· 第 14 节(员工 token 调管理端 401) |
| 未实现 | Refresh Token · jti · 黑名单 · 多设备管理 · IP 维度限流 · 验证码 · BCrypt · 参数校验框架 · Spring Security |
九、如果只给我 3 分钟讲 M1
设计:双端独立的 JWT + Redis 白名单。管理端和员工端两套密钥、两个请求头(
itcast+token/itheima+authentication),共用一张employee表。JWT 的重要内容:claims 里只放
empId,不放任何权限信息——role和status每次请求从数据库读。所以 JWT 是”纯身份凭证”,这是为了降权和禁用能立即生效(否则凭 JWT 不可撤销的性质,降权后旧 token 还能用 2 小时)。为什么还要 Redis 白名单:纯 JWT 无法撤销——用户点退出只是前端删掉本地那份,服务端拦不住。所以登录时把 token 写进 Redis(TTL 与 JWT 的
exp严格对齐),每个请求校验一次,登出就删。我选白名单而不是黑名单,因为白名单是 fail-close(Redis 挂 → 全部 401,安全),黑名单是 fail-open(Redis 挂 → 被撤销的 token 全复活,安全问题)。 代价是用了一点服务端状态,但因为它只存”token → empId”这个映射、不承载业务数据,所以没有背回 Session 的全部成本。登录失败防护:
login:fail:{username}计数,5 次锁 15 分钟,并且只在INCR返回 1 时设 TTL——如果每次都设,攻击者持续尝试会无限刷新 TTL、把账号永久锁死。我知道的问题,必须主动说:
- 管理端登录把明文密码打进了日志——
log.info("员工登录:{}", dto),而@Data的toString()含 password。员工端我写的是只打 username,管理端这处漏了。修法是给 password 加@ToString.Exclude;- JWT 密钥是硬编码的弱值
itcast/itheima,明文提交在主配置里、全环境共用。HS256 是对称加密,密钥泄露即可伪造 token——只是 Redis 白名单(键是完整 token 字符串)意外提高了门槛,改过的 token 对不上键。但密钥管理本身必须修:环境变量 + 高熵 + 支持轮换;password为 null 会直接 NPE 返回 500——项目没有参数校验框架,全靠手写if,登录这里漏了;- 限流只按用户名、没有 IP 维度,所以挡不住撞库(一万个账号各试一次,每个计数都是 1);而且可以被用来恶意锁号(对已知账号故意错 5 次就是 15 分钟拒绝服务);
- 密码是无盐 MD5,演示数据里三个账号哈希完全相同就是证据。要换 BCrypt,并且用”按哈希前缀判断 + 登录时升级”的方式做存量迁移;
- 另外:白名单键用了完整 JWT(payload 是 Base64 不是加密,key 列表会暴露
empId;而且做不到”把某人所有设备一起踢下线”)——修法是加jti;loginValue读了但从不校验(存了值却不用,浪费了一半防御)。