OAuth 2.0 协议详解:用 Go 从零实现四种授权流程

你一定用过”微信登录”或”Google 登录”。点一下按钮,第三方应用就拿到了你的头像和昵称——但你从来没有把微信密码告诉过它。这是怎么做到的?

答案就是 OAuth 2.0。它不是什么新东西,2012 年就发布了 RFC 6749,但直到今天仍然是互联网授权的事实标准。

OAuth 2.0 到底解决什么问题

先说最朴素的场景:你在 A 网站存了一堆照片,现在想在 B 网站做个相册打印服务。B 网站需要读取你在 A 网站的照片。

最原始的办法:把 A 网站的账号密码给 B 网站。B 网站拿着你的密码登录 A 网站,把照片拉过来。

这办法能用,但问题一大堆:

  • B 网站拿到了你的完整密码,它能在 A 网站干任何事——改密码、删照片、发消息
  • 你改了 A 网站密码,B 网站就没法用了
  • 你没法只授权”读照片”这个权限,B 网站拿到了一切
  • 密码在传输和存储过程中可能泄露

OAuth 2.0 的核心思路是:不分享密码,只发令牌。用户在授权服务器上登录并同意授权,授权服务器给第三方应用发一个有限权限的令牌(access_token),第三方应用拿着令牌去访问资源。令牌有有效期、有权限范围、可以随时撤销。

四个角色和几把”钥匙”

OAuth 2.0 定义了四个核心角色:

graph LR
  RO[资源拥有者<br/>Resource Owner] -->|1. 授权| AS[授权服务<br/>Authorization Server]
  AS -->|2. 颁发令牌| C[第三方软件<br/>Client]
  C -->|3. 携带令牌访问| RS[受保护资源服务<br/>Resource Server]
  RS -->|4. 返回受保护数据| C
  • 资源拥有者(Resource Owner):就是你,照片的主人
  • 第三方软件(Client):B 网站,想读你照片的那个
  • 授权服务(Authorization Server):负责验证你的身份、颁发令牌
  • 受保护资源服务(Resource Server):存照片的地方,只认令牌不认人

在这个过程中,有几样东西在角色之间传递:

名称 作用 类比
授权码(code) 临时凭证,用来换令牌 银行取号条
访问令牌(access_token) 访问资源的凭证,有有效期 酒店房卡
刷新令牌(refresh_token) 用来换新的 access_token 酒店续住凭证
权限范围(scope) 限定令牌能做什么 房卡只能开某一层
回调地址(redirect_uri) 授权后跳回第三方应用的地址 快递收货地址

本文用到的示例项目(OAuth2-Tutorial)是一个 Go 实现的 OAuth 2.0 教学演示,结构很简单:

  • client(:8080)模拟第三方软件
  • server(:8081)模拟授权服务 + 受保护资源服务

两者用 Go 标准库 net/http 通信,没有引入任何框架。

授权码许可:最核心、最安全的流程

如果你只记一种流程,就记这个。授权码许可(Authorization Code)是 OAuth 2.0 四种流程里安全性最高、使用最广泛的,目前几乎所有主流平台的”XX 登录”都基于它。

整个流程的核心思想是:把”用户授权”和”颁发令牌”拆成两步。第一步通过浏览器(前端通道,即用户可见的浏览器跳转)拿到授权码,第二步通过服务器(后端通道,即服务端到服务端的直接 HTTP 请求)用授权码换令牌。这样令牌永远不会经过浏览器,不会被截获。

sequenceDiagram
    participant U as 用户浏览器
    participant C as 第三方软件(:8080)
    participant AS as 授权服务(:8081)
    participant RS as 受保护资源(:8081)

    U->>C: 1. 访问第三方软件首页
    C->>AS: 2. 重定向到授权服务<br/>(response_type=code, app_id, scope, redirect_uri)
    AS->>U: 3. 展示授权页面
    U->>AS: 4. 用户点击同意授权
    AS->>C: 5. 重定向回第三方软件<br/>(携带授权码 code)
    C->>AS: 6. 后端请求:用 code 换 access_token<br/>(grant_type=authorization_code, app_id, app_secret)
    AS->>C: 7. 返回 access_token + refresh_token
    C->>RS: 8. 携带 access_token 请求受保护资源
    RS->>C: 9. 返回受保护数据

