"""Authentication routes — login, logout, signup, status, user management.""" from fastapi import APIRouter, Request, Response, HTTPException from pydantic import BaseModel from typing import Optional import logging import os from core.auth import AuthManager from src.rate_limiter import RateLimiter from src.settings import ( load_settings as _load_settings, save_settings as _save_settings, load_features as _load_features, save_features as _save_features, DEFAULT_SETTINGS, ) from src.integrations import ( load_integrations, add_integration, update_integration, delete_integration, get_integration, execute_api_call, INTEGRATION_PRESETS, migrate_from_settings, ) logger = logging.getLogger(__name__) class LoginRequest(BaseModel): username: str password: str remember: bool = True totp_code: Optional[str] = None class SetupRequest(BaseModel): username: str password: str class SignupRequest(BaseModel): username: str password: str class ChangePasswordRequest(BaseModel): current_password: str new_password: str class CreateUserRequest(BaseModel): username: str password: str is_admin: bool = False class DeleteUserRequest(BaseModel): username: str SESSION_COOKIE = "odysseus_session" def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: router = APIRouter(prefix="/api/auth", tags=["auth"]) _login_limiter = RateLimiter(max_requests=15, window_seconds=60) _signup_limiter = RateLimiter(max_requests=3, window_seconds=300) _setup_limiter = RateLimiter(max_requests=3, window_seconds=300) def _get_current_user(request: Request) -> Optional[str]: token = request.cookies.get(SESSION_COOKIE) return auth_manager.get_username_for_token(token) @router.post("/setup") async def first_run_setup(body: SetupRequest, request: Request): """Create initial admin account. Only works if no accounts exist.""" if not _setup_limiter.check(request.client.host): raise HTTPException(429, "Too many requests — try again later") if auth_manager.is_configured: raise HTTPException(400, "Already configured") if len(body.password) < 8: raise HTTPException(400, "Password must be at least 8 characters") ok = auth_manager.setup(body.username, body.password) if not ok: raise HTTPException(500, "Setup failed") return {"ok": True, "message": "Admin account created"} @router.post("/signup") async def signup(body: SignupRequest, request: Request): """Create a new user account. Only works if signup is enabled by admin.""" if not _signup_limiter.check(request.client.host): raise HTTPException(429, "Too many requests — try again later") if not auth_manager.is_configured: raise HTTPException(400, "Run setup first") if not auth_manager.signup_enabled: raise HTTPException(403, "Registration is disabled. Ask an admin for an account.") if len(body.password) < 8: raise HTTPException(400, "Password must be at least 8 characters") if len(body.username.strip()) < 1: raise HTTPException(400, "Username is required") ok = auth_manager.create_user(body.username, body.password, is_admin=False) if not ok: raise HTTPException(409, "Username already taken") return {"ok": True, "message": "Account created"} @router.post("/login") async def login(body: LoginRequest, request: Request, response: Response): if not _login_limiter.check(request.client.host): raise HTTPException(429, "Too many requests — try again later") # Verify password first username = body.username.strip().lower() if not auth_manager.verify_password(username, body.password): raise HTTPException(401, "Invalid credentials") # Check 2FA if enabled if auth_manager.totp_enabled(username): if not body.totp_code: # Password OK but need TOTP — tell client to show code input return {"ok": False, "requires_totp": True, "username": username} if not auth_manager.totp_verify(username, body.totp_code): raise HTTPException(401, "Invalid 2FA code") # All checks passed — create session token = auth_manager.create_session(username, body.password) if not token: raise HTTPException(401, "Invalid credentials") cookie_kwargs = dict( key=SESSION_COOKIE, value=token, httponly=True, samesite="lax", secure=os.getenv("SECURE_COOKIES", "false").lower() == "true", path="/", ) if body.remember: cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days response.set_cookie(**cookie_kwargs) return {"ok": True, "username": username} @router.post("/logout") async def logout(request: Request, response: Response): token = request.cookies.get(SESSION_COOKIE) if token: auth_manager.revoke_token(token) response.delete_cookie(SESSION_COOKIE, path="/") return {"ok": True} @router.get("/status") async def auth_status(request: Request): token = request.cookies.get(SESSION_COOKIE) result = auth_manager.status(token) result["signup_enabled"] = auth_manager.signup_enabled # Include the caller's effective privileges so the frontend can # hide / dim UI controls the user isn't allowed to use. Admins get # ADMIN_PRIVILEGES (everything on), regular users get their stored # set merged with DEFAULT_PRIVILEGES. try: u = result.get("username") if u: result["privileges"] = auth_manager.get_privileges(u) except Exception: pass return result @router.post("/change-password") async def change_password(body: ChangePasswordRequest, request: Request): user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") if len(body.new_password) < 8: raise HTTPException(400, "Password must be at least 8 characters") ok = auth_manager.change_password(user, body.current_password, body.new_password) if not ok: raise HTTPException(400, "Current password is incorrect") return {"ok": True} # ------------------------------------------------------------------ # Two-factor authentication # ------------------------------------------------------------------ @router.post("/2fa/setup") async def totp_setup(request: Request): """Generate a TOTP secret and return the QR code URI.""" user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") if auth_manager.totp_enabled(user): raise HTTPException(400, "2FA is already enabled") secret = auth_manager.totp_generate_secret(user) if not secret: raise HTTPException(500, "Failed to generate secret") uri = auth_manager.totp_get_provisioning_uri(user, secret) # Generate QR code as base64 PNG import qrcode, io, base64 qr = qrcode.make(uri, box_size=6, border=2) buf = io.BytesIO() qr.save(buf, format="PNG") qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii") return {"secret": secret, "uri": uri, "qr_code": f"data:image/png;base64,{qr_b64}"} class TotpVerifyRequest(BaseModel): code: str @router.post("/2fa/confirm") async def totp_confirm(body: TotpVerifyRequest, request: Request): """Verify a TOTP code to confirm 2FA setup. Returns backup codes.""" user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") if not auth_manager.totp_confirm_enable(user, body.code): raise HTTPException(400, "Invalid code — try again") backup = auth_manager.users.get(user, {}).get("totp_backup_codes", []) return {"ok": True, "backup_codes": backup} class TotpDisableRequest(BaseModel): password: str @router.post("/2fa/disable") async def totp_disable(body: TotpDisableRequest, request: Request): """Disable 2FA. Requires password confirmation.""" user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") if not auth_manager.totp_disable(user, body.password): raise HTTPException(400, "Invalid password") return {"ok": True} @router.get("/2fa/status") async def totp_status(request: Request): """Check if 2FA is enabled for the current user.""" user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") return {"enabled": auth_manager.totp_enabled(user)} # Admin-only routes @router.get("/users") async def list_users(request: Request): user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") return {"users": auth_manager.list_users()} @router.post("/users") async def admin_create_user(body: CreateUserRequest, request: Request): user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") if len(body.password) < 8: raise HTTPException(400, "Password must be at least 8 characters") ok = auth_manager.create_user(body.username, body.password, body.is_admin) if not ok: raise HTTPException(409, "Username already taken") return {"ok": True} @router.put("/users/{username}/privileges") async def update_user_privileges(username: str, request: Request): user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") body = await request.json() ok = auth_manager.set_privileges(username, body) if not ok: raise HTTPException(404, "User not found or is admin") return {"ok": True, "privileges": auth_manager.get_privileges(username)} @router.post("/signup-toggle") async def toggle_signup(request: Request): """Toggle open registration on/off. Admin only.""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") auth_manager.signup_enabled = not auth_manager.signup_enabled return {"ok": True, "signup_enabled": auth_manager.signup_enabled} @router.delete("/users") async def admin_delete_user(body: DeleteUserRequest, request: Request): user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") ok = auth_manager.delete_user(body.username, user) if not ok: raise HTTPException(400, "Cannot delete user") return {"ok": True} # ---- Feature visibility (admin-managed) ---- @router.get("/features") async def get_features(): """Public: returns which UI features are enabled.""" return _load_features() @router.post("/features") async def set_features(request: Request): """Admin only: update feature toggles.""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") body = await request.json() current = _load_features() for key in current: if key in body and isinstance(body[key], bool): current[key] = body[key] _save_features(current) return current # ---- App settings (admin-managed) ---- _SECRET_KEY_PATTERNS = ("_api_key", "_password", "_secret", "_token", "_key") def _is_secret_key(name: str) -> bool: n = (name or "").lower() if n in ("google_pse_cx",): # public identifier, not a secret return False return any(n.endswith(p) or n == p.lstrip("_") for p in _SECRET_KEY_PATTERNS) def _scrub_settings(settings: dict) -> dict: """Return a copy of settings with secret-shaped values masked. Frontend reads /settings without auth for things like keybinds + TTS prefs. Secrets (search-provider keys, IMAP/SMTP passwords) must NOT be exposed to non-admin callers. """ scrubbed = {} for k, v in (settings or {}).items(): if _is_secret_key(k) and isinstance(v, str) and v: scrubbed[k] = "" # presence preserved, value blanked else: scrubbed[k] = v return scrubbed @router.get("/settings") async def get_settings(request: Request): """Returns app settings. Admins get the full set; non-admins get a scrubbed copy with secret keys blanked. The frontend uses this for keybinds + TTS prefs, so it stays callable without admin.""" user = _get_current_user(request) settings = _load_settings() if user and auth_manager.is_admin(user): return settings return _scrub_settings(settings) @router.post("/settings") async def set_settings(request: Request): """Admin only: update app settings.""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") body = await request.json() current = _load_settings() for key in DEFAULT_SETTINGS: if key in body: current[key] = body[key] _save_settings(current) return current # ---- Integrations CRUD ---- # Run migration on startup migrate_from_settings() @router.get("/integrations") async def list_integrations_route(request: Request): """List all integrations (admin only, keys masked).""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") items = load_integrations() # Mask API keys for frontend display safe = [] for item in items: copy = dict(item) if copy.get("api_key"): copy["api_key"] = copy["api_key"][:4] + "****" safe.append(copy) return {"integrations": safe} @router.get("/integrations/presets") async def list_presets(): """List available integration presets.""" return {"presets": {k: {kk: vv for kk, vv in v.items() if kk != "api_key"} for k, v in INTEGRATION_PRESETS.items()}} @router.post("/integrations") async def create_integration(request: Request): """Create a new integration (admin only).""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") body = await request.json() item = add_integration(body) return {"ok": True, "integration": item} @router.put("/integrations/{integration_id}") async def update_integration_route(integration_id: str, request: Request): """Update an existing integration (admin only).""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") body = await request.json() item = update_integration(integration_id, body) if not item: raise HTTPException(404, "Integration not found") return {"ok": True, "integration": item} @router.delete("/integrations/{integration_id}") async def delete_integration_route(integration_id: str, request: Request): """Delete an integration (admin only).""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") ok = delete_integration(integration_id) if not ok: raise HTTPException(404, "Integration not found") return {"ok": True} @router.post("/integrations/{integration_id}/test") async def test_integration_route(integration_id: str, request: Request): """Test connectivity to an integration (admin only).""" user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") integ = get_integration(integration_id) if not integ: raise HTTPException(404, "Integration not found") preset = (integ.get("preset") or integ.get("name", "")).lower() # ntfy is special: a GET / proves the server is reachable but # publishes nothing, so the user has no way to know whether # subscribers will actually receive notifications. Instead, do # the real thing — POST a one-line "connectivity test" message # to the topic the Reminders panel is configured to use. If the # subscriber app is wired up correctly, this is what the green # checkmark + a phone ping confirms together. if preset == "ntfy": import httpx from urllib.parse import urlparse # Strip any path/query the user accidentally pasted in the # base URL (e.g. `http://host:8091/odysseus`) — otherwise # the topic gets appended after the path and we publish to # `/odysseus/odysseus` (which ntfy 404s on). ntfy itself # only ever serves from the root. raw_base = (integ.get("base_url") or "").strip() parsed = urlparse(raw_base) base = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else raw_base.rstrip("/") settings = _load_settings() topic = (settings.get("reminder_ntfy_topic") or "reminders").strip() or "reminders" full_url = f"{base}/{topic}" api_key = integ.get("api_key", "") auth_type = (integ.get("auth_type") or "none").lower() headers = { "Title": "Odysseus connectivity test", "Tags": "white_check_mark", "Priority": "default", } if api_key: if auth_type == "bearer": headers["Authorization"] = f"Bearer {api_key}" elif auth_type == "header": headers[integ.get("auth_header") or "Authorization"] = api_key try: async with httpx.AsyncClient(timeout=8.0) as client: r = await client.post( full_url, content="Connectivity test from Odysseus. If you see this on your phone, ntfy is wired up correctly.", headers=headers, ) if r.is_success: # Tell the user EXACTLY where it went and what to # subscribe to on their phone, so they can match # without guesswork. The doubled-topic / wrong-host # mistakes are easier to spot when the actual URL # is right there in the success line. return { "ok": True, "message": ( f"Sent to {full_url} — on your ntfy app, " f"subscribe to topic \"{topic}\" with server " f"\"{base}\" (or paste the full URL: {full_url})." ), } return {"ok": False, "message": f"ntfy returned HTTP {r.status_code} from {full_url}: {r.text[:200]}"} except Exception as e: return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}"[:300]} # All other presets: GET against a known health endpoint. # Fall back to detecting from name if preset is missing. health_paths = { "miniflux": "/v1/me", "gitea": "/api/v1/version", "linkding": "/api/tags/", "homeassistant": "/api/", "home assistant": "/api/", } path = health_paths.get(preset, "/") result = await execute_api_call(integration_id, "GET", path) if result.get("exit_code", 1) == 0: return {"ok": True, "message": "Connection successful"} return {"ok": False, "message": (result.get("error") or "Connection failed")[:300]} return router