DEV Community

loading...
Cover image for Implement login user API that returns PASETO or JWT access token in Go

Implement login user API that returns PASETO or JWT access token in Go

techschoolguru profile image TECH SCHOOL ・12 min read

Hello everyone! Welcome back to the backend master class!

In the previous lecture, we’ve implemented the token maker interface using JWT and PASETO. It provides 2 methods to create and verify tokens.

So today we’re gonna learn how to use it to implement the login API, where the username and password are provided by the client, and the server will return an access token if those credentials are correct.

Here's:

OK let’s start!

Add token Maker to the Server

The first step is to add the token maker to our API server. So let’s open api/server.go file!

In the Server struct, I’m gonna add a tokenMaker field of type token.Maker interface.

type Server struct {
    store      db.Store
    tokenMaker token.Maker
    router     *gin.Engine
}
Enter fullscreen mode Exit fullscreen mode

Then let’s initialize this field inside the NewServer() function! First we have to create a new token maker object. We can choose to use either JWT or PASETO, they both implement the same token.Maker interface.

I think PASETO is better, so let’s call token.NewPasetoMaker(). It requires a symmetric key string, so we will need to load this from environment variable. For now, let’s just put an empty string here as a placeholder.

If the returned error is not nil, we return a nil server, and an error saying "cannot create token maker". The %w is used to wrap the original error.

func NewServer(store db.Store) (*Server, error) {
    tokenMaker, err := token.NewPasetoMaker("")
    if err != nil {
        return nil, fmt.Errorf("cannot create token maker: %w", err)
    }

    server := &Server{
        store:      store,
        tokenMaker: tokenMaker,
    }

    ...

    return server, nil
}
Enter fullscreen mode Exit fullscreen mode

OK, so now we have to change the return type of the NewServer() function to include an error as well. Then in the statement to create a Server object, we add the tokenMaker object that we’ve just created.

Alright, now let’s come back to the symmetric key parameter. I’m gonna add a new environment variable to the app.env file. Let’s call it TOKEN_SYMMETRIC_KEY.

And as we’re using PASETO version 2, which uses ChachaPoly algorithm, the size of this symmetric key should be exactly 32 bytes.

TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012
ACCESS_TOKEN_DURATION=15m
Enter fullscreen mode Exit fullscreen mode

We should also add 1 more variable to store the valid duration of the access token. It’s a best practice to set this to a very short duration, let’s say, just 15 minutes for example.

OK, now we have to update our config struct to include the 2 new variables that we’ve just added.

First, the TokenSymmetricKey of type string. We have to specify the mapstructure tag for it because viper uses mapstructure package to parse the config data. Please refer to the lecture 12 of the course if you don’t know how to use viper.

type Config struct {
    ...
    TokenSymmetricKey   string        `mapstructure:"TOKEN_SYMMETRIC_KEY"`
    AccessTokenDuration time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"`
}
Enter fullscreen mode Exit fullscreen mode

The next field is AccessTokenDuration of type time.Duration. And its mapstructure tag should be this environment variable’s name: ACCESS_TOKEN_DURATION.

As you can see, when the type of a config field is time.Duration, we can specify the value in a human readable format like this: 15m.

OK so now we’ve loaded the secret key and token duration into the config, let’s go back to the server and use them. We have to add a config parameter to the NewServer() function. Then in the token.NewPasetoMaker() call, we pass in config.TokenSymmetricKey.

We should also add a config field to the Server struct, and store it here when initialize the Server object. We will use the TokenDuration in this config object later when creating the tokens.

type Server struct {
    config     util.Config
    store      db.Store
    tokenMaker token.Maker
    router     *gin.Engine
}

func NewServer(config util.Config, store db.Store) (*Server, error) {
    tokenMaker, err := token.NewPasetoMaker(config.TokenSymmetricKey)
    if err != nil {
        return nil, fmt.Errorf("cannot create token maker: %w", err)
    }

    server := &Server{
        config:     config,
        store:      store,
        tokenMaker: tokenMaker,
    }

    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("currency", validCurrency)
    }

    server.setupRouter()
    return server, nil
}
Enter fullscreen mode Exit fullscreen mode

At the end of this function, we should return a nil error. And that will be it!

However, as we added a new config parameter to the NewServer() function, some unit tests that we wrote before are broken. So let’s fix them!

Fix broken unit tests

In the api/main_test.go file, I’m gonna define a function newTestServer() that will create a new server for test. It takes a testing.T object and a db.Store interface as input. And it will return a Server object as output.