第一步:第三方软件发起授权请求

用户在浏览器访问第三方软件首页 http://localhost:8080/,第三方软件构造一个授权请求 URL,然后通过 302 重定向把浏览器引导到授权服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const OauthUrl = "http://localhost:8081/OauthServlet?reqType=oauth"
const RedirectUrl = "http://localhost:8080/AppServlet"

func AppIndexHandle(writer http.ResponseWriter, request *http.Request) {
slog.Info("app index ...")
oauthUrl, _ := url.Parse(OauthUrl)
params := oauthUrl.Query()
params.Add("response_type", "code")
params.Add("redirect_uri", RedirectUrl)
params.Add("app_id", "APPID_RABBIT")
params.Add("scope", "today history")
oauthUrl.RawQuery = params.Encode() // 构造请求授权的URl
writer.Header().Set("Location", oauthUrl.String()) // 授权码流程的【第一次】重定向
writer.WriteHeader(http.StatusFound)
}

这里拼了四个参数:

  • response_type=code:告诉授权服务”我要走授权码流程”
  • redirect_uri:授权完成后跳回的地址,必须和注册时一致
  • app_id:第三方软件的唯一标识,授权服务靠它判断”你是谁”
  • scope:请求的权限范围,不能超出注册时声明的范围

注意,这一步不带 app_secret。因为请求是通过浏览器重定向发出的,URL 会被用户看到,app_secret 不能暴露在前端。

第二步:授权服务校验请求

授权服务收到 GET 请求后,要校验三样东西:app_id 是否注册过、redirect_uri 是否匹配、scope 是否在注册范围内:

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
func doGet(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
responseType := query.Get("response_type")
redirectUri := query.Get("redirect_uri")
appId := query.Get("app_id")
scope := query.Get("scope")

switch responseType {
case "code":
if appMap["app_id"] != appId {
return
}
if appMap["redirect_uri"] != redirectUri {
return
}
if !checkScope(scope) {
return
}

reqid := strconv.FormatInt(time.Now().UnixMilli(), 10)
reqidMap[reqid] = reqid

request.Header.Set("reqid", reqid)
request.Header.Set("response_type", responseType)
request.Header.Set("redirect_uri", redirectUri)
request.Header.Set("app_id", appId)

// 跳转到授权页面
u, _ := url.Parse("http://localhost:8081/approve.html")
proxy := httputil.ReverseProxy{
Director: func(request *http.Request) {
request.URL = u
},
}
proxy.ServeHTTP(writer, request)
}
}

注册信息存在一个 map 里(实际项目应该是数据库):

1
2
3
4
5
6
func init() {
appMap["app_id"] = "APPID_RABBIT"
appMap["app_secret"] = "APPSECRET_RABBIT"
appMap["redirect_uri"] = "http://localhost:8080/AppServlet"
appMap["scope"] = "today history"
}

校验通过后,授权服务生成一个 reqid(这是项目自身的实现细节,用于标识本次授权请求,不是 OAuth 协议标准概念),然后把页面代理到授权确认页 approve.html。这里用反向代理(ReverseProxy)而非普通重定向来渲染授权页,纯粹是 Go 实现上的选择——把请求内部转发到模板渲染处理器,避免多一次浏览器跳转。

第三步:用户授权同意

用户在浏览器看到授权确认页面,上面列出了第三方软件申请的权限范围,用户选择同意或拒绝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<form action="/OauthServlet" method="post">
<input type="hidden" name="reqid" value="{{.Regid}}" />
<input type="hidden" name="response_type" value="{{.ResponseType}}" />
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}" />
<input type="hidden" name="app_id" value="{{.AppId}}" />
<input type="hidden" name="reqType" value="approve" />

