Go学习笔记(七) | 理解OAuth 2.0并实现一个客户端

本文最后更新于:4 年前

OAuth 2.0是一个关于授权的开放网络标准,主要致力于简化客户端人员开发,同时为Web应用程序、桌面应用程序、移动电话和物联网设备提供特定的授权规范。他的官网在这里。在RFC6749中有明确协议规范。

简单来说,我们平时使用的很多第三方登录并获取头像等信息就是用的OAuth 2.0。如我们用QQ登录一些论坛,用google账号登陆facebook,用github账号登陆gitlab等。如下图展示的就是利用QQ登录网易云音乐Web版,其中用到的就是OAuth 2.0。

理解OAuth 2.0

在开始编程前,我们先要掌握OAuth 2.0认证的流程是什么样的,下面我们一起走进OAuth 2.0。

下面的情景都以我用QQ登录网易云Web版为例子。

相关名词

在理解OAuth 2.0前,我们先需要理解下面这几个名词。

  • resource owner: 资源所有者。也就是例子中的我(用户)。

  • resource server: 资源服务器,即服务提供商存放用户资源的服务器。例子中就是QQ的资源服务器,存放了我的QQ昵称,QQ头像,性别等等信息。

  • client: 客户端,需要得到资源的应用程序。例子中的网易云音乐Web版。

  • authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。例子中QQ提供的认证服务器(跳出来让我们扫码登录QQ的这个服务)。

  • user-agent:用户代理。我们用来访问客户端的程序,如这里就是浏览器。

总体说来,这种登录流程大概就是这样:

(resource owner)需要用QQ(resource server) 在浏览器(user-agent)登录网易云音乐(client),我们先登录QQ(authorization server),授权给网易云。然后网易云才能拿着这个授权信息去QQ资源服务器(resource server)获取到我的头像/昵称/性别等信息。

具体流程我们继续往下看。

协议流程

下图来自RFC6749

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+--------+                               +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+

Figure 1: Abstract Protocol Flow
  • (A) 客户端请求resource owner授权;

    这种授权可以是直接向resource owner请求,也可以通过authorization server间接请求。例子中,就是跳转到QQ的authorization server,让用户登录QQ。

  • (B) 用户同意给予授权;

    例子中,用户点击同意,重定向回网易云音乐,这是一种授权模式。在RFC中规定了4中授权模式,具体采用哪一种取决于authorization server,下一小节具体介绍。

  • (C) 客户端拿着上一步的授权,向authorization server申请令牌(access_token);

  • (D) authorization server确认授权无误后,发放令牌(access_token);

  • (E) 客户端拿着令牌(access_token)到resource server去获取资源;

    例子中,获取QQ头像,昵称等。

  • (F) resource server确认无误,同意向客户端下发受保护的资源。

下面详细说到其中四种授权模式。

授权模式

客户端要得到令牌(access_token), 必须需要得到用户的授权,在OAuth 2.0 中定义了四种授权模式:

  • 授权码模式 (Authorization Code)
  • 简化模式 (Implicit)
  • 密码模式 (Resource Owner Password Credentials)
  • 客户端模式 (Client Credentials)

每种模式的使用场景与流程都有一定的差别。

授权码模式 (Authorization Code)

授权码模式的授权流程是基于重定向。我们例子中的用QQ登录网易云就是这种模式,流程图如下(来自RFC6749):

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
 +----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| -+----(A)-- & Redirection URI ---->| |
| User- | | Authorization |
| Agent -+----(B)-- User authenticates --->| Server |
| | | |
| -+----(C)-- Authorization Code ---<| |
+-|----|---+ +---------------+
| | ^ v
(A) (C) | |
| | | |
^ v | |
+---------+ | |
| |>---(D)-- Authorization Code ---------' |
| Client | & Redirection URI |
| | |
| |<---(E)----- Access Token -------------------'
+---------+ (w/ Optional Refresh Token)

Note: The lines illustrating steps (A), (B), and (C) are broken into
two parts as they pass through the user-agent.

Figure 3: Authorization Code Flow
  • (A) 用户访问客户端,客户端将用户重定向到认证服务器;
  • (B) 用户选择是否授权;
  • (C) 如果用户同意授权,认证服务器重定向到客户端事先指定的地址,而且带上授权码(code);
  • (D) 客户端收到授权码,带着前面的重定向地址,向认证服务器申请访问令牌;
  • (E) 认证服务器核对授权码与重定向地址,确认后向客户端发送访问令牌和更新令牌(可选)。

1)在A中,客户端申请授权,重定向到认证服务器的URI中需要包含这些参数:

