OAUTH2

基本角色定义

  1. 授权服务器 - 验证身份并颁发令牌的服务器(我上面代码示例中的 OAuth 服务器)
  2. 资源服务器 - 提供 API 资源的服务器
  3. 客户端应用 - 访问 API 的应用,可以是:
    • 第一方应用 - 您公司自己的应用
    • 第二方应用 - 业务合作伙伴的应用
    • 第三方应用 - 外部开发者的应用

token 类型

  • access_token (默认有效期 24 小时)
  • refresh_token(默认有效期 30 自然日,每次刷新 access_token 的操作可自动刷新 refresh_token 有效期的起始计算时间)。

客户端凭证流程(适用于服务器到服务器通信)

1
2
3
4
5
6
7
8
9
10
第一方/第二方应用                    授权服务器                  资源服务器(API)
| | |
|---1. 请求访问令牌------------>| |
| (使用client_id和client_secret)| |
| | |
|<--2. 返回access_token---------| |
| | |
|---3. 使用access_token调用API----------------------------->|
| | |
|<--4. 返回API响应------------------------------------------|

这种流程中:

  • 获取令牌方: 第一方或第二方应用直接获取令牌
  • 使用令牌方: 同样的应用使用令牌调用API
  • 无用户参与: 整个过程无需最终用户授权
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
客户端                                认证服务器
| |
|------- POST /token ---------------->|
| grant_type=client_credentials |
| client_id=CLIENT_ID |
| client_secret=CLIENT_SECRET |
| |
|<------ 200 OK JSON 响应 ------------|
| { |
| "access_token": "eyJhbGc...", |
| "token_type": "Bearer", |
| "expires_in": 3600 |
| } |
| |
|------- 使用令牌访问API ------------>|
| Authorization: Bearer eyJhbGc... |
| |

授权码流程(适用于有用户参与的场景)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户          第一方/第二方/第三方应用          授权服务器          资源服务器(API)
| | | |
|----1. 访问应用---->| | |
| | | |
|<---2. 重定向到-----| | |
| 授权服务器 | | |
| | | |
|----3. 登录并授权------------------------------>| |
| | | |
|<---4. 重定向回应用,携带授权码(code)-----------| |
| | | |
|----5. 返回应用,-->| | |
| 带上授权码 | | |
| | | |
| |---6. 使用code请求令牌---->| |
| | (附带client_id/secret) | |
| | | |
| |<--7. 返回access_token-----| |
| | 和refresh_token | |
| | | |
| |---8. 使用access_token调用API------------------>|
| | | |
| |<--9. 返回API响应-------------------------------|
| | | |

这里面有两次重定向:

第一次:重定向到授权服务器。用户是带着一个 param 去访问 authserver 的,在我们常见的场景里线索通跳到 sso 是第一次重定向,参数里带有 https://app.example.com/callback 就是授权完以后再回到线索通的一个 url。

  • 当用户尝试访问需要授权的功能时,应用生成一个授权请求 URL
  • 这个 URL 包含 redirect_uri 参数(应用事先在授权服务器注册的 URL)
  • 应用通过 HTTP 302重定向或者前端跳转,将用户浏览器导向授权服务器
1
2
3
4
5
6
GET https://auth-server.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=https://app.example.com/callback&
scope=profile email&
state=random_state_value

第二次:重定向回应用,携带授权码(code)。

  • 用户在授权服务器完成身份验证并授予权限后
  • 授权服务器使用之前请求中的redirect_uri,将用户浏览器重定向回应用
  • 重定向 URL 中附加授权码(code)作为查询参数