Are you sure you want the authorization code?
<br>
appid: {{.AppId}}
<br>
<input type="checkbox" value="today" name="rscope" checked/>today<br>
<input type="checkbox" value="history" name="rscope"/>history<br>
<br>
<input type="submit" value="approve"/> <input type="submit" value="refuse"/>
</form>

用户勾选权限后点击 approve,表单 POST 到授权服务。授权服务收到后,生成授权码并重定向回第三方软件:

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
if reqType == "approve" {
reqid := request.PostFormValue("reqid")

if _, ok := reqidMap[reqid]; !ok {
return
}

if responseType == "code" {
rscope := request.Form["rscope"]
if !checkRScope(rscope) {
slog.Info("out of scope ...")
return
}

code := generateCode(appId, "USERTEST")

codeScopeMap[code] = rscope //授权范围与授权码做绑定

oauthUrl, _ := url.Parse(redirectUri)
params := oauthUrl.Query()
params.Add("code", code)
oauthUrl.RawQuery = params.Encode()
writer.Header().Set("Location", oauthUrl.String())
writer.WriteHeader(http.StatusFound)
}
}

这里有两层 scope 校验:用户提交的权限范围不能超出第三方软件注册时声明的范围。generateCode 生成一个 8 位随机数字作为授权码:

1
2
3
4
5
6
7
8
9
10
11
func generateCode(appId string, user string) string {
var sb strings.Builder
for range 8 {
n, _ := rand.Int(rand.Reader, big.NewInt(8))
sb.WriteString(strconv.FormatInt(n.Int64(), 10))
}

code := sb.String()
codeMap[code] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10)
return code
}

授权码用 crypto/rand 生成(安全随机数),存储时绑定了 app_id、用户 ID 和时间戳。RFC 建议授权码有效期不超过 10 分钟,且一次性使用

第四步:用授权码换取访问令牌

浏览器被重定向回 http://localhost:8080/AppServlet?code=xxxx,第三方软件的后端拿到授权码后,向授权服务发起 POST 请求,用 code 换取 access_token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func AppServletHandle(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
code := query.Get("code")

params := url.Values{}
params.Add("code", code)
params.Add("grant_type", "authorization_code")
params.Add("app_id", "APPID_RABBIT")
params.Add("app_secret", "APPSECRET_RABBIT")

response, err := http.PostForm(OauthURl, params)
if err != nil {
slog.Error("post code for token failed", "error", err)
return
}
defer response.Body.Close()
accessToken, _ := io.ReadAll(response.Body)
}

这一步是服务端到服务端的请求,不经过浏览器。所以这里可以安全地携带 app_secret——它是第三方软件的密钥,只有后端知道。

授权服务收到请求后,校验 app_id、app_secret 和授权码的有效性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
case "authorization_code":
if appId != appMap["app_id"] {
io.WriteString(writer, "app_id is not available")
return
}
if appSecret != appMap["app_secret"] {
io.WriteString(writer, "app_secret is not available")
return
}

code := request.PostFormValue("code")
if !isExistCode(code) {
return
}
delete(codeMap, code) //授权码一旦被使用,须要立即作废

accessToken := generateAccessToken(appId, "USERTEST")
TokenScopeMap[accessToken] = codeScopeMap[code]

refreshToken := generateRefreshToken(appId, "USERTEST")

io.WriteString(writer, accessToken+"|"+refreshToken)

授权码用一次就作废delete(codeMap, code))。如果同一个 code 被用第二次,直接拒绝,防止授权码被截获后的重放攻击。

令牌生成函数:

1
2
3
4
5
6
7
func generateAccessToken(appId string, user string) string {
uid, _ := uuid.NewUUID()
accessToken := uid.String()
expiresIn := "1" //1天时间过期
TokenMap[accessToken] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "|" + expiresIn
return accessToken
}

第五步:携带令牌访问受保护资源

拿到 access_token 后,第三方软件就可以用它来请求受保护资源了:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 继续在 AppServletHandle 中
paramsMap := url.Values{}
paramsMap.Add("app_id", "APPID_RABBIT")
paramsMap.Add("app_secret", "APPSECRET_RABBIT")
paramsMap.Add("token", string(accessToken))

resp, err := http.PostForm(ProtectedURl, paramsMap)
if err != nil {
slog.Error("request protected resource failed", "error", err)
return
}
defer resp.Body.Close()
result, _ := io.ReadAll(resp.Body)

受保护资源服务收到请求后,根据令牌对应的权限范围执行不同的操作:

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
func ProtectedHandle(writer http.ResponseWriter, request *http.Request) {
accessToken := request.PostFormValue("token")

// 根据授权时的权限范围,做相应的处理
scope := TokenScopeMap[accessToken]

var sb strings.Builder
for _, s := range scope {
sb.WriteString(s)
sb.WriteString("|")
}
scopeStr := sb.String()

if strings.Contains(scopeStr, "query") {
queryGoods("")
}
if strings.Contains(scopeStr, "add") {
addGoods("")
}
if strings.Contains(scopeStr, "del") {
delGoods("")
}

// 不同的用户对应不同的数据
user := TokenMap[accessToken]
io.WriteString(writer, queryOrders(user))
}

注意这段代码检查的 scope 值是 query/add/del,和前面授权时声明的 today/history 不一致——这是因为教学项目里不同流程混用了不同的 scope 示例。资源服务根据令牌绑定的 scope 决定允许哪些操作,scope 具体叫什么名字由业务定义。

令牌始终在后端通道传递,从不出现在浏览器的 URL 或前端 JavaScript 中。 授权码虽然经过浏览器,但它是短命的、一次性的,即使被截获,没有 app_secret 也换不到令牌。

隐式许可:省去授权码交换的”快捷模式”

隐式许可(Implicit)是为纯前端应用设计的——没有后端服务器,没法安全地保存 app_secret,也没法做服务端到服务端的令牌交换。

它的做法很直接:跳过授权码这个中间步骤,授权服务直接把 access_token 拼在重定向 URL 的 fragment 里返回

sequenceDiagram
    participant U as 用户浏览器
    participant C as 第三方软件(:8080)
    participant AS as 授权服务(:8081)

    U->>C: 1. 访问第三方软件
    C->>AS: 2. 重定向<br/>(response_type=token, app_id, redirect_uri)
    AS->>U: 3. 展示授权页面
    U->>AS: 4. 用户同意
    AS->>C: 5. 重定向回第三方软件<br/>(#access_token=xxxx)
    C->>C: 6. 前端从 URL fragment 提取 token

第三方软件发起请求时,把 response_type 改为 token

1
2
3
4
5
6
7
8
9
10
11
func AppIndexTokenHandle(writer http.ResponseWriter, request *http.Request) {
oauthUrl, _ := url.Parse(OauthUrl)
params := oauthUrl.Query()
params.Add("response_type", "token") //告诉授权服务直接返回access_token
params.Add("redirect_uri", "http://localhost:8080/AppTokenServlet")
params.Add("app_id", "APPIDTEST")

oauthUrl.RawQuery = params.Encode()
writer.Header().Set("Location", oauthUrl.String())
writer.WriteHeader(http.StatusFound)
}

授权服务收到 response_type=token 后,直接生成 access_token 并拼到重定向 URL 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case "token":
if appId != "APPIDTEST" {
return
}
accessToken := generateAccessToken(appId, "USERTEST")

oauthUrl, _ := url.Parse(redirectUri)
params := oauthUrl.Query()
params.Add("redirect_uri", redirectUri)
params.Add("access_token", accessToken)

oauthUrl.RawQuery = params.Encode()
writer.Header().Set("Location", oauthUrl.String())
writer.WriteHeader(http.StatusFound)

第三方软件的回调端点直接从 URL 中提取 access_token:

1
2
3
4
5
func AppServletTokenHandle(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
accessToken := query.Get("access_token")
slog.Info("accessToken", "token", accessToken)
}

隐式许可省掉了授权码交换步骤,流程更简单,但代价是 access_token 直接暴露在前端。由于没有 app_secret 校验,也没有刷新令牌,令牌泄露的风险更高。OAuth 2.1 已经废弃了这个流程,推荐用授权码 + PKCE 替代。

资源拥有者凭据许可:直接交出用户名密码

这个流程最简单粗暴:用户把自己的用户名密码直接交给第三方软件,第三方软件拿着凭据去授权服务换令牌。

sequenceDiagram
    participant C as 第三方软件(:8080)
    participant AS as 授权服务(:8081)

    C->>AS: POST grant_type=password<br/>(app_id, app_secret, username, password)
    AS->>AS: 校验 app 凭据 + 用户凭据
    AS->>C: 返回 access_token

第三方软件直接把用户名密码 POST 到授权服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func AppServletPasswordHandle(writer http.ResponseWriter, request *http.Request) {
params := url.Values{}
params.Add("grant_type", "password")
params.Add("app_id", "APPIDTEST")
params.Add("app_secret", "APPSECRETTEST")
params.Add("username", "USERNAMETEST")
params.Add("password", "PASSWORDTEST")

response, err := http.PostForm(OauthURl, params)
if err != nil {
slog.Error("post code for token failed", "error", err)
return
}
defer response.Body.Close()
accessToken, _ := io.ReadAll(response.Body)
}

授权服务校验 app 凭据和用户凭据后直接发令牌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case "password":
username := request.PostFormValue("username")
password := request.PostFormValue("password")

if appSecret != "APPSECRETTEST" {
io.WriteString(writer, "app_secret is not available")
return
}
if username != "USERNAMETEST" {
io.WriteString(writer, "username is not available")
return
}
if password != "PASSWORDTEST" {
io.WriteString(writer, "password is not available")
return
}

accessToken := generateAccessToken(appId, "USERTEST")
io.WriteString(writer, accessToken)

这个流程只适用于用户完全信任的场景,比如操作系统内置的邮件客户端登录自家邮箱服务。OAuth 2.1 也已废弃此流程。第三方软件直接接触到了用户的明文密码,这是它被废弃的根本原因。

客户端凭据许可:机器对机器的通行证

这个流程不涉及用户,纯机器对机器。第三方软件用自己的 app_id 和 app_secret 直接换取令牌,适用于服务间 API 调用、定时任务等场景。

sequenceDiagram
    participant C as 第三方软件(:8080)
    participant AS as 授权服务(:8081)

    C->>AS: POST grant_type=client_credentials<br/>(app_id, app_secret)
    AS->>AS: 校验 app 凭据
    AS->>C: 返回 access_token

第三方软件的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func AppServletClientCredentialsHandle(writer http.ResponseWriter, request *http.Request) {
params := url.Values{}
params.Add("grant_type", "client_credentials")
params.Add("app_id", "APPIDTEST")
params.Add("app_secret", "APPSECRETTEST")

response, err := http.PostForm(OauthURl, params)
if err != nil {
slog.Error("post code for token failed", "error", err)
return
}
defer response.Body.Close()
accessToken, _ := io.ReadAll(response.Body)
}

授权服务的处理:

1
2
3
4
5
6
7
8
9
10
11
12
case "client_credentials":
if appId != "APPIDTEST" {
io.WriteString(writer, "app_id is not available")
return
}
if appSecret != "APPSECRETTEST" {
io.WriteString(writer, "app_secret is not available")
return
}

accessToken := generateAccessToken(appId, "USERTEST")
io.WriteString(writer, accessToken)

注意,这个流程没有用户参与,令牌代表的是第三方软件自身而非某个用户。它和密码流程的代码几乎一样,区别只在于 grant_type 和是否提交用户凭据。

刷新令牌:让用户只授权一次

access_token 有有效期(示例中是 1 天),过期后用户就得重新走一遍授权流程。这体验很差。刷新令牌(refresh_token)就是解决这个问题的。

刷新令牌的有效期比 access_token 长得多(比如 30 天),它不出现在前端,只存在第三方软件的后端。access_token 过期后,第三方软件用 refresh_token 向授权服务换一个新的 access_token,全程不需要用户参与:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case "refresh_token":
if appId != "APPIDTEST" {
io.WriteString(writer, "app_id is not available")
return
}
if appSecret != "APPSECRETTEST" {
io.WriteString(writer, "app_secret is not available")
return
}

refreshToken := request.PostFormValue("refresh_token")

if _, ok := refreshTokenMap[refreshToken]; !ok {
return
}

appStr := refreshTokenMap[refreshToken]
if !strings.HasPrefix(appStr, appId+"|"+"USERTEST") {
return
}

accessToken := generateAccessToken(appId, "USERTEST")
io.WriteString(writer, accessToken)

刷新令牌的生成和存储:

1
2
3
4
5
6
7
func generateRefreshToken(appId string, user string) string {
uid, _ := uuid.NewUUID()
refreshToken := uid.String()

refreshTokenMap[refreshToken] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10)
return refreshToken
}

