Welcome to this lesson, where we will explore how to protect all API methods using authentication. In previous lessons, you have learned how to implement user registration and login functionalities, which are crucial for managing user access. Now, we will focus on enhancing the security of our ToDo app by ensuring that only authenticated users can access the API endpoints.
Authentication prevents unauthorized users from accessing and manipulating data, which is vital for application integrity and user privacy. By the end of this lesson, you’ll be equipped to safeguard your application methods using authentication techniques.
JSON Web Tokens (JWT) are a popular choice for securing APIs due to their simplicity and effectiveness. A JWT is a compact, URL-safe token that represents claims between two parties. It consists of three parts: the header, the payload, and the signature.
- Header: Contains metadata about the token, like its type and the signing algorithm.
- Payload: Carries the claims or data you want to transmit (e.g., username). Claims are statements about an entity (typically, the user) and additional data. There are three types of claims:
- Registered Claims: Predefined claims such as
iss
(issuer),exp
(expiration time), andsub
(subject). - Public Claims: Custom claims that you define and register for public use.
- Private Claims: Custom claims agreed upon between parties sharing the JWT.
- Registered Claims: Predefined claims such as
- Signature: Ensures the token hasn't been altered, generated by signing the header and payload.
JWT is favored for API security because it allows stateless authentication, meaning the server doesn’t need to store every token it issues. This makes JWT efficient and scalable for modern applications.
To create a JWT, you typically follow these steps:
- Define Claims: Start by determining the claims, such as user information or permissions, that you want to include in the payload.
- Create the Header: Specify the token's type (
JWT
) and the signing algorithm, such asHS256
. - Sign the Token: Encode the header and payload to Base64Url format. Then, concatenate them with a period (
.
) separator, and sign the resulting string using a secret key. - Form the JWT: Combine the encoded header, payload, and signature with periods (
.
) to form the complete JWT.
Let's see how we can extend our Login service so that it makes use of JWT authentication:
Go1package services 2 3import ( 4 "time" 5 6 "github.com/golang-jwt/jwt/v5" 7) 8 9// Secret key for signing the JWT. 10// This is a simplification for demonstration purposes - in reality, it should be kept safe and secure and not available as plain text in the code. 11var jwtSecret = []byte("your-secret-key") 12 13func LoginUser(db *gorm.DB, username string, password string) (string, error) { 14 // Validate login credentials 15 if err := user_repository.ValidateUser(db, username, password); err != nil { 16 return "", err 17 } 18 // Define claims, which include a username and an expiration time 24 hours from now. 19 claims := jwt.MapClaims{ 20 "username": username, 21 "exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time 24 hours from current time 22 } 23 24 // Create a new JWT token with the specified claims, using the HS256 signing method. 25 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 26 27 // Sign the token using the secret key and return the signed token string. 28 return token.SignedString(jwtSecret) 29}
Clearly, the controller function attached to the /login
endpoint that makes use of this LoginUser
service must return the token in the response; that is:
Go1token, err := services.LoginUser(db, temp.Username, temp.Password) 2c.JSON(http.StatusOK, gin.H{"token": token})
The validation of a JWT ensures that it hasn't been tampered with and is issued by a trusted source. During validation, the following process is carried out:
- Extract the Token: Retrieve the JWT from the request header.
- Decode the JWT: Split the JWT into header, payload, and signature.
- Verify the Signature: Recompute the signature using the stored secret key and compare it with the received signature.
- Check Claims: Examine the payload claims for validity, such as checking the expiration time (
exp
) to ensure the token isn't expired.
Validation is a critical step in securing your API endpoints, confirming that the incoming requests are from an authenticated user.
Implementing JWT authentication requires attention to security best practices to mitigate potential risks.
- Keep Secrets Safe: Ensure that your secret keys are stored securely and not exposed in your codebase or logs.
- Use Strong Algorithms: Prefer algorithms like
HS256
orRS256
for signing to provide strong encryption. - Set Expiration Times: Include an
exp
claim to prevent tokens from being used indefinitely and protect against replay attacks. - Validate Token Audience and Issuer: Use
aud
andiss
claims to verify the token's audience and issuer, ensuring it was delivered to the intended recipient and issued by a trusted source. - Regenerate Tokens Periodically: Regularly rotate and regenerate tokens to limit the window of exposure in case of a compromised token.
By adhering to these practices, you enhance the security of your application while using JWTs for authentication.
In this section, we will build on your existing knowledge of middleware by implementing an authentication middleware using JWT for our Gin application. This middleware will ensure that all incoming requests are authenticated before they reach the endpoint logic.
Here's a step-by-step implementation of the authentication middleware, located in middleware/auth.go
:
Go1package auth 2 3import ( 4 "net/http" 5 "strings" 6 7 "github.com/gin-gonic/gin" 8 "github.com/golang-jwt/jwt/v5" // Import JWT package for token handling 9 10 "codesignal.com/todoapp/services" 11) 12 13func AuthMiddleware() gin.HandlerFunc { 14 return func(c *gin.Context) { 15 // Retrieve the Authorization header from the request 16 authHeader := c.GetHeader("Authorization") 17 if authHeader == "" { 18 // Respond with unauthorized if header is empty 19 c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is empty"}) 20 c.Abort() 21 return 22 } 23 24 // Split the header's Bearer token 25 tokenParts := strings.Split(authHeader, "Bearer ") 26 if len(tokenParts) != 2 { 27 // Respond with unauthorized if token format is incorrect 28 c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"}) 29 c.Abort() 30 return 31 } 32 33 // Extract the token string 34 tokenString := tokenParts[1] 35 claims := &services.Claims{} 36 37 // Parse and validate the JWT using the provided secret key 38 token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { 39 return services.JWTSecret, nil 40 }) 41 if err != nil || !token.Valid { 42 // Respond with unauthorized if token is invalid 43 c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) 44 c.Abort() 45 return 46 } 47 48 // Set the username in the context for access in subsequent handlers 49 c.Set("username", claims.Username) 50 c.Next() // Proceed to the next handler if token is valid 51 } 52}
In this code, we perform the following steps using specific functions:
- Gather the
Authorization
header usingc.GetHeader("Authorization")
. - Split the header to extract the token with
strings.Split()
and validate its presence. - Parse the token using
jwt.ParseWithClaims()
, which verifies its authenticity and extracts the claims. - If the token is invalid or missing, respond with an unauthorized error using
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
. - If valid, set the user's username in the context using
c.Set("username", claims.Username)
to make it accessible to other handlers.
With our middleware ready, we'll now apply it to our API routes to safeguard them. Here’s how the routes are defined and registered in todoapp/router/router.go
:
Go1package router 2 3import ( 4 "github.com/gin-gonic/gin" 5 6 "codesignal.com/todoapp/controllers" 7 "codesignal.com/todoapp/middleware/auth" 8 "codesignal.com/todoapp/repositories/db" 9) 10 11func RegisterRoutes() *gin.Engine { 12 database := db.ConnectDatabase() 13 14 router := gin.Default() 15 router.POST("/register", func(c *gin.Context) { controllers.Register(c, database) }) 16 router.POST("/login", func(c *gin.Context) { controllers.Login(c, database) }) 17 18 protected := router.Group("/api", auth.AuthMiddleware()) 19 { 20 protected.GET("/todos", func(c *gin.Context) { controllers.GetTodos(c, database) }) 21 protected.POST("/todos", func(c *gin.Context) { controllers.CreateTodo(c, database) }) 22 } 23 return router 24}
Here's what happens in the code:
- A
protected
group is created usingrouter.Group
. In Gin, a Group is a way to apply common middleware or path prefixes to a set of routes; for example, in the code snippet both routes share the common/api
prefix as well as theAuthMiddleware
authentication middleware as they belong to the sameprotected
group. - We define our
GET /todos
andPOST /todos
routes within this protected group, ensuring they are only accessible to authenticated users. - Unauthorized requests will be blocked at the middleware stage, never reaching the business logic of these endpoints.
Congratulations on completing this lesson! You have learned how to implement JWT-based authentication to protect your API methods using the Gin framework. By creating authentication middleware and integrating it with your route definitions, you set a strong foundation for safeguarding your application.
As you move forward, practical exercises will reinforce this knowledge by having you implement and test these concepts. Continue to explore how authentication can fortify the integrity and security of your applications.