From 0838dca61a79bc102e7cd091f998d4084df485a4 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 11 Sep 2026 09:25:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BC=9A=E8=AF=9D=E9=95=BF=E6=9C=9F=E4=B8=8D=E5=A4=B1?= =?UTF-8?q?=E6=95=88=EF=BC=8CJWT=20=E6=97=B6=E9=97=B4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8F=AF=E8=AF=BB=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 - 关闭后台页面数天后重新打开,可跳过登录页直接进入后台 - 根因:refresh token 有效期 7 天且滚动续期无上限;refresh Cookie 为持久化 Cookie (maxAge 硬编码 7 天)在浏览器关闭后不会删除,重开页面时 App.vue 启动自检发现 /auth/me 返回 401,静默调 /auth/refresh 换发新 token 并重置窗口 —— 只要 7 天内 来访一次即等效永久会话 会话有效期修复 - application.yml:refresh-expiration 由 7 天改为 8h,作为会话时长的唯一配置项, 所有环境统一生效(不在 dev/prod 覆盖) - auth/AuthController:refresh Cookie 的 Max-Age 由硬编码 7*24*60*60 改为读 jwt.refresh-expiration,消除「改配置不改 Cookie」造成的有效期漂移 - App.vue:刷新 token 成功后二次校验 /auth/me,失败即登出;修复原先 .catch(() => {}) 丢弃返回值导致用户带着空 currentUser 进入后台、权限判断全部 失效、直到某个请求 401 才被踢出的漏洞 JWT 时间配置改用 Duration 可读格式 - expiration / refresh-expiration / sdk-expiration 均绑定为 java.time.Duration, 值写成 15m / 8h / 2h;纯数字仍按毫秒解析,向后兼容旧配置 - JwtTokenProvider 兜底默认值由 604800000(7 天,与 yml 的 8 小时并不一致)修正为 8h - SdkJwtTokenProvider:默认有效期改 Duration,钳制边界 MIN/MAX_EXPIRATION 改为 Duration.ofMinutes(5) / Duration.ofDays(1),并公开 clampExpirationMillis 供控制器复用 SDK 配置接通 - controller/AuthController(SDK):默认 ttl 改读 jwt.sdk-expiration。此前硬编码 7200000L,而唯一读该配置的 3 参 generateToken 无任何调用方,属改了不生效的死配置 - 控制器内不再出现 300000L / 86400000L 等毫秒魔数,expiresIn 与 token 实际有效期 保持一致(对外的 ttl / expiresIn 单位仍为秒,契约不变) 验证 - 实测 Duration 绑定链路:15m→PT15M(900s)、8h→PT8H(28800s)、2h→PT2H(7200s), 纯数字 28800000→PT8H(28800s),确认向后兼容 - mvn clean package -P prod 构建通过(含前端构建) 文档 - CLAUDE.md 同步会话有效期约定、Duration 单位规范、SDK Token 有效期约定 已知残留 - 仍保留滚动续期,页面持续活跃的用户不会掉线;彻底杜绝需引入绝对过期上限或服务端 落库记录最后活动时间,本次未做 --- CLAUDE.md | 5 ++++ frontend/src/App.vue | 9 ++++-- .../wok/supportbot/auth/AuthController.java | 13 +++++++-- .../supportbot/controller/AuthController.java | 12 ++++---- .../supportbot/security/JwtTokenProvider.java | 21 ++++++++++---- .../security/SdkJwtTokenProvider.java | 28 +++++++++++++------ src/main/resources/application.yml | 15 ++++++---- 7 files changed, 72 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e36709..dd85b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,11 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`) - MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法) - **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解 +- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。 +- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。 + - 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。 + - 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。 +- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。 - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`) - **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW - **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2c75068..28a2da7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,10 +33,15 @@ onMounted(async () => { if (isLoggedIn()) { const ok = await auth.fetchUser() if (!ok) { - // 尝试刷新 Token + // access token 已过期,尝试用 refresh cookie 换取新 token const refreshed = await auth.tryRefreshToken() if (refreshed) { - await auth.fetchUser().catch(() => {}) + // 刷新成功后必须再校验一次用户信息:/auth/me 仍失败说明账号已被禁用等, + // 此时不能放行进后台(否则 currentUser 为空、权限判断全部失效) + const okAfterRefresh = await auth.fetchUser() + if (!okAfterRefresh) { + await auth.doLogout() + } } else { await auth.doLogout() } diff --git a/src/main/java/com/wok/supportbot/auth/AuthController.java b/src/main/java/com/wok/supportbot/auth/AuthController.java index 66d9c9e..0871fa9 100644 --- a/src/main/java/com/wok/supportbot/auth/AuthController.java +++ b/src/main/java/com/wok/supportbot/auth/AuthController.java @@ -103,7 +103,7 @@ public class AuthController { loginAttemptService.recordSuccess(username); Map userInfo = buildUserInfo(user, roleKeys); - ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure()); + ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure()); log.info("用户登录成功: {}", username); return ResponseEntity.ok() @@ -164,7 +164,7 @@ public class AuthController { String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer); String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer); - ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L, request.isSecure()); + ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, refreshCookieMaxAgeSeconds(), request.isSecure()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) @@ -279,6 +279,15 @@ public class AuthController { return userInfo; } + /** + * refresh cookie 的 Max-Age(秒) + * 直接取自 jwt.refresh-expiration 配置,保证 Cookie 生命周期与 refresh token 完全一致, + * 避免两处硬编码后「改配置不改 Cookie」导致的有效期漂移 + */ + private long refreshCookieMaxAgeSeconds() { + return jwtTokenProvider.getRefreshExpirationSeconds(); + } + /** * 构建 refresh token Cookie。 * Secure 属性按「配置开关 && 当前请求是否 HTTPS」动态决定: diff --git a/src/main/java/com/wok/supportbot/controller/AuthController.java b/src/main/java/com/wok/supportbot/controller/AuthController.java index e20f60e..9df2cd8 100644 --- a/src/main/java/com/wok/supportbot/controller/AuthController.java +++ b/src/main/java/com/wok/supportbot/controller/AuthController.java @@ -88,12 +88,12 @@ public class AuthController { .map(RoleBrief::id) .collect(Collectors.toList()); - // 4. 计算过期时间(默认 2 小时,钳制到 [5min, 24h]) - long ttlMs = 7200000L; - if (body != null && body.ttl() != null) { - ttlMs = Math.max(body.ttl() * 1000L, 300000L); - ttlMs = Math.min(ttlMs, 86400000L); - } + // 4. 计算过期时间:调用方指定 ttl(秒)则用指定值,否则取 jwt.sdk-expiration 配置的默认值 + // 再统一按 [5min, 24h] 钳制,保证返回的 expiresIn 与 token 实际有效期一致 + long ttlMs = (body != null && body.ttl() != null) + ? body.ttl() * 1000L + : sdkJwtTokenProvider.getDefaultExpirationMillis(); + ttlMs = sdkJwtTokenProvider.clampExpirationMillis(ttlMs); // 5. 签发 SDK JWT(subject = apiKeyId) String token = sdkJwtTokenProvider.generateToken( diff --git a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java index 95d5039..70149ab 100644 --- a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Date; import java.util.List; @@ -25,14 +26,14 @@ public class JwtTokenProvider { public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production"; private final SecretKey key; - private final long expiration; - private final long refreshExpiration; + private final Duration expiration; + private final Duration refreshExpiration; private final boolean failIfDefaultSecret; public JwtTokenProvider( @Value("${jwt.secret}") String secret, - @Value("${jwt.expiration:900000}") long expiration, - @Value("${jwt.refresh-expiration:604800000}") long refreshExpiration, + @Value("${jwt.expiration:15m}") Duration expiration, + @Value("${jwt.refresh-expiration:8h}") Duration refreshExpiration, @Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) { if (DEFAULT_SECRET.equals(secret)) { String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)"; @@ -63,7 +64,7 @@ public class JwtTokenProvider { .claim("type", "access") .claim("ver", tokenVersion) .issuedAt(now) - .expiration(new Date(now.getTime() + expiration)) + .expiration(new Date(now.getTime() + expiration.toMillis())) .signWith(key) .compact(); } @@ -78,11 +79,19 @@ public class JwtTokenProvider { .claim("type", "refresh") .claim("ver", tokenVersion) .issuedAt(now) - .expiration(new Date(now.getTime() + refreshExpiration)) + .expiration(new Date(now.getTime() + refreshExpiration.toMillis())) .signWith(key) .compact(); } + /** + * 刷新令牌有效期(秒) + * 供 refresh Cookie 的 Max-Age 复用,保证 Cookie 与 refresh token 的生命周期始终一致 + */ + public long getRefreshExpirationSeconds() { + return refreshExpiration.toSeconds(); + } + /** * 验证 access token 有效性(校验签名、过期时间,且 type 必须为 access) */ diff --git a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java index 164a9fb..7350a60 100644 --- a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java @@ -12,6 +12,7 @@ import org.springframework.util.StringUtils; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Date; import java.util.List; import java.util.Set; @@ -31,19 +32,19 @@ import java.util.stream.Collectors; public class SdkJwtTokenProvider { private final SecretKey key; - private final long defaultExpiration; + private final Duration defaultExpiration; /** 24 小时上限 */ - private static final long MAX_EXPIRATION = 86400000L; + private static final Duration MAX_EXPIRATION = Duration.ofDays(1); /** 5 分钟下限 */ - private static final long MIN_EXPIRATION = 300000L; + private static final Duration MIN_EXPIRATION = Duration.ofMinutes(5); /** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */ private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; public SdkJwtTokenProvider( @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, - @Value("${jwt.sdk-expiration:7200000}") long defaultExpiration, + @Value("${jwt.sdk-expiration:2h}") Duration defaultExpiration, @Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) { // 空值校验:密钥为空/空白时启动失败并给出明确提示,避免 Keys.hmacShaKeyFor 抛出晦涩异常 if (!StringUtils.hasText(sdkSecret)) { @@ -70,7 +71,7 @@ public class SdkJwtTokenProvider { * @return JWT Token 字符串 */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds, long expirationMs) { - long clampedExpiration = clampExpiration(expirationMs); + long clampedExpiration = clampExpirationMillis(expirationMs); Date now = new Date(); return Jwts.builder() .subject(apiKeyId) @@ -86,7 +87,15 @@ public class SdkJwtTokenProvider { * 使用默认过期时间签发 SDK JWT Token */ public String generateToken(String apiKeyId, String maskedApiKey, List roleIds) { - return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration); + return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration.toMillis()); + } + + /** + * 默认有效期(毫秒),取自 jwt.sdk-expiration 配置 + * 供 SDK 版 AuthController 在调用方未指定 ttl 时作为默认值使用 + */ + public long getDefaultExpirationMillis() { + return defaultExpiration.toMillis(); } /** @@ -151,9 +160,10 @@ public class SdkJwtTokenProvider { } /** - * 将过期时间钳制到 [5min, 24h] 范围内 + * 将过期时间钳制到 [5min, 24h] 范围内(毫秒) + * 公开供 SDK 版 AuthController 复用,使钳制边界只有一处定义,避免响应值与 token 实际有效期不一致 */ - private long clampExpiration(long expirationMs) { - return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION)); + public long clampExpirationMillis(long expirationMs) { + return Math.max(MIN_EXPIRATION.toMillis(), Math.min(expirationMs, MAX_EXPIRATION.toMillis())); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8172a20..86aa47d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -131,10 +131,13 @@ logging: jwt: # 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖) secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production} - # 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口 - expiration: 900000 - # 刷新令牌有效期:7天(毫秒) - refresh-expiration: 604800000 + # 访问令牌有效期:15分钟,降低 XSS 窃取后的利用窗口 + # 单位写法:支持 15m / 900s / 900000ms 等可读格式;纯数字按毫秒解析(向后兼容旧配置) + expiration: 15m + # 刷新令牌有效期:8小时。关闭页面超过该时长后重新打开需重新登录。 + # 这是会话时长的唯一配置项,所有环境统一生效;refresh Cookie 的 Max-Age 由 AuthController 读取本项自动跟随,不得硬编码。 + # 注意:保留滚动续期 —— 页面持续活跃的用户每 15 分钟会静默刷新一次,窗口随之重置,不会掉线。 + refresh-expiration: 8h # refresh token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false) refresh-cookie-secure: true # 使用默认密钥时是否直接启动失败(生产建议 true) @@ -144,8 +147,8 @@ jwt: sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change} # 使用默认 SDK 密钥时是否直接启动失败(生产建议 true) fail-if-default-sdk-secret: false - # SDK Token 默认有效期:2小时(毫秒) - sdk-expiration: 7200000 + # SDK Token 默认有效期:2小时,作为 SDK 换 Token 接口未指定 ttl 时的默认值 + sdk-expiration: 2h # ==================== 登录安全配置 ==================== security: