In the previous tutorial, you learned about setting up your project using a package-oriented design for integrations and setting up the database for user interactions.
Now that you have the foundation, you can focus on making changes and adding features to meet your requirements.
This part of the tutorial will cover:
Setting Handlers for user requests
Creating routes for API endpoints
Implementing the endpoint for sign-up request
Implementing the endpoint for sign-in request
Implementing a secure endpoint
Setting Handlers for User Requests
To process your API requests, you need to compute logic using methods.
Methods in Go are not just any function - they require a receiver to be invoked and are defined explicitly on struct types. You can create these methods to help with your request processing.
To reach your objective, you can use a repository pattern in the handlers
package, similar to how you organize database query methods in the query
package. You can also explore this design patterns to gain a deeper understanding.
So, you can start building a structured approach to creating your handlers:
package handlers
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/akinbyte/go-app/modules/config"
"github.com/akinbyte/go-app/modules/database"
"github.com/akinbyte/go-app/modules/database/query"
"go.mongodb.org/mongo-driver/mongo"
)
type GoApp struct {
App config.GoAppTools
DB database.DBRepo
}
func NewGoApp(app config.GoAppTools, db mongo.Client) GoApp {
return &GoApp{
App: app,
DB: query.NewGoAppDB(app, db),
}
}
func (ga GoApp) Home() gin.HandlerFunc {
return func(ctx gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"resp": "Welcome to Go App home page"})
}
}
In the code above, you designed a GoApp
struct with two parts: App
and DB
. App
is a GoAppTools
type, and DB
is an interface of the DBRepo
type, which holds query methods for computed handlers.
The function NewGoApp
takes pointers to app
and db
as input and returns a pointer to the GoApp
struct. This function helps pass values in the main
package.
Your handler method uses the GoApp
struct to manage user requests for the app homepage. It returns a gin.HandlerFunc is an anonymous function using a gin.Context pointer parameter to handle middleware flow, JSON requests validation, and HTTP response status code 200.
To make your application stable, scalable, and easy to maintain, you need to modify your code by adding a layer of abstraction created in the handler
package being used to handle requests to the web application in main.go.
Current code
//......
client := driver.Connection(uri)
defer func() {
if err = client.Disconnect(context.TODO()); err != nil {
app.ErrorLogger.Fatal(err)
return
}
}()
appRouter := gin.New()
appRouter.GET("/", func (ctx *gin.Context) {
app.InfoLogger.Println("Creating a scalable web application with Gin")
})
Updated code
//........
// connecting to the database
client := driver.Connection(uri)
defer func() {
if err = client.Disconnect(context.TODO()); err != nil {
app.ErrorLogger.Fatal(err)
return
}
}()
appRouter := gin.New()
goApp := handlers.NewGoApp(&app, client)
Routes(appRouter, goApp)
//........
First, you can create a Gin instance and call the NewGoApp
function from the handlers
package. This function requires parameters such as a pointer to an app
struct and a MongoDB client
. Using these pointers, you can implement methods that provide access to all the struct methods through the function's return value.
Afterward, call the define Routes
function with the Gin instance and the GoApp
type returned by the handlers
package. We'll provide more details on this later.
As mentioned earlier, updating your code is critical to creating a scalable application. This approach will help you modularize your code, making it more organized and easier to maintain.
Creating Routes for API Endpoints
You aim to link each route's endpoint to its respective handler, safeguarding all user requests with default security measures, which are Recovery() and Logger() functions.
To make this happen, you will develop a function named Routes
with two parameters. The first parameter, r,
is a pointer to the Gin framework's instance, containing the muxer, default middleware, and configuration settings. The second parameter, g
is a pointer to the GoApp
function within the package.
You will also save user data as cookies to utilize it in other handler methods.
To accomplish this, you will install the gin session package as shown below:
go get github.com/gin-contrib/sessions
go get github.com/gin-contrib/sessions/cookie
You have finished the task, and now you can add code for the Routes function configuration:
package main
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/akinbyte/go-app/handlers"
)
func Routes(r gin.Engine, g handlers.GoApp) {
router := r.Use(gin.Logger(), gin.Recovery())
router.GET("/", g.Home())
// set up for storing details as cookies
cookieData := cookie.NewStore([]byte("go-app"))
router.Use(sessions.Sessions("session", cookieData))
}
Use the Use
method from your Gin instance to add middleware to your router. In this middleware, you can include the Gin Logger
and Recovery
functions to handle logging and panics. Additionally, create an HTTP GET request for your API's homepage and set up a cookie store for your session, which you can then include in the Use
method.
Implementing the Endpoint for Sign-up Request
To handle user requests for sign-up endpoints, add the following code to handler.go
in handlers
packages :
func (ga *GoApp) SignUp() gin.HandlerFunc {
return func (ctx *gin.Context) {
var user *model.User
if err := ctx.ShouldBindJSON(&user); err != nil {
_ = ctx.AbortWithError(http.StatusBadRequest, gin.Error{Err: err})
}
user.CreatedAt, _ = time.Parse(time.RFC3339, time.Now().Format(time.RFC3339))
user.UpdatedAt, _ = time.Parse(time.RFC3339, time.Now().Format(time.RFC3339))
user.Password, _ = encrypt.Hash(user.Password)
if err := ga.App.Validate.Struct(&user); err != nil {
if _, ok := err.(*validator.InvalidValidationError); !ok {
_ = ctx.AbortWithError(http.StatusBadRequest, gin.Error{Err: err})
ga.App.InfoLogger.Println(err)
return
}
}
ok, status, err := ga.DB.InsertUser(user)
if err != nil {
_ = ctx.AbortWithError(http.StatusInternalServerError, errors.New("error while adding new user"))
ctx.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
if !ok {
_ = ctx.AbortWithError(http.StatusInternalServerError, err)
return
}
switch status {
case 1:
ctx.JSON(http.StatusOK, gin.H{"message": "Registered Successfully"})
return
case 2:
ctx.JSON(http.StatusFound, gin.H{"message": "Existing Account, Go to the Login page"})
return
}
}
}
Note that the handler method requires an HTTP request to the endpoint HTTP Header while you are running the server.
Let's talk about how you processed the user sign-up request. You created a variable referencing the user
model struct and assumed the request contained data in the application/json
format. Then, you bound the data to the user variable, checked for errors, and modified the password encryption. You validated the user
struct tag values based on your input.
Now, you'll write queries to interact with your MongoDB database in the query
package of the database.
Here's the InsertUser
method implemented in the database
package:
func (g *GoAppDB) InsertUser(user *model.User) (bool, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
regMail := regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
ok := regMail.MatchString(user.Email)
if !ok {
g.App.ErrorLogger.Println("invalid registered details")
return false, 0, errors.New("invalid registered details")
}
filter := bson.D{{Key: "email", Value: user.Email}}
var res bson.M
err := User(g.DB, "user").FindOne(ctx, filter).Decode(&res)
if err != nil {
if err == mongo.ErrNoDocuments {
user.ID = primitive.NewObjectID()
_, insertErr := User(g.DB, "user").InsertOne(ctx, user)
if insertErr != nil {
g.App.ErrorLogger.Fatalf("cannot add user to the database : %v ", insertErr)
}
return true, 1, nil
}
g.App.ErrorLogger.Fatal(err)
}
return true, 2, nil
}
The InsertUser
method in the database query will add new users and assign them an ID
or check if existing users are stored in the user
collection by using the Email
in your SignUp
request.
Before searching the database, you validate the email with a regex
expression.
You have successfully processed the SignUp
endpoint and met all expected conditions to suit your needs.
To make the InsertUser
method and other future methods available in the handlers
package, you need to add them to the DBRepo
interface as you did before.
package database
import "github.com/akinbyte/go-app/modules/model"
type DBRepo interface {
InsertUser(user *model.User) (bool, int, error)
}
It would be best if you returned to the previous section to review the handler code. You need to add the method to the interface and call it using the DB
field of the GoApp
struct, passing the User
struct model values as an argument.
After you receive the values, check for errors and use a switch-case
statement to generate a JSON response with an appropriate HTTP response status code. This depends on the condition computed from the database query, which determines whether the user is registered.
For the SignUp
handler to work, you will need the URL or path string of http://localhost:8080/sign-up to send an HTTP POST request. It's appropriate to use the HTTP POST
method to indicate your desired action: sending data to the server for processing.
Setting the sign-up routes
After creating the handler for the sign-up endpoint, the next step is to set up a pathway to the endpoint for the user's request.
func Routes(r *gin.Engine, g *handlers.GoApp) {
// .........
// The comment above is to avoid repeating the previous code
router.POST("/sign-up", g.SignUp())
}
Next, you can test the sign-up endpoint you implemented using the URL.
Testing the API endpoints I
API Client Set up
To test the API endpoints, you will use Thunder Client, a VsCode extension that works with REST APIs, instead of Postman. Follow these steps to get started:
- To begin, you should search for and download the Thunder Client extension on vs code:
To access the extension, you need to click on the Flash icon located in the primary right bar:
After that, you can open the Thunder Client and create a new collection named GoApp:
- Select the collection menu, add the home and sign up as new requests:
- Input the URL for each request, select the HTTP method to use, and add the user data for the
POST
request:
Start Application Server
Now that you have set up the API client let's start the server and test each API endpoint individually.
Before starting the server, ensure you are in your go-app project's root directory.
To start the server, use the command below in the terminal or command prompt:
For Mac or Linux users
//using wildcard syntax to execute all the files in the web directory
go run cmd/web/*.go
For Windows users
go run cmd/web/main.go routes.go middleware.go
Home Endpoint
As demonstrated above, sending an HTTP request to the home endpoint yields a successful response with a 200 status code and a JSON output.
Sign up Endpoint
By checking your user collection on MongoDB Atlas, you can confirm that the endpoint was successful and your user data has been added to the database.
Next, you will learn how to create an endpoint for handling user sign-in requests. You will use stored session cookies and implement JWT for authentication and authorization.
Implementing the Endpoint for Sign-in Request
You are almost done with your API endpoints and can now concentrate on creating the sign-in/login endpoint and its supporting internal processes. You will implement the following internal processes that were previously discussed in your package meetings in the following order:
Generation and Parsing of JSON Web Tokens (JWT).
Database Query for verifying user information.
Develop a handler for the sign-in endpoint.
Setting up the route for logging in.
This sequence will ensure that the necessary steps are taken to guarantee the security and integrity of the application.
Generation and Parsing of JSON Web Tokens (JWT)
What is JWT, and what does it use?
JWT is a secure and concise technique for transmitting information online. It shares data as a self-contained JSON object, signed with private or public keys for authenticity. The token includes three parts: the payload, header, and signature.
The payload contains user information and the token expiration date.
The header specifies the algorithms for creating the token's signature.
The signature results from encrypting the header and payload utilizing a secret key.
To begin with, you will install the jwt-go open-source library before delving into generating and parsing tokens effectively. To do this, follow the standard procedure and enter the command in the terminal.
go get github.com/golang-jwt/jwt/v4
Following this process, you will have installed the library on your machine and ensured it is correctly added to the go.mod
that manages your dependencies.
package auth
import (
"github.com/golang-jwt/jwt/v4"
"github.com/akinbyte/go-app/modules/config"
"go.mongodb.org/mongo-driver/bson/primitive"
"net/http"
"os"
"time"
)
var app config.GoAppTools
type GoAppClaims struct {
jwt.RegisteredClaims
Email string
ID primitive.ObjectID
}
var secretKey = os.Getenv("GOAPP_KEY")
func Generate(email string, id primitive.ObjectID) (string, string, error) {
goappClaims := GoAppClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "goAppUser",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: &jwt.NumericDate{
Time: time.Now().Add(24 * time.Hour),
},
},
Email: email,
ID: id,
}
newGoAppClaims := &jwt.RegisteredClaims{
Issuer: "goAppUser",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: &jwt.NumericDate{
Time: time.Now().Add(48 * time.Hour),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, goappClaims).SignedString([]byte(secretKey))
if err != nil {
return "", "", err
}
newToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, newGoAppClaims).SignedString([]byte(secretKey))
if err != nil {
return "", "", err
}
return token, newToken, nil
}
func Parse(tokenString string) (*GoAppClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &GoAppClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secretKey), nil
})
if err != nil {
app.ErrorLogger.Fatalf("error while parsing token with it claims %v", err)
}
claims, ok := token.Claims.(*GoAppClaims)
if !ok {
app.ErrorLogger.Fatalf("error %v controller not authorised access", http.StatusUnauthorized)
}
if err := claims.Valid(); err != nil {
app.ErrorLogger.Fatalf("error %v %s", http.StatusUnauthorized, err)
}
return claims, nil
}
I will simplify the code by explaining each step in detail.
You create a struct called
GoAppClaims
, which includes a standard struct called RegisteredClaims and additional fields for email and a unique user ID.You define a global variable called
secretKey
, which you use to sign and verify tokens.The
Generate
function takes two input parameters,email
andID
, and returns two JWTs as strings along with an error. It creates an instance ofGoAppClaims
, sets some claims, and signs the JWTs using secretKey.The
newGoAppClaims
instance is another instance of theRegisteredClaims
struct with a longer validity period.You define the
Parse
function, which takes atokenString
input and returns a pointer to theGoAppClaims
struct and an error. It uses ParseWithClaims to verify the signature usingsecretKey
.To log any errors during token parsing, authorization, and validation, you use an instance of the built-in
log
package created in theconfig
package.
Database Query for Verifying User Information
To ensure a successful sign-in process, users must be verified through the database and their details confirmed when accessing authorized endpoints.
func (g *GoAppDB) VerifyUser(email string) (primitive.M, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
var res bson.M
filter := bson.D{{Key: "email", Value: email}}
err := User(g.DB, "user").FindOne(ctx, filter).Decode(&res)
if err != nil {
if err == mongo.ErrNoDocuments {
g.App.ErrorLogger.Println("no document found for this query")
return nil, err
}
g.App.ErrorLogger.Fatalf("cannot execute the database query perfectly : %v ", err)
}
return res, nil
}
You have a
VerifyUser
method that searches the database for a document with the given email address. It takes in an email parameter and returns a BSON document (primitive.M
) and an error.To maintain the request's lifecycle, you can set the
Context
timeout. Create an empty variable namedres
of typebson.M
to store the query result. Theemail
parameter is used as a filter to search the database.You can use the
FindOne
method on the user collection to get a single document result since you are only interested in a single user. This document is unmarshalled into the emptyres
variable using theDecode
methodIf the document cannot be found, the query may return an error of
mongo.ErrNoDocument
. Otherwise, any other error is logged.If the query is executed successfully within the set timeout context, the method returns the result (
res
) and anil
error.
One of your tasks is to update the user collection in the database. You need to add or modify the JWT tokens found in the given query:
func (g *GoAppDB) UpdateInfo(userID primitive.ObjectID, tk map[string]string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
filter := bson.D{{Key: "_id", Value: userID}}
update := bson.D{{Key: "$set", Value: bson.D{{Key: "token", Value: tk["t1"]}, {Key: "new_token", Value: tk["t2"]}}}}
_, err := User(g.DB, "user").UpdateOne(ctx, filter, update)
if err != nil {
return false, err
}
return true, nil
}
To update the user information, you implement an
UpdateInfo
method, which is designed to update user information in the database. This method takes two parameters:UserID
(a unique ID) andtk
which holds generated tokens.The
UpdateInfo
method returns a boolean value and an error.First, you set a
context
timeout to pass the cancellation signal and stop any processes within the method.You declare a filter variable assigned to a BSON document with a key-value pair of
UserID
to filter theuser
collection and the value to update to an update variable.Next, you call the
UpdateOne
method on the user collection to make and pass in the filter and update variables as parameters to the do necessary updates.After that, you handle the errors and return true if the update is successful or false if it fails.
Updated DB Interface
To access endpoints, update the DBRepo interface with a new query method. Then, Let’s update the database interface with newly implemented queries:
package database
import (
"github.com/akinbyte/go-app/modules/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type DBRepo interface {
InsertUser(user *model.User) (bool, int, error)
VerifyUser(email string) (primitive.M, error)
UpdateInfo(userID primitive.ObjectID, tk map[string]string) (bool, error)
}
Development of a Handler for the sign-in endpoint
Now, you will assemble the code to handle your user sign-in requests and secure access:
func (ga *GoApp) SignIn() gin.HandlerFunc {
return func(ctx *gin.Context) {
var user *model.User
if err := ctx.ShouldBindJSON(&user); err != nil {
_ = ctx.AbortWithError(http.StatusBadRequest, gin.Error{Err: err})
}
regMail := regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
ok := regMail.MatchString(user.Email)
if ok {
res, checkErr := ga.DB.VerifyUser(user.Email)
if checkErr != nil {
_ = ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("unregistered user %v", checkErr))
ctx.JSON(http.StatusUnauthorized, gin.H{"message": "unregistered user"})
return
}
id := (res["_id"]).(primitive.ObjectID)
password := (res["password"]).(string)
verified, err := encrypt.Verify(user.Password, password)
if err != nil {
_ = ctx.AbortWithError(http.StatusUnauthorized, errors.New("cannot verify user details"))
ctx.JSON(http.StatusUnauthorized, gin.H{"message": "Incorrect login details"})
return
}
switch {
case verified:
cookieData := sessions.Default(ctx)
userInfo := map[string]interface{}{
"ID": id,
"email": user.Email,
"password": user.Password,
}
cookieData.Set("data", userInfo)
if err := cookieData.Save(); err != nil {
log.Println("error from the session storage")
_ = ctx.AbortWithError(http.StatusNotFound, gin.Error{Err: err})
return
}
// generate the jwt token
t1, t2, err := auth.Generate(user.Email, id)
if err != nil {
_ = ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("token no generated : %v ", err))
}
cookieData.Set("token", t1)
if err := cookieData.Save(); err != nil {
log.Println("error from the session storage")
_ = ctx.AbortWithError(http.StatusNotFound, gin.Error{Err: err})
return
}
// var tk map[string]string
tk := map[string]string{"t1": t1, "t2": t2}
// update the database, adding the token to user database
_, updateErr := ga.DB.UpdateInfo(id, tk)
if updateErr != nil {
_ = ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("unregistered user %v", updateErr))
ctx.JSON(http.StatusBadRequest, gin.H{"message": "Incorrect login details"})
return
}
ctx.JSON(http.StatusOK, gin.H{
"message": "Successfully Logged in",
"email": user.Email,
"id": id,
"session_token": t1,
})
case !verified:
ctx.JSON(http.StatusUnauthorized, gin.H{"message": "Incorrect login details"})
return
}
}
}
}
You create a variable called user
to store JSON data from the user. You verify their email and details in the database and check their password using the Verify
function. If verification is successful, you store the data as cookies. Otherwise, you return a JSON response.
Afterwards, you generate a JWT token using the Generate
function and save it as a cookie for your custom middleware. You update the user
collection with the token and send a JSON response with the status code. You prioritize error handling during every function call.
To send an HTTP POST request, you will need a URL or a path string of http://localhost:8080/sign-in.
Managing Cookies Data
In Go, to manage the transfer of data (cookies in our case) over the network, serialization technique is employed, which helps encode the stored data's datatypes. For this to be done, you need to use the built-in encoding/gob package, which manages streams of gobs - binary values exchanged between an Encoder (transmitter) and a Decoder (receiver). A stream of gobs is self-describing. Each data item in the stream is preceded by a specification of its type, expressed in a small set of predefined types.
Setting up the route for logging in.
In the main package's routes.go
file, you should include the HTTP POST method route to handle sign-in endpoints:
//............
//Post request to sign in
router.POST("/sign-in", g.SignIn())
Testing the API endpoints II
You can test the API for the sign-in endpoint using the Thunder Client extension to see if all the implemented procedures are set up correctly.
Excellent, your sign-in endpoint is working as expected! You can be confident that it's free of error and that all the computed functions and processes within are working perfectly.
Implementing a Secure Endpoint
Create a custom middleware before adding a secure endpoint for authorized users. The Sign in
endpoint is working well, so you can move forward.
Authorization and Authentication Middleware
We use a JWT token and middleware to give users access to a protected area. Middleware acts as a connector, helping applications work together and share data.
After you log in through the API, we use middleware to let you access secure areas. It handles the processing and communication needed for this.
package main
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/akinbyte/go-app/auth"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
func Authorization() gin.HandlerFunc {
return func(ctx *gin.Context) {
cookieData := sessions.Default(ctx)
tokenString := cookieData.Get("token").(string)
if tokenString == "" {
_ = ctx.AbortWithError(http.StatusNoContent, errors.New("no value for token"))
return
}
parse, err := auth.Parse(tokenString)
if err != nil {
_ = ctx.AbortWithError(http.StatusUnauthorized, gin.Error{Err: err})
}
ctx.Set("pass", tokenString)
ctx.Set("id", parse.ID)
ctx.Set("email", parse.Email)
ctx.Next()
}
}
Your Authorization
function, which you use as middleware, will check whether you are authorized to access a protected area. It will use a token from a cookie and check if it exists in the session cookie. If it doesn't exist, you will receive an error message. However, if the token is present, the function will set your ID
, email
, and token
in the context of later functions.
Dashboard Endpoint Handler
To establish a default Dashboard
handler that generates a primary JSON response to check the protected dashboard endpoint. You can create a sample code similar to that below:
func (ga *GoApp) DashBoard() gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"resp": "Welcome to Go App Dashboard"})
}
}
You can now add the dashboard endpoint routes to the main
package's routes.go
file, which will be the final update.
To send an HTTP GET request for this handler, you will need a URL or a path string of http://localhost:8080/auth/dashboard.
The code for this can be found below:
package main
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/akinbyte/go-app/handlers"
)
func Routes(r *gin.Engine, g *handlers.GoApp) {
router := r.Use(gin.Logger(), gin.Recovery())
router.GET("/", g.Home())
// set up for storing details as cookies
cookieData := cookie.NewStore([]byte("go-app"))
router.Use(sessions.Sessions("session", cookieData))
router.POST("/sign-up", g.SignUp())
router.POST("/sign-in", g.SignIn())
authRouter := r.Group("/auth", Authorization())
{
authRouter.GET("/dashboard")
}
}
After adding the new code, you now employ the Group method to construct a fresh router group in which you can add all of the routes that share common middleware or have the same path prefix. You've now protected the dashboard
endpoint using' Authorization' middleware.
Testing the dashboard endpoint
Now, You can test the secured endpoint to check if you have access to it after signing in.
Yeah! The secured endpoint (dashboard) worked, and you can now access it.
Conclusion
To summarise, you set up handlers/controllers for user requests, developed Routes to define the endpoints, implemented sign-up and sign-in requests, and established a secure dashboard endpoint using a custom middleware that authorizes user access through JWT token claims.
With this whole information piece, You should understand the basic concept and the complete basics of creating a well-structured REST API.
You can get the GitHub repo of the whole tutorial here.