Web安全 身份认证

现代身份认证与授权技术演进全景图:从 Cookie-Session 到 Passkey

每天打开任何一个应用,你做的第一件事几乎都是“登录”。这个看似平常的动作背后,隐藏着一段跨越近三十年的技术演进史。
在 Web 应用从单体架构向微服务、前后端分离架构演进的过程中,身份认证与授权机制也经历了一场深刻的变革。从最基础的账号密码验证,到如今兼顾极致安全与体验的双令牌无状态架构,每一步迭代都是为了解决特定的痛点。本文将从七个核心阶段出发,为你全景式地梳理这条技术演进脉络。

# 阶段 关键词 解决的核心问题
1 Cookie + Session 有状态 让服务端“记住”登录态
2 CAS 单点登录 Ticket 一次登录,多系统通行
3 OAuth 2.0 授权码 第三方安全访问资源
4 PKCE 动态验证 防授权码劫持
5 OIDC ID Token 标准化的身份认证层
6 JWT 双令牌 无状态 微服务扩展 + 无感续期
7 Passkey / WebAuthn 无密码 消灭密码、对抗钓鱼

一、基石阶段:账号密码与有状态的 Session

一切的开始都源于最朴素的“账号密码登录”。这是用户向系统证明自己身份的最原始方式。当用户首次输入正确的凭证后,服务端需要一种机制来“记住”这个用户,以免用户在后续的每一次页面跳转中都要重新输入密码。于是,经典的 “Cookie + Session” 模式诞生了。
服务端在内存或数据库中创建一个 Session 对象来存储用户的登录状态,并将对应的 SessionID 通过 Set-Cookie 响应头下发给浏览器。此后,浏览器会自动在请求头中携带这个 Cookie,服务端借此识别用户。

浏览器                          服务端
  │  POST /login {user, pwd}     │
  │─────────────────────────────>│  ① 校验凭证
  │                              │  ② 创建 Session
  │                              │     Redis["sid:abc123"] = {uid: 10086, ...}
  │  Set-Cookie: sid=abc123;     │
  │    HttpOnly; Secure; SameSite=Lax
  │<─────────────────────────────│
  │                              │
  │  GET /orders                 │
  │  Cookie: sid=abc123          │
  │─────────────────────────────>│  ③ 查 Redis["sid:abc123"] → 识别用户
  │  200 OK                      │
  │<─────────────────────────────│

关于 Cookie 的三个保命属性,值得每个工程师烂熟于心:
– HttpOnly:禁止 JavaScript 读取 Cookie,直接废掉 XSS 窃取会话的攻击路径;
– Secure:仅在 HTTPS 下传输;
– SameSite:限制跨站请求携带 Cookie,是抵御 CSRF(跨站请求伪造)的第一道防线——因为 Cookie 会被浏览器“无差别自动携带”,恶意网站可以借你的身份伪造请求,这是 Cookie 模式与生俱来的原罪。
这种模式的本质是有状态的,即服务端必须维护用户的登录状态。它完美解决了 HTTP 协议的无状态问题,但随着服务从一台变成一个集群,麻烦来了:
1. Session 复制:节点之间广播同步所有会话,带宽被吃光,节点越多越不可行;
2. 粘性会话:负载均衡按 IP 哈希把用户固定路由到同一台机器,机器一挂,该机器上所有用户的会话集体蒸发;
3. 集中存储:把 Session 统一放进 Redis——这是业界主流方案,但代价是每个请求都多一次网络查询,且 Redis 成为新的可用性瓶颈。

一句话记住:Session 把“好记性”留给了服务端,也就把“成长的负担”留给了服务端。


二、跨域流转:CAS 单点登录与 Ticket 机制

随着企业应用越来越多,用户在不同系统间频繁切换需要重复登录,体验极差。为了解决“多系统重复登录”的问题,CAS(Central Authentication Service) 单点登录应运而生。它引入了一个独立的认证中心,其核心流转机制依然强依赖于 Cookie。
先认识三个关键角色:
– TGT / TGC:认证中心的全局会话票据 / 承载它的 Cookie,即“长期通行证”;
– ST(Service Ticket):发放给某个业务系统的一次性票据,即“一次性兑换券”,通常几秒内过期且只能验证一次。
首次登录的完整流程如下:

