11
JWT authentication
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,
}
}
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")
}
}
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")
}
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()
}
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),
})
}
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:
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
}
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
}
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()
}
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
}
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.
11