Create authentication system with JWT and Redis


This time, I'm going to make authentication system with JWT and Redis

I will use docker to run Redis in background

docker run -d -p 6379:6379 redis

Then create a Go module

mkdir jwt-redis
cd jwt-redis
go mod init gojwt
touch main.go

I need four libraries for this article

  1. gin - to create the server
  2. google/uuid - to generate non duplicated id
  3. jwt - to create tokens for this authentication system
  4. redis - to save temporary tokens

Before writing the server, I will prepare token functions First. Create auth/token.go file

package auth

import (
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt"
	"github.com/google/uuid"
)

var (
	_accessSecret  = "Acc3ss"
	_refreshSecret = "l23fr3sh"
)

type Token struct {
	Token  string
	UUID   string
	Expire int64
}

func GenerateToken(username string, isRefresh bool) (Token, error) {
	randomUUID := uuid.New().String()
	claims := jwt.MapClaims{
		"iss":      "MY_APP",
		"iat":      time.Now().Unix(),
		"username": username,
	}
	var (
		ss     string
		expire int64
		err    error
		secret string
	)
	if isRefresh {
		expire = time.Now().Add(time.Minute * 10).Unix()
		claims["refresh_uuid"] = randomUUID
		secret = _refreshSecret
	} else {
		expire = time.Now().Add(time.Minute).Unix()
		claims["access_uuid"] = randomUUID
		claims["authorized"] = true
		secret = _accessSecret
	}
	claims["exp"] = expire
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	ss, err = token.SignedString([]byte(secret))
	return Token{
		Token:  ss,
		UUID:   randomUUID,
		Expire: expire,
	}, err
}

func GeneratePairToken(username string) ([]Token, error) {
	tokens := make([]Token, 2)
	var err error
	if tokens[0], err = GenerateToken(username, false); err != nil {
		return nil, err
	}
	if tokens[1], err = GenerateToken(username, true); err != nil {
		return nil, err
	}
	return tokens, nil
}

func ExtractToken(token string, isRefresh bool) (jwt.MapClaims, error) {
	secret := _accessSecret
	if isRefresh {
		secret = _refreshSecret
	}
	v, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("Unexpected method (%v)", t.Header["alg"])
		}
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}
	claims, ok := v.Claims.(jwt.MapClaims)
	if !ok || !v.Valid {
		return nil, errors.New("Can't parse")
	}
	return claims, nil
}

For this application, I will create 3 endpoints

  1. POST /login - receive username and generate an access token and a refresh token and save in Redis
  2. GET /resource - extract the access token from the request header to check permission
  3. POST /refresh - receive a refresh token and regenerate an access token back to the client
  4. POST /logout - receive an access token and delete the token from Redis

Then my main.go will be

package main

import (
	"context"
	"errors"
	"fmt"
	"gojwt/auth"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
)

func saveDataInRedis(rdb *redis.Client, key, value string, expire int64) error {
	return rdb.Set(context.Background(), key, value, time.Unix(expire, 0).Sub(time.Now())).Err()
}

func AuthMiddleware(rdb *redis.Client) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		splits := strings.Split(ctx.Request.Header.Get("Authorization"), " ")
		if len(splits) != 2 {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("No auth token"))
			return
		}
		token := splits[1]
		claims, err := auth.ExtractToken(token, false)
		if err != nil {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Invalid token"))
			return
		}
		uuid, ok := claims["access_uuid"].(string)
		if !ok {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Token info is missing"))
			return
		}
		username, err := rdb.Get(context.Background(), uuid).Result()
		if err != nil {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Token is expired"))
			return
		}
		ctx.Set("accessuuid", uuid)
		ctx.Set("username", username)
	}
}

type (
	LoginForm struct {
		Username string
	}
	RefreshRequestForm struct {
		RefreshToken string
	}
	LogoutRequestForm struct {
		RefreshToken string
	}
)

