In this part, we will configure the SignUp route for our server.
Getting Started
For the authentication process of the user, we will be using JSON Web Tokens(JWT's). I am gonna assume you know what a JWT is. If you want to learn about JWT, here is a very good introduction to it.
We will first start by creating some new models inside our models/user.go file.
We will need to add a model called Claims and a model called UserErrors inside the file. The Claims will contain the struct of the information that the JWT token will contain. And the UserErrors will contain the return body if there is an error during the registration process.
models/user.go
package models
++ import (
++ "github.com/dgrijalva/jwt-go"
++ )
// User represents a User schema
type User struct {
Base
Email string `json:"email" gorm:"unique"`
Username string `json:"username" gorm:"unique"`
Password string `json:"password"`
}
++ // UserErrors represent the error format for user routes
++ type UserErrors struct {
++ Err bool `json:"error"`
++ Email string `json:"email"`
++ Username string `json:"username"`
++ Password string `json:"password"`
++ }
++ // Claims represent the structure of the JWT token
++ type Claims struct {
++ jwt.StandardClaims
++ ID uint `gorm:"primaryKey"`
++ }
Auto Migrate the data
Now that we will store data in our database we need to migrate our data. We will use the AutoMigrate function from Gorm for this.
database/postgres.go
package database
import (
"fmt"
"go-authentication-boilerplate/models"
"log"
"os"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DB represents a Database instance
var DB *gorm.DB
// ConnectToDB connects the server with database
func ConnectToDB() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading env file \n", err)
}
dsn := fmt.Sprintf("host=localhost user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
os.Getenv("PSQL_USER"), os.Getenv("PSQL_PASS"), os.Getenv("PSQL_DBNAME"), os.Getenv("PSQL_PORT"))
log.Print("Connecting to Postgres DB...")
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database. \n", err)
os.Exit(2)
}
log.Println("connected")
// turned on the loger on info mode
DB.Logger = logger.Default.LogMode(logger.Info)
++ log.Print("Running the migrations...")
++ DB.AutoMigrate(&models.User{}, &models.Claims{})
}
Creating Utility Functions
We will be creating two tokens, an access_token and a refresh_token. We will create tokens using a Go library called jwt-go.
Then we will create a middleware that validates the access_token stored in the Request's cookie then another function that returns token cookies when called.
Now, we need to create a folder named util. Inside util we will create a new file called auth.go. Right now, this file will contain 5 different functions.
- GenerateTokens - This function will return access_token and refresh_token.
- GenerateAccessClaims - GenerateAccessClaims generates and returns a claim and a acess_token string. This will create an access token with an expiry of 15 minutes.
- GenerateRefreshClaims - GenerateRefreshClaims generates and returns a refresh_token string. This will create a refresh token with an expiry of 30 days.
- SecureAuth - SecureAuth returns a middleware which secures all the private routes. This middleware first validates the access_toke. If the token is valid then we will use Locals which is a method that stores variables scoped to the request.
- GetAuthCookies - GetAuthCookies sends two cookies of type access_token and refresh_token.
util/auth.go
package util
import (
db "go-authentication-boilerplate/database"
"go-authentication-boilerplate/models"
"time"
"os"
"github.com/dgrijalva/jwt-go"
"github.com/gofiber/fiber/v2"
)
var jwtKey = []byte(os.Getenv("PRIV_KEY"))
// GenerateTokens returns the access and refresh tokens
func GenerateTokens(uuid string) (string, string) {
claim, accessToken := GenerateAccessClaims(uuid)
refreshToken := GenerateRefreshClaims(claim)
return accessToken, refreshToken
}
// GenerateAccessClaims returns a claim and a acess_token string
func GenerateAccessClaims(uuid string) (*models.Claims, string) {
t := time.Now()
claim := &models.Claims{
StandardClaims: jwt.StandardClaims{
Issuer: uuid,
ExpiresAt: t.Add(15 * time.Minute).Unix(),
Subject: "access_token",
IssuedAt: t.Unix(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString(jwtKey)
if err != nil {
panic(err)
}
return claim, tokenString
}
// GenerateRefreshClaims returns refresh_token
func GenerateRefreshClaims(cl *models.Claims) string {
result := db.DB.Where(&models.Claims{
StandardClaims: jwt.StandardClaims{
Issuer: cl.Issuer,
},
}).Find(&models.Claims{})
// checking the number of refresh tokens stored.
// If the number is higher than 3, remove all the refresh tokens and leave only new one.
if result.RowsAffected > 3 {
db.DB.Where(&models.Claims{
StandardClaims: jwt.StandardClaims{Issuer: cl.Issuer},
}).Delete(&models.Claims{})
}
t := time.Now()
refreshClaim := &models.Claims{
StandardClaims: jwt.StandardClaims{
Issuer: cl.Issuer,
ExpiresAt: t.Add(30 * 24 * time.Hour).Unix(),
Subject: "refresh_token",
IssuedAt: t.Unix(),
},
}
// create a claim on DB
db.DB.Create(&refreshClaim)
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaim)
refreshTokenString, err := refreshToken.SignedString(jwtKey)
if err != nil {
panic(err)
}
return refreshTokenString
}
// SecureAuth returns a middleware which secures all the private routes
func SecureAuth() func(*fiber.Ctx) error {
return func(c *fiber.Ctx) error {
accessToken := c.Cookies("access_token")
claims := new(models.Claims)
token, err := jwt.ParseWithClaims(accessToken, claims,
func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
if token.Valid {
if claims.ExpiresAt < time.Now().Unix() {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"general": "Token Expired",
})
}
} else if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
// this is not even a token, we should delete the cookies here
c.ClearCookie("access_token", "refresh_token")
return c.SendStatus(fiber.StatusForbidden)
} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {
// Token is either expired or not active yet
return c.SendStatus(fiber.StatusUnauthorized)
} else {
// cannot handle this token
c.ClearCookie("access_token", "refresh_token")
return c.SendStatus(fiber.StatusForbidden)
}
}
c.Locals("id", claims.Issuer)
return c.Next()
}
}
// GetAuthCookies sends two cookies of type access_token and refresh_token
func GetAuthCookies(accessToken, refreshToken string) (*fiber.Cookie, *fiber.Cookie) {
accessCookie := &fiber.Cookie{
Name: "access_token",
Value: accessToken,
Expires: time.Now().Add(24 * time.Hour),
HTTPOnly: true,
Secure: true,
}
refreshCookie := &fiber.Cookie{
Name: "refresh_token",
Value: refreshToken,
Expires: time.Now().Add(10 * 24 * time.Hour),
HTTPOnly: true,
Secure: true,
}
return accessCookie, refreshCookie
}
Now, we will need to create some validator functions to validate the input send while registering the user.
So we do this by creating a new file called validators.go inside the util folder.
util/validators.go
package util
import (
"go-authentication-boilerplate/models"
"regexp"
valid "github.com/asaskevich/govalidator"
)
// IsEmpty checks if a string is empty
func IsEmpty(str string) (bool, string) {
if valid.HasWhitespaceOnly(str) && str != "" {
return true, "Must not be empty"
}
return false, ""
}
// ValidateRegister func validates the body of user for registration
func ValidateRegister(u *models.User) *models.UserErrors {
e := &models.UserErrors{}
e.Err, e.Username = IsEmpty(u.Username)
if !valid.IsEmail(u.Email) {
e.Err, e.Email = true, "Must be a valid email"
}
re := regexp.MustCompile("\\d") // regex check for at least one integer in string
if !(len(u.Password) >= 8 && valid.HasLowerCase(u.Password) && valid.HasUpperCase(u.Password) && re.MatchString(u.Password)) {
e.Err, e.Password = true, "Length of password should be atleast 8 and it must be a combination of uppercase letters, lowercase letters and numbers"
}
return e
}
Creating a SignUp Route
First, we will modify the SetupRoutes function inside router/setup.go file.
router/setup.go
package router
import (
"github.com/gofiber/fiber/v2"
)
++ // USER handles all the user routes
++ var USER fiber.Router
// SetupRoutes setups all the Routes
func SetupRoutes(app *fiber.App) {
api := app.Group("/api")
++ USER = api.Group("/user")
++ SetupUserRoutes()
}
Now we will create a new file called users.go inside the router folder.
Next, we will create our SignUp route(finally!) inside that file.
We will use the following steps to register the user:
- Parse the input data into a User model struct.
- Validate the input by calling the ValidateRegister function from util/validators.go.
- Check that the email and username are unique.
- If all is well till now, then hash the password using bcrypt library with a random salt.
- Now, register the user inside our Database and generate the access and refresh tokens.
- Set the access and refresh token as cookies with httpOnly and secure flag.
- Return the tokens.
Following all these steps, our router/user.go file will look like this:
router/user.go
package router
import (
db "go-authentication-boilerplate/database"
"go-authentication-boilerplate/models"
"go-authentication-boilerplate/util"
"math/rand"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/dgrijalva/jwt-go"
"github.com/gofiber/fiber/v2"
)
var jwtKey = []byte(os.Getenv("PRIV_KEY"))
// SetupUserRoutes func sets up all the user routes
func SetupUserRoutes() {
USER.Post("/signup", CreateUser) // Sign Up a user
}
// CreateUser route registers a User into the database
func CreateUser(c *fiber.Ctx) error {
u := new(models.User)
if err := c.BodyParser(u); err != nil {
return c.JSON(fiber.Map{
"error": true,
"input": "Please review your input",
})
}
// validate if the email, username and password are in correct format
errors := util.ValidateRegister(u)
if errors.Err {
return c.JSON(errors)
}
if count := db.DB.Where(&models.User{Email: u.Email}).First(new(models.User)).RowsAffected; count > 0 {
errors.Err, errors.Email = true, "Email is already registered"
}
if count := db.DB.Where(&models.User{Username: u.Username}).First(new(models.User)).RowsAffected; count > 0 {
errors.Err, errors.Username = true, "Username is already registered"
}
if errors.Err {
return c.JSON(errors)
}
// Hashing the password with a random salt
password := []byte(u.Password)
hashedPassword, err := bcrypt.GenerateFromPassword(
password,
rand.Intn(bcrypt.MaxCost-bcrypt.MinCost)+bcrypt.MinCost,
)
if err != nil {
panic(err)
}
u.Password = string(hashedPassword)
if err := db.DB.Create(&u).Error; err != nil {
return c.JSON(fiber.Map{
"error": true,
"general": "Something went wrong, please try again later. 😕",
})
}
// setting up the authorization cookies
accessToken, refreshToken := util.GenerateTokens(u.UUID.String())
accessCookie, refreshCookie := util.GetAuthCookies(accessToken, refreshToken)
c.Cookie(accessCookie)
c.Cookie(refreshCookie)
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
Now that we have created our SignUp Route, we can start working on our SignIn route on the next Part.
Thanks for reading! If you liked this article, please let me know and share it!
Top comments (3)
Thanks for the great and informative article!
One question though, why would the cookie expiration on the access token be 24 hours when the expiration on the actual jwt access token is shorter? Is there a reason to have those expirations be different?
Hello! Why did you add this snippet? i cannot find the usage
// AfterUpdate will update the Base struct after every update
func (base *Base) AfterUpdate(tx *gorm.DB) error {
// update timestamps
base.UpdatedAt = GenerateISOString()
return nil
}
This is to update the updatedAt field after every update on the database