Password Security — Hashing, Policy & Brute-Force Protection
Vancetope authenticates local user accounts via a password stored as a BCrypt hash (Cost 12) in
UserDocument.passwordHash. This foundation is surrounded by three layers of governance: a global password policy (minimum length, byte upper limit, blacklist of common passwords), a temporary brute-force lockout (Mongo-based, auto-unlock), and a self-service password change in the profile. All password setting paths run through one central policy choke point.Guiding Principle: simple, yet secure. Length beats complexity (NIST-aligned, no enforced character classes); cryptography is encapsulated and swappable in one place; lockout state is business state in MongoDB (not Redis). The policy is a global minimum standard in code — not a per-Tenant setting.
Scope Delimitation: This describes local password authentication. Authorization (who can do what) is covered in
permission-system.md; external secrets ({{secret:…}}, Vault) invault-access.md. SSO (e.g., Google) and 2FA are not implemented but are provisioned in the data model (§8).Status: Policy, uniform BCrypt cost, brute-force lockout (Brain-Login + WebDAV) and self-service change built and tested (Unit +
@Tag("it")-E2EPasswordPolicyLockoutE2ETest).
1. Hashing — PasswordService
de.mhus.vance.shared.password.PasswordService (vance-shared) is the only
place that hashes and verifies account passwords.
- Algorithm: BCrypt with salt (embedded in the hash string), work factor
BCRYPT_COST = 12, uniform across all tenants. No caller instantiates its ownBCryptPasswordEncoder— anus and Setup Wizard also hash via this service. Old hashes with an older cost continue to verify (the cost is embedded in the stored hash). - API:
hash(plaintext)→ salted hash;verify(plaintext, hash)→ boolean. - Timing Side-Channel / User Enumeration:
verifyDecoy(plaintext)performs a full BCrypt comparison against an internal decoy hash and discards the result. Any error path that would otherwise short-circuit before the actualverify(unknown user, inactive, no hash, locked) callsverifyDecoy— thus, the response latency for “user does not exist” is indistinguishable from “wrong password”. Consistently applied in Brain-Login and WebDAV-Login.
The algorithm is encapsulated: a later change (e.g., Argon2) would only require
a modification in PasswordService.
2. Policy — PasswordPolicyService
de.mhus.vance.shared.password.PasswordPolicyService (vance-shared) enforces a
global, hardcoded minimum standard (deliberately not a per-Tenant setting form
— a minimum standard, not a Tenant authentication configuration). NIST-aligned:
| Rule | Value | Rationale |
|---|---|---|
| Minimum length | 10 characters | Length > complexity |
| Maximum (hard, UTF-8) | 72 bytes → reject | BCrypt silently truncates at 72 bytes; longer passwords would be unknowingly weakened |
| Blacklist | bundled common password list, case-insensitive | blocks common/leaked passwords that pass the length rule (password123 etc.) |
| Character classes | none enforced | enforced classes are counterproductive according to NIST |
validate(plaintext) returns normally on success and otherwise throws
PasswordPolicyException with a user-facing English message (exactly one
failed rule). The blacklist (common-passwords.txt, Classpath resource)
is loaded into a HashSet once at startup.
3. Brute-Force Lockout
Failed attempt counters and temporary locks are stored as fields on UserDocument
(§7) and are managed atomically via MongoTemplate in UserService
(data sovereignty). No Redis — lockout is persistent business state, not
ephemeral live state.
| Parameter | Value |
|---|---|
| Threshold | 5 consecutive failed attempts |
| Lockout duration | 15 minutes, then auto-unlock (no admin action required) |
recordFailedLogin(tenant, name)— incrementsfailedLoginAttempts($inc+lastFailedLoginAt); if the counter reaches the threshold,lockedUntil = now + 15minis set and the counter is reset to 0 (fresh window after expiration).resetLoginFailures(tenant, name)— zeroes counter +lockedUntilon successful login (guarded in the happy path to keep the normal case write-free) and on every password change.isLocked(user)— pure checklockedUntil != null && now < lockedUntil.
Enforcement: Both password login surfaces check isLocked before verify
(including verifyDecoy + uniform 401/Reject, so a locked account is
indistinguishable from a wrong password) and count failed attempts:
AccessController (JWT mint, only the password path — the refresh token path is
excluded, as it’s not a guessing attack) and VanceWebDavSecurityManager
(Basic-Auth for WebDAV).
4. Setting Paths — One Policy Choke Point
Every path that sets a password calls PasswordPolicyService.validate before
PasswordService.hash:
| Path | Endpoint / Command | Module |
|---|---|---|
| Admin: Create user | POST /brain/{tenant}/admin/users |
UserAdminController (vance-brain) |
| Admin: Set password | PUT /brain/{tenant}/admin/users/{name}/password |
UserAdminController |
| Self-Service (§5) | PUT /brain/{tenant}/profile/password |
ProfileController |
| Operator Shell | anus user create / user set-password |
UserCommands (vance-anus) |
| Initial Setup | Admin password step | SetupWizard (vance-anus) |
A policy violation on REST paths results in HTTP 400 with the policy message; in the anus shell, it results in an error message (re-prompt). A null/empty password during creation creates a passwordless account (SSO-only, §8) — the policy only applies when a password is set.
UserService.setPasswordHash(tenant, name, hash) is the only store point:
it stamps passwordChangedAt and deletes the lockout state (a fresh
password restarts the error window). The plaintext is never logged.
Bootstrap Exception: BootstrapBrainService (Acme demo seed) hashes directly via
PasswordService (Cost 12), but bypasses the policy — it’s a seed path,
not user input. The demo seed passwords are intentionally short (tied to QA fixtures)
and only active behind vance.bootstrap.acme.
5. Self-Service Password Change
PUT /brain/{tenant}/profile/password (ProfileController) — the subject is the
authenticated user from the JWT, not a path parameter: one can only
change their own password. Body ProfilePasswordRequest{currentPassword,
newPassword}:
currentPasswordis verified against the stored hash (incorrect → 400; a passwordless/SSO-only account cannot use this flow).newPasswordis validated against the policy (violation → 400).setPasswordHashstores the new hash (+passwordChangedAt+ lockout reset).
Admin-driven resets continue to use the /admin/users/{name}/password
path (Action.ADMIN). Web-UI: a dedicated Security tab in profile.html
(current + new + repeat, client-side pre-check + server-side
policy message); users.html displays the server 400 message in the set-password dialog.
6. Login Flow (Order of Gates)
POST /brain/{tenant}/access/{username} (AccessController.createToken),
password path — every rejection is a uniform 401 without a body, the reason only
in Audit/DEBUG:
- Exactly one of Password / Refresh Token (otherwise 401).
- User exists → otherwise
verifyDecoy+ 401. status == ACTIVE→ otherwiseverifyDecoy+ 401.loginEnabled(Service Accounts / manually locked) → otherwiseverifyDecoy+ 401.- Lockout (
isLocked, §3) → otherwiseverifyDecoy+ 401 (reason=locked). - Hash present → otherwise
verifyDecoy+ 401. verify→ incorrect:recordFailedLogin+ 401; correct: if counter is not emptyresetLoginFailures, then issue JWT (+ optional Refresh Token).
Existing tokens remain valid until expiration — JWT verification (filter)
consults neither loginEnabled nor the lockout; only the mint endpoints do.
7. Data Model — UserDocument
Relevant fields (de.mhus.vance.shared.user.UserDocument, Collection users):
| Field | Type | Meaning |
|---|---|---|
passwordHash |
@Nullable String |
BCrypt hash; null = no local password (SSO-only, §8) |
failedLoginAttempts |
int |
consecutive failed attempts since last success |
lockedUntil |
@Nullable Instant |
in the future ⇒ locked (auto-unlock afterwards) |
lastFailedLoginAt |
@Nullable Instant |
timestamp of the last failed attempt (diagnosis) |
passwordChangedAt |
@Nullable Instant |
last password change; tracked, not enforced today |
The lockout fields are deliberately named login-generic (not
passwordFailed*), so that a later 2FA failed attempt can share the same counter.
8. Forward-Compatibility (SSO / 2FA — Not in Scope)
Goal v1: simple, secure password authentication. SSO (e.g., Google) and 2FA will come later; the data model decisions do not preclude this:
- Password is not the only credential.
passwordHash == nullalready means “no local password” today (login handles the null hash cleanly). Policy/Lockout do not assume every user has a password — a later SSO-only account remains valid. - Lockout fields are login-generic → 2FA failed attempts share the counter.
- SSO/2FA config is per-Tenant and will later be stored as settings on the
Tenant’s
_vanceProject, under the reserved namespacesauth.sso.*andauth.mfa.*(not currently occupied). The global password policy remains orthogonal to this. - UserDocument grows additively (
mfaSecret/mfaEnabled/recoveryCodes,externalIdp/ssoSubjectas new optional fields, no migration) — therefore no “auth type” enum now, which would need to be refactored later. - The self-service
/profile/passwordremains purely password-related; 2FA enrollment will get its own endpoints under/profile/mfa/*.
9. What Password Security Does NOT Do (v1)
- No password expiration / no enforced rotation (
passwordChangedAtis only tracked). - No reuse prevention / password history.
- No tenant-configurable policy (deliberately a global minimum standard in code).
- No Redis for lockout (persistent state → MongoDB).
- The anus operator credential (
vance.anus.access.password-hash) is a separate surface (not a Tenant user) and not covered here.
Reference
permission-system.md— Authorization (who can do what)vault-access.md— External secrets ({{secret:…}}/ Vault)audit-system.md—authLoginFailure/authLoginSuccesstrailarchitektur-scopes-clients.md— Tenant/User/Session model