Skip to main content

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.

MethodPathAuthPurpose
POST/api/auth/loginnone (ALTCHA when enabled)Issue access + refresh tokens
POST/api/auth/refreshrefresh token in bodyRotate the refresh token, mint a new access token
POST/api/auth/logoutrefresh token in bodyRevoke the refresh token
GET/api/auth/meBearer tokenCurrent user profile + roles + permissions
POST/api/portal/auth/registernone (ALTCHA when enabled)Create a portal customer account
POST/api/portal/auth/loginnone (ALTCHA when enabled)Issue a portal JWT
GET/api/public/altcha/challengenoneALTCHA 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",
"email": "[email protected]",
"employeeId": 10002,
"roles": ["HR Administrator"],
"permissions": ["hr.employees.view", "hr.employees.create", "..."]
}
}
  • accessToken — HMAC256 JWT, valid 15 minutes by default; send it as Authorization: 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:

StatusWhenBody
401Wrong password or unknown username — deliberately indistinguishable to block username enumeration{"message":"Invalid username or password","status":401,"time":"..."}
423Account 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":"..."}
403Account exists, password correct, but the account is disabled{"message":"This account is inactive","status":403,"time":"..."}
428ALTCHA 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)
429More 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

{ "email": "[email protected]", "password": "s3cret" }

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_ENABLEDreference/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 challengeurl here. Served with Cache-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.