105 lines
5.0 KiB
Markdown
105 lines
5.0 KiB
Markdown
# Password Policy
|
||
|
||
Server-side password validation that every password-write endpoint goes
|
||
through. Configurable via env without a code deploy. Implementation lives in
|
||
`apps/web/app/services/auth/password-policy.server.ts`.
|
||
|
||
## Defaults (OWASP 2024 aligned)
|
||
|
||
| Env var | Default | Effect |
|
||
|---|---|---|
|
||
| `AUTH_PASSWORD_MIN_LENGTH` | `12` | Minimum character count. |
|
||
| `AUTH_PASSWORD_REQUIRE_UPPER` | `true` | Requires at least one A–Z character. |
|
||
| `AUTH_PASSWORD_REQUIRE_LOWER` | `true` | Requires at least one a–z character. |
|
||
| `AUTH_PASSWORD_REQUIRE_DIGIT` | `true` | Requires at least one 0–9 character. |
|
||
| `AUTH_PASSWORD_REQUIRE_SPECIAL` | `false` | Requires at least one non-alphanumeric character. Off by default — adds friction without much entropy beyond the length floor. |
|
||
| `AUTH_PASSWORD_HIBP_CHECK` | `true` | Consults `api.pwnedpasswords.com` via SHA-1 k-anonymity. The plaintext password never leaves the server; only the first 5 characters of its SHA-1 are sent. |
|
||
| `AUTH_PASSWORD_HIBP_MAX_OCCURRENCES` | `0` | Accept passwords seen at most this many times in HIBP. `0` = reject any leak. |
|
||
|
||
Also enforced regardless of env:
|
||
|
||
- An absolute max length of 1024 characters, so the validate-then-hash chain
|
||
can't be amplified into a DoS via 100 MB password strings.
|
||
- If the caller supplies `ctx.email`, the password must not contain the
|
||
email's local-part (case-insensitive), provided the local-part is ≥3
|
||
characters. Short local-parts (`x@example.com`) are skipped to avoid
|
||
false positives.
|
||
|
||
## Enforcement points
|
||
|
||
Every endpoint that writes a password runs the policy:
|
||
|
||
| Route | Caller |
|
||
|---|---|
|
||
| `apps/web/app/utils/services/auth.service.server.ts` (`AuthService.validateRegistration`) | `validatePassword` before `users.db.server.ts` hashes/stores the password |
|
||
| `apps/web/app/routes/reset.tsx` | UserValidationUtils.validatePasswords |
|
||
| `apps/web/app/routes/invitation.$id.tsx` | UserValidationUtils.validatePasswords |
|
||
| `apps/web/app/routes/admin/settings/profile.tsx` (change password) | validatePassword directly |
|
||
| `apps/web/app/routes/app.$tenant/settings/profile.tsx` (change password) | validatePassword directly |
|
||
| `apps/web/app/routes/admin/accounts/users.tsx` (admin reset) | validatePassword directly |
|
||
| `apps/web/app/routes/app.$tenant/__portals/portals.$portal/users.tsx` (portal user creation / reset) | validatePassword directly |
|
||
|
||
Login (`apps/web/app/modules/users/services/AuthService.ts`) deliberately
|
||
does NOT run the strength policy. Enforcing 12-char + class requirements
|
||
there would lock out every user with a pre-policy password instead of
|
||
prompting them to rotate. The plain "is the field a non-empty 4+ char
|
||
string" presence-check in `UserValidationUtils.validatePassword` is what
|
||
guards the login form. The dashboard-banner soft-check from the detail-MD
|
||
is a follow-up.
|
||
|
||
## HIBP fail-open
|
||
|
||
If the HIBP API times out (>3 s), returns a 5xx, or refuses the connection,
|
||
the validation result carries `hibpBypassed: true` and the password is
|
||
accepted (the other policy rules still apply). Caller code can log the
|
||
bypass so operators have a signal when api.pwnedpasswords.com is having a
|
||
bad day. We chose fail-open over fail-closed because the alternative
|
||
("can't register new users while HIBP is down") is worse for availability
|
||
than the alternative ("a newly-leaked password might slip through during
|
||
an HIBP outage").
|
||
|
||
To force fail-closed during a known-bad incident: set
|
||
`AUTH_PASSWORD_HIBP_CHECK=false` to skip HIBP entirely until the API
|
||
recovers. (Yes, that's the same as the bypass — but the env switch makes
|
||
it explicit and avoids the per-request 3 s timeout cost.)
|
||
|
||
## Error model
|
||
|
||
Each failure produces a bounded i18n key plus optional non-secret args:
|
||
|
||
```ts
|
||
{
|
||
valid: false,
|
||
errors: [
|
||
{ code: "auth.password.errors.tooShort", args: { minLength: 12 } },
|
||
{ code: "auth.password.errors.missingDigit" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Codes:
|
||
- `auth.password.errors.empty` — input was empty / wrong type
|
||
- `auth.password.errors.tooShort` — below `AUTH_PASSWORD_MIN_LENGTH`
|
||
- `auth.password.errors.tooLong` — above the absolute max (1024)
|
||
- `auth.password.errors.missingUpper`
|
||
- `auth.password.errors.missingLower`
|
||
- `auth.password.errors.missingDigit`
|
||
- `auth.password.errors.missingSpecial`
|
||
- `auth.password.errors.containsEmail`
|
||
- `auth.password.errors.pwned`
|
||
|
||
Routes are expected to surface the first error to the user; the i18n
|
||
catalog should map every code above.
|
||
|
||
## Operator playbook
|
||
|
||
- **Relax the policy** (e.g. temporary lower bound during a migration):
|
||
set `AUTH_PASSWORD_MIN_LENGTH=10` + restart. The change takes effect on
|
||
next request; no code deploy.
|
||
- **Tighten the policy**: set `AUTH_PASSWORD_REQUIRE_SPECIAL=true` and
|
||
bump `AUTH_PASSWORD_MIN_LENGTH=16`. Existing users keep their old
|
||
passwords until they next rotate; the new policy applies only to writes.
|
||
- **HIBP-Outage handling**: monitor `hibpBypassed` in the audit log; if
|
||
the rate sustains, set `AUTH_PASSWORD_HIBP_CHECK=false` to drop the 3 s
|
||
per-write timeout. Re-enable when api.pwnedpasswords.com recovers.
|