pdf-quiz-generator/backend/app/routers/auth.py
Daniel 1f2770257f
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 27s
docs: a Help page for the people who use the site, a Handbook for whoever runs it
They were one page, and it served neither. Somebody wanting to know what
"Studying for" does had to scroll past what the tutor is prompted with
and what re-embedding breaks.

/help — every signed-in account. Signing in through PedsHub SSO, what
"Studying for" scopes, Qbank against Sessions against Collections, study
and exam mode, where performance comes from, Reading and Cards and study
plans, "Make a deck", and how to report a bad question. An educator also
gets a section of their own: what a moderator has, what a grant gives
and what it does not, that nothing in the bank belongs to anybody, how a
draft becomes an article, how a plan is built, and what a moderator
cannot do. A learner never sees that half.

/handbook — administrators only now, with the same FAQ plus the rest.
The Settings card that pointed at it is admin-only to match, and
RequireAuth learned an admin door, which it did not have.

Help is in the account menu rather than the footer: a question you have
while working is answered from where you are.

And the repo docs describe the site that exists. CLAUDE.md lost the LMS
section — courses, modules, lessons, enrolments, all removed months ago
and still documented — and gained the permission model and the sign-in
flow. ADMIN.md's role table said moderators create courses; it now says
what the three roles actually reach, where roles come from, and that the
bank has no owners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:12:47 +02:00

711 lines
30 KiB
Python

