Go jwt

Kesa...大约 18 分钟golangdockergo-jwtjwt

1 跨域认证问题

传统的 session 的认证流程如下:

  1. 用户向服务器发送用户名和密码
  2. 服务器验证通过后,将相关信息保存至 session 中
  3. 服务器将 session ID 返回给客户端并存储至 cookie 中
  4. 用户请求时会附上 session ID,服务器会根据 session ID 来得知用户身份

1.1 传统 session 认证的问题

Session: 每个用户经过服务认证之后,会将 session 信息保存至内存中,当用户的数量增加时服务器的负荷会增大

扩展性(Scaling): 在分布式系统中,用户需在保存其 session 信息的服务器中才可以获取授权,限制了应用的扩展能力

CSRF: 因为是基于 cokkie 进行识别的,若 cokkie 被截获,用户容易受到跨站请求攻击

1.2 基于 Token 的鉴权机制

基于 token 的认证和传统的 session 方式不同,token 信息存放在客户端而不是服务端

其认证流程如下:

  1. 用户向服务器发送用户名和密码
  2. 服务器验证用户信息
  3. 验证通过后向用户签发 token
  4. 用户请求时附上 token,服务器验证 token 来进行授权

token 在每次请求时发送给服务端,其应放在请求头中并要求服务端支持 CORS(跨域资源共享)

2. JWT

2.1 What is JSON Web Token

JSON Web Token (JWT) is an open standard (RFC 7519open in new window) 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:

    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 Registryopen in new window 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 securityopen in new window.

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:

  1. 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 Connectopen in new window compliant web application will go through the /oauth/authorize endpoint using the authorization code flowopen in new window.
  2. When the authorization is granted, the authorization server returns an access token to the application.
  3. 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-noteopen in new window

下面的例子将创建一个 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.ioopen in new window 上解析 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/publicLogin 函数

新增 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 构建或者使用 GoogleContainerToolsopen in new window/**distrolessopen in new window**来构建

这里采用 docker 官方推荐的 distrolessopen in new window 来进行构建

下面是两种镜像的介绍

Documentation for gcr.io/distroless/base and gcr.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 in gcr.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 代理

  1. Create a systemd drop-in directory for the docker service:

    $ sudo mkdir -p /etc/systemd/system/docker.service.d
    
  2. Create a file named /etc/systemd/system/docker.service.d/http-proxy.conf that adds the HTTP_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"
    
  3. Flush changes and restart Docker

    $ sudo systemctl daemon-reload
    $ sudo systemctl restart docker
    
  4. 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 --from=build-env /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 指定环境为 linux
  • ENV 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

  1. jwtopen in new window jwt.ioopen in new window
  2. JSON Web Token 入门教程open in new window 阮一峰open in new window
  3. jwt-goopen in new window go docs
  4. using jwt in golang applicationopen in new window victor steven
  5. build golang imageopen in new window docker docs
  6. distroless example golang dockerfileopen in new window GoogleContainerToolsopen in new window/distrolessopen in new window
  7. docker set http and https proxyopen in new window docker docs
  8. Automatic proxy configuration for containersopen in new window docker cli docs
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2