User Authentication in Go Echo with JWT
9 min read Go Programming

User Authentication in Go Echo with JWT

In this article, we will build a simple user authentication functionality using JWT (JSON Web Token). In the examples, I’m going to use a Go Echo framework. This will allow us to avoid writing some boilerplate code.

If you are not familiar with a JWT theory, please refer to this resource.

I believe that the easiest way to understand how to work with JWT authentication is by solving a real-world problem. Let’s say, that we have a website with an administration section, that should be accessible only by authenticated users, by providing some credentials. If authentication was successful, the user can access the administration section. If the user is inactive during a defined period of time, we should log him out from the system.

In the beginning, I’m going to create a main.go file with initialization of the web server and some routers:

package main

import (
	"fmt"
	"github.com/alexsergivan/blog-examples/authentication/auth"
	"github.com/alexsergivan/blog-examples/authentication/controllers"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
	"net/http"
)

func main() {
    e := echo.New()

    // Defining of the admin router group.
    adminGroup := e.Group("/admin")

    // Router for "/admin" path.
	adminGroup.GET("", controllers.Admin())

    // Starting the server.
	e.Logger.Fatal(e.Start(":8777"))
}

And controllers/admin.go:

package controllers

import (
	"fmt"
	"github.com/labstack/echo/v4"
	"net/http"
)

func Admin() echo.HandlerFunc {
	return func(c echo.Context) error {
		return c.String(http.StatusOK, "Hi, you have access!")
	}
}

If you run this code and go to http://localhost:8777/admin, you will access this page without any authentication. Let’s protect this path, by adding a JWT authentication.

First, what I’m going to create - it’s an auth package, where we will keep all JWT related logic. Please refer to the code below with added explanatory comments:

package auth

import (
	"github.com/alexsergivan/blog-examples/authentication/user"
	"github.com/labstack/echo/v4"
	"net/http"
	"time"
	"github.com/dgrijalva/jwt-go"
)

const (
    accessTokenCookieName  = "access-token"
    // Just for the demo purpose, I declared a secret here. In the real-world application, you might need to get it from the env variables.
	jwtSecretKey = "some-secret-key"
)

func GetJWTSecret() string {
	return jwtSecretKey
}

// Create a struct that will be encoded to a JWT.
// We add jwt.StandardClaims as an embedded type, to provide fields like expiry time.
type Claims struct {
	Name  string `json:"name"`
	jwt.StandardClaims
}

// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
func GenerateTokensAndSetCookies(user *user.User, c echo.Context) error {
	accessToken, exp, err := generateAccessToken(user)
	if err != nil {
		return err
	}

	setTokenCookie(accessTokenCookieName, accessToken, exp, c)
	setUserCookie(user, exp, c)

	return nil
}

func generateAccessToken(user *user.User) (string, time.Time, error) {
	// Declare the expiration time of the token (1h).
	expirationTime := time.Now().Add(1 * time.Hour)

	return generateToken(user, expirationTime, []byte(GetJWTSecret()))
}

// Pay attention to this function. It holds the main JWT token generation logic.
func generateToken(user *user.User, expirationTime time.Time, secret []byte) (string, time.Time, error) {
	// Create the JWT claims, which includes the username and expiry time.
	claims := &Claims{
		Name:  user.Name,
		StandardClaims: jwt.StandardClaims{
			// In JWT, the expiry time is expressed as unix milliseconds.
			ExpiresAt: expirationTime.Unix(),
		},
	}

	// Declare the token with the HS256 algorithm used for signing, and the claims.
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// Create the JWT string.
	tokenString, err := token.SignedString(secret)
	if err != nil {
		return "", time.Now(), err
	}

	return tokenString, expirationTime, nil
}

// Here we are creating a new cookie, which will store the valid JWT token.
func setTokenCookie(name, token string, expiration time.Time, c echo.Context) {
	cookie := new(http.Cookie)
	cookie.Name = name
	cookie.Value = token
	cookie.Expires = expiration
    cookie.Path = "/"
    // Http-only helps mitigate the risk of client side script accessing the protected cookie.
	cookie.HttpOnly = true

	c.SetCookie(cookie)
}