1
2
3
4
HTTP/1.1 302 Found
Location: https://app.example.com/callback?
code=AUTHORIZATION_CODE_VALUE&
state=random_state_value
  • 授权服务器只会重定向到预先注册的 URI,防止授权码被重定向到恶意网站
  • 防止授权码被重定向到恶意网站
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
用户/浏览器         客户端应用            认证服务器              资源服务器
| | | |
| | | |
| |-- 重定向用户认证 ->| |
| | /authorize? | |
| | client_id=...& | |
| | redirect_uri=...& | |
| | response_type=code| |
| | | |
|<-----------------|<-------------------| |
| | | |
|-- 登录界面交互 ->| | |
| | | |
| |<-- 重定向回客户端 -| |
|<-- 重定向回 -----| /callback?code=.. | |
| 客户端应用 | | |
| | | |
| |-- POST /token ---->| |
| | grant_type= | |
| | authorization_code| |
| | &code=... | |
| | &redirect_uri=... | |
| | &client_id=... | |
| | &client_secret=...| |
| | | |
| |<-- 返回令牌 -------| |
| | access_token, | |
| | refresh_token等 | |
| | | |
| |-------------------- 使用令牌访问资源 ----->|
| | Authorization: | |
| | Bearer {token} | |
| | | |
| |<------------------- 返回受保护资源数据 ----|
| | | |

刷新令牌流程 (Refresh Token Flow)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
客户端                                认证服务器
| |
|------- POST /token ---------------->|
| grant_type=refresh_token |
| refresh_token=REFRESH_TOKEN |
| client_id=CLIENT_ID |
| client_secret=CLIENT_SECRET |
| |
|<------ 200 OK JSON 响应 ------------|
| { |
| "access_token": "NEW_TOKEN", |
| "token_type": "Bearer", |
| "expires_in": 3600, |
| "refresh_token": "NEW_REFRESH" |
| } |
| |

SSO

精确的SSO登录流程 (结合联邦认证)

这个流程描述了当用户首次访问系统,且SSO认证中心本身也需要借助第三方(如QQ)来完成认证的完整过程。

核心参与者

  • 用户 (浏览器): 用户的设备。
  • 系统A (应用/Service Provider): 用户想要访问的业务系统,例如 app-a.com。
  • SSO服务器 (认证中心/Identity Provider): 统一的身份认证服务,例如 sso.com。
  • QQ (外部认证方/External IdP): 提供身份信息的第三方,例如 qq.com。

流程步骤

  1. 访问应用: 用户在浏览器中访问系统A (app-a.com) 的受保护页面。系统A检查发现浏览器请求中没有自己的登录凭证(app_a_cookie),判定用户未登录。

  2. 重定向至SSO: 系统A将用户的浏览器重定向到SSO服务器的登录地址,并在URL中附带自己的身份和期望的回调地址。

    • sso.com/login?service=https://app-a.com/callback
  3. SSO检查并再次重定向: SSO服务器检查发现浏览器请求中也没有SSO的全局登录凭证(sso_cookie),判定用户在认证中心也未登录。此时,SSO服务器呈现一个登录页面,上面有多种登录选项(如:用户名密码、使用QQ登录)。

  4. 用户选择QQ登录: 用户点击“使用QQ登录”按钮。SSO服务器将浏览器重定向到QQ的授权页面,并附带自己的客户端ID和预设的回调地址(指向SSO自己)。

    • qq.com/auth?client_id=sso_client_id&redirect_uri=https://sso.com/auth/qq/callback
  5. QQ认证与授权: 用户在QQ的页面上输入账号密码,并同意授权SSO服务器获取其基本信息。

  6. QQ回调SSO: QQ认证成功后,将浏览器重定向回第4步中指定的SSO回调地址,并在URL中附带一个一次性的authorization_code

    • sso.com/auth/qq/callback?code=qq_auth_code_123
  7. SSO后台换取用户信息: SSO服务器的后端收到authorization_code后,向QQ的API服务器发起一个安全的后台HTTP请求,用code换取access_token,再用access_token获取用户的唯一标识(openid)等信息。

  8. SSO建立全局会话: SSO服务器确认了用户的身份,此时:

    • 在SSO域 (sso.com) 下为浏览器种下全局登录凭证(sso_cookie)。
    • 这标志着用户已经在单点登录系统中完成了认证。
  9. SSO携带Ticket重定向回系统A:SSO服务器现在将浏览器重定向回系统A在第2步中提供的回调地址,并在URL中附带一个一次性票据 (Ticket)。在现实中还会有多余的一步,首先返回一个 uid列表(如果一个qq下有多个 uid的话),让用户选择 uid 以后,再根据 uid 返回一个独一无二的 ticketA(只绑定 uid)。

    • app-a.com/callback?ticket=ST-xyz-789
  10. 系统A后台验证Ticket: 系统A的后端收到ticket后,向SSO服务器的验证接口发起一个安全的后台HTTP请求来核销这个ticket

  11. SSO验证并返回用户信息: SSO服务器验证ticket有效(且是发给系统A的),然后立即销毁该ticket防止重放攻击。验证成功后,向系统A返回该用户的身份信息(如统一的uidrole等)。

  12. 系统A建立本地会话: 系统A收到了确认信息,信任了该用户。它会在自己的域 (app-a.com) 下为浏览器种下应用自身的登录凭证(app_a_cookie),并最终将用户带到他最初想访问的页面。

