抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

OAuth2.0 从入门到精通,不谈论概念,直击实战演练,教你如何实现一个基于OAuth2.0的授权服务,并且了解最新的 OIDC 与 OAuth2.0 的关系。

一、前言

1. 什么是 OAuth ?

OAuth 不是一个具体的框架或服务,而是一个验证授权(Authorization)的开放标准。常见的 SpringSecurity 框架即基于此标准实现。

OAuth 通常是为了解决开放平台资源服务的认证授权而生,但随着时代发展,越来越多系统的用户登录也是用了该标准(基本是因为所用的安全框架基于这个标准)。

OAuth 主要有 OAuth 1.0a 和 OAuth 2.0 两个版本,并且二者完全不同,且不兼容,OAuth2.0 是目前广泛使用的版本,我们多数谈论 OAuth 时,为 OAuth2.0。

2. 为什么要有 OAuth ?

在之前,认证授权多为用户名和密码进行验证,而在三方登录兴起之后,例如某论坛要支持 QQ 登录,支持微信登录,我们不可能在这个论坛上输入自己的 qq 账号密码,因此 OAuth 的出现就是为了解决访问资源的安全性以及灵活性。OAuth 使得第三方应用对资源的访问更加安全。

3. 怎么实现 OAuth2.0 标准的授权服务 ?

OAuth2.0 的授权模式大概分为 授权码模式简化模式密码模式客户端模式和最新扩展 OIDC模式(open id connect)等等,每种模式的请求逻辑略有不同。

一个 OAuth2.0 服务大致会有以下端点(可理解为页面或接口)

  • a. 授权端点(Authorization Endpoint)
    • 用途:用于用户认证和授权请求的起点。
    • 示例:类似微信扫码登录后出现的页面,或某论坛支持 github 登录,点击跳转到 github 的授权用户信息给某论坛的确认页面。
    • 端点:/authorize
  • b. 令牌端点(Token Endpoint)
    • 用途:用于获取访问令牌和刷新令牌。
    • 示例:例如授权码模式下,通过 code 发起请求获取 access_token
    • 端点:/token
  • c. 用户信息端点(Userinfo Endpoint):
    • 用途:用于获取关联于访问令牌的用户信息。
    • 示例:通过 access_token 获取用户 JSON 信息。
    • 端点:/userinfo

一个 OAuth2.0 服务必定会有 client_idclient_secret,服务端会通过 client_id 去识别谁请求的登录。若我们的系统只需要自己用户去登录,甚至后端写固定值也无所谓,若我们的系统需要支持第三方应用来授权登录,则需要将每个第三方应用对应的 client_id 等信息生成并保存,需要后续去做识别认证。

二、授权码模式

最安全但也是最复杂的模式,多适用于第三方登录的场景

下文模拟场景:假设我们作为 Github 服务端,而有一个第三方论坛百度贴吧要来对接我们,实现 Github 快捷登录

1. 分配 Client 信息

我们需要在 Github 服务端给百度贴吧生成专属信息包含 client_idclient_secret,并且一般百度贴吧也要告诉我们授权成功后的重定向 url redirect_uri,避免当它 client 信息泄露被滥用登录。

而我们会将 client_idclient_secret 以及上文说到的a,b,c三个端点都告诉百度贴吧。

2. 跳转登录

百度贴吧需要在其登录页面上,展示一个 Github 登录按钮,点击后应该跳转到我们 Github 的授权端点页面,并且带上 client 信息,Github 才能知道是谁请求的登录,例如点击跳转的页面应该类似如下地址

1
2
3
4
5
https://github.com/login/oauth/authorize
?client_id=xxx
&response_type=code
&scope=profile
&redirect_uri=https://tieba.baidu.com/login

