在本文中,我们将创建一个简单的 RESTful API 来使用 JWT 注册、登录和授权用户 ,首先让我们看看我们在这里尝试解决的问题。
问题和解决方案
问题
这里的问题很简单:您有一个由客户端(Web、移动应用程序、CLI 等)使用的 API,您想保护它并且只授权注册用户访问它。
解决方案
解决方案是使用 JSON Web 令牌(简称 JWT)来登录和授权用户,如下面的简单图片所示
- 客户端将通过将凭据发送到 API 服务器来登录用户。
- API 服务器将验证用户凭据,签署 JWT,并在 HTTP 响应中返回它。
- 客户端将使用接收到的 JWT 访问 API 资源。
- API 服务器将验证 JWT 并授权用户访问资源。
什么是 JWT?
JWT 或 JSON 网络令牌是一个数字签名的字符串,用于在各方之间安全地传输信息。 这是一个 RFC7519 标准。
JWT 由三部分组成:
header.payload.signature
下面是一个示例 JWT。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SfJ6SfyfQ.SfJsflwcsfjfqsfyfqmdiyfqsfj5kwiwibm
标题
标头是一个 Base64 编码的字符串,它包含令牌类型( JWT
在这种情况下)和签名算法( HMAC SHA256
在这种情况下,或 HS256
简称)。
{
"alg": "HS256",
"typ": "JWT"
}
有效载荷
负载是包含声明的 Base64 编码字符串。 声明是与用户和令牌本身相关的数据集合。 示例声明有:( exp
到期时间)、 iat
(发布时间)、 name
(用户名)和 sub
(主题)。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
签名
签名是一个有符号的字符串。 对于 HMAC 签名算法,我们使用 Base64 编码的标头、Base64 编码的有效负载和签名密钥来创建它。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
Golang 中的 JWT
现在我们对 JWT 有了更好的了解,让我们在 Golang 中创建我们的小型身份验证 API。
启动 Go 模块
在您的 中创建一个新目录 GOPATH
,调用它 authapp
,然后启动一个 Go 模块。
go mod init
数据库
为了便于理解,我将使用 SQLite 数据库。
为了在 Go 中处理 SQL 数据库,我强烈建议使用像 GORM 这样 的 ORM ,所以让我们将它与 SQLite 驱动程序一起安装。
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite
现在创建一个名为 database 的新文件夹,并使用此代码启动一个全局数据库对象。
// database/database.go
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// GlobalDB a global db object will be used across different packages
var GlobalDB *gorm.DB
// InitDatabase creates a sqlite db
func InitDatabase() (err error) {
GlobalDB, err = gorm.Open(sqlite.Open("auth.db"), &gorm.Config{})
if err != nil {
return
}
return
}
现在让我们测试一下。
// database/database_test.go
package database
import (
"testing"
)
func TestInitDB(t *testing.T) {
err := InitDatabase()
assert.NoError(t, err)
}
如果测试通过会输出以下信息:
我们的数据库就准备好了。让我们继续讨论用户模型。
用户模型
我们案例中的用户模型很简单:它有姓名、电子邮件和密码。
用户密码应该在数据库中散列,为了实现这一点,我们使用了很棒的 bcrypt 库。
go get golang.org/x/crypto/bcrypt
这是完整的 User 模型,其中包含在数据库中创建记录、散列和检查密码的函数。
// models/models.go
package models
import (
"authapp/database"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// User defines the user in db
type User struct {
gorm.Model
Name string `json:"name"`
Email string `json:"email" gorm:"unique"`
Password string `json:"password"`
}
// CreateUserRecord creates a user record in the database
func (user *User) CreateUserRecord() error {
result := database.GlobalDB.Create(&user)
if result.Error != nil {
return result.Error
}
return nil
}
// HashPassword encrypts user password
func (user *User) HashPassword(password string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
if err != nil {
return err
}
user.Password = string(bytes)
return nil
}
// CheckPassword checks user password
func (user *User) CheckPassword(providedPassword string) error {
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(providedPassword))
if err != nil {
return err
}
return nil
}
现在我们测试上面的所有代码。
// models/models_test.go
package models
import (
"authapp/database"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHashPassword(t *testing.T) {
user := User{
Password: "secret",
}
err := user.HashPassword(user.Password)
assert.NoError(t, err)
os.Setenv("passwordHash", user.Password)
}
func TestCreateUserRecord(t *testing.T) {
var userResult User
err := database.InitDatabase()
if err != nil {
t.Error(err)
}
err = database.GlobalDB.AutoMigrate(&User{})
assert.NoError(t, err)
user := User{
Name: "Test User",
Email: "test@email.com",
Password: os.Getenv("passwordHash"),
}
err = user.CreateUserRecord()
assert.NoError(t, err)
database.GlobalDB.Where("email = ?", user.Email).Find(&userResult)
database.GlobalDB.Unscoped().Delete(&user)
assert.Equal(t, "Test User", userResult.Name)
assert.Equal(t, "test@email.com", userResult.Email)
}
func TestCheckPassword(t *testing.T) {
hash := os.Getenv("passwordHash")
user := User{
Password: hash,
}
err := user.CheckPassword("secret")
assert.NoError(t, err)
}
现在让我们签署 JWT。
签署和验证 JWT
为了在 Golang 中签署和验证 JWT 令牌,我们将使用 jwt-go 包。
go get github.com/dgrijalva/jwt-go
现在让我们创建一个自定义包,我们可以在其中签名和验证令牌。
我简单地称它为 auth
,它具有以下结构和功能:
- 一个
JwtWrapper
结构来包装签名密钥,发行人,以及到期时间 - 一个
JwtClaim
结构来定制要求加入到令牌的参数 GenerateToken
使用 HS256 生成 24 小时后到期的令牌的函数ValidateToken
验证令牌并返回声明的函数
下面是完整的代码。
// auth/auth.go
package auth
import (
"errors"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
// JwtWrapper wraps the signing key and the issuer
type JwtWrapper struct {
SecretKey string
Issuer string
ExpirationHours int64
}
// JwtClaim adds email as a claim to the token
type JwtClaim struct {
Email string
jwt.StandardClaims
}
// GenerateToken generates a jwt token
func (j *JwtWrapper) GenerateToken(email string) (signedToken string, err error) {
claims := &JwtClaim{
Email: email,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Local().Add(time.Hour * time.Duration(j.ExpirationHours)).Unix(),
Issuer: j.Issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err = token.SignedString([]byte(j.SecretKey))
if err != nil {
return
}
return
}
//ValidateToken validates the jwt token
func (j *JwtWrapper) ValidateToken(signedToken string) (claims *JwtClaim, err error) {
token, err := jwt.ParseWithClaims(
signedToken,
&JwtClaim{},
func(token *jwt.Token) (interface{}, error) {
return []byte(j.SecretKey), nil
},
)
if err != nil {
return
}
claims, ok := token.Claims.(*JwtClaim)
if !ok {
err = errors.New("Couldn't parse claims")
return
}
if claims.ExpiresAt < time.Now().Local().Unix() {
err = errors.New("JWT is expired")
return
}
return
}
测试代码:
// auth/auth_test.go
package auth
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGenerateToken(t *testing.T) {
jwtWrapper := JwtWrapper{
SecretKey: "verysecretkey",
Issuer: "AuthService",
ExpirationHours: 24,
}
generatedToken, err := jwtWrapper.GenerateToken("jwt@email.com")
assert.NoError(t, err)
os.Setenv("testToken", generatedToken)
}
func TestValidateToken(t *testing.T) {
encodedToken := os.Getenv("testToken")
jwtWrapper := JwtWrapper{
SecretKey: "verysecretkey",
Issuer: "AuthService",
}
claims, err := jwtWrapper.ValidateToken(encodedToken)
assert.NoError(t, err)
assert.Equal(t, "jwt@email.com", claims.Email)
assert.Equal(t, "AuthService", claims.Issuer)
}
到此为止,现在我们已经有了我们的用户模型和 JWT 签名/验证逻辑,是时候创建实际的 API 了。
API
在本节中,我们将创建三个 RESTful API 端点
[POST] /api/public/signup
=> 创建用户[POST] /api/public/login
=> 用户登录并返回 JWT[GET] /api/private/profile
=> 授权用户并返回请求的数据
但在开始之前,我们需要安装很棒的 gin-gonic Web 框架。
go get -u github.com/gin-gonic/gin
现在让我们设置我们的主文件来创建一个数据库和 gin 路由器并启动服务器。
// main.go
package main
import (
"authapp/database"
"log"
"github.com/gin-gonic/gin"
)
func setupRouter() *gin.Engine {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
return r
}
func main() {
err := database.InitDatabase()
if err != nil {
log.Fatalln("could not create database", err)
}
database.GlobalDB.AutoMigrate(&models.User{})
r := setupRouter()
r.Run(":8080")
}
让我们来测试一下。
// main_test.go
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPingRoute(t *testing.T) {
router := setupRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/ping", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "pong", w.Body.String())
}
我们的 HTTP 路由器已准备就绪。 现在让我们创建注册控制器。
注册
注册是一个公共 API; 它不应该需要身份验证。
要创建 gin 控制器,请创建一个名为的包 controllers
和一个名为 public.go
.
请求是 POST。 我们应该获取有效负载并对其进行验证,将用户插入数据库,并返回插入的用户。
// controllers/public.go
package controllers
import (
"authapp/models"
"log"
"github.com/gin-gonic/gin"
)
// Signup creates a user in db
func Signup(c *gin.Context) {
var user models.User
err := c.ShouldBindJSON(&user)
if err != nil {
log.Println(err)
c.JSON(400, gin.H{
"msg": "invalid json",
})
c.Abort()
return
}
err = user.HashPassword(user.Password)
if err != nil {
log.Println(err.Error())
c.JSON(500, gin.H{
"msg": "error hashing password",
})
c.Abort()
return
}
err = user.CreateUserRecord()
if err != nil {
log.Println(err)
c.JSON(500, gin.H{
"msg": "error creating user",
})
c.Abort()
return
}
c.JSON(200, user)
}
登录
这里登录很简单。 注册用户只需提供电子邮件和用户名,我们将为他们签署令牌。
// controllers/public.go
// LoginPayload login body
type LoginPayload struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse token response
type LoginResponse struct {
Token string `json:"token"`
}
// Login logs users in
func Login(c *gin.Context) {
var payload LoginPayload
var user models.User
err := c.ShouldBindJSON(&payload)
if err != nil {
c.JSON(400, gin.H{
"msg": "invalid json",
})
c.Abort()
return
}
result := database.GlobalDB.Where("email = ?", payload.Email).First(&user)
if result.Error == gorm.ErrRecordNotFound {
c.JSON(401, gin.H{
"msg": "invalid user credentials",
})
c.Abort()
return
}
err = user.CheckPassword(payload.Password)
if err != nil {
log.Println(err)
c.JSON(401, gin.H{
"msg": "invalid user credentials",
})
c.Abort()
return
}
jwtWrapper := auth.JwtWrapper{
SecretKey: "verysecretkey",
Issuer: "AuthService",
ExpirationHours: 24,
}
signedToken, err := jwtWrapper.GenerateToken(user.Email)
if err != nil {
log.Println(err)
c.JSON(500, gin.H{
"msg": "error signing token",
})
c.Abort()
return
}
tokenResponse := LoginResponse{
Token: signedToken,
}
c.JSON(200, tokenResponse)
return
}
让我们测试注册和登录。
// controllers/public_test.go
package controllers
import (
"authapp/database"
"authapp/models"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestSignUp(t *testing.T) {
var actualResult models.User
user := models.User{
Name: "Test User",
Email: "jwt@email.com",
Password: "secret",
}
payload, err := json.Marshal(&user)
assert.NoError(t, err)
request, err := http.NewRequest("POST", "/api/public/signup", bytes.NewBuffer(payload))
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
err = database.InitDatabase()
assert.NoError(t, err)
database.GlobalDB.AutoMigrate(&models.User{})
Signup(c)
assert.Equal(t, 200, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &actualResult)
assert.NoError(t, err)
assert.Equal(t, user.Name, actualResult.Name)
assert.Equal(t, user.Email, actualResult.Email)
}
func TestSignUpInvalidJSON(t *testing.T) {
user := "test"
payload, err := json.Marshal(&user)
assert.NoError(t, err)
request, err := http.NewRequest("POST", "/api/public/signup", bytes.NewBuffer(payload))
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
Signup(c)
assert.Equal(t, 400, w.Code)
}
func TestLogin(t *testing.T) {
user := LoginPayload{
Email: "jwt@email.com",
Password: "secret",
}
payload, err := json.Marshal(&user)
assert.NoError(t, err)
request, err := http.NewRequest("POST", "/api/public/login", bytes.NewBuffer(payload))
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
err = database.InitDatabase()
assert.NoError(t, err)
database.GlobalDB.AutoMigrate(&models.User{})
Login(c)
assert.Equal(t, 200, w.Code)
}
func TestLoginInvalidJSON(t *testing.T) {
user := "test"
payload, err := json.Marshal(&user)
assert.NoError(t, err)
request, err := http.NewRequest("POST", "/api/public/login", bytes.NewBuffer(payload))
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
Login(c)
assert.Equal(t, 400, w.Code)
}
func TestLoginInvalidCredentials(t *testing.T) {
user := LoginPayload{
Email: "jwt@email.com",
Password: "invalid",
}
payload, err := json.Marshal(&user)
assert.NoError(t, err)
request, err := http.NewRequest("POST", "/api/public/login", bytes.NewBuffer(payload))
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
err = database.InitDatabase()
assert.NoError(t, err)
database.GlobalDB.AutoMigrate(&models.User{})
Login(c)
assert.Equal(t, 401, w.Code)
database.GlobalDB.Unscoped().Where("email = ?", user.Email).Delete(&models.User{})
}
我们一直在命令行进行测试,我们可以创建我们的路由并在 Postman 中测试。
将以下代码添加到 main.go
文件中。
// main.go
func setupRouter() *gin.Engine {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
//here
api := r.Group("/api")
{
public := api.Group("/public")
{
public.POST("/login", controllers.Login)
public.POST("/signup", controllers.Signup)
}
}
return r
}
现在运行服务器。
go run main.go
让我们在 Postman 中测试一下。
现在,用户可以创建帐户、登录并接收令牌。
让我们创建一个受保护的路由,只有经过身份验证的用户才能访问它。
受保护的资源
此处的资源将是用户配置文件。 这很简单:它将用户数据返回给客户端。
// controllers/protected.go
package controllers
import (
"authapp/database"
"authapp/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Profile returns user data
func Profile(c *gin.Context) {
var user models.User
email, _ := c.Get("email") // from the authorization middleware
result := database.GlobalDB.Where("email = ?", email.(string)).First(&user)
if result.Error == gorm.ErrRecordNotFound {
c.JSON(404, gin.H{
"msg": "user not found",
})
c.Abort()
return
}
if result.Error != nil {
c.JSON(500, gin.H{
"msg": "could not get user profile",
})
c.Abort()
return
}
user.Password = ""
c.JSON(200, user)
return
}
授权用户和验证令牌应该在中间件中进行。
现在,让我们测试一下配置文件控制器。
// controllers/protected_test.go
package controllers
import (
"authapp/database"
"authapp/models"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestProfile(t *testing.T) {
var profile models.User
err := database.InitDatabase()
assert.NoError(t, err)
database.GlobalDB.AutoMigrate(&models.User{})
user := models.User{
Email: "jwt@email.com",
Password: "secret",
Name: "Test User",
}
err = user.HashPassword(user.Password)
assert.NoError(t, err)
err = user.CreateUserRecord()
assert.NoError(t, err)
request, err := http.NewRequest("GET", "/api/protected/profile", nil)
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
c.Set("email", "jwt@email.com")
Profile(c)
err = json.Unmarshal(w.Body.Bytes(), &profile)
assert.NoError(t, err)
assert.Equal(t, 200, w.Code)
log.Println(profile)
assert.Equal(t, user.Email, profile.Email)
assert.Equal(t, user.Name, profile.Name)
}
func TestProfileNotFound(t *testing.T) {
var profile models.User
err := database.InitDatabase()
assert.NoError(t, err)
database.GlobalDB.AutoMigrate(&models.User{})
request, err := http.NewRequest("GET", "/api/protected/profile", nil)
assert.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = request
c.Set("email", "notfound@email.com")
Profile(c)
err = json.Unmarshal(w.Body.Bytes(), &profile)
assert.NoError(t, err)
assert.Equal(t, 404, w.Code)
database.GlobalDB.Unscoped().Where("email = ?", "jwt@email.com").Delete(&models.User{})
}
授权中间件
中间件位于客户端和资源之间,因此在我们访问数据库之前,将调用中间件来验证令牌并授权用户。 授权逻辑非常简单:
- 检查授权标头中是否存在 JWT。
- 检查令牌格式。
- 验证令牌。
- 继续到控制器。
创建一个名为的包 middlewares
并添加以下代码。
// middlewares/authz.go
package middlewares
import (
"authapp/auth"
"strings"
"github.com/gin-gonic/gin"
)
// Authz validates token and authorizes users
func Authz() gin.HandlerFunc {
return func(c *gin.Context) {
clientToken := c.Request.Header.Get("Authorization")
if clientToken == "" {
c.JSON(403, "No Authorization header provided")
c.Abort()
return
}
extractedToken := strings.Split(clientToken, "Bearer ")
if len(extractedToken) == 2 {
clientToken = strings.TrimSpace(extractedToken[1])
} else {
c.JSON(400, "Incorrect Format of Authorization Token")
c.Abort()
return
}
jwtWrapper := auth.JwtWrapper{
SecretKey: "verysecretkey",
Issuer: "AuthService",
}
claims, err := jwtWrapper.ValidateToken(clientToken)
if err != nil {
c.JSON(401, err.Error())
c.Abort()
return
}
c.Set("email", claims.Email)
c.Next()
}
}
测试代码:
// middlewares/authz_test.go
package middlewares
import (
"authapp/auth"
"authapp/controllers"
"authapp/database"
"authapp/models"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestAuthzNoHeader(t *testing.T) {
router := gin.Default()
router.Use(Authz())
router.GET("/api/protected/profile", controllers.Profile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/protected/profile", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 403, w.Code)
}
func TestAuthzInvalidTokenFormat(t *testing.T) {
router := gin.Default()
router.Use(Authz())
router.GET("/api/protected/profile", controllers.Profile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/protected/profile", nil)
req.Header.Add("Authorization", "test")
router.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
}
func TestAuthzInvalidToken(t *testing.T) {
invalidToken := "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
router := gin.Default()
router.Use(Authz())
router.GET("/api/protected/profile", controllers.Profile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/protected/profile", nil)
req.Header.Add("Authorization", invalidToken)
router.ServeHTTP(w, req)
assert.Equal(t, 401, w.Code)
}
func TestValidToken(t *testing.T) {
var response models.User
err := database.InitDatabase()
assert.NoError(t, err)
err = database.GlobalDB.AutoMigrate(&models.User{})
assert.NoError(t, err)
user := models.User{
Email: "test@email.com",
Password: "secret",
Name: "Test User",
}
jwtWrapper := auth.JwtWrapper{
SecretKey: "verysecretkey",
Issuer: "AuthService",
ExpirationHours: 24,
}
token, err := jwtWrapper.GenerateToken(user.Email)
assert.NoError(t, err)
err = user.HashPassword(user.Password)
assert.NoError(t, err)
result := database.GlobalDB.Create(&user)
assert.NoError(t, result.Error)
router := gin.Default()
router.Use(Authz())
router.GET("/api/protected/profile", controllers.Profile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/protected/profile", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
err = json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "test@email.com", response.Email)
assert.Equal(t, "Test User", response.Name)
database.GlobalDB.Unscoped().Where("email = ?", user.Email).Delete(&models.User{})
}
现在让我们在主路由器中添加一条正确的路由,并在 Postman 中自己查看结果。
// main.go
package main
import (
"authapp/controllers"
"authapp/database"
"authapp/middlewares"
"authapp/models"
"log"
"github.com/gin-gonic/gin"
)
func setupRouter() *gin.Engine {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
api := r.Group("/api")
{
public := api.Group("/public")
{
public.POST("/login", controllers.Login)
public.POST("/signup", controllers.Signup)
}
// here
protected := api.Group("/protected").Use(middlewares.Authz())
{
protected.GET("/profile", controllers.Profile)
}
}
return r
}
文章到此结束。
参考文章:https://betterprogramming.pub/hands-on-with-jwt-in-golang-8c986d1bb4c0
观摩大佬