至此,登录流程完成。如果用户再去访问已接入SSO的系统B,流程会大大简化:他会被重定向到SSO(步骤2),SSO检测到sso_cookie已存在(步骤8),会直接跳过登录和QQ认证,立即执行步骤9,发放一个新票据给系统B,实现无感知的“单点登录”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
用户/浏览器         系统A              SSO服务器           QQ认证服务器         系统A资源服务
| | | | |
|-- 访问系统A -->| | | |
| | | | |
| |-- 重定向到SSO ---->| | |
| | /login?service= | | |
| | app-a.com/callback| | |
| | | | |
| | |-- 检查sso_cookie -->| |
| | | (未登录) | |
| | | | |
|<---------------|<------------------ 展示登录选项页面 ----| |
| | | | |
|-- 选择QQ登录 --|------------------>| | |
| | | | |
| | |-- 重定向到QQ认证 ->| |
| | | /authorize? | |
| | | client_id=sso_id& | |
| | | redirect_uri= | |
| | | sso.com/callback | |
| | | | |
|<---------------|<------------------|<-------------------| |
| | | | |
|-- QQ登录认证 --|-------------------|------------------>| |
| (用户名密码) | | | |
| | | | |
| | |<-- 授权码 code ----| |
| | | | |
| | |-- 后台交换token -->| |
| | | | |
| | |<-- 返回用户信息 ---| |
| | | | |
| | |-- 创建全局会话 --->| |
| | | (设置sso_cookie) | |
| | | | |
|<---------------|<-- 重定向回系统A --| | |
| | 携带ticket | | |
| | | | |
| |-- 验证ticket ----->| | |
| | | | |
| |<-- 返回用户信息 ---| | |
| | | | |
|<-- 设置本地 ---| | | |
| 会话cookie | | | |

在这个流程里,每个系统首先校验本域下的 cookie,每个服务都一开始只给一个一次性的凭证-一般在url 的query param里附带,直接回调回主浏览器,由浏览器主动重定向回调回上一个系统的回调地址(由sso 到外部系统。),然后外部系统去找发放一次性凭证的服务(先是QQ,然后是SSO)获取凭证代表的具体信息(如用户的 OpenId),然后在本地建立一个会话本域种下 cookie 凭证,回调回上一个系统,上一个系统携带回调 url 里的 query param 里的凭证,访问这个系统的会话,获取信息。

如果有层级的多层调用,回调url也要逐层嵌套和解套。

每一个本域的 cookie 是为了下次访问本系统的时候,不再问上游系统,而直接发放一次性凭证用的。回调地址也应该准备为可以从任意地址重定向到 SSO,从 SSO 又重定向回当前页面。

  1. 回调地址如果有可能,尽量从 Referer 头获取。
  2. 如果有可能,回调地址里面一定要去掉 sso_ticket 这种sso 回调才会添加的附加参数
  3. ticket 和 token 最好都和账号、回调 uri 绑定。
  4. 要注意 ticket、uri、token 乱串的情况。如果发生各种绑定出错的情况,尽量使用:
    • 已存在的 token 解析出一个登录账号,然后重定向到 sso 服务,因为 sso 域下的 cookie 已存在,又会重定向回这个登录账号在当前账号下的页面。即/9/account已经登录了,浏览器输入了一个/10/account,则需要靠重定向到 sso,再重定向回来/9/account。如果给 sso 的重定向uri是/10/account则可能产生无限循环。正确的解法:
    • 尝试给出一个和 sso_token 匹配的回调地址再登录。有些 sso 系统不支持。因为 cookie 可能账户绑定关系是一对多的,没人知道具体原来使用了哪个账户(可能一个 token绑定了7、8、9)。
    • 直接清除 sso_token 的 cookie。
  5. 注意cookie的几个属性:
    • domain:如果不设置就是默认当前的域(包括子域):d2ksr8gxr5kl2o(子域名).cloudfront(主域名).net(顶级域名)
    • path:如果不设置则为当前路径的父,所以最好设置为/
    • MaxAge/Expires:旧版本协议用 Expires,新版本用MaxAge
    • HttpOnly:当设置为true时,表示该Cookie只能通过HTTP/HTTPS协议访问。这种设置可以防止JavaScript通过document.cookie访问该Cookie,从而降低XSS(跨站脚本)攻击的风险。这是Web安全的最佳实践之一,特别适用于存储敏感信息(如认证令牌)的Cookie

