5-权限控制

思考并回答以下问题:

实验介绍

本次实验课程主要帮助学员们进一步学习Gin框架中的基础知识,权限控制(Permission Control)。其中主要包括两部分,鉴权(Authentication)和授权(Authorization)。深入了解Gin框架权限控制之后,能够在实战中开发企业级管理后台等应用。

知识点

  • 鉴权
  • Cookie
  • JWT
  • 授权
  • OAuth 2

鉴权

对于企业级应用来说,我们一般会有网络安全的需求,而通常的做法是要求用户提供密码、密钥之类进行登陆,这样的方式统称为鉴权(Authentication)。

下图是一个非常简单的鉴权示意图。

大概的流程如下:

1,客户端告诉服务端它的名字叫“张三”(用户名),并提供了暗号“芝麻开门”(密码);

2,服务端对比之后发现,名字(用户名)和暗号(密码)都能对上,因此允许它合法访问。

这其实就是典型的用户名/密码(Username/Password)鉴权。

下面我们将稍微修改一下之前实验代码,来实现上面的用户名/密码鉴权。

打开VS Code,打开middlewares/id.go文件,稍微修改一下验证状态部分的代码,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package middlewares

import "github.com/gin-gonic/gin"

func IdMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
// ID
id := c.Param("id")

// 验证状态
auth := id == "zhangsan" && c.Query("password") == "zhi-ma-kai-men"

// 保存验证状态
c.Set("auth", auth)

// 如果验证成功,保存 ID
c.Set("id", id)

// 下一步
c.Next()
}
}

其实也就是,在auth处加入了c.Query("password") == "zhi-ma-kai-men",表示如果URL参数password不为zhi-ma-kai-men,就表示验证失败。

然后,我们打开终端,运行go run main.go启动应用。

成功启动后,打开Web服务,更改URL相对路径为/users/zhangsan?password=zhi-ma-kai-men,结果如下。

可以看到auth为true,表示验证成功了。

如果URL参数password为空,或设置为其他值,返回的auth就为false,表示验证失败。

Cookie

刚才我们介绍了基本的用户名/密码鉴权方式,并在Gin代码中在中间件用代码来手动实现了非常简单的鉴权,但这在现实世界中远远不够,它要求每一次请求都进行鉴权,这是非常低效的,而且扩展性也很差。现在,我们需要看看传统的鉴权方式,也就是基于Cookie的鉴权。

Cookie是保留在浏览器本地的非常轻量级的缓存数据。每个网站对应着不同的Cookie。Cookie可以设置过期时间、作用域。Cookie是以键值对存在的。Cookie还有个非常方便的特性,就是每次HTTP请求时,它会随着请求头(Headers)一起带到服务端,而且服务端可以通过返回响应头Set-Header来设置Cookie。因为其简单,很多应用会把优先将Cookie作为鉴权的方式。

Cookie鉴权的示意图如下。

可以看到,客户端只需要登陆一次,服务端通过返回响应中的Set-Cookie:session_id=<SessionID>来保存SessionID在本地Cookie,后续请求自动就可以通过Cookie自动带上SessionID,从而不用重复鉴权。

下面我们来实现一下如何利用Cookie在Gin框架中进行鉴权。我们将用到Gin官方维护的库gin-contrib/sessions

打开VS Code,在终端中输入以下命令下载安装该库。

1
go get github.com/gin-contrib/sessions

下载完毕后,打开routes/users.go,添加登陆方法login,代码如下。
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
...
// 登陆
func login(c *gin.Context) {
username := c.Query("username") // 用户名
password := c.Query("password") // 密码

// 获取 Session
session := sessions.Default(c)

// 校验密码
if password != "admin" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 保存用户名在 Session 中
session.Set("username", username)
session.Save()

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, map[string]interface{}{
"success": true,
})
}
...

