ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从OAuth 2.0到OIDC:统一身份认证的底层原理与实战落地

从OAuth 2.0到OIDC:统一身份认证的底层原理与实战落地 做过后端身份认证这块的人应该都有一种感受现在互联网上的登录体系基本被 OIDCOpenID Connect悄悄统一了。不管你是点“使用 Google 登录”“使用 GitHub 登录”还是企业内部办公系统的 SSO单点登录背后大概率跑的都是这套协议。我最早接触 OIDC 是因为对接第三方账号当时对着文档抄了一个 OAuth 2.0 的授权码流程跑通之后却发现返回了一堆 token其中有一个叫 ID Token 的东西我当时完全搞不清它跟 access token 有什么区别翻了好几篇文章才真正把原理摸透。今天这篇就把我踩过的坑、理解的原理以及实战中用到的校验细节一起梳理出来尽量讲得直白、能直接用。这篇内容主要分享 OIDC 的底层设计逻辑、核心概念、完整登录流程以及落地时容易忽略的细节。适合刚接触身份认证、被 OAuth 2.0 和 OIDC 绕晕的开发者也适合做了几年后端、对接过 SSO 但一直没时间把协议补完整的同学。看完之后你至少能听懂 OIDC 的每一个名词也能独立完成一个最简单的 OIDC 客户端接入。1. 从身份认证与授权说起为什么需要 OIDC1.1 登录这件事的本质认证和授权不是一回事搞清楚 OIDC必须先想明白一个基本问题登录到底要解决什么。最早的时候每个网站自己存用户名和密码用户到哪都要注册一套新账号体验差安全风险也大因为你没法保证每个网站都认真存密码。后来出现了 OAuth 2.0它解决的是“授权”——也就是让用户点击“同意”之后把用户在某个服务里的部分资源使用权委托给第三方应用。比如一个打印小程序想帮你把网盘里的照片打出来OAuth 2.0 可以做到让小程序访问你网盘里那几张照片但这个小程序并不知道你到底是谁。问题就在这里。OAuth 2.0 只管“你能访问什么”不管“你是谁”。比如你用微信登录一个论坛论坛真正想知道的不是“你能不能访问微信里的名片”而是“这个微信用户的身份标识是什么我该给他创建什么样的论坛账号”。这就是认证Authentication要解决的事。认证是确认“你是你”授权是确认“能给你什么”两件事看着像底层逻辑完全不同。OAuth 2.0 是授权框架它怀里没有“身份”这个标准概念而 OIDC 恰恰就是在 OAuth 2.0 之上补上这层身份语义。所以 OIDC 可以理解为“在 OAuth 2.0 的汽车底盘上装了一个标准驾驶室。”它借用 OAuth 2.0 的授权流程作为传输通道但额外定义了一种专门用来表达“用户身份”的 token叫 ID Token。这个 ID Token 就像是互联网世界里的一张标准身份证所有遵守 OIDC 协议的身份提供方Identity Provider简称 IdP签发的身份证格式一致任何网站都能认。1.2 OIDC 到底在 OAuth 2.0 上加了三样东西很多人觉得 OIDC 高深其实它做得非常克制。OIDC 在 OAuth 2.0 的基础上主要定义了三样新东西ID Token一种 JWT 格式的令牌里面装着用户身份信息比如用户唯一标识、姓名、邮箱等。这个 token 才是 OIDC 的灵魂。UserInfo 端点一个通过 access token 换取更多用户信息的标准接口同一个平台的所有 OIDC 服务都遵守同样的返回格式。Discovery 文档一个固定在/.well-known/openid-configuration路径下的 JSON 文档把授权端点、令牌端点、公钥地址等所有配置一次性暴露出来客户端完全可以通过这个文档自动发现所有需要的信息。这三样东西配合起来效果非常明显。拿 Discovery 文档来说以前对接不同平台你得去各自的文档里翻 endpointOIDC 出现之后客户端只要知道一个基础 URL就可以自动获取所有配置。因为这样的标准化设计你用同一套代码接入十几个不同的 OIDC 服务几乎是改改配置就能完成不需要为每个平台重写一遍逻辑。形象一点的类比OAuth 2.0 给了你一张访客卡门卫看见卡知道放你去几楼但不知道你是谁。OIDC 则是在这张卡的基础上增加了一套指纹和身份证号标准门卫不仅能放行还能通过标准接口确认“这个人叫张三验证过邮箱”。整个互联网终于有了一张通用的身份证体系。1.3 为什么企业、开发者、用户都愿意为 OIDC 买单从企业角度讲OIDC 把账号体系从“自己造轮子”变成了“接入标准”。企业内部可以只维护一个身份提供方所有内部系统通过 OIDC 对接员工登录一次就能访问所有系统这就是单点登录。IT 部门不用每个系统各管一套密码也能统一做多因子认证、离职禁用等安全策略省心得多。从开发者角度讲OIDC 最大的价值是省事。你不用自己实现密码找回、邮箱验证、防暴力破解那一整套东西只要对接一个 OIDC 服务这些功能全都有了。而且因为流程标准化OIDC 客户端库在各语言生态里都很成熟不管是 Java、Node.js、Python 还是 Go都有现成库可以用大大减少了自己写协议交互带来的安全漏洞。从用户角度讲OIDC 意味着少记密码。你只需要记住一个平台的账号就可以登录大量第三方网站。免密码体验的流行也让“账号密码”这种传统模式逐渐变成“生物识别 设备绑定”等现代方式。更重要的是用户每次授权时都能明确看到这个网站能拿到哪些信息隐私更透明可控。以上这些因素叠在一起让 OIDC 成了目前事实上的身份认证标准协议。理解了它存在的必然性接下来看具体内容就不会晕了。2. 核心概念拆解ID Token、Claims 与授权流程2.1 ID Token 就是那张“电子身份证”OIDC 里最核心的东西就是 ID Token。它本质上是一个 JWTJSON Web Token由三部分组成头部、载荷、签名。把 ID Token 放到 JWT 解码器里观察它的载荷大概长这样{ iss: https://your-idp.example.com, sub: 248289761001, aud: my-client-id, nonce: n-0S6_WzA2Mj, exp: 1311281970, iat: 1311280970, auth_time: 1311280969, name: Jane Doe, email: jane.doeexample.com, email_verified: true }这里每一个字段都有明确语义。iss是签发者也就是身份提供方的 URL它表示这张身份证是谁签发的。sub是用户唯一标识在同一个签发者下永不变化这一条是最重要的。aud是观众表示这张 ID Token 是给哪个客户端看的防止一个 token 被另一个客户端拿去用。exp和iat是过期时间和签发时间nonce则是一个由客户端在发起登录时生成的随机数用来防止重放攻击。很多人第一次接触 ID Token 会犯一个错误以为 JWT 中间那段 Base64 看到的内容可以被信任。其实 JWT 的载荷只是 Base64 编码不是加密任何拿到 token 的人都能直接读到里面的内容。真正保证数据不被篡改的是最后的签名部分。签名通常用 RSA 或 EC 算法由身份提供方私钥签名客户端用通过标准方式获取的公钥验签。如果验签不通过说明 token 可能被篡改过必须拒绝。这也解释了为什么 ID Token 适合做身份凭证。它有标准统一的签发者、唯一标识、过期时间、防重放随机数并且有签名保证完整性。相比之下自己设计一个“用户信息 token”很难考虑得这么周详OIDC 把这个基础安全性直接帮你做了。2.2 Claims身份证上的每一个字段ID Token 里的每个字段在 OIDC 里叫 Claim。标准 Claim 的语义是由规范定义好的但具体哪些会出现取决于客户端请求的 scope权限范围。其中openid这个 scope 是必须的不带上它就根本不算 OIDC 流程它告诉身份提供方“我不仅要做授权我还想认证用户身份。”profilescope 会返回姓名、头像等基础资料emailscope 会返回邮箱及邮箱是否验证的状态。下面这张表列出几个最常用的标准 Claim方便对照使用Claim含义典型取值示例注意事项sub用户在签发者下的唯一标识248289761001同一用户在任何客户端下都相同iss签发者的 URLhttps://your-idp.example.com必须精确匹配配置中的签发者aud接收方即你的客户端 IDmy-client-id必须包含你的 client_idexp过期时间戳秒1735689600过期后必须拒绝nonce防重放随机数n-0S6_WzA2Mj必须与会话中保存的值一致email邮箱地址jane.doeexample.com需要通过emailscope 请求email_verified邮箱是否已验证true做业务前建议检查标准化 Claim 还有一个隐藏好处不同身份提供方实现的语义一致客户端代码写一次换一个 IdP 也不用改判断逻辑。如果你有自定义需求比如想把“用户等级”“会员类型”塞进 tokenOIDC 也是允许的但要注意别去覆盖标准 Claim 的命名不同 IdP 的自定义 Claim 建议加上前缀避免和未来标准字段冲突。2.3 UserInfo 端点需要更多信息时去哪查ID Token 里一般只携带最核心的身份信息因为 token 体积太大对网络传输不利。那如果想获取更完整的用户资料怎么办OIDC 提供了 UserInfo 端点。流程大概是这样的客户端完成认证拿到 ID Token 和 access token 之后带着 access token 请求 UserInfo 端点身份提供方返回一个 JSON里面装着用户的标准 Claim 信息比如姓名、头像、昵称、手机号等。这里有个关键点UserInfo 返回的信息应该被信任吗答案是如果前面的 ID Token 验签通过且 access token 是合法换来的你可以信任 UserInfo 的数据。因为 UserInfo 端点是 TLS 保护下的 HTTPS 接口access token 本身上是携带授权语义的拿到它本身就说明用户同意了这个客户端访问这些数据。有一种常见需求是这样的用户资料可能在登录后被修改比如换头像、改昵称客户端本地存的 ID Token 里还是旧数据。这时候 UserInfo 端点就能派上用场客户端可以用 refresh token 获取新的 access token然后调用 UserInfo 实时拉取最新资料。整体设计原则是身份核心信息走 ID Token细节资料走 UserInfo各管一摊职责清晰。2.4 三种主流流程与 PKCE 的选择OIDC 沿用了 OAuth 2.0 的流程主要有三种模式选择哪种决定了客户端的安全模型。授权码模式Authorization Code Flow最经典也最推荐。用户跳转到身份提供方登录完成后身份提供方通过回调 URL 返回一个临时的授权码客户端后端拿着这个授权码再换取 ID Token 和 access token。因为授权码通过浏览器回传但换 token 的请求发生在后端到身份提供方之间token 不经过浏览器避免了被页面脚本窃取的风险。适合有后端的传统 Web 应用。授权码 PKCE 模式这是现在最推荐的模式尤其适合纯前端 SPA、小程序、移动端 App。PKCE 的核心是客户端先生成一个随机字符串code_verifier再计算出一个变形值code_challenge传给授权端点换 token 时必须回传原始的code_verifier身份提供方会比对两者。这样就算授权码被截获攻击者没有code_verifier也换不到 token。这个机制用来防拦截很适合没有服务器可以安全保存 client_secret 的场景。隐式模式Implicit Flow历史遗留方案token 直接通过 URL 片段返回给浏览器安全问题很多官方已经不推荐了。除非维护老系统否则不要在新代码里用。我的建议是新项目一律用授权码 PKCE。即便你有后端PKCE 多写几行代码的成本很低但对安全性的提升是实打实的。后面所有实操演示都基于这个模式。3. 一次授权码登录的完整落地过程3.1 从点击“第三方登录”到建立会话的完整时序把流程拆成时序来看会清晰很多。假设你的网站要接入一个 OIDC 身份提供方用户点了“使用 OIDC 登录”整个过程大致是八个步骤前端生成一个随机state和一个随机nonce并计算 PKCE 的code_verifier和code_challenge然后把它们先存在本地会话里。前端把用户重定向到身份提供方的授权端点URL 里带上client_id、redirect_uri、response_typecode、scopeopenid profile email、state、nonce、code_challenge等参数。用户在身份提供方页面上登录并确认授权。身份提供方把用户重定向回你的redirect_uriURL 参数里带着code和state。前端或后端校验state与发起时保存的值一致防止跨站请求伪造。后端拿着code和code_verifier向身份提供方的 token 端点发请求换取 ID Token 和 access token。后端对 ID Token 进行验签、校验iss、aud、exp、nonce等确认用户身份合法。后端建立自己的会话比如签发自己的 Session ID 或 Cookie把用户标记为已登录前端跳转到登录成功页。第 4 步到第 6 步之间有一个容易忽略的细节授权码是一次性的使用一次之后立即失效而且通常有效期很短默认只有几分钟。拿到授权码后应尽快换取 token不要把它长期留在浏览器里或者日志里。你可能会问为什么不能把 ID Token 直接放在 URL 片段里带回来省得再多一次请求因为 URL 片段虽然不发送到服务器但浏览器历史记录、页面脚本都可以翻出来token 暴露面太大。授权码方案绕了一层就是为了让有风险的内容只有一个短期、一次性的介质在浏览器里流转真正的“身份证”只在后端完成交换这个设计很值得借鉴。3.2 拿到 ID Token 之后怎么验这是整个接入过程中最容易出错、也最关键的一步。拿到 ID Token 后必须有条不紊地做以下几项校验大多数 OIDC 库已经封装好了这些逻辑但理解顺序很有帮助先从 ID Token 的头部解析出alg和kidalg一般会是RS256或ES256之类的算法名kid是公钥的标识。根据 Discovery 文档里的jwks_uri获取公钥集合按kid找到对应的公钥。用公钥对 ID Token 的签名做验签确保 token 没有被篡改。检查iss和配置的签发者完全一致。检查aud中包含你的client_id。有些场景下用逗号分隔了多个受众需要逐个判断。检查exp过期时间必须晚于当前时间留一点时钟偏移量。检查nonce它必须和你发起登录时保存到会话里的值一致。看起来是七条本质上就是在回答三个问题这是谁签发的签发给谁是不是有效的把这三个问题验清楚身份才算真正确认了。下面用 Python 伪代码做一个展示思路对任何语言都通用import jwt import requests # 1. 获取公钥并验签 jwks_uri https://your-idp.example.com/.well-known/jwks.json jwks requests.get(jwks_uri).json() unverified_header jwt.get_unverified_header(id_token) public_key find_key_by_kid(jwks, unverified_header[kid]) claims jwt.decode( id_token, public_key, algorithms[unverified_header[alg]], issuerhttps://your-idp.example.com, audiencemy-client-id, options{verify_exp: True}, ) # 2. 校验 nonce if claims[nonce] ! session_nonce: raise Exception(nonce mismatch)真正生产环境里我建议直接用你所在语言的 OIDC 库不要自己拼 JWT 校验逻辑。原因是即便你理解原理自己写的代码也很容易漏掉边界情况——比如算法混淆攻击、JWKS 的缓存更新、密钥轮换。成熟的库把这些都处理掉了。3.3 前端、后端如何分工Session 怎么设计接入 OIDC除了协议本身还需要想清楚前端和后端怎么分工以及登录状态到底怎么保存。前端SPA的主要职责是发起跳转和组织状态。state、nonce、code_verifier都存在 sessionStorage 或内存里每次发起登录前重新生成不要复用。code_verifier不要放进 URL也不要在前端日志里输出。后端的主要职责是接收授权码、换 token、验签、建 Session。这里有一个很常见的姿势后端拿到 ID Token 之后不直接把这个 JWT 当会话凭证返回给前端长期保存而是把它放在后端 Session 里给前端发一个随机的 Session ID本质上是自己的会话 Cookie。这样做的好处是后续请求只要验证自己的 Session ID 就可以了不需要每次都验 JWT 的签名。如果只是把 ID Token 原封不动塞给前端让前端每次请求都带上你还要处理 ID Token 过期后的刷新问题反而麻烦。Session 过期时间怎么设计一个比较合理的做法是Session 的有效期和 ID Token 的过期时间挂钩ID Token 过期后后端尝试用 refresh token 重新换一个换不到就去请用户重新登录。对一般业务系统来说会话保持 7 天还是 30 天取决于你的安全要求。如果是金融、支付类应用会话时间宜短不宜长。不要为了体验牺牲安全。如果需要用 access token 调用这个 IdP 的 API比如拉取用户头像把 access token 放后端不要在浏览器里存。前端需要调 API 时通过后端的代理接口转发这样 access token 不会暴露给页面安全得多。4. 实战中的坑与排查经验4.1 常见报错速查表下面是自己在工程里遇到过的几类典型报错按频率排了序基本覆盖了大多数问题报错或现象可能原因排查思路invalid_grant授权码已过期、已使用或者code_verifier不匹配确认授权码是否只换了一次检查 PKCE 参数redirect_uri不匹配配置的重定向地址与请求地址不一致包括大小写、尾部斜杠、query 参数完全一致才可以通过建议复制粘贴而不是手输state校验失败浏览器里保存的 state 丢失或者发起登录和回调不在同一个会话确认 sessionStorage 是否被清理跳转域名是否变化ID Token 验签失败公钥过期、算法不匹配、JWKS 缓存过期刷新 JWKS 缓存检查kid索引audience校验失败ID Token 的aud不是你的 client_id重新检查配置注意你是否在多个客户端之间复用了同一个 ID Token一直拿不到email发起登录时没有请求emailscope确认 scope 参数是否包含openid profile email这几种错误几乎每个 OIDC 开发者都会遇到画出 CHECKLIST 之后排查就快多了。最费时间的往往是redirect_uri不匹配这类最简单的问题因为它完全是字符串精确匹配差一个字符都不行。我在本地联调时曾经因为把一个/写多了白花了半小时。4.2 自己实现时最容易踩的五个坑第一个坑是把 ID Token 当 access token 用。这个我见过太多次了有人拿到 ID Token 就把它塞进请求头里当 API 凭据。但 ID Token 是给当前客户端验身份的不是给资源服务器验证访问权限的。API 接口要校验的是 access token 的作用域和受众而你拿 ID Token 去调接口资源服务器根本不知道该拿它做什么。正确的分工是认证用 ID Token授权调 API 用 access token。第二个坑是不校验nonce。有些人验完签名觉得没问题就忽略nonce但nonce的存在就是为了防止攻击者把之前截获的 ID Token 重放一遍。不校验 nonce 等于给重放攻击开了绿灯。实现时一定要把 nonce 放在到期时间、签名同为强制校验项。第三个坑是忽略时钟偏移。服务器和客户端时钟不可能完全同步ID Token 里exp字段是按签发者时间算的。如果客户端机器时间慢了一个本应有效的 token 会被判定为过期反过来如果你校验iat是否太早也要留一点偏移余量。一般建议允许 1 到 5 分钟的时钟偏移但不要太大否则过期时间形同虚设。第四个坑是把 client_secret 塞在前端。PKCE 模式本身就是为没有后端秘密的客户端设计的如果你有后端client_secret 就放在环境变量里不要打包进构建产物。前端代码里搜索不到 client_secret这是基本底线。曾经见过有公司把 client_secret 写死在打包后的 JS 里被别人拿到后冒充客户端换 token安全隐患非常严重。第五个坑是为了省事选择隐式流。如果你是新项目千万不要用隐式流。即便你用 Vue、React 做了纯前端应用也应该配一个非常薄的后端或者用由 BFFBackend for Frontend模式让 token 交换发生在后端。不要因为“demo 能跑”就把安全隐患带到生产环境。4.3 关于密钥轮换与 JWKS 缓存的补充生产环境里身份提供方会定期轮换签名密钥一般几天到几周不等。每次轮换都会在 JWKS 里新增一把新的公钥旧的公钥在一段时间内仍然保留用于验证已经签发但还没过期的 token。客户端这边需要注意两个问题按kid找公钥找到之后再验签如果 JWKS 里没有匹配的kid说明缓存过期了应该重新拉取一次再进行验签而不是直接报错。缓存 JWKS 时要设置合理的刷新周期太短了会频繁请求jwks_uri太长了会错过密钥轮换。常见做法是缓存 5 到 15 分钟遇到找不到kid时再强制刷新重试一次。我自己做过一次事件复盘那一次 IdP 完成了密钥轮换客户端因为 JWKS 缓存太久还在用旧公钥验签结果线上直接出现批量登录失败。加了一个“验签失败后强制刷新公钥并再试一次”的逻辑之后这个坑就完全平了。这个策略推荐给所有对接 OIDC 的团队。最后分享一个个人习惯在把 OIDC 流程写进业务代码之前我会先去读一遍 OIDC 的 Discovery 文档把authorization_endpoint、token_endpoint、userinfo_endpoint、jwks_uri逐项列出来和代码里用到的 URL 对照一次。这个习惯帮我省了无数次联调时间。OIDC 是一个设计得相当完整的协议你在接入中遇到的问题绝大多数都已经有标准定义。熟练掌握这套协议对未来做任何 SSO 或者账号体系统一相关的项目都是非常值得的积累。
返回列表