// Purpose of this cookie is to store the user's name.
func setUserCookie(user *user.User, expiration time.Time, c echo.Context) {
	cookie := new(http.Cookie)
	cookie.Name = "user"
	cookie.Value = user.Name
	cookie.Expires = expiration
	cookie.Path = "/"
	c.SetCookie(cookie)
}

// JWTErrorChecker will be executed when user try to access a protected path.
func JWTErrorChecker(err error, c echo.Context) error {
    // Redirects to the signIn form.
	return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse("userSignInForm"))
}

After finishing the main JWT token functionality, let’s add the SignIn controllers, which will handle user authentication. First, we need to add the new routers inside main() function:

    e.GET("/user/signin", controllers.SignInForm()).Name = "userSignInForm"
	e.POST("/user/signin", controllers.SignIn())

In the code below I created a user package with the user structure and a function that loads a dummy user from imaginary database. We gonna need it in our controllers to process and validate user data.

package user

import "golang.org/x/crypto/bcrypt"

type User struct {
	Password string `json:"password" form:"password"`
	Name string `json:"name" form:"name"`
}

func LoadTestUser() *User {
    // Just for demonstration purpose, we create a user with the encrypted "test" password.
    // In real-world applications, you might load the user from the database by specific parameters (email, username, etc.)
	hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("test"), 8)
	return &User{Password: string(hashedPassword), Name: "Test user"}
}

After this, we will create a controllers package, where we add SignInForm() and SignIn() functions:

package controllers

import (
	"github.com/alexsergivan/blog-examples/authentication/auth"
	"github.com/alexsergivan/blog-examples/authentication/user"
	"github.com/labstack/echo/v4"
	"golang.org/x/crypto/bcrypt"
	"html/template"
	"net/http"
	"path"
)

// SignInForm responsible for signIn Form rendering.
func SignInForm() echo.HandlerFunc {
	return func(c echo.Context) error {
		fp := path.Join("templates", "signIn.html")
		tmpl, err := template.ParseFiles(fp)
		if err != nil {
			return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
		}
		if err := tmpl.Execute(c.Response().Writer, nil); err != nil {
			return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
		}

		return nil
	}
}

// SignIn will be executed after SignInForm submission.
func SignIn() echo.HandlerFunc {
	return func(c echo.Context) error {
        // Load our "test" user.
		storedUser := user.LoadTestUser()
        // Initiate a new User struct.
        u := new(user.User)
        // Parse the submitted data and fill the User struct with the data from the SignIn form.
		if err := c.Bind(u); err != nil {
			return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
		}
		// Compare the stored hashed password, with the hashed version of the password that was received.
		if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(u.Password)); err != nil {
			// If the two passwords don't match, return a 401 status.
			return echo.NewHTTPError(http.StatusUnauthorized, "Password is incorrect")
        }
        // If password is correct, generate tokens and set cookies.
		err := auth.GenerateTokensAndSetCookies(storedUser, c)

		if err != nil {
			return echo.NewHTTPError(http.StatusUnauthorized, "Token is incorrect")
		}

		return c.Redirect(http.StatusMovedPermanently, "/admin")
	}
}

Now, in the /templates folder we need to create a signIn.html template with the simple SignIn form:

<!DOCTYPE html>
<html lang="en">
  <form class="w-full" method="post" action="/user/signin">
    <label for="password">Password:</label>
    <input type="password" id="password" name="password">
    <button type="submit">Sign In</button>
  </form>
</html>

Let’s also modify the admin controller in controllers/admin.go:

package controllers

import (
	"fmt"
	"github.com/labstack/echo/v4"
	"net/http"
)

func Admin() echo.HandlerFunc {
	return func(c echo.Context) error {
        // Gets user cookie.
		userCookie, _ := c.Cookie("user")
		return c.String(http.StatusOK, fmt.Sprintf("Hi, %s! You have been authenticated!", userCookie.Value))
	}
}

And finally, we need to add a JWT Middleware to the adminGroup path inside the main() function:

// Read more about JWT Middleware here: https://echo.labstack.com/middleware/jwt
adminGroup.Use(middleware.JWTWithConfig(middleware.JWTConfig{
		Claims:                  &auth.Claims{},
        SigningKey:              []byte(auth.GetJWTSecret()),
		TokenLookup:             "cookie:access-token", // "<source>:<name>"
		ErrorHandlerWithContext: auth.JWTErrorChecker,
    }))

