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:
- Link to the full series playlist on Youtube
- And its Github repository
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
}
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
}
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
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"`
}
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
}
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
}
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()
...
})
}
}
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!
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)
}
}
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"`
}
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"`
}
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"`
}
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"`
}
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"`
}
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,
}
}
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)
}
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
}
...
}
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
}
...
}
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 meanserror
equals tosql.ErrNoRows
. In this case, we send a response with status404 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
}
...
}
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)
}
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)
}
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)
...
}
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
}
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
}
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
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:
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.
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"`
}
So let’s change this value to xyz123
. And send the request again.
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.
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)
}
...
}
That’s it! Let’s restart the server, then go back to Postman and send the login request one more time.
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)
}
...
}
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.
If you want to join me on my current amazing team at Voodoo, check out our job openings here. Remote or onsite in Paris/Amsterdam/London/Berlin/Barcelona with visa sponsorship.
Top comments (3)
Great, looking forward to updating the remaining articles
The whole series was awesome. It was clear, easy to catch and implement.
thank you!
Awesome series! Looking forward to the rest. Keep it up.