接手一个内部系统改造的时候,我遇到的最头疼问题就是:十几个子系统的登录逻辑各写各的,密码散落在不同的数据库里,改一次密码要跑五个地方。于是我用 Node.js 从零搭了一套基于 OAuth 2.0 的 SSO 单点登录体系,顺手把 refresh token 的完整刷新链路也理清楚了。这篇文章就是把当时的设计思路、踩坑记录和核心代码实现完整拆出来,给正在做统一认证或者准备接入 SSO 的同学一个可参考的落地方案。
这套方案解决的核心问题有三个:一是多个应用共用一套账号体系,用户登录一次就能访问所有子系统;二是 access token 短期有效、refresh token 长期换发,避免频繁弹登录框;三是把认证逻辑收拢到独立的 auth-server,子应用只负责校验 token,不碰用户密码。适合团队内部有多个 Node.js 或前后端分离项目、想统一登录入口的技术同学参考。
1. 整体设计与方案选型
1.1 这个权限体系到底要解决什么问题
先想清楚一个事:单纯做登录不难,难的是“一次登录,处处通行”。我当时的业务背景是这样——公司有运营后台、商家端、内部数据平台三个系统,分别是用不同框架写的,原本各自维护一套用户表。结果就是用户在三套系统里的密码不一致,每次换人交接账号都特别痛苦,更别提做权限审计的时候根本对不上号。
SSO 的核心思路很简单:把“验证身份”这个动作从各个业务系统里抽出来,放到一个独立的认证中心。用户只需要在认证中心登录一次,认证中心发一个全局凭证,其他系统拿着这个凭证去换自己的会话。这个凭证在 OAuth 2.0 体系里就是 authorization code 和 token,而在实际部署里我们通常还会配合一个 session cookie 来做会话保持。
这里有个容易混淆的点。很多同学以为 SSO 就是“统一登录页”,其实登录页只是表象。真正的难点在于:一个应用登录成功后,怎么让另一个应用也知道“这个人已经认证过了”。OAuth 2.0 的授权码模式天然适合做这件事,因为它把认证和授权分开了——认证中心负责确认“你是谁”,之后颁发的 code 和 token 负责告诉其他应用“这个人通过了验证”。
1.2 为什么选 Node.js + OAuth 2.0 而不是自研登录
有人可能会问:自己写一个登录接口,然后把用户信息塞进 Redis,每个应用启动的时候去 Redis 查一下不也行吗?技术上确实可行,但有几个很现实的问题。
第一,安全问题远比想象中复杂。密码哈希、暴力破解防护、会话固定攻击、CSRF、token 泄露后的吊销,这些搞一遍下来工作量非常大,而且很容易出漏洞。OAuth 2.0 是一个经过大规模验证的标准协议,直接落标准比自己从头摸黑要稳妥得多。
第二,业务系统可能不只是 Web 应用。我当时还需要给一个第三方小程序提供免登能力,给一个内部 API 网关做服务间鉴权。如果只做“登录成功后存 session”,这些场景全都得单独扩展。而 OAuth 2.0 的授权码模式、客户端凭证模式分别覆盖了“用户授权”和“应用间授权”两类场景,一套协议通吃。
第三,Node.js 在生态上有天然优势。jsonwebtoken、jose、openid-client 这些库都很成熟,express 写中间件做 token 校验也顺手。再加上 Node.js 的异步模型在处理 Redis 存储、HTTP 回调这些 I/O 密集操作时很从容,所以选择 Node.js 作为认证中心的实现语言非常合适。
1.3 SSO 与普通登录的本质区别
普通登录:用户在应用 A 输入账号密码 -> 应用 A 创建自己的 session -> 用户访问应用 B,需要再登录一次。
SSO 登录:用户在认证中心输入账号密码 -> 认证中心创建全局会话,同时生成授权码返回给应用 A -> 应用 A 拿授权码去认证中心换 token -> 用户访问应用 B,应用 B 发现没有会话,重定向到认证中心 -> 认证中心发现用户已经有全局会话,直接发授权码给应用 B,不需要用户再次输入密码。
这个流程里最关键的是“认证中心知道用户已经登录过”这个状态。实现上最常见的就是基于 cookie 的 session,用户第一次登录后认证中心种下一个 cookie,后续所有子系统的跳转登录请求都会带上这个 cookie,认证中心直接放行。
但 cookie 有域的限制。如果认证中心和业务系统不同域,跨域种 cookie 会非常麻烦。我当时为了避免这个坑,把认证中心和所有子系统放在了同一个顶级域名下,通过子域名区分。后来也看到有人用 iframe + postMessage 的方式做跨域 session 同步,那个方案更复杂,不建议新手直接上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:access token、refresh token 与 SSO 会话的关系
2.1 三张凭证各管什么
在讲代码之前,必须把这三样东西的角色分清楚。很多后端同学在这上面栽过跟头,主要是把 access token 和 refresh token 的职责搞混了。
access token 是“短期通行证”。它发给用户,用户拿着它去访问业务 API。设计上它的有效期很短,我一般设 15 分钟到 2 小时。短的原因很现实:token 一旦发出,在过期之前是没法主动让所有持有者失效的(除非做黑名单)。所以有效期越短,泄露后的风险窗口越小。
refresh token 是“续期凭证”。access token 过期后,客户端拿 refresh token 向认证中心换新的 access token,不需要用户重新输入密码。它的有效期可以很长,我一般设 7 到 30 天。但它对应着“用户的长期登录状态”,一旦泄露,等于账号被人长期接管,所以它必须安全存储,而且必须以“一次性”的方式使用。
SSO session 是“登录状态”。它存在于认证中心的服务端,以 cookie 形式种在浏览器里。它的作用是:当用户被重定向到认证中心时,认证中心确认“这个人登录过”,然后快速发码。它和 refresh token 的关系是:用户退出登录时,SSO session、refresh token 需要一起吊销。
2.2 为什么 refresh token 必须是“一次性”的
这是我从真实事故里总结出来的教训。最开始我做 refresh token 的时候,图省事,发一个长效 token 然后一直复用。后来出现了一个问题:用户手机丢失,我们吊销了 refresh token,但用户电脑上的客户端还握着另一个 refresh token,依然能正常换新 token。等于吊销了个寂寞。
OAuth 2.0 官方文档里其实推荐了一种做法叫 refresh token rotation(刷新令牌轮换)。核心逻辑是:每次使用 refresh token 换新 token 时,认证中心先验证旧 token 是否有效,然后立刻把旧 token 作废,签发一个新的 refresh token 给客户端。这样每一个 refresh token 只能被使用一次,令牌泄露后的风险窗口大幅缩小。
更细一点,业界普遍还会加一层检测:如果认证中心发现同一个 refresh token 被重复使用了两次,说明可能有人盗用,直接把整个 token 族全部吊销,让用户重新登录。这个叫 refresh token reuse detection。我在项目里实现了这一层,代码量并不大,但安全性提升非常明显。
2.3 密钥与存储选型
token 本身只是凭证,真正决定安全的是背后的存储和密钥管理。
access token 我用了 JWT 格式,用 RS256 非对称签名。认证中心持有私钥,业务系统持有公钥。这样业务系统校验 token 时不需要调用认证中心的接口,只需要本地用公钥验签。这个设计对高并发场景很友好,减少了一次网络往返。
refresh token 我不建议用 JWT。因为 refresh token 需要能“吊销”,而 JWT 是无状态的,除非做黑名单否则无法提前失效。我用了不透明字符串(opaque token),生成一个随机值存在 Redis 里,value 是用户 ID、token 族 ID、过期时间等结构化数据。每次校验都读 Redis,吊销就是删除 key。
存储方面,refresh token 的 Redis key 我用的是 rt:{tokenId},value 是一个 JSON。另外维护了一个 rt_family:{familyId} 的集合,存这个 token 族下所有已签发但已失效的 token。检测重复使用的时候,只要看这个集合里有没有对应记录就行。
3. 实操搭建:从零实现授权码模式与 refresh token 轮换
3.1 项目目录与基础依赖
下面是我当时整理的目录结构,直接可以作为参考起步:
text复制auth-server/
├── src/
│ ├── config/
│ │ └── index.js
│ ├── routes/
│ │ ├── authorize.js
│ │ ├── token.js
│ │ ├── revoke.js
│ │ └── jwks.js
│ ├── services/
│ │ ├── clientService.js
│ │ ├── tokenService.js
│ │ └── userService.js
│ ├── middleware/
│ │ └── auth.js
│ └── utils/
│ └── crypto.js
├── keys/
│ ├── private.pem
│ └── public.pem
├── package.json
└── .env
依赖只用了几个核心库,尽量少引依赖减少维护成本:
json复制{
"dependencies": {
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"redis": "^4.6.13",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5"
}
}
express 做路由,jsonwebtoken 签发和校验 JWT,redis 存 refresh token,crypto 内置模块生成随机字符串。你可能会问为什么不用 Passport.js 这类更重的库,我的经验是:认证中心这种核心基础设施,自己掌握每个环节的细节比封装好的黑盒更让人放心,排错也容易。
3.2 客户端注册与授权端点
先做客户端注册。每个要接入 SSO 的子系统,都需要先在认证中心注册一个 client_id 和 client_secret,同时配置回调地址。回调地址的校验非常重要,不校验的话会被人拿去构造钓鱼链接。
javascript复制// src/services/clientService.js
const clients = new Map();
// 实际生产环境请存数据库
clients.set('web-console', {
clientId: 'web-console',
clientSecret: 'a3f8c9...',
redirectUris: ['https://console.example.com/callback'],
grants: ['authorization_code'],
});
function getClient(clientId) {
return clients.get(clientId);
}
function validateRedirectUri(clientId, redirectUri) {
const client = getClient(clientId);
if (!client) return false;
return client.redirectUris.includes(redirectUri);
}
module.exports = { getClient, validateRedirectUri };
授权端点是整个 SSO 流程的入口。用户在浏览器访问业务系统,业务系统发现未登录,就把用户重定向到 https://auth.example.com/authorize?client_id=web-console&redirect_uri=...&response_type=code&state=xyz。
state 参数一定要带上,它是防 CSRF 的关键。我在实际项目里遇到过没有校验 state 导致登录劫持的案例,这里多写几行代码非常值得。
javascript复制// src/routes/authorize.js
const express = require('express');
const router = express.Router();
const { validateRedirectUri, getClient } = require('../services/clientService');
const { createAuthorizationCode } = require('../services/tokenService');
const { checkSession } = require('../middleware/auth');
router.get('/authorize', checkSession, async (req, res) => {
const { client_id, redirect_uri, response_type, state } = req.query;
if (response_type !== 'code') {
return res.status(400).send('unsupported response_type');
}
if (!validateRedirectUri(client_id, redirect_uri)) {
return res.status(400).send('invalid redirect_uri');
}
// 如果认证中心没有用户的登录 session,重定向到登录页
if (!req.session.userId) {
const redirectUrl = `/login?returnTo=${encodeURIComponent(req.originalUrl)}`;
return res.redirect(redirectUrl);
}
// 用户已登录,生成一次性授权码
const code = await createAuthorizationCode({
userId: req.session.userId,
clientId: client_id,
redirectUri: redirect_uri,
});
const redirectTarget = new URL(redirect_uri);
redirectTarget.searchParams.set('code', code);
if (state) {
redirectTarget.searchParams.set('state', state);
}
res.redirect(redirectTarget.toString());
});
module.exports = router;
这里有个细节值得注意:授权码只能使用一次,并且要设置极短的有效期,通常 1 到 5 分钟就够。授权码存 Redis,key 为 auth_code:{code},使用后立即删除。这样可以防止授权码被截获后重放。
3.3 token 端点的完整实现
用户拿到授权码后,业务系统后端拿授权码 + client_id + client_secret 去 token 端点换 token。这个请求是客户端直接发起,不是浏览器发起,所以不需要 cookie。
javascript复制// src/routes/token.js
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const router = express.Router();
const { getClient } = require('../services/clientService');
const { getRedis, setRedis, deleteRedis } = require('../utils/redis');
const { generateTokenPair } = require('../services/tokenService');
const privateKey = process.env.JWT_PRIVATE_KEY;
router.post('/token', async (req, res) => {
const { grant_type, code, refresh_token, client_id, client_secret, redirect_uri } = req.body;
const client = getClient(client_id);
if (!client || client.clientSecret !== client_secret) {
return res.status(401).json({ error: 'invalid_client' });
}
// 处理授权码换取 token
if (grant_type === 'authorization_code') {
const authCodeKey = `auth_code:${code}`;
const codeData = await getRedis(authCodeKey);
if (!codeData) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'authorization code expired or already used' });
}
if (codeData.clientId !== client_id || codeData.redirectUri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'code and redirect_uri mismatch' });
}
// 授权码一次性使用,立刻删除
await deleteRedis(authCodeKey);
const { accessToken, refreshToken, familyId } = await generateTokenPair({
userId: codeData.userId,
clientId: client_id,
scope: 'openid profile',
});
// 存储 refresh token
const refreshKey = `rt:${refreshToken}`;
await setRedis(refreshKey, JSON.stringify({
userId: codeData.userId,
clientId: client_id,
familyId,
revoked: false,
}), 30 * 24 * 60 * 60); // 30 天过期
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
});
}
// 处理 refresh token 换发新 token
if (grant_type === 'refresh_token') {
// 核心逻辑见 3.4 节
}
return res.status(400).json({ error: 'unsupported_grant_type' });
});
module.exports = router;
验 client 的时候有个容易忽略的坑:client_secret 的比较一定要用时间恒定的字符串比较,不能直接 ===。因为直接比较在遇到不匹配时会提前退出,攻击者可以根据响应时间做侧信道攻击。虽然很细微,但认证中心这类基础设施值得做严谨。
3.4 refresh token 轮换与复用检测
这是整篇文章的重头戏。refresh token 轮换的完整逻辑包括:验证旧 token -> 确认没有使用过 -> 作废旧 token -> 生成新 token -> 记录旧 token 到家族黑名单。下面是核心代码。
javascript复制// src/routes/token.js 续:refresh_token 处理逻辑
if (grant_type === 'refresh_token') {
const oldRefreshToken = refresh_token;
const refreshKey = `rt:${oldRefreshToken}`;
const stored = await getRedis(refreshKey);
if (!stored) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token not found or expired' });
}
const data = JSON.parse(stored);
if (data.revoked) {
// 重复使用检测:如果这个 old refresh token 是已轮换掉的,说明可能有人盗用
// 此时应吊销整个 token 家族,强制用户重新登录
await revokeFamily(data.familyId);
return res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token reuse detected, family revoked' });
}
if (data.clientId !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token client mismatch' });
}
// 轮换:旧 token 立即作废
await setRedis(refreshKey, JSON.stringify({ ...data, revoked: true }), 60 * 60);
// 记录到家族黑名单,用于复用检测
const familyBlacklistKey = `rt_family_blacklist:${data.familyId}`;
await setRedis(`${familyBlacklistKey}:${oldRefreshToken}`, 'revoked', 48 * 60 * 60);
// 生成新 token 对
const { accessToken, refreshToken, familyId } = await generateTokenPair({
userId: data.userId,
clientId: client_id,
scope: 'openid profile',
});
// 如果 familyId 变了说明实现有问题,这里应该保持同一个 familyId
const newData = {
userId: data.userId,
clientId: client_id,
familyId: data.familyId,
revoked: false,
};
const newRefreshKey = `rt:${refreshToken}`;
await setRedis(newRefreshKey, JSON.stringify(newData), 30 * 24 * 60 * 60);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
});
}
这个逻辑里,我把所有已轮换掉的旧 refresh token 标记为 revoked: true,但并不立即删除。这样做的目的是为了检测“同一个 refresh token 被再次使用”——如果一个被轮换过的 token 又出现在请求里,说明拿到这个 token 的客户端可能有两个(一个是你自己,一个是黑客),这时候直接吊销整个 token 家族的资格是最安全的选择。
有个细节是,我同时把旧 token 加入了家族黑名单,但设置了 48 小时过期。这个时间要比 refresh token 的有效期短,避免黑名单无限膨胀。因为 token 在轮换后最多只会被重放一小段时间,过期后就没必要继续留着了。
3.5 JWT access token 的生成与校验
access token 我用 JWT 签名,业务方用公钥验签。这样设计的好处前面说过了,不用每次校验都请求认证中心。
javascript复制// src/services/tokenService.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const privateKey = process.env.JWT_PRIVATE_KEY;
const publicKey = process.env.JWT_PUBLIC_KEY;
function generateTokenPair({ userId, clientId, scope }) {
const accessToken = jwt.sign(
{
sub: userId,
client_id: clientId,
scope,
},
privateKey,
{
algorithm: 'RS256',
issuer: 'https://auth.example.com',
audience: 'api-gateway',
expiresIn: '1h',
}
);
const refreshToken = crypto.randomBytes(32).toString('hex');
const familyId = crypto.randomBytes(16).toString('hex');
return { accessToken, refreshToken, familyId };
}
function verifyAccessToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api-gateway',
});
}
module.exports = { generateTokenPair, verifyAccessToken };
密钥生成我用的是 openssl 命令行:
bash复制openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
私钥放在认证服务器的环境变量或密钥管理服务里,公钥可以放业务系统仓库。要注意的是公钥要定期轮换,轮换期间新旧公钥都要保留一段时间,避免正在使用的 token 突然全部失效。
在实际接入的时候,业务系统只需要这样写:
javascript复制// 业务系统中的 token 校验中间件
const { verifyAccessToken } = require('./authService');
function authRequired(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'unauthorized' });
}
try {
const payload = verifyAccessToken(authHeader.slice(7));
req.user = { id: payload.sub, scope: payload.scope };
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token' });
}
}
3.6 SSO 客户端接入的关键点
业务子系统接入 SSO 的核心流程可以抽象为三步:未登录 -> 跳转认证中心 -> 回调拿到 code -> 后端换 token -> 存到自己会话。下面是客户端核心逻辑。
javascript复制// sso-client 示例:express 应用接入
const express = require('express');
const axios = require('axios');
const app = express();
const AUTH_SERVER = 'https://auth.example.com';
const CLIENT_ID = 'web-console';
const CLIENT_SECRET = 'a3f8c9...';
const REDIRECT_URI = 'https://console.example.com/callback';
function isLoggedIn(req) {
return !!req.session.accessToken;
}
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.state = state;
const authUrl = new URL(`${AUTH_SERVER}/authorize`);
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', state);
res.redirect(authUrl.toString());
});
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) {
return res.status(400).send('state mismatch');
}
const tokenRes = await axios.post(`${AUTH_SERVER}/token`, new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}));
req.session.accessToken = tokenRes.data.access_token;
req.session.refreshToken = tokenRes.data.refresh_token;
res.redirect('/dashboard');
});
这里有个我觉得很重要的经验:业务系统自己的 session 有效期应该明显短于 refresh token 的有效期。我把业务系统的 session 设为 30 分钟,但 refresh token 有效期是 30 天。这样用户每 30 分钟需要静默刷新一次,但因为有 refresh token,用户完全无感知。而如果业务系统的 session 有效期等于 refresh token 的有效期,refresh token 就等于形同虚设,而且一旦被窃取,攻击者的访问窗口会特别长。
4. 常见问题与排查技巧实录
4.1 400 bad request: invalid 'refresh_token': empty string
这个报错我最开始接入第三方应用的时候经常遇到,核心原因很简单:客户端发请求时,refresh token 是空的。通常是两个地方出错。
一是前端没有把 refresh token 存进请求体,后端取到空字符串。我之前遇到过有人把 refresh token 放在 Authorization header 里,服务器却从 body 里取,结果自然是空。
二是 post 的 content-type 不对。token 端点要求 application/x-www-form-urlencoded 格式,如果客户端用 application/json 发请求,req.body.refresh_token 是 undefined,再一序列化就变成了 empty string。排查这种问题时,用 curl 手动发一遍请求最直接:
bash复制curl -X POST https://auth.example.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=xxx&client_id=web-console&client_secret=xxx"
如果 curl 正常但代码里报错,基本可以确定是客户端代码的问题。我在接入 Node.js 的 fetch 时踩过这个坑,fetch 默认的 JSON 序列化不会自动转成 urlencoded。
4.2 your access token could not be refreshed because your refresh token was revoked
这个提示说的是 refresh token 已经被吊销了,但客户端还在拿它换新的 access token。可能的原因有几种:
- 用户已经退出登录,服务端主动吊销了 refresh token,但客户端的本地存储里还留着旧的 refresh token。
- refresh token 被轮换过,客户端使用了一个已经被替换掉的旧 refresh token。
- 服务端因为检测到异常行为(比如重复使用),把整个 token 家族都吊销了。
在实际排障时候,我一般先去 Redis 里查一下这个 refresh token 的状态:
bash复制redis-cli GET rt:xxx
如果查到 revoked: true,说明是被轮换取代的旧 token,客户端应该去使用最新的 refresh token 而不是旧的。如果直接查不到,说明 token 已过期或被显式删除。
一个很重要但容易被忽略的设计是:客户端拿到新的 refresh token 后,必须立刻替换本地存储的旧值。如果客户端用的是 localStorage 或内存变量,要写成“先存新值再覆盖旧值”的顺序。我在实际中遇到过客户端因为并发请求导致旧 token 覆盖新 token 的诡异问题,后面用了一个简单的方案:本地只保存最新 token,刷新操作加锁,避免并发写覆盖。
4.3 并发刷新导致旧 refresh token 失效
这个问题非常典型:页面加载时同时发了两个 API 请求,两个请求都发现 access token 过期,于是同时发起 refresh,结果第一个请求成功轮换了 refresh token,第二个请求拿旧 token 去刷,被服务端判定为“复用检测”,整个 token 家族被吊销。用户被强制重新登录,体验极差。
市面上主流的解法是“客户端单飞模式”(single-flight),核心是让并发的刷新请求共享同一个 Promise,而不是各自执行。
javascript复制let refreshPromise = null;
async function getAccessToken() {
if (accessToken && !isTokenExpired(accessToken)) {
return accessToken;
}
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
服务端代码我也做了一点配合:如果检测到同一个 family 下的旧 token 被使用,先不急着吊销整个家族,而是等待几秒,如果紧接着收到新 token 的合法请求,就认为这是客户端并发刷新的误判。这个缓解方案能救回相当一部分并发场景,但只推荐配合客户端单飞使用,不能作为主要防线。
4.4 id_token 校验时报时钟偏移错误
接入 OIDC(OpenID Connect)后多了一个 id_token,它也是 JWT。校验的时候有个经典坑:认证服务器和业务服务器的系统时间不一致,导致 nbf(not before)或 exp(expiration)校验失败。
我遇到过最夸张的一次是业务服务器的系统时间慢了 5 分钟,导致所有新签发的 token 在业务端都显示“尚未生效”。排查方法很简单:登录认证服务器和业务服务器分别执行 date,对比时间差异。
解决方式是配置时钟偏移容忍度。jsonwebtoken 库支持直接传 clockTolerance:
javascript复制const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api-gateway',
clockTolerance: 30, // 允许 30 秒偏移
});
但我建议把容忍度控制到 30 秒以内,不要给太大。NTP 同步正常的情况下服务器之间的时间差不会超过几秒。给太大的容忍度会缩短 token 的实际有效时间,在安全性和便利性之间需要取舍。
4.5 退出登录时 token 清理的顺序问题
退出登录是很多人最后才考虑的事,但恰恰是 SSO 体系里最容易出问题的环节。一次完整的退出需要做三件事:删除业务系统本地 session、吊销 refresh token、销毁认证中心的 SSO session。顺序不对会导致用户退出后又被自动登录回来。
我踩过的坑是:只删除了业务系统的 session,没有吊销认证中心的 SSO cookie。结果用户下次跳转登录时,认证中心发现 cookie 还在,直接发了一个新 code,等于“退出失败”。
正确的做法是先请求认证中心的 logout 接口销毁 SSO session,再吊销本地 refresh token,最后清理业务系统的 session。顺序是:先服务端吊销,再客户端清理。日志记录也要做全,出问题时能定位到是哪个环节没执行到位。
最后再分享一个小技巧
做了这么久的认证系统,我最大的体会是:认证中心这种东西,宁可做得“重”一点,也不要图省事。很多看起来无关紧要的细节,上线之后都会变成事故点。比如 refresh token 的存储结构、黑名单的过期时间、并发刷新的兜底方案,这些当时多花半小时,后面能省好几天。
如果你也是从零开始搭 SSO,建议先不要急着追求功能完整,而是把一个最小闭环跑通:注册客户端 -> 授权码登录 -> 换 token -> 访问资源 -> refresh token 续期 -> 退出登录,每条链路都走通之后再逐步加复杂度。这个最小闭环我在这篇文章里基本都覆盖到了,代码可以直接按着敲一遍,对照着调试理解会深很多。
如果后续要做多环境部署,建议把密钥管理、审计日志、token 吊销通知这三件事优先补上。尤其是审计日志,出安全事故的时候,它是你唯一能依赖的定位工具。
