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
|
|
@ -1,6 +1,8 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
|
|
@ -9,11 +11,20 @@ from sqlalchemy import select
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt
|
||||
from app.security import encrypt, hash_password, verify_password
|
||||
|
||||
# Email+password auth tuning.
|
||||
PASSWORD_MIN_LEN = 10
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
|
@ -291,12 +302,189 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
|||
return {"authenticated": False}
|
||||
|
||||
|
||||
def _establish_session(request: Request, user: User) -> None:
|
||||
"""Sign `user` in for this browser session, mirroring the Google callback's multi-account
|
||||
handling so password sign-in joins the same account switcher."""
|
||||
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
|
||||
accounts.append(user.id)
|
||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||
request.session["user_id"] = user.id
|
||||
|
||||
|
||||
def _app_base() -> str:
|
||||
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
|
||||
u = settings.oauth_redirect_url
|
||||
return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
|
||||
(goes only into the emailed link)."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
AuthToken(
|
||||
user_id=user.id,
|
||||
kind=kind,
|
||||
token_hash=_hash_token(raw),
|
||||
expires_at=datetime.now(timezone.utc) + ttl,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind."""
|
||||
if not raw:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
||||
return None
|
||||
row.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Email+password registration. Creates a pending (inactive, unverified) account, sends a
|
||||
verification link, and records an access request for admin approval. Two gates before
|
||||
sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
|
||||
the endpoint can't be used to discover which emails are registered."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
# Input validation is safe to reveal (independent of whether the email exists).
|
||||
if not _EMAIL_RE.match(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
)
|
||||
if not sysconfig.get_bool(db, "allow_registration"):
|
||||
raise HTTPException(status_code=403, detail="Registration is currently closed.")
|
||||
if not _register_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
|
||||
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
is_active=False,
|
||||
email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
upsert_pending_invite(db, email) # admin-approval gate
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}")
|
||||
if settings.admin_email_set:
|
||||
background.add_task(
|
||||
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
|
||||
)
|
||||
# Existing email → do nothing visible (no enumeration). Uniform success either way.
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
"""Confirm an email-verification link, then bounce to a friendly status on the app."""
|
||||
user = _consume_token(db, token, "verify")
|
||||
if user is None:
|
||||
return RedirectResponse(url="/?verify=invalid")
|
||||
user.email_verified = True
|
||||
db.commit()
|
||||
return RedirectResponse(url="/?verify=ok")
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
def password_login(
|
||||
payload: dict, request: Request, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
|
||||
Once the password is proven correct, the owner gets a specific reason if their account is
|
||||
still pending — that's not an enumeration leak (they already hold the password)."""
|
||||
if not _login_limiter.allow(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if user is None or user.is_demo or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
if not user.email_verified:
|
||||
raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
|
||||
_establish_session(request, user)
|
||||
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
def password_reset_request(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Request a password-reset link. Uniform response regardless of whether the email has a
|
||||
password account, so it can't probe for registered emails."""
|
||||
if not _reset_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if _EMAIL_RE.match(email):
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if user is not None and user.password_hash and not user.is_demo:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
|
||||
token = payload.get("token")
|
||||
new_password = payload.get("password") or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||
user.password_hash = hash_password(new_password)
|
||||
# Burn any other outstanding reset tokens for this user.
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id, AuthToken.kind == "reset", AuthToken.used_at.is_(None)
|
||||
)
|
||||
).scalars():
|
||||
row.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
if user is None or not user.is_active:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
# Always keep the active account in the switchable list — covers sessions created before
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue