Files
HiveGrid-Wireframes/hivegrid-cloud/docs/auth/password-policy.md
T

105 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.