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
Let’s navigate into that project directory and create a Go mod file.
cd go_jwt && go mod init ./go_jwt
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
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)
}
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
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
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
}
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);
}
}
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
}
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)
}
}
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)
}
}