Skip to main content

Authentication & Authorization Guide

Overview

MotorPH uses JWT-based auth on a stateless Spring Security backend, with Redux + localStorage on the React frontend. Every request carries a Bearer token; every route either requires a valid token or a specific permission string.

Login issues a short-lived (15 min) access token plus a longer-lived, rotating refresh token. The refresh token is the only thing that can grant a new access token or be revoked (logout) -- see Refresh & Logout below. Repeated failed logins lock the account for 15 minutes, and login/refresh/register are rate-limited per IP.

The login flow at a glance


Backend

1. Login — POST /api/auth/login

The entry point is AuthController (no token required — public endpoint, but IP-rate-limited to 10 req/min by LoginRateLimitFilter).

Request
{ "username": "...", "password": "..." }

AuthenticationManager.authenticate()
- LockedException if locked_until is in the future -- rejected BEFORE the password
is even compared (Spring Security's own pre-authentication check)

UserDetailsServiceImpl.loadUserByUsername()
- Loads User entity from DB
- Collects primary role + any additional roles from user_role table
- Walks parent roles recursively to inherit permissions
- Builds Set<GrantedAuthority> from all permission names
- Returns AuthenticatedUser (implements UserDetails)

DaoAuthenticationProvider verifies the password hash (Argon2id; legacy BCrypt
hashes still match and are transparently re-hashed to Argon2id on success --
see request-pipeline.md, "Password Security")
- On failure: AuthController catches BadCredentialsException and delegates to
LoginAttemptService.registerFailure(), which increments
users.failed_login_attempts; the 5th consecutive failure sets
locked_until = now + 15 min (see Account Lockout below)
- On success: failed_login_attempts/locked_until are reset to 0/null

JwtTokenManager.generateToken(authenticatedUser)
- Signs with HMAC256(JWT_SECRET)
- Embeds: subject=username, userId, employeeId, roles[]
- Expires in 15 min — configurable via JWT_ACCESS_TOKEN_EXPIRATION_MINUTE

RefreshTokenService.issue(userId, clientIp)
- Opaque random token (not a JWT), SHA-256 hash stored in refresh_tokens
- Expires in 14 days — configurable via JWT_REFRESH_TOKEN_EXPIRATION_DAY

Response
{
"accessToken": "eyJhbGc...",
"refreshToken": "BZ0zxsT4z...",
"expiresIn": 900,
"user": {
"id": 1,
"username": "jdoe",
"fullName": "John Doe",
"email": "[email protected]",
"employeeId": 100,
"roles": ["Employee", "HR Administrator"],
"permissions": ["employee.profile.view", "hr.employees.view", ...]
}
}

A locked account gets 423 Locked instead of the usual 401, with a distinct message so the frontend can tell "wrong password" apart from "temporarily locked."

Key files:


1a. Refresh & Logout

POST /api/auth/refresh and POST /api/auth/logout both take { "refreshToken": "..." } in the body and are public endpoints -- the refresh token itself is the credential, so there's no access token to require (it may already be expired).

Refresh rotates the token on every call: the presented refresh token is revoked and a new one is issued in the same reuse-detection "family." If a refresh token is presented a second time (i.e. it was already rotated away), the entire family is revoked and an Auth.refreshTokenReuseDetected audit entry is written -- the assumption is that a token being reused means it was stolen and the legitimate client and the attacker are now racing each other.

Logout revokes the given refresh token and always returns 204 No Content, even for an already-invalid token (idempotent, doesn't leak whether the token was valid). The now-short-lived access token isn't individually revoked -- it simply expires within 15 minutes on its own, which is the standard accepted tradeoff for stateless access tokens.

Key file: RefreshTokenService.java (refresh_tokens table, V75__auth_hardening.sql)


1b. Account Lockout, Rate Limiting & ALTCHA

Three independent, complementary protections on /api/auth/login:

  • Per-account lockout with exponential backoff (LoginAttemptService): the 5th consecutive failed attempt locks the account for 15 minutes, and every further failure after a lockout expires doubles the duration (30m, 1h, 2h, ... capped at 24h) via users.failed_login_attempts / users.locked_until. The counter only resets on a successful login, so it encodes the consecutive-failure history -- no extra column needed. Only tracked for known usernames; there's no account to lock for a username that doesn't exist.
  • Per-IP rate limit (LoginRateLimitFilter): Bucket4j token buckets, 10 requests/minute with greedy refill per (client IP, path), covering /api/auth/login, /api/auth/refresh, /api/portal/auth/login, /api/portal/auth/register; rejections are HTTP 429. This is what protects against username enumeration and brute-forcing across many accounts from one source. In-memory buckets -- fine because this is a one-backend-instance-per-client deployment (see docs/deployment/vps-guide.md); Bucket4j's distributed backends (JCache/Redis) are the drop-in upgrade if that ever changes.
  • ALTCHA proof-of-work CAPTCHA (AltchaVerificationFilter, production only -- ALTCHA_ENABLED): the browser solves an HMAC-signed SHA-256 challenge fetched from GET /api/public/altcha/challenge and submits the solution in the X-Altcha-Payload header. Missing/invalid/expired/replayed payloads are rejected with HTTP 428 before authentication runs. Challenges are single-use (in-memory replay registry) and expire after 30 minutes. Fully self-hosted (MIT org.altcha:altcha) -- zero external calls. Filter order: rate limit -> ALTCHA -> JWT.

All three emit Prometheus counters (see AuthMetrics; scraped from /actuator/prometheus, visualized by the optional docker-compose.monitoring.yml Grafana stack).

Key files:


2. Request Validation — JwtAuthenticationFilter

Runs on every request before the controller layer.

Incoming request

Extract "Bearer {token}" from Authorization header

JwtTokenManager.validateToken()
- Verify HMAC256 signature
- Check issuer = "motorph-payroll"
- Check not expired

Load UserDetails from DB by username in token subject
Check user.status = "Active"

SecurityContextHolder.setAuthentication(
UsernamePasswordAuthenticationToken(user, null, authorities)
)

Continue filter chain → controller

If the token is missing, invalid, or expired the filter does nothing — the security context stays empty and Spring Security returns 401 via JwtAuthenticationEntryPoint.

Key files:


3. Method-Level Authorization

Controllers use @PreAuthorize (enabled by @EnableMethodSecurity in SecurityConfiguration):

@PreAuthorize("hasAuthority('hr.employees.manage')")
public ResponseEntity<?> createEmployee(...) { ... }

Spring checks the GrantedAuthority set on the security context. No match → 403 Forbidden.


4. RBAC Data Model

role ──────────────────── parent_role_id (self-FK, supports inheritance)

└── role_permission ── permission
(109 total, format: category.subcategory.action)

users ─── role_id (primary role)

└── user_role (junction) ── role_id (additional roles)

Four core roles (plus three module roles added later — Warehouse Manager, Sales Representative, Account Manager — all likewise children of Employee):

RoleParentPermissions
Employee12 (self-service: profile, leave, timesheet…)
HR AdministratorEmployee12 inherited + 32 HR-specific
Payroll AdministratorEmployee12 inherited + 34 payroll-specific
System AdministratorEmployeeAll 109 permissions

Key files:


Frontend

1. Auth State — Redux + localStorage

After a successful login the access token, refresh token, and user object are stored in two places:

dispatch(setCredentials({ token, refreshToken, user }))

Redux store → state.auth.{ token, refreshToken, user, activeRole }
localStorage → "motorph.auth" (JSON, re-hydrated on page load)

On logout (manual or unrecoverable 401):

performLogout() // api/auth.ts

POST /api/auth/logout { refreshToken } // skipped if there's no refresh token;
↓ // best-effort -- errors are swallowed
dispatch(logout())

Redux state cleared
localStorage item removed // inside the logout reducer

performLogout() itself does not navigate — the caller does. Manual logout redirects explicitly (profile-menu.tsx); an interceptor-triggered logout redirects implicitly, because clearing the token flips isAuthenticated to false and ProtectedRoute then renders <Navigate to="/login">.

Both paths go through performLogout(), so a logout always revokes the refresh token server-side, not just the local session.

Key files:


2. Axios Client — Auto Token Injection & Silent Refresh

Every API call goes through client.ts. The interceptors handle auth automatically:

Request interceptor:
read state.auth.token from Redux store
→ add header: Authorization: Bearer {token}

Response interceptor:
if HTTP 401 AND not a /login|/refresh|/logout call:
if not already retried:
→ refreshAccessToken() // POST /api/auth/refresh with the stored refreshToken
→ success: retry the original request with the new access token
→ still 401 (refresh failed, or the retry itself 401'd): performLogout()

Concurrent requests that all 401 around the same moment (e.g. right when the access token expires) share a single in-flight refresh promise rather than each calling /api/auth/refresh independently. This matters because refresh tokens are single-use/rotating: two simultaneous refresh calls presenting the same token would make the second one look like a stolen-token replay to the backend's reuse detection, logging the user out on what's actually a benign race.

Key file: api/client.ts


3. useAuth Hook

Central hook used by route guards and any component that needs auth info:

const {
token, // raw JWT string
user, // AuthenticatedUserDto from login response
isAuthenticated, // boolean shorthand
activeRole, // currently selected role (null = all permissions)
availableRoles, // roles the user qualifies for (derived from permissions)
hasPermission, // (permission: string) => boolean
switchRole, // (roleName: string) => void
} = useAuth();

hasPermission logic:

  • If activeRole is set → checks intersection of user's permissions and that role's permission set
  • If activeRole is null → checks user's full permission list

availableRoles logic:

  • Filters ROLE_DISPLAY_ORDER by whether the user holds all permissions for that role

Key file: hooks/useAuth.ts


4. Route Guards

Two wrapper components in App.tsx protect every page.

ProtectedRoute — authentication gate

// Wraps ALL app routes
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
{/* every page route */}
</Route>
</Route>

Logic: if !isAuthenticated → redirect to /login, else render <Outlet />.

Key file: ui/components/protected-route.tsx


RequirePermission — permission gate

Wraps individual routes that need a specific permission (or one of several):

// Single permission
<Route element={<RequirePermission permission="hr.employees.view" />}>
<Route path="/employees" element={<Employees />} />
</Route>

// OR logic — user needs at least one
<Route element={<RequirePermission permission={[
"payroll.overtime.approve",
"hr.overtime.requests.view"
]} />}>
<Route path="/overtime-requests" element={<OvertimeRequests />} />
</Route>

Logic: calls hasPermission() for each string; if none match → redirect to dashboard.

Key file: ui/components/require-permission.tsx


5. Role Switching

Users with multiple roles can switch their active role from the UI. This does not re-fetch a new token — it filters the permission set client-side:

switchRole("HR Administrator")

dispatch(setActiveRole("HR Administrator"))

useAuth.hasPermission() now intersects user.permissions
with ROLE_PERMISSIONS["HR Administrator"]

RequirePermission re-evaluates → pages the active role cannot see become inaccessible

Key files:


End-to-End Flow Summary

1. User submits login form
POST /api/auth/login → { accessToken, refreshToken, expiresIn, user }
(5 failed attempts in a row → 423 Locked for 15 min, doubling on each further
lockout up to 24h; >10 req/min per IP → 429)

2. Frontend stores both tokens in Redux + localStorage
All subsequent axios requests attach: Authorization: Bearer {accessToken}

3. Backend JwtAuthenticationFilter runs on every request
Validates token → populates SecurityContextHolder

4. Controller @PreAuthorize checks GrantedAuthority
Match → 200 OK | No match → 403 Forbidden

5. Frontend ProtectedRoute checks isAuthenticated
RequirePermission checks hasPermission()
Either can redirect before a request is even made

6. Access token expires (15 min)
Backend returns 401 → axios interceptor silently calls POST /api/auth/refresh,
retries the original request with the new access token
Refresh token itself expired/revoked/reused → refresh fails → performLogout()

7. User logs out
performLogout() → POST /api/auth/logout (revokes the refresh token) → dispatch(logout())
localStorage cleared, user sent to /login

Public Endpoints (no token required)

PathPurpose
POST /api/auth/loginLogin (IP rate-limited 429, ALTCHA-guarded 428 in production, exponential lockout 423 after 5 failures)
POST /api/auth/refreshRotate access/refresh token pair
POST /api/auth/logoutRevoke a refresh token
GET /api/public/**Public data, incl. GET /api/public/altcha/challenge (204 = ALTCHA disabled, 200 = challenge JSON)
/swagger-ui/**, /v3/api-docs/**API docs (disabled in production via SWAGGER_ENABLED=false)
/actuator/health, /actuator/prometheusHealth probe + metrics scrape (compose-network internal; not proxied by the frontend nginx)
/ws/**WebSocket

Everything else requires a valid JWT.