其中也就是访问 Github 的授权页面 https://github.com/login/oauth/authorize,并通过 url 传递几个参数

  • client_id:客户端 id,识别是哪个平台请求的授权,我们 Github 应该对此值判断是否有效
  • response_type:相应类型,此时固定 code,表示是授权码模式
  • scope:范围,该值决定了获取信息时返回的字段,或该令牌的权限,多个范围用空格隔开,例如 profile repo:read repo:write 等等,每个平台都可以不同,例如百度贴吧只需要用户邮箱,而有些平台就需要 github 的仓库读写权限等等,至于填写 profile 还是 repo 等等,一般依赖于不同平台的识别需求,并没有固定要求
  • redirect_uri:授权成功后重定向 url,我们 Github 也应该对此校验,否则毕竟 client_id 是明文,可能被滥用

3. 授权页面编写

我们 Github 则需要编写第一个 /login/oauth/authorize 登录页面,该页面逻辑应该先判断用户是否登录,没有则有登录逻辑,若登录后则应该校验 client_idscope 等值,校验无误后在界面显示一个授权样式,提示用户是否将信息授权给百度贴吧,当用户点击同意,我们应该跳转回 redirect_uri 并传递 code 参数,该参数后端自由生成,code 的有效期应尽可能短(如 10 分钟)保证安全,后续后端能通过 code 获取到授权的用户信息即可,例如跳转回 https://tieba.baidu.com/login?code=xxx

4. 获取令牌

百度贴吧页面读取到 code 值后,得知用户已经授权登录了,再请求 Github 令牌端点(此请求应该在百度贴吧自己的后端执行),并传递 codeclient_secret 等值去获取令牌,例如 https://github.com/login/oauth/access_token?code=xxx&grant_type=authorization_code&client_id=xx&client_secret=xx&redirect_uri=xx

其中也就是通过 POST 请求 Github 的令牌接口 https://github.com/login/oauth/access_token,并通过 url 传递几个参数(或通过 JSON body 传递,取决于令牌接口如何设计)

  • client_id:客户端 id,识别是哪个平台请求的授权,我们 Github 应该对此值判断是否有效
  • grant_type:授权类型,此时固定 authorization_code,表示请求需要提供 codeclient_id 等值
  • client_secret:客户端密钥,Github 后端应该校验此值与 client_id 是否配套,但部分系统也会允许“百度贴吧”可不传递此值,主要看是否注重安全性
  • redirect_uri:授权成功后重定向 url,我们 Github 应该对此值校验,与 /authorize 端点传递的是否一致,避免 code 被劫持

当校验通过后,我们 Github 应该返回如下 JSON 数据

1
2
3
4
5
6
7
8
9
{
"access_token": "xxxx", // 访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"scope": "repo gist", // 用户授权的范围,毕竟有的用户不会全部权限都打勾
// 此值若有多个一般通过空格隔开
// 也有平台使用逗号隔开例如github,但逗号其实不符合OAuth2.0规范
"refresh_token": "xxxx" // 刷新令牌,若我们系统支持的话,且scope=offline_access时应返回
}

5. 获取用户信息

百度贴吧获取到令牌后,再通过 token_typeaccess_token 去请求用户信息端点接口,获取授权用户的信息,例如

发送 GET 请求,Github 提供的用户信息接口 https://github.com/user,并设置请求头 Authorization: Bearer ${access_token},即可获取到用户信息,有哪些字段,以及字段名称主要根据授权服务和 scope 而定,一般会如下值及字段

1
2
3
4
5
6
7
8
9
10
11
12
{
"sub": "", // 一般表示用户id,scope=profile应返回
"name": "", // 名称,姓名全称或用户名等等,scope=profile应返回
"nickname": "", // 昵称,scope=profile应返回
"picture": "", // 头像url地址,scope=profile应返回
"gender": "", // 性别,scope=profile应返回
"address": "", // 地址,scope=address应返回
"email": "", // 邮箱,scope=email应返回
"email_verified": true, // 邮箱是否验证,scope=email应返回
"phone_number": "", // 手机号,scope=phone应返回
"phone_number_verified": true, // 手机号是否验证,scope=phone应返回
}

6.总结

至此已经完成授权码模式全流程,期间我方平台需要做的便是

  • 生成 client 信息
  • 提供授权页面并编写相关逻辑(此处还需要包含一个内部使用接口,去生成 code 之类)
  • 提供获取令牌接口,并作相关校验
  • 提供获取用户信息接口

三、简化模式

基于授权码模式的简化版,在 3.授权页面 此步骤,原本 Github 应该重定向并传递 code,而百度贴吧再通过 code 请求令牌接口获取 access_token,而简化模式则是百度贴吧跳转 Github 授权页面时,修改 response_type=coderesponse_type=token,表示简化模式,此时 Github 授权成功后重定向回百度贴吧时就不传递 code,而是直接传递 access_token 例如 https://tieba.baidu.com/login?access_token=xxx,省去了 code 通过令牌接口换取 access_token 的步骤。

后续获取用户信息接口就和授权码模式一样了,带上请求头 Authorization: Bearer ${access_token} 即可。

四、密码模式

密码模式一般用于自己平台登录,毕竟如果用于第三方登录,用户需要把自己 github 的密码告诉百度贴吧明显十分危险

密码接口只需要调用令牌端点即可,例如

通过 POST 请求 https://github.com/login/oauth/access_token,并传入参数

1
2
3
4
5
6
7
{
"client_id": "", // 客户端id,身份认证用
"client_secret": "", // 客户端secret,身份认证用
"grant_type": "password", // 固定password,表示密码模式
"username": "", // 用户名
"password": "" // 密码
}

返回

1
2
3
4
5
6
{
"access_token": "xxxx", // 访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"refresh_token": "xxxx" // 刷新令牌,若我们系统支持的话
}

后续获取用户信息接口就和授权码模式一样了,带上请求头 Authorization: Bearer ${access_token} 即可。

五、客户端模式

客户端模式一般用于公共接口的授权,表示我 Github 知道你是哪个平台请求的,但不需要知道你是哪个用户,例如某些字典接口,不能对外暴露,但是又必须让例如百度贴吧它先在我平台注册才能访问这个接口

客户端模式也只需要请求令牌端点即可

通过 POST 请求 https://github.com/login/oauth/access_token,并传入参数

1
2
3
4
5
{
"client_id": "", // 客户端id,身份认证用
"client_secret": "", // 客户端secret,身份认证用
"grant_type": "client_credentials" // 固定client_credentials,表示客户端模式
}

返回

1
2
3
4
5
6
{
"access_token": "xxxx", // 访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"refresh_token": "xxxx" // 刷新令牌,若我们系统支持的话
}

后续调用资源服务提供的公共接口,和获取用户信息接口一样,带上请求头 Authorization: Bearer ${access_token} 即可。

六、刷新令牌

刷新令牌只需要调用令牌端点即可,例如

通过 POST 请求 https://github.com/login/oauth/access_token,并传入参数

1
2
3
4
5
6
{
"client_id": "", // 客户端id,身份认证用
"client_secret": "", // 客户端secret,身份认证用
"grant_type": "refresh_token", // 固定refresh_token,表示刷新令牌
"refresh_token": "" // 旧的刷新令牌
}

返回

1
2
3
4
5
6
{
"access_token": "xxxx", // 新的访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"refresh_token": "xxxx" // 新的刷新令牌,若我们系统支持的话
}

七、OIDC 模式

OIDC 全称为 OpenID Connect,是对于 OAuth2.0 的一个扩展,毕竟在标准 OAuth2.0 模式下,要获取到用户信息需要经过多个端点请求,而某些平台的第三方登录,只需要获取这个用户在第三方的唯一标识,以及昵称头像之类即可,所以便出现了 OIDC 模式。

1. 最常见的 OIDC 用法

在授权码模式中的第一步骤,跳转往授权端点时,传递参数scope增加openid,例如 scope=email openid,多个用空格隔开。

在令牌端点时,Github服务端检测到scope含有openid,则多返回一个 id_token,例如

1
2
3
4
5
6
7
{
"access_token": "xxxx", // 新的访问令牌
"token_type": "Bearer", // 令牌类型,通常为 "Bearer"
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位)
"refresh_token": "xxxx", // 新的刷新令牌,若我们系统支持的话
"id_token":"xxx" // 该值应该是个jwt格式字符串,scope包含openid时应返回
}

此时百度贴吧直接解析 id_token 中的 jwt 值即可获取到用户的一些基本信息,信息字段与用户信息端点基本应保持一致,也就是

1
2
3
4
5
6
7
8
9
10
11
12
{
"sub": "", // 一般表示用户id
"name": "", // 名称,姓名全称或用户名等等
"nickname": "", // 昵称
"picture": "", // 头像url地址
"address": "", // 地址
"gender": "", // 性别
"email": "", // 邮箱
"email_verified": true, // 邮箱是否验证
"phone_number": "", // 手机号
"phone_number_verified": true // 手机号是否验证
}

通过这种模式,既完全兼容原有的 oauth2.0标准,又能扩展出 id_token值,百度贴吧不需要再通过 access_token 去请求用户信息端点,直接解析jwt即可知道用户的信息。

2. OIDC 简化模式

有人说这种模式仍然还是需要请求两次,那么有没有更简单一点的方法能直接获取到用户信息呢?当然有,回到第一步骤,百度贴吧跳往 Github 授权页,当 response_type=code 改为 response_type=id_token 即为 OIDC 模式(需要注意,在OAuth2.0规范中,response_type并不能传递id_token值,这是OIDC引申出的)。

1
2
3
4
5
https://github.com/login/oauth/authorize
?client_id=xxx
&response_type=id_token
&scope=profile
&redirect_uri=https://tieba.baidu.com/login

当 Github 授权成功后,重定向回百度贴吧时,附带的值就是 jwt 格式的 id_token,例如 https://tieba.baidu.com/login?id_token=xxx,这种模式不需要再请求令牌端点,也不需要请求用户信息端点,大大简化了流程。

这种模式十分适合授权登录场景,不过若百度贴吧后续还需要代表用户请求 Github 接口,例如查询用户所有仓库等等,这种简化的 OIDC 模式明显无法实现,毕竟没有拿到 access_token ,因此又延申出了混合模式。

八、混合模式

1. OIDC 模式 + 简化模式

混合模式也很简单,实际就是百度贴吧跳往 Github 授权页,当 response_type=id_token 改为 response_type=token id_token 即为 OIDC 模式+简化模式结合,表示 Github 授权成功后,重定向回百度贴吧时,应该带上 OIDC 模式和简化模式应该传递的值,例如

1
https://tieba.baidu.com/login?id_token=xxx&access_token=xxx

2. OIDC 模式 + 授权码模式

授权端点传递 response_type=id_token code 就表示 OIDC 模式+授权码模式结合,重定向时应附带

1
https://tieba.baidu.com/login?id_token=xxx&code=xxx

3. 其他任意组合模式

当然只要 response_type 可以组合的都行,但其实没有实际意义了。

九、额外安全措施防护

百度贴吧通过额外传递两个参数 statenonce 进行额外安全措施防护,这两个值不是必须传递,且也没要求一定得同时使用,例如

1
2
3
4
5
6
7
https://github.com/login/oauth/authorize
?client_id=xxx
&response_type=code
&scope=profile
&redirect_uri=https://tieba.baidu.com/login
&state=7t3ze8muocp
&nonce=bknslkj1tps

1. state

state 应该是一个随机不重复字符串,Github 授权成功后,会原值返回给百度贴吧,百度贴吧应对此值进行逻辑判断,防止恶意第三方攻击者通过操纵或篡改授权请求和响应来进行 CSRF 攻击,提高安全性

1
https://tieba.baidu.com/login?code=xxx&state=7t3ze8muocp

2. nonce

nonce 应该是一个随机不重复字符串,在 OIDC 模式下,Github 在进行授权时,应该判断 nonce 值是否已经被授权过,可以防止 ID 令牌(id_token)的重放攻击。在授权成功后,Github 应该将 nonce 值存放于 id_token 的 jwt 中,而非在重定向链接中显式传递,例如

