Browse Source

fix(auth): 修复后台会话长期不失效,JWT 时间配置改用可读单位

问题
- 关闭后台页面数天后重新打开,可跳过登录页直接进入后台
- 根因: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 有效期约定

已知残留
- 仍保留滚动续期,页面持续活跃的用户不会掉线;彻底杜绝需引入绝对过期上限或服务端
  落库记录最后活动时间,本次未做
Spring-AI-1.1.2
wanghanlin 2 days ago
parent
commit
0838dca61a
  1. 5
      CLAUDE.md
  2. 9
      frontend/src/App.vue
  3. 13
      src/main/java/com/wok/supportbot/auth/AuthController.java
  4. 12
      src/main/java/com/wok/supportbot/controller/AuthController.java
  5. 21
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  6. 28
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  7. 15
      src/main/resources/application.yml

5
CLAUDE.md

@ -90,6 +90,11 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key` - **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法) - MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long 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 对象 `'{}'`,非数组 `'[]'` - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW - **向量维度**: 由 `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 - **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true

9
frontend/src/App.vue

@ -33,10 +33,15 @@ onMounted(async () => {
if (isLoggedIn()) { if (isLoggedIn()) {
const ok = await auth.fetchUser() const ok = await auth.fetchUser()
if (!ok) { if (!ok) {
// Token
// access token refresh cookie token
const refreshed = await auth.tryRefreshToken() const refreshed = await auth.tryRefreshToken()
if (refreshed) { if (refreshed) {
await auth.fetchUser().catch(() => {})
// /auth/me
// currentUser
const okAfterRefresh = await auth.fetchUser()
if (!okAfterRefresh) {
await auth.doLogout()
}
} else { } else {
await auth.doLogout() await auth.doLogout()
} }

13
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -103,7 +103,7 @@ public class AuthController {
loginAttemptService.recordSuccess(username); loginAttemptService.recordSuccess(username);
Map<String, Object> userInfo = buildUserInfo(user, roleKeys); Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure());
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
log.info("用户登录成功: {}", username); log.info("用户登录成功: {}", username);
return ResponseEntity.ok() return ResponseEntity.ok()
@ -164,7 +164,7 @@ public class AuthController {
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer); String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, 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() return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
@ -279,6 +279,15 @@ public class AuthController {
return userInfo; return userInfo;
} }
/**
* refresh cookie Max-Age
* 直接取自 jwt.refresh-expiration 配置保证 Cookie 生命周期与 refresh token 完全一致
* 避免两处硬编码后改配置不改 Cookie导致的有效期漂移
*/
private long refreshCookieMaxAgeSeconds() {
return jwtTokenProvider.getRefreshExpirationSeconds();
}
/** /**
* 构建 refresh token Cookie * 构建 refresh token Cookie
* Secure 属性按配置开关 && 当前请求是否 HTTPS动态决定 * Secure 属性按配置开关 && 当前请求是否 HTTPS动态决定

12
src/main/java/com/wok/supportbot/controller/AuthController.java

@ -88,12 +88,12 @@ public class AuthController {
.map(RoleBrief::id) .map(RoleBrief::id)
.collect(Collectors.toList()); .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 JWTsubject = apiKeyId // 5. 签发 SDK JWTsubject = apiKeyId
String token = sdkJwtTokenProvider.generateToken( String token = sdkJwtTokenProvider.generateToken(

21
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -10,6 +10,7 @@ import org.springframework.stereotype.Component;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.List; 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"; public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production";
private final SecretKey key; private final SecretKey key;
private final long expiration;
private final long refreshExpiration;
private final Duration expiration;
private final Duration refreshExpiration;
private final boolean failIfDefaultSecret; private final boolean failIfDefaultSecret;
public JwtTokenProvider( public JwtTokenProvider(
@Value("${jwt.secret}") String secret, @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) { @Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) {
if (DEFAULT_SECRET.equals(secret)) { if (DEFAULT_SECRET.equals(secret)) {
String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)"; String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)";
@ -63,7 +64,7 @@ public class JwtTokenProvider {
.claim("type", "access") .claim("type", "access")
.claim("ver", tokenVersion) .claim("ver", tokenVersion)
.issuedAt(now) .issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.expiration(new Date(now.getTime() + expiration.toMillis()))
.signWith(key) .signWith(key)
.compact(); .compact();
} }
@ -78,11 +79,19 @@ public class JwtTokenProvider {
.claim("type", "refresh") .claim("type", "refresh")
.claim("ver", tokenVersion) .claim("ver", tokenVersion)
.issuedAt(now) .issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.expiration(new Date(now.getTime() + refreshExpiration.toMillis()))
.signWith(key) .signWith(key)
.compact(); .compact();
} }
/**
* 刷新令牌有效期
* refresh Cookie Max-Age 复用保证 Cookie refresh token 的生命周期始终一致
*/
public long getRefreshExpirationSeconds() {
return refreshExpiration.toSeconds();
}
/** /**
* 验证 access token 有效性校验签名过期时间 type 必须为 access * 验证 access token 有效性校验签名过期时间 type 必须为 access
*/ */

28
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -31,19 +32,19 @@ import java.util.stream.Collectors;
public class SdkJwtTokenProvider { public class SdkJwtTokenProvider {
private final SecretKey key; private final SecretKey key;
private final long defaultExpiration;
private final Duration defaultExpiration;
/** 24 小时上限 */ /** 24 小时上限 */
private static final long MAX_EXPIRATION = 86400000L;
private static final Duration MAX_EXPIRATION = Duration.ofDays(1);
/** 5 分钟下限 */ /** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
private static final Duration MIN_EXPIRATION = Duration.ofMinutes(5);
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */ /** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
public SdkJwtTokenProvider( public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, @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) { @Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) {
// 空值校验密钥为空/空白时启动失败并给出明确提示避免 Keys.hmacShaKeyFor 抛出晦涩异常 // 空值校验密钥为空/空白时启动失败并给出明确提示避免 Keys.hmacShaKeyFor 抛出晦涩异常
if (!StringUtils.hasText(sdkSecret)) { if (!StringUtils.hasText(sdkSecret)) {
@ -70,7 +71,7 @@ public class SdkJwtTokenProvider {
* @return JWT Token 字符串 * @return JWT Token 字符串
*/ */
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds, long expirationMs) { public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds, long expirationMs) {
long clampedExpiration = clampExpiration(expirationMs);
long clampedExpiration = clampExpirationMillis(expirationMs);
Date now = new Date(); Date now = new Date();
return Jwts.builder() return Jwts.builder()
.subject(apiKeyId) .subject(apiKeyId)
@ -86,7 +87,15 @@ public class SdkJwtTokenProvider {
* 使用默认过期时间签发 SDK JWT Token * 使用默认过期时间签发 SDK JWT Token
*/ */
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds) { public String generateToken(String apiKeyId, String maskedApiKey, List<Long> 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()));
} }
} }

15
src/main/resources/application.yml

@ -131,10 +131,13 @@ logging:
jwt: jwt:
# 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖) # 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖)
secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production} 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 token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false)
refresh-cookie-secure: true refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true) # 使用默认密钥时是否直接启动失败(生产建议 true)
@ -144,8 +147,8 @@ jwt:
sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change} sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change}
# 使用默认 SDK 密钥时是否直接启动失败(生产建议 true) # 使用默认 SDK 密钥时是否直接启动失败(生产建议 true)
fail-if-default-sdk-secret: false fail-if-default-sdk-secret: false
# SDK Token 默认有效期:2小时(毫秒)
sdk-expiration: 7200000
# SDK Token 默认有效期:2小时,作为 SDK 换 Token 接口未指定 ttl 时的默认值
sdk-expiration: 2h
# ==================== 登录安全配置 ==================== # ==================== 登录安全配置 ====================
security: security:

Loading…
Cancel
Save