Introduction
Though we had made great progress in building a performant, secure, and resilient full-stack authentication system, we still have some features deficit. A user who couldn't meet the token expiration deadline has no way to regenerate another one and as a result, such a user wouldn't be able to access the application resource unless an admin is reached out to for manual update. We don't want this. Also, a user whose password is compromised or who can't remember the password used will have to live with it and there's currently no way to update it. This article will address these shortcomings and maybe more.
Source code
The source code for this series is hosted on GitHub via:
go-auth
This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.
It is currently live here (the backend may be brought down soon).
To run locally, kindly follow the instructions in each subdirectory.
Implementation
Step 1: Token regeneration
Let's write an endpoint that provides a nifty interface for users to regenerate account activation tokens:
// cmd/api/regenerate_token.go
package main
import (
"errors"
"net/http"
"time"
"goauthbackend.johnowolabiidogun.dev/internal/data"
"goauthbackend.johnowolabiidogun.dev/internal/tokens"
"goauthbackend.johnowolabiidogun.dev/internal/validator"
)
func (app *application) regenerateTokenHandler(w http.ResponseWriter, r *http.Request) {
// Expected data from the user
var input struct {
Email string `json:"email"`
}
// Try reading the user input to JSON
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the user input
v := validator.New()
if data.ValidateEmail(v, input.Email); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
db_user, err := app.models.Users.GetEmail(input.Email, false)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Generate 6-digit token
otp, err := tokens.GenerateOTP()
if err != nil {
app.serverErrorResponse(w, r, errors.New("something happened and we could not fullfil your request at the moment"))
return
}
err = app.storeInRedis("activation_", otp.Hash, db_user.ID, app.config.tokenExpiration.duration)
if err != nil {
app.logError(r, err)
}
now := time.Now()
expiration := now.Add(app.config.tokenExpiration.duration)
exact := expiration.Format(time.RFC1123)
// Send email to user, using separate goroutine, for account activation
app.background(func() {
data := map[string]interface{}{
"token": tokens.FormatOTP(otp.Secret),
"userID": db_user.ID,
"frontendURL": app.config.frontendURL,
"expiration": app.config.tokenExpiration.durationString,
"exact": exact,
}
err = app.mailer.Send(db_user.Email, "user_welcome.tmpl", data)
if err != nil {
app.logError(r, err)
}
app.logger.PrintInfo("Email successfully sent.", nil, app.config.debug)
})
// Respond with success
app.successResponse(
w,
r,
http.StatusAccepted,
"Account activation link has been sent to your email address. Kindly take action before its expiration",
)
}
The logic here is almost like the one for user registration aside from some differences:
- We only require the user's email address
- User data was retrieved from the database as against creating one. In retrieving the user, we ensured that only a user with inactive status, i.e.
is_active = false
, is retrieved from the database. This ensures that active users cannot regenerate activation tokens. - The response message is also different.
Aside from those, everything else remains the same.
You can now append this handler to the ones we currently have.
Step 2: Changing users' passwords
For users to change their password on our system, they will first need to request a change by supplying their registered and VERIFIED email addresses. If their data are available in our database, we will email them some tokens which they need to submit alongside their new passwords. With these, we will have two handlers to work with:
-
requestChangePasswordHandler
- This handler accepts the user's registered email address, generates a token, and sends it to the email address with instructions on how the user's password can be changed. -
changePasswordHandler
- It retrieves the token and new password of the user, verifies the authenticity of such a token and update the password of the user accordingly.
These handlers have the following logic, starting with requestChangePasswordHandler
:
// cmd/api/request_password_change.go
package main
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"goauthbackend.johnowolabiidogun.dev/internal/data"
"goauthbackend.johnowolabiidogun.dev/internal/tokens"
"goauthbackend.johnowolabiidogun.dev/internal/validator"
)
func (app *application) requestChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
expirationInt, err := strconv.Atoi(strings.Split(app.config.tokenExpiration.durationString, "m")[0])
if err != nil {
app.serverErrorResponse(w, r,
errors.New("something happened and we could not fullfil your request at the moment"))
return
}
expirationStr := fmt.Sprintf("%dm", expirationInt*2)
// Expected data from the user
var input struct {
Email string `json:"email"`
}
// Try reading the user input to JSON
err = app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Validate the user input
v := validator.New()
if data.ValidateEmail(v, input.Email); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
db_user, err := app.models.Users.GetEmail(input.Email, true)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// Generate 6-digit token
otp, err := tokens.GenerateOTP()
if err != nil {
app.serverErrorResponse(w, r, errors.New("something happened and we could not fullfil your request at the moment"))
return
}
err = app.storeInRedis("password_reset_", otp.Hash, db_user.ID, (app.config.tokenExpiration.duration * 2))
if err != nil {
app.serverErrorResponse(w, r,
errors.New("something happened and we could not fullfil your request at the moment"),
)
return
}
now := time.Now()
expiration := now.Add(app.config.tokenExpiration.duration * 2)
exact := expiration.Format(time.RFC1123)
// Send email to user, using separate goroutine, for account activation
app.background(func() {
data := map[string]interface{}{
"name": fmt.Sprintf("%s %s", db_user.FirstName, db_user.LastName),
"token": tokens.FormatOTP(otp.Secret),
"userID": db_user.ID,
"frontendURL": app.config.frontendURL,
"expiration": expirationStr,
"exact": exact,
}
err = app.mailer.Send(db_user.Email, "password_reset.tmpl", data)
if err != nil {
app.logError(r, err)
}
app.logger.PrintInfo("Email successfully sent.", nil, app.config.debug)
})
// Respond with success
app.successResponse(
w,
r,
http.StatusAccepted,
"You requested a password change. Check your email address and follow the instruction to change your password. Ensure your password is changed before the token expires",
)
}
It is a basic handler like the ones we've written before. The keynotes here are:
- We want password reset tokens to stay longer (two times longer) than activation tokens. Hence the logic at the beginning of the handler.
-
We also used a new template for our email. Its name is
password_reset.tmpl
, located ininternal/mailer/templates
and has the following contents:
<!-- internal/mailer/templates/password_reset.tmpl --> {{define "subject"}}Password reset instructions - GoAuth!{{end}} {{define "plainBody"}} Hello {{.name}}, A request to reset your password was submitted. If you did not make this request, simply ignore this email. If you did make this request, please visit {{.frontendURL}}/auth/password/change/{{.userID}} and input the token below as well as your new password: {{.token}} Please note that this is a one-time use token and it will expire in {{.expiration}} ({{.exact}}). Thanks, The John - GoAuth Team {{end}} {{define "htmlBody"}} <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> </head> <body> <table style="background: #ffffff; border-radius: 1rem; padding: 30px 0px"> <tbody> <tr> <td style="padding: 0px 30px"> <h3 style="margin-bottom: 0px; color: #000000">Hello {{.name}},</h3> <p> A request to reset your password was submitted. </p> </td> </tr> <tr> <td style="padding: 0px 30px"> <p>If you did not make this request, simply ignore this email.</p> <p> If you did make this request, please visit <a href="{{.frontendURL}}/auth/password/change/{{.userID}}"> {{.frontendURL}}/auth/password/change/{{.userID}} </a> and input the token below as well as your new password: </p> </td> </tr> <tr> <td style="padding: 10px 30px; text-align: center"> <strong style="display: block; color: #00a856"> One Time Password (OTP) </strong> <table style="margin: 10px 0px" width="100%"> <tbody> <tr> <td style=" padding: 25px; background: #faf9f5; border-radius: 1rem; " > <strong style=" letter-spacing: 8px; font-size: 24px; color: #000000; " > {{.token}} </strong> </td> </tr> </tbody> </table> <small style="display: block; color: #6c757d; line-height: 19px"> <strong> Please note that this is a one-time use token and it will expire in {{.expiration}} ({{.exact}}). </strong> </small> </td> </tr> <tr> <td style="padding: 0px 30px"> <hr style="margin: 0" /> </td> </tr> <tr> <td style="padding: 30px 30px"> <table> <tbody> <tr> <td> <strong> Kind Regards,<br /> The John - GoAuth Team </strong> </td> <td></td> </tr> </tbody> </table> </td> </tr> </tbody> </table> </body> </html> {{end}}
Next is the changePasswordHandler
:
// cmd/api/change_password.go
package main
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"goauthbackend.johnowolabiidogun.dev/internal/data"
"goauthbackend.johnowolabiidogun.dev/internal/tokens"
"goauthbackend.johnowolabiidogun.dev/internal/validator"
)
func (app *application) changePasswordHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
app.logger.PrintError(err, nil, app.config.debug)
app.badRequestResponse(w, r, err)
return
}
var input struct {
Secret string `json:"token"`
Password string `json:"password"`
}
err = app.readJSON(w, r, &input)
if err != nil {
app.logger.PrintError(err, nil, app.config.debug)
app.badRequestResponse(w, r, err)
return
}
v := validator.New()
if tokens.ValidateSecret(v, input.Secret); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
hash, err := app.getFromRedis(fmt.Sprintf("password_reset_%s", id))
if err != nil {
app.logger.PrintError(err, nil, app.config.debug)
app.badRequestResponse(w, r, err)
return
}
tokenHash := fmt.Sprintf("%x\n", sha256.Sum256([]byte(input.Secret)))
if *hash != tokenHash {
app.logger.PrintError(errors.New("the supplied token is invalid"), nil, app.config.debug)
app.failedValidationResponse(w, r, map[string]string{
"token": "The supplied token is invalid",
})
return
}
user := &data.User{
ID: *id,
}
// Hash user password
err = user.Password.Set(input.Password)
if err != nil {
app.logger.PrintError(err, nil, app.config.debug)
app.serverErrorResponse(w, r, err)
return
}
result, err := app.models.Users.UpdateUserPassword(user)
if err != nil {
app.logger.PrintError(err, nil, app.config.debug)
app.serverErrorResponse(w, r, err)
return
}
app.logger.PrintInfo(fmt.Sprintf("%x", result), nil, app.config.debug)
ctx := context.Background()
deleted, err := app.redisClient.Del(ctx, fmt.Sprintf("password_reset_%s", id)).Result()
if err != nil {
app.logger.PrintError(err, map[string]string{
"key": fmt.Sprintf("password_reset_%s", id),
}, app.config.debug)
}
app.logger.PrintInfo(fmt.Sprintf("Token hash was deleted successfully :activation_%d", deleted), nil, app.config.debug)
app.successResponse(w, r, http.StatusOK, "Password updated successfully.")
}
The logic is almost like the one for the activation of users' accounts. The only difference here is that we are updating users' passwords instead and to do that, we are using the UpdateUserPassword
method on the UserModel
:
// internal/data/user_queries.go
...
func (um UserModel) UpdateUserPassword(user *User) (*sql.Result, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
query := `UPDATE users SET password = $1 WHERE id = $2`
result, err := um.DB.ExecContext(ctx, query, user.Password.hash, user.ID)
if err != nil {
return nil, err
}
return &result, nil
}
...
With that, everything appears to be cool. We'll stop here for now. In the next section, we'll talk about users' profile updates and keeping metrics of your application. See you.
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)