然后,将其注册到引擎中,在routes/users.go代码如下。
1
2
3
4
5
6
7
8
9
10
...
// 注册路由
func RegisterUsers(app *gin.Engine) {
g := app.Group("/users/:id")
g.GET("", getUser)
g.POST("*action", postUserAction)
app.GET("/me", getMe)
app.GET("/login", login)
}
...

routes/users.go的完整代码如下。
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
package routes

import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"net/http"
)

// RegisterUsers 注册路由
func RegisterUsers(app *gin.Engine) {
g := app.Group("/users/:id")
g.GET("", getUser)
g.POST("*action", postUserAction)
app.GET("/me", getMe)
app.GET("/login", login)
app.GET("/login/jwt", loginJwt)
}

// 获取用户
func getUser(c *gin.Context) {
// 用户 ID
id := c.GetString("id")

// 用户
user := map[string]interface{}{
"username": id,
"role": "normal",
"auth": c.GetBool("auth"),
}

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, user)
}

// 执行用户操作
func postUserAction(c *gin.Context) {
// 用户 ID
id := c.Param("id")

// 操作
action := c.Param("action")

// 如用户不存在,返回 404
if id != "zhangsan" {
c.AbortWithStatus(http.StatusNotFound)
return
}

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, map[string]interface{}{
"id": id,
"action": action,
})
}

// 获取当前用户
func getMe(c *gin.Context) {
c.Redirect(http.StatusFound, "/users/zhangsan")
}

// 登陆
func login(c *gin.Context) {
username := c.Query("username") // 用户名
password := c.Query("password") // 密码

// 获取 Session
session := sessions.Default(c)

// 校验密码
if password != "admin" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 保存用户名在 Session 中
session.Set("username", username)
session.Save()

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, map[string]interface{}{
"success": true,
})
}

// JWT 登陆
func loginJwt(c *gin.Context) {
username := c.Query("username") // 用户名
password := c.Query("password") // 密码

// 校验密码
if password != "admin" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 生成 JWT 令牌
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": username,
})
token, err := jwtToken.SignedString([]byte("secret"))
if err != nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, map[string]interface{}{
"success": true,
"token": token,
})
}

现在,我们将实现一个基于Cookie的鉴权中间件。新建文件middlewares/cookie_auth.go,输入如下代码。
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
package middlewares

import (
"fmt"
"net/http"

"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)

func CookieAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// 忽略 /login
if strings.HasPrefix(c.Request.RequestURI, "/login") {
c.Next()
return
}

// 获取 Session
session := sessions.Default(c)

// 从 Session 获取用户名
username := session.Get("username")

// 若用户名不存在,返回 401
if username == "" || username == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 下一步
c.Next()
}
}

最后,我们将注册Sessions中间件。在VS Code中打开main.go,将之前内容更改为如下代码。
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
package main