1
https://tieba.baidu.com/login?id_token=xxx

而百度贴吧解析 id_token 中的信息,以及 nonce 值后,应该判断 nonce 值与自己发送的是否一致,防止 id_token 被串改。

1
2
3
4
5
{
"sub": "", // 一般表示用户id
"name": "", // 名称
"nonce": "bknslkj1tps" // 原值返回
}

而在授权码模式中,nonce 值应该在令牌端点时返回,例如

1
2
3
4
{
"access_token": "",
"nonce": "bknslkj1tps" // 原值返回
}

十、总结

总结一下,OAuth2.0 的核心基本围绕以下这三个端点

1. 授权端点(是个页面)

传参

1
2
3
4
5
6
7
https://example.com/login/oauth/authorize
?client_id=xxx
&response_type=code
&scope=email
&redirect_uri=https://example.com/callback
&state=7t3ze8muocp
&nonce=bknslkj1tps

成功后重定向传参

1
2
https://example.com/callback
?code=xxx

其中各种模式的核心便是 response_type传递的值,且多个值时用空格隔开,其决定了在重定向回回调地址时会传递什么值,例如

  • 授权码模式:code->code
  • 简化模式:token->access_token
  • OIDC简化模式:id_token->id_token

2. 令牌端点(是个接口)

通过 POST 请求接口 /oauth/token
传参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"client_id": "", // 必填,客户端id,身份认证用
"client_secret": "", // 必填,客户端secret,身份认证用

// 1.授权码模式
"grant_type": "authorization_code", // 必填,此时authorization_code表示授权码模式
"code": "", // 授权端点重定向传递的code值
// 2.密码模式
"grant_type": "password", // 必填,此时password表示密码模式
"username": "", // 用户名,密码模式需要
"password":"", // 密码,密码模式需要
// 3.客户端模式
"grant_type": "client_credentials", // 必填,此时client_credentials表示客户端模式
// 4.刷新令牌操作
"grant_type": "refresh_token", // 必填,此时refresh_token表示刷新令牌
"refresh_token": "", // 刷新令牌
}

返回

1
2
3
4
5
6
7
8
{
"access_token": "xxxx", // 访问令牌,必填
"token_type": "Bearer", // 令牌类型,通常为 "Bearer",必填
"expires_in": 3600, // 访问令牌的过期时间(以秒为单位),必填
"scope":"profile email", // 授权范围
"refresh_token": "xxxx", // 刷新令牌,若我们系统支持的话,scope包含offline_access应返回
"id_token":"xxx" // 该值应该是个jwt格式字符串,scope包含openid时应返回
}

其中核心便是 grant_type 传入的值,根据不同值,服务端需要取不同参数做不同的逻辑处理校验,而返回时除了必填的几个参数,其他则基本会根据scope返回不同值。

3.用户信息端点(是个接口)

通过 GET 请求接口 /userinfo,附带请求头 Authorization: Bearer ${access_token},且无需传参

回参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"sub": "", // 一般表示用户id
// scope=profile应返回
"name": "", // 名称,姓名全称或用户名等
"nickname": "", // 昵称
"picture": "", // 头像url地址
"gender": "", // 性别
"birthdate":"", // 用户的出生日期
// scope=address应返回
"address": "", // 地址
// scope=email应返回
"email": "", // 邮箱
"email_verified": true, // 邮箱是否验证
// scope=phone应返回
"phone_number": "", // 手机号
"phone_number_verified": true, // 手机号是否验证
}

十一、最后

最后再说几句,OAuth2.0 并非指哪个安全框架,而是一个授权标准,OIDC 也不是一个新的安全框架,而是基于 OAuth2.0 的扩展而已。在实际项目中我们应该去理解 OAuth2.0 的各种模式认证流程,根据我们的业务场景去选择使用哪种授权模式,这样才能提升对于系统安全方面的认知。而在普通单体项目或简单的外包项目中,其实简单的 token + 过滤器 就已经能对系统的安全提供保护了,不一定要强行使用各种大而全但相对麻烦的安全框架。

评论