import logging
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
from app.services import captcha, refresh_tokens, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
from app.schemas.auth import (
UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest,
)
from app.services import email_service
from app.utils.auth import (
check_rate_limit, get_password_hash, verify_password, create_access_token,
get_current_user,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _login_key(client_ip: str) -> str:
return f"login_attempts:{client_ip}"
def _check_login_rate_limit(client_ip: str):
"""How many times an address may guess, before it has to wait.
Counted per address and cleared by a success, because what this is for is
guessing — and somebody who signs in correctly has not guessed. Counting
successes too would lock out a hospital: one public address, a ward full of
people, eleven of whom happened to open the app this afternoon.
"""
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
key = _login_key(client_ip)
count = r.incr(key)
if count == 1:
r.expire(key, settings.LOGIN_WINDOW_MINUTES * 60)
if count > settings.LOGIN_MAX_ATTEMPTS:
raise HTTPException(
status_code=429,
detail=f"Too many login attempts. Try again in {settings.LOGIN_WINDOW_MINUTES} minutes.")
except HTTPException:
raise
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {e}")
def _clear_login_rate_limit(client_ip: str):
"""A correct password is the end of the matter."""
try:
import redis as redis_lib
from app.config import settings
redis_lib.from_url(settings.REDIS_URL, decode_responses=True,
socket_connect_timeout=1).delete(_login_key(client_ip))
except Exception:
pass
# Rate limit: max 3 reset requests per email per hour
RESET_LIMIT = 3
RESET_WINDOW_HOURS = 1
def _check_reset_rate_limit(db: Session, email: str):
"""Refuse a fourth request in an hour — for any address, not only a real one.
Returning early for an unknown address made this the oracle the careful
wording below exists to avoid: ask four times and a registered address
answers 429 while an unknown one answers 200 for ever. The limit is now
counted against the address as typed, so both answer the same.
"""
email_normalized = email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
if user is None:
# No rows to count for an address with no account, so the attempts are
# counted in Redis instead — keyed by a fingerprint, because a list of
# addresses somebody tried is itself worth not keeping.
import hashlib
from app.utils.auth import check_rate_limit
check_rate_limit(
key=f"pwreset:{hashlib.sha256(email_normalized.encode()).hexdigest()}",
max_calls=RESET_LIMIT, window_seconds=RESET_WINDOW_HOURS * 3600,
detail="Too many reset requests. Please wait before trying again.")
return
count = db.query(PasswordReset).filter(
PasswordReset.user_id == user.id,
PasswordReset.created_at >= window_start,
).count()
if count >= RESET_LIMIT:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many reset requests. Please wait before trying again.",
)
@router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody.
Unauthenticated on purpose: the registration form has to know whether to
ask for a code, and it is asking before it has an account to ask with. It
says only whether one is needed — never whether a given code is valid,
which would turn this into somewhere to guess them.
"""
# The very first account is always allowed, or a new install could lock
# itself out before an administrator exists to issue a code.
if db.query(User).count() == 0:
return {"first_user": True, "registration_open": True}
# Whether anyone may join at all, so the sign-in page can stop offering a
# door that is locked. It was offering one: registration can be turned off
# site-wide, and the only way to find out was to fill the form in and be
# refused.
# Single sign-on closes the password door, and this is the one question
# the sign-up form already asks. Saying so here means the form can decline
# to draw itself rather than collect a name, an email and a password
# twice, and then be refused by the POST.
from app.config import settings as cfg
sso_only = _get_sso_settings()["sso_only"]
return {"first_user": False,
"sso_only": sso_only,
# So the page can name the provider rather than say "single
# sign-on" at somebody who only knows it as PedsHub SSO.
"provider_name": (cfg.OIDC_PROVIDER_NAME
if cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID else None),
"registration_open": (not sso_only
and site_settings.get_flag("registration_enabled"))}
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_refuse_when_sso_only("Accounts are created through SSO on this site.")
# Failing open: an hCaptcha outage should cost the site a little spam, not
# every account that would have been created while it lasted.
await captcha.require_human(user_data.captcha_token, fail_open=True)
# Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0
if not is_first_user:
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
registration_enabled = r.get("settings:registration_enabled")
# Default to enabled if not set, disabled if explicitly set to "false"
if registration_enabled == "false":
raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
except HTTPException:
raise
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
email_normalized = user_data.email.lower().strip()
existing = db.query(User).filter(User.email == email_normalized).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=email_normalized,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="admin" if is_first_user else "user",
)
db.add(user)
db.flush()
# Create email verification record
token = secrets.token_urlsafe(32)
verification = EmailVerification(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24),
verified_at=datetime.utcnow() if is_first_user else None,
)
db.add(verification)
db.commit()
db.refresh(user)
if is_first_user:
# Auto-verified admin — log them in immediately
access_token = create_access_token(data={"sub": user.email})
return {"access_token": access_token, "token_type": "bearer"}
# Regular user — must verify email before logging in
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Block password login if SSO-only mode
sso_settings = _get_sso_settings()
if sso_settings["sso_only"]:
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
client_ip = (request.client.host if request and request.client else "unknown")
if request:
_check_login_rate_limit(client_ip)
email_normalized = login_data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
# An account with no password is not an account with the wrong password,
# but it is told the same thing: which accounts have one is not a question
# this endpoint answers.
if not user or not user.hashed_password or not verify_password(
login_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Check email verification — skip for users without any verification record (legacy/seeded)
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(
status_code=403,
detail="Email not verified. Please check your inbox and verify your email before logging in.",
)
_clear_login_rate_limit(client_ip)
return _signed_in(db, user, login_data, request)
def _signed_in(db: Session, user: User, login_data: LoginRequest, request: Request | None) -> Token:
"""What a successful sign-in hands back.
A browser gets what it always got. A client that says it wants a refresh
token also gets one, because it has nowhere safe to keep a password and no
person sitting in front of it to ask again.
"""
# Imported here, as everywhere else in this file: a module-level `settings`
# plus these function-level ones would make the name local to each of them
# and blow up on first use.
from app.config import settings
token = Token(
access_token=create_access_token(data={"sub": user.email}),
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
if login_data.refresh:
token.refresh_token = refresh_tokens.issue(
db, user, label=login_data.device,
ip=request.client.host if request and request.client else None)
return token
@router.post("/refresh", response_model=Token)
def refresh(data: RefreshRequest, db: Session = Depends(get_db), request: Request = None):
"""Trade a refresh token for a new pair.
The old one is spent by this call. Presenting a spent token ends the whole
session it belongs to: either it was copied or a client is replaying, and
from here those look the same.
"""
from app.config import settings
client_ip = request.client.host if request and request.client else "unknown"
# Flood protection, not a security control: the security here is that a
# refresh token is 256 unguessable bits and spending one twice ends the
# session. Generous, because an address can be a whole hospital behind one
# NAT and every app launch refreshes.
check_rate_limit(f"refresh:{client_ip}", settings.REFRESH_MAX_PER_HOUR, 3600,
"Too many refresh attempts. Try again later.")
spent = refresh_tokens.spend(db, data.refresh_token, ip=client_ip)
if spent is None:
raise HTTPException(401, "That sign-in has expired. Sign in again.")
user, rotated = spent
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(403, "Email not verified.")
return Token(
access_token=create_access_token(data={"sub": user.email}),
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
refresh_token=rotated,
)
@router.post("/logout", status_code=204)
def logout(data: LogoutRequest, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""End this session, or every session.
An access token already issued is a signature and cannot be recalled; it
dies of old age within the day. What this ends is the ability to get
another one, which is what a lost phone actually needs.
"""
if data.everywhere:
refresh_tokens.revoke_all(db, current_user)
elif data.refresh_token:
refresh_tokens.revoke_one(db, data.refresh_token)
@router.get("/sessions")
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Where this account is signed in, so a person can see and end them."""
return refresh_tokens.sessions(db, current_user)
@router.delete("/sessions/{family}", status_code=204)
def end_session(family: str, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
refresh_tokens.revoke_family(db, current_user, family)
@router.get("/verify-email")
def verify_email(token: str, db: Session = Depends(get_db)):
record = db.query(EmailVerification).filter(EmailVerification.token == token).first()
if not record:
raise HTTPException(status_code=400, detail="Invalid verification token")
if record.verified_at is not None:
return {"message": "Email already verified. You can log in."}
if datetime.utcnow() > record.expires_at:
raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.")
record.verified_at = datetime.utcnow()
db.commit()
return {"message": "Email verified successfully! You can now log in."}
@router.post("/resend-verification")
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_refuse_when_sso_only("Accounts are created through SSO on this site.")
email_normalized = data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user:
return {"message": "If that email exists, a verification link has been sent."}
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if record and record.verified_at is not None:
# The same sentence as every other outcome. Saying "already verified"
# here told anybody who asked that the address has an account and that
# it is in use — which is the whole of what the wording below is for.
return {"message": "If that email exists, a verification link has been sent."}
token = secrets.token_urlsafe(32)
if record:
record.token = token
record.expires_at = datetime.utcnow() + timedelta(hours=24)
else:
db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
db.commit()
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"message": "If that email exists, a verification link has been sent."}
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_refuse_when_sso_only("There is no password to reset. Please use SSO.")
email_normalized = data.email.lower().strip()
_check_reset_rate_limit(db, email_normalized)
user = db.query(User).filter(User.email == email_normalized).first()
# Always return same message to avoid email enumeration
if not user:
return {"message": "If that email is registered, a reset link has been sent."}
token = secrets.token_urlsafe(32)
reset = PasswordReset(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1),
)
db.add(reset)
db.commit()
background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
return {"message": "If that email is registered, a reset link has been sent."}
@router.post("/reset-password")
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
_refuse_when_sso_only("There is no password to reset. Please use SSO.")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
record = db.query(PasswordReset).filter(
PasswordReset.token == data.token,
PasswordReset.used == False,
).first()
if not record:
raise HTTPException(status_code=400, detail="Invalid or already used reset token")
if datetime.utcnow() > record.expires_at:
raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
user = db.query(User).filter(User.id == record.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = get_password_hash(data.new_password)
record.used = True
db.commit()
return {"message": "Password reset successfully. You can now log in."}
@router.get("/me/settings")
def get_user_settings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get user settings. Most settings live in Redis (Nextcloud config etc.);
reminders_disabled is canonical in Postgres and overrides Redis."""
data = {}
try:
import redis as redis_lib, json
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
raw = r.get(f"user_settings:{current_user.id}")
if raw:
data = json.loads(raw)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
# Canonical preferences come from the DB. Redis holds the rest, and a cache
# is not where a choice somebody made once should live.
data["reminders_disabled"] = bool(current_user.reminders_disabled)
data["tts_voice"] = current_user.tts_voice
return data
@router.put("/me/settings")
def save_user_settings(
settings_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Save user settings. reminders_disabled is persisted to Postgres;
other keys go to Redis."""
# Persist opt-out preference to DB (canonical source for the scheduler)
changed = False
if "reminders_disabled" in settings_data:
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
changed = True
if "tts_voice" in settings_data:
voice = (settings_data.get("tts_voice") or "").strip()
current_user.tts_voice = voice or None
changed = True
if changed:
db.add(current_user)
db.commit()
# Keep the full blob in Redis so other fields (Nextcloud config etc.) persist
try:
import redis as redis_lib, json
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
r.set(f"user_settings:{current_user.id}", json.dumps(settings_data))
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to save user settings to Redis: {e}")
return {"saved": True}
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return UserResponse.of(current_user)
@router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if data.new_password:
# Setting one is the same door. A password nobody can log in with is
# not harmless: it is a credential waiting for the flag to be turned
# off again.
_refuse_when_sso_only("Passwords are not used on this site. Please use SSO.")
# A first password is not a change of one. Somebody who signed in with
# a code or through single sign-on has none to confirm, and asking for
# it locked them out of ever setting one — the only way through was
# "forgot password", which is a strange thing to click when you never
# had one.
if current_user.hashed_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
if not verify_password(data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
current_user.hashed_password = get_password_hash(data.new_password)
if data.name:
current_user.name = data.name
db.commit()
db.refresh(current_user)
return {
"id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role,
}
# ── SSO / OIDC ─────────────────────────────────────────────────────
def _get_sso_settings():
"""Return SSO admin settings from Redis."""
try:
import redis as redis_lib
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
return {
"sso_only": r.get("settings:sso_only") == "true",
}
except Exception:
return {"sso_only": False}
#: How long a one-time code is worth anything. The browser is mid-redirect and
#: spends it immediately; a minute is generous for that and short enough that a
#: code seen in a log is already dead.
SSO_CODE_TTL = 60
def _sso_store():
import redis as redis_lib
from app.config import settings as cfg
return redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
def _stash_sso_token(token: str) -> str | None:
"""Park a freshly minted token behind a random code. None if Redis is away."""
code = secrets.token_urlsafe(32)
try:
_sso_store().setex(f"sso:exchange:{code}", SSO_CODE_TTL, token)
return code
except Exception:
return None
@router.post("/sso/exchange", response_model=Token)
def sso_exchange(data: SsoExchangeRequest):
"""Trade a one-time code for the token it stands for.
Once. The key is deleted as it is read, so a code replayed from a log, a
history entry or a Referer header buys nothing.
"""
from app.config import settings as cfg
key = f"sso:exchange:{data.code}"
try:
store = _sso_store()
# GETDEL where the server has it, which is atomic and settles the race
# between two tabs; the pipeline is the same thing for an older Redis.
try:
token = store.getdel(key)
except Exception:
pipe = store.pipeline()
pipe.get(key)
pipe.delete(key)
token = pipe.execute()[0]
except Exception:
raise HTTPException(status_code=503, detail="Could not complete sign-in. Please try again.")
if not token:
raise HTTPException(status_code=400, detail="That sign-in link has expired. Please sign in again.")
return Token(access_token=token, expires_in=cfg.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
def _refuse_when_sso_only(what: str = "Password login is disabled. Please use SSO.") -> None:
"""No password door while the site is single sign-on.
Login and the login codes checked this; register, forgot-password and
reset-password did not. So with SSO-only on, somebody could still be
issued a password account they could not use — and if the flag were ever
turned off, that is a password account nobody vetted, sitting in the
users table waiting.
"""
if _get_sso_settings()["sso_only"]:
raise HTTPException(status_code=403, detail=what)
@router.get("/sso/config")
def sso_config():
"""Public endpoint — tells frontend whether SSO is available and login mode."""
from app.config import settings as cfg
sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID)
sso_settings = _get_sso_settings()
from app.services import sso_roles
return {
"sso_enabled": sso_enabled,
"sso_only": sso_settings["sso_only"],
"provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
# Whether roles come from the provider's groups. Says only that they
# do, never which groups — enough for the access page to stop offering
# a control that can now only answer 409.
"roles_from_provider": sso_roles.is_configured(cfg),
}
@router.get("/sso/login")
async def sso_login(request: Request):
"""Redirect user to the OIDC provider for login.
`async`, and the redirect awaited. Authlib's Starlette client is the async
one — `authorize_redirect` hands back a coroutine — so a sync endpoint
returned that coroutine to FastAPI, which tried to serialise it as a
response body and answered 500: "'coroutine' object is not iterable". The
button had never worked; nothing found it because nothing had SSO
configured to click it with.
"""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback"
return await oauth.oidc.authorize_redirect(request, redirect_uri)
@router.get("/sso/callback")
async def sso_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OIDC provider callback — create or login user."""
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from app.config import settings as cfg
from app.services import sso_roles
if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
raise HTTPException(status_code=400, detail="SSO is not configured")
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
client_id=cfg.OIDC_CLIENT_ID,
client_secret=cfg.OIDC_CLIENT_SECRET,
client_kwargs={"scope": cfg.OIDC_SCOPES},
)
try:
token = await oauth.oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
userinfo = token.get("userinfo") or {}
email = userinfo.get("email", "").lower().strip()
name = userinfo.get("name") or userinfo.get("preferred_username") or email.split("@")[0]
if not email:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=no_email")
# An address the provider will not vouch for is not an identity. Matching
# on email means whoever proves an address owns the account that already
# uses it, so an unverified claim would hand over an existing account to
# anybody who typed the address into a provider that does not check. Only
# refused when the provider says so explicitly: a provider that omits the
# claim is not asserting the address is unverified.
if userinfo.get("email_verified") is False:
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=email_unverified")
# Find or create user
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(
email=email,
# No password rather than one nobody knows. A random string here
# reads as "has a password" everywhere that asks.
hashed_password=None,
name=name,
role="user",
)
db.add(user)
db.flush()
# Auto-verify SSO users
verification = EmailVerification(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + timedelta(hours=1),
verified_at=datetime.utcnow(),
)
db.add(verification)
db.commit()
db.refresh(user)
# The provider's directory decides what they are here, when it has been
# told to. Applied on every sign-in rather than only at creation: a list
# that can add somebody to the educators group and never take them out is
# not a list anybody can rely on. Off unless configured — see
# services/sso_roles.
if sso_roles.apply(db, user, userinfo, cfg):
db.commit()
db.refresh(user)
# A code in the address bar, never the token itself.
#
# This redirect is a page load: the browser asks nginx for it, and nginx
# logs `"$request"` — so every sign-in wrote a live bearer token, good for
# a day, into the frontend container's access log, and into the browser's
# history, and into the Referer of whatever the page loaded next. The code
# that goes there instead is worth one exchange, within a minute, and is
# gone the moment it is spent.
code = _stash_sso_token(create_access_token(data={"sub": user.email}))
if code is None:
logger.error("SSO succeeded but the exchange store is unreachable")
return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?code={code}")