T10.3 D13 - drop users.password_hash and every path that touched it
The irreversible one. The suite no longer stores a credential of any kind.
Removed from app.py: /api/auth/forgot-password, /api/auth/reset-password,
/api/auth/reset-available, /api/auth/password, /api/auth/users/{id}/password,
the four password-bearing input models, the reset throttle and mail body, and
the password arguments to create_user. Removed from auth.py: hash_password,
verify_password, password_problem, MIN_PASSWORD_LEN, _COMMON_PASSWORDS,
create_reset_token, decode_reset_token, RESET_MINUTES, and the bcrypt import.
Removed from notify.py: the password_reset_enabled feature flag. Removed from
manage_users.py: the password prompt and the reset-password command.
token_version STAYS. Password changes no longer exist, but a role change or a
deactivation still has to invalidate sessions that are already issued.
/api/auth/users/{id}/role stays, which is D13 criterion 4 - granting admin to
an existing account must keep working, and it does.
Migration b7e4f1a20c93 uses batch_alter_table because SQLite has no DROP COLUMN
before 3.35 and local dev runs on SQLite while production runs on Postgres.
downgrade() recreates the column NULLABLE rather than NOT NULL as the baseline
declared it: there are no hashes to put back, and a NOT NULL column with no
server default refuses to add itself to a table with rows. The docstring says
plainly that the downgrade does not restore the old login - it exists so the
revision is well-formed, not because stepping back is a recovery path.
Verified:
upgrade head from empty -> password_hash absent from users
downgrade -1 -> column back, nullable (notnull=0)
upgrade head again -> absent again
remaining /api/auth routes -> no password or reset route left
create-admin -> works with no password prompt
grep for the removed symbols -> nothing outside the migration and one
docstring that names the dropped column
NOT verified, and it is a done-when box left open rather than ticked: the
migration has only been round-tripped on SQLite. No Postgres is available here.
batch_alter_table takes the direct ALTER path on Postgres, which is the simpler
of the two, but "simpler" is not "tested".
notify.send_now is now orphaned - its only caller was forgot_password. Logged as
BL-026 rather than deleted in passing, because an immediate unqueued send is a
reasonable primitive to keep and that decision does not belong in an auth task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
Authentication is an LDAPS simple bind against the domain (D13); this module owns
|
||||
everything *after* that. A successful sign-in issues a signed JWT that rides in an
|
||||
HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
@@ -12,6 +12,8 @@ Security model:
|
||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||
• Roles are LOCAL. The directory supplies identity; this app decides what that
|
||||
identity may do, which is why an existing admin keeps admin (D13 criterion 4).
|
||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||
set; if it is missing we fall back to a random per-process key (which logs a
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
@@ -35,10 +37,11 @@ The user-administration SCOPE of a super user is worked out in server/app.py
|
||||
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||
membership rows — this module only decides which roles carry the power at all.
|
||||
|
||||
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||
to the account's address. It is single-use by construction — it embeds the user's
|
||||
`token_version`, which is bumped when the password changes, so a used or
|
||||
superseded link stops validating.
|
||||
There is no password and no password reset: D13 replaced local credentials with an
|
||||
LDAPS bind (server/ldap_auth.py). People change their password with the domain, and
|
||||
the login page points them at Okta. `token_version` survives as the
|
||||
session-revocation mechanism — a role change or a deactivation must take effect on
|
||||
sessions that have already been issued.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
@@ -46,7 +49,6 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
@@ -61,8 +63,6 @@ COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# How long an emailed password-reset link stays valid.
|
||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
@@ -121,29 +121,6 @@ def is_project_admin(user: "models.User") -> bool:
|
||||
# same question used to exist here and silently disagreed with the scoped one, which
|
||||
# locked per-project super users out of the routes they were entitled to.
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
@@ -184,22 +161,6 @@ def _load_secret() -> str:
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -227,33 +188,6 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
return claims
|
||||
|
||||
|
||||
def create_reset_token(user: "models.User") -> str:
|
||||
"""Short-lived, single-use token for an emailed password-reset link.
|
||||
|
||||
Single-use falls out of `ver`: completing a reset bumps the user's
|
||||
token_version, so the link (and any older link) no longer validates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "pwreset",
|
||||
"sub": user.id,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=RESET_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_reset_token(token: str) -> Optional[dict]:
|
||||
"""Claims for a valid, unexpired reset token, else None."""
|
||||
try:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if claims.get("typ") != "pwreset":
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
|
||||
Reference in New Issue
Block a user