import (
"gin-course/middlewares"
"gin-course/routes"

"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)

func main() {
// 创建 Gin 引擎
app := gin.New()

// 注册 Sessions 中间件
app.Use(sessions.Sessions("session_id", cookie.NewStore([]byte("secret"))))

// 注册 Cookie 鉴权中间件
app.Use(middlewares.CookieAuth())

// 注册路由 users
routes.RegisterUsers(app)

// 运行 Gin 引擎
app.Run(":8080")
}

这里,我们清理了之前的老代码。其中,我们注册了Sessions中间件,session_id是SessionID在Cookie中的键名,secret是密钥。

接下来,打开终端,执行go run main.go启动应用。

成功启动后,打开Web服务,更改URL相对路径为/users/zhangsan,我们会得到401的页面。这是因为鉴权中间件中找不到username,验证失败了。

现在,我们尝试一下更改URL相对路径为/login?username=admin&password=admin,会返回{"success":true}的结果,表示登陆成功。然后,我们打开Chrome浏览器的Application标签,展开左侧Cookie菜单,点击其包含的网址,可以看到如下结果。

这表示浏览器已经在Cookie保存了该Session对应的SessionID。

现在,再次请求/users/zhangsan,就不会是401页面了,而是会返回响应的JSON结果。

JWT

JWT是JSON Web Token的简称,很多现代的应用都逐渐开始从传统基于Cookie的鉴权方式,转向JWT。其中,最主要的原因是它通常是无状态(Stateless)的,也就是说,JWT不占用服务端空间资源。还记得Cookie方式么,它要求在服务端存放Session,这就无可避免的将占用内存或其他媒介的空间资源。

在基于JWT鉴权模式中,服务端会向客户端签发令牌(Token),这个令牌被客户端保存下来之后,可以复用在后续请求上。大致流程如下。

如上图,JWT鉴权流程跟Cookie鉴权类似,但不同的是服务端不再保存令牌或其他信息,一些必要的信息(例如用户名、ID)将通过加密的方式内置在令牌里,从而达到节省服务端空间资源的目的。

下面,我们将通过一个实战练习,来学习如何使用Gin框架JWT鉴权。我们将利用一个官方推荐的第三方JWT库。

打开VS Code,在终端中输入以下命令安装JWT相关依赖。

1
go get github.com/golang-jwt/jwt/v4

在routes/users.go中添加JWT登陆方法,代码如下。
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
// JWT 登陆
func loginJwt(c *gin.Context) {
username := c.Query("username") // 用户名
password := c.Query("password") // 密码

// 校验密码
if password != "admin" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 生成 JWT 令牌
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": username,
})
token, err := jwtToken.SignedString([]byte("secret"))
if err != nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 返回响应
c.AbortWithStatusJSON(http.StatusOK, map[string]interface{}{
"success": true,
"token": token,
})
}

可以注意到上述代码,跟之前Session-Cookie不同的是,JWT令牌在服务端是不保存的,它会通过响应返回给客户端,并保存。因此,JWT令牌是不占用服务端内存资源,这在大型应用中是非常棒的特性。

接下来,我们将这个登陆方法注册到路由中。

1
2
3
4
5
6
7
8
9
// 注册路由
func RegisterUsers(app *gin.Engine) {
g := app.Group("/users/:id")
g.GET("", getUser)
g.POST("*action", postUserAction)
app.GET("/me", getMe)
app.GET("/login", login)
app.GET("/login/jwt", loginJwt)
}

现在,我们需要添加中间件来鉴权。添加JWT中间件文件middlewares/jwt_auth.go,输入以下内容。
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
package middlewares

import (
"github.com/golang-jwt/jwt/v4"
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

func JwtAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// 忽略 /login
if strings.HasPrefix(c.Request.RequestURI, "/login") {
c.Next()
return
}

// 从 Header 中获取 JWT 令牌
token := c.GetHeader("Authorization")

// 令牌为空,返回401
if token == "" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 解析 JWT 令牌
jwtToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { return []byte("secret"), nil })
if err != nil {
return
}

// JWT 令牌无效,返回 401
if !jwtToken.Valid {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 获取 JWT 令牌声明
claim, ok := jwtToken.Claims.(jwt.MapClaims)
if !ok {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 获取用户名
username, ok := claim["username"]
if !ok {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 若用户名不存在,返回 401
if username == "" || username == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// 验证成功,下一步
c.Next()
}
}

然后,我们将这个中间件注册到应用中。打开main.go,更改代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"gin-course/middlewares"
"gin-course/routes"

"github.com/gin-gonic/gin"
)

func main() {
// 创建 Gin 引擎
app := gin.New()

// 注册 JWT 鉴权中间件
app.Use(middlewares.JwtAuth())

// 注册路由 users
routes.RegisterUsers(app)

// 运行 Gin 引擎
app.Run(":8080")
}

接下来,打开终端,执行go run main.go启动应用。

成功启动后,打开Web服务,我们更改URL相对路径为/login/jwt?username=admin&password=admin,会返回如下结果。

1
2
3
4
{
success: true,
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec"
}