刷新令牌的安全性要求比 access_token 更高,因为它能反复换取新的 access_token。在实际项目中,刷新令牌只应存储在后端,有效期可以长但必须可撤销。更好的做法是每次使用后旋转——发一个新的 refresh_token,旧的作废。

OIDC:OAuth 2.0 之上的身份认证层

前面所有流程解决的都是”授权”问题——第三方软件拿到令牌后能访问哪些资源。但有一个问题没解决:第三方软件怎么知道”这个令牌是谁的”?

OAuth 2.0 本身不负责身份认证。access_token 是一个不透明的字符串,第三方软件拿到它后只能去资源服务器换数据,不知道令牌对应的用户是谁。如果你用 access_token 来做”登录”,那就是在用授权协议干认证的活——这恰恰是很多安全漏洞的根源。

OIDC(OpenID Connect)在 OAuth 2.0 之上加了一层,核心就是多发一个 ID Token。ID Token 是一个 JWT(JSON Web Token),里面直接包含了用户身份信息,第三方软件拿到后可以直接解析,不需要再额外请求。

sequenceDiagram
    participant U as 用户浏览器
    participant C as 第三方软件(:8080)
    participant AS as 授权服务(:8081)

    U->>C: 1. 访问第三方软件
    C->>AS: 2. 重定向到授权服务
    AS->>U: 3. 展示授权页面
    U->>AS: 4. 用户同意
    AS->>C: 5. 重定向回第三方软件(携带 code)
    C->>AS: 6. 用 code 换 token(grant_type=authorization_code_2)
    AS->>C: 7. 返回 access_token + id_token(JWT)
    C->>C: 8. 解析 JWT 获取用户身份(sub)
    C->>U: 9. 显示"你好,XXX"

OIDC 流程的前半段和授权码流程一模一样,区别在令牌交换那一步——授权服务除了返回 access_token,还会返回一个 ID Token。

授权服务生成 ID Token,用 JWT 签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func generateIdToken(appId string, user string) string {
sharedTokenSecret := []byte("hellooauthhellooauthhellooauthhellooauth")

iat := time.Now().Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "http://localhost:8081/", // 签发者
"sub": user, // 用户唯一标识
"aud": appId, // 接收方
"iat": iat, // 签发时间
"exp": iat + 60*60, // 过期时间(1小时)
})

tokenString, _ := token.SignedString(sharedTokenSecret)
return tokenString
}

JWT 的 payload 包含五个标准声明:

  • iss(issuer):签发者,标识是哪个授权服务发的
  • sub(subject):用户唯一标识,这就是第三方软件要拿到的”你是谁”
  • aud(audience):接收方,标识这个 token 是发给哪个第三方软件的
  • iat(issued at):签发时间
  • exp(expiration):过期时间

