OAuth2.0与OpenID Connect核心原理梳理:授权码模式与ID Token实战指南
在梳理手头项目的登录模块时我发现一个很有意思的现象很多调用方的需求描述就是一句接入一下OAuth登录但你再追问一句用的是OAuth2的哪一种模式要不要顺手把OpenID Connect加上对方多半就答不上来了。我自己也是那种先会用、后补原理的人最早接第三方登录时就是照着文档往配置里填账号密码、回调地址跑通了就扔在一边。直到后来有一天要自己搭一套授权服务才真正把这些协议从头过了一遍。这个系列就是想按我的理解思路把这些东西捋清楚第一篇先集中讲整体框架和最常用的授权码模式。先说这两个协议到底在解决什么。OAuth2.0解决的是授权问题OpenID Connect解决的是认证问题。很多人把OAuth2和OpenID协议放在一起讲是因为实际业务里它们几乎总是成对出现OAuth2给你一张通行证让你能访问别人负责保管的资源OpenID Connect在这张通行证上额外印上你是哪个用户回答你到底是谁的问题。这篇内容适合谁如果你是后端开发尤其在做接口设计、基础平台或者登录模块又或者你只是想在自己的项目里接一个第三方登录但不想只听前端的安排这篇都能帮你想明白事情的原委。看完你应该能回答几个问题OAuth2里有哪几个角色为什么大家都推荐授权码模式OpenID Connect和OAuth2差在哪跳转过程中那一堆参数各自是干什么的下面按我的学习顺序展开。1. 从密码共享到授权委托协议解决的核心问题1.1 共享密码的思路为什么走不通回到最原始的方案。假如有一个第三方应用想帮你打印云端相册里的照片它怎么拿到你的相册访问权最直接的办法是你把账号密码交给它它拿着你的账号密码去云端相册的接口里取数据。这就是密码共享思路。稍微推演一下就知道问题有多大。第三方应用拿到的是你完整的账号密码它不仅能读相册还能改密码、删文件、看你存在云端的其他一切东西。更糟糕的是你没法在某个时刻只收回读相册这一个权限只能一揽子把密码改掉那这家第三方应用从此也就用不了了。密码共享方案把风险全部集中在用户手里为了保护一个低风险的场景交出了全部钥匙。时间一长有些服务会把明文密码留在自己的数据库里有些会把它写进日志安全问题是根本收不住的。还有一层更本质的冲突第三方应用不是你的服务器它是另一个独立主体。它和你之间没有天然的信任关系。OAuth2要解决的就是怎么让资源所有者你本人在不交出密码的前提下把有限范围的访问权授予另一个主体。它不只是改了一个交互方式而是从底层的授权模型上重新定义了这件事。1.2 一个更方便理解的类比有个场景大家几乎都遇过。你去商场开车车钥匙在你手里。代客泊车员说你把钥匙给我我帮你停。你把钥匙给他之后他既能帮你停车也能把车开走去兜风还能搬空后备箱。这就是密码共享。OAuth2要规避的正是这一点。它引入一个中间角色——授权服务器相当于商场的物业。你作为车主去物业那里登记我允许这辆代客泊车专用车把我的车从门口停到B3仅此一次。物业给你发一张凭证代客泊车拿着凭证去停车车钥匙始终在你手里。这里车主就是资源所有者代客泊车就是客户端物业就是授权服务器停车场就是资源服务器。这套模型的精髓在于信任被分散到了不同的边界上。客户端不再拥有用户的身份信息它只拥有被授权使用某项能力的许可。协议里所有概念——授权范围scope、授权码authorization code、访问令牌access token、刷新令牌refresh token——都是围绕这个边界感设计的。我后来再看文档一旦抓住这条主线很多疑惑就迎刃而解了。1.3 认证和授权必须分开理解在我搞明白之前一直把认证和授权混在一起结果看OAuth2文档总觉得哪里不对。现在我用一句话就能分清认证Authentication回答的是你是谁。授权Authorization回答的是你能干什么。OAuth2本身只做授权。在授权码的交互过程中授权服务器当然需要确认用户身份才能让他同意授权但协议并不关心这个登录的动作是怎么发生的。它把用户登录当作一个隐含前提。OpenID Connect的作用就是专门把这环补上这个区别到第4章再展开。2. OAuth2的角色模型与四种授权模式选型2.1 四个角色边界不能乱OAuth2规范定义了四个角色记不住这四个后面看什么都容易糊资源所有者Resource Owner拥有受保护资源的人或系统最常见的就是用户本人。客户端Client想要访问受保护资源的应用程序。这个词容易把人带偏觉得客户端就是浏览器。实际上协议里的客户端可以是网页后端、移动App、桌面软件也可以是一个纯后端的批处理任务。它代表的是发起授权请求的一方。授权服务器Authorization Server负责验证资源所有者身份并颁发令牌的一方。资源服务器Resource Server保存受保护资源的服务负责校验令牌并决定是否放行。实际部署里这四个角色经常在同一个进程里。比如你既写了授权服务器又写了业务接口那两者从部署层面看是同一个服务但协议层面角色边界必须清晰因为redirect_uri校验、token验签、scope判定都依赖这种边界。我见过有人把登录会话管理塞进资源服务器里结果token过期逻辑和session过期逻辑打架排查起来非常痛苦。2.2 四种模式各自解决的场景完全不同RFC 6749定义了四种授权模式它们不是可以随便替换的选项而是对应不同的客户端环境和信任模型。模式典型场景核心凭证推荐度授权码模式authorization_code有后端服务的Web应用先用code后端再换token推荐绝大多数场景首选隐式模式implicit纯前端单页应用旧方案直接在URL返回access_token不推荐已逐渐被授权码PKCE替代客户端凭证模式client_credentials服务与服务之间的调用客户端自己作为主体可用但只适用于没有用户参与的场景密码模式password高信任的内部系统用户直接把账号密码交给客户端慎用规范注明仅在特殊场景使用我做个选择题给你接入方信不信任我们客户端环境能不能安全保存client_secrettoken要不要代表某个具体用户按这三问一般就能定位到正确模式。过去很多人图省事用隐式模式因为前端跳转一下就直接拿到token不用后端参与。但token暴露在浏览器地址栏里历史记录、代理日志、前端脚本都可能泄露刷新token也没法安全保存。现在主流的做法已经是授权码模式加PKCE连单页应用都用这套隐式模式可以说正在被淘汰。密码模式我多说一句。它本质上就是当初的密码共享只不过在协议层面给了个正式名字。规范里白纸黑字写着它只适用于客户端和资源所有者之间有高度信任关系且其他模式都不可用的场景。我自己的判断标准是只有自研的两套内部系统之间且网络环境可信才会考虑它。面向第三方开放平台这个模式能不开就不开。3. 授权码模式全流程拆解为什么它是最稳的3.1 六步流程走一遍授权码模式的核心思想其实就一句话token不经过浏览器只经过后端。完整流程是这样的客户端把用户浏览器重定向到授权服务器的authorize端点带上自己的client_id、redirect_uri、请求的scope、随机生成的state以及respone_typecode。授权服务器处现用户要求登录如果还没登录显示授权确认页把用户要授予的权限列清楚。用户同意后授权服务器把浏览器重定向回客户端预注册的redirect_uri地址栏里带一个一次性code。客户端后端收到这个code拿着它加上自己的client_id和client_secret直接向授权服务器的token端点发请求。授权服务器校验通过返回真正的access_token通常还带refresh_token如果是OIDC流程还会返回id_token。客户端后端用access_token去资源服务器请求数据。为什么要绕这么大一圈关键在第4步。code只在浏览器里闪了一下换token发生在后端服务器之间的直接通信。攻击者就算在浏览器层面截获了这个code因为他没有client_secret这个code对他而言就是一段废字符串。而且code是一次性的有效期也很短授权服务器还会校验它绑定的client_id和redirect_uri这多重限制叠在一起把token暴露在浏览器的风险降到了最低。3.2 授权请求里那些参数为什么一个都不能省拿到授权服务器文档时第一步跳转的URL长这样GET /oauth2/authorize? response_typecode client_idWEB_APP redirect_urihttps://example.com/callback scopeopenid%20profile%20email statea34fe30d9c1a code_challengeK2-ltwP0Z4q3YkQ5aInExample code_challenge_methodS256几个关键参数逐个看response_typecode告诉授权服务器你要走授权码模式。client_id客户端的公开标识相当于你的门牌号。注意它不是密钥可以暴露在浏览器端。redirect_uri用户同意后跳回哪里。这个参数必须严格等于你预注册的回调地址授权服务器要用它来做匹配校验。scope你要申请的权限范围。OIDC流程里一定要带上openid这样才能拿到身份信息。state客户端自己生成的随机字符串用于防止跨站请求伪造。用户被跳回来的时候这个参数会原样带回来客户端要校验它和自己发出去的一致。code_challenge与code_challenge_methodPKCE相关的参数后面单独说。state参数我见过太多人直接置空。你想想如果跳回去的时候没校验state攻击者可以构造一个带参数的链接诱导用户点击然后这个链接里带着一个攻击者自己的code客户端把攻击者的code当成用户的来用那整个认证链路就对着一个陌生人建立了。state就是为了拦住这种伪造回跳。3.3 换token这一步的隐藏细节拿到code之后后端开始干活。这一步是标准后端到后端交互POST /oauth2/token Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code codex0P9qP3wE2jJ redirect_urihttps://example.com/callback client_idWEB_APP client_secretsome_secret_value code_verifier...grant_type必须写死是authorization_code。把redirect_uri再传一次不是多余的授权服务器会拿它和authorize阶段传的比对防止掉包。client_secret通常放在请求体里更规范的做法是用HTTP Basic认证放在Authorization头里具体看各授权服务器的要求。这一段的返回值大概是这样的{ access_token: eyJhbGciOiJSUzI1NiIsInR5cCI6..., token_type: Bearer, expires_in: 3600, refresh_token: e0e6a1f6-9b6d-4f0a..., id_token: eyJhbGciOiJSUzI1NiIsInR5cCI6... }有的同学问过access_token和refresh_token是不是都要存起来我建议访问令牌能短就短最好本地存一份拷贝真正使用时放内存或短期Cookie里就好。refresh_token的保存位置更敏感因为它可以换新token丢了等于授权长期被接管能放后端就别放前端。3.4 PKCE给授权码再加一道保险最早设计PKCE是为了解决移动端和单页应用这类公共客户端不能安全保存client_secret的问题。它的思路很巧妙客户端自己生成一个随机串code_verifier把它哈希后的值code_challenge在授权请求阶段发出去换token的时候再把原始code_verifier发出去授权服务器验证两者是否匹配。翻译成人话就是授权码就算被人截获了拿到它也没用因为换token时要拿出验证器这个验证器只有真正的客户端才持有而且它不会出现在任何URL里。现在很多授权服务器已经默认要求所有授权码流程都带PKCE包括有后端的Web应用。我建议你也这样要求自己别嫌麻烦两行代码的事。4. OpenID Connect把认证能力补上来4.1 协议关系OAuth2加一张身份证标题里写的OpenId规范名其实是OpenID Connect一般简称OIDC。它构建在OAuth2之上目的非常单一增加认证能力。一个场景就能说明这个区别。你用某个第三方网站登录一次之后这个网站拿到了访问你资料的access_token。OAuth2能告诉你这个token能用你去调吧但拿token的人到底是谁它不知道。OIDC就是在OAuth2的流程里额外签发一个id_token用这个token告诉你这位用户是谁、认证是什么时候完成的、他在授权服务器那边被记录成了什么标识。所以OIDC不是OAuth2的替代品而是它的一个认证层。两者结合使用时OAuth2继续负责授权流程OIDC负责身份信息传递。4.2 ID Token、JWT和UserInfo端点OIDC新增的核心产物是ID Token格式上是JWTJSON Web Token。JWT由三段组成头部、载荷、签名。头部说明签名算法载荷放用户相关信息签名保证内容没被篡改。ID Token里面关键的字段有这些iss签发者要和你配置的授权服务器地址一致。sub用户的唯一标识稳定且不会变以后要用它做用户映射。aud受众必须等于你的client_id防止这个token被用在别处。exp和iat过期时间和签发时间。nonce客户端发起认证请求时带的随机串用来防重放攻击。我刚开始验ID Token时吃过亏只验了签名和过期时间忘了校验aud等于让一个本来发给别人的身份证明在自己服务里也能通过这是典型的漏洞。完整校验流程应该是验签、验iss、验aud、验exp、验nonce一个都不能跳。除了ID TokenOIDC还提供了UserInfo端点。客户端拿着access_token请求这个端点可以拿到email、昵称、头像等详细信息。为什么这些信息不一股脑放ID Token里因为ID Token有签名、有大小限制只放核心身份信息更安全也更精简需要更多画像数据时按scope去UserInfo拉就行。4.3 一个容易踩的概念坑经常有人搞混ID Token和access_token的用途我拿个类比说清楚。access_token是给资源服务器看的门票它证明你有权限进某个区域资源服务器认它。ID Token是给客户端自己看的身份证它告诉你用户是谁。ID Token绝不能当成API调用凭证去用因为资源服务器根本不认识它。反过来access_token也别当身份凭证存因为那里面压根不含用户身份信息。还有一个细节授权请求里不写scopeopenidOIDC整套流程都不会启动你只会拿到access_token不会有id_token。这话听起来简单但实际项目里真有人配了OAuth2却怎么拿都拿不到id_token查来查去就是scope漏了。5. 实操配置一份能照着填的检查清单5.1 关键字段速查表我把自己在接入和搭建授权服务时经常要核对的项目整理成了一张表遇到问题可以先对着查检查项正确做法违规后果redirect_uri预注册且完全匹配授权请求和token请求保持一致授权失败或回调被劫持state每次请求随机生成回调时严格比对可遭遇CSRF伪造回跳scopeOIDC场景必须包含openid拿不到id_tokencode一次性使用短时有效用完立即作废重放攻击client_secret只出现在后端不允许进浏览器代码凭证泄露令牌被冒领PKCE授权码模式下推荐全程启用公共客户端风险升高ID Token校验验签、iss、aud、exp、nonce被注入伪造身份5.2 我踩过的坑和应对办法和授权服务联调时如果报错别急着四处问人先按顺序排查这几点跳转回回调页却没有code。看一眼是不是授权请求少了scope或response_type多数是URL拼错。errorunauthorized_client。通常client_id配置错或者redirect_uri没在授权服务器里预注册。errorinvalid_grant。多半是code过期了、用过了或者token请求里的redirect_uri和授权阶段不一致。换token后调资源接口返回401。优先查access_token是不是过期其次查验签用的密钥和算法是否匹配。ID Token验签失败。确认你的jwks_uri地址有没有配错另外签发算法和验签算法是否一致也要留意。refresh_token换来new token后老token还能不能用这取决于服务端是否开了refresh_token rotation。如果开了旧token一用即失效你手里存了旧token再拿去换就会被拒。我自己调试时还有个习惯先把授权服务器日志打开看它具体在哪个环节拒绝的请求。大多数OAuth2问题本质是参数不匹配日志里会写明expected和actual的差异跟着日志一步步对齐比瞎猜快得多。最后再分享一个个人经验。与其在接入第三方时一个参数一个参数地试不如先花半小时把OAuth2的授权码流程和OIDC的ID Token校验过一遍。state、code、nonce这几个参数背后都是具体的安全攻击场景读懂它们比背UI的配置步骤有用得多。我第二篇准备深入讲JWT的签名与验签细节还有把授权服务器从业务代码里剥离开时要注意的边界问题。学习协议没有捷径但按自己的方式建立一条主线后面每次遇到新问题都能挂回到这条主线上来会越学越顺。