Skip to content

鉴权认证

认证(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 做"登录"之所以别扭,是因为你拿着一个只说明"能访问什么"的令牌,去反推"是谁"。

text
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(主动失效立即生效)
属性作用不配的后果
HttpOnlyJS 读不到 document.cookieXSS 成功即可直接偷走会话(见 前端安全
Secure仅 HTTPS 传输明文 HTTP 下被中间人截获
SameSite=Lax/Strict限制跨站请求携带CSRF:用户在 A 站点着,B 站悄悄发请求

SameSite=None 必须同时带 Secure,用于需要跨站携带的合法场景(如嵌入第三方页面)。

2.2 服务端 session 放哪

js
// 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 编码后用 . 连接。

js
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):

text
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

三个结论要记住:

  1. base64url 不是加密,payload 只是"能还原的字符串",别在里面放手机号、身份证、内部定价;
  2. 签名只保护完整性,改一个字节签名就对不上(实测 false);
  3. 不验签的 JWT 等于明文——务必拒绝 alg: none,并固定服务端期望的算法白名单。

3.1 无状态换来的三个麻烦

麻烦原因常见解法
无法主动失效服务端不存状态,签发后到 exp 前都有效access token 短(5–15 分钟)+ refresh token 可吊销
续期语义别扭快过期时换新 token,旧 token 仍在有效期内刷新窗口收窄 + 关键操作再校验
令牌体积每次请求都带,含 claim 越多越大只放必要 claim(sub/tenant/role/exp),其余查库
js
// 吊销能力:用 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 授权模式怎么选

模式场景现状
授权码 + PKCEWeb 应用、SPA、移动端默认选择,SPA/移动端必须用 PKCE
客户端凭证 Client Credentials服务端对服务端,无用户上下文机器间调用的标准做法
设备授权 Device Code电视/CLI 等无浏览器设备输入受限设备
刷新令牌 Refresh Token换取新的 access token需轮换与重用检测
隐式 Implicit早期 SPA 方案已废弃,令牌暴露在 URL
密码模式 ROPC直接拿用户名密码换令牌不推荐,仅在自有第一方客户端

4.3 授权码 + PKCE 时序

text
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_token

PKCE 的价值:即使授权码被截获,没有 code_verifier 也换不到令牌,因此公开客户端(SPA、App)不再需要把 client_secret 编进前端。

4.4 ID Token 与 Access Token

ID TokenAccess Token
用途证明"用户是谁"(给客户端看)证明"能访问什么"(给资源服务器看)
格式必须是 JWT可以是 JWT,也可以是不透明串
消费方ClientResource Server
校验重点iss/aud/exp/nonce + 签名签名、aud、scope、exp

最常见的错:拿 ID Token 去调 API。ID Token 的受众是本客户端,资源服务器应当校验 aud 是否包含自己——拿 ID Token 调接口,等于把认证凭证当授权凭证用。

五、落地要点:真正出事的地方

5.1 密码存储

js
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 刷新令牌轮换与重用检测

text
1. refresh_token 与 access_token 一起签发,refresh 有效期长(7–30 天)
2. 用 refresh 换新令牌时,服务端同时签发一个新 refresh 并作废旧的(轮换)
3. 若收到一个已作废的 refresh → 判定泄露,吊销该用户整条令牌链并告警
4. refresh 端点独立限流,且要求 client 认证

5.4 权限模型

模型表达适用
RBAC用户 → 角色 → 权限绝大多数业务系统,够用且易懂
ABAC属性 + 策略规则(时间/地域/设备)规则复杂、需要动态判断
ReBAC关系图谱(文档-团队-成员)协作类产品的分享权限

无论哪种:前端隐藏菜单只是体验,真正的校验必须在服务端每次请求都做。越权(IDOR)常年排漏洞榜前列,根因就是"前端没显示就没人能访问"的错误假设。

5.5 网关统一鉴权

text
Client → 网关:校验签名/过期/受众 → 注入 X-User-Id、X-Tenant-Id → 转发内部服务
内部服务:信任网关注入的身份头(但必须校验请求来源为网关)
  • 网关只做通用校验(签名、过期、scope),业务权限留给各服务;
  • 内部服务之间也要有身份(mTLS 或服务级 token),内网不等于可信;
  • 注入头必须在入口统一剥离外部传入的同名头,防止客户端伪造 X-User-Id

六、设计检查清单

  1. 认证与授权分离:Access Token 只携带授权信息,登录态由 OIDC 的 ID Token 或 session 表达;
  2. 口令传输全程 HTTPS,服务端存的是慢哈希(argon2id/bcrypt)+ 每用户独立盐;
  3. Cookie 三件套齐全:HttpOnly + Secure + SameSite
  4. Access Token 短(5–15 分钟),Refresh Token 轮换 + 重用检测;
  5. 关键操作(改密、改绑手机、支付)重新认证,不复用长令牌;
  6. 服务端固定算法白名单,拒绝 alg: none 与密钥混淆(HS256/RS256 混用漏洞);
  7. 每次请求都做服务端鉴权,不依赖前端菜单/按钮隐藏;
  8. 多租户场景,身份里必须带租户维度,每个查询都带上租户过滤;
  9. 提供主动失效能力(session 删除或 token 版本号),改密/封禁后立刻生效;
  10. 登录、改密、异常地点登录失败等事件有审计日志与告警。

七、常见坑速查

  1. 把 JWT 当加密用:base64url 只是一层编码,payload 谁都能解——敏感信息一律不放;
  2. 不验签直接解 payloadJSON.parse(atob(段)) 就当作已认证身份,等于裸奔;
  3. alg 信任客户端传值alg: none 或 RS256→HS256 降级攻击,必须服务端白名单;
  4. Token 永不过期或 exp 过长:泄露后长期有效——短 access + 可吊销 refresh;
  5. 登出只删前端 token:服务端不失效,令牌依旧可用——必须服务端侧失效;
  6. refresh token 不做轮换:一次泄露长期可用;不做重用检测则无法发现泄露;
  7. 拿 ID Token 调 API:受众不对,资源服务器必须校验 aud
  8. Session 固定攻击:登录成功后未更换 sessionId,攻击者诱导用户使用已知 sid——登录成功必须重新生成;
  9. Cookie 漏配 SameSite:CSRF 直接可用;SameSite=None 忘配 Secure 则 Cookie 被浏览器拒绝;
  10. 权限只做在前端:隐藏按钮不算权限控制,接口必须独立校验(越权/IDOR)。

状态与参考

下一步

  • [ ] 用 node:crypto 实现 RS256 签发/验签(公私钥分离),对比 HS256 的密钥分发差异
  • [ ] 跑通一次完整的 OIDC 授权码 + PKCE 流程,抓取并逐字段解读 ID Token
  • [ ] 补一篇"多租户权限模型(RBAC → ABAC → ReBAC)"的落地笔记

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