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:
- AuthController.java
- UserDetailsServiceImpl.java
- JwtTokenManager.java
- RefreshTokenService.java
- LoginRateLimitFilter.java
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) viausers.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 (seedocs/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 fromGET /api/public/altcha/challengeand submits the solution in theX-Altcha-Payloadheader. 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 (MITorg.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:
- LoginAttemptService.java
- LoginRateLimitFilter.java
- AltchaService.java / AltchaVerificationFilter.java
- AuthMetrics.java
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):
| Role | Parent | Permissions |
|---|---|---|
| Employee | — | 12 (self-service: profile, leave, timesheet…) |
| HR Administrator | Employee | 12 inherited + 32 HR-specific |
| Payroll Administrator | Employee | 12 inherited + 34 payroll-specific |
| System Administrator | Employee | All 109 permissions |
Key files:
- V1__core_rbac.sql — schema
- V9__seed_lookup_and_rbac.sql — seed data
- PermissionConstants.java
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:
- redux/auth.ts — slice + localStorage persistence
- api/auth.ts —
performLogout() - redux/store.ts
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
activeRoleis set → checks intersection of user's permissions and that role's permission set - If
activeRoleis null → checks user's full permission list
availableRoles logic:
- Filters
ROLE_DISPLAY_ORDERby 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:
- constants/role-permissions.ts — role → permission mapping
- constants/permissions.ts — all 109 permission strings
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)
| Path | Purpose |
|---|---|
POST /api/auth/login | Login (IP rate-limited 429, ALTCHA-guarded 428 in production, exponential lockout 423 after 5 failures) |
POST /api/auth/refresh | Rotate access/refresh token pair |
POST /api/auth/logout | Revoke 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/prometheus | Health probe + metrics scrape (compose-network internal; not proxied by the frontend nginx) |
/ws/** | WebSocket |
Everything else requires a valid JWT.