In this function, let’s create a new config object, with TokenSymmetricKey is util.RandomString of 32 characters, and AccessTokenDuration is 1 minute.

func newTestServer(t *testing.T, store db.Store) *Server {
    config := util.Config{
        TokenSymmetricKey:   util.RandomString(32),
        AccessTokenDuration: time.Minute,
    }

    server, err := NewServer(config, store)
    require.NoError(t, err)

    return server
}
Enter fullscreen mode Exit fullscreen mode

Then we create a new server with that config object and the input store interface. Require no errors, and finally return the created server.

Now get back to the api/transfer_test.go file. Here, instead of NewServer(), we will call newTestServer, and pass in the testing.T object and the mock store.

func TestTransferAPI(t *testing.T) {
    ...

    for i := range testCases {
        tc := testCases[i]

        t.Run(tc.name, func(t *testing.T) {
            ...

            server := newTestServer(t, store)
            recorder := httptest.NewRecorder()

            ...
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

We do the same for the server inside api/user_test.go file and api/account_test.go file as well. There are several calls of NewServer() in these files, so we have to change all of them to newTestServer().

Alright, now everything is updated. Let’s run the whole api package tests!

Alt Text

All passed! Excellent! So the tests are now working well with the new Server struct.

But there’s one more place we need to update, that’s the main entry point of our server: main.go

func main() {
    config, err := util.LoadConfig(".")
    if err != nil {
        log.Fatal("cannot load config:", err)
    }

    conn, err := sql.Open(config.DBDriver, config.DBSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server, err := api.NewServer(config, store)
    if err != nil {
        log.Fatal("cannot create server:", err)
    }

    err = server.Start(config.ServerAddress)
    if err != nil {
        log.Fatal("cannot start server:", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, in this main() function, we have to add config to the api.NewServer() call. And this call will return a server and an error.

If error is not nil, we just write a fatal log, saying "cannot create server". Just like that, and we’re done!

Now it’s time to build the login user API!

Implement login user handler

Let’s open the api/user.go file!

The login API’s request payload must contain the username and password, which is very similar to the createUserRequest:

type createUserRequest struct {
    Username string `json:"username" binding:"required,alphanum"`
    Password string `json:"password" binding:"required,min=6"`
    FullName string `json:"full_name" binding:"required"`
    Email    string `json:"email" binding:"required,email"`
}
Enter fullscreen mode Exit fullscreen mode

So I’m gonna copy this struct, and paste it to the end of this file. Then let’s change the struct name to loginUserRequest and remove the FullName and Email fields, just keep the Username and Password fields.

type loginUserRequest struct {
    Username string `json:"username" binding:"required,alphanum"`
    Password string `json:"password" binding:"required,min=6"`
}
Enter fullscreen mode Exit fullscreen mode

Next, let’s define the loginUserResponse struct. The most important field that should be returned to the client is AccessToken string. This is the token that we will create using the token maker interface.

type loginUserResponse struct {
    AccessToken string       `json:"access_token"`
}
Enter fullscreen mode Exit fullscreen mode

Beside the access token, we might also want to return some information of the logged in user, just like the one we returned in the create user API:

type createUserResponse struct {
    Username          string    `json:"username"`
    FullName          string    `json:"full_name"`
    Email             string    `json:"email"`
    PasswordChangedAt time.Time `json:"password_changed_at"`
    CreatedAt         time.Time `json:"created_at"`
}
Enter fullscreen mode Exit fullscreen mode

So to make this struct reusable, I’m gonna change its name to just userResponse. It will be the type of the User field in this loginUserResponse struct:

type userResponse struct {
    Username          string    `json:"username"`
    FullName          string    `json:"full_name"`
    Email             string    `json:"email"`
    PasswordChangedAt time.Time `json:"password_changed_at"`
    CreatedAt         time.Time `json:"created_at"`
}

type loginUserResponse struct {
    AccessToken string       `json:"access_token"`
    User        userResponse `json:"user"`
}
Enter fullscreen mode Exit fullscreen mode

Then let’s copy the userResponse object from the createUser() handler, and define a newUserResponse() function at the top.

func newUserResponse(user db.User) userResponse {
    return userResponse{
        Username:          user.Username,
        FullName:          user.FullName,
        Email:             user.Email,
        PasswordChangedAt: user.PasswordChangedAt,
        CreatedAt:         user.CreatedAt,
    }
}
Enter fullscreen mode Exit fullscreen mode

The role of this function is to convert the input db.User object into userResponse. The reason we do that is because there’s a sensitive data inside the db.User struct, which is the hashed_password, that we don’t want to expose to the client.

OK, so now in the createUser() handler, we can just call the newUserResponse() function to create the response object.

func (server *Server) createUser(ctx *gin.Context) {
    ...

    user, err := server.store.CreateUser(ctx, arg)
    ...

    rsp := newUserResponse(user)
    ctx.JSON(http.StatusOK, rsp)
}
Enter fullscreen mode Exit fullscreen mode

The newUserResponse() function will be useful for our new loginUser() handler as well.

Alright, now let’s add a new method to the server struct: loginUser(). Similar as in other API handlers, this function will take a gin.Context object as input.

Inside, we declare a request object of type loginUserRequest, and we call the ctx.ShouldBindJSON() function with a pointer to that request object. This will bind all the input parameters of the API into the request object.

func (server *Server) loginUser(ctx *gin.Context) {
    var req loginUserRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

If error is not nil, we send a response with status 400 Bad Request to the client, together with the errorResponse() body to explain why it failed.

If there’s no error, we will find the user from the database by calling server.store.GetUser() with the context ctx and req.Username.

func (server *Server) loginUser(ctx *gin.Context) {
    ...

    user, err := server.store.GetUser(ctx, req.Username)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

If the error returned by this call is not nil, then there are 2 possible cases:

  • The first case is when the username doesn’t exist, which means error equals to sql.ErrNoRows. In this case, we send a response with status 404 Not Found to the client, and return immediately.
  • The second case is an unexpected error occurs when talking to the database. In this case, we send a 500 Internal Server Error status to the client, and also return right away.

If everything goes well, and no errors occur, we will have to check if the password provided by the client is correct or not. So we call util.CheckPassword() with the input req.Password and user.HashedPassword.

func (server *Server) loginUser(ctx *gin.Context) {
    ...

    err = util.CheckPassword(req.Password, user.HashedPassword)
    if err != nil {
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

If this function returns a not nil error, then it means the provided password is incorrect. We will send a response with status 401 Unauthorized to the client, and return.

Only when the password is correct, then we will create a new access token for this user.

Let’s call server.tokenMaker.CreateToken(), pass in user.Username, and server.config.AccessTokenDuration as input arguments.

func (server *Server) loginUser(ctx *gin.Context) {
    ...

    accessToken, err := server.tokenMaker.CreateToken(
        user.Username,
        server.config.AccessTokenDuration,
    )
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    rsp := loginUserResponse{
        AccessToken: accessToken,
        User:        newUserResponse(user),
    }
    ctx.JSON(http.StatusOK, rsp)
}
Enter fullscreen mode Exit fullscreen mode

If an unexpected error occurs, we just return 500 Internal Server Error status code.

Otherwise, we will build the loginUserResponse object, where AccessToken is the created access token, and User is newUserResponse(user). We then send this response to the client with a 200 OK status code.

And that’s basically it! The loginUser() handler function is completed:

func (server *Server) loginUser(ctx *gin.Context) {
    var req loginUserRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    user, err := server.store.GetUser(ctx, req.Username)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    err = util.CheckPassword(req.Password, user.HashedPassword)
    if err != nil {
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }

    accessToken, err := server.tokenMaker.CreateToken(
        user.Username,
        server.config.AccessTokenDuration,
    )
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    rsp := loginUserResponse{
        AccessToken: accessToken,
        User:        newUserResponse(user),
    }
    ctx.JSON(http.StatusOK, rsp)
}
Enter fullscreen mode Exit fullscreen mode

Add login API route to the server

The next step is to add a new API endpoint to the server that will route the login request to the loginUser() handler.

I’m gonna put it next to the create user route. So router.POST(), the path should be /users/login, and the handler function is server.loginUser().

func NewServer(config util.Config, store db.Store) (*Server, error) {
    ...

    router := gin.Default()

    router.POST("/users", server.createUser)
    router.POST("/users/login", server.loginUser)

    ...
}
Enter fullscreen mode Exit fullscreen mode

And we’re done!

However, this NewServer() function is getting quite long now, which makes it harder to read.

So I’m gonna split the routing part into a separate method of the server struct. Let’s call it setupRouter(). Then paste in all the routing codes.

func (server *Server) setupRouter() {
    router := gin.Default()

    router.POST("/users", server.createUser)
    router.POST("/users/login", server.loginUser)

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)
    router.GET("/accounts", server.listAccounts)

    router.POST("/transfers", server.createTransfer)

    server.router = router
}
Enter fullscreen mode Exit fullscreen mode

We should move the gin router variable here as well. And at the end, we should save this router to the server.router field.

Then all we have to do in the NewServer() function is to call server.setupRouter().

func NewServer(config util.Config, store db.Store) (*Server, error) {
    tokenMaker, err := token.NewPasetoMaker(config.TokenSymmetricKey)
    if err != nil {
        return nil, fmt.Errorf("cannot create token maker: %w", err)
    }

    server := &Server{
        config:     config,
        store:      store,
        tokenMaker: tokenMaker,
    }

    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("currency", validCurrency)
    }

    server.setupRouter()
    return server, nil
}

Enter fullscreen mode Exit fullscreen mode

And now we’ve really completed the login user API’s implementation. It’s pretty easy and straightforward, isn’t it?

Run the server and send login user request

Let’s run the server and send some requests to see how it goes!

❯ make server
go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /users                    --> github.com/techschool/simplebank/api.(*Server).createUser-fm (3 handlers)
[GIN-debug] POST   /users/login              --> github.com/techschool/simplebank/api.(*Server).loginUser-fm (3 handlers)
[GIN-debug] POST   /accounts                 --> github.com/techschool/simplebank/api.(*Server).createAccount-fm (4 handlers)
[GIN-debug] GET    /accounts/:id             --> github.com/techschool/simplebank/api.(*Server).getAccount-fm (4 handlers)
[GIN-debug] GET    /accounts                 --> github.com/techschool/simplebank/api.(*Server).listAccounts-fm (4 handlers)
[GIN-debug] POST   /transfers                --> github.com/techschool/simplebank/api.(*Server).createTransfer-fm (4 handlers)
[GIN-debug] Listening and serving HTTP on 0.0.0.0:8080
Enter fullscreen mode Exit fullscreen mode

As you can see here, the login user API is up and running.

Now I’m gonna open Postman, create a new request and set it method to POST. The URL should be http://localhost:8080/users/login, then select body, raw, and JSON format.

The JSON body will have 2 fields: username and password. In the database, there are 4 users that we already created in previous lectures. So I’m gonna use the first user with username quang1 and the password is secret.

OK let’s send this request:

Alt Text

Voilà! It's successful!

We’ve got the PASETO v2 local access token here. And all the information of the logged in user in this object. So it worked!

Let’s try login with an invalid password: xyz. Send the request.

Alt Text

Now we’ve got 400 Bad Request because the password we sent was too short. That's because we have a validation rule for the password field to have at least 6 characters:

type loginUserRequest struct {
    ...
    Password string `json:"password" binding:"required,min=6"`
}
Enter fullscreen mode Exit fullscreen mode

So let’s change this value to xyz123. And send the request again.

Alt Text

This time we’ve got 401 Unauthorized status code, and the error is: "hashed password is not the hash of the given password", or in other words, the provided password is incorrect.

Now let’s try the case when username doesn’t exist. I’m gonna change the username to quang10, and send the request again.

Alt Text

This time, we’ve got 404 Not Found status code. That’s exactly what we expected! So the login user API is working very well.

Before we finish, I’m gonna show you how easy it is to change the token types.

Change the token type

Right now, we’re using PASETO, but since it implements the same token maker interface with JWT, it will be super easy if we want to switch to JWT.

All we have to do is just change the token.NewPasetoMaker() call to token.NewJWTMaker() in the api/server.go file:

func NewServer(config util.Config, store db.Store) (*Server, error) {
    tokenMaker, err := token.NewJWTMaker(config.TokenSymmetricKey)
    if err != nil {
        return nil, fmt.Errorf("cannot create token maker: %w", err)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

That’s it! Let’s restart the server, then go back to Postman and send the login request one more time.

Alt Text

As you can see, the request is successful. And now the access token looks different because it’s a JWT token, not a PASETO token as before.

OK, now as we’ve confirmed that it worked, I’m gonna revert the token type to PASETO because it’s better than JWT in my opinion.

func NewServer(config util.Config, store db.Store) (*Server, error) {
    tokenMaker, err := token.NewPasetoMaker(config.TokenSymmetricKey)
    if err != nil {
        return nil, fmt.Errorf("cannot create token maker: %w", err)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

And that wraps up this lecture about implementing login user API in Go.

I hope you find it useful. Thanks a lot for reading, and see you soon in the next one!


If you like the article, please subscribe to our Youtube channel and follow us on Twitter or Facebook for more tutorials in the future.

Discussion (0)

Forem Open with the Forem app