Devoping 3: Frontend and API
This is not intended to be a guide. I’m sure that everything explained here can be done in a better/easier/more efficient way. Here, I will explain the whole learning process and the solutions I found that could solve my problems.
In the last post, I described how to automate unit testing using github actions. Our code contains just some CRUD functions. In this post I will briefly describe how I developed the backend API and the endpoint. I will focus in important functionalities like middleware, sessions, etc.
Backend API
For the backend development, I used the GIN framework that allows us to manage routers and middleware more easy.
The main idea of the backend was to develop an API with several endpoints that makes usage of the CRUD oprerations to interact with the DB and obtain persistance. I want the endpoints to be protected so only logged users can use them. To achieve this we need:
- Protected and unprotected endpoints
- Sessions
- Auth middleware
Protected and unprotected endpoints
We just need two endpoints to be unprotected: the /login and the /register. This endpoints will be used by users that haven’t been logged on yet or that they want to create an account.
The rest of the endpoints should be protected. We can easily do this with Gin creating two routing groups. For the protected routes, we use the Use()
function and add the middleware function that we want to execute before executing the handler.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Routes not protected by authentication middleware
authRoutes := router.Group("/")
{
authRoutes.POST("/api/login", handleLogin)
authRoutes.POST("/api/register", handleRegister)
}
// Routes protected by authentication middleware
protectedRoutes := router.Group("/")
protectedRoutes.Use(auth.AuthMiddleware())
{
protectedRoutes.GET("/api/lists", handleLists)
protectedRoutes.GET("/api/logout", handleLogout)
protectedRoutes.GET("/api/lists_restaurants", handleListRestaurants)
protectedRoutes.POST("/api/create_list", handleCreateList)
protectedRoutes.POST("/api/create_restaurant", handleCreateRestaurant)
protectedRoutes.GET("/api/restaurant_search", handleSearchRestaurant)
protectedRoutes.POST("/api/add_to_list", handleAddRestaurantToList)
protectedRoutes.POST("/api/delete_from_list", handleDeleteRestaurantFromList)
}
As you can see, it is really easy to create endpoints with Gin. We create a routing group and then using the .GET()
or .POST()
functions (to define what HTTP method will trigger the endpoint) we define the endpoint name and then the handler that will be executed.
The handlers are functions that contain the logic of the endpoint and return the results or the corresponding error. After explaining how we handle sessions and the auth middleware, I will show the code of the Login handler.
Sessions
To maintain sessions, I decided wanted to use JWT (Json Web Tokens) since there is also a library for this in golang. A JWT contains information (also known as claims). The information that I want to keep in the token is the session ID and some predefined common claims:
1
2
3
4
type Claims struct {
UserID int `json:"userID"`
jwt.RegisteredClaims
}
If you want to learn more about JWT and common vulnerabilities, you can check this other post: JWT Vulnerabilities
Then I created a function to generate a token:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func GenerateToken(userID int) (string, error) {
claims := &Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString([]byte(os.Getenv("JWT_KEY")))
if err != nil {
return "", err
}
return "Bearer " + signedToken, nil
}
In this function, we obtain the ID of the user that wants to establish a session, then we create the claims using the correct data and sign the jwt using HMAC hashing algorithm and our private key defined in a environment variable (JWT_KEY). This code generates a valid and signed Bearer jwt.
Next, we need to create another function that checks if a jwt is valid or not. I created this one:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func VerifyToken(tokenString string) (*Claims, error) {
const bearerPrefix = "Bearer "
if strings.HasPrefix(tokenString, bearerPrefix) {
// Remove the "Bearer " prefix
tokenString = tokenString[len(bearerPrefix):]
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_KEY")), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("Invalid Token")
}
return claims, nil
}
We first remove the Bearer prefix and then we parse the token using the ParseWithClaims
function. Then we get the claims and check if everything is correct and the session is still valid and hasn’t expired using token.Valid
Now that we have this VerifyToken
function, we can develop our auth middleware that will protect our endpoints.
Auth Middleware
We have some endpoints that should only be used by logged on users. A logged on uses should have a valid session (JWT). We have a function that validates if a token is valid or not. We can put all this together and create a function that protects the endpoints and if the user has a valid token, executes the handler, otherwise it sends a unauthorized error.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// AuthMiddleware checks if there is a valid token and attaches user information to the Gin context.
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := c.Cookie("usession")
if tokenString == "" || err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token not provided"})
return
}
claims, err := VerifyToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Token"})
return
}
// Attach user data to Gin context
c.Set("user", claims)
// Continue to the next handler
c.Next()
}
}
This function returns a gin handler function that will be executed as middleware. This function obtains the usession
cookie from the HTTP request and it sends it to the VerifyToken
function that we explained before. If we don’t obtain valid claims it implies that there was an error so we return with an Unauthorized error. Otherwise, we add the claims data to the gin context so the handlers that will be executed next will be able tu use the user id.
When we defined the endpoint groups, for the protected endpoints we used this function as middleware, so before executing the handler, the AuthMiddleware will be executed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Routes protected by authentication middleware
protectedRoutes := router.Group("/")
protectedRoutes.Use(auth.AuthMiddleware())
{
protectedRoutes.GET("/api/lists", handleLists)
protectedRoutes.GET("/api/logout", handleLogout)
protectedRoutes.GET("/api/lists_restaurants", handleListRestaurants)
protectedRoutes.POST("/api/create_list", handleCreateList)
protectedRoutes.POST("/api/create_restaurant", handleCreateRestaurant)
protectedRoutes.GET("/api/restaurant_search", handleSearchRestaurant)
protectedRoutes.POST("/api/add_to_list", handleAddRestaurantToList)
protectedRoutes.POST("/api/delete_from_list", handleDeleteRestaurantFromList)
}
If the middleware fails because the user doesn’t have a valid session, the handler won’t be executed. Since the login and the register endpoints will be used by users with no sessions, these endpoints are not protected by the auth middleware.
This is the register handler.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func handleRegister(c *gin.Context) {
var registerData struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(®isterData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Bad Request"})
return
}
//mirar si ya existe este usuario
_, err := database.GetUserByUsername(registerData.Username)
// si encuentra el usuario, el error sera nil, por lo que devolvemos error
if err == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Username already in use"})
return
}
//We create the user
err2 := auth.RegisterUser(registerData.Username, registerData.Email, registerData.Password)
if err2 != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal Error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Account Created"})
}
In this handler we first define a struct with the data that we expect from the HTTP request. Then we use the ShouldBindJson()
to bind the data from the request to the struct. We then search in the database for a user with that username. If no user is found, we can create a new user with this username, so we create the user using the RegisterUser
function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func RegisterUser(username, email, password string) error {
//TODO: Maybe implement a regex function to validate password policy
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := models.User{
Username: username,
Email: email,
Password: string(hashedPassword),
CreatedTime: time.Now().Format("2006-01-02 15:04:05"),
}
_, err = database.CreateUser(user)
if err != nil {
return err
}
return nil
}
This function hashes the password using bcrypt
, creates the user object and assigns all properties and then inserts this new object to the database. Now the user will be able to use the login endpoint with the username and password and if the password hash matches the hash in the database, a new token will be generated and assigned.
Frontend
I used React for the frontend since I was familiarized with it. Have to admit that I wasn’t willing to spend hours doing html and css, so gpt-3 (with some iterations) can take all credits for this amazing super modern, original and impactfull login page:
Again, I won’t delve into details regarding the html or css, but I will explain some new things I learnt or problems I had to solve. It is important to mention that, at this time, I was developing in localhost.
Redirection
It may seem something trivial to you, but It was my first time implementing protected routes. If someone doesn’t have a token, I don’t want them to be able to see the home page. They should be redirected to the frontend.
To do this, I created a react hook using useState, whenever this hook is updated, the react component is built again.
Since all components interact with the API, if the response is 401, we update the state:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const [redirectToLogin, setRedirectToLogin] = useState(false);
useEffect(() => {
const fetchLists = async () => {
try {
const response = await fetch('https://'+apiHOSTNAME+'/api/lists', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setLists(data);
} else if (response.status === 401) {
// Unauthorized, set state to redirect to login
setRedirectToLogin(true);
} else {
console.error('Request failed');
}
} catch (error) {
console.error('Error during request', error);
}
};
fetchLists();
}, []);
Next step is to add some logic at the HTLM we return. If the redirectToLogin is true, instead of rendering the home page we want to redirect the user to the login, so i did this:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Redirect to login if unauthorized
if (redirectToLogin) {
return <Navigate to="/login" />;
}
//No redirection needed, so we return the home page
return (
<div>
<NavBar />
<div className="flex flex-col items-center min-h-screen bg-black text-gray-300">
{showCreateListForm && <CreateListForm />}
<h1 className="text-4xl font-bold mb-8 mt-20">Your Lists</h1>
<ul>
.....
The <Navigate to="/login" />
will redirect the user to the login component.
Logout
My JWT has the HTTP Only attribute. This means that it can’t be accessed by javascript. I wanted to implement a button in the application that deletes the cookie from the browser local storage, but I couldn’t find a way to do this with the HTTP Only set, since I couldn’t interact with the cookie.
I spend a lot of time searching for a solution, and the only thing I was able to do is, instead of deleting the cookie, the logout function requests a new cookie, but this cookie will only be valid for 1 second. This cookie will expire after 1 second and will be deleted by the browser.
This means that I had to develop a logout endpoint that looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func handleLogout(c *gin.Context) {
frontendURL := os.Getenv("FRONTEND_URL")
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"message": "Bad Request"})
return
}
userData, ok := user.(*auth.Claims)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"message": "Bad Request"})
return
}
token, err := auth.GenerateToken(userData.UserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Internal Error"})
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.Header("Access-Control-Expose-Headers", "Set-Cookie")
c.SetCookie("usession", token, 1, "/", "http://"+frontendURL, false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logout correct"})
}
As you can see, in the SetCookie
function we use only 1 second as the TTL for this cookie.
At the frontend code, this is the handler triggered by clicking the logout function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const logout = async () => {
try {
const response = await fetch('https://'+apiHOSTNAME+'/api/logout', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
setTimeout(() => {
window.location.reload(); // Refresh the page after logout
}, 1500); // Delay reload by 1.5 seconds
} else {
console.error('Request failed');
}
} catch (error) {
console.error('Error during request', error);
}
};
It sends a GET request to the logout endpoint and then, after 1,5s it refreshes the page. The cookie will be already deleted so the other API requests of the page will fail and the web page will redirect the user to the Login form.
Real time searchbar
I have a component that allows you to search for restaurants and add them to the list. I wanted to implement a realtime search bar that displays items while you type. However, I didn’t want to call the API for each letter the user types in the searchbar, thats why I implemented a debounced search.
A debounce search waits until there is no input for a specific amount of time and then does the search. I used the _debounce
function from the lodash library.
The code looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
useEffect(() => {
debounceSearchRef.current = _debounce(async (query) => {
try {
const response = await fetch(`https://`+apiHOSTNAME+`/api/restaurant_search?restaurant_name=${query}`, {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setSearchResults(data);
} else {
console.error('Search request failed');
}
} catch (error) {
console.error('Error during search request', error);
}
}, 500); // Debounce time of 500 milliseconds
}, []);
This function assigns a debounce search in a reference called debounceSearchRef
. The search will be executed after 500 milliseconds and it will query the API and assign the results using the setSearchResults
.
However, everytime there is a change in the searchbar, this function is executed:
1
2
3
4
5
6
7
8
9
const handleSearchChange = (e) => {
const { value } = e.target;
setSearchQuery(value);
//Cancel the previous search and reset the timer
if (debounceSearchRef.current) {
debounceSearchRef.current.cancel();
}
debounceSearchRef.current(value);
};
This function cancels the current search (assuming it didn’t reach the 500 milliseconds) and creates a new one with the new search value and starting the timer again.
This post had nothing related with DevOps, but I wanted to explain how I solved some of the problems I faced during the development.
Now we have a frontend that interacts with the backend and is able to maintain sessions. Next step is to dockerize these components of the application so they can be executed anywhere.