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
63
backend/alembic/versions/0022_password_auth.py
Normal file
63
backend/alembic/versions/0022_password_auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""email+password auth foundations
|
||||||
|
|
||||||
|
Revision ID: 0022_password_auth
|
||||||
|
Revises: 0021_system_config
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
|
||||||
|
Adds password-auth columns to users (password_hash, email_verified, is_active), makes
|
||||||
|
google_sub nullable (password-only accounts have no Google sub until they link), and a
|
||||||
|
single-use auth_tokens table for email verification + password reset. Existing rows are all
|
||||||
|
Google/demo accounts → backfilled email_verified=true (is_active defaults true).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0022_password_auth"
|
||||||
|
down_revision: Union[str, None] = "0021_system_config"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("password_hash", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
)
|
||||||
|
# Existing accounts are all Google/demo — their email is already proven.
|
||||||
|
op.execute("UPDATE users SET email_verified = true")
|
||||||
|
# Password-only accounts have no Google sub until they link one.
|
||||||
|
op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"auth_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False, unique=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("auth_tokens")
|
||||||
|
op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=False)
|
||||||
|
op.drop_column("users", "is_active")
|
||||||
|
op.drop_column("users", "email_verified")
|
||||||
|
op.drop_column("users", "password_hash")
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
|
|
@ -9,11 +11,20 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
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.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]+$")
|
_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}
|
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:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None:
|
if user is None or not user.is_active:
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
# Always keep the active account in the switchable list — covers sessions created before
|
# Always keep the active account in the switchable list — covers sessions created before
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ class Settings(BaseSettings):
|
||||||
allowed_emails: str = ""
|
allowed_emails: str = ""
|
||||||
admin_emails: str = ""
|
admin_emails: str = ""
|
||||||
|
|
||||||
|
# Whether anyone may submit an email+password registration (still gated by email
|
||||||
|
# verification + admin approval before they can sign in). Admin-tunable via the DB.
|
||||||
|
allow_registration: bool = True
|
||||||
|
|
||||||
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||||
frontend_origin: str = ""
|
frontend_origin: str = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,28 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||||
|
|
||||||
|
|
||||||
|
def send_verify_email(to: str, link: str) -> bool:
|
||||||
|
body = (
|
||||||
|
"Hi,\n\n"
|
||||||
|
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If you didn't sign up, you can ignore this message.\n\n"
|
||||||
|
"— Siftlode"
|
||||||
|
)
|
||||||
|
return _send([to], "Confirm your Siftlode email", body)
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_reset(to: str, link: str) -> bool:
|
||||||
|
body = (
|
||||||
|
"Hi,\n\n"
|
||||||
|
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||||
|
f"{link}\n\n"
|
||||||
|
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
|
||||||
|
"— Siftlode"
|
||||||
|
)
|
||||||
|
return _send([to], "Reset your Siftlode password", body)
|
||||||
|
|
||||||
|
|
||||||
def send_test(to: str) -> bool:
|
def send_test(to: str) -> bool:
|
||||||
body = (
|
body = (
|
||||||
"This is a test email from Siftlode.\n\n"
|
"This is a test email from Siftlode.\n\n"
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,20 @@ class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
# NULL for an email+password account that hasn't linked Google yet (Postgres allows
|
||||||
|
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
|
||||||
|
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
|
||||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
|
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
||||||
|
# verifies via a link). Login requires this AND is_active.
|
||||||
|
email_verified: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
# Admin-approved & enabled. A pending password registration starts inactive; admin
|
||||||
|
# approval activates it. A deactivated account can't log in.
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
|
|
@ -70,6 +82,26 @@ class OAuthToken(Base):
|
||||||
user: Mapped["User"] = relationship(back_populates="token")
|
user: Mapped["User"] = relationship(back_populates="token")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthToken(Base):
|
||||||
|
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
||||||
|
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
||||||
|
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
||||||
|
|
||||||
|
__tablename__ = "auth_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Invite(Base):
|
class Invite(Base):
|
||||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
|
@ -59,6 +59,15 @@ def list_invites(
|
||||||
return [_serialize(i) for i in rows]
|
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:
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||||
inv = db.get(Invite, invite_id)
|
inv = db.get(Invite, invite_id)
|
||||||
if inv is None:
|
if inv is None:
|
||||||
|
|
@ -78,6 +87,7 @@ def approve_invite(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
inv = _decide(db, invite_id, admin, approved=True)
|
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)
|
background.add_task(email_mod.send_access_approved, inv.email)
|
||||||
return _serialize(inv)
|
return _serialize(inv)
|
||||||
|
|
||||||
|
|
@ -109,9 +119,61 @@ def add_invite(
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
db.commit()
|
db.commit()
|
||||||
|
_activate_user_by_email(db, email)
|
||||||
return _serialize(inv)
|
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 -------------------------------------
|
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||||
|
|
||||||
def _serialize_demo(row: DemoWhitelist) -> dict:
|
def _serialize_demo(row: DemoWhitelist) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
|
from argon2 import PasswordHasher
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
|
||||||
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str | None) -> bool:
|
||||||
|
if not hashed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return _ph.verify(hashed, password)
|
||||||
|
except Exception:
|
||||||
|
# Any failure (mismatch, malformed hash) is a non-match — never raise to the caller.
|
||||||
|
return False
|
||||||
|
|
||||||
_fernet: Fernet | None = (
|
_fernet: Fernet | None = (
|
||||||
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||||
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
||||||
|
# --- Access / registration ---
|
||||||
|
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ apscheduler>=3.10,<4.0
|
||||||
py3langid>=0.3,<1.0
|
py3langid>=0.3,<1.0
|
||||||
itsdangerous>=2.1,<3.0
|
itsdangerous>=2.1,<3.0
|
||||||
cryptography>=46.0.7,<47
|
cryptography>=46.0.7,<47
|
||||||
|
argon2-cffi>=23.1,<26
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue