This is a simple authentication server with a user database.
This project will use mysql and gorm.
Create a github repository, and copy it's url (removing the https:// part). Then, we'll the repo name to setup the golang project :
export REPO_NAME=#put your repository name here
go mod init $REPO_NAME
It's useful to synchronize the name of the repo with the go.mod file so that your project is automatically packaged, and installable with go install (or go get).
You should see at the root of the directory the go.mod file, with the correct name in it. This file will contain all the dependencies of your project.
Don't forget to run go mod tidy
to sync your source code with your dependencies.
We will divide our code with logical slices, and we will have the following folders :
- Model : this package will contain all the models used with the database orm.
To bypass the basic user setup, you can checkout the feat/user branch
We need to create a user with basic data (id, email, password and timestamps). Create a new file called model/user.go, and initialize it with the correct package name.
Here, we will define our User model, and add to it the hooks to handle timestamps and password hash (we never save the password in plain text !)
This is the code for the User struct
type User struct {
gorm.Model
Email string `json:"email"`
Password string `json:"-"`
}
Notice the json:"-" ? This instruct the JSON marshaller to ignore this field.
Next, we write the hooks to handle user specific logic
/*
BeforeCreate sets the CreatedAt and UpdatedAt fields to the current time,
hashes the user's password, and stores the hashed password in the Password field.
Args:
u (*User): a pointer to a User struct that includes the password to be hashed.
tx (*gorm.DB): a GORM database transaction.
Returns:
err (error): an error that occurred while setting the CreatedAt and UpdatedAt fields, hashing the password, or storing the hashed password in the Password field.
*/
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
u.CreatedAt = time.Now()
u.UpdatedAt = time.Now()
// hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return
}
u.Password = string(hashedPassword)
return
}
Using bcrypt, we set up the hooks to hash the password upon new creation. We also handle the timestamps this way.
Exercice
Write a Before save hook to handle the update of the updatedAt timestamp.
At the same time, verify if the password field is being updated. If so, re-hash it.
Finally, as the password is hashed, we need to provide a function to test a plaintext password
/*
CheckPassword takes a password string as input and compares it to the hashed password stored in the User struct.
It returns an error if the comparison fails.
Args:
u (*User): a pointer to a User struct that includes the password to be hashed.
tx (*gorm.DB): a GORM database transaction.
password (string): The password to check against the hashed password stored in the User struct.
Returns:
(error): An error if the password comparison fails.
*/
func (u *User) CheckPassword(password string) error {
return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
}
This function returns an error if the passwords do not match, nil otherwise.
We want to initialize a database connection on our api startup. For that, we will create two files in a config folder. One will be to load variable from the environnement (aka .env), the other for the mysql database connection.
Let's start with the latter. Create the config/database.go file :
/*
InitDB initializes a GORM database connection using the provided Config.
Parameters:
- config (*Config): A pointer to the Config struct containing database connection details.
Returns:
- (*gorm.DB): A pointer to the GORM database object.
- (error): An error object if the connection fails, nil otherwise.
*/
func InitDB() (*gorm.DB, error) {
dsn := "root:rootme@tcp(127.0.0.1:3306)/go_user_auth?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}
For now, we will leave the connection string as it is. If you didn't do it already, spin up a mariadb instance using the provided compose.yml file.
Next, we will want to load the connection informations from the environnement. Let's write in config/env.go :
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DB_HOST string
DB_USER string
DB_PASS string
DB_PORT string
DB_NAME string
}
func InitConfig() *Config {
godotenv.Load()
return &Config{
DB_HOST: os.Getenv("DB_HOST"),
DB_USER: os.Getenv("DB_USER"),
DB_PASS: os.Getenv("DB_PASS"),
DB_PORT: os.Getenv("DB_PORT"),
DB_NAME: os.Getenv("DB_NAME"),
}
}
We will be using the godotenv package to automatically read from a .env file. The env file will look like this
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=rootme
DB_NAME=go_user_auth
Let's modify our InitDB function to take the incoming config. First, add a parameter to the function
func InitDB(config *Config) (*gorm.DB, error)
Then, format the dsn string to interpolate with the config
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", config.DB_USER, config.DB_PASS, config.DB_HOST, config.DB_PORT, config.DB_NAME)
Let's test this.
Create a main.go in the root directory, and tie together our work done so far
func main() {
conf := config.InitConfig()
db, err := config.InitDB(conf)
if err != nil {
log.Fatalln(err)
}
db.AutoMigrate(&model.User{})
}
Don't forget to run go mod tidy
once in a while.
To run our little api, type go run main.go
in your terminal. If you don't see any error, you should be good to continue.
We will expose our CRUD User service in a struct, called UserService. We will then have a layer of isolation between our controller and our ORM, which is always a good practice.
Inside service/user.go, we will initialize our user service by creating a struct with the according New function
type UserService struct {
db *gorm.DB
}
/*
NewUserService returns a new instance of the UserService struct with the provided gorm.DB instance
as its database connection.
Parameters:
- db (*gorm.DB): The gorm.DB instance to use as the database connection.
Returns:
- (*UserService): A pointer to the newly created UserService instance.
*/
func NewUserService(db *gorm.DB) *UserService {
return &UserService{
db: db,
}
}
Now, let's write our basic Read functions :
/*
GetUser retrieves a user by ID from the database.
Parameters:
s - a pointer to a UserService instance
id - the ID of the user to retrieve
Return values:
*model.User - a pointer to the retrieved user object
error - if any error occurs while retrieving the user, it is returned here
*/
func (s *UserService) GetUser(id int) (*model.User, error) {
var user model.User
err := s.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
/*
GetUsers retrieves all users from the database.
Returns:
- []*model.User: A slice of user objects.
- error: An error object if the query fails.
*/
func (s *UserService) GetUsers() ([]*model.User, error) {
var users []*model.User
err := s.db.Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
Exercice
Write a delete function that takes in an id, deletes the record, and returns the deleted record. Don't forget to handle the possible errors (at least not found).
Write a GetByEmail that fetches user by email. As we did not specify uniqueness on the email field, these search would possibly return multiple records.
To create and update a user, we will create the according DTO. These struct will help us maintain a clean code, while providing knowledge of the API later with swagger.
In model/userDTO.go, we will setup the create and update object.
type UserCreateDTO struct {
Email string `json:"email"`
Password string `json:"password"`
}
type UserUpdateDTO struct {
Email string `json:"email"`
}
For good practice, we will disallow the user from directly update his password with the CRUD update function.
With these DTO, let's write the create function
/*
CreateUser creates a new user in the UserService database.
Args:
- s (*UserService): A pointer to the UserService instance.
- data (*model.UserCreateDTO): A pointer to the data used to create the new user.
Returns:
- (*model.User): A pointer to the newly created user.
- (error): An error if the creation failed.
*/
func (s *UserService) CreateUser(data *model.UserCreateDTO) (*model.User, error) {
user := &model.User{
Email: data.Email,
Password: data.Password,
}
err := s.db.Save(&user).Error
if err != nil {
return nil, err
}
return user, nil
}
Exercice
Write the update function. This function should return the updated user.
We have our service, our model and our database connection. Let's add the last functionnal part by creating the controller, aka gin handler. This controller will be in charge of taking the incomming request, parsing it's body and parameters, and call the appropriate service function.
In a handler/user.go file, proceed to write the following initializer
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}
A basic get function will look like this
/*
GetUser gets a user by their ID from the userService and returns it in the response body.
Parameters:
- c (*gin.Context): the context of the current HTTP request
- h (*UserHandler): the handler that handles user-related requests
Errors:
- 400 Bad Request: if the parameter id cannot be converted to an integer, or if there is an error retrieving the user
*/
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
user, err := h.userService.GetUser(id)
if err != nil {
log.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
c.JSON(200, user)
}
The id is retrieved from the route, and we will implement the router now to test that our api starts.
Finally, add the following to the main and try starting your api
userService := service.NewUserService(db)
userHandler := handler.NewUserHandler(userService)
r := gin.Default()
userApi := r.Group("/api/v1/user")
userApi.GET("/:id", userHandler.GetUser)
r.Run()
If you can curl localhost:8080/api/v1/user/2
and get a {"error":"record not found"}, everything is good.
Exercice
Write the rest of the CRUD functions to the service. Add the according route and test it out.
If you are tired of killing and re-runnig your api, you can use gowatch. This is a file watcher and go builder that automatically restart whenever you make a change.
So, you already know the logic of a jwt. We store the user's information inside it, we secure it with a key, and we set an expiration time on it. The refresh token acts like a second key, simpler as it has no information inside it, that enable long term jwt regeneration. It can last a month or more, and is used to refresh the jwt token. We store it in the cookies of a browser, alongside the jwt. And when a client closes his laptop and later re-enters our site, he does not have to write his connection information again.
Let's dive in.
We want to save the refresh token in the database, and link it with a foreign key to the user. Let's create the model/refreshToken.go file
type RefreshToken struct {
gorm.Model
User User
UserId int `json:"userId" gorm:"<-:create"`
Ip string `json:"ip" gorm:"<-:create"`
Hash string `json:"hash" gorm:"<-:create unique index"`
}
func (rt *RefreshToken) BeforeCreate(tx *gorm.DB) (err error) {
rt.CreatedAt = time.Now()
rt.UpdatedAt = time.Now()
return
}
We will use gorm annotation to provide settings to the refresh token model. "<-:create" specify that the field is only writable upon creation.
Exercice
Write the refresh token service to handle creation and retrieval of a refresh token by hash. be careful, gorm does not load the relations by default !
We will need to do the following :
- Create a function to generate a token, using the JWT_SECRET from the environnement and with a lifespan of 5 minutes
- Add the token to the cookies when a user successfully logs in or registers
- Verify the token on protected route
- Refresh the token with the refresh token
Let's create the auth handler In handler/auth.go, we'll instantiate the struct and create the initialization function
type AuthHandler struct {
RTService *service.RTService
UserService *service.UserService
*config.Config
}
func NewAuthHandler(rTService *service.RTService, userService *service.UserService, config *config.Config) *AuthHandler {
return &AuthHandler{
RTService: rTService,
UserService: userService,
Config: config,
}
}
Exercice
Add a JWT_SECRET field in the config struct, and parse it from the environnement
Next, let's create the jwt generation function
/*
GenerateToken generates a JWT token for a given user.
Args:
AuthHandler (*AuthHandler): A pointer to the AuthHandler object.
user (*model.User): A pointer to the User object.
Returns:
string: The generated JWT token.
error: An error if one occurred during the generation process.
*/
func (authHandler *AuthHandler) GenerateToken(user *model.User) (string, error) {
claims := jwt.MapClaims{}
claims["authorized"] = true
claims["id"] = user.ID
claims["exp"] = time.Now().Add(time.Minute * 5).Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(authHandler.JWT_SECRET))
}
We'll use this jwt package.
Let's dive into the handler for the login request. We need to extract the JSON body, check the email and password, create the two tokens and add them to the cookie and the JSON response
Exercice
Create the LoginDTO in the model folder
/*
Login handles the login request. It parses the request body into a LoginDTO struct
and attempts to retrieve a user from the UserService instance with the email provided
in the LoginDTO. If a user is found, the password is checked against the user's hashed
password. If the password matches, a JWT is generated and set as a cookie in the response.
A refresh token is also generated and set as a cookie in the response. Finally, a JSON
response is returned with the JWT, the refresh token, and the user object.
@param authHandler *AuthHandler: an instance of the AuthHandler struct
@param c *gin.Context: the current request context
@return none
*/
func (authHandler *AuthHandler) Login(c *gin.Context) {
var loginDTO *model.LoginDTO
if err := c.ShouldBindJSON(&loginDTO); err != nil {
fmt.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
user, err := authHandler.UserService.GetUserByEmail(loginDTO.Email)
if err != nil {
fmt.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
err = user.CheckPassword(loginDTO.Password)
if err != nil {
fmt.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
jwt, err := authHandler.GenerateToken(user)
if err != nil {
fmt.Println(err)
if err == bcrypt.ErrMismatchedHashAndPassword {
c.JSON(400, gin.H{
"error": "incorrect password",
})
} else {
c.JSON(400, gin.H{
"error": err.Error(),
})
}
return
}
rt, err := authHandler.RTService.CreateRT(c.ClientIP(), int(user.ID))
if err != nil {
fmt.Println(err)
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
c.SetCookie("jwt", jwt, 3600, "/", "*", true, true)
c.SetCookie("rt", rt.Hash, 3600, "/", "*", true, true)
c.JSON(200, gin.H{
"token": jwt,
"refreshToken": rt.Hash,
"user": user,
})
}
Exercice
Add the handler to the login route. This route should be under the auth group, with a base url like "api/v1/auth". You'll need to instanciate the handler and the refresh token service in the main.go file.
You can now verify with the created route that the login is working, returning a jwt, a refresh token, and the user. Also verify the errors when putting an incorrect email and/or password.
Let's create a custom auth middleware. The following code defines a middleware function called AuthMiddleware that handles user authentication using JWT tokens. It takes in an AuthHandler instance containing JWTSECRET and a gin.Context instance as parameters, and returns a function that handles the middleware. The function first checks for the JWT token in the cookie or the Authorization header of the request, and extracts it. Then it verifies the token using the JWTSECRET from the AuthHandler instance, and extracts the user information from the token. Finally, it sets the user information in the gin.Context and passes the request to the next middleware.
/*
AuthMiddleware is a middleware function that handles user authentication using JWT tokens.
Parameters:
- authHandler (*AuthHandler): A pointer to an AuthHandler instance containing JWT_SECRET.
- c (*gin.Context): A pointer to the gin.Context instance.
Returns:
- gin.HandlerFunc: A function that handles the middleware.
*/
func (authHandler *AuthHandler) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// before request
jwtToken, err := c.Cookie("jwt")
if err == http.ErrNoCookie {
authHeader := c.GetHeader("Authorization")
splitToken := strings.Split(authHeader, "Bearer ")
if len(splitToken) != 2 {
c.JSON(401, gin.H{
"error": "cannot extract token from authorization header",
})
c.Abort()
return
}
jwtToken = splitToken[1]
if jwtToken == "" {
c.JSON(401, gin.H{
"error": "no token provided",
})
c.Abort()
return
}
}
if err != nil {
c.JSON(401, gin.H{
"error": err.Error(),
})
c.Abort()
return
}
token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(authHandler.JWT_SECRET), nil
})
if err != nil {
c.JSON(401, gin.H{
"error": err.Error(),
})
c.Abort()
return
}
if !token.Valid {
c.JSON(401, gin.H{
"error": "invalid token",
})
c.Abort()
return
}
userId := token.Claims.(jwt.MapClaims)["id"].(float64)
user, err := authHandler.UserService.GetUser(int(userId))
if err != nil {
c.JSON(401, gin.H{
"error": err.Error(),
})
c.Abort()
return
}
c.Set("user", user)
c.Next()
// after request
}
}
Exercice
Add this middleware to a hello world route and test it with the jwt in the cookie and in the authorization header.
Then, try to get the user from the gin context and return it in the response.
Add the automatic renewal of the refresh token. This renewal will take effect only when the jwt is expired.
It's important to document our API. To do that, let's use swag. First, install the swag binary :
go install github.com/swaggo/swag/cmd/swag@latest
Then, let's initialize our swagger docs :
swag init
It should create a new docs folder. Now, everytime you make a swagger documentation change, you need to run swag init so that it can generate the documentation from the godoc comments.
In main.go, we'll put the basic api documentation with the route to the swagger docs.
Just before the main function declaration, add the following comments :
// @title Gorm User & Auth
// @version 0.0.3
// @description This is a simple user registration and auth server with automatic jwt renewal.
// @BasePath /api/v1
func main()
Then, let's add the correct import :
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
Finally, we'll setup the swagger route :
r := gin.Default()
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
After running swag init
and starting the server, the documentation should be avalaible at http://localhost:8080/swagger/index.html .
With that setup, you can add annotation to every route so they are documented.
In handler/user.go, we'll setup the user routes. First, because swag cannot go through the grom.Model we added to our user model, we need to specify here the response and error type (specifically for swagger):
type UserRespone struct {
ID int `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
Now we can add the swagger documentation for our first functions :
// GetUser godoc
// @Summary Get a User
// @Description get user by ID
// @Tags User
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} UserRespone
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /user/{id} [get]
func (h *UserHandler) GetUser(c *gin.Context)
// GetUsers godoc
// @Summary Get all Users
// @Description get all users with no filter
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} UserRespone[]
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /user [get]
func (h *UserHandler) GetUsers(c *gin.Context)
See how we used UserResponse struct ? You can put almost any go struct for object, including in the body with the @Param tag.
Exercice
Add the rest of the swagger annotation. It should be visible and querryable from the UI.