04 · 认证与会话:登录、Token、Shiro、Redis
04 · 认证与会话:登录、Token、Shiro、Redis
这是本项目的核心,也是后端面试高频考点。我们顺着「登录 → 后续请求 → 鉴权」一步步看,先讲清楚概念,再对照真实代码,最后给坑和练习。
4.1 两个常被搞混的概念:认证 vs 授权
很多初学者把这两个词当成一回事,但它们是两件不同的事:
| 概念 | 英文 | 要回答的问题 | 本项目在哪里发生 |
|---|---|---|---|
| 认证 | Authentication | 你是谁?(证明身份) | 登录、BearerToken 校验 |
| 授权 | Authorization | 你能做什么?(权限校验) | PermissionGuard / doGetAuthorizationInfo |
一句话记忆:认证是进门刷工牌(你是员工),授权是看工牌上的权限级别(你能否进机房)。
本项目的一个设计选择:用 Shiro 做「认证框架」,但把细粒度「授权」放到自己的 PermissionGuard 里做(见 4.10)。这样既借用了 Shiro 成熟的认证流程,又让权限校验更直观可控。
4.2 整体流程(先看全局)
登录 POST /api/auth/login
→ AuthController.login() (controller/AuthController.java:28)
→ AuthService.login() 校验用户名密码 (service/AuthService.java:32)
→ 生成 Token(随机 UUID)
→ 把 LoginSession(含权限)写入 Redis
→ 返回 Token 给前端
后续请求 GET /api/users
→ 前端在 Header 带 Authorization: Bearer <token>
→ BearerSessionFilter 拦截,从 Redis 取出 LoginSession 校验
→ 绑定到 SessionContext(ThreadLocal)
→ PermissionGuard 做权限判断
→ 执行业务逻辑为什么用 Redis 存会话?因为后端可能有多实例(多个容器 / 多台服务器),会话放在共享的 Redis 里,任意实例都能识别同一个 Token —— 这就是「无状态服务 + 集中式会话」的典型组合。
┌──────────┐ 登录 ┌─────────────┐
前端 ─────▶│ 实例 A │──────────▶│ Redis │ uas:session:<token>
└──────────┘ │ (共享会话) │
┌──────────┐ 请求 │ │
前端 ─────▶│ 实例 B │──────────▶│ │
└──────────┘ └─────────────┘
两个实例都能识别同一个 token4.3 Shiro 架构速成(五分钟入门)
Apache Shiro 是 Java 里最经典的认证/授权框架之一。理解下面 5 个名词,后面代码就通顺了:
- SecurityManager:Shiro 的「总指挥」,所有安全操作都经它调度。本项目把它配成 Spring Bean(
config/ShiroConfig.java:13)。 - Realm:「 realm = 领域/数据源」。Shiro 自己不管用户名密码存在哪,它问
Realm。本项目只有 一个ShiroUserRealm。 - Subject:「当前用户」的抽象对象。你在代码里几乎只跟
Subject打交道(subject.login(...)、subject.getPrincipal())。 - Principal:「身份主体」,即「你是谁」的代表。可以是 userId,也可以是整个
LoginSession对象。 - Token:交给 Shiro 去认证的「凭证」,如
UsernamePasswordToken(账号密码)、本项目自定义的BearerToken(令牌)。
ShiroUserRealm 继承 AuthorizingRealm(security/ShiroUserRealm.java:22),而 AuthorizingRealm 同时承担「认证 + 授权」两件事:
// security/ShiroUserRealm.java
public class ShiroUserRealm extends AuthorizingRealm {
// 决定本 Realm 能处理哪些 Token
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof UsernamePasswordToken || token instanceof BearerToken;
}
// 授权:回答"这个用户有哪些权限"
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { ... }
// 认证:回答"这个 Token 合法吗"
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { ... }
}doGetAuthenticationInfo(认证)和 doGetAuthorizationInfo(授权)就是 AuthorizingRealm 留给子类实现的两个钩子,supports() 则声明「我只认账密 Token 和 Bearer Token 两种」。
4.4 登录流程逐行走读:AuthService.login
AuthController.login 只是薄薄一层转发(controller/AuthController.java:28),真正逻辑在 AuthService.login:
// service/AuthService.java:32
public AuthResponse login(LoginRequest request) {
Subject subject = new Subject.Builder(securityManager).buildSubject(); // ① 造一个空的 Subject
try {
subject.login(new UsernamePasswordToken(request.username(), request.password())); // ② 登录
} catch (AuthenticationException ex) {
throw new BadRequestException("用户名或密码错误"); // ③ 失败:统一对外口径,不暴露细节
}
UserResponse user = userService.getUserByUsername(request.username()); // ④ 取用户信息
List<String> permissions = userService.listPermissionCodes(user.id()); // ⑤ 取权限列表
LoginSession session = loginSessionService.create(user.id(), user.username(), permissions); // ⑥ 建会话写 Redis
return toAuthResponse(session, user, permissions); // ⑦ 返回 token
}② 这一步 Shiro 内部做了什么? 顺着调用链理解:
subject.login(token)
→ SecurityManager 接手 (config/ShiroConfig.java:13 注入)
→ 找到 supports(token)==true 的 Realm → ShiroUserRealm
→ 调用 doGetAuthenticationInfo(token)
→ authenticateByPassword() 查库 + 比对密码哈希
→ 成功:把 Principal(=userId) 装进 Subject
→ 失败:抛出 AuthenticationException(被 ③ 捕获)注意 ③ 的安全细节:无论「用户不存在」还是「密码错」,对外都只报「用户名或密码错误」。这能避免攻击者用返回差异去试探「哪个用户名存在」。
SecurityManager 哪来的? AuthService 通过构造器注入(AuthService.java:26-30),而它又是 ShiroConfig 里定义的 Bean(config/ShiroConfig.java:13-17)。Spring 启动时把 Shiro 的 DefaultSecurityManager 创建好、塞进 ShiroUserRealm,再注入到各个 Service / Filter。这就是「Shiro 与 Spring Boot 整合」的粘合点。
⑥ 登录成功后为什么还要自己写一份 Redis 会话? 因为 Shiro 原生的 Subject 只活在当次调用里,登录完就没了。本项目要的是「跨请求的持久会话」,所以登录成功后立刻用 loginSessionService.create(...) 生成 token 并落 Redis(见 4.8)。
扩展了解:Shiro 其实自带会话管理(
DefaultWebSessionManager+SessionDAO),也能把会话存 Redis。本项目没有用 Shiro 的会话,而是自己用StringRedisTemplate实现了一套更简单的「Bearer Token ↔ Redis」会话,逻辑更透明、更适合教学。
4.5 Realm 里的两套认证逻辑
ShiroUserRealm.doGetAuthenticationInfo 根据 Token 类型分两条路(security/ShiroUserRealm.java:55-84):
路径 A:账号密码登录(authenticateByPassword)
// security/ShiroUserRealm.java:65
private AuthenticationInfo authenticateByPassword(UsernamePasswordToken token) {
String username = token.getUsername();
SysUser user = userService.findByUsername(username);
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) {
throw new AuthenticationException("用户名或密码错误"); // 用户不存在 / 被禁用
}
String rawPassword = new String(token.getPassword());
if (!passwordHasher.matches(rawPassword, user.getPasswordHash())) {
throw new AuthenticationException("用户名或密码错误"); // 密码不对
}
return new SimpleAuthenticationInfo(user.getId(), token.getCredentials(), getName()); // Principal = userId
}返回的 SimpleAuthenticationInfo 第一个参数是 Principal,这里是 user.getId()(Long 型)。注意此处把「禁用账号」和「密码错」都归一成同一句话,同样是为了不泄露信息。
路径 B:令牌登录(authenticateBySession)
// security/ShiroUserRealm.java:78
private AuthenticationInfo authenticateBySession(BearerToken token) {
Optional<LoginSession> session = loginSessionService.validateAndTouch(token.getToken());
if (session.isEmpty()) {
throw new AuthenticationException("登录会话已失效,请重新登录");
}
return new SimpleAuthenticationInfo(session.get(), token.getToken(), getName()); // Principal = 整个 LoginSession
}这里 Principal 升级成了整个 LoginSession 对象(后面 BearerSessionFilter 直接强转拿它,见 4.7)。而 validateAndTouch 就是「校验 + 顺延有效期」的核心(见 4.8)。
授权钩子 doGetAuthorizationInfo
// security/ShiroUserRealm.java:42
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
Object primaryPrincipal = principals.getPrimaryPrincipal();
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
if (primaryPrincipal instanceof LoginSession session) {
info.addStringPermissions(session.permissions()); // 已经快照了权限,直接用
return info;
}
Long userId = (Long) primaryPrincipal;
info.addStringPermissions(userService.listPermissionCodes(userId)); // 账密登录场景,现查
return info;
}这段体现一个细节:Bearer Token 登录时 Principal 是 LoginSession,权限已在登录时快照;而账密登录时 Principal 只是 userId,权限要现查。 本项目实际权限判断走 PermissionGuard(4.10),这个钩子更多是为「万一将来用 Shiro 注解 @RequiresPermissions」留的后路。
4.6 BearerToken:一个极简的自定义 Token
BearerToken 只是实现 Shiro 的 AuthenticationToken 接口,把字符串 token 包了一层(security/BearerToken.java):
public class BearerToken implements AuthenticationToken {
private final String token;
public BearerToken(String token) { this.token = token; }
public String getToken() { return token; }
@Override public Object getPrincipal() { return token; } // 身份 = token 本身
@Override public Object getCredentials() { return token; } // 凭证 = token 本身
}它把「token 字符串」同时当成 principal 和 credentials 交给 Shiro;真正拿它去换 LoginSession 的是 ShiroUserRealm.authenticateBySession。这种「token 即凭证」的设计在「引用型令牌(reference token)」里很常见:服务端不解码 token,而是拿它去存储里查。
4.7 BearerSessionFilter:每个请求的第一道关卡
这是「后续请求」的入口关卡,继承 OncePerRequestFilter(保证每次请求只过一次)。
哪些请求要放行?
// security/BearerSessionFilter.java:36
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return "OPTIONS".equalsIgnoreCase(request.getMethod()) // 预检请求
|| path.equals("/api/health")
|| path.equals("/api/auth/login")
|| path.equals("/api/auth/register")
|| path.startsWith("/v3/api-docs")
|| path.startsWith("/swagger-ui")
|| path.equals("/swagger-ui.html");
}登录、注册、健康检查、Swagger 文档都不拦截——否则没人能登录了。
过滤核心逻辑
// security/BearerSessionFilter.java:48
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
ThreadState threadState = null;
try {
Subject subject = new Subject.Builder(securityManager).buildSubject();
subject.login(new BearerToken(resolveToken(request))); // ① 用 Bearer 令牌登录
LoginSession session = (LoginSession) subject.getPrincipal(); // ② 拿到会话(Realm 里塞的)
threadState = new SubjectThreadState(subject);
threadState.bind();
SessionContext.set(session); // ③ 绑到当前线程
filterChain.doFilter(request, response); // ④ 放行到业务
} catch (AuthenticationException ex) {
writeUnauthorized(response); // ⑤ 失败返回 401 JSON
} finally {
SessionContext.clear(); // ⑥ 无论如何清空线程变量
if (threadState != null) {
threadState.clear();
}
}
}① resolveToken 怎么解析?
// security/BearerSessionFilter.java:70
private String resolveToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
return authorization.substring(7).trim(); // "Bearer " 正好是 7 个字符,去掉前缀
}
return null; // 没带 → 返回 null → 后面 login 失败 → 401
}③ ThreadLocal 是什么,为什么要 finally 里 clear?
SessionContext 内部是一个 ThreadLocal(session/SessionContext.java:5):
public final class SessionContext {
private static final ThreadLocal<LoginSession> CURRENT = new ThreadLocal<>();
public static void set(LoginSession session) { CURRENT.set(session); }
public static LoginSession current() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}ThreadLocal 让每个线程有自己独立的「当前会话」副本。Web 服务器(Tomcat)用的是线程池:一个请求用完线程后,线程会被回收去处理下一个请求。如果不 clear(),下一个请求可能读到上一个用户留下的 LoginSession——这就是线程安全问题,严重时等于「越权顶替他人身份」。所以必须在 finally 里 SessionContext.clear(),确保线程归还池子前被擦干净。同理 SubjectThreadState.clear() 也是擦 Shiro 自己的线程状态。
扩展了解:Spring 的
@Async、线程池、定时任务里如果用ThreadLocal,也会遇到「值传不到子线程」的问题。需要跨线程传递时要用InheritableThreadLocal或阿里 TransmittableThreadLocal,但这超出本项目范围。
4.8 LoginSession:登录后的「身份快照」
会话对象是一个 record(不可变值对象),字段一览(session/LoginSession.java):
public record LoginSession(
String token, // 令牌,也是 Redis key 的一部分
Long userId, // 用户主键
String username, // 用户名(便于排查/日志)
List<String> permissions,// 权限列表(登录时快照!)
LocalDateTime createdAt, // 登录时间
LocalDateTime lastAccessAt, // 上次访问时间(用于空闲超时)
LocalDateTime expiresAt // 绝对过期时间
) {
public LoginSession touch(LocalDateTime now) { // 不可变对象:更新时返回新对象
return new LoginSession(token, userId, username, permissions, createdAt, now, expiresAt);
}
}为什么权限要「快照」在登录时刻? 因为 token 期间如果管理员改了你的权限,本项目不会立刻生效——要等下次登录重新生成会话。这是「服务端存储会话」的一个固有特性:权限是登录时拍的快照。优点是实现简单、行为可预期;缺点是「权限即时回收」做不到。若要做到即时回收,需要每次请求都去查最新权限(性能损耗),或主动踢掉会话(logout/delete)。
4.9 LoginSessionService:Redis 里的会话存储与续期
写入(create)
// service/LoginSessionService.java:34
public LoginSession create(Long userId, String username, List<String> permissions) {
SysSessionSetting setting = sessionSettingService.requireSetting();
LocalDateTime now = LocalDateTime.now();
String token = UUID.randomUUID().toString().replace("-", ""); // 128 位随机,几乎不可猜
LocalDateTime expiresAt = now.plusMinutes(setting.getAbsoluteTimeoutMinutes()); // 默认 +480 分钟
LoginSession session = new LoginSession(token, userId, username, permissions, now, now, expiresAt);
save(session, Duration.between(now, expiresAt)); // TTL = 绝对有效期
return session;
}- Key:
uas:session:+ token(KEY_PREFIX = "uas:session:",LoginSessionService.java:20)。 - Value:
LoginSession经ObjectMapper序列化成 JSON。 - TTL:直接等于「绝对有效期」,所以 Redis 侧的过期和
expiresAt是一致的——两条防线对齐,不会 Redis 里还活着但业务认为过期。
校验与续期(validateAndTouch)—— 重点
这是每个请求都会调到的核心。逐行解读 if 条件:
// service/LoginSessionService.java:44
public Optional<LoginSession> validateAndTouch(String token) {
if (token == null || token.isBlank()) {
return Optional.empty(); // 防御:没 token 直接失败
}
LoginSession session = get(token).orElse(null);
if (session == null) {
return Optional.empty(); // Redis 里查不到(可能已过期/被踢)
}
LocalDateTime now = LocalDateTime.now();
SysSessionSetting setting = sessionSettingService.requireSetting();
// 条件一:绝对过期 —— 超过了 expiresAt 就死
if (!session.expiresAt().isAfter(now)) {
delete(token);
return Optional.empty();
}
// 条件二:空闲过期 —— 距上次访问超过 idleTimeout 就死(默认 30 分钟)
if (session.lastAccessAt().plusMinutes(setting.getIdleTimeoutMinutes()).isBefore(now)) {
delete(token);
return Optional.empty();
}
LoginSession touched = session.touch(now); // 更新 lastAccessAt = now
save(touched, Duration.between(now, session.expiresAt())); // 顺延 Redis TTL 到 expiresAt
return Optional.of(touched);
}两种超时含义不同,初学者容易混:
- 绝对超时(absoluteTimeoutMinutes=480):从登录那一刻起,最多活 8 小时,到点必死——防止令牌永久有效。
- 空闲超时(idleTimeoutMinutes=30):连续 30 分钟没任何请求,会话作废——防止「人走了电脑没关」被别人乱用。
续期逻辑 save(touched, Duration.between(now, session.expiresAt())):每次成功访问都把 Redis TTL 重新刷到绝对过期时刻。注意它不会延长 expiresAt 本身,所以「空闲续期」只是把 TTL 往绝对终点拉,不会让会话突破 8 小时上限。这正是「绝对超时 + 空闲超时」双保险的正确实现。
思考:
idleExpiresAt怎么算?见LoginSessionService.java:75:public LocalDateTime idleExpiresAt(LoginSession session) { SysSessionSetting setting = sessionSettingService.requireSetting(); LocalDateTime idleExpiresAt = session.lastAccessAt().plusMinutes(setting.getIdleTimeoutMinutes()); return idleExpiresAt.isBefore(session.expiresAt()) ? idleExpiresAt : session.expiresAt(); }取
min(空闲过期时刻, 绝对过期时刻):如果绝对过期更近,以绝对为准。这个值会返回给前端(见AuthService.toAuthResponse),前端可以据此决定何时弹「即将过期」提示。
get / save 的细节
// service/LoginSessionService.java:94
private void save(LoginSession session, Duration ttl) {
if (ttl.isNegative() || ttl.isZero()) {
delete(session.token()); // TTL 非正说明已过期,直接删
return;
}
redisTemplate.opsForValue().set(key(session.token()),
objectMapper.writeValueAsString(session), ttl);
}get(:81)从 Redis 取 JSON 反序列化;如果 JSON 损坏会抛 JsonProcessingException,此时直接 delete(token) 并视为无效——避免脏数据卡住认证。
4.10 授权:PermissionGuard 如何判断「你能做什么」
前面说过本项目把细粒度权限判断放在自己的 PermissionGuard(service/PermissionGuard.java):
@Component
public class PermissionGuard {
public void require(String permission) {
LoginSession session = SessionContext.current(); // 从线程里取当前会话
if (session == null || !session.permissions().contains(permission)) {
throw new BadRequestException("无权执行该操作");
}
}
public void requireAny(String... permissions) {
LoginSession session = SessionContext.current();
if (session == null || Arrays.stream(permissions).noneMatch(session.permissions()::contains)) {
throw new BadRequestException("无权执行该操作");
}
}
}关键点:它读的是 SessionContext.current() 里的 LoginSession,而这个 LoginSession 正是 BearerSessionFilter 在 ③ 里 SessionContext.set(session) 放进去的。一条完整的链路就串起来了:
BearerSessionFilter.set(session)
→ 业务方法里 PermissionGuard.require("session:manage")
→ SessionContext.current().permissions().contains(...)SessionSettingController.updateSetting 就用它来守门(controller/SessionSettingController.java:35-38):
@PutMapping
public SessionSettingResponse updateSetting(@Valid @RequestBody SessionSettingRequest request) {
requirePermission("session:manage"); // 没有这个权限就抛异常
return sessionSettingService.updateSetting(request);
}注意它不依赖 Shiro 的 @RequiresPermissions 注解,而是显式调用。好处是逻辑一目了然、异常可控、好调试;代价是要记得在每个需要权限的地方手动写 require(...)。
4.11 会话策略可配置:SessionSetting
会话时长不是写死的,存在 sys_session_setting 表(entity/SysSessionSetting.java 对应表 sys_session_setting),且固定只用 id=1 这一行(SessionSettingService.java:16):
private static final long SETTING_ID = 1L;requireSetting()(:29)有个「懒初始化」巧思:如果表里没有这行,就用默认值(绝对 480 / 空闲 30 分钟)自动插入一行,避免首次启动报错。
更新接口有业务约束(SessionSettingService.java:43-46):
public SessionSettingResponse updateSetting(SessionSettingRequest request) {
if (request.idleTimeoutMinutes() > request.absoluteTimeoutMinutes()) {
throw new BadRequestException("不活跃时间阈值不能大于登录会话有效期");
}
...
}为什么 idle 必须 ≤ absolute? 如果空闲超时比绝对超时还长,那「绝对超时」反而先到,空闲超时就永远没机会触发,逻辑矛盾。这个约束保证了两个超时语义自洽。
4.12 密码哈希:SHA-256 与更安全的方案
PasswordHasher 用 JDK 自带的 MessageDigest 算 SHA-256 并输出十六进制(service/PasswordHasher.java):
// service/PasswordHasher.java:13
public String hash(String rawPassword) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is not available", ex);
}
}
// service/PasswordHasher.java:23
public boolean matches(String rawPassword, String expectedHash) {
return MessageDigest.isEqual(hash(rawPassword).getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8));
}两个安全细节值得讲:
matches用MessageDigest.isEqual而不是String.equals。原因是String.equals是「短路比较」,一旦发现某字节不同就提前返回——攻击者可通过计时差异(timing attack)推测正确前缀。MessageDigest.isEqual是恒定时间比较:无论第几个字节不同,都走完全程再返回,杜绝这类侧信道攻击。这是密码比较的规范做法。- 但 SHA-256 本身不适合存密码。它快、且无盐(salt),意味着:
- 相同密码 → 相同哈希,攻击者拿「彩虹表」一查就中;
- 算得快 → 暴力枚举每秒上亿次也不在话下。
生产级方案:用 BCrypt / Argon2 / PBKDF2,它们自带随机盐、且故意算得慢。以 BCrypt 为例的代码草图(替换 PasswordHasher):
// 扩展了解:BCrypt 草图(需引入 org.springframework.security:spring-security-crypto)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // strength=12,成本因子
String hash = encoder.encode(rawPassword); // 自动生成随机盐并拼进结果
boolean ok = encoder.matches(rawPassword, hash); // 自动取盐比对,恒定时间BCrypt 的哈希里自带盐(形如 $2a$12$<salt><hash>),同密码每次结果都不同,且 matches 同样是恒定时间比较。Argon2 则更进一步,对 GPU/ASIC 并行破解更有抵抗力,是当下 OWASP 首推。
4.13 登录 / 注册 / 退出 三个接口一览
AuthController 暴露 4 个端点(controller/AuthController.java):
| 端点 | 作用 | 关键调用 |
|---|---|---|
POST /api/auth/login | 登录 | AuthService.login |
POST /api/auth/register | 注册并直接登录 | AuthService.register(:46 注册用户后同样 create 会话) |
GET /api/auth/me | 当前用户 | AuthService.current(:58,从 SessionContext 取会话返回用户信息+过期时间) |
POST /api/auth/logout | 退出 | AuthService.logout(:65) |
退出登录做了什么?
// service/AuthService.java:65
public void logout() {
LoginSession session = SessionContext.current();
if (session != null) {
loginSessionService.delete(session.token()); // 把 Redis 里的会话删掉
}
}注意:登出是服务端把会话销毁,下次再拿旧 token 访问,validateAndTouch 就会因为 Redis 查不到而返回 401。这比「只删前端 token」安全得多——因为旧 token 在 Redis 里已失效,就算被人截获也无法再用。
4.14 扩展了解:本项目会话 vs JWT
本项目是「引用型令牌(reference token)」——token 本身无意义,靠 Redis 查详情。它的对立面是 JWT(JSON Web Token,自包含令牌)。对比:
| 维度 | 本项目(Redis 会话) | JWT |
|---|---|---|
| token 内容 | 随机 UUID,无含义 | 自带 userId/权限/过期,Base64 可解码 |
| 校验方式 | 查 Redis | 验签名(服务端不存) |
| 吊销/踢人 | 删 Redis 即可,立刻生效 | 难!只能等过期,或维护黑名单 |
| 权限实时变更 | 需重新登录(快照) | 同样需重新签发 |
| 多实例 | 依赖共享 Redis | 无需共享存储,纯无状态 |
| 存储压力 | Redis 存会话 | 服务端零存储 |
结论:简单、可控、好吊销 → 选本项目这种;追求纯无状态、不想依赖 Redis、且无强吊销需求 → 选 JWT。两者也可混用:用 JWT 做访问令牌,再用 Redis 存「吊销列表」做折中。
刷新令牌(refresh token) 是另一常见扩展:访问令牌短命(如 15 分钟),另发一个长命刷新令牌;访问令牌过期后,用刷新令牌换新的,避免频繁输密码。本项目目前没有,可作为练习(见 4.16)。
4.15 常见坑(必看)
Token 别放 URL 里。URL 会出现在浏览器历史、服务器日志、Referer 头里,令牌泄露风险极高。本项目用
Authorization: Bearer请求头,是正确的。永远不要把令牌拼在查询参数上。ThreadLocal 不清理 = 越权。忘记在
finally调SessionContext.clear(),线程池复用时会串号(见 4.7)。这是本项目专门在finally处理的原因。登出只清前端不清后端。
localStorage.removeItem('token')不够——服务端 Redis 里那条还在,旧 token 仍可用。本项目logout()显式delete,是正确做法。会话固定(session fixation)。经典攻击:攻击者先拿到一个会话 ID 骗用户登录,登录后还是同一 ID,攻击者即可冒用。本项目每次登录都
UUID.randomUUID()全新生成 token,天然规避了这个问题。CSRF(跨站请求伪造)。基于 Cookie 的会话才会中招(浏览器自动带 Cookie)。本项目用 Bearer 请求头,前端需手动加,浏览器不会自动发,所以 CSRF 不适用——但若以后改用 Cookie 存 token,就必须上 CSRF 防护(如 sameSite=strict + CSRF token)。
密码错误信息差异。本项目把「用户不存在」和「密码错」归一成一句,避免账号枚举。若你以后改代码,别把它们分成两种报错。
空闲超时 UX。用户填了半天表单,30 分钟没动就被踢,体验差。前端可用
idleExpiresAt(AuthService.current返回)做「即将过期请续期」提示。Redis 宕机 = 全站无法认证。因为会话全在 Redis。可用 Redis 哨兵/集群、或本地兜底缓存来缓解;但真挂了,已登录用户确实会 401(这是集中式会话的代价)。
expiresAt与 Redis TTL 要一致。save()的 TTL 直接取Duration.between(now, expiresAt),保证两边同生同死;若你改逻辑让两者不一致,会出现「业务认为有效但 Redis 已删」或反之的诡异 bug。
4.16 练习(分难度)
入门
- 用浏览器 / Postman 调
POST /api/auth/login,观察响应里的token;再带着Authorization: Bearer <token>调GET /api/auth/me,确认能拿到用户信息。 - 调一次
POST /api/auth/logout,再用旧 token 调任意接口,确认返回 401。 - 不带头调
GET /api/users(假设存在),确认被BearerSessionFilter拦下返回 401。
进阶(跟踪调用链)
- 画一张时序图,完整描述一个已登录请求从进入
BearerSessionFilter→ShiroUserRealm.authenticateBySession→LoginSessionService.validateAndTouch→SessionContext.set→PermissionGuard.require→ 业务方法的全链路,标出每一步读/写 Redis 的时机。 - 在
validateAndTouch的两个if处各加一行日志,分别用「等 31 分钟不请求」和「等 8 小时以上」两种手法触发,观察哪种先失效,验证「双超时」逻辑。 - 把
sys_session_setting的idleTimeoutMinutes改成比absoluteTimeoutMinutes大,调PUT /api/session-settings(不带session:manage权限 / 带权限两种情况),观察报错。
实战改造
- 实现刷新令牌:新增
RefreshToken概念——登录时额外发一个长命(如 7 天)的 refresh token 存 Redis;POST /api/auth/refresh用旧 refresh token 换新的 access token。写出LoginSessionService需要新增的方法签名与 Redis key 设计。 - 把
PasswordHasher换成 BCrypt(见 4.12 草图):引入spring-security-crypto,改造hash/matches,并写一个一次性脚本把sys_user.password_hash里现有 SHA-256 值迁移成 BCrypt(提示:旧值可先识别前缀再逐个重算)。 - 权限即时回收:在
validateAndTouch里额外调userService.listPermissionCodes(userId),与快照对比,若不一致则删除旧会话并抛「权限已变更,请重新登录」。评估此举对 Redis 压力的影响。 - 单设备登录:登录时把
userId → token也写一份 Redis;新登录时先删掉旧 token,实现「后登录踢掉前登录」。写出 key 设计与create的改动。
4.17 一句话总结
登录 = Shiro 认证账号密码 → 生成 UUID 令牌 → 把 LoginSession(含权限快照)写进 Redis;后续请求 = BearerSessionFilter 用令牌换会话、绑到 ThreadLocal、由 PermissionGuard 做权限判断;双超时(绝对 480 / 空闲 30 分钟)+ Redis TTL 共同保证会话安全可控。