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 inlocalStoragecan 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
POST /users/loginreceives an email and password.AuthenticationManagerverifies them, usingFairShareUserDetailsServiceto load the user by email andBCryptPasswordEncoderto check the password against the stored hash.- 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. - On success the resulting
Authenticationis written to the session bySecurityContextRepository, and the response carriesSet-Cookie: JSESSIONID=.... - On every later request Spring Security restores the security context from that cookie.
- Application code that needs to know who is calling injects
CurrentUserProviderand callscurrentUserId(), 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/registerPOST /users/loginPOST /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 profilePUT /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
CurrentUserProviderinstead. - Scope reads by membership at the repository level rather than fetching and then checking.
ExpenseGroupRepository.findByIdAndMembersUserIdis 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 whileGET /groups/{id}/membersreturns 403. - Any new endpoint is authenticated by default. Adding it to the permit-all list in
SecurityConfigshould be a deliberate, justified decision.
Rules for new frontend code
- Use
apiFetchfromsrc/api/config.jsrather than callingfetchdirectly. It sends the session cookie and redirects to the login page if the backend rejects the request as unauthenticated. Callingfetchdirectly means the cookie is not sent and the request will fail with a 403. - The one exception is
getCurrentUser, which uses a plainfetchbecause 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
requireAuthloader inmain.jsx, which redirects before the page renders.apiFetchcovers the other case, where a session expires while a page is already open. - Identity comes from
GET /users/me, viagetCurrentUser()insrc/api/users.js. It returnsnullwhen 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.