feat(auth): email+password registration with two-gate activation (5a backend)

Add email+password auth alongside Google: argon2id hashing; users gains
password_hash/email_verified/is_active and google_sub becomes nullable (migration
0022); a single-use, hashed auth_tokens table for email verification + password
reset. Registration creates a pending (inactive, unverified) account + an access
request; sign-in needs verified email AND admin approval. Anti-enumeration: uniform
register/login/reset responses (a correct-password owner still gets a specific
pending reason). allow_registration flag (DB-tunable). Admin users-list + role
endpoint (guards: not self, not demo, not the last admin); approving access (or a
manual whitelist add) now activates the matching pending account. current_user
rejects deactivated accounts. Verified end-to-end via curl + DB.
This commit is contained in:
npeter83 2026-06-19 14:03:11 +02:00
parent 62f46de301
commit 8f5e26d2ee
9 changed files with 398 additions and 6 deletions

View file

@ -4,7 +4,7 @@ import re
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import email as email_mod
@ -59,6 +59,15 @@ def list_invites(
return [_serialize(i) for i in rows]
def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
if user is not None and not user.is_active:
user.is_active = True
db.commit()
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
@ -78,6 +87,7 @@ def approve_invite(
db: Session = Depends(get_db),
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv)
@ -109,9 +119,61 @@ def add_invite(
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv)
# --- Users & roles --------------------------------------------------------------------
def _serialize_user(u: User) -> dict:
return {
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"is_active": u.is_active,
"email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None,
"has_google": u.google_sub is not None,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
@router.get("/users")
def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
return [_serialize_user(u) for u in rows]
@router.patch("/users/{user_id}/role")
def set_user_role(
user_id: int,
payload: dict,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
account, and can't remove the last remaining admin."""
role = payload.get("role")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.")
if target.role == "admin" and role == "user":
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
target.role = role
db.commit()
return _serialize_user(target)
# --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict: