DEV Community

Cover image for Authentication system using Golang and Sveltekit - Login and Logout
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

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

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

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

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

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

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

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

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

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

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

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

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)