用户浏览器            业务系统A             CAS认证中心
   │ ①访问受保护页面     │                     │
   │──────────────────>│ ②发现未登录,302跳转  │
   │<──────────────────│                     │
   │ ③携带service参数访问登录页               │
   │────────────────────────────────────────>│
   │ ④检测无TGC Cookie,展示登录表单           │
   │<────────────────────────────────────────│
   │ ⑤提交账号密码                           │
   │────────────────────────────────────────>│ ⑥校验通过:建立全局会话TGT
   │   Set-Cookie: TGC=xxx                   │   下发TGC Cookie
   │   302 → app-a?ticket=ST-xyz             │   签发一次性ST并重定向
   │<────────────────────────────────────────│
   │ ⑦带ticket访问A     │                     │
   │──────────────────>│ ⑧后台拿ST向CAS校验   │
   │                   │──────────────────>  │
   │                   │ ⑨校验通过,返回用户信息│
   │                   │<──────────────────  │
   │ ⑩建立A的本地会话   │                     │
   │<──────────────────│                     │

而当用户再访问业务系统 B 时,魔法发生了:

用户浏览器            业务系统B             CAS认证中心
   │ ①访问B,未登录,302跳转CAS                │
   │────────────────────────────────────────>│
   │ ②浏览器自动携带TGC Cookie                │
   │   CAS验证TGT有效 → 免密直接签发新ST       │
   │   302 → app-b?ticket=ST-uvw             │
   │<────────────────────────────────────────│
   │ ③B拿ST校验 → 建立本地会话(用户全程无感知)│

Ticket 就像一张一次性的“兑换券”,而浏览器的 TGC Cookie 则是那张长期有效的“通行证”,两者配合实现了跨域的身份流转。业务系统与认证中心之间的票据校验走后端通道,浏览器全程碰不到用户的凭证,设计相当优雅。
需要客观指出的是:CAS 是典型的“企业内网时代”产物,协议链路较长、概念较重。如今 CAS 服务端(5.x/6.x)自己也已支持 OAuth 2.0 / OIDC 协议族——新项目通常直接选 OIDC,CAS 更多作为存量系统存在。

一句话记住:CAS 用“认证中心 + 一次性票据”第一次实现了身份的跨域流转,它是后面所有单点登录方案的鼻祖与原型。


三、范式转移:OAuth 2.0 授权协议

OAuth 2.0 的出现标志着技术重心从“认证”跨越到了“授权”。它解决的核心问题不再是“你是谁”,而是“你同意把资源借给别人用吗”。它允许第三方应用在不获取用户密码的情况下,安全地访问用户在资源服务器上的数据。
典型的场景是:“用 GitHub 登录 XX 网站,并授权它读取你的仓库列表”——整个过程 XX 网站拿不到你的 GitHub 密码。
OAuth 2.0 定义了四种角色:

角色 说明 例子
Resource Owner(资源所有者) 资源的真正主人 你
Client(客户端) 想借资源的第三方应用 XX 网站
Authorization Server(授权服务器) 签发令牌的机构 GitHub 授权服务
Resource Server(资源服务器) 存放资源的服务 GitHub API
它共定义了四种授权模式(Grant Type):
授权模式 适用客户端 现状 说明
授权码模式 Web 后端(机密客户端) ✅ 推荐 先拿 code 再换 token,密钥不进前端
授权码 + PKCE SPA / 移动端(公开客户端) ✅ OAuth 2.1 强制 无需 client_secret,见第四节
隐式模式 旧版纯前端应用 ❌ 已废弃 token 直接暴露在 URL 片段中
密码模式 完全自有的应用 ❌ 2.1 已移除 依赖明文密码,与“不碰密码”的初衷背道而驰
客户端凭证模式 服务间调用(M2M) ✅ 推荐 无用户参与,适合微服务通信
最经典的授权码模式时序如下:
用户(浏览器)       第三方应用        授权服务器(GitHub)    资源服务器
    │ ①点击"用GitHub登录"│                │                  │
    │──────────────────>│                │                  │
    │ ②302跳转授权页(client_id, redirect_uri,    │            │
    │   scope, state, response_type=code)        │            │
    │<──────────────────│                │                  │
    │ ③用户已在GitHub登录(Cookie/Session)        │            │
    │   展示授权确认页"是否允许XX读取你的仓库?"   │            │
    │ ④用户点击"同意" ──────────────────────────>│            │
    │ ⑤302 → redirect_uri?code=xxx&state=yyy     │            │
    │<──────────────────────────────────────────  │            │
    │ ⑥携带code回到第三方应用 │             │                  │
    │──────────────────>│ ⑦后台:code+client_id+client_secret   │
    │                   │───────────────>│ ⑧校验通过,返回     │
    │                   │<───────────────│   access_token     │
    │                   │ ⑨GET /user/repos (Bearer token)      │
    │                   │───────────────────────────────────> │
    │                   │ ⑩返回受保护的资源                     │
    │                   │<─────────────────────────────────── │