可以看到,返回响应中有token的字符串,即登陆校验成功后生成的JWT令牌。可以在记事本或其他地方将token里的值复制保存下来,用作后续请求。

下面,我们需要将这个JWT令牌做后续请求的验证。

1,打开Thunder Client扩展,点击New Request创建新请求;

2,URL输入http://localhost:8080/users/me

3,在Auth标签下,选择Bearer,将刚才token值复制粘贴到Bearer Token中;

4,点击Send发起请求。

结果如下,可以看到已经请求成功了。

您可以试试看不加Bearer Token或用其他值的情况,返回状态值都会是401。

授权

授权(Authorization)是很多Web应用中不可或缺的功能。不同于鉴权(Authentication),它更多强调的是允许客户端或其他实体访问服务端资源(Resources)的概念。简单来说,授权相当于是告诉用户或客户端,什么资源允许访问,什么资源禁止访问;而鉴权更多的是判别用户的身份是否合法。因此,之前提到的登陆接口就是鉴权,通过Cookie或JWT令牌来判别用户是否合法。而授权相对来说更通用,也更复杂一些,授权可以是将资源访问权限细化了,可以指定某类用户访问特定的资源;此外,授权还可以通过第三方来代理进行,也就是说可以通过第三方权威机构或实体来授权客户端访问服务端资源,这是更复杂的情况了。

如果我们用生活的例子做比喻,鉴权就相当于办公楼大门,员工需要有大门门禁才允许进入大楼;而授权相当于办公楼每家公司的公司门禁,只有属于自己公司的员工持公司门禁才可以刷开公司所在楼层的前台门。因此,如果咱们仔细思考,鉴权其实相当于授权的特殊形式,也就是Yes或No的二元判别问题,授权更灵活,可以对每一种资源进行更细化的权限分配。

OAuth 2

OAuth 2是当今云计算时代非常重要的授权模式。在OAuth 2中,每一个系统或应用,如果需要访问其他资源,都可以通过Access Token的形式对目标资源发起请求,而资源所在服务端会校验Access Token的合法性,即请求发起者是否允许访问该资源。而这个Access Token是怎么来的呢?这就是OAuth 2中重要的授权服务端(Authorization Server)的角色。授权服务端,简称AuthServer,相当于第三方授权服务端,需要对资源进行请求的客户端不用每次都找资源所在服务端进行鉴权,而是通过AuthServer授权生成Access Token,然后利用它向资源发起请求。

OAuth 2的请求示意图如下。

那么,为什么我们需要大费周章让授权服务端来签发Access Token,然后才让用户端去访问资源呢?直接让客户端访问资源所在服务端鉴权不是更好么?其实,这就是网络应用系统变得越来越复杂、规模越来越大之后的有效解决方案。一个简单系统,只需要鉴权就可以了,但对于大型的现代网络应用,权限管理的复杂度将随着权限细化程度而指数级上升。

想象一下,如果你去一家酒店,当你拿着身份证需要让保安验证身份给你开门,当去餐厅吃早饭时也需要身份证进入,或者去咖啡厅时也需要身份证验证,这是不是非常麻烦的一件事情?实际上,现在酒店都会在你入住时给你一个房卡,只需要这个房卡就可以去酒店中其他任何地方,这比身份证验证要方便得多。而这里的房卡其实就是Access Token,而前台就是授权服务端,而其他房间、餐厅、咖啡厅就是资源服务端。

实验总结

通过本次实验,我们学习了Gin框架中鉴权的几种方式,即用户名/密码、Cookie和JWT,Cookie鉴权简单但需要服务端存储空间,JWT鉴权不占用服务端存储空间。我们同样了解了授权与鉴权的区别,同时还介绍了OAuth 2这个通用的鉴权方式。相信通过本次实验,我们对Gin框架的权限管理有更近一步的认识。现在,我们距离开发大型企业Web应用又进了一步,不过要上生产环境,我们还得继续学习。下一节,我们将介绍Gin框架中另一个非常重要的模块,即日志模块。

0%