DEV Community

Matija Krajnik
Matija Krajnik

Posted on • Updated on • Originally published at letscode.blog

JWT authentication

#go

Authentication is one of the most important part of almost every web application. We must ensure that every user can create, read, update and delete only data for which it's authorized. For that purpose we will use JWT (JSON Web Token). Fortunately, there are various Golang modules specialized for this. One that will be used in this guide can be found in this GitHub repo. Current latest version is v3 which can ne installed by running go get github.com/cristalhq/jwt/v3.

Since we will need secret key for generating and verifying tokens, let's add line export RGB_JWT_SECRET=jwtSecret123 to our .env file. Of course, in production you would want to use some long randomly generated string.
Next thing we should do is add new variable to internal/conf/conf.go. We will add constant jwtSecretKey = "RGB_JWT_SECRET" with the rest of our constants and then add new field JwtSecret of type string to Config struct. Now we can read new env variable and add it inside of NewConfig() function:

const (
  hostKey       = "RGB_HOST"
  portKey       = "RGB_PORT"
  dbHostKey     = "RGB_DB_HOST"
  dbPortKey     = "RGB_DB_PORT"
  dbNameKey     = "RGB_DB_NAME"
  dbUserKey     = "RGB_DB_USER"
  dbPasswordKey = "RGB_DB_PASSWORD"
  jwtSecretKey  = "RGB_JWT_SECRET"
)

type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
}

func NewConfig() Config {
  host, ok := os.LookupEnv(hostKey)
  if !ok || host == "" {
    logAndPanic(hostKey)
  }

  port, ok := os.LookupEnv(portKey)
  if !ok || port == "" {
    if _, err := strconv.Atoi(port); err != nil {
      logAndPanic(portKey)
    }
  }

  dbHost, ok := os.LookupEnv(dbHostKey)
  if !ok || dbHost == "" {
    logAndPanic(dbHostKey)
  }

  dbPort, ok := os.LookupEnv(dbPortKey)
  if !ok || dbPort == "" {
    if _, err := strconv.Atoi(dbPort); err != nil {
      logAndPanic(dbPortKey)
    }
  }

  dbName, ok := os.LookupEnv(dbNameKey)
  if !ok || dbName == "" {
    logAndPanic(dbNameKey)
  }

  dbUser, ok := os.LookupEnv(dbUserKey)
  if !ok || dbUser == "" {
    logAndPanic(dbUserKey)
  }

  dbPassword, ok := os.LookupEnv(dbPasswordKey)
  if !ok || dbPassword == "" {
    logAndPanic(dbPasswordKey)
  }

  jwtSecret, ok := os.LookupEnv(jwtSecretKey)
  if !ok || jwtSecret == "" {
    logAndPanic(jwtSecretKey)
  }

  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
  }
}
Enter fullscreen mode Exit fullscreen mode

We will create new file internal/server/jwt.go:

package server

import (
  "rgb/internal/conf"

  "github.com/cristalhq/jwt/v3"
  "github.com/rs/zerolog/log"
)

var (
  jwtSigner   jwt.Signer
  jwtVerifier jwt.Verifier
)

func jwtSetup(conf conf.Config) {
  var err error
  key := []byte(conf.JwtSecret)

  jwtSigner, err = jwt.NewSignerHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg("Error creating JWT signer")
  }

  jwtVerifier, err = jwt.NewVerifierHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg("Error creating JWT verifier")
  }
}
Enter fullscreen mode Exit fullscreen mode

Function jwtSetup() will only create signer and verifier that will later be used in authentication. Now we can call this function from internal/server/server/go when starting server:

package server

import (
  "rgb/internal/conf"
  "rgb/internal/database"
  "rgb/internal/store"
)

func Start(cfg conf.Config) {
  jwtSetup(cfg)

  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  // Start listening and serving requests
  router.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

To generate tokens, we will create function in internal/server/jwt.go:

func generateJWT(user *store.User) string {
  claims := &jwt.RegisteredClaims{
    ID:        fmt.Sprint(user.ID),
    ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)),
  }
  builder := jwt.NewBuilder(jwtSigner)
  token, err := builder.Build(claims)
  if err != nil {
    log.Panic().Err(err).Msg("Error building JWT")
  }
  return token.String()
}
Enter fullscreen mode Exit fullscreen mode

