权限认证
在 API 服务器中想对特定资源或功能的访问进行控制就需要实行身份权限验证。
换句话说,如果你想要将某些 API 端点或数据限制给具有特殊角色的用户(例如管理员、版主或高级用户等)使用,你就需要进行身份权限验证。
传统用户流程
更新代码
添加 User
领域实体,domain/model/user.go:
package model
import "time"
// User 表示业务用户
type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
Password string `json:"password,omitempty"`
Salt string `json:"salt,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
声明其业务能力,domain/gateway/user_manager.go:
package gateway
import (
"context"
"literank.com/rest-books/domain/model"
)
// UserManager 管理用户
type UserManager interface {
CreateUser(ctx context.Context, u *model.User) (uint, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
}
添加其实现,infrastructure/database/mysql.go:
@@ -23,7 +23,7 @@ func NewMySQLPersistence(dsn string, pageSize int) (*MySQLPersistence, error) {
return nil, err
}
// Auto Migrate the data structs
- db.AutoMigrate(&model.Book{})
+ db.AutoMigrate(&model.Book{}, &model.User{})
return &MySQLPersistence{db, pageSize}, nil
}
@@ -67,3 +67,18 @@ func (s *MySQLPersistence) GetBooks(ctx context.Context, offset int, keyword str
}
return books, nil
}
+
+func (s *MySQLPersistence) CreateUser(ctx context.Context, u *model.User) (uint, error) {
+ if err := s.db.WithContext(ctx).Create(u).Error; err != nil {
+ return 0, err
+ }
+ return u.ID, nil
+}
+
+func (s *MySQLPersistence) GetUserByEmail(ctx context.Context, email string) (*model.User, error) {
+ var u model.User
+ if err := s.db.WithContext(ctx).Where("email = ?", email).First(&u).Error; err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
实现用户注册和登录功能,application/executor/user_operator.go:
package executor
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"math/rand"
"time"
"literank.com/rest-books/application/dto"
"literank.com/rest-books/domain/gateway"
"literank.com/rest-books/domain/model"
)
const (
saltLen = 4
errEmptyEmail = "empty email"
errEmptyPassword = "empty password"
)
type UserOperator struct {
userManager gateway.UserManager
}
func NewUserOperator(u gateway.UserManager) *UserOperator {
return &UserOperator{userManager: u}
}
// CreateUser creates a new user
func (u *UserOperator) CreateUser(ctx context.Context, uc *dto.UserCredential) (*dto.User, error) {
if uc.Email == "" {
return nil, errors.New(errEmptyEmail)
}
if uc.Password == "" {
return nil, errors.New(errEmptyPassword)
}
salt := randomString(saltLen)
user := &model.User{
Email: uc.Email,
Password: sha1Hash(uc.Password + salt),
Salt: salt,
}
uid, err := u.userManager.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return &dto.User{
ID: uid,
Email: uc.Email,
}, nil
}
// SignIn signs an user in
func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.User, error) {
if email == "" {
return nil, errors.New(errEmptyEmail)
}
if password == "" {
return nil, errors.New(errEmptyPassword)
}
user, err := u.userManager.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
passwordHash := sha1Hash(password + user.Salt)
if user.Password != passwordHash {
return nil, errors.New("wrong password")
}
return &dto.User{
ID: user.ID,
Email: user.Email,
}, nil
}
func randomString(length int) string {
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
for i := range result {
result[i] = charset[random.Intn(len(charset))]
}
return string(result)
}
func sha1Hash(input string) string {
h := sha1.New()
h.Write([]byte(input))
hashBytes := h.Sum(nil)
hashString := hex.EncodeToString(hashBytes)
return hashString
}
函数 randomString
和 sha1Hash
是用于 salt 生成和密码哈希的辅助函数。
为什么需要盐(salt)?
没有salt,攻击者可以为常见密码预先计算哈希值并将其存储在表中(称为彩虹表)。如果数据库遭到入侵,攻击者可以将哈希密码与预先计算的哈希值进行比较,从而快速识别密码。为每个密码添加 salt 确保即使两个用户使用相同的密码,由于唯一的 salt,它们的哈希密码也会不同。
即使用户选择了弱密码,例如常见的词典单词,salt 也确保了生成的哈希值是唯一的。没有 salt,相同密码的所有实例都会哈希到相同的值,使其易受攻击。
添加 DTO,application/dto/user.go:
package dto
// UserCredential represents the user's sign-in email and password
type UserCredential struct {
Email string `json:"email,omitempty"`
Password string `json:"password,omitempty"`
}
// User is used as result of a successful sign-in
type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
}
DTO 表示数据传输对象(Data Transfer Object)。
在应用程序中,数据的内部表示通常可能与你想通过 API 公开展示的表示方式不同。数据传输对象(DTO)允许你将内部数据结构转换为更适合 API 消费者的格式。这有助于将内部表示与外部表示解耦,从而实现更好的灵活性和抽象化。
引入依赖,application/wire_helper.go:
@@ -31,6 +31,10 @@ func (w *WireHelper) BookManager() gateway.BookManager {
return w.sqlPersistence
}
+func (w *WireHelper) UserManager() gateway.UserManager {
+ return w.sqlPersistence
+}
+
func (w *WireHelper) ReviewManager() gateway.ReviewManager {
return w.noSQLPersistence
}
最后,添加路由,adaptor/router.go:
@@ -22,12 +22,14 @@ const (
type RestHandler struct {
bookOperator *executor.BookOperator
reviewOperator *executor.ReviewOperator
+ userOperator *executor.UserOperator
}
func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
rest := &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager(), wireHelper.CacheHelper()),
reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
+ userOperator: executor.NewUserOperator(wireHelper.UserManager()),
}
// Create a new Gin router
r := gin.Default()
@@ -49,6 +51,10 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
r.POST("/reviews", rest.createReview)
r.PUT("/reviews/:id", rest.updateReview)
r.DELETE("/reviews/:id", rest.deleteReview)
+
+ userGroup := r.Group("/users")
+ userGroup.POST("", rest.userSignUp)
+ userGroup.POST("/sign-in", rest.userSignIn)
return r, nil
}
@@ -220,3 +226,33 @@ func (r *RestHandler) deleteReview(c *gin.Context) {
}
c.JSON(http.StatusNoContent, nil)
}
+
+func (r *RestHandler) userSignUp(c *gin.Context) {
+ var ucBody dto.UserCredential
+ if err := c.ShouldBindJSON(&ucBody); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ u, err := r.userOperator.CreateUser(c, &ucBody)
+ if err != nil {
+ fmt.Printf("Failed to create user: %v\n", err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "failed to sign up"})
+ return
+ }
+ c.JSON(http.StatusCreated, u)
+}
+
+func (r *RestHandler) userSignIn(c *gin.Context) {
+ var m dto.UserCredential
+ if err := c.ShouldBindJSON(&m); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ u, err := r.userOperator.SignIn(c, m.Email, m.Password)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, u)
+}
使用 curl 测试
用户注册:
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users
结果:
{"id":2,"email":"test-user@example.com"}
如果你检查数据库中的记录,你将看到类似下面内容:
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| id | email | password | salt | is_admin | created_at | updated_at |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| 2 | test-user@example.com | f9264ef99598a1be989cfca6a692cb677dbe7ac4 | 4Fnd | 0 | 2024-02-27 11:44:05.350 | 2024-02-27 11:44:05.350 |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
用户成功登录:
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users/sign-in
结果:
{"id":2,"email":"test-user@example.com"}
用户使用错误密码登录:
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "wrong-pass"}' http://localhost:8080/users/sign-in
结果:
{"error":"wrong password"}
注意:
为成功和错误响应使用统一的 json 格式是一种最佳实践。 比如:{"code":0, "message":"ok", "data": {"id":2,"email":"test-user@example.com"}} {"code":1001, "message":"wrong password", "data": null}
使用 JWT(JSON Web Token)
JWT(JSON Web Token)是一种紧凑、URL 安全的用于表示传输双方之间声明 claims 的方式。它经常用于 Web 应用程序和 API 中的身份验证和授权。
安装 JWT 依赖
go get -u github.com/golang-jwt/jwt/v5
更新代码
添加 UserPermission
枚举,domain/model/user.go:
@@ -2,6 +2,17 @@ package model
import "time"
+// UserPermission represents different levels of user permissions.
+type UserPermission uint8
+
+// UserPermission levels
+const (
+ PermNone UserPermission = iota
+ PermUser
+ PermAuthor
+ PermAdmin
+)
+
// User represents an app user
type User struct {
ID uint `json:"id,omitempty"`
声明它的业务能力,domain/gateway/user_manager.go:
@@ -11,3 +11,9 @@ type UserManager interface {
CreateUser(ctx context.Context, u *model.User) (uint, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
}
+
+// PermissionManager manage user permissions by tokens
+type PermissionManager interface {
+ GenerateToken(userID uint, email string, perm model.UserPermission) (string, error)
+ HasPermission(tokenResult string, perm model.UserPermission) (bool, error)
+}
实现 2 个方法,infrastructure/token/jwt.go:
package token
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"literank.com/rest-books/domain/model"
)
const (
errInvalidToken = "invalid token"
errFailToConvert = "failed to convert token type"
)
// Keeper manages user tokens.
type Keeper struct {
secretKey []byte
expireHours uint
}
// UserClaims includes user info.
type UserClaims struct {
UserID uint `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Permission model.UserPermission `json:"permission,omitempty"`
jwt.RegisteredClaims
}
// NewTokenKeeper constructs a new JWT token keeper
func NewTokenKeeper(secretKey string, expireInHours uint) *Keeper {
return &Keeper{[]byte(secretKey), expireInHours}
}
// GenerateToken generates a new JWT token.
func (t *Keeper) GenerateToken(userID uint, email string, perm model.UserPermission) (string, error) {
claims := UserClaims{
userID, email, perm,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(t.expireHours) * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenResult, err := token.SignedString(t.secretKey)
if err != nil {
return "", err
}
return tokenResult, nil
}
// ExtractToken extracts the token from the signed string.
func (t *Keeper) ExtractToken(tokenResult string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(tokenResult, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return t.secretKey, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New(errInvalidToken)
}
claims, ok := token.Claims.(*UserClaims)
if !ok {
return nil, errors.New(errFailToConvert)
}
return claims, nil
}
// HasPermission checks if user has the given permission.
func (t *Keeper) HasPermission(tokenResult string, perm model.UserPermission) (bool, error) {
claims, err := t.ExtractToken(tokenResult)
if err != nil {
return false, err
}
return claims.Permission >= perm, nil
}
添加 Tokens 相关的配置项,infrastructure/config/config.go:
@@ -21,8 +21,10 @@ type DBConfig struct {
}
type ApplicationConfig struct {
- Port int `json:"port" yaml:"port"`
- PageSize int `json:"page_size" yaml:"page_size"`
+ Port int `json:"port" yaml:"port"`
+ PageSize int `json:"page_size" yaml:"page_size"`
+ TokenSecret string `json:"token_secret" yaml:"token_secret"`
+ TokenHours int `json:"token_hours" yaml:"token_hours"`
}
type CacheConfig struct {
置入其值,config.yml:
@@ -1,6 +1,8 @@
app:
port: 8080
page_size: 5
+ token_secret: "I_Love_LiteRank"
+ token_hours: 72
db:
file_name: "test.db"
生成并返回 tokens,application/executor/user_operator.go:
@@ -21,10 +21,11 @@ const (
type UserOperator struct {
userManager gateway.UserManager
+ permManager gateway.PermissionManager
}
-func NewUserOperator(u gateway.UserManager) *UserOperator {
- return &UserOperator{userManager: u}
+func NewUserOperator(u gateway.UserManager, p gateway.PermissionManager) *UserOperator {
+ return &UserOperator{userManager: u, permManager: p}
}
// CreateUser creates a new user
@@ -52,7 +53,7 @@ func (u *UserOperator) CreateUser(ctx context.Context, uc *dto.UserCredential) (
}
// SignIn signs an user in
-func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.User, error) {
+func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto.UserToken, error) {
if email == "" {
return nil, errors.New(errEmptyEmail)
}
@@ -67,13 +68,33 @@ func (u *UserOperator) SignIn(ctx context.Context, email, password string) (*dto
if user.Password != passwordHash {
return nil, errors.New("wrong password")
}
+ token, err := u.permManager.GenerateToken(user.ID, user.Email, calcPerm(user.IsAdmin))
+ if err != nil {
+ return nil, err
+ }
- return &dto.User{
- ID: user.ID,
- Email: user.Email,
+ return &dto.UserToken{
+ User: dto.User{
+ ID: user.ID,
+ Email: user.Email,
+ },
+ Token: token,
}, nil
}
+// HasPermission checks if user has the given permission.
+func (u *UserOperator) HasPermission(tokenResult string, perm model.UserPermission) (bool, error) {
+ return u.permManager.HasPermission(tokenResult, perm)
+}
+
+func calcPerm(isAdmin bool) model.UserPermission {
+ perm := model.PermUser
+ if isAdmin {
+ perm = model.PermAdmin
+ }
+ return perm
+}
+
func randomString(length int) string {
source := rand.NewSource(time.Now().UnixNano())
random := rand.New(source)
添加 DTO,application/dto/user.go:
@@ -11,3 +11,9 @@ type User struct {
ID uint `json:"id,omitempty"`
Email string `json:"email,omitempty"`
}
+
+// UserToken is an combination of User struct and token field
+type UserToken struct {
+ User User `json:"user,omitempty"`
+ Token string `json:"token,omitempty"`
+}
微调 application/wire_helper.go:
@@ -5,6 +5,7 @@ import (
"literank.com/rest-books/infrastructure/cache"
"literank.com/rest-books/infrastructure/config"
"literank.com/rest-books/infrastructure/database"
+ "literank.com/rest-books/infrastructure/token"
)
// WireHelper is the helper for dependency injection
@@ -12,6 +13,7 @@ type WireHelper struct {
sqlPersistence *database.MySQLPersistence
noSQLPersistence *database.MongoPersistence
kvStore *cache.RedisCache
+ tokenKeeper *token.Keeper
}
func NewWireHelper(c *config.Config) (*WireHelper, error) {
@@ -24,7 +26,10 @@ func NewWireHelper(c *config.Config) (*WireHelper, error) {
return nil, err
}
kv := cache.NewRedisCache(&c.Cache)
- return &WireHelper{sqlPersistence: db, noSQLPersistence: mdb, kvStore: kv}, nil
+ tk := token.NewTokenKeeper(c.App.TokenSecret, uint(c.App.TokenHours))
+ return &WireHelper{
+ sqlPersistence: db, noSQLPersistence: mdb,
+ kvStore: kv, tokenKeeper: tk}, nil
}
func (w *WireHelper) BookManager() gateway.BookManager {
@@ -35,6 +40,10 @@ func (w *WireHelper) UserManager() gateway.UserManager {
return w.sqlPersistence
}
+func (w *WireHelper) PermManager() gateway.PermissionManager {
+ return w.tokenKeeper
+}
+
func (w *WireHelper) ReviewManager() gateway.ReviewManager {
return w.noSQLPersistence
}
添加 PermCheck
中间件到创建、更新、删除路由中,adaptor/router.go:
@@ -25,12 +25,16 @@ type RestHandler struct {
userOperator *executor.UserOperator
}
-func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
- rest := &RestHandler{
+func newRestHandler(wireHelper *application.WireHelper) *RestHandler {
+ return &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager(), wireHelper.CacheHelper()),
reviewOperator: executor.NewReviewOperator(wireHelper.ReviewManager()),
- userOperator: executor.NewUserOperator(wireHelper.UserManager()),
+ userOperator: executor.NewUserOperator(wireHelper.UserManager(), wireHelper.PermManager()),
}
+}
+
+func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
+ rest := newRestHandler(wireHelper)
// Create a new Gin router
r := gin.Default()
@@ -43,9 +47,9 @@ func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
})
r.GET("/books", rest.getBooks)
r.GET("/books/:id", rest.getBook)
- r.POST("/books", rest.createBook)
- r.PUT("/books/:id", rest.updateBook)
- r.DELETE("/books/:id", rest.deleteBook)
+ r.POST("/books", rest.PermCheck(model.PermAuthor), rest.createBook)
+ r.PUT("/books/:id", rest.PermCheck(model.PermAuthor), rest.updateBook)
+ r.DELETE("/books/:id", rest.PermCheck(model.PermAuthor), rest.deleteBook)
r.GET("/books/:id/reviews", rest.getReviewsOfBook)
r.GET("/reviews/:id", rest.getReview)
r.POST("/reviews", rest.createReview)
PermCheck
的实现,adaptor/middleware.go:
package adaptor
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"literank.com/rest-books/domain/model"
)
const tokenPrefix = "Bearer "
// PermCheck checks user permission
func (r *RestHandler) PermCheck(allowPerm model.UserPermission) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"})
c.Abort()
return
}
token := strings.Replace(authHeader, tokenPrefix, "", 1)
hasPerm, err := r.userOperator.HasPermission(token, allowPerm)
message := "Unauthorized"
if err != nil {
message = err.Error()
}
if !hasPerm {
c.JSON(http.StatusUnauthorized, gin.H{"error": message})
c.Abort()
return
}
c.Next()
}
}
以上就是 API 中使用 JWT 的所需更改。让我们再次测试一下服务器功能!
使用 curl 测试
用户登录获得 Token
curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:8080/users/sign-in
结果:
{
"user": { "id": 2, "email": "test-user@example.com" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8"
}
将 token 放到 https://jwt.io/ 调试器中,可查看 token 的负载和签名是否正常。
创建新的图书,携带合法正常的 Token
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjozLCJleHAiOjE3MDkyNzExMjl9.ybMnzuP0v__JVviWDKxeKaeTqpAn6wQL9CXfiNj7cM4' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
成功结果:
{
"id": 14,
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100,
"created_at": "2024-02-27T13:32:40.5+08:00",
"updated_at": "2024-02-27T13:32:40.5+08:00"
}
创建新的图书,携带合法 Token,但是权限不够
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
结果:
{"error":"Unauthorized"}
创建新的图书,不带 Token
curl -X POST \
http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
结果:
{"error":"token is required"}
创建新的图书,带一个假 Token
curl -X POST \
http://localhost:8080/books \
-H 'Authorization: Bearer FAKE_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"title": "Test Book",
"author": "John Doe",
"published_at": "2003-01-01",
"description": "A sample book description",
"isbn": "1234567890",
"total_pages": 100
}'
结果:
{"error":"token is malformed: token contains an invalid number of segments"}
完美! 🎉
现在,你的部分端点已经被权限认证机制保护起来啦。