Session-based Authentication

Authentication is the security layer implemented to prevent access to personal or sensitive information. It is a way to confirm identity before granting access to information. There are multiple approaches to confirming an identity, but, for the first phase of my experiment, I’ll be focusing on the popular ones used in web applications.

Features

In order to make it more practical, I’ll be building a basic web application with the following features:

  • Login: Accept user name and password
  • Log out: Invalidate the session ID
  • Home: An endpoint that requires authentication to be accessed

How it works

A sequence diagram showing the client and server interaction for session-based authentication

With this method, the credential, typically the username and password, is provided for validation. On a successful validation of the credential, a session ID is generated, stored and added to the cookie header of the response. The client saves this session ID (in Cookies storage) and passes it along for every request. This is also known as a cookie-based authentication because of the use of cookies.

Session-based authentication is a stateful authentication method because the means of authorization (session ID) is persisted in a storage (DB and/or Cache). The session ID is persisted because the server uses it to validate the request from the client. Session IDs are invalidated and deleted either when they expire or when a logout request is initiated.

Implementation

Go is the language of choice for implementation harnessing the standard libraries with little to no third-party library. As much as possible, I'd also like to use a TDD approach to help with validating my thought before implementing the feature.

The project set up is straight forward:

Project set up
mkdir session
cd session
go mod init authentication/session

Login

The /login endpoint is a POST request accepting a username and password. Generally, the authentication requirements are:

  • Return an unauthorized access error (HTTP 401) when either the username or password isn’t provided
  • Return a user not found error (HTTP 404) when the username doesn’t exist
  • Return an unauthorized access error (HTTP 401) when the username and password don’t match
  • Return a session ID (with a 200 OK code) on a successful validation

With a TDD approach, a test file (server_test.go) is created to capture these requirements:

server_test.go
package main
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestLogin(t *testing.T) {
t.Run("return error for missing username", func(t *testing.T) {
credentials := []byte(`{"username": "", "password": "admin"}`)
request, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(credentials))
response := httptest.NewRecorder()
Login(response, request)
got := response.Body.String()
52 collapsed lines
expected := fmt.Sprint(ErrRequiredUsername, "\n")
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
})
t.Run("return error for non-existent user", func(t *testing.T) {
credentials := []byte(`{"username": "admin1", "password": "admin"}`)
request, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(credentials))
response := httptest.NewRecorder()
Login(response, request)
got := response.Body.String()
expected := fmt.Sprint(ErrUserNotFound, "\n")
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
})
t.Run("return error for for credential mismatch", func(t *testing.T) {
credentials := []byte(`{"username": "admin", "password": "admin1"}`)
request, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(credentials))
response := httptest.NewRecorder()
Login(response, request)
got := response.Body.String()
expected := fmt.Sprint(ErrCredentialMismatch, "\n")
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
})
t.Run("return a session ID", func(t *testing.T) {
credentials := []byte(`{"username": "admin", "password": "admin"}`)
request, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(credentials))
response := httptest.NewRecorder()
Login(response, request)
cookie := response.Result().Cookies()
sessionId := cookie[0].Value
if len(sessionId) <= 0 {
t.Errorf("expected a session id, got %q", sessionId)
}
})
}

Ideally, the test for each requirement will be written, then the most minimal amount of code required to make the test pass is written. Finally, a refactor is done to tidy things up. However, that’s outside the scope of this topic.

To implement the requirements, another file, server.go, is created:

