`/auth/forgot-password` and `/auth/resend-verification` both take care to say "if that email exists" and both then answered the question anyway. The reset limiter returned early for an unknown address, so it counted nothing for one and counted for the other: ask four times and a registered address gets 429 while an unknown one gets 200 for ever. It counts either way now — in Redis for an address with no rows to count, keyed by a fingerprint, because a list of addresses somebody tried is itself worth not keeping. Resend answered "Email already verified." for a known verified address and "if that email exists" for everything else, which is not a hint but an answer. One sentence for every outcome now. And Editorial has a way to write something. Drafting was only reachable from the library — a page about reading, behind a button an educator arriving to work has no reason to look for — so the two panels now open from a link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
478 lines
20 KiB
Python
478 lines
20 KiB
Python
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, invites, 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,
|
|
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest,
|
|
)
|
|
from app.services import email_service
|
|
from app.utils.auth import (
|
|
get_password_hash, verify_password, create_access_token, get_current_user,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _check_login_rate_limit(client_ip: str):
|
|
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
|
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 = f"login_attempts:{client_ip}"
|
|
count = r.incr(key)
|
|
if count == 1:
|
|
r.expire(key, 15 * 60)
|
|
if count > 10:
|
|
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {e}")
|
|
|
|
|
|
# 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 {"invite_required": False, "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.
|
|
return {"invite_required": site_settings.get_flag("invite_only"),
|
|
"first_user": False,
|
|
"registration_open": site_settings.get_flag("registration_enabled")}
|
|
|
|
|
|
@router.post("/register")
|
|
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
|
# 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}")
|
|
|
|
# Invite-only: a code is checked before anything is created, and spent only
|
|
# once the account exists, so a failure part-way through does not burn it.
|
|
invite = None
|
|
if not is_first_user and site_settings.get_flag("invite_only"):
|
|
invite = invites.usable(db, user_data.invite_code)
|
|
if invite is None:
|
|
raise HTTPException(403, "This site is invite-only. A valid invite code is required.")
|
|
|
|
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()
|
|
if invite is not None:
|
|
invites.spend(db, invite, user)
|
|
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.")
|
|
|
|
if request:
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
_check_login_rate_limit(client_ip)
|
|
|
|
email_normalized = login_data.email.lower().strip()
|
|
user = db.query(User).filter(User.email == email_normalized).first()
|
|
if not user 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.",
|
|
)
|
|
|
|
access_token = create_access_token(data={"sub": user.email})
|
|
return Token(access_token=access_token)
|
|
|
|
|
|
@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)):
|
|
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)):
|
|
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)):
|
|
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 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:
|
|
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}
|
|
|
|
|
|
@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()
|
|
return {
|
|
"sso_enabled": sso_enabled,
|
|
"sso_only": sso_settings["sso_only"],
|
|
"provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
|
|
}
|
|
|
|
|
|
@router.get("/sso/login")
|
|
def sso_login(request: Request):
|
|
"""Redirect user to the OIDC provider for login."""
|
|
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 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
|
|
|
|
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")
|
|
|
|
# Find or create user
|
|
user = db.query(User).filter(User.email == email).first()
|
|
if not user:
|
|
user = User(
|
|
email=email,
|
|
hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
|
|
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)
|
|
|
|
access_token = create_access_token(data={"sub": user.email})
|
|
return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?token={access_token}")
|