鉴权认证
认证(Authentication)解决"你是谁",授权(Authorization)解决"你能做什么"。这两件事被混为一谈,是绝大多数鉴权事故的起点。本文沿着 Session-Cookie → JWT → OAuth 2.0/OIDC 的演进顺序,讲清每种方案的信任边界与代价,最后落到密码存储、Token 存放位置、刷新轮换等真正容易出事的地方。
一、先把概念分清楚
| 概念 | 回答的问题 | 典型载体 | 常见混淆 |
|---|---|---|---|
| 认证 Authentication | 你是谁 | 密码、短信码、OIDC 的 ID Token | 把 Access Token 当成"登录凭证"用 |
| 授权 Authorization | 你能做什么 | Role、Scope、ACL、策略引擎 | 把角色写死在前端菜单里当权限 |
| 凭证 Credential | 凭什么证明 | Session ID、JWT、API Key | 把凭证放进 URL(会被日志/Referer 泄露) |
| 身份 Identity | 你是哪个主体 | sub、用户 ID、租户 ID | 多租户下缺租户维度导致越权 |
关系速记:OAuth 2.0 是授权框架(发 Access Token),OIDC 是它上面的认证层(发 ID Token)。用 OAuth 2.0 做"登录"之所以别扭,是因为你拿着一个只说明"能访问什么"的令牌,去反推"是谁"。
二、Session-Cookie:最朴素,也最可控
1. POST /login {username, password}
2. 服务端校验通过 → 生成随机 sessionId(128bit 熵)→ 存 session 存储
3. Set-Cookie: sid=abc...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
4. 后续请求自动携带 Cookie → 服务端按 sid 查 session → 得到用户身份
5. 登出:服务端删 session(主动失效立即生效)2.1 Cookie 三个必须配的属性
| 属性 | 作用 | 不配的后果 |
|---|---|---|
HttpOnly | JS 读不到 document.cookie | XSS 成功即可直接偷走会话(见 前端安全) |
Secure | 仅 HTTPS 传输 | 明文 HTTP 下被中间人截获 |
SameSite=Lax/Strict | 限制跨站请求携带 | CSRF:用户在 A 站点着,B 站悄悄发请求 |
SameSite=None 必须同时带 Secure,用于需要跨站携带的合法场景(如嵌入第三方页面)。
2.2 服务端 session 放哪
// Express + Redis 会话(示意:只保留核心链路)
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
app.use(session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false, // 未登录不发 Cookie,减少无意义存储
rolling: true, // 活跃用户顺延过期
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}))| 存储 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 进程内存 | 零依赖、最快 | 重启即丢、多副本不一致 | 本地开发 / 单实例小工具 |
| Redis | 共享、可设 TTL、可主动删 | 多一个依赖与网络跳数 | 绝大多数生产场景的默认选择 |
| 数据库 | 无需新组件 | 每次请求打库、压力大 | 低流量后台 |
Session 的"有状态"常被视为缺点,但它换来的是服务端可主动失效——这是安全事件响应时最值钱的能力(改密后踢掉所有设备、封禁用户立刻生效)。
三、JWT:无状态的诱惑与代价
JWT 由三段组成:header.payload.signature,均以 base64url 编码后用 . 连接。
import crypto from 'node:crypto'
const b64uJson = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const header = { alg: 'HS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const payload = { sub: 'user-1001', role: 'admin', iat: now, exp: now + 900 }
const signingInput = `${b64uJson(header)}.${b64uJson(payload)}`
const sig = crypto.createHmac('sha256', process.env.JWT_SECRET).update(signingInput).digest('base64url')
const token = `${signingInput}.${sig}`Node v22.22.1 实测(密钥 super-secret-key,payload 含 sub/role/iat/exp):
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEwMDEiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODgzNDcxMjQsImV4cCI6MTc4ODM0ODAyNH0.digYKVaHDovSD5N_32_mnmFWqkHtYqWXLRqR1guVauo
长度: 172 字符
header 解码: {"alg":"HS256","typ":"JWT"}
payload 解码: {"sub":"user-1001","role":"admin","iat":1788347124,"exp":1788348024}
验签(正确密钥): true
验签(错误密钥): false
验签(篡改 role 为 superadmin): false三个结论要记住:
- base64url 不是加密,payload 只是"能还原的字符串",别在里面放手机号、身份证、内部定价;
- 签名只保护完整性,改一个字节签名就对不上(实测
false); - 不验签的 JWT 等于明文——务必拒绝
alg: none,并固定服务端期望的算法白名单。
3.1 无状态换来的三个麻烦
| 麻烦 | 原因 | 常见解法 |
|---|---|---|
| 无法主动失效 | 服务端不存状态,签发后到 exp 前都有效 | access token 短(5–15 分钟)+ refresh token 可吊销 |
| 续期语义别扭 | 快过期时换新 token,旧 token 仍在有效期内 | 刷新窗口收窄 + 关键操作再校验 |
| 令牌体积 | 每次请求都带,含 claim 越多越大 | 只放必要 claim(sub/tenant/role/exp),其余查库 |
// 吊销能力:用 Redis 维护"用户凭证版本号"
async function issueAccessToken(user) {
const ver = await redis.get(`token_ver:${user.id}`) ?? '1'
return sign({ sub: user.id, tenant: user.tenantId, ver, exp: nowSec() + 900 })
}
async function verify(token) {
const claims = verifySignature(token) // 先验签 + exp
const ver = await redis.get(`token_ver:${claims.sub}`)
return claims.ver === ver ? claims : null // 版本号不一致即失效
}
async function revokeAll(userId) { // 改密 / 封禁 / 登出所有设备
await redis.incr(`token_ver:${userId}`)
}这个"版本号"方案只引入一次 Redis 读,却拿回了主动失效能力,是 JWT 落地里性价比最高的一招。完全不需要失效能力的场景(内部服务间短时调用)才用纯无状态。
四、OAuth 2.0 与 OIDC
4.1 四种角色
| 角色 | 是谁 | 例子 |
|---|---|---|
| Resource Owner | 用户 | 你本人 |
| Client | 第三方应用 | 某个笔记 App |
| Authorization Server | 发令牌的 | 微信/Google 登录服务 |
| Resource Server | 提供数据的 API | 用户资料接口 |
4.2 授权模式怎么选
| 模式 | 场景 | 现状 |
|---|---|---|
| 授权码 + PKCE | Web 应用、SPA、移动端 | 默认选择,SPA/移动端必须用 PKCE |
| 客户端凭证 Client Credentials | 服务端对服务端,无用户上下文 | 机器间调用的标准做法 |
| 设备授权 Device Code | 电视/CLI 等无浏览器设备 | 输入受限设备 |
| 刷新令牌 Refresh Token | 换取新的 access token | 需轮换与重用检测 |
| 早期 SPA 方案 | 已废弃,令牌暴露在 URL | |
| 直接拿用户名密码换令牌 | 不推荐,仅在自有第一方客户端 |
4.3 授权码 + PKCE 时序
1. Client 生成随机 code_verifier,派生 code_challenge = BASE64URL(SHA256(code_verifier))
2. 浏览器跳转:/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&state=xyz
3. 用户认证并同意 → 302 回 redirect_uri?code=AUTH_CODE&state=xyz
4. Client 校验 state 一致(防 CSRF)
5. Client 用 code + code_verifier(不传 client_secret)POST /token 换令牌
6. AS 校验 SHA256(code_verifier) === code_challenge → 发 access_token / id_token / refresh_tokenPKCE 的价值:即使授权码被截获,没有 code_verifier 也换不到令牌,因此公开客户端(SPA、App)不再需要把 client_secret 编进前端。
4.4 ID Token 与 Access Token
| ID Token | Access Token | |
|---|---|---|
| 用途 | 证明"用户是谁"(给客户端看) | 证明"能访问什么"(给资源服务器看) |
| 格式 | 必须是 JWT | 可以是 JWT,也可以是不透明串 |
| 消费方 | Client | Resource Server |
| 校验重点 | iss/aud/exp/nonce + 签名 | 签名、aud、scope、exp |
最常见的错:拿 ID Token 去调 API。ID Token 的受众是本客户端,资源服务器应当校验
aud是否包含自己——拿 ID Token 调接口,等于把认证凭证当授权凭证用。
五、落地要点:真正出事的地方
5.1 密码存储
import crypto from 'node:crypto'
// 仅为演示"成本参数"对耗时的影响;生产推荐 argon2id / bcrypt 专用库
for (const N of [16384, 32768]) {
const t0 = performance.now()
crypto.scryptSync('password123', crypto.randomBytes(16), 64, { N, r: 8, p: 1, maxmem: 128 * N * 8 * 4 })
console.log(`N=${N} cost=${(performance.now() - t0).toFixed(1)}ms`)
}
// Node v22.22.1 实测:N=16384 → 59.5ms,N=32768 → 115.4ms(参数翻倍,耗时翻倍)| 算法 | 推荐 | 说明 |
|---|---|---|
| Argon2id | 首选 | 抗 GPU/ASIC,需调 memory/iterations/parallelism |
| bcrypt | 可接受 | 广泛支持,但有 72 字节截断,长密码要先哈希 |
| scrypt | 可接受 | 内存硬,参数换算要小心(见上方 maxmem) |
| PBKDF2 | 兼容遗留 | 迭代次数要很高,性价比最低 |
| MD5/SHA1/SHA256 裸哈希 | 禁止 | 加盐也挡不住 GPU 彩虹表 |
5.2 Token 放哪里
| 存放位置 | XSS 风险 | CSRF 风险 | 适用 |
|---|---|---|---|
localStorage | 高:任意 JS 可读 | 低:需手动加 Header | 不推荐放长期凭证;只放可短时失效的 token 且 XSS 防线过硬 |
httpOnly Cookie | 低:JS 读不到 | 需防:SameSite + CSRF Token/自定义头 | 浏览器场景首选 |
| 内存(变量) | 最低:刷新即失 | 需自行处理 | SPA 配合 refresh 轮换的最佳实践 |
结论不是"JWT 必须放 localStorage",而是:凭证能不放 JS 可达处就不放;放 Cookie 就把
SameSite/CSRF 补齐,放前端存储就把 XSS 防线补齐(CSP、转义、依赖治理,见 前端安全)。
5.3 刷新令牌轮换与重用检测
1. refresh_token 与 access_token 一起签发,refresh 有效期长(7–30 天)
2. 用 refresh 换新令牌时,服务端同时签发一个新 refresh 并作废旧的(轮换)
3. 若收到一个已作废的 refresh → 判定泄露,吊销该用户整条令牌链并告警
4. refresh 端点独立限流,且要求 client 认证5.4 权限模型
| 模型 | 表达 | 适用 |
|---|---|---|
| RBAC | 用户 → 角色 → 权限 | 绝大多数业务系统,够用且易懂 |
| ABAC | 属性 + 策略规则(时间/地域/设备) | 规则复杂、需要动态判断 |
| ReBAC | 关系图谱(文档-团队-成员) | 协作类产品的分享权限 |
无论哪种:前端隐藏菜单只是体验,真正的校验必须在服务端每次请求都做。越权(IDOR)常年排漏洞榜前列,根因就是"前端没显示就没人能访问"的错误假设。
5.5 网关统一鉴权
Client → 网关:校验签名/过期/受众 → 注入 X-User-Id、X-Tenant-Id → 转发内部服务
内部服务:信任网关注入的身份头(但必须校验请求来源为网关)- 网关只做通用校验(签名、过期、scope),业务权限留给各服务;
- 内部服务之间也要有身份(mTLS 或服务级 token),内网不等于可信;
- 注入头必须在入口统一剥离外部传入的同名头,防止客户端伪造
X-User-Id。
六、设计检查清单
- 认证与授权分离:Access Token 只携带授权信息,登录态由 OIDC 的 ID Token 或 session 表达;
- 口令传输全程 HTTPS,服务端存的是慢哈希(argon2id/bcrypt)+ 每用户独立盐;
- Cookie 三件套齐全:
HttpOnly+Secure+SameSite; - Access Token 短(5–15 分钟),Refresh Token 轮换 + 重用检测;
- 关键操作(改密、改绑手机、支付)重新认证,不复用长令牌;
- 服务端固定算法白名单,拒绝
alg: none与密钥混淆(HS256/RS256 混用漏洞); - 每次请求都做服务端鉴权,不依赖前端菜单/按钮隐藏;
- 多租户场景,身份里必须带租户维度,每个查询都带上租户过滤;
- 提供主动失效能力(session 删除或 token 版本号),改密/封禁后立刻生效;
- 登录、改密、异常地点登录失败等事件有审计日志与告警。
七、常见坑速查
- 把 JWT 当加密用:base64url 只是一层编码,payload 谁都能解——敏感信息一律不放;
- 不验签直接解 payload:
JSON.parse(atob(段))就当作已认证身份,等于裸奔; alg信任客户端传值:alg: none或 RS256→HS256 降级攻击,必须服务端白名单;- Token 永不过期或 exp 过长:泄露后长期有效——短 access + 可吊销 refresh;
- 登出只删前端 token:服务端不失效,令牌依旧可用——必须服务端侧失效;
- refresh token 不做轮换:一次泄露长期可用;不做重用检测则无法发现泄露;
- 拿 ID Token 调 API:受众不对,资源服务器必须校验
aud; - Session 固定攻击:登录成功后未更换 sessionId,攻击者诱导用户使用已知 sid——登录成功必须重新生成;
- Cookie 漏配 SameSite:CSRF 直接可用;
SameSite=None忘配Secure则 Cookie 被浏览器拒绝; - 权限只做在前端:隐藏按钮不算权限控制,接口必须独立校验(越权/IDOR)。
状态与参考
- 状态:已收录(2026-09-02,由后端领域规划清单「鉴权认证|JWT、OAuth 2.0、Session 与会话管理」新建)。
- 版本:JWT 结构、OAuth 2.0 授权码 + PKCE、OIDC 的 ID/Access Token 区分均为 RFC 标准流程,与语言无关;文中示例基于 Node v22.22.1 内置
node:crypto与 Express/Redis 常规用法实测,可直接运行验证。 - 参考:RFC 7519 JWT、RFC 6749 OAuth 2.0、RFC 7636 PKCE、OIDC Core、OWASP Password Storage Cheat Sheet、OWASP Session Management。
- 阅读联动:XSS/CSRF/CSP 防线见 前端安全;会话存储与 TTL 策略见 缓存;网关、熔断与限流见 微服务;多副本会话与配置见 容器与部署。
下一步
- [ ] 用
node:crypto实现 RS256 签发/验签(公私钥分离),对比 HS256 的密钥分发差异 - [ ] 跑通一次完整的 OIDC 授权码 + PKCE 流程,抓取并逐字段解读 ID Token
- [ ] 补一篇"多租户权限模型(RBAC → ABAC → ReBAC)"的落地笔记
写作规范请参阅领域概览。