The account settings offered "Change password", and there is no password to change: accounts live at PedsHub SSO, which is also where a passkey, an address or a second factor is set. A form that writes a credential nothing checks is worse than no form — it invites somebody to secure their account by a route that does not secure it. The panel now keeps the display name and points at the provider for the rest. Retired with it: GET /auth/verify-email, POST /auth/resend-verification, POST /auth/forgot-password, POST /auth/reset-password, the new_password branch of PUT /auth/me, the two schemas behind them, and the three pages — VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their routes and the links into them. An account with no password cannot forget one. POST /auth/login stays, still refused while sso_only is set. It is the way back in if the provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door at all on a bad day. The test that walked five password doors now walks that one and asserts the other four answer 404 rather than 403 — gone, not guarded. Verified live: all four endpoints 404, and the account panel shows a name field and "Manage your account ↗" to sso.pedshub.com/if/user/#/settings, with no password field anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
524 lines
21 KiB
Python
524 lines
21 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 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 (
|
|
UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
|
|
UserUpdateMe, SsoExchangeRequest,
|
|
)
|
|
from app.services import email_service
|
|
from app.utils.auth import (
|
|
check_rate_limit, 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.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)
|
|
|
|
|
|
# ── No password lifecycle ─────────────────────────────────────────────
|
|
#
|
|
# Verifying an address, resending that mail, forgetting a password and
|
|
# resetting one all lived here. Accounts are at the identity provider now: it
|
|
# owns the address, the passkey, the second factor and any password there is,
|
|
# so a reset link minted here would set a credential nothing checks — and an
|
|
# account with no password cannot forget one.
|
|
#
|
|
# /login stays, refused while `sso_only` is set. It is the way back in if the
|
|
# provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at
|
|
# startup, and taking it out would leave no door at all on a bad day.
|
|
|
|
|
|
@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)):
|
|
"""Your name. Your sign-in belongs to the provider.
|
|
|
|
This used to set a password as well. There is nowhere for one to be used
|
|
that the provider does not own, so writing one here would store a
|
|
credential nothing checks — and the settings page that offered it invited
|
|
somebody to secure their account by a route that secures nothing.
|
|
"""
|
|
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 checked this; forgot-password and reset-password did not. Accounts
|
|
are made at the provider now, so what is left to guard is the password
|
|
somebody already has: resetting it, or setting a new one.
|
|
"""
|
|
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}")
|