Skip to content

feat(auth): add refresh token module - #229

Open
luoqiz wants to merge 1 commit into
continew-org:devfrom
luoqiz:feat-refresh-token
Open

luoqiz wants to merge 1 commit into
continew-org:devfrom
luoqiz:feat-refresh-token

Conversation

@luoqiz

@luoqiz luoqiz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

变更类型

  • 新特性(feat)
  • 问题修复(fix)
  • 功能优化(refactor / perf / chore / style)
  • 文档变更(docs)
  • 构建、CI 或依赖升级(build / ci)
  • 测试(test)
  • 其他

破坏性变更

  • 本 PR 包含破坏性变更(BREAKING CHANGE),已在「破坏性变更与迁移」中说明影响与迁移方式

变更目的

为管理端 Web、App 与小程序提供可轮换的 Refresh Token 登录态:缩短 Access Token 生命周期,过期后由 Refresh Token 安全恢复原登录会话,并支持跨端会话统一失效(强退、顶下线、密码修改、租户/客户端禁用)。登录、刷新、登出、在线用户、强制下线与 WebSocket 实时通知全部纳入同一会话模型。

暂无关联 Issue。

功能设计

会话模型

  • 新增独立 continew-auth-refresh 模块,集中管理 Refresh Token 签发、轮换、重放保护、会话查询与失效。
  • 令牌采用 sessionId.secret 无状态格式:sessionId 仅定位服务端会话,secret 才是凭证;服务端只保存 secret 的 HMAC-SHA256 指纹,比对使用常量时间算法,数据库/Redis 泄露不直接暴露可用令牌。
  • 会话状态集中在 Redis AUTH:REFRESH:V1: 命名空间:SESSION(会话本体,含安全版本快照)、ROTATION(轮换结果快照)、LATEST(当前有效令牌指纹门禁)、REASON(失效原因,供客户端区分强退/顶下线/普通失效)、限流计数等。

轮换与重放保护

  • 每次刷新执行 R0→R1 轮换:旧令牌作废、签发新令牌,切换由 LATEST 指纹门禁原子完成。
  • 5 秒宽限期内,同一旧令牌的并发/重复请求幂等返回首次轮换结果,避免浏览器多标签页与弱网重试互相作废。
  • 轮换期间的 Access Token / Refresh Token 使用 AES-GCM 加密封存于快照,崩溃中断后可恢复,不会留下半轮换状态。

统一撤销与实时通知

  • 撤销统一收敛于会话锁内完成(策略锁→会话锁固定顺序加锁,锁持有越过事务提交,Redisson 同线程可重入),保证与轮换并发时互不撕裂。
  • 撤销后通过 Redisson RTopic 广播 WebSocket 撤销通知与访问会话缓存失效,其它实例子秒级关闭该会话的实时连接。
  • 新增多连接 WebSocket 会话 DAO:同一 Access Token 的多标签页连接互不覆盖,撤销时全量关闭;本实例维护令牌→会话本地索引,批量撤销免于逐连接回源。

热路径性能

  • 已登录请求的会话校验命中 Caffeine 本地缓存(TTL 2 秒)时为 0 次 Redis 往返;未命中一律回源全量校验,正确性不依赖缓存。
  • 失效广播 Topic 默认按应用名隔离(auth:access-session-invalid:{spring.application.name}),同一服务多副本共享、不同服务互不串扰,可显式覆盖为公共 Topic;提供开关,关闭即恢复逐请求实时校验。
  • 拦截器在 401/403 短路路径显式清理线程本地上下文,避免池化线程跨用户串用。

多客户端适配

  • Web 端:Refresh Token 写入 HttpOnly Cookie(支持 __Host- 前缀、Secure/SameSite/Path 配置),刷新请求由浏览器自动携带。
  • App / 小程序:BODY 模式随请求体提交,由客户端安全存储。
  • 传输模式、有效期、Cookie 属性、来源白名单按客户端(Client)差异化配置;Cookie 来源使用独立白名单,支持精确 Origin 与 http[s]://*.example.com 子域通配符。
  • 适配登录签发、/auth/refresh 刷新、/auth/logout 登出、在线用户查询、按 sessionId 强制下线、客户端/租户安全策略(密码修改后会话失效、租户禁用即失活)与 WebSocket 会话撤销通知;AK/SK 第三方访问路径不参与 Refresh Token 处理。

租户隔离

  • 刷新与登出为租户无关的认证请求:请求入口忽略残留租户请求头(回落默认租户),业务层按会话记录的租户显式切换,普通租户的无感刷新不会错误落到默认租户。
  • 会话记录按 sys_user 行级隔离,不进入租户忽略白名单。

安全基线

  • 启动时校验 Access Token JWT 密钥与 Refresh Token 密钥:任一短于 32 字符即启动失败,两密钥相同(复用)直接拒绝启动;生产环境密钥全部由环境变量注入、无默认值。
  • 刷新接口默认使用连接对端地址限流,不信任任何 X-Forwarded-For;仅显式配置可信代理地址与跳数后才解析转发地址,配置错误为 fail-safe。
  • 内置 IP 与单会话双维度限流;轮换宽限期、限流窗口等均可配置。

配置项(application.yml auth.refresh-token

配置 默认 说明
secret ${REFRESH_TOKEN_SECRET} 服务端密钥(≥32 字符,禁止与 JWT 密钥复用)
cookie-name / cookie-path / cookie-secure / cookie-same-site refresh_token / / / false / Lax Cookie 属性,生产需开启 Secure
cookie-allowed-origins [] 跨源白名单,空列表仅允许同源
rotation-grace-period 5 轮换幂等宽限期(秒)
ip-rate-limit / ip-rate-limit-period 60 / 60 单 IP 刷新限流
session-rate-limit / session-rate-limit-period 10 / 60 单会话刷新限流
trusted-proxy-addresses / trusted-proxy-hops [] / 0 可信代理,默认不解析 XFF
access-session-cache-enabled true 热路径会话校验本地缓存开关
access-session-invalid-topic 空(按应用名隔离) 失效广播 Topic 显式覆盖
websocket-validation-interval 60000 本实例 WebSocket 凭证周期校验间隔(毫秒)

测试情况

  • 已执行 mvn -B -pl continew-server -am verify:四道门禁(Enforcer / Spotless / Checkstyle / SpotBugs)与全部单元测试通过,BUILD SUCCESS。
  • CI 中 continew-auth-refreshcontinew-plugin-tenantcontinew-server 三个模块的单元测试实际运行(模块级覆盖 Surefire skip=false,根 POM 全局 skip=true 不变),覆盖:令牌轮换/重放、会话撤销、租户刷新边界、拦截器线程本地清理、在线用户、WebSocket 多标签页撤销与批量通知缓存、多连接 DAO 保留存活标签页等场景。
  • continew-server 排除依赖 MySQL/Redis 的 Spring 上下文测试(ContiNewAdminApplicationTests),交由 CI 完成最终上下文验证。

Changelog

模块 Changelog Related issues
auth-refresh / system / server / tenant 新增 Refresh Token 轮换、会话管理、多端认证协议适配与统一会话失效 暂无关联 Issue

提交前确认

  • 一个 PR 聚焦于 Refresh Token 功能,不夹带无关改动
  • 本地 ./mvnw verify 四道门禁全部通过(完整上下文验证依赖本机 MySQL/Redis,本机已通过模块级 verify)
  • 已完整填写 Changelog,并关联相关 Issue(暂无关联 Issue)
  • commit message 符合 Conventional Commits(约定式提交)规范
  • 如包含 AI 生成的较大改动,相关 commit 已添加 Assisted-by: <智能体> 标记
  • 已签署 CLA(首次贡献可在 PR 创建后按机器人提示完成签署)
  • 目标分支正确:dev(新功能与优化)或 x.x.x 维护分支(仅 Bug 修复)

破坏性变更与迁移

  • 登录响应使用 accessToken,客户端不得再读取旧 token 字段。
  • 新 Access Token 必须包含认证会话 sid;部署升级后,旧会话将被拒绝并要求重新登录。此行为是安全不变量(无 sid 的旧令牌无法与会话绑定,不得在升级后继续有效),属有意破坏性变更。
  • 在线用户强退改以 sessionId 为目标;登出/刷新统一由 /auth/logout/auth/refresh 提供。
  • App / 小程序需使用 BODY 模式提交 Refresh Token;Web 端由 HttpOnly Cookie 自动携带。
  • Cookie 模式刷新依赖浏览器同源携带的 Origin/Referer 头:网关不得剥离这些头;非浏览器客户端(脚本、App)请配置 BODY 模式。

发布协调与部署

必须与 continew-admin-ui#89 同时合并并同步发布。生产部署需配置两个独立认证密钥:ACCESS_TOKEN_JWT_SECRETREFRESH_TOKEN_SECRET

反向代理与真实客户端 IP(影响限流正确性)

刷新接口的限流默认使用 TCP 连接对端地址,不信任任何 X-Forwarded-For

  • auth.refresh-token.trusted-proxy-hops 默认 0trusted-proxy-addresses 默认为空 → 后端完全不解析 XFF,天然免疫伪造。
  • 后端直接对外(无反向代理):无需任何配置。
  • 经 Nginx / 网关部署时必须显式配置,否则所有用户会共享代理这一个 IP 的限流桶(ip-rate-limit 默认 60 次/分钟将变成全站共享),必然大面积误伤:
auth:
  refresh-token:
    # 填「后端看到的 TCP 对端地址」,即反向代理自身在容器网络中的 IP
    # 注意:不是 upstream 里写的 172.17.0.1,那是代理访问宿主机用的网关地址
    trusted-proxy-addresses: ["<反向代理容器 IP>"]
    # 有几层可信代理就填几层,多层网关时按实际跳数填
    trusted-proxy-hops: 1

白名单取值可在后端容器内查看实际来源地址(docker network inspect <网络名>),或临时打印 request.getRemoteAddr() 确认。

本 PR 同时把 docker/nginx/conf/nginx.conf 的 XFF 由 $proxy_add_x_forwarded_for(追加)改为 $remote_addr(覆盖),即在入口丢弃客户端自带的 XFF 头。原因:后端按「从右往左数 hops 个条目」解析,若 hops 配置错误——

nginx XFF 写法 hops 正确 hops 误配(多填)
$proxy_add_x_forwarded_for(追加) 取到真实 IP 伪造 IP 落在左侧被选中,限流可被绕过
$remote_addr(覆盖,本 PR 后) 取到真实 IP 回退代理 IP,限流退化为共桶——误伤但不产生安全漏洞

即覆盖语义下配置错误是 fail-safe 的,追加语义则会变成可绕过的防护缺口。

@luoqiz
luoqiz force-pushed the feat-refresh-token branch 2 times, most recently from 949bbb4 to 3763ec7 Compare September 9, 2026 05:41
Comment thread pom.xml
@luoqiz
luoqiz force-pushed the feat-refresh-token branch from 8e3adc1 to a40fe6a Compare September 9, 2026 23:10
@Charles7c

Copy link
Copy Markdown
Member

/continew-code-review

为管理端 Web、App 与小程序提供可轮换的 Refresh Token 登录态:缩短
Access Token 生命周期,过期后由 Refresh Token 安全恢复原登录会话,
并支持跨端会话统一失效(强退、顶下线、密码修改、租户/客户端禁用)。

- 新增独立 continew-auth-refresh 模块:Refresh Token 签发、轮换、
  重放保护、会话查询与失效;令牌采用 sessionId.secret 无状态格式,
  服务端仅保存 HMAC-SHA256 指纹并做常量时间比对
- 轮换 R0→R1 由 LATEST 指纹门禁原子完成,宽限期内并发/重复请求
  幂等返回首次结果;AES-GCM 快照保证崩溃后恢复一致
- 统一撤销收敛于会话锁内完成(策略锁→会话锁固定顺序),撤销后经
  Redisson RTopic 广播 WebSocket 通知与访问会话缓存失效,其它实例
  子秒级关闭对应实时连接
- 已登录请求的会话校验命中 Caffeine 本地缓存(TTL 2s)时为 0 次
  Redis 往返,未命中一律回源全量校验,正确性不依赖缓存
- 多连接 WebSocket 会话 DAO:同一 Access Token 的多标签页连接互不
  覆盖,撤销时全量关闭;本实例维持令牌→会话本地索引,批量撤销免于
  逐连接回源
- Web 端经 HttpOnly Cookie 传输、App/小程序经 BODY 传输,按客户端
  差异化配置有效期、Cookie 属性与来源白名单(支持子域通配符)
- 适配登录/登出/在线用户/强制下线/客户端与租户策略/WebSocket 通知;
  AK/SK 第三方访问路径不参与 Refresh Token 处理
- 刷新/登出为租户无关认证请求:请求入口忽略残留租户头回落默认租户,
  业务层按会话租户显式切换,普通租户无感刷新不会落默认租户
- 启动校验 Access/Refresh 密钥独立性,生产环境无默认密钥;限流默认
  使用连接对端地址,仅显式配置可信代理后解析 XFF(配置错误 fail-safe)
- 新增单元测试并在 CI 实际运行(auth-refresh/plugin-tenant/server
  三模块 Surefire skip=false),Enforcer/Spotless/Checkstyle/SpotBugs
  四道门禁全部通过

Assisted-by: DeepSeek Harness
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants