OAuth2 简介
一、OAuth2 的背景与问题动机
回想一下,你上一次老老实实填写手机号、设置密码、接收验证码来注册一个新APP是什么时候?
在现在的中国互联网环境下,这种繁琐的操作已经越来越少见。取而代之的是,当你下载了百度网盘或者知乎,手指会下意识地点击那个绿色的 微信登录 按钮;当你打开王者荣耀或者和平精英,你会自然地授权游戏读取你的微信好友列表,以便能和朋友开黑;当你戴着智能手环跑完步,打开 Keep 或者 小米运动,你会发现你的步数已经自动同步到了微信运动的排行榜上,等着占领朋友的封面。 这还没完,更深度的场景发生在你的钱包里。当你打完滴滴下车,或者收到了美团外卖,你甚至不需要掏出手机输入支付密码,钱就已经自动通过支付宝或微信的免密支付扣除了。
在这个过程中,你发现了吗?这些APP仿佛形成了一个默契的生态圈:百度网盘不需要知道你的微信密码,却能确认你是谁;滴滴不需要掌握你的银行卡密码,却能从你的账户里扣钱。这种只办事、不交底的体验如今我们早已习以为常。但如果把时间倒推回去,在 OAuth 协议普及之前的黑暗时代,想要实现同样的跨应用协作,我们往往不得不采用一种极其危险的方案——密码反模式。
具体的做法是,第三方应用(比如上面提到的照片冲印网站)会弹出一个对话框,要求你直接输入你的 Google/微信 账号和密码。应用拿到你的账号密码后,会通过 HTTP Basic Auth 的方式,或者直接模拟用户登录的行为,把你的账号密码发送给平台服务器进行验证。验证通过后,这个第三方应用就相当于完全替代了你本人,拥有了和你一样的权限,可以随心所欲地获取你的所有数据。
这种简单粗暴的方法虽然解决了互通问题,但却带来了三个致命的安全隐患:
-
信任危机:用户被迫将核心凭证也就是密码直接交给第三方应用。你必须无条件信任这个第三方应用不会保存你的密码,也不会拿你的密码去做原本需求之外的事情。因为一旦第三方应用拥有了密码,它就拥有了该账号的所有权限。比如照片冲印网站本该只能读取照片,但因为有了密码,它还能看你的邮件、删除你的文件、甚至修改你的账号密码。
-
撤销困难:如果想取消对这个第三方应用的授权,你无法单独切断它的访问,唯一的办法是修改你的Google账号密码。这会导致所有绑定了该Google账号的 服务都需要一个个重新登录。
-
连带风险:如果这个第三方应用的数据库被黑客攻击,导致存储在其中的用户密码泄露,那么你的Google账号也就随之泄露了,这种连带效应极大地增加了账号的安全风险。并且许多用户为了方便记忆,会把许多应用的密码设置成同一个,这导致泄露密码给应用更危险。
二、OAuth2 协议
为了彻底终结“交出账号密码”带来的安全噩梦,OAuth2 引入了一套全新的逻辑。这套逻辑的核心非常简单,就是把“我是谁”和“我能干什么”这两件事彻底分开。
2.1. OAuth2 的核心思想
我们不妨用一个大家都在写字楼里经历过的场景来打个比方:公司访客门禁卡。 假设你在一家安保极其森严的科技大厂工作,中午点了一份外卖,需要外卖小哥送到你的办公室。 如果是以前的密码模式,就相当于你直接把你自己的员工工牌扔给了外卖小哥,以便于他上电梯并送外卖给你。但是要知道,你的工牌里可是绑定了你的身份信息和最高权限,外卖小哥拿着它,不仅能刷卡坐电梯,进入大门,还能刷开财务室、机房,甚至推门进老板办公室,这显然太疯狂了。
而 OAuth2 的模式,就相当于你去公司前台办理了一张【访客通行证】。当外卖小哥来了之后,前台打电话核实了你的身份后,给外卖小哥发一张你办理的【访客通行证】。这个访客通行证有两个关键特点:第一,权限受限,它只能刷开大门和电梯,绝对进不了财务室;第二,时效有限,一小时后自动作废。这些限制并不影响拿到这张访客通行证的外卖小哥顺利完成送餐任务。
在这个过程中,代表你核心身份的“员工工牌”从未离开过你的手,外卖小哥拿到的只是一个受限的、临时的“通行许可”。这里的“访客通行证”,在 OAuth2 协议中就叫做“令牌(Token)”。这其实就是 OAuth2 的精髓:令牌化授权。第三方应用永远无法触碰到用户的真实密码,它只能向授权服务器申请一个令牌。这个令牌通过“作用域”限制了它能看什么、能做什么,即便令牌不小心泄露了,黑客也没法用它去修改你的密码或者转走你的钱。
2.2. 角色模型
理解了“访客通行证”的核心思想后,我们再来看看这出大戏里的四个关键角色。搞清楚它们是谁,是理解后面所有流程的前提。
-
资源拥有者【Resource Owner】。 这就是用户,也就是屏幕前的你。你是那些数据的主人,无论是你的微信头像、通讯录,还是相册里的照片,给不给别人看,完全由你说了算。
-
客户端【Client】。 这里特指那些想要访问你数据的第三方应用程序。比如前面提到的“在线冲印网站”,或者想读你好友列表的“王者荣耀”。之所以叫它客户端,是因为相对于提供数据的服务商来说,它是发起请求的一方。它必须先拿到你的授权令牌后,才能开始干活。
-
授权服务器【Authorization Server】。 它是整个系统的“发证机关”,通常是互联网巨头们的安全门户,比如微信开放平台或者Google账号中心。它的职责就像公司前台,专门负责验证你是不是本人,并根据你的批准,给第三方应用印发那张临时的“访客通行证”(令牌)。
-
资源服务器【Resource Server】。 它是真正保管核心资产的地方,比如微信的头像数据库或者Google相册的服务器。它是“仓库管理员”,只认令牌不认人。当第三方应用拿着令牌来取数据时,它会校验令牌是不是真的、有没有过期。如果一切正常,它才会把数据交出去。
在现实生活中,授权服务器和资源服务器往往属于同一家公司,比如都在腾讯或阿里的机房里。但在逻辑上,我们把它们看作两个独立的部门:一个负责在大门口发证,一个负责在仓库门口验票。
2.3、OAuth2 的授权流程
OAuth 2.0 是目前关于授权的行业标准协议(RFC 6749)。它的核心任务非常单一且明确:允许用户授权第三方应用访问其存储在其他服务上的信息,而无需将用户名和密码提供给第三方应用。 它不仅仅是一个协议,更是现代互联网身份认证的基础设施。无论是使用微信等登录,还是企业内部系统之间的互联,背后几乎都是 OAuth2 在运行。它已经成为了互联网事实上的门禁标准。
OAuth2 极其灵活,为了适应不同的场景(比如是从服务器访问、从手机 App 访问,还是从智能电视访问),它定义了多种获取令牌的方式,被称为“授权模式”。 主要有以下几种:
-
授权码模式: 这是最常用、最安全,也是正经开发者的首选模式。适用于那些有自己后端服务器的应用(Web App)。也就是我们前面花大篇幅讲的“前端跑腿、后端换证”的那套流程。
-
客户端凭证模式: 这种模式没有用户参与,纯粹是机器和机器对话。比如你们公司的两个后台服务之间要同步数据,它们不需要用户点同意,直接拿自己的身份证换个令牌就开始传输了。
-
隐式模式: 这里要给这么模式打上一个大大的红叉。该模式是早期为了方便纯前端页面(没有后端)设计的,因为它省去了换令牌的步骤,直接把令牌扔给浏览器。但因为太不安全,现在已经被官方废弃了,属于“时代的眼泪”,了解一下即可,千万别用。
-
密码模式: 该模式也要打上一个大大的红叉。密码模式的做法是让用户直接在第三方应用里输入账号密码。这完全违背了 OAuth2 的设计初衷,回归到了没有 OAuth 的黑暗时代,只有在极度信任的旧系统改造中才会作为权宜之计,现代应用绝对不推荐使用。
-
设备码模式: 这是专门为那些不方便打字的设备设计的,比如智能电视、打印机。你在电视上登录视频会员时,屏幕上会弹出一个二维码让你用手机扫一扫,这就是典型的设备码模式。
- 详解:授权码模式
这是 OAuth2 家族里辈分最高、安全性最好,也是你每天都在用的模式。无论是微信登录、支付宝授权,还是 GitHub 登录,背后跑的都是这个流程。 为了讲清楚这个复杂的后台逻辑,我们先不要谈技术细节,而是回想一下你作为用户,在百度网盘上使用微信登录时看到的真实过程。
通常是这样的:你点击登录按钮,网页刷地一下跳到了微信的二维码页面;你扫码同意后,网页又刷地一下跳回了百度网盘,然后你就显示登录成功了。 表面上看很简单,但这中间其实藏着一个非常奇怪的细节。 当你扫码同意后,微信跳回百度网盘时,并没有直接把那把万能的金库钥匙(也就是令牌 Token)交给浏览器带回去,而是只给了一张临时的“提货单”(也就是授权码)。百度网盘的后台拿到这张单子后,还得自己偷偷跑一趟微信的后台,才能换回真正的钥匙。
你可能会问:为什么搞这么麻烦?既然我都同意了,微信为什么不直接把钥匙给浏览器带回来,非要中间多倒腾这一手? 这里必须要引入一个核心的安全逻辑:浏览器是不可信的。 这就好比你让人帮忙送一把金库钥匙。如果你直接把金库钥匙交给一个跑腿小哥(也就是用户的浏览器),他在路上可能会弄丢,可能会被坏人劫持,甚至他自己可能就会偷偷配一把。这太危险了。 所以 OAuth2 采取了“提货单”策略:微信先给跑腿小哥一张不值钱的提货单。这张单子如果丢了也无所谓,因为要兑换钥匙,不仅需要这张单子,还需要配合只有百度网盘自己才知道的“公司私章”【密钥】。这样一来,作为跑腿小哥的浏览器,全程只摸到了提货单,根本没机会碰到金库钥匙。 搞懂了这个防君子不防小人的逻辑,我们再来看具体的时序图,详细讲解每一步在干什么。
+----------+ +------------+ +-------------+ +-------------+
| 资源拥有者 | | 用户代理 | | 客户端 | | 授权服务器 |
| (用户) | | (浏览器) | | (百度网盘) | | (微信) |
+----------+ +------------+ +-------------+ +-------------+
| | | |
| (1) 点击微信登录 | | |
|------------------>| | |
| | | |
| | | |
| | | |
| | (2) 重定向到微信 |
| | (3) 携带client_id,scope 请求授权页面 |
| |----------------------------------------->|
| | | |
| (4) 扫码/确认授权 | | |
|<------------------| | |
|------------------>| | |
| | (5) 发送同意信号 |
| |----------------------------------------->|
| | | |
| | (6) 重定向回百度盘[附带 code (授权码)] |
| |<-----------------------------------------|
| | | |
| | | |
| | (7) 将code发给后台 | |
| |------------------->| |
| | | (8) 后台请求令牌,携 |
| | | 带code+cliet_secret |
| | |-------------------->|
| | | |
| | | (9)返回access_tocken |
| | |<--------------------|
+ + + +
[ 前 端 通 道 (不安全) ] [ 后 端 通 道 (安全) ]
整个过程其实就分为两场戏:前台的跑腿戏,和后台的交易戏。
第一阶段:前台交互(跑腿小哥拿提货单) 这一阶段全程都在你的浏览器里发生,目的是拿到那张临时的“提货单”。 当你在百度网盘点击那个绿色的登录按钮时,百度网盘会跟浏览器说:“带用户去微信那边办个手续。” 浏览器乖乖跳转到微信的服务器。这时候,URL里其实带了一堆参数,翻译过来就是:“微信大哥,我是百度网盘(client_id),我想申请读一下这个用户的昵称和头像(scope),办完事麻烦把他送回这个地址(redirect_uri)。”
接着就是你最熟悉的环节:扫码。微信得确认真的是你在操作,并且你真的同意把头像给百度网盘。 当你点击允许的那一刻,微信就生成了一张临时的提货单,也就是授权码(code)。微信通过重定向,让浏览器带着这张提货单,回到了百度网盘事先约定好的回调地址。 注意,这时候那张提货单是暴露在浏览器地址栏里的,谁都能看见。但它时效极短,且单凭它换不到访问令牌。
第二阶段:后台交互(私密交易换钥匙) 这一阶段是机器与机器的对话,发生在百度网盘的机房和腾讯的机房之间,你在浏览器上是看不见的。 百度网盘的后台服务器拿到了浏览器传回来的提货单[授权码]后,它会悄悄地给微信的后台服务器打一个电话。在这个电话里,百度网盘需要出示两样东西: 一是刚才那张提货单[授权码]。 二是它自己的公司私章[应用密钥client_secret]。这个私章是百度网盘注册微信开放平台时生成的,只有百度和微信知道,绝对不会告诉浏览器。
微信一核对:提货单是真的,私章也是真的,确认是百度网盘本尊来取货,不是黑客冒充的。 于是,微信愉快地交出了最终的“金库钥匙”——访问令牌(access_token)。至此,百度网盘终于拿到了令牌,可以去读取你的头像和昵称了。
上述过程虽然绕了一个大圈子,但这个设计的精髓就在于:最值钱的“金库钥匙”(令牌)和最重要的“公司私章”(密钥),全程都在安全的服务器之间传输,从来没有经过那个不可信的浏览器。这就是为什么授权码模式是目前最安全、最主流的选择。
进阶补充:PKCE 增强模式
还记得我们刚才强调的那个“公司私章”(应用密钥 client_secret)吗?在 Web 应用里,把它藏在服务器机房里是非常安全的。 但是,如果你的客户端不是百度网盘这种有独立后台的网站,而是一个纯粹安装在用户手机上的 App,或者是一个直接跑在浏览器里的单页应用(SPA),麻烦就来了。 手机 App 的代码本质上是公开的。如果把“公司私章”写死在 App 代码里,这就像是把自家保险柜密码写在了大门的便利贴上。黑客只要稍微花点功夫反编译一下代码,就能轻松拿到这个密钥,然后伪装成你的正版 App 去骗取用户的令牌。 为了解决这个问题,OAuth2 引入了一个补丁,叫做 PKCE(读作 Pixy),全称是“用于代码交换的证明密钥”。
学过密码学的很容易理解,PKCE 的原理本质上就是密码学里经典的“承诺-验证”机制,并且使用的是最简单的哈希承诺实现。它的核心逻辑是:既然手机存不住固定的“私章”,那我们就不存了,改成每次请求都临时生成一对“一次性暗号”。这个过程就像是特工接头:
-
生成随机数(做出承诺) 手机 App 在每次发起登录请求前,都会临时生成一个随机的长字符串,我们管它叫验证码(Code Verifier)。 然后,App 利用 SHA-256 算法对这个字符串进行哈希运算,生成一个挑战码(Code Challenge)【实际上就是哈希承诺 c = H(code Verifier)】。
-
发送哈希值(第一步通信:给指纹) 当 App 指挥浏览器跳去微信授权时,它不再出示那个固定的私章,而是把刚才算出来的“挑战码”发给微信。 这句话的潜台词是:“微信大哥,我手里有个随机数,我现在先把它的指纹(哈希)给你存着。我不给你看原件,但我承诺待会儿来换令牌的人,一定持有这个原件。” 【由于承诺的隐藏性和绑定性,这个是安全的】
-
发送原值(第二步通信:亮底牌) 当用户授权完成,App 拿到授权码(Code)准备去换取令牌时,它会将那个原始的验证码发给微信。微信收到原始的验证码后,用同样的 SHA-256 算法算一遍。如果算出来的结果,和第一步收到的“指纹”一模一样,那就证明:现在来换令牌的设备,就是刚才发起授权的那个设备,中间没有被黑客截胡。
通过这种“每次都变动态暗号”的方式,即便没有后端服务器来保管私章,手机 App 也能在充满敌意的网络环境中,安全地完成授权。
三、常用的访问令牌格式 JWT
在上一篇 OAuth2 的笔记中,我们费了九牛二虎之力,终于让百度网盘拿着“授权码”去微信后台换回了最终的 Access Token(访问令牌)。 流程走完了,但故事还没结束。 现在,百度网盘手里捏着这串 Token,兴冲冲地去找微信的资源服务器:“我要取用户的头像!” 微信的资源服务器也就是守门员,拦住了它:“先别动,我得看看你手里的这串 Token 是真的还是假的,以及它到底代表谁。” 这时候,就轮到 Token 本身出场了。在软件架构的历史演变中,关于Token 到底长什么样,主要分成了两个流派。理解这两个流派的区别,你才能明白为什么现在大家都在用 JWT。
3.1 OAuth2 中的访问令牌形式
流派一:引用令牌 (Reference Token)
引用令牌是最传统的做法,我们可以把它称为“洗澡手牌模式”。它是怎么运作的呢? 我们以上面举的百度网盘和微信的例子来说明。在上述OAuth2中过程完成后,微信给百度网盘发了一个 Token,可能就是一串没有任何意义的随机字符,比如 uuid-8f9s-2d3f ,当你拿着这个令牌来访问微信的资源时,微信会查询一下自己的数据库,看这个令牌是否存在,是否过期等,从而判断你是否有权限访问资源。这就像你去澡堂洗澡,前台给你一个写着“305”号的手牌。这个“305”本身没有任何含金量,光看这个牌子,你不知道它是男宾还是女宾,也不知道有没有买自助餐券。 关键动作:当服务员看到你亮出“305”手牌时,他必须去前台的电脑系统(数据库或 Redis)里查一下:“305 号是谁?哦,是张三,买了全套服务。”
-
优点:撤销极快,如果想封你的号,或者你手牌丢了,前台只需要在电脑系统里把305号注销掉,你再拿着手牌去消费,服务员一查发现无效,你就废了。这在安全控制上非常灵活。
-
缺点:服务器累死,每一次请求,资源服务器都必须去数据库里查一次。如果微信有 10 亿用户同时在使用第三方登录,数据库的压力会大到爆炸。这就是所谓的“有状态(Stateful)”。
流派二:自包含令牌 (Self-contained Token)
为了解决服务器压力过大的问题,第二种流派诞生了。这就是 JWT (JSON Web Token) 所属的流派。我们可以把它称为“火车票模式”。 它是怎么运作的? 微信不再发那个毫无意义的随机数了,而是发给百度网盘一张“实名火车票”。 这张票(Token)上直接写满了信息:例如 [我是张三] ; [我是金牌会员] ; [过期时间是今晚 12 点] 等等。并且最关键的是,这张票上盖了一个微信官方的“防伪印章”(签名)。 当百度网盘拿着这张“火车票”来取头像时,微信的资源服务器不需要去查数据库。它只需要看一眼票面上的信息,再校验一下那个防伪印章是不是真的。只要印章是真的,服务器就承认这张票有效。
-
优点: 服务器彻底解放:资源服务器不需要记录任何登录状态,也不需要频繁查库。它只需要负责验票,这就意味着无论加多少台服务器,都能轻松扩展。这完美契合现代的微服务架构。
-
缺点: 撤销困难:这是最大的痛点。一张火车票一旦打印出来交给用户,在它过期之前,它就是有效的。哪怕用户手机丢了,黑客捡到这张票,在过期前也能一直用。服务器很难在中间把这张票“作废”,除非换这趟车的验票规则(换密钥)。
当前常用流派:自包含令牌
在早期的单体应用时代,“洗澡手牌模式”(Session ID / Reference Token)是主流。 但是到了现在的移动互联网和微服务时代,应用往往有几十个微服务节点,用户量也是千万级别。如果还用老办法查库,数据库早就崩了。 因此,牺牲一点点“撤销的灵活性”,换取极致的“性能和扩展性”,成为了业界的共识。目前常用的自包含令牌是JWT (JSON Web Token),它已经成为现代互联网令牌的事实标准。它就是那张自带信息、自带防伪印章的数字火车票。 那么,这张“数字火车票”内部到底长什么样?那些信息是怎么写上去的?防伪印章又是怎么盖的?
3.2 无状态令牌 JWT 的格式介绍
如果你在网络请求的 Header 里抓包看到了一个 JWT,你会发现它长得非常像一串乱码。但如果你仔细观察,会发现这串乱码中间有两个点号 (.),把它分成了三截。 它的标准格式长这样:【Header.Payload.Signature】。这三部分分别对应了:“信封.信件内容.防伪印章”。 注意,前两部分(Header 和 Payload)虽然看起来像乱码,但它们其实只是做了 Base64Url 编码。这意味着,任何人只要把这串字符扔进解码器,都能立刻看到里面的明文 JSON 数据。
第一部分:头部 (Header)
这是 JWT 的第一段。它通常是一个简单的 JSON 对象,主要告诉服务器两件事:
- 我是谁:通常是 typ: “JWT”,表明这是一个 JSON Web Token。
- 我用什么封口:alg (Algorithm) 字段,声明了第三部分签名所使用的加密算法。
例子:(下面一段被 Base64 编码后,就变成了 JWT 的第一部分)
{
"alg": "HS256", // HS256:表示使用 HMAC SHA-256 算法(对称加密,你需要一个密钥)。
"typ": "JWT" // RS256:表示使用 RSA 算法(非对称加密,私钥签名,公钥验签)。
}第二部分:载荷 (Payload)
这是 JWT 的中间部分,也是最“值钱”的部分。所有的用户数据、权限声明都放在这里。在 JWT 的术语里,每一个键值对被称为一个 Claim (声明)。
这些声明分为三类,但我们重点关注工程中最常用的两类:
-
标准声明:这是 JWT 规范里预定义好的一些字段,虽然不强制使用,但建议遵守。常见的有:
- sub (Subject):主题,通常用来放用户 ID(如 user_123)。
- exp (Expiration Time):过期时间。服务器验证的令牌如果过了此时间会被拒绝访问资源。
- iat (Issued At):签发时间。
-
自定义声明 (Custom Claims) 这是开发者自己定义的字段。你可以随意往里塞业务数据。如:
- “name”: “张三”
- “admin”: true
- 注意:Payload 同样只是做了 Base64 编码,不是加密!绝对不要把用户的密码放在这里。绝对不要把用户的手机号、身份证号等隐私信息放在这里。因为任何人截获了这个 Token,都能瞬间解码看到这些信息。这一段 JSON 被 Base64 编码后,变成了 JWT 的第二部分。
第三部分:签名 (Signature)
这是 JWT 的最后一段,也是防止那张 JWT 的 header 和 payload 被篡改的关键。这一部分的生成逻辑,对于学密码学的人来说非常简单直观: 它把前两部分的编码后的字符串用点号拼接起来,当作“原文”,然后结合服务器手里的私钥 (Secret),进行哈希运算,计算出哈希值如下:
Signature = HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret )
服务器收到这个签名之后,使用自己事先存储好的 secret 按照上述公式重新计算哈希值,并比较计算出的哈希值和tocken中的哈希值是否相等。如果相等则认为这个tocken 是合法的。这个过程实际上就是 HMAC 的验证过程。由于哈希函数的绑定性和隐藏性,这个过程是安全的。
具体来说,验证过程如下:
- 使用 . 分隔符将 JWT 分为三个部分(header,payload,签名)。
- 使用 Base64URL 对 header 和 payload 进行解码。
- 使用 header 中指定的算法和公钥(适用于非对称算法)来验证签名。
有许多库可以帮助进行 JWT 验证,例如针对 Node.js 和 web 浏览器的 jose
我们可以简单分析一下这个签名的作用是什么? 假设有一个黑客叫“法外狂徒张三”,他截获了你的 JWT。 他发现 Payload 里写着 “role”: “user”。 他想把自己提权成管理员,于是他把 Payload 解码,改成 “role”: “admin”,然后重新编码塞回去。这时候,当他把伪造的 Token 发给服务器时:服务器收到 Header 和 被改过的 Payload。服务器拿出自己私藏的 secret,按照公式重新算一遍签名。露馅了:因为 Payload 变了,服务器算出来的新签名,肯定和 Token 最后自带的那个旧签名不一致。服务器直接拒绝请求。
有许多算法可以创建数字签名。我们前面介绍了使用基于HMAC的签名算法,但它可能不够强大,因为 secret 必须在双方(例如客户端和服务器)之间共享,这在很多情况下是不现实的,因此我们首选的做法时使用基于公钥密码学的数字签名技术来签署JWT,例如我们使用最流行的RSA签名算法。此时 JWT 的 header JSON 如下:
{
"alg": "RS256", # RS256 代表 RSA-SHA256,这意味着签名是由 RSA 算法和 SHA256 哈希函数生成的。
"typ": "JWT"
}此时 JWT 的签名部分等于 Signature = RS256( base64UrlEncode(Header) + “.” + base64UrlEncode(Payload), privateKey) 并且验证者只需要使用签发者的 RSA 公钥 publicKey 即可验证 JWT 的合法性,而不需要实现存储一对相同的密钥 secret 。
总结来说:JWT 的本质就是: 用 Header 告诉你算法。用 Payload 装着数据(透明的,谁都能看)。用 Signature 保证数据不被篡改(只有服务器能签发)。 这三部分通过点号连接,共同构成了一张可以在互联网上安全裸奔、且不需要服务器查数据库的“数字火车票”。
第四部分:刷新令牌 (Refresh Token)
在上一节关于 JWT 的介绍中,我们提到了 exp (Expiration Time) 字段。出于安全考虑,Access Token(也就是那张“数字火车票”)的有效期通常设置得很短(例如 1 小时)。 这就带来了一个体验问题:难道用户每隔一小时,就得重新扫码、重新授权一次吗? 显然不能这样。为了解决安全与体验的矛盾,OAuth2 引入了一个至关重要的概念:Refresh Token。
- 在标准的 OAuth2 流程中(特别是授权码模式),授权服务器其实会一次性给你两个令牌:
-
Access Token (短效):
- 角色:这是用来干活的。你拿它去取用户头像、发朋友圈。
- 特点:有效期短(如 1 小时),通常是 JWT 格式。
- 安全策略:因为用得频繁,暴露风险大,必须短命。哪怕被黑客截获也只能用一小会儿。
-
Refresh Token (长效):
- 角色:它的作用只有一个:当 Access Token 过期后,拿着它去换一个新的 Access Token。
- 特点:有效期长(如 30 天),通常是一串无意义的随机字符(引用令牌)。
- 安全策略:它从不参与具体的业务请求,平时一直严密锁在客户端的保险柜里,只有在 Access Token 失效的那一瞬间才会拿出来用一次。
- 刷新流程 (Silent Refresh)
这个过程对用户是完全无感的,被称为“静默刷新”:- 发现令牌失效:当令牌过期之后,如果客户端继续拿着 Access Token 去请求数据,那么服务器将会返回 401 Unauthorized,这是在提示你:访问令牌已经过期了。
- 换证:发现访问令牌过期之后,客户端并不会去打扰用户,而是悄悄拿出本地存储的 Refresh Token,向授权服务器发起“刷新请求”。授权服务器验证 Refresh Token 没问题,就会颁发一个新的 Access Token 给客户端(通常也会同时给一个新的 Refresh Token,旧的作废)。
- 重新访问数据:客户端拿着新的 Access Token 重新发起刚才失败的业务请求,获取数据。
请求示例:
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token // 告诉服务器:我是来刷新的
&refresh_token=8xLOxBtZp8... // 出示长效令牌
&client_id=s6BhdRkqt3 // 验明正身
&client_secret=... // (后端应用需要提供密钥)通过这套机制,即便是 Access Token 只有 15 分钟有效期,用户也能感觉到 App 好像永远处于登录状态,既保证了安全,又兼顾了体验。
四、OpenID Connect (OIDC) 简介
在上一篇笔记中,我们通过 OAuth2 完美解决了百度网盘在不知道你密码的情况下,获得读取你微信头像权限的问题。但是,如果你细心观察,会发现 OAuth2 协议从头到尾只关注了一件事:授权 (Authorization),也就是允许客户端干什么。 这就好比我们之前提到的外卖小哥:通过 OAuth2,外卖小哥成功获得了访客通行证,他可以刷卡上楼、把外卖送到你的工位。但是,这张通行证只代表他有权进来,却并没有告诉外卖小哥是谁批准他进来的。对于门禁系统来说,只要卡是对的,谁用都行。 然而,在很多场景下(比如使用微信登录),百度网盘不仅仅想获得读取头像的权限,它更想知道现在的用户到底是谁,以便在百度网盘的系统里为你建立档案。这就涉及到了 认证 (Authentication)。 在 OIDC 出现之前,开发者们为了用 OAuth2 做认证,通过各种魔改手段来实现。比如百度网盘拿着 Access Token 去微信的 API 问一句:“告诉我这个 Token 的主人是谁”。虽然也能用,但每家大厂的接口都不一样:Google 可能叫 user_info,Facebook 可能叫 me,这就像每家酒店验证身份的方式不同,有的看身份证,有的看指纹,有的看脸,乱成一锅粥。 为了结束这种混乱,OIDC (OpenID Connect) 诞生了。它的核心定义非常简单: $$\text{OIDC} = \text{OAuth 2.0} + \text{身份认证层 (Identity Layer)}$$
OIDC 不是要把 OAuth2 推倒重来,而是构建在 OAuth2 之上的一个补丁。它直接复用了 OAuth2 的授权流程(就是你熟悉的授权码模式),只是在流程中多加了一点点东西,让这个协议不仅能解决“能干什么”,还能解决“我是谁”【该过程通过ID token实现,具体介绍如下】。
4.1 OIDC中的 ID Token
如果说 OAuth2 的产物是 Access Token(访问令牌),那么 OIDC 在此基础上,多给了一个产物:ID Token(身份证)。 这个过程非常简单:当百度网盘发起授权请求时,只要在请求的 scope 参数里加上一个特殊的暗号:openid(例如 scope = openid profile email)。 微信(授权服务器)收到这个暗号后,就会明白:“噢,原来百度网盘不仅要权限,还要核实用户身份。” 于是,在最终的步骤里,微信不仅会给百度网盘返回 Access Token,还会顺便多给一个 ID Token。
在上一章我们已经学过了 JWT。OIDC 协议强制规定:ID Token 必须是一个 JWT。 这意味着,百度网盘拿到 ID Token 后,不需要再发网络请求去问微信“这是谁”,直接在本地解码这个 JWT,就能看到类似下面这样的信息:
{
"iss": "https://accounts.weixin.com", // 签发人:谁发的证?(微信)
"sub": "user_123456", // 主题:这是谁?(用户的唯一ID)
"aud": "baidu_disk_client_id", // 受众:这是发给谁的?(百度网盘)
"exp": 1311281970, // 过期时间
"iat": 1311280970, // 签发时间
"name": "张三", // 用户信息(Profile)
"email": "[email protected]"
}
这两个令牌虽然经常一起出现,但职责完全不同,具体来说:
- Access Token:就像是“酒店房卡”。它的作用是授权,用来告诉资源服务器(API)我有权取数据。它是给后端(Resource Server)看的,客户端通常不需要也不应该去解析它的内容。
- ID Token:就像是“身份证”。它的作用是认证,用来告诉客户端(App)当前登录的用户是谁。它是专门给客户端看的,客户端必须解析它,才能知道用户的名字、邮箱等信息。
除了引入 ID Token,OIDC 还规范了一整套“去哪里拿什么”的标准。 在以前,你想获取用户资料,Facebook 的接口叫 /me,GitHub 的叫 /user,毫无规律可言。 OIDC 规定了一个统一的接口:UserInfo Endpoint。 当你拿到 Access Token 后,如果 ID Token 里的信息不够(比如 ID Token 里只有用户 ID,但你还想要用户的头像和电话),你可以拿着 Access Token 去这个标准接口请求。微信必须在这个接口返回标准化的 JSON 数据,开发者再也不用去猜接口地址了。
4.2 OIDC 授权码流程详解 (Authorization Code Flow)
OIDC(OpenID Connect)授权码流程是最标准、最常用的认证模式,适用于具有后端服务器的 Web 应用。 该流程基于 OAuth 2.0 的 Authorization Code Grant,在此基础上通过引入 openid 作用域,实现了在获取 Access Token 的同时获取 ID Token,从而完成用户身份认证。
+--------+ +---------------+
| |--(A) Authorization Request -->| Auth Server |
| | (scope=openid) | (微信/Google) |
| | +---------------+
| |<-(B) Authorization Code ------| |
| Client | | |
| (App) | | |
| |--(C) Token Request ---------->| |
| | (code + client_secret) | |
| | | |
| |<-(D) Token Response ----------| |
| | (Access Token + ID Token)| |
+--------+ +---------------+
|
| (E) Validate ID Token & Retrieve User Info
v
步骤一:发起认证请求 (Authorization Request)
当用户使用服务提供商的账号登录客户端(Client)时,客户端像 OAuth2 的过程一样,首先重定向用户代理(浏览器)到授权服务器的授权端点,期望获取授权码。 为了更加详细的描述这个过程,这里将HTTP请求的具体细节也简单描述一下。上述请求授权码的过程使用 HTTP 的 GET 方法 且 scope 必须包含 openid 字段。 这是触发 OIDC 流程的开关。通常还会加上 profile email 等。请求的主要内容如下:
GET /authorize?
response_type=code # 固定值,表示请求授权码
&client_id=s6BhdRkqt3 # 客户端在授权服务器注册的 Client ID
&redirect_uri=https://client.example.com/callback # 认证完成后的回调地址
&scope=openid profile email # 【核心】包含 openid 才是 OIDC
&state=af0ifjsldkj # 随机字符串,用于防止 CSRF
HTTP/1.1
Host: server.example.com步骤二:用户认证与授权 (Authentication & Authorization)
授权服务器收到客户端发起的授权码获取请求之后,首先验证用户身份,并询问用户是否授予请求的权限(如读取个人信息等),用户一般通过输入用户名密码、或者扫码授权等方式同意授予客户端权限。此步骤由授权服务器完全接管,并负责和用户交互,客户端不参与。
步骤三:返回授权码 (Authorization Grant)
如果授权服务器发现用户同意为客户端授权,它会将浏览器重定向回客户端指定的 redirect_uri,并附带用于客户端申请访问令牌的授权码 (code)。 具体来说,授权服务器会将下面的响应发送给客户端。
HTTP/1.1 302 Found
Location: https://client.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA # 授权码:短期有效,仅能使用一次
&state=af0ifjsldkj # 原样返回,用于 CSRF 校验步骤四:请求令牌 (Token Request) 客户端后端收到授权服务器返回的授权码 code 后,通过后端通道(Back-Channel)向授权服务器发送 POST 请求,从而使用授权码换取服务的访问令牌。 具体来说,他会向授权服务器发送下面的HTTP请求。
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpwYXNzd29yZA== # client_id:client_secret 的 Base64 编码
grant_type=authorization_code # 固定值
&code=SplxlOBeZQQYbYS6WxSbIA # 授权码
&redirect_uri=https://client.example.com/callback # 必须与步骤一完全一致步骤五:接收令牌 (Token Response) 授权服务器收到客户端的请求后,首先验证授权码是否是合法的,验证通过后,他会返回 OAuth2 中介绍过的访问令牌 和 刷新令牌,并且还会额外返回 OIDC 中特有的身份令牌 id_token, 这些令牌都是 JWT 格式的 JSON 数据。具体数据如下:
{
"access_token": "SlAV32hkKG", # 房卡:用于调用 UserInfo 接口或业务 API
"token_type": "Bearer",
"expires_in": 3600, # Access Token 的剩余有效期(秒)
"id_token": "eyJhbGciOi...", # 【重点】身份证:JWT格式,包含用户身份认证信息
"refresh_token": "8xLOxBtZp8" # (可选) 用于刷新 Access Token
}步骤六:验证 ID Token 与获取用户信息
-
解析与验证 ID Token 客户端拿到 id_token 后,必须在本地校验这个 JWT 的有效性,校验通过后即可直接读取 Payload 中的用户信息完成登录。
- Signature: 验证签名,确保未被篡改。
- iss (Issuer): 验证签发者是否为信任的授权服务器 URL。
- aud (Audience): 验证受众是否为自己的 client_id。
- exp (Expiry): 验证令牌是否已过期。
-
请求 UserInfo 端点 (可选) 如果 id_token 中的信息不足(例如缺少头像),客户端可使用 access_token 请求标准端点,以获取更多的用户相关信息。
GET /userinfo HTTP/1.1
Host: server.example.com
Authorization: Bearer SlAV32hkKG # 步骤5拿到的 Access Token