func main() {
	r := gin.Default()
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})
	r.POST("/login", func(ctx *gin.Context) {
		var form LoginForm
		if err := ctx.ShouldBindJSON(&form); err != nil {
			ctx.String(http.StatusUnprocessableEntity, "Wrong format")
			return
		}

		tokens, err := auth.GeneratePairToken(form.Username)
		if err != nil {
			ctx.String(http.StatusInternalServerError, "Something went wrong")
			return
		}

		if err := saveDataInRedis(rdb, tokens[0].UUID, form.Username, tokens[0].Expire); err != nil {
			ctx.String(http.StatusInternalServerError, "Something went wrong")
			return
		}
		if err := saveDataInRedis(rdb, tokens[1].UUID, form.Username, tokens[1].Expire); err != nil {
			ctx.String(http.StatusInternalServerError, "Something went wrong")
			return
		}

		ctx.JSON(http.StatusOK, gin.H{
			"AccessToken":  tokens[0].Token,
			"RefreshToken": tokens[1].Token,
		})
	})
	authRoute := r.Group("/", AuthMiddleware(rdb))
	authRoute.GET("/resource", func(ctx *gin.Context) {
		var username string
		if v, ok := ctx.Get("username"); !ok {
			ctx.String(http.StatusForbidden, "Something went wrong")
			return
		} else if username, ok = v.(string); !ok {
			ctx.String(http.StatusForbidden, "Some info is missing")
			return
		}
		ctx.String(http.StatusOK, fmt.Sprintf("Hello %s!", username))
	})
	r.POST("/refresh", func(ctx *gin.Context) {
		var form RefreshRequestForm
		if err := ctx.ShouldBindJSON(&form); err != nil {
			ctx.String(http.StatusUnprocessableEntity, "Wrong format")
			return
		}
		claims, err := auth.ExtractToken(form.RefreshToken, true)
		if err != nil {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Invalid token"))
			return
		}
		username, ok := claims["username"].(string)
		if !ok {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Token info is missing"))
			return
		}
		accessToken, err := auth.GenerateToken(username, false)
		if err != nil {
			ctx.String(http.StatusInternalServerError, "Something went wrong")
			return
		}
		if err := saveDataInRedis(rdb, accessToken.UUID, username, accessToken.Expire); err != nil {
			ctx.String(http.StatusInternalServerError, "Something went wrong")
			return
		}
		ctx.JSON(http.StatusOK, gin.H{
			"AccessToken": accessToken.Token,
		})
	})
	authRoute.POST("/logout", func(ctx *gin.Context) {
		var form LogoutRequestForm
		if err := ctx.ShouldBindJSON(&form); err != nil {
			ctx.String(http.StatusUnprocessableEntity, "Wrong format")
			return
		}
		var accessuuid string
		if v, ok := ctx.Get("accessuuid"); !ok {
			ctx.String(http.StatusForbidden, "Something went wrong")
			return
		} else if accessuuid, ok = v.(string); !ok {
			ctx.String(http.StatusForbidden, "Some info is missing")
			return
		}
		refreshClaims, err := auth.ExtractToken(form.RefreshToken, true)
		if err != nil {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Invalid token"))
			return
		}
		refreshuuid, ok := refreshClaims["refresh_uuid"].(string)
		if !ok {
			ctx.AbortWithError(http.StatusUnauthorized, errors.New("Token info is missing"))
			return
		}
		rdb.Del(context.Background(), accessuuid)
		rdb.Del(context.Background(), refreshuuid)
		ctx.String(http.StatusOK, "Logged out successfully")
	})
	r.Run()
}

Steps:

  • Login to the server in /login endpoint. This end point requires a username to be sent via request body
  • The endpoint will return two tokens which are access token and refresh token
  • To access /resource endpoint, need to add the access token in request header. The /resource endpoint will extract the token and response a greeting.
  • Before the access token is expired, go to /refresh endpoint to get a new access token. This endpoint requires a refresh token in request body
  • About logging out, send a request to /logout endpoint with the access token in request header and the refresh token in request body

P.S. An access token will be valid for a minute after the server generated the token and for a refresh token it will be 10 minutes

sources