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:
parent
62f46de301
commit
8f5e26d2ee
9 changed files with 398 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue