Skip to content

Latest commit

 

History

History
1039 lines (816 loc) · 31.2 KB

part37-eng.md

File metadata and controls

1039 lines (816 loc) · 31.2 KB

How to manage user session with refresh token - Golang

Original video

Hello guys!

Welcome back to the Backend Master Class. It might be a surprise to you because, in the previous video, I said it was the end of the course.

My plan was to start a new course with more advanced backend topics. However, after giving it some thought, I think it's more suitable to continue posting them in this course, since the main purpose of the course is to help you become a master in backend development.

It wouldn't be a master class without advanced topics, right?

Alright, let's start with the first topic: how to manage user sessions?

How to manage user sessions

If you still remember, from lecture 19 to lecture 22, we've learned how to use PASETO or JWT as a token-based authentication.

However, I must emphasize that you should not use them for a long session.

Because of the stateless design, those access tokens are not stored in the database, and so there's no way to revoke them in case they got leaked.

Therefore, their lifetime should be very short, such as 10 or 15 minutes.

But if we only use access tokens, then when they're expired, users will need to log in again with their username and password.

It is definitely not a good user experience to ask them to log in every 10 or 15 minutes, right?

So this is where another type of token called refresh token comes into play. The main idea is, we will use it to maintain a stateful session on the server, and the client can use the refresh token with a long valid duration, to request a new access token when it's expired.

The refresh token can be as simple as a random string, or we can also use PASETO if we want. But it will be stored in a sessions table in the database, with 1 additional boolean field to allow blocking the token in case it is compromised.

With the ability to revoke the refresh token, its lifetime can be much longer than the access token, such as several days or even weeks.

Alright, it's time to go into coding.

First, let's start the simple bank API server. Then try sending the login request in Postman.

As you can see, right now, the server only returns a PASETO access token. And if we look at the app.env file in the Visual Studio Code,

DB_DRIVER=postgres
DB_SOURCE=postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable
SERVER_ADDRESS=0.0.0.0:8080
TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012
ACCESS_TOKEN_DURATION=15m

we can see that the duration of the access token is very short, only 15 minutes. What we want to have is a refresh token in the login API response, with a longer duration. So, here I'm gonna add a new variable called REFRESH_TOKEN_DURATION and let's set it to 24 hours.

REFRESH_TOKEN_DURATION=24h

Then in the config.go file, we have to update the Config struct to load this new variable.

The new field is gonna be RefreshTokenDuration of type time.Duration and we must add a mapstructure tag for it with the same name as the environment variable.

type Config struct {
	DBDriver             string        `mapstructure:"DB_DRIVER"`
	DBSource             string        `mapstructure:"DB_SOURCE"`
	ServerAddress        string        `mapstructure:"SERVER_ADDRESS"`
	TokenSymmetricKey    string        `mapstructure:"TOKEN_SYMMETRIC_KEY"`
	AccessTokenDuration  time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"`
	RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"`
}

OK, done!

Next step, we will need to create a new sessions table in the database.

So let's use this command in the README file to create a new DB migration.

migrate create -ext sql -dir db/migration -seq <migration_name>

I'm gonna copy, and paste it to the terminal. Then change the migration name to add_sessions.

migrate create -ext sql -dir db/migration -seq add_sessions

Alright, 2 migration files, up and down have been generated.

The up script will be similar to that of the users table, so let's just copy it from here, and paste it to the new migration file.

I'm gonna change the table name to sessions. The first column to this table will be the ID of the session, which can be the same as the ID of the refresh token that we define in the token payload. So its type should be uuid, and it will be the primary key of this table. We will also store the username of the user in this table, but of course, it's not the primary key anymore. The third column will store the refresh token of this session if we want to keep track of the client type, and where the user is connecting to the server, we can store the user agent, and the client IP address in this table as well. Next, we need 1 important boolean column to block the session in case the refresh token is compromised. Finally, let's add an expires_at column to tell us the time when the refresh token will be expired. And there's also a foreign key constraint on the username column that references the same column of the users table. That's it for the migration up script.

CREATE TABLE "sessions" (
     "id" uuid PRIMARY KEY,
     "username" varchar NOT NULL,
     "refresh_token" varchar NOT NULL,
     "user_agent" varchar NOT NULL,
     "client_ip" varchar NOT NULL,
     "is_blocked" boolean NOT NULL DEFAULT false,
     "expires_at" timestamptz NOT NULL,
     "created_at" timestamptz NOT NULL DEFAULT (now())
);

ALTER TABLE "sessions" ADD FOREIGN KEY ("username") REFERENCES "users" ("username");

For the migration down, similar as in the add_users.down migration script, all we have to do is to drop the sessions table.

DROP TABLE IF EXISTS "sessions";

Alright, now let's open the terminal and run make migrateup to apply the new migration.

make migrateup

OK, it runs successfully! Let's check the new db schema in Table Plus. Here we can see the new sessions table.

It has the username column that links to the users table. The refresh_token, and is_blocked columns, exactly as we defined in the migration script. Awesome!

Next step, we will add some new SQL queries to create and retrieve a session.

I'm gonna create a new file session.sql inside the query folder. Then let's copy the content of the user.sql file here. The first query is to create a new session so, INSERT INTO sessions, the columns are id, username, and you know what, it's faster to just copy the rest of the columns from the migration up file. Then reformat all columns to the valid syntax.

In Visual Studio Code, we can easily create multiple cursors by pressing the Option (or Alt) key while clicking at different positions in the code editor. This allows us to edit multiple lines at the same time, which is much faster than doing it one by one.

OK, so we've just added all necessary columns: the refresh_token, user_agent, client_ip, is_blocked and expires_at. In total, we have 7 columns in this INSERT statement. So we must add 3 more parameters to the VALUES list. And the INSERT query is done!

-- name: CreateSession: one
INSERT INTO sessions (
    id,
    username,
    refresh_token,
    user_agent,
    client_ip,
    is_blocked,
    expires_at
) VALUES (
    $1, $2, $3, $4, $5, $6, $7
) RETURNING *;

Now let's move to the next query: GetSession. For this one, we will look for a specific session by its ID. So I'm gonna change this clause

-- name: GetUser :one
SELECT * FROM users
WHERE username = $1 LIMIT 1;

to WHERE id equals the first parameter.

--name: GetSession :one
SELECT * FROM sessions
WHERE id = $1 LIMIT 1;

That's it! Now let's run

make sqlc

in the terminal to generate Golang codes for the 2 new queries we've just written.

It's successful. So if we open the session.sql.go file inside the sqlc folder, we will see that the code has been generated. sqlc is using the google/uuid for the session ID column. And there's one function to create a new session, and another function to get a session by its ID. Cool!

Now as 2 more functions have been added to the Store interface, you will notice that some of our API unit tests are showing some errors. That's because those 2 new functions are not implemented by the mock Store yet.

To fix this, we must run

make mock

in the terminal to regenerate the mock Store.

Only after that, then the errors will be gone.

We can run

make test

to make sure that everything is working as expected.

And it really is! All tests passed. Excellent!

Alright, now comes the important part, we will modify the login API to create and return the refresh token together with the access token.

So let's open the api/user.go file. In the loginUser function after creating the access token here,

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

we will call server.tokenMaker.CreateToken one more time to create the refresh token. We still pass in the same user.Username, but the duration of the refresh token should be different. Its value is taken from the config.RefreshTokenDuration variable. This function will return a refreshToken string and an error. If error is not nil, we just return internal server error, the same as for the access token above.

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

If no error occurs, we will call server.store.CreateSession to insert a new session into the database. We pass in the context, and a db.CreateSessionParam object. I'm gonna copy all the required fields of this struct

type CreateSessionParams struct {
	ID           uuid.UUID `json:"id"`
	Username     string    `json:"username"`
	RefreshToken string    `json:"refresh_token"`
	UserAgent    string    `json:"user_agent"`
	ClientIp     string    `json:"client_ip"`
	IsBlocked    bool      `json:"is_blocked"`
	ExpiresAt    time.Time `json:"expires_at"`
}

and paste them inside this CreateSessionParams object.

	server.store.CreateSession(ctx, db.CreateSessionParams{
		ID           uuid.UUID `json:"id"`
		Username     string    `json:"username"`
		RefreshToken string    `json:"refresh_token"`
		UserAgent    string    `json:"user_agent"`
		ClientIp     string    `json:"client_ip"`
		IsBlocked    bool      `json:"is_blocked"`
		ExpiresAt    time.Time `json:"expires_at"`
	})