参数名称参数含义是否必须
response_type授权类型,此处的值为code必须
client_id客户端ID,客户端到资源服务器注册的ID必须
redirect_uri重定向URI可选
scope申请的权限范围,多个逗号隔开可选
state客户端的当前状态,可以指定任意值,认证服务器会原封不动的返回这个值推荐

RFC6749中例子如下:

1
2
3
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

我们在用QQ登录网易云音乐时实际的地址如下:

1
2
3
4
5
6
7
8
https://graph.qq.com/oauth2.0/show?which=Login
&display=pc
&client_id=100495085
&response_type=code
&redirect_uri=https://music.163.com/back/qq
&forcelogin=true
&state=ichQQlAgNi
&checkToken=sretfyguihojpr

我们看到参数基本一致,至于还多几个,有什么用这里就没有去深究了。

2)在C中,认证服务器返回的URI中,需要包含下面这些参数:

参数名称参数含义是否必须
code授权码。认证服务器返回的授权码,生命周期不超过10分钟,而且要求只能使用一次,和A中的client_id,redirect_uri绑定。必须
state如果A中请求包含这个参数,资源服务器原封不动的返回。可选

如:

1
2
3
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
&state=xyz
  1. 在D中客户端向认证服务器申请令牌(access_token)时,需要包含下面这些参数。
参名称参数含义是否必须
grant_type授权模式,此处为authorization_code必须
code授权码,C中获取的code必须
redirect_uri重定向URI,需要和A中一致。必须
client_id客户端ID,与A中一致。必须

如:

1
2
3
4
5
6
7
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
  1. 在E中,认证服务器返回的信息中,包含下面参数:
参数名称参数含义是否必须
access_token访问令牌必须
token_type令牌类型,大小写不敏感。例如 Bearer,MAC。必须
expires_in过期时间(s), 如果不设置也要通过其他方法设置一个。推荐
refresh_token更新令牌的token。当令牌过期的时候,可用通过该值刷新token。可选
scope权限范围,如果与客户端申请范围一致,可省略。可选

如:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
  1. 如果我们的令牌过期了,需要更新,这里就需要使用refresh_token获取一个新令牌了。此时发起HTTP请求需要的参数有:
参数名称参数含义是否必须
grant_type授权类型,此处是refresh_token必须
refresh_token更新令牌的token。必须
scope权限范围。可选

如:

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

这就是授权码模式,应该也是我们平常见的较多的模式了。

简化模式 (Implicit)

简化模式,相当于授权码模式中,C步骤不再通过客户端,直接在浏览器(user-agent)中向认证服务器申请令牌,认证服务器不再返回授权码,所有步骤都在浏览器中完成,最后资源服务器将令牌放在Fragment中,浏览器从中将令牌提取,发送给客户端。

所以这个令牌对访问者时可见的,而且客户端不需要认证。详细流程如下。

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
+----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| -+----(A)-- & Redirection URI --->| |
| User- | | Authorization |
| Agent -|----(B)-- User authenticates -->| Server |
| | | |
| |<---(C)--- Redirection URI ----<| |
| | with Access Token +---------------+
| | in Fragment
| | +---------------+
| |----(D)--- Redirection URI ---->| Web-Hosted |
| | without Fragment | Client |
| | | Resource |
| (F) |<---(E)------- Script ---------<| |
| | +---------------+
+-|--------+
| |
(A) (G) Access Token
| |
^ v
+---------+
| |
| Client |
| |
+---------+

  • (A) 客户端将用户导向认证服务器, 携带客户端ID及重定向URI;
  • (B) 用户是否授权;
  • (C) 用户同意授权后,认证服务器重定向到A中指定的URI,并且在URI的Fragment中包含了访问令牌;
  • (D) 浏览器向资源服务器发出请求,该请求中不包含C中的Fragment值;
  • (E) 资源服务器返回一个网页,其中包含了可以提取C中Fragment里面访问令牌的脚本;
  • (F) 浏览器执行E中获得的脚本,提取令牌;
  • (G) 浏览器将令牌发送给客户端。

1)在A步骤中,客户端发送请求,需要包含这些参数:

参数名称参数含义是否必须
response_type授权类型,此处值为token必须
client_id客户端的ID。必须
redirect_uri重定向的URI。可选
scope权限范围。可选
state客户端的当前状态。指定后服务器会原封不动返回。推荐

如:

1
2
3
GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com
  1. 在C中,认证服务器返回的URI中,参数主要有:
参数名称参数含义是否必须
access_token访问令牌。必须
token_type令牌类型。必须
expires_in过期时间。推荐
scope权限范围。可选
state客户端访问时如果指定了,原封不动返回。可选

如:

1
2
3
HTTP/1.1 302 Found
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
&state=xyz&token_type=example&expires_in=3600

我们可以看到C中返回的是一个重定向,而重定向的这个网址的Fragment部分包含了令牌。

D步骤中就是访问这个重定向指定的URI,而且不带Fragment部分,服务器会返回从Fragment中提取令牌的脚本,最后浏览器运行脚本获取到令牌发送给客户端。

密码模式 (Resource Owner Password Credentials)

密码模式就是用户直接将用户名密码提供给客户端,客户端使用这些信息到认证服务器请求授权。具体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+----------+
| Resource |
| Owner |
| |
+----------+
v
| Resource Owner
(A) Password Credentials
|
v
+---------+ +---------------+
| |>--(B)---- Resource Owner ------->| |
| | Password Credentials | Authorization |
| Client | | Server |
| |<--(C)---- Access Token ---------<| |
| | (w/ Optional Refresh Token) | |
+---------+ +---------------+

Figure 5: Resource Owner Password Credentials Flow
  • (A) 资源所有者提供用户名密码给客户端;
  • (B) 客户端拿着用户名密码去认证服务器请求令牌;
  • (C) 认证服务器确认后,返回令牌;
  1. 在B中客户端发送的请求中,需要包含这些参数:
参数名称参数含义是否必须
grant_type授权类型,此处值为password必须
username用户名。必须
password用户的密码。必须
scope权限范围。可选

如:

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3w
  1. 在C中,认证服务器返回访问令牌。如:
1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
客户端模式 (Client Credentials)

客户端模式,其实就是客户端直接向认证服务器请求令牌。而用户直接在客户端注册即可,一般用于后端 API 的相关操作。其流程如下:

1
2
3
4
5
6
7
8
9
10

+---------+ +---------------+
| | | |
| |>--(A)- Client Authentication --->| Authorization |
| Client | | Server |
| |<--(B)---- Access Token ---------<| |
| | | |
+---------+ +---------------+

Figure 6: Client Credentials Flow
  • (A) 客户端发起身份认证,请求访问令牌;
  • (B) 认证服务器确认无误,返回访问令牌。
  1. 在A中,客户端发起请求的参数有:
参数名称参数含义是否必须
grant_type授权类型,此处值为client_credentials必须
scope权限范围。可选

如:

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

2) 认证服务器认证后,发放访问令牌,如:

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"example_parameter":"example_value"
}

实现OAuth 2.0

上面我们一起了解了OAuth 2.0的流程,现在我们开始实现OAuth 2.0。

首先,我需要一个使用场景,大概是这样的。

我有一个需要登录的应用(web app)。
用户可以选择使用github登录。
登录后,显示一个简单的欢迎页面。

有了这个场景后,我们开始设计(这里我们实现的是第一种授权码模式)。

登录页面

在这里,我们充当的角色就是client,我们需要一个简单的页面,让用户选择使用github登录。

这是一个简单的html页面,public/index.html页面。

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>

<body>
<a href="https://github.com/login/oauth/authorize?client_id=89ac6f58f15f658d8dd5&redirect_uri=http://localhost:8080/oauth/redirect">
Login with github
</a>
</body>

</html>

也就是当用户点击Login with github会访问

1
2
3
https://github.com/login/oauth/authorize?
client_id=89ac6f58f15f658d8dd5
&redirect_uri=http://localhost:8080/oauth/redirect

其中:

  • https://github.com/login/oauth/authorize 是GitHub的OAuth网关地址。

  • client_id=89ac6f58f15f658d8dd5 这个是我申请的客户端ID,

    需要到https://github.com/settings/applications/new注册。

    注册过程很简单,但要注意,最后一栏Authorization callback URL是和下面redirect_uri一致。

    如:

    成功后你会获的Client IDClient Secret, 前者就是这里的client_id,两者在后面的编码中需要用到。

  • redirect_uri=http://localhost:8080/oauth/redirect 当用户确认,获取权限后重定向到的该地址。

然后我们写个mian.go, 启动一个简单的服务。

1
2
3
4
5
6
func main() {
fs := http.FileServer(http.Dir("public"))
http.Handle("/", fs)

http.ListenAndServe(":8080", nil)
}

写到这里,我们代码已经能跑起了,如果你点击Login with github我们会看到如下的授权页面。

当我们确定过后,会重定向到我们指定的地址,而且带上code,如:

1
http://localhost:8080/oauth/redirect?code=260f17a7308f2c566725

此时我们需要做的是,拿着该code去请求访问令牌(access_token),对应授权码模式中的D步骤。

重定向路由

现在我们有code了,我们需要去请求令牌。接下来我们就需要向https://github.com/login/oauth/access_token发送POST请求获取访问令牌(access_token)。

关于更多GitHub重定向URI的信息你可以看这里

现在,让我们将/oauth/redirect路由补全,在该步骤中请求访问令牌(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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
)

// 你在注册时得到的
const (
clientID = "你的客户端ID"
clientSecret = "你的客户端密钥"
)

var httpClient = http.Client{}

type OAuthAccessResponse struct {
AccessToken string `json:"access_token"`
}

func main() {
fs := http.FileServer(http.Dir("public"))
http.Handle("/", fs)
http.HandleFunc("/oauth/redirect", HandleOAuthRedirect)

http.ListenAndServe(":8080", nil)
}

// HandleOAuthRedirect doc
func HandleOAuthRedirect(w http.ResponseWriter, r *http.Request) {
// 首先,我们从URI中解析出code参数
// 如: http://localhost:8080/oauth/redirect?code=260f17a7308f2c566725
err := r.ParseForm()
if err != nil {
log.Printf("could not parse query: %v", err)
w.WriteHeader(http.StatusBadRequest)
}
code := r.FormValue("code")

// 接下来,我们通过 clientID,clientSecret,code 获取授权密钥
// 前者是我们在注册时得到的,后者是用户确认后,重定向到该路由,从中获取到的。
reqURL := fmt.Sprintf("https://github.com/login/oauth/access_token?client_id=%s&client_secret=%s&code=%s",
clientID, clientSecret, code)
req, err := http.NewRequest(http.MethodPost, reqURL, nil)
if err != nil {
log.Printf("could not create HTTP request: %v", err)
w.WriteHeader(http.StatusBadRequest)
}

// 设置我们期待返回的格式为json
req.Header.Set("accept", "application/json")

// 发送http请求
res, err := httpClient.Do(req)
if err != nil {
log.Printf("could not send HTTP request: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
defer res.Body.Close()

// 解析
var t OAuthAccessResponse
if err := json.NewDecoder(res.Body).Decode(&t); err != nil {
log.Printf("could not parse JSON response: %v", err)
w.WriteHeader(http.StatusBadRequest)
}

// 最后获取到access_token后,我们重定向到欢迎页面,也就是表示用户登录成功,同属获取一些用户的基本展示信息
w.Header().Set("Location", "/welcome.html?access_token="+t.AccessToken)
w.WriteHeader(http.StatusFound)
}

欢迎页面

上面我们拿到了访问令牌,同时,我们重定向到了欢迎页面。接下来,我们就在欢迎页中获取用户的GitHub名称作为展示。

添加public/welcome.html页面。

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Hello</title>
</head>

<body>

</body>
<script>
// 获取access_token
const query = window.location.search.substring(1)
const token = query.split('access_token=')[1]

// 访问资源服务器地址,获取相关资源
fetch('https://api.github.com/user', {
headers: {
// 将token放在Header中
Authorization: 'token ' + token
}
})
// 解析返回的JSON
.then(res => res.json())
.then(res => {
// 这里我们能得到很多信息
// 具体看这里 https://developer.github.com/v3/users/#get-the-authenticated-user
// 这里我们就只展示一下用户名了
const nameNode = document.createTextNode(`Welcome, ${res.name}`)
document.body.appendChild(nameNode)
})
</script>

到这里,我们再次运行程序,点击Login with github,同意后我们可以看到类似Welcome, Razeen的信息,此时我们已经完成整个OAuth 2.0的流程了。

获得授权之后,我们能访问的API不仅仅有这些,更多的请看这里

源码在这里

关于安全

  1. 这里我们将访问令牌直接放在了URI中,这么做其实是不安全的。更好的做法是我们创建一个会话session,将cooike发送给用户即可。

  2. 在前面OAuth 2.0的解释中,我们知道在请求权限的过程中,我们可以加上state字段,如

    1
    2
    3
    4
    https://github.com/login/oauth/authorize?
    client_id=89ac6f58f15f658d8dd5
    &redirect_uri=http://localhost:8080/oauth/redirect
    &state=xxxxx

    而服务器会原封不动的返回该state。这样我们可以将该字段设置一些随机值,如果资源服务器返回的state值与我们设置的不同,我们认为该请求不是来自正确的资源服务器,应该拒绝。

参考


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!