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}")