In this object, we must have the ID of the session, and as I said before, we will use the refresh token's ID for this field. But the problem is, the createToken function doesn't return the token payload. It just returns an encrypted token string, so we don't know what the token's ID is. Therefore, I'm gonna add the token payload to the list of returning values of this function.

type Maker interface {
    CreateToken(username string, duration time.Duration) (string, *Payload, error)
	...
}

Now as we've changed the TokenMaker interface, we must change the PasetoMaker and JwtMaker as well, because they're implementing the TokenMaker interface. First, for PasetoMaker, I'm gonna add Payload to the output of this function.

func (maker *PasetoMaker) CreateToken(username string, duration time.Duration) (string, *Payload, error) {
	...
}

Then change this return statement to include payload as well.

    return "", payload, err

Now, for this Encrypt function call, we must store its output value in 2 variables: token and error. Then we return all 3 values: token, payload, and error at the end of the function.

func (maker *PasetoMaker) CreateToken(username string, duration time.Duration) (string, *Payload, error) {
	payload, err := NewPayload(username, duration)
	if err != nil {
		return "", payload, err
	}

	token, err := maker.paseto.Encrypt(maker.symmetricKey, payload, nil)
	return token, payload, err
}

OK, the PasetoMaker's implementation is now fixed.

Now I'm gonna do the same for the JwtMaker. Normally in a real project, we just choose either 1 of them, but not both. And I highly recommend using Paseto instead of JWT. If you don't know why, please go back to watch lecture 19 of the course.

func (maker *JWTMaker) CreateToken(username string, duration time.Duration) (string, *Payload, error) {
	payload, err := NewPayload(username, duration)
	if err != nil {
		return "", payload, err
	}

	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
	token, err := jwtToken.SignedString([]byte(maker.secretKey))
	return token, payload, err
}

OK, now the JwtMaker is fixed.

Fix unit tests

But some of the unit tests are still showing errors, so let's fix them as well. In the TestPasetoMaker function, we must add a payload variable here.

	token, payload, err := maker.CreateToken(util.RandomOwner(), -time.Minute)

and remove this colon because payload is not a new variable here anymore.

	payload, err = maker.VerifyToken(token)

Then I will also require it to be not empty.

    require.NotEmpty(t, payload)

You can do more checks here to make the test more robust if you want. Let's do the same for the TestExpiredPasetoMaker.

Then I'm gonna fix the rest of the JWT maker unit tests in the same manner.

func TestJWTMaker(t *testing.T) {
	maker, err := NewJWTMaker(util.RandomString(32))
	require.NoError(t, err)

	username := util.RandomOwner()
	duration := time.Minute

	issuedAt := time.Now()
	expiredAt := issuedAt.Add(duration)

	token, payload, err := maker.CreateToken(username, duration)
	require.NoError(t, err)
	require.NotEmpty(t, token)
	require.NotEmpty(t, payload)

	payload, err = maker.VerifyToken(token)
	require.NoError(t, err)
	require.NotEmpty(t, token)

	require.NotZero(t, payload.ID)
	require.Equal(t, username, payload.Username)
	require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second)
	require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second)
}

func TestExpiredJWTToken(t *testing.T) {
	maker, err := NewJWTMaker(util.RandomString(32))
	require.NoError(t, err)

	token, payload, err := maker.CreateToken(util.RandomOwner(), -time.Minute)
	require.NoError(t, err)
	require.NotEmpty(t, token)
	require.NotEmpty(t, payload)

	payload, err = maker.VerifyToken(token)
	require.Error(t, err)
	require.EqualError(t, err, ErrExpiredToken.Error())
	require.Nil(t, payload)
}

OK, it's done!

Let's rerun the whole token package tests. They're all passed! Excellent!

I'm gonna close all of these files.

But wait, there's still some errors in the api/middleware_test.go file. Let's check it out!

OK, so here in the addAuthorization function, we also have to add the payload object and require it to be not empty.

	token, payload, err := tokenMaker.CreateToken(username, duration)
    require.NoError(t, err)
    require.NotEmpty(t, payload)

OK, cool. The errors are gone.