And then we will call it from internal/server/user.go instead of hardcoded string we had so far for testing purposes:

package server

import (
  "net/http"
  "rgb/internal/store"

  "github.com/gin-gonic/gin"
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    "msg": "Signed up successfully.",
    "jwt": generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Sign in failed."})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    "msg": "Signed in successfully.",
    "jwt": generateJWT(user),
  })
}
Enter fullscreen mode Exit fullscreen mode

Let's test this by signing up or signing in through our frontend. Open browser dev tools and check signIn or signUp response. You can see that our backend now generated random JWT:

JWT authentication

Token is now created in signIn and signUp handlers, which means we can verify it for all secured routes. For that we will first implement verifyJWT() function in internal/server/jwt.go. This function will receive token in the form of string, verify its signature, extract ID from claims and if everything is ok, user's ID will be returned as int:

func verifyJWT(tokenStr string) (int, error) {
  token, err := jwt.Parse([]byte(tokenStr))
  if err != nil {
    log.Error().Err(err).Str("tokenStr", tokenStr).Msg("Error parsing JWT")
    return 0, err
  }

  if err := jwtVerifier.Verify(token.Payload(), token.Signature()); err != nil {
    log.Error().Err(err).Msg("Error verifying token")
    return 0, err
  }

  var claims jwt.StandardClaims
  if err := json.Unmarshal(token.RawClaims(), &claims); err != nil {
    log.Error().Err(err).Msg("Error unmarshalling JWT claims")
    return 0, err
  }

  if notExpired := claims.IsValidAt(time.Now()); !notExpired {
    return 0, errors.New("Token expired.")
  }

  id, err := strconv.Atoi(claims.ID)
  if err != nil {
    log.Error().Err(err).Str("claims.ID", claims.ID).Msg("Error converting claims ID to number")
    return 0, errors.New("ID in token is not valid")
  }
  return id, err
}
Enter fullscreen mode Exit fullscreen mode

Functions for generating and verifying are done, and with that we are almost ready to write Gin middleware for authorization. Before that, we will add function that will fetch user from database based on its ID. In internal/store/users.go, add function:

func FetchUser(id int) (*User, error) {
  user := new(User)
  user.ID = id
  err := db.Model(user).Returning("*").WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg("Error fetching user")
    return nil, err
  }
  return user, nil
}
Enter fullscreen mode Exit fullscreen mode

It's time to create new file internal/server/middleware.go:

package server

import (
  "net/http"
  "rgb/internal/store"
  "strings"

  "github.com/gin-gonic/gin"
)

func authorization(ctx *gin.Context) {
  authHeader := ctx.GetHeader("Authorization")
  if authHeader == "" {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing."})
    return
  }
  headerParts := strings.Split(authHeader, " ")
  if len(headerParts) != 2 {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format is not valid."})
    return
  }
  if headerParts[0] != "Bearer" {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is missing bearer part."})
    return
  }
  userID, err := verifyJWT(headerParts[1])
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
    return
  }
  user, err := store.FetchUser(userID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
    return
  }
  ctx.Set("user", user)
  ctx.Next()
}
Enter fullscreen mode Exit fullscreen mode

Authorization middleware extracts token from Authorization header. It first checks if header exists, if it's in valid format, and then calls verifyJWT() function. If JWT verification passes, user's ID is returned. User with that ID is fetched from database and set as current user for this context.

Getting current user from context is something that we will need fairly often, so let's extract this into helper function:

func currentUser(ctx *gin.Context) (*store.User, error) {
  var err error
  _user, exists := ctx.Get("user")
  if !exists {
    err = errors.New("Current context user not set")
    log.Error().Err(err).Msg("")
    return nil, err
  }
  user, ok := _user.(*store.User)
  if !ok {
    err = errors.New("Context user is not valid type")
    log.Error().Err(err).Msg("")
    return nil, err
  }
  return user, nil
}
Enter fullscreen mode Exit fullscreen mode

First we check if user is set for this context. If not, error is returned. Since ctx.Get() returns interface, we must check if value is of type *store.User. If not, error is returned. When both checks are passed, current user is returned from context.

Authorization middleware is now ready to use for protected routes, as we will see in next chapter.

Discussion (0)