第七章 · 登录全流程(system 认证垂直切片)
前面五章是”地基”,从这一章开始”盖房子”。我们以最经典的账号密码登录为主轴,把从
AuthController一路到 Redis 里落下一个 token 的完整链路逐层拆开。它是连接 第四章 security 与 第六章 多租户/Redis 的”活例题”——读完你就能自己走通一条真实请求。
1. 模块概述
登录横跨三个模块,分工如下:
- **
AuthController**(表现层):暴露/admin-api/system/auth/*接口,接收前端 JSON。 - **
AdminAuthServiceImpl**(应用层):编排登录步骤——校验验证码 → 校验账号密码 → 记登录日志 → 签发 token。 OAuth2TokenServiceImpl(基础设施层):真正生成accessToken + refreshToken,MySQL 落库 + Redis 缓存。
📌 关键认知:登录并没有用 Spring Security 的 Filter 那一套,而是完全自研的
AuthController + Service(源码注释明说:多用户、多登录方式在 Spring Security 里拓展太复杂)。Spring Security 只负责登录之后的”权限判定”。
graph LR
subgraph Controller[表示层]
AC[AuthController /system/auth/*]
CAP[CaptchaController 滑块验证码]
end
subgraph Service[应用层]
AIS[AdminAuthServiceImpl]
LOG[LoginLogService 登录日志]
USER2[AdminUserService 用户]
end
subgraph Token[基础设施层]
OTS[OAuth2TokenServiceImpl]
REDIS[(Redis 缓存 token)]
MYSQL[(MySQL 落库 token)]
OAUTHCLIENT[OAuth2ClientService 校验 client]
end
AC --> AIS
CAP --> AIS
AIS --> LOG
AIS --> USER2
AIS --> OTS
OTS --> OAUTHCLIENT
OTS --> MYSQL
OTS --> REDIS
2. 命名体系与易混淆方法对比
2.1 命名规律拆解(认证方法族)
| 前缀 | 模块/对象 | 操作 | 含义 |
|---|---|---|---|
login |
(认证) | 主流程 | 密码登录,产 token 记日志 |
smsLogin |
(认证) | 主流程 | 短信验证码登录 |
socialLogin |
(认证) | 主流程 | 社交登录(微信等) |
register |
(认证) | 主流程 | 注册并登录 |
logout / refreshToken |
(认证) | 生命周期 | 退出 / 刷新 |
createToken |
AfterLoginSuccess |
私有 | 登录成功后统一”记日志 + 发 token”收口 |
命名规律总结:登录家族统一以”登录方式 + Login“命名,且都收敛到私有方法 createTokenAfterLoginSuccess——签发 token 与记登录日志是所有登录方式的公共结尾,抽出后任何新登录方式都可复用。
2.2 易混淆方法对比:login vs authenticate
| 对比维度 | authenticate(username, password) |
login(AuthLoginReqVO) |
|---|---|---|
| 职责 | 只校验”账号密码是否有效” | 完整登录编排(验证码+认证+token+日志) |
| 返回 | AdminUserDO(查出来的用户) |
AuthLoginRespVO(含 token) |
| 副作用 | 失败记登录日志 | 成功后记日志 + 发 token |
| 一句话区分 | 是”凭据校验器”;是”一次完整登录事务”。 |
特别提示:
authenticate在web或第三方调用场景也可单独复用(把”校验用户”与”发 token”解耦),而login面向 HTTP 前端。
3. API Signatures
完整链路调用关系:
sequenceDiagram
participant F as 前端
participant AC as AuthController
participant AS as AdminAuthServiceImpl
participant US as AdminUserService
participant TS as OAuth2TokenService
F->>AC: POST /admin-api/system/auth/login
AC->>AS: authService.login(reqVO)
AS->>AS: validateCaptcha()
AS->>US: authenticate → getUserByUsername + isPasswordMatch
AS->>US: createLoginLog(失败或成功)
AS->>TS: createAccessToken(userId, ADMIN, defaultClient, scopes)
TS->>TS: validOAuthClientFromCache
TS->>TS: generateRefreshToken + AccessToken(UUID)
TS-->>AS: OAuth2AccessTokenDO(含 token)
AS-->>AC: AuthConvert 转 AuthLoginRespVO
AC-->>F: CommonResult.success(respVO)
1 | // Controller 端点(节选) |
1 | // Service 实现(核心,节选签名) |
4. 数据结构深度解析
4.a 结构体存在的理由:OAuth2AccessTokenDO
登录的目标产物就是一个”令牌对象”。把它建模成
OAuth2AccessTokenDO是为了:①双写——MySQL 存全量(可查询/审计/撤销),Redis 存热数据(读快);②承认它是 OAuth2 语义的对象(client_id/scopes/expires齐备),为 SSO 多应用预留;③缓存到 Redis 时若不把tenantId一并存下,反序列化归来会丢失租户——所以在createOAuth2AccessToken里手动补齐 tenantId(见 第六章)。
4.b 代码:签发 token 的核心
📍 源码:OAuth2TokenServiceImpl.java:161-173
1 | private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) { |
4.c 字段三层分析表(OAuth2AccessTokenDO 关键字段)
| 字段 | 设计动机 | 反事实 | 替代方案 |
|---|---|---|---|
accessToken |
请求鉴权的核心凭证(UUID,不可猜) | 无法标识一次会话 | 自增 id,可枚举不安全 |
refreshToken |
续期令牌,比 accessToken 更长寿命 | 过期后只能重新登录 | 仅 accessToken 设很长 TTL,安全差 |
userInfo |
把昵称/部门预读到 token,避免每次查库 | 渲染昵称多一次查询 | 前端缓存,一致性难保 |
scopes |
授权范围(hasScope 判定) |
无法限制第三方应用能力 | 仅用权限,粒度不够 |
tenantId |
多租户下 token 归属哪个租户 | Redis 缓存回读丢失租户 | 每次都从头解析,有状态不一致风险 |
4.d 生命周期状态图(一次登录会话)
stateDiagram-v2
[*] --> 认证中: login 提交
认证中 --> 认证失败: 验证码/账号/密码错(记登录失败日志)
认证失败 --> [*]
认证中 --> 已签发: 成功 → 生成 access+refresh token
已签发 --> 活跃: 前端持 accessToken 调用接口(Redis/MySQL 校验)
活跃 --> 已过期: accessToken 到期
活跃 --> 已退出: logout 删除
已退出 --> [*]
已过期 --> 刷新: 用 refreshToken 换新 accessToken
刷新 --> 已签发
5. 函数逐行精讲
5.a 场景卡片
**函数:
login(AuthLoginReqVO)**(源码:99-114)
- 调用时机:前端提交登录表单。
- 典型调用者:
AuthController.login。- 前置条件:前置校验已过(Controller
@Valid校验字段格式)。- 目的:把”账号密码 → 登录会话 token”这一整条业务完整跑完。
5.b 逐行注释式精讲
1 |
|
5.c 场景卡片:OAuth2TokenServiceImpl.getAccessToken
函数:
getAccessToken(String)
- 调用时机:token 鉴权时(
TokenAuthenticationFilter→OAuth2TokenApi.checkAccessToken)。- 典型调用者:security Starter 的
OAuth2TokenApiImpl。- 前置条件:传入请求携带的 accessToken。
- 目的:尽量走 Redis 快路径,MySQL 兜底,并顺带把 MySQL 读到的数据回填 Redis。
1 | public OAuth2AccessTokenDO getAccessToken(String accessToken) { |
🔍 缓存策略解构:《Linux 磁盘 vs 内存》式的两级存储:Redis 提供热路径低延迟,MySQL 提供持久与审计;未命中即”穿库”,穿库后反填,保证「首次慢、后续快」的自平衡。
6. 关键机制剖析:验证码 → 登录 → 鉴权 三段的防御层次
flowchart LR
A[前端请求 /login] --> B[CaptchaService 校验滑块
yudao.captcha.enable]
B --> C[authenticate
用户存在? 密码 BCrypt 匹配? 状态启用?]
C --> D[签发 token
MySQL + Redis 双写]
D --> E[后续请求携带 Bearer token]
E --> F[TokenAuthenticationFilter 每次校验
Redis优先→MySQL兜底→类型比对]
F --> G[权限 @PreAuthorize]
每段的防护意图:
- 验证码:防机器人撞库(可在配置里关闭,便于开发);
- 密码 BCrypt:
userService.isPasswordMatch(raw, encoded)用 BCrypt 校验,库中不存明文; - 登录日志:无论成败都记
LoginLog(含LoginLogTypeEnum/LoginResultEnum),可审计; - 二次校验用户类型:token 里的
userType与当前 URL 终端不相符 → 拒绝,防 admin token 闯 app 接口。
7. 设计决策分析
- 为什么 token 用 UUID 而非 JWT? 权衡:UUID 简单、可服务端随时撤销(Redis 删除即失效),契合”后端可控”;JWT 无状态但有撤销痛点(黑名单复杂)。对管理后台,可撤销性 > 无状态性,故选 UUID + Redis。
- 为什么 access/refresh 双令牌? accessToken TTL 短(如 30 分钟)降低泄露风险,refreshToken TTL 长用于静默续期,减少频繁登录。
- 为什么登录不走 Spring Security 的登录处理器? 原因前述:多登录方式 + 要落日志 + 要知租户,自定义更可控。这是”用框架做它擅长的事,自建做它不擅长的细化事”的原则。
8. 学习检查点
📝 本章小结
- 登录 = 校验验证码 →
authenticate验凭据 → 记日志 →createTokenAfterLoginSuccess发 token。 - token 采用 UUID + access/refresh 双令牌,
OAuth2TokenServiceImpl负责 MySQL 落库 + Redis 缓存。 - 鉴权读取 Redis 优先 → MySQL 兜底 → 回填 Redis,两级缓存自平衡。
- 所有登录方式收敛到同一”发 token + 记日志”公共收口,扩展性强。
- token 里预置
userInfo、tenantId,让后续鉴权与展示零额外查询。
🤔 思考题
为什么
getAccessToken里会有”直接把 refreshToken 当 accessToken 校验”的代码?什么场景需要?(提示:WebSocket、积木报表)参考答案
某些集成场景(如积木报表)只允许传一个
token,不允许传refresh_token,WebSocket 的 token 也直接拼在 URL 上;若接口只认 accessToken,这些场景因 accessToken 过期就无法刷新。为此getAccessToken在 accessToken 查不到时,再尝试把传入值当 refreshToken 校验,若 refreshToken 未过期就用它转换出一个 accessToken——用”善意接纳刷新令牌”换取兼容性。createOAuth2AccessToken里为什么**手动setTenantId**?如果不做会出什么 bug?(提示:Redis 缓存 + 多租户)参考答案
OAuth2AccessTokenDO序列化进 Redis 时,如果tenantId恰好为 null,反序列化回来后拿到的 token 对象就丢失租户归属;而后续校验(TokenAuthenticationFilter)要用 token 里的tenantId构造LoginUser。若 tenantId 丢失,登录者在多租户环境下的数据访问会错乱或取不到正确租户。因此签发时从TenantContextHolder手动取租户塞进 token(与 MySQL 落库同源),确保缓存与库里的租户一致。请设计一个”登录失败 5 次锁定 10 分钟”的防爆破策略,应该在哪一层、用什么机制实现?(提示:结合验证码、Redis 计数、
LoginResultEnum)参考答案
推荐在该 module 或
CaptchaService之上叠加:登录失败时在 Redis 维护login_fail_{username}计数(失败incr,成功del),达到阈值(如 5 次)后对该用户名/ IP 写入fail_lock_{key}(TTL 10 分钟),期间login()入口直接拒绝并引导走验证码。它应放在AdminAuthServiceImpl.login的validateCaptcha之后、authenticate之前,因为要”CD 防御在认证之前”。LoginResultEnum里有 CAPTCHA_CODE_ERROR 等结果,可配合记录具体失败原因到登录日志。