Now let's go back to the loginUser handler function!

As we've changed the TokenMaker interface, there should be a lot of errors here. But somehow they're not showing up, maybe that's because of this incompleted piece of codes.

	server.store.CreateSession(ctx, db.CreateSessionParams{
		ID           
		Username     string    `json:"username"`
		RefreshToken string    `json:"refresh_token"`
		UserAgent    string    `json:"user_agent"`
		ClientIp     string    `json:"client_ip"`
		IsBlocked    bool      `json:"is_blocked"`
		ExpiresAt    time.Time `json:"expires_at"`
	})

So first, I'm gonna comment out all these fields of the CreateSessionParams object.

OK, cool, now the errors have shown up. Let's fix them!

Here, when creating the access token, we need to add 1 more variable to store the access token payload.

accessToken, accessPayload, err := server.tokenMaker.CreateToken(
    user.Username,
    server.config.AccessTokenDuration,
)

Then, let's move up all the way to the top.

I'm gonna add a few more fields to the loginUserResponse struct. First, a field to know when the access token will expire. This will be useful for the client to set up a schedule to renew the access token later. I'm gonna move this field up, next to the AccessToken. Then we will add 2 more similar fields for the refresh token and its expiration time.

type loginUserResponse struct {
	AccessToken           string       `json:"access_token"`
	AccessTokenExpiresAt  time.Time    `json:"access_token_expires_at"`
	RefreshToken          string       `json:"refresh_token"`
	RefreshTokenExpiresAt time.Time    `json:"refresh_token_expires_at"`
	User                  userResponse `json:"user"`
}

Alright, now back to the loginUser handler.

Let's add the refreshPayload variable to this CreateToken call.

	refreshToken, refreshPayload, err := server.tokenMaker.CreateToken(
		user.Username,
		server.config.RefreshTokenDuration,
	)

OK, now I'm gonna uncomment these parameters.

	server.store.CreateSession(ctx, db.CreateSessionParams{
		//ID, 
		//Username, string    `json:"username"`
		//RefreshToken string    `json:"refresh_token"`
		//UserAgent    string    `json:"user_agent"`
		//ClientIp     string    `json:"client_ip"`
		//IsBlocked    bool      `json:"is_blocked"`
		//ExpiresAt    time.Time `json:"expires_at"`
	})

We're gonna set the session ID to be refreshPayload.ID, the Username is gonna be user.Username, RefreshToken is simply the value returned by the CreateToken call above. For the UserAgent and ClientIP, let's temporarily set them as empty string for now. We will come back a bit later to fill in the correct values. Next, IsBlocked field should be false, of course. And finally ExpiresAt will be set to refreshPayload.ExpiredAt. OK, this CreateSession statement will return a session object and an error.

    session, err := server.store.CreateSession(ctx, db.CreateSessionParams{
        ID: refreshPayload.ID,
        Username: user.Username,
        RefreshToken: refreshToken,
        UserAgent: "", // TODO: fill it
        ClientIp: "", // TODO: fill it
        IsBlocked: false,
        ExpiresAt: refreshPayload.ExpiredAt,
    })

If error is not nil, then we just return internal server error as in other cases. Otherwise, we're ready to send the response to the client.

    if err != nil {
		ctx.JSON(http.StatusInternalServerError, errorResponse(err))
	}
    rsp := loginUserResponse{
        AccessToken: accessToken,
        User:        newUserResponse(user),
    }
    ctx.JSON(http.StatusOK, rsp)

