Go jwt
1 跨域认证问题
传统的 session 的认证流程如下:
- 用户向服务器发送用户名和密码
- 服务器验证通过后,将相关信息保存至 session 中
- 服务器将 session ID 返回给客户端并存储至 cookie 中
- 用户请求时会附上 session ID,服务器会根据 session ID 来得知用户身份
1.1 传统 session 认证的问题
Session: 每个用户经过服务认证之后,会将 session 信息保存至内存中,当用户的数量增加时服务器的负荷会增大
扩展性(Scaling): 在分布式系统中,用户需在保存其 session 信息的服务器中才可以获取授权,限制了应用的扩展能力
CSRF: 因为是基于 cokkie 进行识别的,若 cokkie 被截获,用户容易受到跨站请求攻击
1.2 基于 Token 的鉴权机制
基于 token 的认证和传统的 session 方式不同,token 信息存放在客户端而不是服务端
其认证流程如下:
- 用户向服务器发送用户名和密码
- 服务器验证用户信息
- 验证通过后向用户签发 token
- 用户请求时附上 token,服务器验证 token 来进行授权
token 在每次请求时发送给服务端,其应放在请求头中并要求服务端支持 CORS(跨域资源共享)
2. JWT
2.1 What is JSON Web Token
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON objec. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a pulic/private key pair using RSA or ECDSA
Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signedt tokens can verify the integrity of the claims contained within it, while encryted tokens hide those claimes from other parties. When tokens are signed usign pulic/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.
2.2 When should you use JSON Web Tokens
Here are some scenarios where JSON Web Tokens are useful:
- Authorization: This is the most common scenario for using JWT. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token. Single Sign On is a feature that widely uses JWT nowadays, because of its samll overhead and its ability to be easily used across different domains.
- Information Exchange: JSON Web Tokens are a good way of securely transmitting information between parties. Because JWTs can be signed, for example, using pulic/private key pairs you can be sure the senders are who they say they are. Addtionally, as hte signature is calculated using the header and the payload, you can also verify that the content hasn’t been tampered with.
2.3 What is the JSON Web Token structure
In its compact form, JSON Web Tokens consist of three parts separated by dots .
, which are :
- Header
- Payload
- Signature
Therefore, a JWT typically looks like the following:
xxxx.yyyy.zzzz
2.3.1 Header
The header typically consists of two prats: the type of the token, which is JWT, and the signing algorithm being usesd, such as HMAC SHA256 or RSA
{
"alg": "HS256",
"typ": "JWT"
}
Then, this JSON is Base64Url encoded to form the first part of the JWT
2.3.2 Payload
The second part of the token is the payload, which contains the claims. Claims are statements about an entity (typically, the user) and additional data. There are three types of claims: registered,public, and private claims
Registered claims: These are a set of predefined claims which are not mandantory but recommended, to provide a set of useful, interoperable claims. Some of them are:
- iss: issuer
- exp: expiration time
- sub: subject
- aud: audience
- others
Notice that the claim names are only three characters long as JWT is meant to be compact
Public claims: These can be defined at will by those using JWTs. But to avoid collisions they should be defined in the IANA JSON Web Token Registry or be defined as a URI that contains a collision resistant namespace
Private claims: These are the custom claims created to share informaion between parties that agree on using them and are neither registered or public claims
An example payload could be :
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
The payload is then Base64Url encoded to form the second part of the JSON Web Token
NOTE
Do note that for signed tokens this information, though protected against tampering, is readable by anyone. Do not put secret information in the payload or header elements of a JWT unless it is encrypted.
2.3.3 Signature
To create the signature part you have to take the encoded header, the encoded payload, a secret, the algorithm specified in the header, and sign that
For example if you want to use the HMAC SHA256 algorithm, the signature will be created in the following way:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
The signature is used to verify the message wasn’t changed along the way, and , in the case of tokens signed with a private key, it can also verify that the sender of the JWT is who it says it is.
2.3.4 Putting all together
The output is three Base64-URL strings separated by dots that can be easily passed in HTML and HTTP environments, while being more compact when compared to XML-based standards such as SAML
The following shows a JWT that has the previous header and payload encoded, and it is singed with a secret
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VybmFtZSI6Imtlc2EiLCJQYXNzd29yZCI6IjEyMyIsImlzcyI6Imtlc2EiLCJleHAiOjE2Mzg1MjE2OTl9.PECoPr7AxpmPFTdS8E-hn46xU5D065mklVGxODmYMiQ
2.4 How do JSON Web Tokens work
In authentication, when the user successfully logs in using their credentials, a JSON Web Token will be returned. Since tokens are credentials, great care must be taken to prevent security issues. In general, you should not keep tokens longer than required.
You also should not store sensitive session data in browser storage due to lack of security.
Whenever the user wants to access a protected route or resource, the user agent should send the JWT, typically in the Authorization header using the Bearer schema. The content of the header should look like the following:
Authorization: Bearer <token>
This can be, in certain cases, a stateless authorization mechanism. The server's protected routes will check for a valid JWT in the Authorization
header, and if it's present, the user will be allowed to access protected resources. If the JWT contains the necessary data, the need to query the database for certain operations may be reduced, though this may not always be the case.
If the token is sent in the Authorization
header, Cross-Origin Resource Sharing (CORS) won't be an issue as it doesn't use cookies.
The following diagram shows how a JWT is obtained and used to access APIs or resources:
- The application or client requests authorization to the authorization server. This is performed through one of the different authorization flows. For example, a typical OpenID Connect compliant web application will go through the
/oauth/authorize
endpoint using the authorization code flow. - When the authorization is granted, the authorization server returns an access token to the application.
- The application uses the access token to access a protected resource (like an API).
Do note that with signed tokens, all the information contained within the token is exposed to users or other parties, even though they are unable to change it. This means you should not put secret information within the token.
###2.5 Why should we use JSON Web Tokens?
Let's talk about the benefits of JSON Web Tokens (JWT) when compared to Simple Web Tokens (SWT) and Security Assertion Markup Language Tokens (SAML).
As JSON is less verbose than XML, when it is encoded its size is also smaller, making JWT more compact than SAML. This makes JWT a good choice to be passed in HTML and HTTP environments.
Security-wise, SWT can only be symmetrically signed by a shared secret using the HMAC algorithm. However, JWT and SAML tokens can use a public/private key pair in the form of a X.509 certificate for signing. Signing XML with XML Digital Signature without introducing obscure security holes is very difficult when compared to the simplicity of signing JSON.
JSON parsers are common in most programming languages because they map directly to objects. Conversely, XML doesn't have a natural document-to-object mapping. This makes it easier to work with JWT than SAML assertions.
Regarding usage, JWT is used at Internet scale. This highlights the ease of client-side processing of the JSON Web token on multiple platforms, especially mobile.
Comparison of the length of an encoded JWT and an encoded SAML
3. Jwt-go
在了解完 JWT 及其使用场景之后,这里采用一个示例来具体实践下
代码可以在这里找到go-jwt-note
下面的例子将创建一个 application 简单实现登录,接口权限验证功能
3.1 目录结构
基于 go mod 初始化 application
go mod init go-jwt-note
创建如下目录
go-jwt
├── api
│ └── v1
├── initialize
├── middleware
├── routers
├── utils
├── global
├── go.mod
└── main.go
api/v1
: RESTFul API 接口,v1 为 version 1 ,也可以不用指定版本initialize
: application 初始化,如 gin,redis 等middleware
: 中间件routers
: 路由utils
: 工具包global
: 全局变量main.go
: application 入口
3.2 Login
首先创建 RESTFul API
引入 Gin Framework 和 endless (用于优雅启动应用)
go get -u github.com/gin-gonic/gin github.com/fvbock/endless
在 initilize
下创建 server.go
func Run() {
router := gin.Default()
publicGroup := router.Group("")
routers.InintializePulicGroup(publicGroup)
server := endless.NewServer(":9090",router)
server.ListenAndServe()
}
publicGroup
: 公共路由组,无需鉴权privateGroup
: 私有路由组,需要鉴权InintializePulicGroup
: 初始化公共路由组,注册路由
在 api/v1
下创建 public.go
func Ping(c *gin.Context) {
c.String(http.StatusOK,"pong")
}
func Login(c *gin.Context) {
name := c.PostForm("username")
pass := c.PostForm("password")
if name != "kesa" || pass != "123" {
c.String(http.StatusUnauthorized,"please provide valid login details")
return
}
token,err := utils.GenerateToken(name)
if err != nil {
c.String(http.StatusUnprocessableEntity,"generate token error")
return
}
c.JSON(http.StatusOK,gin.H{
"token": token,
})
}
上例流程如下:
- 从 post form 中获取登录信息
- 验证登录信息是否正确,这里简单起见使用固定用户名和密码,一般会去持久层查询后验证
- 使用用户名和密码生成 jwt
- 将 jwt 返回给客户端
引入 go-jwt , 用于生成和解析 jwt
go get -u github.com/golang-jwt/jwt/v4
在 utils
下创建 jwt.go
, 定义生成 jwt 的函数
const (
secret = "my-secret"
)
var (
ErrGenerateToken = errors.New("generate token error")
)
type UserClaims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateToken(username string) (string,error) {
expireTime := time.Now().Add(15 * time.Minute)
claims := UserClaims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "jwt-demo",
ExpiresAt: jwt.NewNumericDate(expireTime),
},
}
token,err := jwt.NewWithClaims(jwt.SingingMethodHS256,claims).SingedString([]byte(secret))
if err != nil {
log.Printf("Generate token failed for user: %s ,err: %s",username,err.Error())
return "",ErrGenerateToken
}
return token,nil
}
在 routers
下创建 public.go
func InitializePublicGroup(router *gin.RouterGroup) {
publicRouter := router.Group("/public")
{
publicRouter.GET("/ping",v1.Ping)
publicRouter.POST("/login",v1.Login)
}
}
修改 main.go
func main() {
initilize.Run()
}
目录结构如下:
go-jwt
├── api
│ └── v1
│ └── public.go
├── initialize
│ └── server.go
├── middleware
├── routers
│ └── public.go
├── utils
│ └── jwt.go
├── global
├── go.mod
├── go.sum
└── main.go
Run and test
$ curl 'http://localhost:9090/public/ping'
pong
$ curl -X POST 'http://localhost:9090/public/login' -d 'username=kesa&&password=123'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VybmFtZSI6Imtlc2EiLCJpc3MiOiJqd3QtZGVtbyIsImV4cCI6MTYzODY3ODMyM30.FSmWi3ew6QdhzB87TLXeZQVp9bAEBaIj_bdB6CN3XbQ"}
生成 jwt 成功, 可以在 jwt.io 上解析 token 验证
3.3 Loopholes
至此,我们已经实现了登录并返回 jwt 到客户端的功能,但是会有以下问题:
- 生成的 jwt 只会在到达过期时间时才会生效,若用户登录之后在有效时间内登出,jwt 是不会马上失效的
- jwt 会被攻击者劫持并利用,而用户在 jwt 失效之前无法进行处理
- 用户在 jwt 失效之后将需要重新登录,使得用户体验很差
我们分两步来解决这些问题:
- 使用持久层来保存 jwt ,这样可以使得我们可以在用户登出时主动使 jwt 失效,并且能够提高安全性
- 使用 refresh token 来重新生成 access token ,在 access token 失效后自动生成以提高用户体验 只有在 refresh token 失效时用户才需要重新登录
3.4 Using redis
这里推荐使用 redis 作为持久层来保存 jwt,因为 jwt 拥有失效时间的特性,而 redis 可以设置 key 的失效时间,可以在 jwt 失效时将其删除, 并且 redis 读写性能较高适合用于保存 jwt
因为 redis 以 key-value 形式存储数据, key 必须是唯一的,这里我们使用 UUID 来作为 key
引入 redis-go 和 uuid
go get -u github.com/google/uuid github.com/go-redis/redis/v8
在 global
下创建 global.go
var (
RedisDB *redis.Client
)
在 initilize
下创建 redis.go
var ctx = context.Background()
func Redis() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
if _, err := rdb.Ping(ctx).Result(); err != nil {
log.Fatalln("failed to connect redis: ", err.Error())
}
log.Println("Connect redis success")
global.RedisDB = rdb
}
在 utils/jwt
中定义新的 struct
包含 access token 和 refresh token 及其 失效时间
重写 GenerateToken
函数
type tokenDetails struct {
AccessToken string
RefreshToken string
AccessUUID string
RefreshUUID string
// access token expires time
AtExpires time.Time
// refresh token expires time
RtExpires time.Time
}
func GenerateToken(username string) (*tokenDetails, error) {
rtExpiresTime := time.Now().Add(7 * 24 * time.Hour)
atExpireTime := time.Now().Add(15 * time.Minute)
atUUID := uuid.New().String()
rtUUID := uuid.New().String()
accessToken, err := createToken(username, "access", atUUID, atExpireTime)
if err != nil {
return nil, ErrGenerateToken
}
refreshToken, err := createToken(username, "refresh", rtUUID, rtExpiresTime)
if err != nil {
return nil, ErrGenerateToken
}
td := &tokenDetails{
AccessToken: accessToken,
RefreshToken: refreshToken,
AccessUUID: atUUID,
RefreshUUID: rtUUID,
AtExpires: atExpireTime,
RtExpires: rtExpiresTime,
}
return td, nil
}
func createToken(username, tokenType, tokenUUID string, expiresTime time.Time) (string, error) {
claims := UserClaims{
Username: username,
UUID: tokenUUID,
TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "jwt-demo",
ExpiresAt: jwt.NewNumericDate(expiresTime),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
log.Printf("Generate token for user: %s, err: %s", username, err.Error())
return "", err
}
return token, nil
}
上述函数分别生成了 access token 和 refresh token 并将 access token 超市设为 15 分钟, refresh token 超时设为 7 天
创建 utils/redis.go
var ctx = context.Background()
var (
ErrSaveToken = errors.New("save token error")
)
func SaveUserTokens(username string, td *tokenDetails) error {
rdb := global.RedisDB
now := time.Now()
atExp := td.AtExpires.Sub(now)
rtExp := td.RtExpires.Sub(now)
err := rdb.Set(ctx, td.AccessUUID, username, atExp).Err()
if err != nil {
log.Println("Save access token failed: ", err.Error())
return ErrSaveToken
}
err = rdb.Set(ctx, td.RefreshUUID, username, rtExp).Err()
if err != nil {
log.Println("Save refresh token failed: ", err.Error())
return ErrSaveToken
}
return nil
}
将 token 对应的 UUID 作为 key, username 作为 value (一般使用 user ID 这里简单使用 username),超时时间作为 key 的过期时间, 过期后将删除
修改 api/v1/public
的 Login
函数
新增 UserTokens
结构体
type UserTokens struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
func Login(c *gin.Context) {
name := c.PostForm("username")
pass := c.PostForm("password")
if name != "kesa" || pass != "123" {
c.String(http.StatusUnauthorized, "please provide valid login details")
return
}
td, err := utils.GenerateToken(name)
if err != nil {
c.String(http.StatusUnprocessableEntity, err.Error())
return
}
err = utils.SaveUserTokens(name, td)
if err != nil {
c.String(http.StatusUnprocessableEntity, err.Error())
return
}
tokens := UserTokens{
AccessToken: td.AccessToken,
RefreshToken: td.RefreshToken,
}
c.JSON(http.StatusOK, tokens)
}
Login
函数获取 access token 和 refresh token 相关信息后存储至 redis 中,并将两个 token 返回给客户端
Run and test
$ curl -X POST 'http://localhost:9090/public/login' -d 'username=kesa&&password=123'
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imtlc2EiLCJ1dWlkIjoiN2ZlOTk5MjktNzcyZi00NjcwLWIxYTAtNjM3YjdiNzQxMTAzIiwidHlwZSI6ImFjY2VzcyIsImlzcyI6Imp3dC1kZW1vIiwiZXhwIjoxNjM4Njg4MDkzfQ.KYchLZsv46c2EX0OS_WBqs5vHHArGa716Rn1WFQM2q4","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imtlc2EiLCJ1dWlkIjoiYjVhMjVmMjctMzllZC00NTAyLTg2YWUtZTY5OWIyZmYyNWJkIiwidHlwZSI6InJlZnJlc2giLCJpc3MiOiJqd3QtZGVtbyIsImV4cCI6MTYzOTI5MTk5M30.FQhlMVj7ZjAqZek2cIXET6zjttWdfxPOtkGH6XmfLjQ"}
当前目录结构
go-jwt
├── api
│ └── v1
│ └── public.go
├── global
│ └── global.go
├── initialize
│ ├── redis.go
│ └── server.go
├── middleware
├── routers
│ └── public.go
├── utils
│ ├── jwt.go
│ └── redis.go
├── go.mod
├── go.sum
└── main.go
至此,已经完成了登录获取 token 的流程,接下来将通过自定中间件来实现路由验证
3.5 Verify Token
服务器已经将生成 token 返回给了客户端,而客户端将会在 request Headers 的 Authorization
附上验证用的 token 来验证
utils/redis.go
新增获取用户信息(本例中只有用户名)
var (
ErrSaveToken = errors.New("save token error")
ErrFetchToken = errors.New("fetch token error")
)
func FetchAuth(details *AuthDetails) (string, error) {
username, err := global.RedisDB.Get(ctx, details.UUID).Result()
if err != nil {
log.Println("Get token failed: ", err.Error())
return "", ErrFetchToken
}
return username, nil
}
利用 token 对应的 UUID 来获取用户信息
utils/jwt.go
中新增 token 解析和验证
func ParseToken(token string) (string, error) {
tokenClaims, err := jwt.ParseWithClaims(token, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
log.Println("Parse token failed: ", err.Error())
return "", ErrParseToken
}
claims, ok := tokenClaims.Claims.(*UserClaims)
if !ok {
return "", ErrParseToken
}
username, err := FetchAuth(claims.UUID)
if err != nil {
return "", err
}
return username, nil
}
创建 middleware/jwt.go
func Jwt() gin.HandlerFunc {
return func(c *gin.Context) {
token := utils.ExtractToken(c)
log.Println("Token: ", token)
userClaims, err := utils.ParseToken(token)
if err != nil {
c.String(http.StatusForbidden, "unauthorized")
c.Abort()
return
}
err = utils.FetchAuth(userClaims.UUID)
if err != nil {
c.String(http.StatusForbidden, "unauthorized")
c.Abort()
return
}
c.Set("userClaims", userClaims)
c.Next()
}
}
c.Set("username", username)
: 设置变量交由下游路由处理
utils/jwt.go
新增提取 token
func ExtractToken(c *gin.Context) string {
authHeader := c.GetHeader("Authorization")
token := strings.TrimPrefix(authHeader, "Bearer ")
return token
}
api/v1/user.go
func Welcome(c *gin.Context) {
userClaims, _ := c.MustGet("userClaims").(*utils.UserClaims)
c.String(http.StatusOK, "Welcome ! %s", userClaims.Username)
}
router/user.go
注册路由
func InitializeUserRouter(router *gin.RouterGroup) {
userRouter := router.Group("/user")
{
userRouter.GET("/welcome", v1.Welcome)
}
}
initilize/server.go
创建私有路由组,使用中间件
func Run() {
router := gin.Default()
// public router
publicGroup := router.Group("")
routers.InitializePublicGroup(publicGroup)
// private router, needs authorization
privateGroup := router.Group("")
privateGroup.Use(middleware.Jwt())
routers.InitializeUserRouter(privateGroup)
server := endless.NewServer(":9090", router)
if err := server.ListenAndServe(); err != nil {
log.Printf("Start server failed: %s", err.Error())
}
}
当前目录结构
go-jwt
├── api
│ └── v1
│ ├── public.go
│ └── user.go
├── global
│ └── global.go
├── initialize
│ ├── redis.go
│ └── server.go
├── middleware
│ └── jwt.go
├── routers
│ ├── public.go
│ └── user.go
├── utils
│ ├── jwt.go
│ └── redis.go
├── go.mod
├── go.sum
└── main.go
Run and test
$ curl -X POST -d 'username=kesa&&password=123' 'http://localhost:9090/public/login'
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imtlc2EiLCJ1dWlkIjoiMzY5ZWE1OGYtMjYzYS00M2FmLTlkMTItNmUzMGM2MWM5YzdmIiwidHlwZSI6ImFjY2VzcyIsImlzcyI6Imp3dC1kZW1vIiwiZXhwIjoxNjM4Njk0ODE0fQ.KnRmoZYZV2QlbMSSXdaTRLVSml4tULCwmQ9bHrlDIR8","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imtlc2EiLCJ1dWlkIjoiNjVlNGJhYzItMDM4My00NGY3LWE5ODMtNjQ2ZjNlZDVjOWM2IiwidHlwZSI6InJlZnJlc2giLCJpc3MiOiJqd3QtZGVtbyIsImV4cCI6MTYzODY5NTA1NH0.7hY1hj3zsYOX4fvI8OtRsQa2tPl4PzADaLyM9vnYMpc"}
$ curl 'http://localhost:9090/user/welcome' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imtlc2EiLCJ1dWlkIjoiMzY5ZWE1OGYtMjYzYS00M2FmLTlkMTItNmUzMGM2MWM5YzdmIiwidHlwZSI6ImFjY2VzcyIsImlzcyI6Imp3dC1kZW1vIiwiZXhwIjoxNjM4Njk0ODE0fQ.KnRmoZYZV2QlbMSSXdaTRLVSml4tULCwmQ9bHrlDIR8'
Welcome ! kesa
3.6 Logout
前面我们已经实现了登录获取 jwt 并且将其用于鉴权路由,接下来实现用户登出的功能
utils/redis.go
,定义函数删除保存的用户 access token
func RemoveAuth(tokenUUID string) (int64, error) {
deleted, err := global.RedisDB.Del(ctx, tokenUUID).Result()
if err != nil {
log.Println("Remove token failed: ", err.Error())
return 0, ErrRemoveToken
}
log.Printf("%d keys deleted", deleted)
return deleted, nil
}
api/v1/user.go
新增 Logout
func Logout(c *gin.Context) {
userClaims, _ := c.MustGet("userClaims").(*utils.UserClaims)
deleted, err := utils.RemoveAuth(userClaims.UUID)
if err != nil || deleted == 0 {
c.String(http.StatusUnauthorized, "unauthorized")
return
}
c.String(http.StatusOK, "Logout success")
}
routers/user.go
注册路由
userRouter.GET("/logout", v1.Logout)
3.7 Refreshing token
至此我们已经完成了 access token 的生成和使用以及用户的登出,但是用户在登出后手动登录或者在 access token 失效后需要用户手动进行登录,为了提升用户体验我们可以使用 refresh token 来实现自动登录
api/v1/public.go
func RefreshToken(c *gin.Context) {
token := utils.ExtractToken(c)
userClaims, err := utils.ParseToken(token)
if err != nil {
c.String(http.StatusUnauthorized, err.Error())
return
}
err = utils.FetchAuth(userClaims.UUID)
if err != nil {
c.String(http.StatusUnauthorized, err.Error())
return
}
// refresh token valid
// delete previous refresh token
name := userClaims.Username
deleted, err := utils.RemoveAuth(userClaims.UUID)
if err != nil || deleted == 0 {
c.String(http.StatusUnauthorized, err.Error())
return
}
// generate new access token and refresh token
td, err := utils.GenerateToken(name)
if err != nil {
c.String(http.StatusUnprocessableEntity, err.Error())
return
}
err = utils.SaveUserTokens(name, td)
if err != nil {
c.String(http.StatusUnprocessableEntity, err.Error())
return
}
tokens := UserTokens{
AccessToken: td.AccessToken,
RefreshToken: td.RefreshToken,
}
c.JSON(http.StatusOK, tokens)
}
流程如下
- 判断 refresh token 是否有效,失效则无法刷新,需要用户重新登录
- 删除之前的 refresh token
- 重新生成新的 access token 和 refresh token
routers/public.go
注册路由
publicRouter.GET("/refresh", v1.RefreshToken)
至此,我们的 go-jwt 的示例应用就完成了
这里以调用user/welcome
接口整理以下流程图
4. Deploy to Docker
完成 application 之后将其部署至 docker
在编写 DockerFile 之前先看下 golang 镜像的大小
golang alpine 6f9d081b1170 36 hours ago 315MB
golang latest d939cc1fb139 4 weeks ago 941MB
官方镜像接近 1GiB, 而 alpine 也有 315MB,对 golang application 来说不需要这么大的镜像(这些镜像包含各种编译环境和库)
我们可以使用 scratch 构建或者使用 GoogleContainerTools/**distroless**来构建
这里采用 docker 官方推荐的 distroless 来进行构建
下面是两种镜像的介绍
Documentation for
gcr.io/distroless/base
andgcr.io/distroless/static
Image Contents
This image contains a minimal Linux, glibc-based system. It is intended for use directly by "mostly-statically compiled" languages like Go, Rust or D.
Statically compiled applications (Go) that do not require libc can use the
gcr.io/distroless/static
image, which contains:
- ca-certificates
- A /etc/passwd entry for a root user
- A /tmp directory
- tzdata
Most other applications (and Go apps that require libc/cgo) should start with
gcr.io/distroless/base
, which contains all of the packages ingcr.io/distroless/static
, and
- glibc
- libssl
- openssl
Usage
Users are expected to include their compiled application and set the correct cmd in their image.
4.1 Proxy
因为 gcr.io
属于 Google 服务,之前配置的网易镜像适用于 dockerhub
这里直接配置 docker 代理
Create a systemd drop-in directory for the docker service:
$ sudo mkdir -p /etc/systemd/system/docker.service.d
Create a file named
/etc/systemd/system/docker.service.d/http-proxy.conf
that adds theHTTP_PROXY
environment variable:[Service] Environment="HTTP_PROXY=http://proxy.example.com:80"
If you are behind an HTTPS proxy server, set the
HTTPS_PROXY
environment variable:[Service] Environment="HTTPS_PROXY=https://proxy.example.com:443"
Multiple environment variables can be set; to set both a non-HTTPS and a HTTPs proxy;
[Service] Environment="HTTP_PROXY=http://proxy.example.com:80" Environment="HTTPS_PROXY=https://proxy.example.com:443"
Flush changes and restart Docker
$ sudo systemctl daemon-reload $ sudo systemctl restart docker
Verify that the configuration has been loaded and matches the changes you made, for example:
$ sudo systemctl show --property=Environment docker
上述配置是针对 docker 本身的网络代理,下面配置针对容器的网络代理
创建 ~/.docker/config.json
{
"default": {
"proxies": {
"httpProxy": "http://127.0.0.1:7890",
"httpsProxy": "http://127.0.0.1:7890"
}
}
}
启动容器并查看环境变量
$ docker run -it centos /bin/bash
[root@44baac59b55d /]# env
HTTP_PROXY=http://127.0.0.1:7890
https_proxy=http://127.0.0.1:7890
http_proxy=http://127.0.0.1:7890
HTTPS_PROXY=http://127.0.0.1:7890
[root@44baac59b55d /]# curl google.com
# no response
但是直接启动容器是无法使用 host 的网络代理,因为 docker 默认为桥接模式,需要使用--network host
来使用 host 网络
$ docker run -it --network host centos /bin/bash
[root@kesa-PC /]# curl google.com
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="http://www.google.com/">here</A>.
</BODY></HTML>
至此,docker 和 docker container 的网络代理都配置好了
4.2 DockerFile
为了缩小镜像的大小,使用多阶段构建,在 golang:alpine
中编译再将可执行文件复制到 gcr.io/distroless/static
中
#
# Build
#
FROM golang:alpine AS build-env
# set golang proxy
ENV GOPROXY https://goproxy.cn,direct
WORKDIR /app
COPY . /app
RUN go mod download \
&& CGO_ENABLED=0 GOOS=linux go build -o /go-jwt-demo
#
# Deploy
#
FROM gcr.io/distroless/static
COPY /go-jwt-demo /
EXPOSE 9090
ENTRYPOINT ["/go-jwt-demos"]
CGO_ENABLED=0 GOOS=linux go build -o /go-jwt-demo
因为要使用没有 C 库的环境,这里使用静态编译,CGO_ENABLED=0 表示不适用动态链接,GOOS=linux 指定环境为 linuxENV GOPROXY https://goproxy.cn,direct
: golang 代理,这里使用国内的代理 如果没有配置这个,可以在后续构建过程中使用宿主机的网络代理直接下载
创建镜像
$ docker build --network=host -t go-jwt-demo .
这里注意使用 host 模式,为了使用宿主机的代理下载 go package
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
go-jwt-demo latest 45735a238f38 9 minutes ago 18.6MB
golang alpine 6f9d081b1170 38 hours ago 315MB
可以看到构建的镜像仅有 18.6 MiB
现在程序还无法运行,缺少 redis 支持,下面接着配置 redis
4.3 Docker Redis
下载镜像
$ docker pull redis
启动容器
$ docker run -d --rm --name -p 16379:6379 redis-test redis
--rm
: 容器停止后自动删除容器
测试
$ redis-cli -h localhost -p 16379
localhost:16379> ping
PONG
4.4 Container Connection
现在已经有了 golang 和 redis 容器,需要将其互联起来
docker run
的 --link
选项可以实现容器一对一的连接,但是不建议使用
下面将创建自定义的 docker 网络来连接多个容器
创建网络
docker network create -d bridge my-net
-d
: 指定网络类型,这里使用桥接模式
修改下 initilize/redis.go
(建议将配置写在配置文件中,使用 viper 来读取)
rdb := redis.NewClient(&redis.Options{
Addr: "my-redis:6379",
Password: "",
DB: 0,
})
go-jwt-redis
: 将作为我们 redis 容器的名字
删除之前的镜像并重新构建
$ docker image rm go-jwt-demo:latest
Untagged: go-jwt-demo:latest
# ...
$ docker build --network host -t go-jwt-demo .
# ...
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
go-jwt-demo latest 4ddd03c488b7 4 seconds ago 18.6MB
<none> <none> dd4ac9b09c0b 6 seconds ago 557MB
可以看到使用multi-stage
构建会产生 TAG <none>
的镜像
需要使用 docker image prune
清除,为了避免每次手动删除,创建 shell 脚本
#!/usr/bin/env sh
# build image
docker build --network host -t go-jwt-demo .
yes|docker image prune
下载官方的 redis 配置文件
$ curl https://raw.githubusercontent.com/redis/redis/6.2/redis.conf > redis.conf
接下来创建启动脚本,启动 golang application 和 redis
#!/usr/bin/env sh
# start redis container
docker run -d \
-p 16379:6379 \
--name my-redis \
--restart always \
--network my-net \
-v $HOME/my-docker-apps/redis/data:/data \
-v $HOME/my-docker-apps/redis/conf:/etc/redis \
-v /etc/localtime:/etc/localtime:ro \
redis:alpine \
redis-server --appendonly yes
# start golang app
docker run -d \
-p 19090:9090 \
--name go-jwt-demo \
--restart always \
--network my-net \
go-jwt-demo
--restart always
: 容器将会随着 docker 启动--network my-net
: 使用自定义的网络-v /etc/localtime:/etc/localtime:ro
: 同步宿主机时区
启动脚本后即可测试
Reference
- jwt jwt.io
- JSON Web Token 入门教程 阮一峰
- jwt-go go docs
- using jwt in golang application victor steven
- build golang image docker docs
- distroless example golang dockerfile GoogleContainerTools/distroless
- docker set http and https proxy docker docs
- Automatic proxy configuration for containers docker cli docs