After that, execute go run main.go, to run the server, and go to /admin path. You will be redirected to the /user/signin path, because you need to be authenticated to access it. That is exactly what we need! Just enter test password and click on Sign In button. You will see this message: Hi, Test user! You have been authenticated! Awesome!

As you remember earlier, we set expiration time for the token: expirationTime := time.Now().Add(1 * time.Hour) It means, that after 1 hour user will be automatically logged-out. This is something what we want to prevent, especially if user is still active and doing some work on our resource. This is possible to solve, by introducing a Refresh token. This token will have a much longer life-time and will be used for refreshing the Access token. Let’s modify our previous code.

First of all, we need to declare a secret for the Refresh token and cookie name to store the generated JWT. I will store it in a constant, but in the real-world applications please use environment variables for security reasons.

Inside auth package (/auth/auth.go) we need to add these modifications:

const (
	accessTokenCookieName  = "access-token"
	refreshTokenCookieName = "refresh-token"
	jwtSecretKey = "some-secret-key"
	jwtRefreshSecretKey = "some-refresh-secret-key"
)

func GetRefreshJWTSecret() string {
	return jwtRefreshSecretKey
}

func GenerateTokensAndSetCookies(user *user.User, c echo.Context) error {
	accessToken, exp, err := generateAccessToken(user)
	if err != nil {
		return err
	}

	setTokenCookie(accessTokenCookieName, accessToken, exp, c)
    setUserCookie(user, exp, c)
    // We generate here a new refresh token and saving it to the cookie.
	refreshToken, exp, err := generateRefreshToken(user)
	if err != nil {
		return err
	}
	setTokenCookie(refreshTokenCookieName, refreshToken, exp, c)

	return nil
}

func generateRefreshToken(user *user.User) (string, time.Time, error) {
	// Declare the expiration time of the token - 24 hours.
	expirationTime := time.Now().Add(24 * time.Hour)

	return generateToken(user, expirationTime, []byte(GetRefreshJWTSecret()))
}

At this point, when the user is signing-in, we generate 2 tokens: access and refresh. We still need to add logic for updating the access token, if the user is still active. For that, we can add a middleware, where we can check how much time is left for the user’s access token, and if this time is less than some period of time (in this example it’s 15 mins) we can generate the new tokens, by providing a valid refresh token.

// TokenRefresherMiddleware middleware, which refreshes JWT tokens if the access token is about to expire.
func TokenRefresherMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
        // If the user is not authenticated (no user token data in the context), don't do anything.
		if c.Get("user") == nil {
			return next(c)
		}
        // Gets user token from the context.
		u := c.Get("user").(*jwt.Token)

		claims := u.Claims.(*Claims)

		// We ensure that a new token is not issued until enough time has elapsed.
		// In this case, a new token will only be issued if the old token is within
		// 15 mins of expiry.
		if time.Unix(claims.ExpiresAt, 0).Sub(time.Now()) < 15*time.Minute {
            // Gets the refresh token from the cookie.
			rc, err := c.Cookie(refreshTokenCookieName)
			if err == nil && rc != nil {
                // Parses token and checks if it valid.
				tkn, err := jwt.ParseWithClaims(rc.Value, claims, func(token *jwt.Token) (interface{}, error) {
					return []byte(GetRefreshJWTSecret()), nil
				})
				if err != nil {
					if err == jwt.ErrSignatureInvalid {
						c.Response().Writer.WriteHeader(http.StatusUnauthorized)
					}
				}

				if tkn != nil && tkn.Valid {
                    // If everything is good, update tokens.
					_ = GenerateTokensAndSetCookies(&user.User{
						Name:  claims.Name,
					}, c)
				}
			}
		}

		return next(c)
	}
}

And finally, we have to attach our middleware to the adminGroup router inside the main() function:

	adminGroup.Use(auth.TokenRefresherMiddleware)

You can run the server again and experiment, how does it work. As an example, you can change the access token lifetime to 1min and investigate how the jwt cookies behave.

That was pretty much it. I hope this article was helpful for you.

The complete source code you can found here.

A

Alex

Passionate about web development and sharing knowledge with the community.

Share on X

You might also like