JWT
JSON Web Toekn(JWT)是一个开放标准RFC 7519,以JSON的方式进行通信,是目前最流行的一种身份验证方式之一。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
下图是通过JWT.io解码,查看JWT token的组成
可以看出JWT是由下面三个部分组成的:
- 头部(Header)
- 载荷(Payload)
- 签名(Signature)
Header
Header是Token的构成的第一部分,包含了Token类型、Token使用的加密算法(加密算法可以是HMAC, SHA256或者是RSA等)。在某些场景下,可以使用kid字段,用来标识一个密钥的ID
Payload
Payload是token的第二部分,由JWT标准中注册的、公共的、私有的声明三部分组成。Payload通常包含一些用户的声明信息,比如签发者、过期时间、签发时间等。其中最常见的是issuer, expiration, subject。
- issuer被用来标识token的颁发人
- expiration是token的过期时间
- subject被用来标识token主体部分
Signature
Signature是由头部和载荷加密后连接起来的,程序通过验证Signature是否合法来决定认证是否通过
JWT的优势
- 体积小。JWT是采用JSON进行通信的,JSON比XML更加简洁,因此在对其编码时,JWT的体积比SAML更小(SAML是一种基于XML的开放标准,用在身份提供者和服务提供者之间交换身份验证和授权的数据,SAML的一个重要的应用就是基于Web的单点登录)
- 更加安全。JWT能够使用公钥或者私钥对证书进行加密或解密,虽然SAML也可以使用JWT等公钥或私钥进行加密或解密,但是与JSON相比,使用XML数字签名容易引进比较晦涩的安全漏洞
- 更加通用。JSON可以转换成很多语言的对象方式,而XML没有一种可以转为对象的映射
- 更容易处理。不管是在PC端还是在移动端,JSON都能够很好的进行通信
JWT的使用场景
- 身份验证。
- 授权
- 信息交换
需要注意的是不要将敏感信息存在Token里面!!!
Casbin
Casbin是一个强大的、高效的、开源的权限访问控制库,它提供了多种权限控制访问模型,比如ACL(权限控制列表)、RBAC(基于角色的访问控制)、ABAC(基于属性的权限验证)等。除此之外Casbin还支持多种编程语言
Casbin可以做什么
-
通过经典的
{subject, object, action}
或者自定义的模式执行想要的策略,同时支持allow和deny两种授权方式 - 处理控制访问存储和权限
- 管理用户-角色-资源权限控制访问映射(RBAC)
- 支持超级管理员授权方式
- 可以使用内置的函数配置访问规则
Casbin不可以做什么
- 使用用户名或密码登录的身份验证
- 管理用户或者角色列表,这些由系统本身管理更加方便,casbin主要是用来作为用户-角色的一种权限访问控制映射
Casbin的工作原理
在Casbin中,访问控制模型被抽象为PERM(Policy, Effect, Request, Matcher)
的一个文件
- Request
定义请求参数。基本请求时一个元组对象,至少需要主题(访问实体), 对象(访问资源), 动作(访问方式),例如r={sub, obj, act}
,它实际定义了我们应该提供访问控制匹配功能的参数名称和顺序
- Policy
定义访问策略模式,例如p={sub, obj, act}或p={sub, obj, act, eff}
, 它定义字段的名称和顺序
- Matcher
匹配请求和策略的规则,例如m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
,它的意思是如果请求的参数被匹配,那么结果就会被返回
- Effect
匹配后的结果会储存于Effect当中,可以对匹配结果再次做出逻辑判断,例如e = some (where (p.eft == allow))
Casbin中最基本的model是ACL,下面是ACL的model配置
1
2
3
4
5
6
7
8
9
10
11
|
[request_definition] r = sub, obj, act # Policy definition [policy_definition] p = sub, obj, act # Policy effect [policy_effect] e = some(where (p.eft == allow)) # Matchers [matchers] m = r.sub == p.sub && r.obj == p.obj && r.act == p.act |
实践
编写一个简单的TODO
RESTful API。
创建一个simple-jwt-auth
的目录,然后通过go mod
管理依赖 go mod init simple-jwt-auth
建立的目录结构如下:
在model定义User
和Todo
的结构体
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
|
// models/model.go type User struct { ID string `json: "id" ` UserName string `json: "username" ` Password string `json: "password" ` } type Todo struct { UserID string `json: "user_id" ` Title string `json: "title" ` Body string `json: "body" ` } // SetPassword sets a new password stored as hash. func (m *User) SetPassword(password string ) error { if len (password) < 6 { return fmt.Errorf( "new password for %s must be at least 6 characters" , m.UserName) } m.Password = password return nil } // InvalidPassword returns true if the given password does not match the hash. func (m *User) InvalidPassword(password string ) bool { if password == "" { return true } if m.Password != password { return true } return false } |
登录接口请求
当用户通过用户名和密码等信息登录系统服务时,需要验证是否已注册、密码是否正确等,然后返回信息, 下面在api
层实现Login
的接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// api/auth_api.go func Login(c *gin.Context) { var u models.User if err := c.ShouldBindJSON(&u); err != nil { c.JSON(http.StatusUnprocessableEntity, "Invalid json provided" ) return } //find user with username user, err := models.UserRepo.FindByID( 1 ) //compare the user from the request, with the one we defined: if user.UserName != u.UserName || user.Password != u.Password { c.JSON(http.StatusUnauthorized, "Please provide valid login details" ) return } c.JSON(http.StatusOK, "Login successfully" ) } func Logout(c *gin.Context) { c.JSON(http.StatusOK, "Successfully logged out" ) } |
在真实的项目中,数据都是存在数据库中。在该教程中,为了方便,创建一个mock文件user_repository.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
|
// models/user_repository.go var us = []User{ { ID: "2" , UserName: "users" , Password: "pass" , }, { ID: "3" , UserName: "username" , Password: "password" , }, } var UserRepo = UserRepository{ Users: us, } type UserRepository struct { Users []User } func (r *UserRepository) FindAll() ([]User, error ) { return r.Users, nil } func (r *UserRepository) FindByID(id int ) (User, error ) { for _, v := range r.Users { uid, err := strconv.Atoi(v.ID) if err != nil { return User{}, err } if uid == int (id) { return v, nil } } return User{}, errors. New ( "Not found" ) } func (r *UserRepository) Save(user User) (User, error ) { r.Users = append (r.Users, user) return user, nil } func (r *UserRepository) Delete (user User) { id := - 1 for i, v := range r.Users { if v.ID == user.ID { id = i break } } if id == - 1 { log.Fatal( "Not found user " ) return } r.Users[id] = r.Users[ len (r.Users)- 1 ] // Copy last element to index i. r.Users[ len (r.Users)- 1 ] = User{} // Erase last element (write zero value). r.Users = r.Users[: len (r.Users)- 1 ] // Truncate slice. return } |
为了不让Login
函数变得臃肿,生成token
的逻辑放在auth
目录中, 下面实现token
验证逻辑
Token实现
用JWT
实现的系统中,用户登录后,系统会生成并返回一个token
给用户,下次请求时将会带上该token
进行身份验证。token有以下问题需要处理:
- 用户退出登录的时候,需要使token失效
-
token
有可能被黑客劫持和使用 -
当
token
过期后需要用户重新登录,体验不友好
上面的问题可以通过以下两种方式解决:
- 使用Redis存储token的信息。当用户退出时,使token失效, 这在一定程度上提高的安全性
- 在token过期的时候,使用刷新token的方式重新生成一个token, 不用用户退出登录,提高用户体验
使用Redis存储Token信息
使用uuid
作为redis中的key, token信息作为value, 下面定义TokenManager
结构体,通过接口的方式实现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
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
113
114
115
|
type TokenManager struct {} func NewTokenService() *TokenManager { return &TokenManager{} } type TokenInterface interface { CreateToken(userId, userName string ) (*TokenDetails, error ) ExtractTokenMetadata(*http.Request) (*AccessDetails, error ) } //Token implements the TokenInterface var _ TokenInterface = &TokenManager{} func (t *TokenManager) CreateToken(userId, userName string ) (*TokenDetails, error ) { td := &TokenDetails{} td.AtExpires = time.Now().Add(time.Minute * 30 ).Unix() //expires after 30 min td.TokenUuid = uuid.NewV4(). String () td.RtExpires = time.Now().Add(time.Hour * 24 * 7 ).Unix() td.RefreshUuid = td.TokenUuid + "++" + userId var err error //Creating Access Token atClaims := jwt.MapClaims{} atClaims[ "access_uuid" ] = td.TokenUuid atClaims[ "user_id" ] = userId atClaims[ "user_name" ] = userName atClaims[ "exp" ] = td.AtExpires at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims) td.AccessToken, err = at.SignedString([] byte (os.Getenv( "ACCESS_SECRET" ))) if err != nil { return nil , err } //Creating Refresh Token td.RtExpires = time.Now().Add(time.Hour * 24 * 7 ).Unix() td.RefreshUuid = td.TokenUuid + "++" + userId rtClaims := jwt.MapClaims{} rtClaims[ "refresh_uuid" ] = td.RefreshUuid rtClaims[ "user_id" ] = userId rtClaims[ "user_name" ] = userName rtClaims[ "exp" ] = td.RtExpires rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims) td.RefreshToken, err = rt.SignedString([] byte (os.Getenv( "REFRESH_SECRET" ))) if err != nil { return nil , err } return td, nil } func (t *TokenManager) ExtractTokenMetadata(r *http.Request) (*AccessDetails, error ) { token, err := VerifyToken(r) if err != nil { return nil , err } acc, err := Extract(token) if err != nil { return nil , err } return acc, nil } func TokenValid(r *http.Request) error { token, err := VerifyToken(r) if err != nil { return err } if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid { return err } return nil } func VerifyToken(r *http.Request) (*jwt.Token, error ) { tokenString := ExtractToken(r) token, err := jwt.Parse(tokenString, func (token *jwt.Token) ( interface {}, error ) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil , fmt.Errorf( "unexpected signing method: %v" , token.Header[ "alg" ]) } return [] byte (os.Getenv( "ACCESS_SECRET" )), nil }) if err != nil { return nil , err } return token, nil } //get the token from the request body func ExtractToken(r *http.Request) string { bearToken := r.Header.Get( "Authorization" ) strArr := strings.Split(bearToken, " " ) if len (strArr) == 2 { return strArr[ 1 ] } return "" } func Extract(token *jwt.Token) (*AccessDetails, error ) { claims, ok := token.Claims.(jwt.MapClaims) if ok && token.Valid { accessUuid, ok := claims[ "access_uuid" ].( string ) userId, userOk := claims[ "user_id" ].( string ) userName, userNameOk := claims[ "user_name" ].( string ) if ok == false || userOk == false || userNameOk == false { return nil , errors. New ( "unauthorized" ) } else { return &AccessDetails{ TokenUuid: accessUuid, UserId: userId, UserName: userName, }, nil } } return nil , errors. New ( "something went wrong" ) } func ExtractTokenMetadata(r *http.Request) (*AccessDetails, error ) { token, err := VerifyToken(r) if err != nil { return nil , err } acc, err := Extract(token) if err != nil { return nil , err } return acc, nil } |
上面的代码设置token的有效时间为30分钟,30分钟过后token将失效,用户不能使用该token进行正确验证。
另外,使用了从.env
配置文件获取的密钥(ACCESS_SECRET)签名。在真实的项目,不能在代码中公开这个密钥!!!
1
2
3
4
5
6
|
REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= ACCESS_SECRET=98hbun98hsdfsdwesdfs REFRESH_SECRET=786dfdbjhsbsdfsdfsdf PORT=8081 |
定义AuthInterface
处理会话
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
|
package auth import ( "errors" "fmt" "github.com/go-redis/redis/v7" "time" ) type AccessDetails struct { TokenUuid string UserId string UserName string } type TokenDetails struct { AccessToken string RefreshToken string TokenUuid string RefreshUuid string AtExpires int64 RtExpires int64 } type AuthInterface interface { CreateAuth( string , *TokenDetails) error FetchAuth( string ) ( string , error ) DeleteRefresh( string ) error DeleteTokens(*AccessDetails) error } type RedisAuthService struct { client *redis.Client } var _ AuthInterface = &RedisAuthService{} func NewAuthService(client *redis.Client) *RedisAuthService { return &RedisAuthService{client: client} } //Save token metadata to Redis func (tk *RedisAuthService) CreateAuth(userId string , td *TokenDetails) error { at := time.Unix(td.AtExpires, 0 ) //converting Unix to UTC(to Time object) rt := time.Unix(td.RtExpires, 0 ) now := time.Now() atCreated, err := tk.client.Set(td.TokenUuid, userId, at.Sub(now)).Result() if err != nil { return err } rtCreated, err := tk.client.Set(td.RefreshUuid, userId, rt.Sub(now)).Result() if err != nil { return err } if atCreated == "0" || rtCreated == "0" { return errors. New ( "no record inserted" ) } return nil } //Check the metadata saved func (tk *RedisAuthService) FetchAuth(tokenUuid string ) ( string , error ) { userid, err := tk.client.Get(tokenUuid).Result() if err != nil { return "" , err } return userid, nil } //Once a user row in the token table func (tk *RedisAuthService) DeleteTokens(authD *AccessDetails) error { //get the refresh uuid refreshUuid := fmt.Sprintf( "%s++%s" , authD.TokenUuid, authD.UserId) //delete access token deletedAt, err := tk.client.Del(authD.TokenUuid).Result() if err != nil { return err } //delete refresh token deletedRt, err := tk.client.Del(refreshUuid).Result() if err != nil { return err } //When the record is deleted, the return value is 1 if deletedAt != 1 || deletedRt != 1 { return errors. New ( "something went wrong" ) } return nil } func (tk *RedisAuthService) DeleteRefresh(refreshUuid string ) error { //delete refresh token deleted, err := tk.client.Del(refreshUuid).Result() if err != nil || deleted == 0 { return err } return nil } |
用Casbin做授权管理
在Casbin中,一个权限访问控制模型的配置文件是基于PERM(Policy, Effect, Role, Matcher)
的方式,因此当要修改或升级权限的时候非常方便,只需要修改配置文件就行了。使用者可以自定义配置文件,例如定义RBAC
或者ACL
最基本的也是最简单的模型是ACL
, 下面创建一个ACL
的模型配置文件
1
2
3
4
5
6
7
8
9
10
|
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [role_definition] g = _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act |
Casbin 的权限是存储在.csv
文件中或者是SQL
数据库中, 在该教程是通过csv
文件的方式存储
1
2
3
4
5
6
|
p, user, resource, read p, username, resource, read p, admin, resource, read p, admin, resource, write g, alice, admin g, bob, user |
上面权限的意思是:
- 所有的用户可以读数据,但是不能写
- 所有的admin用户可以读数据,也可以写数据
- alice是admin用户,bob是普通用户 因此Alice有控制整个系统数据的权限,而Bob只有读的权限
实现Casbin的策略
首先,定义一个policies的中间件
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
|
import ( "fmt" "github.com/casbin/casbin" "github.com/casbin/casbin/persist" "github.com/gin-gonic/gin" "github.com/simple-jwt-auth/auth" "log" "net/http" ) func TokenAuthMiddleware() gin.HandlerFunc { return func (c *gin.Context) { err := auth.TokenValid(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized" ) c.Abort() return } c.Next() } } // Authorize determines if current subject has been authorized to take an action on an object. func Authorize(obj string , act string , adapter persist.Adapter) gin.HandlerFunc { return func (c *gin.Context) { err := auth.TokenValid(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "user hasn't logged in yet" ) c.Abort() return } metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized" ) return } // casbin enforces policy ok, err := enforce(metadata.UserName, obj, act, adapter) //ok, err := enforce(val.(string), obj, act, adapter) if err != nil { log. Println (err) c.AbortWithStatusJSON( 500 , "error occurred when authorizing user" ) return } if !ok { c.AbortWithStatusJSON( 403 , "forbidden" ) return } c.Next() } } func enforce(sub string , obj string , act string , adapter persist.Adapter) ( bool , error ) { enforcer := casbin.NewEnforcer( "config/rbac_model.conf" , adapter) err := enforcer.LoadPolicy() if err != nil { return false , fmt.Errorf( "failed to load policy from DB: %w" , err) } ok := enforcer.Enforce(sub, obj, act) return ok, nil } |
然后,修改上面的Login
和Logout
接口, 增加身份验证及授权信息
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
|
func Login(c *gin.Context) { var u models.User if err := c.ShouldBindJSON(&u); err != nil { c.JSON(http.StatusUnprocessableEntity, "Invalid json provided" ) return } //find user with username user, err := models.UserRepo.FindByID( 1 ) //compare the user from the request, with the one we defined: if user.UserName != u.UserName || user.Password != u.Password { c.JSON(http.StatusUnauthorized, "Please provide valid login details" ) return } ts, err := tokenManager.CreateToken(user.ID, user.UserName) if err != nil { c.JSON(http.StatusUnprocessableEntity, err. Error ()) return } save token to redis saveErr := servers.HttpServer.RD.CreateAuth(user.ID, ts) if saveErr != nil { c.JSON(http.StatusUnprocessableEntity, saveErr. Error ()) } tokens := map [ string ] string { "access_token" : ts.AccessToken, "refresh_token" : ts.RefreshToken, } c.JSON(http.StatusOK, tokens) } func Logout(c *gin.Context) { //If metadata is passed and the tokens valid, delete them from the redis store metadata, _ := tokenManager.ExtractTokenMetadata(c.Request) if metadata != nil { deleteErr := servers.HttpServer.RD.DeleteTokens(metadata) if deleteErr != nil { c.JSON(http.StatusBadRequest, deleteErr. Error ()) return } } c.JSON(http.StatusOK, "Successfully logged out" ) } |
创建Todo
定义Todo
的结构体
1
2
3
4
5
|
type Todo struct { UserID string `json: "user_id" ` Title string `json: "title" ` Body string `json: "body" ` } |
创建Todo
的接口
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
|
package api import ( "github.com/gin-gonic/gin" "github.com/simple-jwt-auth/auth" "github.com/simple-jwt-auth/models" "net/http" ) func CreateTodo(c *gin.Context) { var td models.Todo if err := c.ShouldBindJSON(&td); err != nil { c.JSON(http.StatusUnprocessableEntity, "invalid json" ) return } metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized" ) return } td.UserID = metadata.UserId //you can proceed to save the to a database c.JSON(http.StatusCreated, td) } func GetTodo(c *gin.Context) { metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized" ) return } userId := metadata.UserId c.JSON(http.StatusOK, models.Todo{ UserID: userId, Title: "Return todo" , Body: "Return todo for testing" , }) } |
注册路由
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func (s *Server) InitializeRoutes() { s.Router.POST( "/login" , api.Login) authorized := s.Router.Group( "/" ) authorized.Use(gin.Logger()) authorized.Use(gin.Recovery()) authorized.Use(middleware.TokenAuthMiddleware()) { authorized.POST( "/api/todo" , middleware.Authorize( "resource" , "write" , s.FileAdapter), api.CreateTodo) authorized.GET( "/api/todo" , middleware.Authorize( "resource" , "read" , s.FileAdapter), api.GetTodo) authorized.POST( "/logout" , api.Logout) authorized.POST( "/refresh" , api.Refresh) } } |
最后在main.go
文件中调用server
层的Run方法即可运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package main import ( "github.com/joho/godotenv" "github.com/simple-jwt-auth/servers" "log" ) func init() { if err := godotenv.Load(); err != nil { log. Print ( "No .env file found" ) } } func main() { servers.Run() log. Println ( "Server exiting" ) } |
结果如下:
原作者仓库地址github
以上就是Golang基于JWT与Casbin身份验证授权实例详解的详细内容,更多关于Go JWT Casbin身份验证授权的资料请关注服务器之家其它相关文章!
原文链接:https://juejin.cn/post/7121237647829237768