But I want to add 1 more field to the response struct which is the ID of the session (or it's also the ID of the refresh token).

type loginUserResponse struct {
	SessionID             uuid.UUID    `json:"session_id"`
	AccessToken           string       `json:"access_token"`
	AccessTokenExpiresAt  time.Time    `json:"access_token_expires_at"`
	RefreshToken          string       `json:"refresh_token"`
	RefreshTokenExpiresAt time.Time    `json:"refresh_token_expires_at"`
	User                  userResponse `json:"user"`
}

Alright, now in this loginUserResponse object, I'm gonna set SessionID to be session.ID, AccessTokenExpiresAt to be accessPayload.ExpiredAt, RefreshToken to be refreshToken, and finally, refreshTokenExpiresAt to be refreshPayload.ExpiredAt.

	rsp := loginUserResponse{
		SessionID: session.ID,
		AccessToken: accessToken,
		AccessTokenExpiresAt: accessPayload.ExpiredAt,
		RefreshToken: refreshToken,
		RefreshTokenExpiresAt: refreshPayload.ExpiredAt,
		User:        newUserResponse(user),
	}

That will be it!

The login user API with refresh token support is now completed.

Let's open user_test.go file, and try to run the TestLoginUserAPI to see if it's still working well or not.

OK, so 1 unit test failed. And the reason is: "there are no expected calls of the method CreateSession". This is reasonable, since we haven't added a stub call requirement for this new method yet.

Fixing this is very simple. In the buildStubs function of the OK case, I'm gonna add 1 more store.EXPECT() statement but this time, we're gonna expect the CreateSession function to be called with any context, and any parameters. And it should be called exactly 1 time. Just like that!

        {
			name: "OK",
			body: gin.H{
				"username": user.Username,
				"password": password,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					GetUser(gomock.Any(), gomock.Eq(user.Username)).
					Times(1).
					Return(user, nil)
				store.EXPECT().
					CreateSession(gomock.Any(), gomock.Any()).
					Times(1)
			},
			checkResponse: func(recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusOK, recorder.Code)
			},
		},

And we're done! Of course, you can make the test stronger by replacing gomock.Any() with a more specific object. Please watch lecture 18 if you don't know how to do it.

Alright, let's rerun the unit tests.

Now all of the tests have passed. Awesome!

Let's go ahead and run

make server

in the terminal to start the server.

Then, I'm gonna send the login API request again using Postman.

This time, we have several more fields in the response.

First the session_id, the access_token and its expiration time, then the refresh token together with its expiration time. You can see that it lasts much longer, 1 day, compared to just 15 minutes of the access token.

Now let's check the database. In the sessions table, we see 1 record. We can open this right hand side section to see more details.

As you can see, it has the same ID as the session we received in Postman. And with the is_blocked field, we can easily block this session in case its refresh token get hacked. Here we also see its cretion and expiration time. But the user_agent and client IP address are still empty. Now let's go back to the code to fill in their correct values.

It's actually pretty simple because we're using Gin framework. For the UserAgent, we can just call ctx.Request.UserAgent(). This information is already available inside the Gin context object. And similarly, for the client IP, we can just call ctx.ClientIP(). And that's it! Super easy, isn't it?

You can also get other metadata from the context, such as the Content-Type, if you want. OK, now let's restart the server and test it out!

I'm gonna resend this login user request. It's successful! So let's checkout the sessions table.

This time, we have a new session record, and look at the user_agent and client_ip fields! They have been saved with the correct value. Pretty cool, right?

Alright, so now as the login user API with refresh token and session record has been working very well, the only thing that is still missing is an API to renew the access token when it expires.

As this API is very similar to the login user API, I'm gonna copy the content of this loginUser handler, and create a new file called token.go inside the api package. Then let's paste in the content that we've just copied. Now we must change all the request, response and handler function name from loginUser to renewAccessToken.

As you can see, in Visual Studio Code, I use the Command + Shift + L key combination to edit this value at multiple places at the same time.

Alright, now for the renewAccessTokenRequest, the only field we would need is the refreshToken.

type renewAccessTokenRequest struct {
	RefreshToken string `json:"refresh_token" binding:"required"`
}

So I'm gonna change this Username field to refreshToken and remove the Password field.

Similarly, for the renewAccessTokenResponse, we will just return 2 fields: AccessToken and its expiration time. So let's get rid of all other redundant fields.

OK, then in the renewAccessToken function, first we bind the input JSON into the request object. Then, here, we need to verify if the refresh token is valid or not. So I'm gonna call server.tokenMaker.VerifyToken, and pass in req.RefreshToken. The output of this function call is a refreshPayload and an error.

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

	refreshPayload, err := server.tokenMaker.VerifyToken(req.RefreshToken)
	if err != nil {
		ctx.JSON(http.StatusUnauthorized, errorResponse(err))
		return
	}
	...
}

If error is not nil, then it means the refresh token is invalid or expired. In that case, we simply return an Unauthorized status code to the client. Otherwise, we will find the session in the database by calling server.store.GetSession and pass in the refresh token's ID as session ID.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	session, err := server.store.GetSession(ctx, refreshPayload.ID)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }
	...
}