server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
)
type Credential struct {
Username string `json:"username"`
Password string `json:"password"`
}
const (
ErrRequiredUsername = "username is required"
ErrRequiredPassword = "password is required"
63 collapsed lines
ErrUserNotFound = "user not found"
ErrCredentialMismatch = "username and password don't match"
ErrCookieNotFound = "cookie not found"
ErrMissingSessionID = "session id is missing"
ErrInvalidSessionID = "Invalid session ID. Try logging in"
ErrNoSession = "Already logged out"
)
var users = map[string]string{
"admin": "admin",
"user1": "user1",
}
const MY_SESSION_ID = "MY_SESSION_ID"
var Sessions = make(map[string]string)
var sessionMutex sync.Mutex
func Login(w http.ResponseWriter, r *http.Request) {
var credential Credential
err := json.NewDecoder(r.Body).Decode(&credential)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
if credential.Username == "" {
http.Error(w, ErrRequiredUsername, http.StatusBadRequest)
return
}
if credential.Password == "" {
http.Error(w, ErrRequiredPassword, http.StatusBadRequest)
return
}
if _, ok := users[credential.Username]; !ok {
http.Error(w, ErrUserNotFound, http.StatusNotFound)
return
}
if users[credential.Username] != credential.Password {
http.Error(w, ErrCredentialMismatch, http.StatusUnauthorized)
return
}
sessionId := uuid.NewString()
sessionMutex.Lock()
Sessions[sessionId] = credential.Username
sessionMutex.Unlock()
cookie := http.Cookie{
Name: MY_SESSION_ID,
Value: sessionId,
Expires: time.Now().Add(time.Hour),
MaxAge: 3600,
Secure: true,
}
http.SetCookie(w, &cookie)
w.WriteHeader(http.StatusOK)
}

A lot seems to be going on for the login feature but it’s straightforward:

  • Packages: Keeping to the implementation constraint, we are using the standard libraries except for the UUID library (github.com/google/uuid) which is used for generating unique session IDs.
  • Credential struct: A custom type was created for the login details. The login details will be sent via JSON, so we need to export the fields to allow encoding/decoding in JSON.
  • Map of users: In keeping the implementation simple, a map was used as store for existing users. Ideally, this should be read from storage.
  • Map of sessions: Like users, a map is also used to store the created sessions.
  • Mutex: This was added to guard the map of sessions against a race condition when writing to it.
  • Constants and variables: These consists of all identifiers that are used within the package.

With the setup in place, the login function starts by decoding the JSON request into the Credential type. By using a pointer, the decoder can write the decoded result into the memory address of the credential variable. The major part of the function are checks from the requirements. Once all negative path have been covered, the session ID is created, using the uuid package. The session ID is then mapped to the username and used to create the cookie. The cookie is set on the response header and a 200 Ok response is returned.

Home

With the login implemented, I can now add a path that requires authentication to access. Again, the requirements are:

  • Return an unauthorized access (401) if a cookie is missing
  • Return an unauthorized access (401) for an invalid cookie
  • Return a success response if session exists and is valid

The server_test.go file can be updated to capture these requirements and the feature can be implemented to make the test pass:

Logout

A session ID can becomes invalid when it either expires or when the user initiates a logout request. The client typically takes care of the former. Handling the latter involves removing the session ID from the store, in this case, the map:

Router

Each created function cannot be used by a client yet because they haven’t been tied to a route. The implementation of the router will be done in a new file, main.go, inside the main() function:

main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /login", Login)
mux.HandleFunc("GET /home", Home)
mux.HandleFunc("DELETE /logout", Logout)
fmt.Println("Server listening on port 9009")
log.Fatal(http.ListenAndServe(":9009", mux))
}

A request router (multiplexer to be precise) is created with the NewServeMux() function in the http package. The routes are defined but specifying a string with an HTTP method, a space then the path and the handler for the route. The server can be configured with a port and the router.

Concerns

Session-based authentication benefits from an ease of implementation, however, because of the reliance on the client, it poses some concerns:

  • Cross Site Request Forgery (CSRF): Cookies are on the client side which creates the room for bad actors to retrieve them through fake requests
  • Privacy: With cookies usage for state managment, it can be easily used to track the activities of a user.
  • Implementation: Some clients have limitations on the cookies such as: maximum number of bytes, maximum number of cookies per domain and the total number of cookies.

Lessons

Here are some things I can do better:

  1. Write and implement to allow for a more natural flow
  2. There are still rough edges with TDD, particularly around mocking and stubbing
  3. For the logout implementation, I didn’t consider how to invalidate expired session IDs

References