DEV Community

Cover image for Authentication system using Golang and Sveltekit - Regenerate token & Password change
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Golang and Sveltekit - Regenerate token & Password change

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:

GitHub logo Sirneij / go-auth

A fullstack session-based authentication system using golang and sveltekit

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",
    )
}
Enter fullscreen mode Exit fullscreen mode

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",
    )
}
Enter fullscreen mode Exit fullscreen mode

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 in internal/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.")
}
Enter fullscreen mode Exit fullscreen mode

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
}
...
Enter fullscreen mode Exit fullscreen mode

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? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...

Top comments (0)