1318 lines
68 KiB
Python
1318 lines
68 KiB
Python
"""Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt).
|
|
|
|
Split out of app/auth.py; the core auth surface (current_user, scopes, token
|
|
helpers) stays in app/auth.py, imported below."""
|
|
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import NamedTuple
|
|
from urllib.parse import urlencode
|
|
|
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import apitokens
|
|
from app import email as email_mod
|
|
from app import logs
|
|
from app import mailtext
|
|
from app import sysconfig
|
|
from app.auth import (
|
|
_YOUTUBE_SCOPES,
|
|
BASE_SCOPES,
|
|
MAX_SESSION_ACCOUNTS,
|
|
PASSWORD_MIN_LEN,
|
|
READ_SCOPES,
|
|
WRITE_SCOPES,
|
|
current_user,
|
|
env_admin_canonicals,
|
|
env_allowed_canonicals,
|
|
find_user_by_email,
|
|
google_enabled,
|
|
is_allowed,
|
|
log,
|
|
operator_contact,
|
|
resolved_user_id,
|
|
)
|
|
from app.config import settings
|
|
from app.db import SessionLocal, get_db
|
|
from app.models import AuthToken, Invite, OAuthToken, User, display_text
|
|
from app.ratelimit import RateLimiter, client_key
|
|
from app.security import encrypt, hash_password, hash_token, verify_password
|
|
from app.utils import canonical_email, valid_email
|
|
from app.refusal import refused
|
|
|
|
VERIFY_TTL = timedelta(hours=24)
|
|
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
|
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
|
|
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
|
|
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
|
|
# in and sets no password.
|
|
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
|
RESET_TTL = timedelta(hours=1)
|
|
# An admin-issued set-password link travels out of band — the admin has to actually reach the
|
|
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
|
|
# the affordance the admin was shown.
|
|
ADMIN_SETPW_TTL = timedelta(hours=24)
|
|
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
|
|
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
|
|
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
|
|
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
|
|
# not silently satisfy the email gate. See password_reset_confirm.
|
|
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
|
|
|
|
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
|
|
|
_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)
|
|
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
|
|
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
|
|
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
|
|
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
|
|
|
|
|
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
|
|
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
|
|
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
|
|
triggered by someone who doesn't control the account.
|
|
|
|
Takes the user rather than the address because the mail needs their language, and the send runs
|
|
as a background task — after this request's session, and the row, are out of reach."""
|
|
if _suspend_email_limiter.allow(user.email):
|
|
background.add_task(
|
|
email_mod.send_account_suspended,
|
|
user.email,
|
|
mailtext.lang_for_user(user),
|
|
operator_contact(),
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
_oauth = OAuth()
|
|
|
|
_oauth_creds: tuple[str, str] | None = None
|
|
|
|
|
|
def google_oauth(db: Session):
|
|
"""The configured authlib Google client, or None when no credentials are set (Google sign-in
|
|
is then disabled — email+password still works)."""
|
|
global _oauth, _oauth_creds
|
|
cid = sysconfig.get_str(db, "google_client_id")
|
|
csec = sysconfig.get_str(db, "google_client_secret")
|
|
if not cid or not csec:
|
|
return None
|
|
if _oauth_creds != (cid, csec):
|
|
_oauth = OAuth()
|
|
_oauth.register(
|
|
name="google",
|
|
client_id=cid,
|
|
client_secret=csec,
|
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
client_kwargs={"scope": BASE_SCOPES},
|
|
)
|
|
_oauth_creds = (cid, csec)
|
|
return _oauth.google
|
|
|
|
|
|
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
|
|
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
|
|
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
|
|
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
|
|
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
|
|
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
|
|
before any DB admin exists.
|
|
|
|
Grouped rather than one blast to everyone, because these recipients are people with their own UI
|
|
language: a single message would have to pick one of them and be wrong for the rest. An env
|
|
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
|
|
account is (say) suspended is still written to in the language they chose."""
|
|
rows = db.execute(
|
|
select(User.email, User.preferences).where(
|
|
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
|
)
|
|
).all()
|
|
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
|
|
for email in settings.admin_email_set:
|
|
if email not in by_email:
|
|
by_email[email] = mailtext.lang_for_email(db, email)
|
|
groups: dict[str, list[str]] = {}
|
|
for email in sorted(by_email):
|
|
groups.setdefault(by_email[email], []).append(email)
|
|
return groups
|
|
|
|
|
|
def notify_admins_of_request(
|
|
db: Session, background: BackgroundTasks | None, requester: str
|
|
) -> None:
|
|
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
|
|
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
|
|
for lang, recipients in admin_notify_groups(db).items():
|
|
if background is None:
|
|
email_mod.send_admin_new_request(recipients, lang, requester)
|
|
else:
|
|
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
|
|
|
|
|
|
class InviteRequest(NamedTuple):
|
|
"""What an access request settled on.
|
|
|
|
`invite` is the row the request now lives under — which is NOT always the address that was just
|
|
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
|
|
address is where mail will go. Callers that tell the user what happened need it.
|
|
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
|
|
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
|
|
"""
|
|
|
|
invite: Invite | None
|
|
is_new: bool
|
|
|
|
|
|
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
|
|
"""Idempotently record an access request, and the language it was made in.
|
|
|
|
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
|
|
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
|
|
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
|
|
not reopen a decision that has already been made for that mailbox. The row is still STORED under
|
|
the address as typed, because that is where mail has to go.
|
|
|
|
`lang` is the language the request came in — the locale Google reported for a refused sign-in.
|
|
The latest one wins: someone who asks again under another locale is telling us something newer
|
|
than last time. Anything unsupported is ignored
|
|
rather than stored, so a forged body cannot put junk in the column.
|
|
|
|
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
|
|
on that — `_register_account` persists its own flushed user precisely because a no-op here
|
|
commits nothing.
|
|
"""
|
|
email = (email or "").lower()
|
|
if not valid_email(email):
|
|
return InviteRequest(None, False)
|
|
lang = lang if lang in mailtext.SUPPORTED else None
|
|
canon = canonical_email(email)
|
|
rows = (
|
|
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
# An address the env already lets in has nothing to ask for, and a pending row for it would be
|
|
# worse than nothing: `is_allowed` answers from the invite rows once any exist, so a stranger
|
|
# registering a sibling spelling (`b.ob@` beside an env-granted `bob@`) would lock the real
|
|
# owner out of a grant no admin could see.
|
|
if not rows and canon in env_allowed_canonicals():
|
|
return InviteRequest(None, False)
|
|
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
|
|
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
|
|
# row exists, or the reopen branch below would fire and put one person in the admin's queue
|
|
# twice. Only when every row is denied does the spelling decide which one reopens.
|
|
undecided = [r for r in rows if r.status != "denied"]
|
|
inv = (
|
|
next((r for r in rows if r.status == "approved"), None)
|
|
or next((r for r in undecided if r.email == email), None)
|
|
or next(iter(undecided), None)
|
|
or next((r for r in rows if r.email == email), None)
|
|
or next(iter(rows), None)
|
|
)
|
|
if inv is not None:
|
|
relearned = lang is not None and inv.lang != lang
|
|
if relearned:
|
|
inv.lang = lang
|
|
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
|
if inv.status == "denied":
|
|
inv.status = "pending"
|
|
inv.requested_at = datetime.now(timezone.utc)
|
|
inv.decided_at = None
|
|
inv.decided_by = None
|
|
db.commit()
|
|
return InviteRequest(inv, True)
|
|
if relearned:
|
|
db.commit()
|
|
return InviteRequest(inv, False)
|
|
inv = Invite(email=email, status="pending", lang=lang)
|
|
db.add(inv)
|
|
db.commit()
|
|
return InviteRequest(inv, True)
|
|
|
|
|
|
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
|
|
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
|
|
the request) or `denied` (nothing to record).
|
|
|
|
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
|
|
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
|
|
notice now carries the user's address. Naming it is the point: on 2026-09-03 a
|
|
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
|
|
"request recorded" without saying FOR WHAT hid the mismatch for a day.
|
|
|
|
`linked` names the address the request actually lives under, and ONLY when that differs from the
|
|
one Google just returned — the honest version of "we didn't open a second request". Same
|
|
spelling twice is just a repeat, and saying anything there would be noise.
|
|
|
|
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
|
|
would read it back as a space and mangle exactly the address this epic exists to protect.
|
|
"""
|
|
params = {"access": kind}
|
|
if email:
|
|
params["email"] = email
|
|
if inv is not None and inv.email != email:
|
|
params["linked"] = inv.email
|
|
return "/#" + urlencode(params)
|
|
|
|
|
|
_DEV_OAUTH_HOSTS = frozenset(
|
|
{"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"}
|
|
)
|
|
|
|
|
|
def _oauth_redirect_uri(request: Request) -> str:
|
|
"""The redirect_uri handed to Google. Production always uses the fixed configured URL and never
|
|
trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In
|
|
local dev we honor the request's own origin when it is one of the known dev hosts, so a login
|
|
started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080."""
|
|
if settings.session_https_only: # https redirect URL == real deployment
|
|
return settings.oauth_redirect_url
|
|
host = (request.headers.get("host") or "").lower()
|
|
if host in _DEV_OAUTH_HOSTS:
|
|
return f"http://{host}/auth/callback"
|
|
return settings.oauth_redirect_url
|
|
|
|
|
|
# --- Request bodies (typed contracts; B-core-6) ---------------------------------------------
|
|
# Fields are `str | None` (not required) so the handlers keep their exact prior behaviour — a
|
|
# missing/null field reads as None and the existing `(... or "")` coercions still apply — while
|
|
# FastAPI now validates the body is a JSON object and publishes the schema.
|
|
|
|
|
|
class EmailIn(BaseModel):
|
|
email: str | None = None
|
|
|
|
|
|
class EmailPasswordIn(BaseModel):
|
|
email: str | None = None
|
|
password: str | None = None
|
|
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
|
|
# the choice exists anywhere the server can see: it lives in the browser until an account does,
|
|
# and a preference nobody recorded cannot be backfilled later (as `google_email` showed: those
|
|
# rows still read NULL until each user signs in again). It is stored as the account's
|
|
# `preferences["language"]`, which the UI starts in and every mail to the account is written in
|
|
# (`mailtext.lang_for_user` / `lang_for_email`).
|
|
lang: str | None = None
|
|
|
|
|
|
class TokenIn(BaseModel):
|
|
token: str | None = None
|
|
|
|
|
|
class TokenPasswordIn(BaseModel):
|
|
token: str | None = None
|
|
password: str | None = None
|
|
|
|
|
|
class SetPasswordIn(BaseModel):
|
|
password: str | None = None
|
|
current_password: str | None = None
|
|
|
|
|
|
@router.get("/login")
|
|
async def login(request: Request, db: Session = Depends(get_db)):
|
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
|
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
|
# is kept when Google doesn't re-issue one (see the callback).
|
|
client = google_oauth(db)
|
|
if client is None:
|
|
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
|
# hides the button, so this is just a safety net for a stale/direct hit).
|
|
return RedirectResponse(url="/")
|
|
return await client.authorize_redirect(
|
|
request,
|
|
_oauth_redirect_uri(request),
|
|
access_type="offline",
|
|
prompt="select_account",
|
|
include_granted_scopes="true",
|
|
scope=BASE_SCOPES,
|
|
)
|
|
|
|
|
|
@router.get("/callback")
|
|
async def callback(
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
client = google_oauth(db)
|
|
if client is None:
|
|
return RedirectResponse(url="/")
|
|
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
|
# password account enabling YouTube or connecting SSO). The markers are set by /auth/link and
|
|
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
|
|
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
|
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
|
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
|
link_uid = request.session.pop("oauth_link_uid", None)
|
|
link_explicit = request.session.pop("oauth_link_explicit", False)
|
|
try:
|
|
token = await client.authorize_access_token(request)
|
|
except OAuthError as exc:
|
|
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
|
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
|
|
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
|
|
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
|
|
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
|
|
# link_uid, so the add-account path (no marker) is covered too.
|
|
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
|
if request.session.get("user_id") is not None:
|
|
return RedirectResponse(url="/?oauth=cancelled")
|
|
return RedirectResponse(url="/?login=oauth_cancelled")
|
|
|
|
userinfo = token.get("userinfo")
|
|
if not userinfo or not userinfo.get("sub"):
|
|
raise refused(400, "google_no_userinfo")
|
|
|
|
sub = userinfo["sub"]
|
|
email = (userinfo.get("email") or "").lower()
|
|
|
|
if link_uid is not None:
|
|
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
|
|
|
if not is_allowed(db, email):
|
|
# No uid to name — having no account here is the branch. And the address is left out on
|
|
# purpose: `redact_text` turns it into `[email]` before either sink sees it, so a line
|
|
# whose only variable is always `[email]` says nothing. WHO asked is in the pending
|
|
# invite recorded just below, which is where an admin looks anyway.
|
|
log.warning(
|
|
"Google sign-in denied: address not approved",
|
|
# log-pii-ok: whether an address came back, not what it was
|
|
extra=logs.event("auth.login.denied", reason="not_approved", has_email=bool(email)),
|
|
)
|
|
# A denied Google login doubles as an access request: record it for the admin and
|
|
# land the user on a friendly "request received" screen instead of a raw 403.
|
|
if email:
|
|
# Google just told us this person's locale — the only language signal that will ever
|
|
# exist for an address with no account, and the decision mail is written from it.
|
|
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
|
|
if req.is_new:
|
|
notify_admins_of_request(db, background, email)
|
|
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
|
|
return RedirectResponse(url=_access_notice_url("denied"))
|
|
|
|
user = db.execute(select(User).where(User.google_sub == sub)).scalar_one_or_none()
|
|
if user is None:
|
|
# No account carries this Google identity yet. If one already exists for this MAILBOX —
|
|
# e.g. an email+password registration — adopt the Google identity onto it instead of
|
|
# inserting a duplicate. Google has verified the address, so this safely unifies password +
|
|
# Google sign-in on one account.
|
|
#
|
|
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
|
|
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
|
|
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
|
|
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
|
|
# matches literally is never passed over for a canonical sibling.
|
|
canon = canonical_email(email)
|
|
candidates = (
|
|
db.execute(select(User).where(User.email_canonical == canon).order_by(User.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
existing = next((u for u in candidates if u.email == email), None) or next(
|
|
iter(candidates), None
|
|
)
|
|
if existing is not None:
|
|
if existing.google_sub is not None and existing.google_sub != sub:
|
|
# A different Google identity is already bound to this email — refuse rather than
|
|
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
|
log.warning(
|
|
"Google sign-in refused: a different Google identity holds this address",
|
|
extra=logs.event("auth.login.denied", reason="sub_collision", uid=existing.id),
|
|
)
|
|
return RedirectResponse(url=_access_notice_url("denied"))
|
|
if not userinfo.get("email_verified", False):
|
|
# Adopting (and activating) a pre-existing password account requires Google to
|
|
# actually attest the address — otherwise an unverified Google email that merely
|
|
# collides with a pending registration could seize that account. Defense-in-depth.
|
|
log.warning(
|
|
"Google sign-in refused: unverified address, not adopting the account",
|
|
extra=logs.event(
|
|
"auth.login.denied", reason="email_unverified", uid=existing.id
|
|
),
|
|
)
|
|
return RedirectResponse(url=_access_notice_url("denied"))
|
|
user = existing
|
|
user.google_sub = sub
|
|
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
|
|
# ownership (bumps the epoch to revoke any session it minted). Runs before the
|
|
# activation below, which is what it reads.
|
|
revoke_untrusted_preset_password(db, user)
|
|
# Google verified the email and is_allowed granted access, so finish activating a
|
|
# previously pending password registration (otherwise current_user would bounce it).
|
|
user.email_verified = True
|
|
user.is_active = True
|
|
else:
|
|
user = User(google_sub=sub, email=email)
|
|
db.add(user)
|
|
# A suspended account can't sign in by any method. Block before establishing the session and
|
|
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
|
if user.is_suspended:
|
|
log.info(
|
|
"Google sign-in blocked: the account is suspended",
|
|
extra=logs.event("auth.login.denied", reason="suspended", uid=user.id),
|
|
)
|
|
_notify_suspended(background, user)
|
|
return RedirectResponse(url="/?login=suspended")
|
|
# Record what Google actually answers with, beside the address the user registered with. The two
|
|
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
|
|
# pattern) the UI shows both, so nobody has to guess which one signs them in.
|
|
if email:
|
|
user.google_email = email
|
|
_sync_google_email(db, user, email)
|
|
user.display_name = display_text(userinfo.get("name"))
|
|
user.avatar_url = userinfo.get("picture")
|
|
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
|
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
|
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
|
|
# default for a brand-new account (its role attribute is still unset before flush).
|
|
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
|
|
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
|
|
#
|
|
# Becoming admin this way drops any password the account holds, on whichever sign-in it
|
|
# happens. Nothing here can tell who chose that password: without SMTP registration marks the
|
|
# address verified with no proof (and SMTP configured later never revisits it), and an admin's
|
|
# Activate vouches for access, not for the password. So a stranger who registered the address
|
|
# first, and was let in, would otherwise see their own live session become an admin one. The
|
|
# real owner, signed in through Google right now, sets a new password in Settings.
|
|
if canonical_email(email) in env_admin_canonicals():
|
|
if user.role != "admin" and user.password_hash is not None:
|
|
_revoke_password(db, user)
|
|
user.role = "admin"
|
|
elif not user.role:
|
|
user.role = "user"
|
|
# Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets
|
|
# a Google user who later adds a password sign in (password-login gates on email_verified).
|
|
if userinfo.get("email_verified", True):
|
|
user.email_verified = True
|
|
# Default UI language from the Google-reported locale on first login (if the user hasn't
|
|
# picked one yet); unsupported locales fall back to English.
|
|
prefs = dict(user.preferences or {})
|
|
if not prefs.get("language"):
|
|
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
|
|
user.preferences = prefs
|
|
db.flush()
|
|
|
|
_store_token(db, user, token)
|
|
_establish_session(request, user)
|
|
db.commit()
|
|
log.info(
|
|
"signed in",
|
|
extra=logs.event("auth.login.ok", method="google", uid=user.id, role=user.role),
|
|
)
|
|
return RedirectResponse(url="/")
|
|
|
|
|
|
def _sync_google_email(db: Session, user: User, email: str) -> None:
|
|
"""Follow a genuine Google-side email change — and ONLY that.
|
|
|
|
A different SPELLING of the same mailbox is the user's own choice and must survive. People
|
|
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
|
|
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
|
|
of the spelling they chose, because sign-in matches the stored address first. Google returns the
|
|
account's PRIMARY address, which need not be what they registered with — and can never contain a
|
|
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
|
|
sign-in. (Matching adoption across spellings made that reachable; the rewrite itself is
|
|
older.)
|
|
|
|
Never overwrite with an address another account already owns either: that would hit the unique
|
|
constraint and 500, wedging this user's sign-in.
|
|
"""
|
|
if not email or user.email == email:
|
|
return
|
|
if canonical_email(user.email) == canonical_email(email):
|
|
return # same mailbox, their spelling — leave it alone
|
|
clash = db.execute(
|
|
select(User).where(User.email == email, User.id != user.id)
|
|
).scalar_one_or_none()
|
|
if clash is None:
|
|
user.email = email
|
|
|
|
|
|
def _store_token(db: Session, user: User, token: dict) -> None:
|
|
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
|
|
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
|
|
include_granted_scopes=true means `scope` is the union of everything granted, so this
|
|
reflects read/write upgrades correctly."""
|
|
tok = user.token or OAuthToken(user=user)
|
|
old = set((tok.scopes or "").split())
|
|
new = set((token.get("scope") or "").split())
|
|
# A plain re-sign-in requests only BASE_SCOPES, and Google can hand back a FRESH base-only
|
|
# access+refresh token. Adopting it would DESTROY a previously-granted YouTube read/write grant —
|
|
# can_read/can_write flip to false and the feed demands a reconnect (the exact bug users hit on a
|
|
# logout→login). The OLD refresh token stays valid on Google's side, so when this exchange would
|
|
# NARROW our YouTube scopes, keep the WHOLE existing grant (refresh token, access token, scopes)
|
|
# untouched. The grant only shrinks on a full disconnect (purge_user deletes the token row); an
|
|
# externally-revoked scope surfaces later as a YouTube API 403 → reconnect, not through this write.
|
|
if (old & _YOUTUBE_SCOPES) - new:
|
|
db.add(tok)
|
|
return
|
|
if token.get("refresh_token"):
|
|
tok.refresh_token_enc = encrypt(token["refresh_token"])
|
|
# Encrypt the access token at rest too (it's a live ~1h bearer credential), matching the
|
|
# refresh token — a DB/backup/log leak otherwise hands out working Google API tokens.
|
|
tok.access_token = encrypt(token.get("access_token"))
|
|
expires_at = token.get("expires_at")
|
|
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
|
# Union (never narrow): a broader-or-equal exchange may still list only the newly-added scope.
|
|
tok.scopes = " ".join(sorted(old | new)) or BASE_SCOPES
|
|
db.add(tok)
|
|
|
|
|
|
def _complete_link(
|
|
request: Request,
|
|
db: Session,
|
|
link_uid: int,
|
|
explicit: bool,
|
|
sub: str,
|
|
userinfo: dict,
|
|
token: dict,
|
|
):
|
|
"""Attach the just-authorized Google identity to the already-signed-in account that started
|
|
the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a
|
|
Google identity owned by another account, or to silently swap a different linked identity.
|
|
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
|
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
|
# SEC-11: the marker must name an account signed into THIS browser — the session default OR any
|
|
# account in the wallet — not only the default. A tab acting as a secondary account links Google to
|
|
# ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so
|
|
# accepting wallet membership stops the identity/token landing on the default account A instead of B.
|
|
accounts = request.session.get("account_ids") or []
|
|
if link_uid != request.session.get("user_id") and link_uid not in accounts:
|
|
return RedirectResponse(url="/?link=error")
|
|
user = db.get(User, link_uid)
|
|
if user is None:
|
|
return RedirectResponse(url="/?link=error")
|
|
other = db.execute(
|
|
select(User).where(User.google_sub == sub, User.id != user.id)
|
|
).scalar_one_or_none()
|
|
if other is not None:
|
|
return RedirectResponse(url="/?link=conflict")
|
|
if user.google_sub is not None and user.google_sub != sub:
|
|
return RedirectResponse(url="/?link=mismatch")
|
|
user.google_sub = sub
|
|
linked_email = (userinfo.get("email") or "").strip().lower()
|
|
if linked_email:
|
|
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
|
if not user.display_name:
|
|
user.display_name = display_text(userinfo.get("name"))
|
|
if not user.avatar_url:
|
|
user.avatar_url = userinfo.get("picture")
|
|
_store_token(db, user, token)
|
|
db.commit()
|
|
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
|
|
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
|
|
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
|
|
return RedirectResponse(url="/?link=ok" if explicit else "/")
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request):
|
|
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
|
|
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
|
|
out in one tab doesn't disturb another tab running a different account. If the account being
|
|
removed was the session default, promote the most recent remaining account (or clear the
|
|
session when none remain)."""
|
|
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
|
|
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
|
|
active, is_default = resolved_user_id(request)
|
|
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
|
request.session["account_ids"] = remaining
|
|
if is_default:
|
|
if remaining:
|
|
request.session["user_id"] = remaining[-1]
|
|
return JSONResponse({"ok": True, "switched": True})
|
|
request.session.clear()
|
|
return JSONResponse({"ok": True, "switched": False})
|
|
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
|
|
# is untouched. The tab drops its own override and falls back to the default on reload.
|
|
return JSONResponse({"ok": True, "switched": False})
|
|
|
|
|
|
def _establish_session(request: Request, user: User) -> None:
|
|
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
|
and password login, so both join the same account switcher and both stamp `last_login_at` (the
|
|
caller commits). Multi-session: every account that authenticated in this browser is remembered so
|
|
the user can switch between them; only accounts that actually proved themselves HERE land in that
|
|
wallet (it is the proof they may be switched to)."""
|
|
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
|
|
_remember_epoch(request, user)
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|
|
|
|
|
def _app_base() -> str:
|
|
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
|
|
return settings.app_base
|
|
|
|
|
|
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 _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
|
|
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
|
|
the same "does its user still exist" answer, so the two can never disagree about a given token.
|
|
|
|
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
|
|
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
|
|
authority, because a token can expire, be spent, or lose its account between the two calls.
|
|
"""
|
|
if not raw:
|
|
return None
|
|
row = db.execute(
|
|
select(AuthToken).where(
|
|
AuthToken.token_hash == hash_token(raw),
|
|
AuthToken.kind == kind,
|
|
AuthToken.used_at.is_(None),
|
|
AuthToken.expires_at >= datetime.now(timezone.utc),
|
|
)
|
|
).scalar_one_or_none()
|
|
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
|
|
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
|
|
return db.get(User, row.user_id) if row is not None else None
|
|
|
|
|
|
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
|
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
|
|
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
|
|
read-check-write, so two concurrent requests can't both consume the same verify/reset token —
|
|
only the one whose UPDATE flips used_at gets a row back."""
|
|
if not raw:
|
|
return None
|
|
now = datetime.now(timezone.utc)
|
|
row = db.execute(
|
|
update(AuthToken)
|
|
.where(
|
|
AuthToken.token_hash == hash_token(raw),
|
|
AuthToken.kind == kind,
|
|
AuthToken.used_at.is_(None),
|
|
AuthToken.expires_at >= now,
|
|
)
|
|
.values(used_at=now)
|
|
.returning(AuthToken.user_id)
|
|
).first()
|
|
db.commit()
|
|
if row is None:
|
|
return None
|
|
return db.get(User, row.user_id)
|
|
|
|
|
|
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
|
|
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
|
|
swallowed, because the caller has to treat them differently — only a mailed reset attests the
|
|
address."""
|
|
for kind in _PASSWORD_TOKEN_KINDS:
|
|
user = _consume_token(db, raw, kind)
|
|
if user is not None:
|
|
return user, kind
|
|
return None
|
|
|
|
|
|
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
|
|
"""An admin-issued, single-use link that lets `user` set a password without any email.
|
|
|
|
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
|
|
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
|
|
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
|
|
"no SMTP configured and someone forgot their password".
|
|
|
|
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
|
|
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
|
|
which is what evicts a squatter — see password_reset_confirm."""
|
|
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
|
|
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
|
|
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
|
|
# consume it, and a second entry shape would be a second thing to keep correct.
|
|
return f"{_app_base()}/#reset={raw}", expires_at
|
|
|
|
|
|
@router.post("/register")
|
|
def register(
|
|
body: EmailPasswordIn,
|
|
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 = (body.email or "").strip().lower()
|
|
password = body.password or ""
|
|
# Input validation is safe to reveal (independent of whether the email exists).
|
|
if not valid_email(email):
|
|
raise refused(400, "invalid_email")
|
|
if len(password) < PASSWORD_MIN_LEN:
|
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
|
if not sysconfig.get_bool(db, "allow_registration"):
|
|
raise refused(403, "registration_closed")
|
|
if not _register_limiter.allow(client_key(request)):
|
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
|
|
|
# Do the existence check + account creation OFF the response path (a background task with its own
|
|
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
|
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
|
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
|
background.add_task(_register_account, email, password, body.lang)
|
|
return {"ok": True, "status": "ok"}
|
|
|
|
|
|
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
|
"""Background worker for /register: create the pending account (+ verification email + admin
|
|
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
|
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
|
with SessionLocal() as db:
|
|
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
|
|
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
|
|
# the end that stops new ones appearing, while the Google callback copes with the existing.
|
|
candidates = (
|
|
db.execute(
|
|
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
|
|
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
|
|
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
|
|
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
|
|
existing = next((u for u in candidates if u.email == email), None) or next(
|
|
iter(candidates), None
|
|
)
|
|
if existing is not None:
|
|
# Already registered. This used to return in silence, and that silence is the most
|
|
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
|
|
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
|
|
# inbox", and waited for a message that did not exist.
|
|
#
|
|
# Re-sending the link instead leaks nothing new. The response and its timing are
|
|
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
|
|
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
|
|
# for the same input — so it is not a new capability, just the end of a dead end. An
|
|
# account that is already confirmed still gets nothing: there is nothing to confirm.
|
|
#
|
|
# And a re-send is ALL it does: the submitted password is discarded. A closing
|
|
# review tried "while the address is unverified, the newest registration owns it"
|
|
# (replace the hash, kill the outstanding verify tokens) as a fix for address
|
|
# pre-emption, and it proved worse than the problem — DO NOT REPEAT IT.
|
|
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
|
|
# caller the power to overwrite a real registrant's chosen password and delete the
|
|
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
|
|
# NULL, password_revoked_at set) it would install a password where the whole point was
|
|
# that there is none. The owner's real recourse already exists and needs no new
|
|
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
|
|
# and bumps the session epoch. The residual is logged as SEC-16.
|
|
user = existing
|
|
if not user.email_verified and email_mod.email_enabled():
|
|
send_verification_link(db, user)
|
|
return
|
|
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
|
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
|
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
|
email_ok = email_mod.email_enabled()
|
|
user = User(
|
|
email=email,
|
|
password_hash=hash_password(password),
|
|
is_active=False,
|
|
email_verified=not email_ok,
|
|
# Only a supported code is stored, so a forged body can't put junk in preferences.
|
|
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
|
|
)
|
|
db.add(user)
|
|
try:
|
|
db.flush()
|
|
except IntegrityError:
|
|
# A concurrent registration for the same new email won the race between the pre-check
|
|
# above and this flush (the unique-email constraint fires). Same already-registered
|
|
# no-op as the pre-check hit — roll back and stop. The line carries nothing: the
|
|
# answer to the caller is deliberately identical either way, and so is this.
|
|
log.info(
|
|
"a registration lost the race for its address",
|
|
extra=logs.event("auth.register.race_lost"),
|
|
)
|
|
db.rollback()
|
|
return
|
|
upsert_pending_invite(db, email) # admin-approval gate
|
|
# Persist the account ourselves. upsert_pending_invite commits only when it actually writes an
|
|
# invite row — not when this address already has a pending or approved one — and _issue_token
|
|
# below only runs with SMTP on. So for an address the admin had already approved (or one whose
|
|
# request was still pending), on an instance with no working SMTP, NOTHING along this path
|
|
# committed and the flushed user was silently rolled back when the session closed: the
|
|
# would-be user got the uniform "ok" and no account, and re-registering did the same again.
|
|
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
|
|
db.commit()
|
|
if email_ok:
|
|
send_verification_link(db, user)
|
|
# Fired unconditionally, unlike the Google callback, which gates on a fresh invite row: for
|
|
# an address the admin already approved, nothing lands in the Access-requests queue, so this
|
|
# is the only place the account's existence is announced at all. Delivery is a separate
|
|
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
|
|
# still needs the admin to notice them (roadmap UX-25).
|
|
notify_admins_of_request(db, None, email)
|
|
|
|
|
|
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
|
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
|
|
link inside a larger message (the branching approval/invitation mails).
|
|
|
|
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
|
|
kinds of link cannot drift apart in shape or in how they are consumed."""
|
|
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
|
|
return f"{_app_base()}/#verify={raw}"
|
|
|
|
|
|
def send_verification_link(db: Session, user: User) -> bool:
|
|
"""Issue a fresh verification token for `user` and mail them the link.
|
|
|
|
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
|
|
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
|
|
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
|
|
|
|
One definition, three callers — registration, the self-service resend, and the admin-side resend.
|
|
The link's shape and TTL must not drift between them; a verification mail that differs
|
|
by who triggered it is a bug waiting to be discovered by exactly one user.
|
|
|
|
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
|
|
admin-side resend) can say so instead of reporting success for a message that was dropped.
|
|
"""
|
|
return email_mod.send_verify_email(
|
|
user.email,
|
|
mailtext.lang_for_user(user),
|
|
issue_verification_link(db, user),
|
|
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
|
)
|
|
|
|
|
|
@router.post("/verify")
|
|
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
|
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
|
on any invalid/expired/used token.
|
|
|
|
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
|
|
dropped the password this account registered with, so sign-in would answer with the deliberately
|
|
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
|
|
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
|
|
already proves that much. The common source of that state is gone (admin approval no longer
|
|
revokes), leaving accounts stripped before that change and the Google-adoption path, so this
|
|
flag is rare now — but it must stay, because those users have no other way back in."""
|
|
user = _consume_token(db, body.token, "verify")
|
|
if user is None:
|
|
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
|
|
user.email_verified = True
|
|
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
|
email = user.email
|
|
db.commit()
|
|
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
|
|
# password_reset_required above: holding a valid single-use verify token for this account already
|
|
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
|
|
# for a different account, where an unqualified "email confirmed" would be actively misleading.
|
|
return {"ok": True, "password_reset_required": needs_password, "email": email}
|
|
|
|
|
|
@router.post("/verify/resend")
|
|
def verify_email_resend(
|
|
body: EmailIn,
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
) -> dict:
|
|
"""Re-issue an email-verification link. Needed because the original link expires (VERIFY_TTL)
|
|
and re-registering an already-registered email is a silent no-op, so a user who lost the first
|
|
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
|
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
|
if not _verify_resend_limiter.allow(client_key(request)):
|
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
|
email = (body.email or "").strip().lower()
|
|
if valid_email(email):
|
|
background.add_task(_resend_verify_if_eligible, email)
|
|
return {"ok": True, "status": "ok"}
|
|
|
|
|
|
def _resend_verify_if_eligible(email: str) -> None:
|
|
"""Background worker for verify/resend: issue a fresh verify token + email the link, but ONLY for
|
|
a real account that still needs verifying. Runs after the response with its own DB session, so the
|
|
request's timing never reveals whether the account exists. No-op when SMTP is disabled (accounts
|
|
are auto-verified on a no-SMTP instance, so there is nothing to resend). Token rides the URL
|
|
FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3)."""
|
|
if not email_mod.email_enabled():
|
|
return
|
|
with SessionLocal() as db:
|
|
user = find_user_by_email(db, email)
|
|
if user is not None and not user.email_verified:
|
|
send_verification_link(db, user)
|
|
|
|
|
|
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
|
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
|
shape so the SPA can say it in the user's own language (the old bare English strings were
|
|
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
|
|
|
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
|
|
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
|
|
still shut. It is not an enumeration leak: this branch is only reachable once the password has
|
|
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
|
|
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
|
|
real account and an unknown one.
|
|
|
|
The two gates are order-independent, so `approved` really can be true while
|
|
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
|
rather than assuming a sequence.
|
|
"""
|
|
detail: dict = {"code": "login", "reason": reason}
|
|
if user is not None:
|
|
detail |= {
|
|
"email": user.email,
|
|
"email_verified": user.email_verified,
|
|
"approved": user.is_active,
|
|
}
|
|
detail |= {k: v for k, v in extra.items() if v is not None}
|
|
return HTTPException(status_code=status, detail=detail)
|
|
|
|
|
|
@router.post("/password-login")
|
|
def password_login(
|
|
body: EmailPasswordIn,
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
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 or suspended — that's not an enumeration leak (they already hold the password)."""
|
|
if not _login_limiter.allow(client_key(request)):
|
|
raise _login_refused(429, "rate_limited")
|
|
email = (body.email or "").strip().lower()
|
|
password = body.password or ""
|
|
user = find_user_by_email(db, email)
|
|
# Always run the KDF (against a decoy hash for an unknown / passwordless account) so the
|
|
# response time is the same whether or not the email exists — no timing-based enumeration.
|
|
candidate_hash = user.password_hash if user else None
|
|
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
|
|
if user is None or not password_ok:
|
|
raise _login_refused(401, "invalid")
|
|
if user.is_suspended:
|
|
_notify_suspended(background, user)
|
|
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
|
|
if not user.email_verified:
|
|
raise _login_refused(403, "email_unverified", user)
|
|
if not user.is_active:
|
|
raise _login_refused(403, "pending_approval", user)
|
|
_establish_session(request, user)
|
|
db.commit()
|
|
log.info(
|
|
"signed in",
|
|
extra=logs.event("auth.login.ok", method="password", uid=user.id, role=user.role),
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/password-reset/request")
|
|
def password_reset_request(
|
|
body: EmailIn,
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
) -> dict:
|
|
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
|
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
|
if not _reset_limiter.allow(client_key(request)):
|
|
return {"ok": True, "status": "ok"}
|
|
email = (body.email or "").strip().lower()
|
|
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
|
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
|
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
|
if valid_email(email):
|
|
background.add_task(_send_reset_if_eligible, email)
|
|
return {"ok": True, "status": "ok"}
|
|
|
|
|
|
def _send_reset_if_eligible(email: str) -> None:
|
|
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
|
|
for a password account. Runs after the response with its own DB session, so the
|
|
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
|
|
can't leak into proxy/access logs or a Referer (SB3).
|
|
|
|
"Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a
|
|
password and must be able to get a new one, or it is locked out permanently (no password to sign in
|
|
with, and formerly no reset link either). A Google-only account that never had a password stays
|
|
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
|
|
proves identity through the session rather than by mail."""
|
|
with SessionLocal() as db:
|
|
user = find_user_by_email(db, email)
|
|
if user is None:
|
|
return
|
|
if user.password_hash or user.password_revoked_at:
|
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
|
# The account's own address, as above — never the spelling that was typed into the form.
|
|
email_mod.send_password_reset(
|
|
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
|
|
)
|
|
|
|
|
|
@router.post("/password-reset/check")
|
|
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
|
"""Is the reset link the SPA just opened still good? Answers without spending the token.
|
|
|
|
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
|
|
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
|
|
account and then opening its reset link — the user types a new password into a form that answers
|
|
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
|
|
in spam, and whoever got two mails usually clicks the older one.
|
|
|
|
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
|
|
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
|
|
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
|
|
answer it must degrade to the old behaviour instead of declaring a live link dead.
|
|
"""
|
|
if not _reset_check_limiter.allow(client_key(request)):
|
|
return {"ok": True}
|
|
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
|
|
|
|
|
@router.post("/password-reset/confirm")
|
|
def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict:
|
|
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
|
|
token = body.token
|
|
new_password = body.password or ""
|
|
if len(new_password) < PASSWORD_MIN_LEN:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
|
)
|
|
spent = _consume_password_token(db, token)
|
|
if spent is None:
|
|
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
|
user, kind = spent
|
|
user.password_hash = hash_password(new_password)
|
|
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
|
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
|
# email — this rescues an account that registered but never clicked the verification link (it
|
|
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
|
# here: a reset proves email ownership, not that an admin approved the account.
|
|
#
|
|
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
|
|
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
|
|
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
|
|
# about. The person still confirms their address the normal way.
|
|
if kind == "reset":
|
|
user.email_verified = True
|
|
# SA4: a reset (often a compromise response) kills all existing sessions — and API tokens.
|
|
bump_session_epoch(db, user)
|
|
# Burn any other outstanding reset tokens for this user.
|
|
for row in db.execute(
|
|
select(AuthToken).where(
|
|
AuthToken.user_id == user.id,
|
|
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
|
AuthToken.used_at.is_(None),
|
|
)
|
|
).scalars():
|
|
row.used_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
def _remember_epoch(request: Request, user: User) -> None:
|
|
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
|
|
can later reject the cookie if the account's epoch was bumped (password reset/change, or
|
|
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
|
|
epochs = dict(request.session.get("epochs") or {})
|
|
epochs[str(user.id)] = user.session_epoch
|
|
request.session["epochs"] = epochs
|
|
|
|
|
|
def bump_session_epoch(db: Session, user: User) -> None:
|
|
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
|
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
|
API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not
|
|
outlive the answer to that theft. Not committed — the caller commits it with its change.
|
|
|
|
The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at
|
|
the same moment either commits first and is deleted here, or waits and sees the new epoch. FOR NO
|
|
KEY UPDATE (`key_share`): the two still exclude each other, but a row that only refers to the user
|
|
(a notice, an audit line) is not held up — a token's expiry notice would deadlock with it."""
|
|
if user.id is None:
|
|
user.session_epoch = (user.session_epoch or 0) + 1
|
|
return
|
|
epoch = db.execute(
|
|
select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True)
|
|
).scalar_one()
|
|
user.session_epoch = (epoch or 0) + 1
|
|
apitokens.revoke_all(db, user.id)
|
|
|
|
|
|
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
|
|
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
|
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
|
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
|
session it minted; the owner re-sets one via password-reset, or from Settings while the Google
|
|
session lasts.
|
|
|
|
A NEVER-ACTIVATED account is untrusted too, verified or not: without SMTP, registration marks the
|
|
address verified with no proof at all. For an ADMIN_EMAILS / ALLOWED_EMAILS address no invite row
|
|
gates the registration, so a stranger who registered it first would otherwise be activated here
|
|
with their own password still working. (An account the callback makes ADMIN loses its password
|
|
whatever its state — see the ADMIN_EMAILS block there.)
|
|
|
|
The Google-adoption callback is the ONLY caller. Admin approval used to call this too, and
|
|
dropping it was right for the reason given — the revoke destroyed the password a legitimate
|
|
registrant had just chosen, and since the admin normally decides before the user reads their mail,
|
|
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
|
|
|
|
The revoke was also called REDUNDANT there, because `password_login` refuses an unverified
|
|
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
|
|
gate holds only while the address is unverified, and the approval mail exists to get the real
|
|
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
|
|
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
|
|
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
|
|
would have handed anonymous callers a write onto a real account's credential. What the owner
|
|
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
|
|
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
|
|
back in front of every legitimate registrant, which is the whole reason it was removed.
|
|
|
|
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
|
|
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
|
|
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
|
|
was locked out for good (it could not sign in either). The stamp separates the two cases."""
|
|
if not user.email_verified or (not user.is_active and user.password_hash is not None):
|
|
_revoke_password(db, user)
|
|
|
|
|
|
def _revoke_password(db: Session, user: User) -> None:
|
|
"""Drop the account's password, stamp why (so password-reset still accepts it) and end every
|
|
session it signed in."""
|
|
user.password_hash = None
|
|
user.password_revoked_at = datetime.now(timezone.utc)
|
|
bump_session_epoch(db, user)
|
|
|
|
|
|
@router.post("/logout-others")
|
|
def logout_others(
|
|
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
|
|
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
|
|
in somewhere" / a suspected session compromise."""
|
|
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
|
# session at a newer epoch than the DB (see set_password).
|
|
bump_session_epoch(db, user)
|
|
db.commit()
|
|
_remember_epoch(request, user)
|
|
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/link")
|
|
async def link_google(
|
|
request: Request,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
|
|
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
|
|
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
|
|
client = google_oauth(db)
|
|
if client is None:
|
|
return RedirectResponse(url="/?link=error")
|
|
request.session["oauth_link_uid"] = user.id
|
|
request.session["oauth_link_explicit"] = True
|
|
return await client.authorize_redirect(
|
|
request,
|
|
_oauth_redirect_uri(request),
|
|
access_type="offline",
|
|
prompt="select_account",
|
|
include_granted_scopes="true",
|
|
scope=BASE_SCOPES,
|
|
)
|
|
|
|
|
|
@router.post("/set-password")
|
|
def set_password(
|
|
body: SetPasswordIn,
|
|
request: Request,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Set or change the signed-in account's password. Setting a first password (Google-only
|
|
account) needs no current password — the session already proves identity. Changing an
|
|
existing one requires the current password, so a hijacked session can't silently rotate it."""
|
|
new_password = body.password or ""
|
|
if len(new_password) < PASSWORD_MIN_LEN:
|
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
|
if user.password_hash:
|
|
if not verify_password(body.current_password or "", user.password_hash):
|
|
raise refused(403, "wrong_password")
|
|
user.password_hash = hash_password(new_password)
|
|
user.password_revoked_at = None # same as the reset path: a password exists again
|
|
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
|
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
|
|
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
|
bump_session_epoch(db, user)
|
|
db.commit()
|
|
_remember_epoch(request, user)
|
|
log.info("Password set/changed: uid=%s", user.id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/upgrade")
|
|
async def upgrade(
|
|
request: Request,
|
|
access: str = "read",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
|
read-only (enough to build the feed); `access=write` additionally grants management
|
|
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
|
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
|
the least-privileged read scope."""
|
|
client = google_oauth(db)
|
|
if client is None:
|
|
return RedirectResponse(url="/")
|
|
# Attach the grant to THIS account in the callback. Without it a password account (no
|
|
# google_sub) would get a brand-new, separate Google user created instead of being linked.
|
|
request.session["oauth_link_uid"] = user.id
|
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
|
return await client.authorize_redirect(
|
|
request,
|
|
_oauth_redirect_uri(request),
|
|
access_type="offline",
|
|
prompt="consent",
|
|
include_granted_scopes="true",
|
|
scope=scope,
|
|
)
|
|
|
|
|
|
@router.get("/config")
|
|
def auth_config(db: Session = Depends(get_db)) -> dict:
|
|
"""Public: which sign-in options this instance offers, so the login page can hide what's off
|
|
(e.g. no Google button when Google OAuth isn't configured)."""
|
|
return {
|
|
"google_enabled": google_enabled(db),
|
|
"allow_registration": sysconfig.get_bool(db, "allow_registration"),
|
|
# Contact shown on the public legal pages (the operator's admin email), if configured.
|
|
"operator_contact": operator_contact(),
|
|
}
|