两个容易被忽略的细节:
– state 参数:客户端在步骤②生成随机值,授权服务器在步骤⑤原样带回,客户端比对。它用于防 CSRF——防止攻击者伪造一个“别人的 code”塞给你。
– 为什么要有 code 中转:因为浏览器地址栏是“不安全区”,code 一次性、短时效、且必须配合密钥才能兑换,即使被看见也难以利用;而直接在 URL 中放 access_token(即隐式模式的做法)等于把钥匙扔在了大街上。
在此必须强调一个极易被忽视的关键前提:授权永远建立在认证之上。OAuth 2.0 的流程能够启动,其绝对前提是用户已经与资源提供方(如 GitHub)建立了合法的 Cookie/Session 登录态——这正是步骤③的内容。如果用户连 GitHub 都没登录,GitHub 根本无法确认“你是谁”,更无从谈起将“你的资源”授权给第三方。因此,OAuth 2.0 实际上是在已有的 Cookie/Session 认证基础上,叠加了一层资源访问许可的逻辑。

一句话记住:OAuth 2.0 解决了“借”,没有解决“你是谁”——它是一台没有方向盘的发动机,身份认证这块拼图要等到 OIDC 才补齐。


四、安全加固:PKCE 扩展协议

随着前端技术的发展,SPA(单页应用)和移动端应用成为主流。这些“公开客户端”无法安全地存储客户端密钥(client_secret 写在前端代码里等于公开),且容易遭受授权码拦截攻击。
攻击是这样发生的:恶意 App 注册了与合法 App 相同的自定义 URL Scheme(如 myapp://callback),当操作系统收到携带 code 的回调时,可能把授权码交给恶意 App——它拿着 code 加上公开可得的 client_id,就能冒充受害者兑换出 Token。
为了封堵这一隐患,PKCE(Proof Key for Code Exchange,RFC 7636) 被引入。其核心思想是:让客户端自己生成一个“动态秘密”,发起请求时证明“我知道这个秘密的哈希”,兑换令牌时证明“我确实持有这个秘密”——从而确保两步操作来自同一个客户端。

客户端                                        授权服务器
  │ ①本地生成随机 code_verifier(43~128位)        │
  │   code_challenge = BASE64URL(SHA256(verifier))│
  │                                              │
  │ ②授权请求 + code_challenge + method=S256      │
  │─────────────────────────────────────────────>│  服务器记住 challenge
  │        (用户登录、点击同意授权)               │
  │ ③返回授权码 code                              │
  │<─────────────────────────────────────────────│
  │                                              │
  │ ④兑换请求:code + code_verifier(明文)          │
  │─────────────────────────────────────────────>│  服务器校验:
  │                                              │  SHA256(verifier) == challenge ?
  │ ⑤access_token(校验通过才发放)                │
  │<─────────────────────────────────────────────│

攻击者即便在步骤③劫持了 code,也没有那个随机生成的 code_verifier,兑换必然失败。生成代码也非常简单:

// 1. 生成高熵随机 code_verifier
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
// 2. 计算 code_challenge(S256 方式)
const digest = await crypto.subtle.digest(
  'SHA-256', new TextEncoder().encode(verifier)
);
const challenge = base64url(digest);
// 3. 发起授权请求时携带
// GET /authorize?...&code_challenge=${challenge}&code_challenge_method=S256

这一机制彻底封堵了授权码在 URL 跳转过程中被劫持的风险。值得一提的是,在最新的 OAuth 2.1 草案中,PKCE 已从“公开客户端的可选项”升级为所有使用授权码模式的客户端的强制要求——机密客户端同样可能被劫持,没有理由不加。

一句话记住:PKCE 用“哈希先行、原文殿后”的动态验证,把静态密钥从公开客户端上彻底解放了出来。


五、身份回归:OIDC 与 ID Token

OAuth 2.0 虽然解决了授权问题,但它本身并不提供标准的用户身份信息,导致第三方应用往往需要额外调用 API 来获取用户资料——更尴尬的是,很多人直接拿 OAuth 2.0 当“登录协议”滥用,这正是无数安全事故的源头。
OIDC(OpenID Connect) 正是为了解决这一尴尬而诞生。它在 OAuth 2.0 的授权底座上,增加了一个核心交付物——ID Token。你可以把二者理解为:OAuth 2.0 是发动机,OIDC 是装好了方向盘和仪表盘的整车。
ID Token 本质上就是一个 JWT。当用户完成授权后,认证服务器不仅返回用于访问资源的 Access Token,还会返回这个包含用户身份信息的 ID Token:

// ID Token 的 Payload(解码后)
{
  "iss": "https://auth.example.com",  // 签发者:谁来着发
  "sub": "user-10086",                // 用户唯一标识(stable ID)
  "aud": "mall-app",                  // 受众:发给谁用
  "exp": 1767139200,                  // 过期时间
  "iat": 1767052800,                  // 签发时间
  "nonce": "n-0S6_WzA2Mj",            // 防重放随机数
  "name": "张三",
  "picture": "https://cdn.example.com/a.png"
}

第三方应用只需验证 JWT 的签名,并校验 iss(签发者对不对)、aud(是不是发给我的)、exp(过没过期)、nonce(是否为本次请求发出的,防重放),就能确信用户的真实身份,从而在自己系统内完成“登录”动作。
其中 nonce 尤其值得展开:客户端在发起授权请求时生成随机 nonce,认证服务器将其原样写进 ID Token 返回。若客户端发现 token 中的 nonce 与自己发出的不一致,说明这个 token 可能是被注入的旧令牌——重放攻击就此现形。
OIDC 完美地补齐了 OAuth 2.0 在身份认证层面的短板,也成了“用 GitHub/Google 登录”这类社交登录事实上的标准协议。

一句话记住:需要“登录”就请使用 OIDC;只使用裸 OAuth 2.0 做登录,是安全设计上的重大错误。


六、终极形态:无状态架构与双令牌机制

在现代前后端分离架构中,为了彻底摆脱服务端存储 Session 的压力,实现微服务的水平扩展,业务系统拿到 Access Token 后,通常会采用无状态的 JWT(JSON Web Token) 来维持自身的登录态。
一个 JWT 长这样——用两个点分隔的三段 Base64URL 字符串:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9     ← Header:签名算法与令牌类型
.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEwMDg2In0     ← Payload:用户与元数据
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c     ← Signature:防篡改签名

⚠️ 常见误区:Base64URL 只是编码而不是加密,任何人都能解开 Payload 看到内容。JWT 默认提供的是“防篡改”(签名),不是“防偷看”——手机号、权限明细等敏感信息绝不要放进去。
签名算法的选择在微服务架构下很有讲究:
– HS256(对称加密):签发与验证用同一个密钥。简单,但每个能验签的微服务都持有密钥,等于人人都能签发令牌,密钥泄露面巨大;
– RS256 / ES256(非对称加密):认证中心用私钥签发,各微服务通过公钥验签,公钥经 JWKS 端点(/.well-known/jwks.json)公开分发。这是生产环境的主流选择——网关统一验签、密钥永不外流。
而 JWT 最大的痛点在于:一旦签发,在过期前无法主动作废(服务端根本不存它)。为了在安全性与用户体验之间取得平衡,业界演化出了标准的“双令牌机制”:

Access Token(AT) Refresh Token(RT)
寿命 极短,分钟级(如 15 分钟) 较长,天/周级(如 7~30 天)
用途 请求业务接口 绝不请求业务接口,只用于换新 AT
暴露面 每个请求都要携带 仅在刷新时使用一次
存储位置 内存(推荐) httpOnly Cookie 或安全存储
泄露后果 作恶窗口极小 可持续换取新 AT → 必须轮换保护
AT 是“短命鬼”,即使泄露,黑客能作恶的时间窗口也极小;RT 是“老寿星”,让用户只要不长时间离线,就能享受“无感刷新”的流畅体验:
客户端                            业务服务端
  │ 请求A: Authorization: Bearer AT(已过期)     │
  │───────────────────────────────────────────>│
  │                 401 Unauthorized            │
  │<───────────────────────────────────────────│
  │ POST /auth/refresh(RT 放在 httpOnly Cookie 中)│
  │───────────────────────────────────────────>│ 校验 RT 有效性
  │ 新 AT(+ 新 RT,旧 RT 立即作废)              │
  │<───────────────────────────────────────────│
  │ 自动重放请求A: Bearer 新AT                   │
  │───────────────────────────────────────────>│
  │                 200 OK(用户全程无感知)       │
  │<───────────────────────────────────────────│

前端用 axios 拦截器即可优雅实现(简化示意):

let refreshing = null; // 保证并发 401 时只触发一次刷新
axios.interceptors.response.use(undefined, async (error) => {
  const { config, response } = error;
  if (response?.status === 401 && !config._retried) {
    config._retried = true;
    refreshing = refreshing || axios.post('/auth/refresh');
    try {
      const { data } = await refreshing;
      setAccessToken(data.access_token);
      config.headers.Authorization = `Bearer ${data.access_token}`;
      return axios(config); // 重放原请求
    } finally {
      refreshing = null;
    }
  }
  return Promise.reject(error);
});

工程实践中还有三个绕不开的进阶话题:
① Refresh Token 轮换与重用检测:每次刷新时旧 RT 作废、签发新 RT。若服务端发现一个“已作废的 RT”再次出现——说明令牌族已经泄露——立即吊销整条链路,逼迫用户重新认证。这是目前公认对 RT 泄露最有效的防御。
② JWT 的注销难题:用户点“退出登录”或修改密码时,已签发的 AT 怎么办?短有效期硬扛、按 jti 维护 Redis 黑名单、或在 payload 中携带用户版本号——但后两者本质上都让“无状态”重新变回了“有状态”。所以请记住:完全的无状态是理想主义,工程上都是“无状态为主 + 少量集中状态兜底”。就连 RT 的吊销本身,也注定 RT 是一份需要服务端管理的状态。
③ 前端存储之争与 BFF 模式:AT 放 localStorage 会成为 XSS 的提款机;全放 httpOnly Cookie 则要小心 CSRF。因此现代最佳实践是 BFF(Backend for Frontend):让 Token 只存在于一个轻量后端层,浏览器与 BFF 之间仍然使用 httpOnly Cookie 会话——前端“感觉”在用 Session,后端实际在用 Token,鱼与熊掌兼得。IETF 面向浏览器应用的 OAuth 最佳实践也将其列为 SPA 的推荐方案。

一句话记住:双令牌用“短命 AT 控损失、长命 RT 保体验、轮换机制防泄露”三板斧,把无状态架构的安全账算平了。


七、面向未来:无密码认证与 Passkey(WebAuthn)

回望前六个阶段,一切都建立在“账号密码”这个地基上。但密码本身就是安全体系中最脆弱的一环:弱口令满天飞、撞库攻击防不胜防、钓鱼页面无孔不入,一旦数据库泄露,数亿凭证灰飞烟灭。
WebAuthn / FIDO2 给出了一个釜底抽薪的答案:干脆不要密码。
它的原理是用设备内置的公私钥对取代密码:
– 注册阶段:用户在网站完成初始身份核验后,设备(手机 Secure Enclave、电脑 TPM)生成一对密钥,私钥永不离开设备,公钥上传给网站;
– 登录阶段:网站下发一个随机 challenge,设备用私钥对它签名,网站用公钥验证签名。全程没有密码参与,也不存在“可以被钓走的秘密”。
它防钓鱼的原理尤为精妙:密钥对在生成时就与网站域名(RP ID)绑定。当用户访问钓鱼网站 githuh.com 时,认证器会拒绝签名——因为私钥只认 github.com。密码可以被用户“主动输错地方”,密钥则从机制上不可能被输错地方。
而 Passkey 则在 WebAuthn 之上叠加了云同步(iCloud 钥匙串、Google 密码管理器),解决了早期 FIDO “换台设备就丢密钥”的致命短板,让无密码认证第一次具备了走向十亿级普通用户的可行性。Apple、Google、Microsoft 均已深度支持。
需要强调的是,Passkey 并不推翻前六阶段的成果,它只是替换了“认证”这一环:Passkey 验证成功后,认证中心照样签发 OIDC 的 ID Token,业务系统照样运行双令牌会话。手段在变,而“认证 → 授权 → 会话管理”的分层架构岿然不动——这正是这套演进体系生命力的最好证明。

一句话记住:密码之死已经进入倒计时,但 Passkey 革的是“凭证”的命,不是“架构”的命。


总结:一张表看懂三十年演进

阶段 代表技术 解决的核心问题 架构状态 典型遗留问题
1 Cookie + Session 记住登录态 有状态 分布式同步、CSRF
2 CAS SSO 多系统一次登录 认证中心有状态 协议较重,生态偏内网
3 OAuth 2.0 第三方安全授权 — 本身不解决“你是谁”
4 PKCE 防授权码劫持 — —
5 OIDC 标准化身份认证层 — 依赖授权码模式最佳实践
6 JWT 双令牌 无状态 + 无感续期 无状态(相对) 撤销难、令牌体积、存储之争
7 WebAuthn / Passkey 消灭密码、防钓鱼 — 设备生态迁移成本
避坑清单(每一条背后都是真实的事故):
1. OAuth 2.0 不是登录协议——需要用户身份请直接上 OIDC;
2. JWT 默认不加密,Payload 里别放敏感信息;
3. Implicit 模式已废弃、Password 模式已被 OAuth 2.1 移除,新项目别再选;
4. 任何授权码场景都应启用 PKCE,不分机密/公开客户端;
5. Token 放 localStorage 等于把钥匙交给 XSS,Web 场景优先 httpOnly Cookie + BFF;
6. “无状态”是相对的——RT 的吊销、JWT 的黑名单注定需要一点服务端状态兜底。
选型速查:
– 传统多页单体应用 → Cookie + Session(+ Redis 集中存储),简单够用;
– 企业内网多系统 → 存量 CAS 可继续维护,新建直接 OIDC;
– 开放平台 / 第三方登录 → OAuth 2.0 授权码模式 + PKCE,一律启用;
– 前后端分离 / 微服务 → OIDC + JWT 双令牌 + RS256/ES256,网关统一验签,SPA 考虑 BFF;
– 高安全场景 → 叠加 Passkey 作为主登录方式或第二因素。
从最早的 Cookie/Session,到如今的 OIDC、双令牌与 Passkey,身份认证技术走过了一条从“简单有状态”到“复杂无状态”、从“单一认证”到“认证授权分离”、从“秘密只有一方的知识”到“秘密藏在硬件里”的演进之路。每一次迭代,都是在安全、体验与架构扩展性的天平上重新寻找支点。
理解这一过程,不仅能帮助我们掌握当下的主流技术,更能让我们在面对未来架构挑战时,做出最合理的选型与决策——因为无论凭证形态如何变化,“认证、授权、会话管理”三层分而治之的思想,将是这场漫长演进中唯一不变的东西。
—
如果你希望调整篇幅(比如压缩成一篇 3000 字的精简版)、更换行文风格(更口语化/更学术化),或者针对某个阶段(如 PKCE 或 BFF)单独展开成系列文章,随时告诉我。