第三方软件收到 ID Token 后,验证签名并提取用户身份:

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
func AppServletOIDCHandle(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
code := query.Get("code")

params := url.Values{}
params.Add("code", code)
params.Add("grant_type", "authorization_code_2") // 区分 OIDC 流程
params.Add("app_id", "APPID_RABBIT")
params.Add("app_secret", "APPSECRET_RABBIT")

response, err := http.PostForm(OauthURl, params)
if err != nil {
slog.Error("post code for token failed", "error", err)
return
}
defer response.Body.Close()
result, _ := io.ReadAll(response.Body)

arry := strings.Split(string(result), "&")
accessToken := arry[0]
idToken := arry[1]

// 获取用户登录标识
m := parseJwt(idToken)
sub := fmt.Sprintf("%v", m["sub"])

// 跳转到欢迎页面
u, _ := url.Parse("http://localhost:8080/oidc.html")
proxy := httputil.ReverseProxy{
Director: func(request *http.Request) {
request.URL = u
},
}
proxy.ServeHTTP(writer, request)
}

JWT 解析和验签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func parseJwt(jwtString string) map[string]any {
sharedTokenSecret := []byte("hellooauthhellooauthhellooauthhellooauth")

token, _ := jwt.Parse(jwtString, func(token *jwt.Token) (any, error) {
return sharedTokenSecret, nil
})

m := make(map[string]any)
if token == nil {
return m
}

if _, ok := token.Header["alg"].(string); !ok || jwt.SigningMethodHS256.Alg() != token.Header["alg"] {
slog.Error("unexpected signing method", "alg", token.Header["alg"])
return m
}

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims
}

return m
}

这里验证了三样东西:签名是否正确(用共享密钥验证)、算法是否匹配(防止算法降级攻击)、token 是否有效。最后从 sub 字段拿到用户标识,渲染到欢迎页面:

1
2
3
<body>
hello, {{.Sub}}, you have signed in successfully.
</body>

OAuth 2.0 负责授权,OIDC 负责把”你是谁”传给第三方软件。你平时点的”微信登录”,底下跑的就是这两层。

安全最佳实践

最后梳理几个生产环境必须做到的安全实践:

1. HTTPS 全程加密。 示例项目用 HTTP 是为了教学方便,生产环境所有通信必须走 HTTPS。授权码、令牌、用户凭据在传输过程中都必须加密。

2. redirect_uri 严格匹配。 授权服务必须验证 redirect_uri 与注册时完全一致,不能做前缀匹配或通配。否则攻击者可以注册一个相似域名,把授权码截获到自己的服务器。

3. 授权码一次性使用。 代码中的 delete(codeMap, code) 就在做这件事。授权码被使用后立即作废,防止重放攻击。

4. 授权码有效期要短。 RFC 建议不超过 10 分钟。示例代码中记录了时间戳但没有做过期检查,生产环境必须补上。

5. scope 最小权限原则。 只授予必要的权限范围。代码中两层 scope 校验(请求时校验 + 用户授权时校验)就是这个思路。

6. PKCE 扩展。 对于纯前端应用(无法安全保存 app_secret 的场景),应使用 PKCE(Proof Key for Code Exchange)。它在授权码流程的基础上增加了一层动态验证,即使授权码被截获,没有 PKCE 的 verifier 也换不到令牌。这比隐式许可安全得多,是 OAuth 2.1 推荐的替代方案。

四种流程对比

流程 用户参与 app_secret 安全性 适用场景 OAuth 2.1
授权码 后端持有 Web 应用、有后端的服务 保留
隐式许可 不需要 纯前端 SPA 废弃
密码许可 需要 第一方应用 废弃
客户端凭据 需要 机器对机器 保留

如果你在设计一个新系统,记住两个选择:有后端用授权码流程,纯前端用授权码 + PKCE。其他流程了解原理即可,新项目不应该再使用。

总结

OAuth 2.0 的核心是”不分享密码,只发令牌”。四种流程的区别在于令牌怎么发:授权码流程先发临时码再在后端换令牌,安全性最高,也是你现在最该用的;隐式许可跳过中间步骤直接发令牌,已被废弃;密码许可直接交用户名密码,同样废弃;客户端凭据不涉及用户,机器对机器场景仍在使用。

OIDC 在这之上多发一个 JWT 格式的 ID Token,让第三方软件能安全地知道”令牌是谁的”——这才是”微信登录”的完整实现。

回头再看那个登录按钮,每一步都清楚了:重定向到授权页,授权码换令牌,令牌换资源,ID Token 换身份。