Authentication - 310-Blueprint/fairshare GitHub Wiki

FairShare uses server-side session authentication provided by Spring Security. A user logs in once, the server issues a session cookie, and every subsequent request is identified by that cookie. This page explains how the pieces fit together and the rules to follow when adding new endpoints or frontend calls.

Tracked under issue #49.

Why sessions rather than tokens

Sessions were chosen over a JWT-based approach for three reasons:

  • Spring Security handles most of the mechanics, so there is less bespoke code to get wrong.
  • The session cookie is HttpOnly, so it cannot be read by JavaScript. A token held in localStorage can be, which makes any cross-site scripting flaw far more damaging.
  • Logging out actually ends the session. Invalidating a stateless token requires either a revocation list or accepting that a stolen token stays valid until it expires.

The trade-off is that CSRF protection becomes relevant. See "Known gaps" below.

How a request is authenticated

  1. POST /users/login receives an email and password.
  2. AuthenticationManager verifies them, using FairShareUserDetailsService to load the user by email and BCryptPasswordEncoder to check the password against the stored hash.
  3. The session id is rotated with changeSessionId(). Without this the pre-login session id would survive authentication, so an attacker who could get a victim to use a known session id would end up sharing their authenticated session.
  4. On success the resulting Authentication is written to the session by SecurityContextRepository, and the response carries Set-Cookie: JSESSIONID=....
  5. On every later request Spring Security restores the security context from that cookie.
  6. Application code that needs to know who is calling injects CurrentUserProvider and calls currentUserId(), which reads the id from the authenticated principal.

CurrentUserProvider is the single seam through which the rest of the application learns the caller's identity. Nothing else should read the security context directly.

FairShareUserDetails

Spring Security's UserDetails interface declares getUsername(), and it must return the login identifier. For FairShare that is the email address, not the username field on the User entity, which is a display name.

FairShareUserDetails exists to make this explicit. The User entity deliberately does not implement UserDetails: it already has a getUsername() returning the display name, so implementing the interface directly would compile cleanly and silently authenticate against the wrong field. There is a test covering this.

The wrapper also carries the user id, so CurrentUserProvider can read it from the principal without a database round trip on every request.

Endpoints

Public:

  • POST /users/register
  • POST /users/login
  • POST /users/logout

Everything else requires an authenticated session, including all group endpoints.

Endpoints acting on the current user use /users/me rather than taking an id:

  • GET /users/me — the caller's profile
  • PUT /users/me — update the caller's profile

There is deliberately no /users/{id} variant. Taking the id from the request rather than the session would let any authenticated user read or modify any other user's account, including their password.

Rules for new backend code

  • Never take the acting user's id from a path variable, query parameter or request body. Inject CurrentUserProvider instead.
  • Scope reads by membership at the repository level rather than fetching and then checking. ExpenseGroupRepository.findByIdAndMembersUserId is the pattern to follow.
  • Unauthenticated requests return 401; a logged-in user who lacks permission gets 403. The frontend redirects to login on 401 only, so getting this wrong sends a valid session to the login page. Note the group endpoints are currently inconsistent here: GET /groups/{id} returns 404 for a non-member while GET /groups/{id}/members returns 403.
  • Any new endpoint is authenticated by default. Adding it to the permit-all list in SecurityConfig should be a deliberate, justified decision.

Rules for new frontend code

  • Use apiFetch from src/api/config.js rather than calling fetch directly. It sends the session cookie and redirects to the login page if the backend rejects the request as unauthenticated. Calling fetch directly means the cookie is not sent and the request will fail with a 403.
  • The one exception is getCurrentUser, which uses a plain fetch because a 403 there means "not logged in", which is a normal answer rather than a reason to redirect. There is a comment on it saying so.
  • Routes that require a session use the requireAuth loader in main.jsx, which redirects before the page renders. apiFetch covers the other case, where a session expires while a page is already open.
  • Identity comes from GET /users/me, via getCurrentUser() in src/api/users.js. It returns null when there is no session, so "am I logged in" is an ordinary null check.
  • Never store user identity in localStorage. It is readable by any script on the page and is not a session the server issued or can revoke.

Local development

The backend runs against the real security configuration; there is no permissive development profile. Register a user through the frontend, or with:

curl -X POST http://localhost:8080/users/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"password123","email":"[email protected]","country":"NEW_ZEALAND","currency":"NZD"}'

To exercise endpoints from the command line, save the session cookie on login and send it on subsequent requests:

curl -c cookies.txt -X POST http://localhost:8080/users/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"password123"}'

curl -b cookies.txt http://localhost:8080/users/me

cookies.txt is written to the current directory. Add it to .gitignore or delete it afterwards; it contains a valid session.

Known gaps

CSRF protection is disabled. Spring Security's token-based CSRF is switched off in SecurityConfig, and SonarCloud flags this (java:S4502). The current mitigation is that the session cookie is HttpOnly with SameSite=Lax, which prevents it being sent on cross-site POST requests, and CORS is restricted to the frontend origin. This is a reasonable position for development but enabling proper CSRF tokens is the complete fix and should happen before any deployment.

User input is not validated server-side. POST /users/register and PUT /users/me bind directly to the User entity with no constraints, so an over-long username reaches MySQL and produces a 500 rather than a 400. LoginRequest shows the pattern; adding equivalent request DTOs for register and update would fix this and also stop the entity being bindable from the request body.