Welcome to the first lesson of our course on Securing And Testing the Gin ToDo App. In this lesson, we will focus on implementing user authentication through registration and login functionalities. Authentication is crucial because it ensures that only authorized users can access certain parts of your application. By the end of this lesson, you will understand how to securely register new users and authenticate existing users through the use of hashed passwords.
User registration and login are fundamental components of web security. Registration occurs when a new user creates an account on your app, while login is when an existing user accesses their account.
To ensure security, it's vital to hash passwords before storing them. Hashing is a one-way process that converts a password into an unreadable string. This means that even if a malicious actor accesses your database, they can't see the original passwords. It's important to note that hashing should be performed with a salt, a random value added to the password before hashing, to defend against precomputed attacks like rainbow tables.
In this lesson, we'll use bcrypt
for password hashing. bcrypt
is a popular library that makes it easy to hash and verify passwords. It automatically handles issues like salting, which adds extra security to the hashed data. Additionally, bcrypt
allows you to control the computational cost of hashing, which can be adjusted to enhance security based on hardware capabilities.
In our application, we use a User
struct to represent users within the database:
Go1package models 2 3import ( 4 "gorm.io/gorm" 5) 6 7type User struct { 8 gorm.Model 9 Username string `gorm:"uniqueIndex"` 10 Password string 11}
This struct plays a crucial role in user management and database interactions. It incorporates gorm.Model
, which is a convenient abstraction provided by GORM. By embedding gorm.Model
, our User
struct automatically includes fields like ID
, CreatedAt
, UpdatedAt
, and DeletedAt
. This allows for efficient tracking of user records over time without the need to define these fields explicitly in every model.
Additionally, the Username
field uses the tag gorm:"uniqueIndex"
, which enforces a uniqueness constraint at the database level. This ensures that every username in our database is distinct, preventing duplicate user accounts. When a user attempts to register with an existing username, the database generates an error, which is handled by our registration logic to provide an appropriate error message. These design choices enhance the integrity and reliability of our application's user management system.
Now, we'll write the code for the user registration handler. This function will take a JSON payload containing a username and password, validate it, hash the password, and store the user in the database.
Here's how you can implement it:
controllers/controllers.go
:
Go1package controllers 2 3import ( 4 "net/http" 5 6 "github.com/gin-gonic/gin" 7 "gorm.io/gorm" 8 "github.com/go-playground/validator/v10" 9 10 "codesignal.com/todoapp/services" 11) 12var validate = validator.New() 13 14func Register(c *gin.Context, db *gorm.DB) { 15 var temp struct { 16 Username string `json:"username" validate:"required,min=3,max=20"` 17 Password string `json:"password" validate:"required,min=8"` 18 } 19 20 if err := c.ShouldBindJSON(&temp); err != nil { 21 c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) 22 return 23 } 24 25 if err := validate.Struct(&temp); err != nil { 26 c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()}) 27 return 28 } 29 30 if err := services.RegisterUser(db, temp.Username, temp.Password); err != nil { 31 c.JSON(http.StatusBadRequest, gin.H{"error": "Username already exists"}) 32 return 33 } 34 35 c.JSON(http.StatusCreated, gin.H{"message": "User created"}) 36}
services/services.go
:
Go1package services 2 3import ( 4 "golang.org/x/crypto/bcrypt" 5 "gorm.io/gorm" 6 7 "codesignal.com/todoapp/models" 8 "codesignal.com/todoapp/repositories/user_repository" 9) 10 11func RegisterUser(db *gorm.DB, username string, password string) error { 12 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 13 if err != nil { 14 return err 15 } 16 17 user := models.User{Username: username, Password: string(hashedPassword)} 18 return user_repository.CreateUser(db, &user) 19}
repositories/user_repository/user_repository.go
:
Go1package user_repository 2 3import ( 4 "gorm.io/gorm" 5 6 "codesignal.com/todoapp/models" 7) 8 9func CreateUser(db *gorm.DB, user *models.User) error { 10 result := db.Create(user) 11 return result.Error 12}
Let's break down the core parts of this setup:
- Validation: In the controller function, a Go Playground Validator is used to enforce rules such as a required username and password, with specific length constraints (
min=3
,max=20
for username andmin=8
for password). This is achieved by adding thevalidate:
annotation in the struct declaration. - Password Hashing: In
services.go
, thebcrypt.GenerateFromPassword
function is used to hash the user's password. This function takes two parameters: a byte slice of the password and the cost factor (bcrypt.DefaultCost
). The cost factor determines the computational complexity of the hashing process, which you can tune based on hardware capabilities for better security. The output is a hashed version of the password, which is stored in the database instead of the plain text password, enhancing security. - Database Interaction: Moving to the user repository in
user_repository.go
, we employ GORM'sdb.Create(&user)
method to create a new user record in the database. TheUser
struct contains theUsername
and the hashedPassword
. The database is configured to ensure the uniqueness of the username, so if the given username already exists, a database error is triggered, which is caught and handled by returning an appropriate JSON response.
For the login functionality, we will create a handler that checks the user's credentials and authenticates them.
Here's the implementation:
/controllers/controllers.go
:
Go1func Login(c *gin.Context, db *gorm.DB) { 2 var temp struct { 3 Username string `json:"username"` 4 Password string `json:"password"` 5 } 6 7 if err := c.ShouldBindJSON(&temp); err != nil { 8 c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"}) 9 return 10 } 11 12 if err := services.ValidateUserCredentials(db, temp.Username, temp.Password); err != nil { 13 c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) 14 return 15 } 16 17 c.JSON(http.StatusOK, gin.H{"message": "Login successful"}) 18}
services/services.go
:
Go1func ValidateUserCredentials(db *gorm.DB, username string, password string) error { 2 user, err := user_repository.GetUserByUsername(db, username) 3 if err != nil { 4 return err 5 } 6 7 return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) 8}
repositories/user_repository/user_repository.go
:
Go1func GetUserByUsername(db *gorm.DB, username string) (*models.User, error) { 2 var user models.User 3 err := db.Where("username = ?", username).First(&user).Error 4 return &user, err 5}
Let's take a closer look at the workings of this implementation:
- User Lookup: The
db.Where("username = ?", temp.Username).First(&user)
line inuser_repository.go
uses GORM to query the database for a user record matching the provided username. This step is crucial to find the user attempting to log in. If the user doesn't exist, an error is returned, indicating that the credentials are invalid. - Password Verification: In
services.go
, once a user is found,bcrypt.CompareHashAndPassword
compares the stored hashed password with the password provided by the user during login. The first parameter is the hashed password from the database, and the second is the plain password from the user input. If the verification fails, an error response is sent back to indicate invalid credentials. This function ensures that only users who know the correct password can log in.
In both functions, the use of bcrypt
ensures secure password handling, and the database operations with GORM provide a reliable way to manage user data. The functions utilize the Gin framework to manage HTTP requests and responses, effectively handling user input and system feedback.
Now that we've set up the registration and login handlers, let's see how they fit into the overall application:
router/router.go
:
Go1package router 2 3import ( 4 "github.com/gin-gonic/gin" 5 6 "codesignal.com/todoapp/controllers" 7 "codesignal.com/todoapp/repositories/db" 8) 9 10func SetupRouter() *gin.Engine { 11 database := db.ConnectDatabase() 12 13 r := gin.Default() 14 r.POST("/register", func(c *gin.Context) { controllers.Register(c, database) }) 15 r.POST("/login", func(c *gin.Context) { controllers.Login(c, database) }) 16 17 return r 18}
This code initializes the database and the Gin router. It sets up routes for registration and login using the handlers we wrote earlier. Running this application will start a server on port 3000
, where users can register and login.
In this lesson, you learned how to implement user registration and login functionality in a web application using the Gin framework and GORM. We covered:
- The importance of user authentication and secure password handling.
- Setting up a database with GORM for storing user data.
- Writing handler functions for user registration and login.
Next, you will get to practice these concepts and see how they fit into the larger picture of developing a secure and robust ToDo application. Keep up the good work, and let's continue building a great app!