Implementing JWTs in Go

Kinanee Samson - Jul 16 - - Dev Community

Go is our favorite programming language for now and that’s for a good reason if you agree with me. Others might not think so but don’t worry it isn’t easy being a full-stack developer who knows how to use multiple programming languages especially one designed and developed by Google.

We can remember Angular and Google due to their unnecessary complexity, but trust me, Google learned from their mistake. Thus, Go is a very easy-to-use, elegant, and modern systems programming language. In today's post, we examine how we can implement JWT authentication and RBAC (Role Based Access Control) in our API as such we will cover the following

  • Project setup
  • Creating a token
  • Verifying a token

Project step up

Let’s set up a basic Go project, to do that we’ll need to create a new folder inside our projects directory.

mkdir go_jwt 
Enter fullscreen mode Exit fullscreen mode

Let’s navigate into that project directory and create a Go mod file.

cd go_jwt && go mod init ./go_jwt
Enter fullscreen mode Exit fullscreen mode

Now we have our project set up correctly let’s install Gorilla Mux to help us set up a basic server.

go get -u github.com/gorilla/mux 
Enter fullscreen mode Exit fullscreen mode

Now we have gorilla mux installed let’s create a basic server

package main

import (
    "net/http"
    "github.com/gorilla/mux"
)

func main () {
    r := mux.NewRouter()
    http.handle("/", r)
    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

Create Token

We will implement a simple RBAC where only users who are admins can create a project and update its status. Meanwhile, other users can only view existing projects.

package main

// cont’d

type User struct {
    Type string    `json:"type"`
}
// cont’d

Enter fullscreen mode Exit fullscreen mode

Now we have a struct to serve as a user let’s install the package to help us with JWTs.

go get -u github.com/golang-jwt/jwt/v5
Enter fullscreen mode Exit fullscreen mode

Let’s go ahead and set up a helper function for creating a token.

package main

import (
    // cont’d 
    "github.com/golang-jwt/jwt/v5"
)

// cont’d

var secretKey = []byte("Test1234")

func CreateToken(type string) (string, error) {
    claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
             // Issuer
        "aud": user_type,                        // Audience (user role)
        "exp": time.Now().Add(time.Hour).Unix(), // Expiration time
        "iat": time.Now().Unix(),                // Issued at
    })
    tokenString, err := claims.SignedString(secretKey)

    if err != nil {
        fmt.Printf("Error in creating token: %v\n", err)
        return "", err
    }

    return tokenString, err
}
Enter fullscreen mode Exit fullscreen mode

The function creates a new JSON Web Token (JWT). The function accepts a string argument named type. This argument represents the type of token being created (e.g., "admin", "user"). The jwt.NewWithClaims function creates a JWT with certain claims for us. The claims specify the following information:

  • aud: This represents the audience for the token, set to the user type.
  • exp: This defines the expiration time for the token, set to one hour from the current time.
  • iat: This specifies the time at which the token was issued.

The function uses the SignedString method to sign the JWT claims with a secret key (secretKey). This signing process ensures the integrity of the token. If there's an error during signing, the function prints an error message and returns an empty string along with the error. If everything is successful, the function returns the signed JWT token string as well as a nil error value.

Now we'll create a new token whenever we create a new user account

package main

import(
    "fmt"
)

func CreateUser (type string) func(http.ResponseWriter, *http.Request) {
    return func (w http.ResponseWriter, r *http.Request) {
    // Create user somehow 
    result := db.Create(&user)
        if result.Error != nil {
            http.Error(w, result.Error.Error(), http.StatusInternalServerError)
            return
        }
        token, err := CreateToken(type)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.FPrintf(w, token);
     }
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above builds upon the previous CreateToken function by defining a new function called CreateUser within the main package. The CreateUser function relies on the CreateToken function to generate a token after a user is created. The CreateUser function returns another inner function that handles HTTP requests. Upon successful user creation, it calls the CreateToken function (from the previous codebase) to generate a token for the newly created user. The type argument passed to CreateUser is forwarded to CreateToken. If there's an error creating the token, it returns an internal server error response to the client. If both user creation and token generation are successful, it writes the generated token (from CreateToken) to the HTTP response.

Verifying a token

Now let’s see how we can verify a token to ensure that the user with the right role can take the right action;


func VerifyToken(tokenString string) (*jwt.Token, error) {
    // Parse the token with the secret key
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        return secretKey, nil
    })

    // Check for verification errors
    if err != nil {
        return nil, err
    }

    // Check if the token is valid
    if !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }

    // Return the verified token
    return token, nil
}

func IsUserAdmin(token *jwt.Token) bool {
    // get token from request headers
    claims := token.Claims

    audClaims, audienceErr := claims.GetAudience()

    if audienceErr != nil {
        return false
    }

    userRole := audClaims[0]

    res := userRole == "admin"

    return res
}
Enter fullscreen mode Exit fullscreen mode

We have added two helper functions one for verifying our token and another for checking if the user is an admin or not. functions VerifyToken and IsUserAdmin work together to validate and extract information from tokens generated by the CreateToken function.

VerifyToken: This function takes a token string (received from a client) and verifies its authenticity using the same secret key used for signing in CreateToken. If the token is valid, it returns the parsed token object. If the token is invalid or there are errors during parsing, it returns an error.

The IsUserAdmin: This function assumes a verified token is provided as input (from VerifyToken). It extracts the audience information (claims) from the token and checks if the user role (audience) is set to "admin". If the user role is "admin", it returns true. Otherwise, it returns false.

In essence, these functions ensure that only authorized users (with valid tokens) can access specific functionalities (like admin actions) within your application.


func CreateProject() func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        tokenString := r.Header.Get("Authorization")
        token, err := helper.VerifyToken(tokenString)

        if err != nil {
            http.Error(w, err.Error(), http.StatusUnauthorized)
            return
        }

        isAdmin := helper.IsUserAdmin(token)

        if !isAdmin {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        // create project somehow
        result := db.Create(&project)

        if result.Error != nil {
            http.Error(w, result.Error.Error(), http.StatusInternalServerError)
            return
        }

        json.NewEncoder(w).Encode(project)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above defines a function named CreateProject that serves as a route handler for creating projects. The function returns another function that acts as the actual handler for project creation requests. The inner function retrieves the authorization token from the request header then It calls the VerifyToken function to validate the retrieved token string. If the token is invalid or there are errors during verification, it returns an unauthorized error message (HTTP status code 401) to the client. If the token is valid, it calls the IsUserAdmin function (likely also from the helper package) to check if the user making the request has admin privileges based on the token's audience information.

If the user is not an admin, it returns an unauthorized error message (HTTP status code 401) to the client. If both token verification and admin checks pass, it attempts to create a new project. If there's an error creating the project, it returns an internal server error message (HTTP status code 500) to the client. Upon successful project creation, it encodes the newly created project object as JSON and sends it back in the HTTP response.

Let’s add another function that allows any user with a token to retrieve a list of projects.


func GetProjects() func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        tokenString := r.Header.Get("Authorization")
        _, err := helper.VerifyToken(tokenString)
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnauthorized)
            return
        }

        // get project some how
        db.Find(&projects)

        json.NewEncoder(w).Encode(projects)
    }
}
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .