最近节日
正在加载日历...----
设计 ZSK-Cloud 的 Access Token + Refresh Token 认证体系,介绍 RS256 签名、HttpOnly Cookie、Redis 会话白名单、主动吊销和跨服务验签。

ZSK-Cloud 采用 RS256 签名的 Access Token + Refresh Token 双令牌方案。两个令牌通过 HttpOnly Cookie 下发,Redis Set 维护会话白名单并支持主动吊销,前端 Pinia 只缓存非敏感用户状态。本文重点审查令牌生命周期、Cookie 属性、跨服务验签和吊销策略。
ZSK-Cloud 当前采用单 Access Token 方案:
SecurityConstants.TOKEN_EXPIRE 控制。该方案在单体阶段运行正常,但进入 Spring Cloud 微服务架构后,存在以下问题:
| 问题 | 影响 | 风险等级 |
|---|---|---|
| 单 Token 有效期过长 | Token 泄露后 12 h 内均可被利用 | P0 |
| Token 通过 Body 返回 | 前端可能存入 localStorage,XSS 可窃取 | P0 |
| 密码修改后旧 Token 仍有效 | 用户感知不到其他会话仍在活动 | P1 |
| 跨服务共享鉴权信息 | 各业务服务需重复查询用户权限 | P1 |
| 移动端 / App 集成困难 | Cookie 机制对非浏览器客户端不友好 | P2 |
本次方案目标:
衡量标准:
Set-Cookie 下发。本次范围不包括:
[配图:ZSK-Cloud Token 分层存储架构图]
双 Token 解决了单 Token 的核心矛盾:
[配图:RS256 私钥签名、公钥验证流程]
zsk-auth 持有私钥,负责签发 Token。纯 JWT 的缺陷是“签发后无法撤回”。本方案通过 Redis Set 维护 Token 白名单:
吊销 Token 时只需从 Redis Set 中删除对应元素,下一次请求即被拦截。
| 维度 | Session | JWT | 胜出方 |
|---|---|---|---|
| 状态管理 | 有状态(服务端存储) | 无状态(Token 自包含) | 视场景 |
| 水平扩展 | 需 Sticky Session 或集中式 Session Store | 任意节点可本地验签 | JWT |
| 跨域 / 跨服务 | Cookie 跨域限制多 | Header 传递无跨域问题 | JWT |
| 主动吊销 | 删除 Session 即可 | 需额外黑名单 / 白名单 | Session |
| 网络开销 | 仅传递 Session ID(约 50 B) | 传递完整 Token(约 500–800 B) | Session |
| 移动端适配 | Cookie 对 App 不友好 | Header 方式通用 | JWT |
选择 JWT 的原因:
ZSK-Cloud 基于 Spring Cloud 微服务,网关与多个业务服务独立部署。JWT 使各服务无需共享 Session Store,网关解析 Token 后通过 Header 注入用户信息,业务服务无状态运行。
| 维度 | Cookie (HttpOnly) | localStorage | Pinia (内存) |
|---|---|---|---|
| XSS 防御 | 优(JS 不可读) | 差(JS 可读) | 良(内存中) |
| CSRF 防御 | 需 SameSite 配合 | 天然免疫 | 天然免疫 |
| 持久性 | 可控过期 | 永久(需手动清理) | 刷新即丢失 |
| 跨标签页 | 同域共享 | 同域共享 | 各标签页独立 |
| 跨域 | 受限 | 独立存储 | 独立存储 |
| 容量 | 4 KB | 5–10 MB | 无硬限制 |
| 自动携带 | 浏览器自动 | 需手动设置 Header | 需拦截器 |
选择 Cookie + Pinia 分层存储的原因:
/auth/user-info 重新获取。选择 Cookie 而非 localStorage 存储 Token 的取舍:
选择 Redis 白名单而非纯 JWT 的取舍:
@Overridepublic LoginResponse login(LoginRequest request) { LoginUser loginUser = authenticate(request); SysUserApi user = loginUser.getSysUser(); Long userId = user.getId();
// 1. 生成 Access Token Map<String, Object> accessClaims = new HashMap<>(); accessClaims.put(SecurityConstants.USER_ID, userId); accessClaims.put(SecurityConstants.USER_NAME, user.getUserName()); accessClaims.put(SecurityConstants.NICK_NAME, user.getNickName()); accessClaims.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS); String accessToken = JwtUtils.createToken(accessClaims);
// 2. 生成 Refresh Token Map<String, Object> refreshClaims = new HashMap<>(); refreshClaims.put(SecurityConstants.USER_ID, userId); refreshClaims.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_REFRESH); String refreshToken = JwtUtils.createToken(refreshClaims);
// 3. 写入 Redis 白名单,限制最多 5 个设备 String accessTokenKey = CacheConstants.CACHE_LOGIN_TOKEN + userId; String refreshTokenKey = CacheConstants.CACHE_LOGIN_REFRESH + userId; storeTokenWithLimit(accessTokenKey, accessToken, SecurityConstants.TOKEN_EXPIRE, TimeUnit.MINUTES, 5); storeTokenWithLimit(refreshTokenKey, refreshToken, SecurityConstants.REFRESH_TOKEN_EXPIRE, TimeUnit.DAYS, 5);
// 4. 缓存角色权限 cacheRolesAndPermissions(userId, loginUser);
// 5. 通过 HttpOnly Cookie 下发 response.addCookie(buildCookie( SecurityConstants.ACCESS_TOKEN_COOKIE, accessToken, SecurityConstants.TOKEN_EXPIRE * 60, true, true, "Lax")); response.addCookie(buildCookie( SecurityConstants.REFRESH_TOKEN_COOKIE, refreshToken, (int) SecurityConstants.REFRESH_TOKEN_EXPIRE * 24 * 60 * 60, true, true, "Lax"));
// 6. 返回用户信息(不含 Token) return LoginResponse.builder() .userId(user.getId()) .username(user.getUserName()) .nickname(user.getNickName()) .avatar(user.getAvatar()) .expiresIn(SecurityConstants.TOKEN_EXPIRE * 60L) .build();}JWT Claims 定义:
| Claim | Access Token | Refresh Token | 说明 |
|---|---|---|---|
user_id | 是 | 是 | 用户唯一标识 |
user_name | 是 | 否 | 登录账号 |
nick_name | 是 | 否 | 用户昵称 |
token_type | access | refresh | 区分 Token 类型,防止 Refresh Token 被用于访问 API |
@Overridepublic RefreshResponse refreshAccessToken(String refreshToken) { // 1. 解析并校验 Token 类型 Claims claims = JwtUtils.parseToken(refreshToken); String tokenType = claims.get(SecurityConstants.TOKEN_TYPE, String.class); if (!SecurityConstants.TOKEN_TYPE_REFRESH.equals(tokenType)) { throw new AuthException(ResultCode.TOKEN_INVALID); }
Long userId = JwtUtils.getUserIdAsLong(refreshToken);
// 2. 校验 Refresh Token 是否在白名单 String refreshTokenKey = CacheConstants.CACHE_LOGIN_REFRESH + userId; Boolean isMember = redisService.isMemberOfSet(refreshTokenKey, refreshToken); if (Boolean.FALSE.equals(isMember)) { throw new AuthException(ResultCode.REFRESH_TOKEN_EXPIRED); }
// 3. 生成新 Access Token Map<String, Object> accessClaims = new HashMap<>(); accessClaims.put(SecurityConstants.USER_ID, userId); accessClaims.put(SecurityConstants.USER_NAME, claims.get(SecurityConstants.USER_NAME)); accessClaims.put(SecurityConstants.NICK_NAME, claims.get(SecurityConstants.NICK_NAME)); accessClaims.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS); String newAccessToken = JwtUtils.createToken(accessClaims);
// 4. 存储新 Access Token 并更新 TTL String accessTokenKey = CacheConstants.CACHE_LOGIN_TOKEN + userId; storeTokenWithLimit(accessTokenKey, newAccessToken, SecurityConstants.TOKEN_EXPIRE, TimeUnit.MINUTES, 5);
// 5. 刷新 Refresh Token 与角色权限的过期时间 redisService.expire(refreshTokenKey, SecurityConstants.REFRESH_TOKEN_EXPIRE, TimeUnit.DAYS); redisService.expire(CacheConstants.CACHE_LOGIN_ROLES + userId, SecurityConstants.REFRESH_TOKEN_EXPIRE, TimeUnit.DAYS); redisService.expire(CacheConstants.CACHE_LOGIN_PERMISSIONS + userId, SecurityConstants.REFRESH_TOKEN_EXPIRE, TimeUnit.DAYS);
// 6. 通过 Cookie 下发新 Access Token response.addCookie(buildCookie( SecurityConstants.ACCESS_TOKEN_COOKIE, newAccessToken, SecurityConstants.TOKEN_EXPIRE * 60, true, true, "Lax"));
return new RefreshResponse(SecurityConstants.TOKEN_EXPIRE * 60L);}@Overridepublic void logout(HttpServletRequest request) { String accessToken = getTokenFromCookie(request, SecurityConstants.ACCESS_TOKEN_COOKIE); String refreshToken = getTokenFromCookie(request, SecurityConstants.REFRESH_TOKEN_COOKIE);
Long userId = null; if (StrUtil.isNotBlank(accessToken)) { userId = JwtUtils.getUserIdAsLong(accessToken); redisService.removeSetCacheObject( CacheConstants.CACHE_LOGIN_TOKEN + userId, accessToken); }
if (StrUtil.isNotBlank(refreshToken)) { userId = JwtUtils.getUserIdAsLong(refreshToken); redisService.removeSetCacheObject( CacheConstants.CACHE_LOGIN_REFRESH + userId, refreshToken); }
// 清除 Cookie response.addCookie(buildCookie( SecurityConstants.ACCESS_TOKEN_COOKIE, "", 0, true, true, "Lax")); response.addCookie(buildCookie( SecurityConstants.REFRESH_TOKEN_COOKIE, "", 0, true, true, "Lax"));
// 若该用户已无活跃 Token,清理角色权限缓存 if (userId != null) { Long remaining = redisService.getSetSize( CacheConstants.CACHE_LOGIN_TOKEN + userId); if (remaining == null || remaining == 0) { redisService.deleteObject(CacheConstants.CACHE_LOGIN_ROLES + userId); redisService.deleteObject(CacheConstants.CACHE_LOGIN_PERMISSIONS + userId); } }}@Overridepublic void revokeAllTokens(Long userId) { redisService.deleteObject(CacheConstants.CACHE_LOGIN_TOKEN + userId); redisService.deleteObject(CacheConstants.CACHE_LOGIN_REFRESH + userId); redisService.deleteObject(CacheConstants.CACHE_LOGIN_ROLES + userId); redisService.deleteObject(CacheConstants.CACHE_LOGIN_PERMISSIONS + userId);}private String getToken(ServerHttpRequest request) { // 1. 优先从 Authorization Header 获取(API 调用 / App 场景) String token = request.getHeaders().getFirst(SecurityConstants.AUTHORIZATION_HEADER); if (StringUtils.isNotEmpty(token) && token.startsWith(SecurityConstants.TOKEN_PREFIX)) { return token.replace(SecurityConstants.TOKEN_PREFIX, ""); }
// 2. 回退从 Cookie 获取(浏览器 / OAuth 回调场景) HttpCookie cookie = request.getCookies().getFirst(SecurityConstants.ACCESS_TOKEN_COOKIE); if (cookie != null && StringUtils.isNotEmpty(cookie.getValue())) { return cookie.getValue(); }
return null;}完整校验逻辑:
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String token = getToken(request);
// 1. 验签 Claims claims = JwtUtils.parseToken(token);
// 2. 校验 token_type,防止 Refresh Token 被用于访问 API String tokenType = claims.get(SecurityConstants.TOKEN_TYPE, String.class); if (!SecurityConstants.TOKEN_TYPE_ACCESS.equals(tokenType)) { return unauthorizedResponse(exchange, ResultCode.TOKEN_INVALID); }
// 3. 查 Redis 白名单 String userId = claims.get(SecurityConstants.USER_ID).toString(); String tokenKey = CacheConstants.CACHE_LOGIN_TOKEN + userId; Boolean isMember = redisService.isMemberOfSet(tokenKey, token); if (Boolean.FALSE.equals(isMember)) { return unauthorizedResponse(exchange, ResultCode.TOKEN_EXPIRED); }
// 4. 注入用户信息到 Header ServerHttpRequest mutatedRequest = request.mutate() .header(SecurityConstants.USER_ID, userId) .header(SecurityConstants.USER_NAME, claims.get(SecurityConstants.USER_NAME, String.class)) .header(SecurityConstants.ROLES_HEADER, getRolesFromRedis(userId)) .build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());}import { defineStore } from "pinia";import { ref, computed } from "vue";
export interface UserInfo { userId: number; username: string; nickname: string; avatar: string; roles: string[]; permissions: string[];}
export const useUserStore = defineStore("user", () => { const userId = ref<number | null>(null); const username = ref(""); const nickname = ref(""); const avatar = ref(""); const roles = ref<string[]>([]); const permissions = ref<string[]>([]);
const isLoggedIn = computed(() => userId.value !== null); const isAdmin = computed(() => roles.value.includes("admin"));
function setUserInfo(info: UserInfo) { userId.value = info.userId; username.value = info.username; nickname.value = info.nickname; avatar.value = info.avatar; roles.value = info.roles ?? []; permissions.value = info.permissions ?? []; }
function hasPermission(permission: string): boolean { if (isAdmin.value) return true; return permissions.value.includes(permission); }
function clear() { userId.value = null; username.value = ""; nickname.value = ""; avatar.value = ""; roles.value = []; permissions.value = []; }
return { userId, username, nickname, avatar, roles, permissions, isLoggedIn, isAdmin, setUserInfo, hasPermission, clear, };});import axios, { type InternalAxiosRequestConfig } from "axios";import { useUserStore } from "@/stores/user";
let isRefreshing = false;let pendingRequests: Array<() => void> = [];
const api = axios.create({ baseURL: "/api", withCredentials: true,});
api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; };
if (error.response?.status !== 401 || originalRequest._retry) { return Promise.reject(error); }
const errorCode = error.response.data?.code; const userStore = useUserStore();
// Access Token 过期,尝试刷新 if (errorCode === 10301) { if (!isRefreshing) { isRefreshing = true; try { await axios.post("/auth/refresh", null, { withCredentials: true }); isRefreshing = false; pendingRequests.forEach((cb) => cb()); pendingRequests = []; return api(originalRequest); } catch { isRefreshing = false; pendingRequests = []; userStore.clear(); window.location.href = "/login"; return Promise.reject(error); } }
return new Promise<void>((resolve) => { pendingRequests.push(() => { resolve(api(originalRequest)); }); }); }
// Refresh Token 过期,强制重新登录 if (errorCode === 10311) { userStore.clear(); window.location.href = "/login"; }
return Promise.reject(error); });| Key | 类型 | 说明 | TTL |
|---|---|---|---|
zsk:login:token:{userId} | Set | Access Token 白名单 | 30 min |
zsk:login:refresh:{userId} | Set | Refresh Token 白名单 | 7 d |
zsk:login:roles:{userId} | Set | 角色集合 | 7 d |
zsk:login:permissions:{userId} | Set | 权限集合 | 7 d |
Key 命名规范说明:
zsk:login: 表示认证域,便于按业务隔离。{userId} 为用户唯一标识,支持按用户精确清理。Cookie 下发时必须同时设置以下属性:
| 属性 | 取值 | 作用 |
|---|---|---|
HttpOnly | true | 阻止 JavaScript 读取 Cookie,防御 XSS 窃取 |
Secure | true | 仅 HTTPS 传输,防御中间人窃听 |
SameSite | Lax | 阻止跨站 POST / 跨站携带 Cookie,防御 CSRF |
Path | / | 全站 API 均可携带 |
SameSite=Lax 与 SameSite=Strict 的选择:
Lax:从外部站点通过链接跳转至本站时,GET 请求仍携带 Cookie,用户体验较好。Strict:任何跨站请求均不携带 Cookie,安全性最高,但可能影响正常外链回流。ZSK-Cloud 默认使用 Lax,后台管理类高敏感场景可单独配置为 Strict。
[配图:五层纵深防御体系]
| 层级 | 目标 | 措施 |
|---|---|---|
| 第 1 层:存储安全 | 让 Token 难以被窃取 | HttpOnly + Secure + SameSite=Lax |
| 第 2 层:Token 自身安全 | 即使被窃取,窗口极小 | Access Token 30 min 有效期、RS256 签名、不含敏感 Claims |
| 第 3 层:请求绑定 | 即使 Token 被窃取,也难以使用 | 设备指纹、Refresh Token 一次性轮换 |
| 第 4 层:行为监控 | 即使攻击者成功使用,也能发现 | 异地登录告警、并发使用检测、审计日志 |
| 第 5 层:应急响应 | 发现异常后快速止损 | 单设备踢出、全局吊销、用户自助安全中心 |
单个用户最多允许 5 个设备同时持有 Refresh Token。超过限制时,淘汰最早登录的设备:
private void storeTokenWithLimit(String key, String token, long timeout, TimeUnit unit, int maxSize) { Long size = redisService.getSetSize(key); if (size != null && size >= maxSize) { // 移除最早加入的成员(Set 无序,实际需改用 Sorted Set 按时间排序) // 生产环境建议使用 ZSet,score 为登录时间戳 Set<String> members = redisService.getSetMembers(key); if (members != null && !members.isEmpty()) { redisService.removeSetCacheObject(key, members.iterator().next()); } } redisService.setSetCacheObject(key, token); redisService.expire(key, timeout, unit);}风险:使用 Redis Set 无法精确按时间淘汰。生产环境建议改为 Sorted Set:
key: zsk:login:refresh:{userId} → ZSet member: refreshToken score: 登录时间戳新增设备时,若 ZCARD >= 5,则 ZREMRANGEBYRANK key 0 0 删除最旧的设备。
| 触发场景 | 响应动作 | 用户体验 |
|---|---|---|
| Access Token 被窃取 | 30 min 后自动失效 | 无感知 |
| Refresh Token 重放 | 吊销该用户所有 Token | 所有设备需重新登录 |
| 密码修改 / 重置 | 吊销该用户所有 Token | 所有设备需重新登录 |
| 用户主动踢出设备 | 删除该设备 Token | 被踢设备需重新登录 |
| 异地登录 | 发送告警邮件 | 邮件通知 |
跨域场景下,Cookie 下发需要服务端显式允许凭证:
@Beanpublic CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("https://*.zsk.com"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source);}若网关前部署 Nginx,需确保以下 Header 正确透传:
server { listen 443 ssl http2; server_name api.zsk.com;
location / { proxy_pass http://zsk-gateway; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# 允许跨域携带 Cookie add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Allow-Origin "https://www.zsk.com" always; }}private Cookie buildCookie(String name, String value, int maxAge, boolean httpOnly, boolean secure, String sameSite) { Cookie cookie = new Cookie(name, value); cookie.setMaxAge(maxAge); cookie.setHttpOnly(httpOnly); cookie.setSecure(secure); cookie.setPath("/"); // Spring Boot 2.6+ 支持 SameSite cookie.setAttribute("SameSite", sameSite); return cookie;}| 维度 | 当前状态 | 目标状态 |
|---|---|---|
| Token 类型 | 单 Access Token | Access Token + Refresh Token |
| Access Token 有效期 | 720 min | 30 min |
| Refresh Token | 未实现 | 7 d |
| Token 下发方式 | Response Body | HttpOnly Cookie |
| 前端存储 | 不统一 | Cookie + Pinia |
| 吊销能力 | 单设备退出 | 单设备退出 + 全局吊销 |
Phase 1:后端双 Token 改造
AuthServiceImpl 新增 Refresh Token 生成逻辑。CacheConstants.CACHE_LOGIN_REFRESHSecurityConstants.REFRESH_TOKEN_COOKIESecurityConstants.TOKEN_TYPE、TOKEN_TYPE_ACCESS、TOKEN_TYPE_REFRESHResultCode.REFRESH_TOKEN_EXPIRED(10311, "刷新令牌已过期")。LoginResponse 移除 accessToken / refreshToken 字段。Set-Cookie 下发 Token。Phase 2:网关适配
AuthFilter 校验 token_type=access,拒绝 Refresh Token 访问业务 API。/auth/refresh 放行,不校验 Access Token。Phase 3:前端适配
withCredentials: true。/auth/refresh。Phase 4:缩短 Access Token 有效期
TOKEN_EXPIRE 从 720 min 调整为 30 min。Phase 5:增强安全机制(可选)
每次请求需执行一次 SISMEMBER,复杂度为 O(1)。以单机 Redis 为例,单节点 QPS 可达 50,000+,不会成为瓶颈。
优化建议:
JWT 验签是 CPU 密集型操作。建议:
isRefreshing 标志 + 请求队列,避免多个 401 同时触发多次 /auth/refresh。BroadcastChannel 通知其他标签页登录状态变化,避免重复刷新。HttpOnly 阻断 JavaScript 读取 Cookie,但 XSS 仍可发起已认证请求(Cookie 会自动携带)。因此需配合:
SameSite=Lax 阻止跨站请求携带 Cookie。RS256 签名的 JWT 通常在 500–800 B,远小于 4 KB。若 Claims 过多导致超限,应精简 Claims,禁止在 JWT 中存放角色 / 权限列表。
常见原因:
Access-Control-Allow-Credentials: true。Access-Control-Allow-Origin 使用了通配符 *,与 Allow-Credentials: true 冲突。withCredentials: true。Domain 或 Path 与请求路径不匹配。Pinia 状态仅存于内存,各标签页独立。推荐方案:
BroadcastChannel 广播状态变更。原生 App 无法使用浏览器 Cookie 机制,推荐方案:
Authorization: Bearer {token} Header 接收 Token。本方案将 ZSK-Cloud 的认证体系从单 Token + Response Body 演进为 Access Token + Refresh Token 双令牌 + HttpOnly Cookie:
未覆盖的后续方向: