Auth API
The one module documented end-to-end (everything else defers detail to Swagger
— README.md). Two separate auth systems live here: the ERP
login (/api/auth/*, JWT + rotating refresh token) and the customer portal
login (/api/portal/auth/*, standalone 24-hour JWT). How the tokens work
internally: ../security/authentication.md.
Controllers: AuthController.java, PortalAuthController.java, PublicAltchaController.java.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/auth/login | none (ALTCHA when enabled) | Issue access + refresh tokens |
| POST | /api/auth/refresh | refresh token in body | Rotate the refresh token, mint a new access token |
| POST | /api/auth/logout | refresh token in body | Revoke the refresh token |
| GET | /api/auth/me | Bearer token | Current user profile + roles + permissions |
| POST | /api/portal/auth/register | none (ALTCHA when enabled) | Create a portal customer account |
| POST | /api/portal/auth/login | none (ALTCHA when enabled) | Issue a portal JWT |
| GET | /api/public/altcha/challenge | none | ALTCHA challenge / enabled-probe |
POST /api/auth/login
Request (LoginRequest — both fields @NotBlank):
{ "username": "hr_demo", "password": "<the seeded demo password>" }
Response 200 (AuthResponse):
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "d41b7f6e-...-opaque-random-string",
"expiresIn": 900,
"user": {
"id": 3,
"username": "hr_demo",
"fullName": "Harvey Reyes",
"employeeId": 10002,
"roles": ["HR Administrator"],
"permissions": ["hr.employees.view", "hr.employees.create", "..."]
}
}
accessToken— HMAC256 JWT, valid 15 minutes by default; send it asAuthorization: Bearer ...on every subsequent call.refreshToken— opaque random string, valid 14 days, single-use (rotated on every refresh). Store it as securely as your client allows.expiresIn— seconds until the access token expires, so clients can refresh proactively.user.permissions— the authority strings the backend authorizes against (conventions.md).
Error semantics:
| Status | When | Body |
|---|---|---|
| 401 | Wrong password or unknown username — deliberately indistinguishable to block username enumeration | {"message":"Invalid username or password","status":401,"time":"..."} |
| 423 | Account locked out (exponential lockout after 5 straight failures; rejected before credentials are even compared) | {"message":"Account temporarily locked due to repeated failed login attempts. Try again later.","status":423,"time":"..."} |
| 403 | Account exists, password correct, but the account is disabled | {"message":"This account is inactive","status":403,"time":"..."} |
| 428 | ALTCHA enabled and the X-Altcha-Payload header is missing/invalid/expired | {"message":"Human verification failed or expired. Please try again.","status":428} (filter-written; no time) |
| 429 | More than 10 POSTs per minute from one client IP to this path | {"message":"Too many attempts. Please wait a minute and try again.","status":429} (filter-written; no time) |
Every successful login writes an Auth.login audit row; failures feed the
lockout counter and the Prometheus auth metrics.
POST /api/auth/refresh
Request (RefreshRequest):
{ "refreshToken": "d41b7f6e-..." }
Response 200: the same AuthResponse shape as login — a fresh
accessToken and a new refreshToken. The presented refresh token is
consumed by this call; always replace your stored copy.
Rotation and reuse detection
(RefreshTokenService.java):
tokens are stored SHA-256-hashed and chained into a family per login. On
refresh, the old token is marked revoked and linked to its replacement.
Presenting an already-used refresh token is treated as theft: the entire
family is revoked (both the legitimate client and the attacker are logged
out, forcing a fresh login) and an Auth.refreshTokenReuseDetected audit row
is written. Expired, unknown, and reused tokens — and refresh attempts for a
since-disabled or locked account — all surface as the same generic
401 {"message":"Invalid username or password"}; the specific reason is
deliberately not leaked to the caller.
Refresh is rate-limited (429, same 10/min/IP bucket) but not ALTCHA-guarded — it's a programmatic call whose token is already a strong single-use credential.
POST /api/auth/logout
Request: { "refreshToken": "..." }. Response: 204 No Content, always —
revocation silently no-ops if the token is unknown or already revoked
(idempotent), so a client can log out even after its access token has
expired. The refresh token itself is the credential here; no Authorization
header is needed. The access token is not blacklisted — it simply ages out
within 15 minutes.
GET /api/auth/me
Requires Authorization: Bearer. Returns the user object from the login
response (AuthenticatedUserDto) for the token's owner — the frontend uses it
to rehydrate auth state after a reload.
POST /api/auth/change-password
Requires Authorization: Bearer and nothing else — no permission is checked,
deliberately: the people who most need this are holding a temporary password
handed out at provisioning, and until they use it the API refuses every
permission-bearing endpoint.
{ "currentPassword": "...", "newPassword": "..." }
Verifies the current password, stores the new one, and clears
mustChangePassword. Returns the updated AuthenticatedUserDto, so a client
can replace the stale copy that still says a change is outstanding. A wrong
current password is a 401; a new password under 8 characters, or identical to
the current one, is a 400.
While mustChangePassword is set
PasswordChangeRequiredFilter answers 403 to every request except
/api/auth/change-password, /api/auth/me, /api/auth/refresh and
/api/auth/logout — enough to reach the screen, render it, stay signed in while
it is filled in, and leave. The frontend redirects too, but the filter is what
makes the flag a rule rather than advice.
Suspended tenants
A user whose tenant is not ACTIVE fails isEnabled(), which every
authentication path consults. Login is refused, an already-issued access token
stops working on its next request, and refresh will not mint a new one. Platform
accounts have no tenant and are unaffected.
Portal auth (separate system)
The customer portal has its own accounts and its own token — an ERP JWT is
useless on /api/portal/** (except portal-admin, see
portal-crm-inventory.md) and vice versa.
POST /api/portal/auth/register
{ "email": "[email protected]", "fullName": "Jane Cruz", "companyName": "Acme Parts", "password": "s3cret" }
Response 201 (PortalAuthResponse):
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 7, "email": "[email protected]", "fullName": "Jane Cruz", "companyName": "Acme Parts" }
}
Duplicate email → 400 "An account with email '...' already exists.".
POST /api/portal/auth/login
Response 200: same PortalAuthResponse shape. Bad credentials or an
inactive account → the generic 401.
Portal token facts (verified in
JwtTokenManager.java
and application.yml): valid 24 hours by default
(JWT_PORTAL_EXPIRATION_MINUTE, default 1440), subject portal:<email>,
claims portalUserId and roles: ["PORTAL_CUSTOMER"] — the single authority
every portal endpoint checks. There is no refresh flow: when the token
expires the customer logs in again. Both portal login and register are
ALTCHA-guarded (428) and rate-limited (429), same as ERP login.
ALTCHA (proof-of-work CAPTCHA)
ALTCHA is a proof-of-work challenge the browser solves in the background — no
puzzles for the user. It is off by default in dev and enabled per
deployment (ALTCHA_ENABLED — reference/env-vars.md).
GET /api/public/altcha/challenge
Doubles as the feature probe:
- 200 + a challenge JSON body → ALTCHA is enabled; the login forms
mount the widget and point its
challengeurlhere. Served withCache-Control: no-store— every challenge is single-use, and caching one guarantees replay rejections. - 204 No Content → ALTCHA is disabled; clients skip the widget and the header entirely.
The X-Altcha-Payload header
When enabled, exactly three endpoints require it — POST /api/auth/login,
POST /api/portal/auth/login, POST /api/portal/auth/register
(AltchaVerificationFilter.java):
X-Altcha-Payload: <base64 payload produced by the solved ALTCHA widget>
Missing, invalid, replayed, or expired payloads are rejected with 428 before the credentials are even looked at. For curl/API testing against an ALTCHA-enabled stack, either disable ALTCHA locally or drive the login through the browser; there is no server-side bypass header.