SSO登出流程 (Logout)

SSO的核心理念是“一次登录,处处通行”。相应地,登出的核心理念应该是“一次登出,处处失效”,这被称为单点登出 (Single Logout, SLO)

如果只清除了当前应用(系统A)的登录状态,而SSO认证中心的全局会话依然存在,那么用户刷新页面后,系统A会再次将他重定向到SSO,SSO发现全局会话还在,会立刻又把他自动登录回系统A。这就导致了用户无法真正登出的尴尬局面。

因此,正确的登出流程必须由SSO认证中心来协调。也因此,中间服务不要缓存 sso_token,因为不一定能感知到这个 sso_token 被他处 invalidate 了。

流程步骤:

  1. 用户发起登出: 用户在系统A的界面上点击“登出”按钮。

  2. 系统A清理本地会话: 系统A的后端接收到登出请求后,首先执行本地登出操作。这包括:

    • 销毁系统A自身的会话 (Session)。
    • 清除种在app-a.com域下的本地登录凭证(app_a_cookie)-这一步需要重新使用 Set-Cookie header 设置一个过期的 cookie
  3. 重定向至SSO进行全局登出: 清理完本地会话后,系统A必须将用户的浏览器重定向到SSO服务器的登出地址。通常还会附带一个参数,告诉SSO登出后应该跳转回哪里。注意这个参数是靠重放 cookie 而不是携带参数来实现的。

    • sso.com/logout?post_logout_redirect_uri=https://app-a.com/login
  4. SSO执行全局登出: SSO服务器接收到请求后,执行全局登出操作:

    • 销毁SSO的中央认证会话。
    • 清除种在sso.com域下的全局登录凭证(sso_cookie)。
  5. SSO通知所有相关应用登出 (关键步骤): 这是实现单点登出 (SLO) 的核心。SSO服务器会查找在此次全局会话期间,该用户都登录了哪些应用(例如系统A、系统B、系统C)。然后,SSO服务器会从后端向所有这些应用的预设登出接口发送登出通知 (Logout Notification)。

    • 这个通知通常是一个包含了logout_token的HTTP POST请求,应用收到后需要验证这个token的合法性。
  6. 其他应用清理本地会话: 系统B、系统C等其他应用收到来自SSO的登出通知后,执行与步骤2相同的本地登出操作,清理各自的会话和Cookie。

  7. 最终跳转: 在SSO服务器完成所有登出操作后,它会将用户的浏览器重定向到第3步中指定的post_logout_redirect_uri地址,通常是应用的登录页面。用户此时会看到系统A的登录页,表示已成功登出。

至此,用户在整个SSO体系中的所有会话均已失效,实现了彻底的“单点登出”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户/浏览器         系统A              SSO服务器           QQ认证服务器         系统A资源服务
| | | | |
|-- 请求登出 --->| | | |
| | | | |
| |-- 重定向到SSO ---->| | |
| | /logout | | |
| | | | |
| | |-- 清除全局会话 --->| |
| | | | |
| | |-- 通知QQ登出 ---->| |
| | | | |
| |<-- 清除本地会话 ---| | |
| | | | |
|<-- 重定向到 ---| | | |
| 登录页面 | | | |