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
711 lines
30 KiB
Python
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}")
|