Authentication system using Golang and Sveltekit – Login and Logout

Introduction

Having seen the beauty we made so far, let’s add more features so that registered and activated users can log in and out of our system while also being about to access some user-only content.

Source code

The source c…


This content originally appeared on DEV Community and was authored by John Owolabi Idogun

Introduction

Having seen the beauty we made so far, let's add more features so that registered and activated users can log in and out of our system while also being about to access some user-only content.

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




Implementation

Step 1: User cookies

Since we are building a session-based authentication system, we need to encrypt non-sensitive user data in cookies. These cookies will then be sent to the users' browsers so that users won't always need to provide login every time to access some private resources. In our case, we will also save the encrypted cookie in redis to double-check incoming requests. Our system's cookies will have max-age whose value can be changed using an environment variable. For encryption, we will use some encoded secrets, whose value can also be changed using an environment variable.

Although there are pretty good session managers in the Go ecosystem such as alexedwards/scs, golangcollege/session and gorilla/sessions, we won't use any but using this great guide, we'll write our own. This is to keep our project's dependence on external packages at the barest minimum.

The entire code for the cookie encryption and decryption is located in internal/cookies/cookies.go:

// internal/cookies/cookies.go
package cookies

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"
)

var (
    ErrValueTooLong = errors.New("cookie value too long")
    ErrInvalidValue = errors.New("invalid cookie value")
)

func Write(w http.ResponseWriter, cookie http.Cookie) error {
    cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value))

    if len(cookie.String()) > 4096 {
        return ErrValueTooLong
    }

    http.SetCookie(w, &cookie)

    return nil
}

func Read(r *http.Request, name string) (string, error) {
    cookie, err := r.Cookie(name)
    if err != nil {
        return "", err
    }

    value, err := base64.URLEncoding.DecodeString(cookie.Value)
    if err != nil {
        return "", ErrInvalidValue
    }

    return string(value), nil
    }

func WriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return err
    }

    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return err
    }

    nonce := make([]byte, aesGCM.NonceSize())
    _, err = io.ReadFull(rand.Reader, nonce)
    if err != nil {
        return err
    }

    plaintext := fmt.Sprintf("%s:%s", cookie.Name, cookie.Value)

    encryptedValue := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)

    cookie.Value = string(encryptedValue)

    return Write(w, cookie)
}

func ReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) {
    encryptedValue, err := Read(r, name)
    if err != nil {
        return "", err
    }

    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return "", err
    }

    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }

    nonceSize := aesGCM.NonceSize()

    if len(encryptedValue) < nonceSize {
        return "", ErrInvalidValue
    }

    nonce := encryptedValue[:nonceSize]
    ciphertext := encryptedValue[nonceSize:]

    plaintext, err := aesGCM.Open(nil, []byte(nonce), []byte(ciphertext), nil)
    if err != nil {
        return "", ErrInvalidValue
    }

    expectedName, value, ok := strings.Cut(string(plaintext), ":")
    if !ok {
        return "", ErrInvalidValue
    }

    if expectedName != name {
        return "", ErrInvalidValue
    }

    return value, nil
}

Reading through the code with this guide at your side, you will definitely not be lost.

The only data we will encrypt in the cookies is the UserID type. We need to register this in the cmd/api/main.go file. Also, we will use this opportunity to add some data to our config type:

// cmd/api/main.go
...
type config struct {
    ...
    tokenExpiration struct {
        durationString string
        duration       time.Duration
    }
    secret struct {
        HMC               string
        secretKey         []byte
        sessionExpiration time.Duration
    }
    ...
}
...
func main() {
    gob.Register(&data.UserID{})
    ...
}
...

We also need to update cmd/api/config.go:

// cmd/api/config.go
...
func updateConfigWithEnvVariables() (*config, error) {
    ...
    // Secret
    flag.StringVar(&cfg.secret.HMC, "secret-key", os.Getenv("HMC_SECRET_KEY"), "HMC Secret Key")
    ...
    secretKey, err := hex.DecodeString(cfg.secret.HMC)
    if err != nil {
        return nil, err
    }
    cfg.secret.secretKey = secretKey
    sessionDuration, err := time.ParseDuration(os.Getenv("SESSION_EXPIRATION"))
    if err != nil {
        return nil, err
    }
    cfg.secret.sessionExpiration = sessionDuration

    // Token Expiration
    tokexpirationStr := os.Getenv("TOKEN_EXPIRATION")
    duration, err := time.ParseDuration(tokexpirationStr)
    if err != nil {
        return nil, err
    }
    cfg.tokenExpiration.durationString = tokexpirationStr
    cfg.tokenExpiration.duration = duration
    ...
}
...

With that, we can now create a login handler.

Step 2: User login

Let's open cmd/api/login.go and fill it with:

// cmd/api/login.go
package main

import (
    "bytes"
    "encoding/gob"
    "errors"
    "net/http"

    "goauthbackend.johnowolabiidogun.dev/internal/cookies"
    "goauthbackend.johnowolabiidogun.dev/internal/data"
)