It will return a session object, or an error. If error is not nil, then we just handle it just like when user is not found in the login API. Return 404 Not found if session doesn't exist, or internal server error in other cases.

We don't have to compare password in this renewAccessToken API, but instead, we must check if the session is blocked or not.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	if session.IsBlocked {
        err := fmt.Errorf("blocked session")
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }
	...
}

If it is blocked, then we will create a new error object saying "blocked session" and return it to the client together with a status code 401 Unauthorized. We should also check if the session.Username is the same as the one stored in the refresh token.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	if session.Username != refreshPayload.Username {
        err := fmt.Errorf("incorrect session user")
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }
	...
}

If they're not the same, then we also return an error to the client saying "incorrect session user". Let's also check if the session.RefreshToken is the same as req.RefershToken or not.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	if session.RefreshToken != req.RefreshToken {
        err := fmt.Errorf("mismatched session token")
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }
	...
}

Normally they should be the same. But, just in case they're not, we return an error saying "mismatched session token".

Finally, although the expiration time of the token has been checked in the VerifyToken function, we can check it one more time here, because in some rare cases, we might want to force it to expire before its actual expiration time.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	if time.Now().After(session.ExpiresAt) {
        err := fmt.Errorf("expired session")
        ctx.JSON(http.StatusUnauthorized, errorResponse(err))
        return
    }
	...
}

So if current time is after session.ExpiresAt we return an error saying "expired session". And I think that should be enough for the checking part. Now if everything goes well, we can issue a new access token.

func (server *Server) renewAccessToken(ctx *gin.Context) {
	...
	accessToken, accessPayload, err := server.tokenMaker.CreateToken(
        refreshPayload.Username,
        server.config.AccessTokenDuration,
    )
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }
	...
}

This time, we can get the username from the refreshPayload object. And that's it! The error handling part should stay the same. And we can remove the rest of the code that creates a new refresh token and session.

Finally, we will delete all redundant fields in the response object, just keep 2 fields: accessToken, and accessTokenExpiresAt.

func (server *Server) renewAccessToken(ctx *gin.Context) {
    ...
	rsp := renewAccessTokenResponse{
        AccessToken:          accessToken,
        AccessTokenExpiresAt: accessPayload.ExpiredAt,
    }
    ctx.JSON(http.StatusOK, rsp)
}

And we're done with the renewAccessToken handler function. Next, we must register this handler as a new route in the server.go file.

I'm gonna add here a new router.POST, where the path is /token/renew_access, and the handler is server.renewAccessToken.

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

	router.POST("/users", server.createUser)
	router.POST("/users/login", server.loginUser)
	router.POST("/token/renew_access", server.renewAccessToken)
	...
}

And we're done!

Let's open the terminal and restart the server!

make server

Sending requests using Postman

Then open Postman, I'm gonna create a new request. Change the method to POST, then the request URL should be localhost:8080/tokens/renew_access. Then in the Body tab, let's select Raw, and JSON format. The only field we would need is refresh_token. I'm gonna copy its value from the login API's response. Alright, now let's send the request!

Yee! It's successful!

We've got a new access token that will last for the next 15 minutes! Awesome!

OK, now I'm gonna save this new request into my Simple Bank Postman collection.

And before we finish, I will try 1 edge case, where we use the access token instead of the refresh token in the API.

Let's see if we will be able to get a new access token or not.

OK, I'm gonna send the request!

Oh, the access token has already expired. So let's go back and login again. Then copy the new access token, paste it to the renew access API, and send the request one more time!

This time, we've got error: "no rows in result set", that's because there's no session record in the database with the ID of the access token we've used. That's exactly what we wanted: only the refresh token should be able to issue a new access token.

OK, how about trying an invalid refresh token, such as "abc"? We've got an error: "token is invalid".

And this is a very nice bonus when we use PASETO as the refresh token, because for cases like this, the server doesn't have to query the database at all in order to know that the refresh token is invalid.

That definitely will reduce a lot of load to the database!

And this brings us to the end of this video. I hope it was interesting and you've learned something useful.

Thanks a lot for watching, and see you in the next lecture!