diff --git a/backend/alembic/versions/0022_password_auth.py b/backend/alembic/versions/0022_password_auth.py new file mode 100644 index 0000000..decb549 --- /dev/null +++ b/backend/alembic/versions/0022_password_auth.py @@ -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") diff --git a/backend/app/auth.py b/backend/app/auth.py index 8ebf762..4139b4f 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index af60b2b..ee8aa4a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,10 @@ class Settings(BaseSettings): allowed_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. frontend_origin: str = "" diff --git a/backend/app/email.py b/backend/app/email.py index 27119ca..d1af768 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -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) +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: body = ( "This is a test email from Siftlode.\n\n" diff --git a/backend/app/models.py b/backend/app/models.py index 0952533..4f12e9f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -23,8 +23,20 @@ class User(Base): __tablename__ = "users" 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) + # 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)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) 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") +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): """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).""" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 545a1ce..7151788 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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: diff --git a/backend/app/security.py b/backend/app/security.py index 00d2fc6..4693582 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -1,7 +1,25 @@ +from argon2 import PasswordHasher from cryptography.fernet import Fernet 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(settings.token_encryption_key.encode()) if settings.token_encryption_key else None ) diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index abe117f..b5db3dc 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = ( 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) --- 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} diff --git a/backend/requirements.txt b/backend/requirements.txt index 6ba02a4..67f14ad 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,4 @@ apscheduler>=3.10,<4.0 py3langid>=0.3,<1.0 itsdangerous>=2.1,<3.0 cryptography>=46.0.7,<47 +argon2-cffi>=23.1,<26