func (app *application) loginUserHandler(w http.ResponseWriter, r *http.Request) {
    // Expected data from the user
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    // Try reading the user input to JSON
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    db_user, err := app.models.Users.GetEmail(input.Email, true)
    if err != nil {

        app.badRequestResponse(w, r, err)
        return
    }

    match, err := db_user.Password.Matches(input.Password)
    if err != nil {

        return
    }

    if !match {
        app.badRequestResponse(w, r, errors.New("email and password combination does not match"))
        return
    }

    var userID = data.UserID{
        Id: db_user.ID,
    }

    var buf bytes.Buffer

    // Gob-encode the user data, storing the encoded output in the buffer.
    err = gob.NewEncoder(&buf).Encode(&userID)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened encoding your data"))
        return
    }

    session := buf.String()

    // Store session in redis
    err = app.storeInRedis("sessionid_", session, userID.Id, app.config.secret.sessionExpiration)
    if err != nil {
        app.logError(r, err)
    }

    cookie := http.Cookie{
        Name:     "sessionid",
        Value:    session,
        Path:     "/",
        MaxAge:   int(app.config.secret.sessionExpiration.Seconds()),
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write an encrypted cookie containing the gob-encoded data as normal.
    err = cookies.WriteEncrypted(w, cookie, app.config.secret.secretKey)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened setting your cookie data"))
        return
    }

    app.writeJSON(w, http.StatusOK, db_user, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "Logged in successfully")
}

Most of the code should be pretty familiar by now. Only this section isn't:

...
    var userID = data.UserID{
        Id: db_user.ID,
    }

    var buf bytes.Buffer

    // Gob-encode the user data, storing the encoded output in the buffer.
    err = gob.NewEncoder(&buf).Encode(&userID)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened encoding your data"))
        return
    }

    session := buf.String()

    // Store session in redis
    err = app.storeInRedis("sessionid_", session, userID.Id, app.config.secret.sessionExpiration)
    if err != nil {
        app.logError(r, err)
    }

    cookie := http.Cookie{
        Name:     "sessionid",
        Value:    session,
        Path:     "/",
        MaxAge:   int(app.config.secret.sessionExpiration.Seconds()),
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write an encrypted cookie containing the gob-encoded data as normal.
    err = cookies.WriteEncrypted(w, cookie, app.config.secret.secretKey)
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened setting your cookie data"))
        return
    }
...

We are encoding the user's ID and storing it in redis, setting the cookie, and then encrypting it.

Step 3: User logout

Now to the logout handler:

// cmd/api/logout.go
package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

func (app *application) logoutUserHandler(w http.ResponseWriter, r *http.Request) {
    userID, status, err := app.extractParamsFromSession(r)
    if err != nil {
        switch *status {
        case http.StatusUnauthorized:
            app.unauthorizedResponse(w, r, err)

        case http.StatusBadRequest:
            app.badRequestResponse(w, r, errors.New("invalid cookie"))

        case http.StatusInternalServerError:
            app.serverErrorResponse(w, r, err)

        default:
            app.serverErrorResponse(w, r, errors.New("something happened and we could not fullfil your request at the moment"))
        }
        return
    }

    // Get session from redis
    _, err = app.getFromRedis(fmt.Sprintf("sessionid_%s", userID.Id))
    if err != nil {
        app.unauthorizedResponse(w, r, errors.New("you are not authorized to access this resource"))
        return
    }

    // Delete session from redis
    ctx := context.Background()
    _, err = app.redisClient.Del(ctx, fmt.Sprintf("sessionid_%s", userID.Id)).Result()
    if err != nil {
        app.serverErrorResponse(w, r, errors.New("something happened decosing your cookie data"))
        return
    }

    http.SetCookie(w, &http.Cookie{
        Name:    "sessionid",
        Value:   "",
        Expires: time.Now(),
    })

    // Respond with success
    app.successResponse(w, r, http.StatusOK, "You have successfully logged out")
}

Every other thing should be familiar aside from the extractParamsFromSession black box:

// cmd/api/helpers.go
...
func (app *application) extractParamsFromSession(r *http.Request) (*data.UserID, *int, error) {
    gobEncodedValue, err := cookies.ReadEncrypted(r, "sessionid", app.config.secret.secretKey)

    if err != nil {
        var errorData error
        var status int
        switch {
        case errors.Is(err, http.ErrNoCookie):
            status = http.StatusUnauthorized
            errorData = errors.New("you are not authorized to access this resource")

        case errors.Is(err, cookies.ErrInvalidValue):
            app.logger.PrintError(err, nil, app.config.debug)
            status = http.StatusBadRequest
            errorData = errors.New("invalid cookie")

        default:
            status = http.StatusInternalServerError
            errorData = errors.New("something happened getting your cookie data")

        }
        return nil, &status, errorData
    }

    var userID data.UserID

    reader := strings.NewReader(gobEncodedValue)
    if err := gob.NewDecoder(reader).Decode(&userID); err != nil {
        status := http.StatusInternalServerError
        return nil, &status, errors.New("something happened decosing your cookie data")
    }

    return &userID, nil, nil
}

We are decrypting the sessionid provided by the user and extracting the user's ID. This ID is what we need to get and delete the token from redis. Appropriate errors are returned at every stage.

Step 4: Getting currently active user

If a user is logged in and has an authentic session token, we want to return such user's data without providing email and password every time. This handler does that:

package main

import (
    "errors"
    "fmt"
    "net/http"
)

func (app *application) currentUserHandler(w http.ResponseWriter, r *http.Request) {

    userID, status, err := app.extractParamsFromSession(r)
    if err != nil {
        switch *status {
        case http.StatusUnauthorized:
            app.unauthorizedResponse(w, r, err)

        case http.StatusBadRequest:
            app.badRequestResponse(w, r, errors.New("invalid cookie"))

        case http.StatusInternalServerError:
            app.serverErrorResponse(w, r, err)

        default:
            app.serverErrorResponse(
                w,
                r,
                errors.New("something happened and we could not fullfil your request at the moment"),
            )
        }
        return
    }

    // Get session from redis
    _, err = app.getFromRedis(fmt.Sprintf("sessionid_%s", userID.Id))
    if err != nil {
        app.unauthorizedResponse(w, r, errors.New("you are not authorized to access this resource"))
        return
    }

    db_user, err := app.models.Users.Get(userID.Id)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusOK, db_user, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
    app.logSuccess(r, http.StatusOK, "User was retrieved successfully")
}

Almost the same as the logout route aside from the fact that we ain't deleting the token and we used a method to return the user from the database:

func (um UserModel) Get(id uuid.UUID) (*User, error) {
    query := `
    SELECT 
        u.*, p.* 
    FROM 
        users u 
        LEFT JOIN user_profile p ON p.user_id = u.id 
    WHERE 
        u.is_active = true AND u.id = $1
    `
    var user User
    var userP UserProfile
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    err := um.DB.QueryRowContext(ctx, query, id).Scan(&user.ID,
        &user.Email, &user.Password.hash, &user.FirstName, &user.LastName, &user.IsActive, &user.IsStaff, &user.IsSuperuser, &user.Thumbnail, &user.DateJoined, &userP.ID, &userP.UserID, &userP.PhoneNumber, &userP.BirthDate, &userP.GithubLink,
    )
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }
    user.Profile = userP
    return &user, nil
}

func (um UserModel) GetEmail(email string, active bool) (*User, error) {
    query := `
    SELECT 
        u.*, p.*
    FROM 
        users u 
        JOIN user_profile p ON p.user_id = u.id 
    WHERE 
        u.is_active = $2 AND u.email = $1`

    var user User
    var userP UserProfile

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    err := um.DB.QueryRowContext(ctx, query, email, active).Scan(
        &user.ID,
        &user.Email,
        &user.Password.hash,
        &user.FirstName,
        &user.LastName,
        &user.IsActive,
        &user.IsStaff,
        &user.IsSuperuser,
        &user.Thumbnail,
        &user.DateJoined,
        &userP.ID,
        &userP.UserID,
        &userP.PhoneNumber,
        &userP.BirthDate,
        &userP.GithubLink,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            if active {
                return nil, ErrRecordNotFound
            } else {
                return nil, errors.New("an inactive user with the provided email address was not found")
            }
        default:
            return nil, err
        }
    }

    user.Profile = userP

    return &user, nil
}

The methods are simple to reason about.

Any other methods and snippets omitted can be gotten from the project's GitHub repository.

Now, let's register these routes in the cmd/api/routes.go:

// cmd/api/routes.go
...
func (app *application) routes() http.Handler {
    ...
    router.HandlerFunc(http.MethodPost, "/users/login/", app.loginUserHandler)
    router.HandlerFunc(http.MethodPost, "/users/logout/", app.logoutUserHandler)
    router.HandlerFunc(http.MethodGet, "/users/current-user/", app.currentUserHandler)
    ...
}

That's it for now, see you in the next one.

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...


This content originally appeared on DEV Community and was authored by John Owolabi Idogun


Print Share Comment Cite Upload Translate Updates
APA

John Owolabi Idogun | Sciencx (2023-06-04T19:58:55+00:00) Authentication system using Golang and Sveltekit – Login and Logout. Retrieved from https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/

MLA
" » Authentication system using Golang and Sveltekit – Login and Logout." John Owolabi Idogun | Sciencx - Sunday June 4, 2023, https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/
HARVARD
John Owolabi Idogun | Sciencx Sunday June 4, 2023 » Authentication system using Golang and Sveltekit – Login and Logout., viewed ,<https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/>
VANCOUVER
John Owolabi Idogun | Sciencx - » Authentication system using Golang and Sveltekit – Login and Logout. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/
CHICAGO
" » Authentication system using Golang and Sveltekit – Login and Logout." John Owolabi Idogun | Sciencx - Accessed . https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/
IEEE
" » Authentication system using Golang and Sveltekit – Login and Logout." John Owolabi Idogun | Sciencx [Online]. Available: https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/. [Accessed: ]
rf:citation
» Authentication system using Golang and Sveltekit – Login and Logout | John Owolabi Idogun | Sciencx | https://www.scien.cx/2023/06/04/authentication-system-using-golang-and-sveltekit-login-and-logout/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.