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:
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
}
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? 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)