import logging import os import secrets from datetime import datetime, timedelta, timezone from typing import Annotated import jwt as _jwt from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials from jwt.exceptions import PyJWTError from sqlalchemy import delete, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.api.routes.settings import get_external_login_url from backend.app.core.auth import ( ALGORITHM, SECRET_KEY, Permission, RequirePermissionIfAuthEnabled, _is_token_fresh, _validate_api_key, apikey_effective_permissions, authenticate_user, authenticate_user_by_email, create_access_token, create_media_token, create_websocket_token, get_current_active_user, get_password_hash, get_user_by_email, get_user_by_username, is_jti_revoked, require_auth_if_enabled, resolve_apikey_owner, resolve_session_max_minutes, revoke_jti, security, ) from backend.app.core.database import async_session, get_db from backend.app.core.oidc_env import env_bool from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType from backend.app.models.group import Group from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.schemas.auth import ( EncryptionRowCounts, EncryptionStatusResponse, ForgotPasswordConfirmRequest, ForgotPasswordRequest, ForgotPasswordResponse, GroupBrief, LDAPProvisionRequest, LDAPSearchResultResponse, LoginRequest, LoginResponse, ResetPasswordRequest, ResetPasswordResponse, SetupRequest, SetupResponse, SMTPSettings, TestSMTPRequest, TestSMTPResponse, UserResponse, _validate_password_complexity, ) from backend.app.services.email_service import ( create_password_reset_link_email_from_template, get_smtp_settings, save_smtp_settings, send_email, ) from backend.app.services.finance_defaults import ensure_user_finance_defaults _logger = logging.getLogger(__name__) def _user_to_response(user: User) -> UserResponse: """Convert a User model to UserResponse schema.""" return UserResponse( id=user.id, username=user.username, email=user.email, role=user.role, is_active=user.is_active, is_admin=user.is_admin, auth_source=getattr(user, "auth_source", "local"), groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups], permissions=sorted(user.get_permissions()), created_at=user.created_at.isoformat(), ) async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse: """Describe a valid API key as the identity it actually carries (#1894). Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``, ``is_admin=True`` and every permission in the enum. That was wrong in both directions. A key cannot perform administrative operations at all -- ``_check_apikey_permissions`` denies every permission that is not in the scope allowlist -- so a client that builds its UI from this response (which is exactly what a native client does) rendered admin actions that 403 on use, and had no way to learn the id its own prints are filed under. Now: identity comes from the key's owner, and ``permissions`` is the set the key can genuinely exercise. ``is_admin`` is always False because no key can reach an administrative route regardless of who owns it. Legacy keys predating per-user ownership (``user_id IS NULL``) have no identity to report, so they keep ``id=0`` and the ``api-key:`` username -- but they stop claiming admin. ``created_at`` describes the credential in both branches, unchanged. """ # Same resolution the permission gate uses, so what is reported here and # what is enforced there cannot drift -- including the 403 when the owner # has been deactivated, which makes the key dead rather than anonymous. owner = await resolve_apikey_owner(db, api_key) return UserResponse( id=owner.id if owner else 0, username=owner.username if owner else f"api-key:{api_key.key_prefix}", # Withheld on purpose: the owner's email is not needed to resolve # identity, and this response is reachable by anyone holding the key. email=None, # Deprecated free-text field; "user" is the existing value meaning # "not an admin". Inventing an "api_key" role here would put a third # value into a field callers compare against string literals. role="user", is_active=True, is_admin=False, auth_source=getattr(owner, "auth_source", "local") if owner else "local", # The key is not a group member -- listing the owner's groups would # imply capabilities the key does not inherit. groups=[], permissions=apikey_effective_permissions(api_key, owner), created_at=api_key.created_at.isoformat(), ) # --------------------------------------------------------------------------- # M-R9-A: Real client IP resolution for rate limiting behind reverse proxies. # Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust. # Without this env var client.host is used directly (safe default). # --------------------------------------------------------------------------- _TRUSTED_PROXY_IPS: frozenset[str] = frozenset( ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip() ) # #1589: read at call time, not import time, so tests can monkeypatch os.environ # between cases without re-importing the module. def _local_login_env_bypass() -> bool: """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy. Bypasses the ``local_login_enabled`` DB setting on the local-credentials code path AND the forgot-password endpoint so a server admin can recover an install whose SSO provider is unreachable. Accepted truthy values: ``true``, ``1``, ``yes`` (case-insensitive). """ # strict=False: this runs on the login/forgot-password request path, not at # startup. An unrecognized value must fall back to "off" (the safe default), # never raise -- a 500 on the recovery endpoint is the opposite of what this # bypass is for. return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False) def _get_client_ip(request: Request) -> str: """Return the real client IP for rate-limiting purposes. When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is NOT itself a trusted proxy is the true client address (M-R10-A fix). Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so the rightmost entry is always the one added by the last trusted proxy — i.e. the real client. Walking right-to-left and skipping known proxies is safe for multi-hop chains as well. Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct deployment without a reverse proxy). """ # I5: Use a per-request unique token instead of "unknown" when the transport # layer provides no client address. This prevents all such requests from # sharing one rate-limit bucket, and avoids collision with a literal username # "unknown". The token is not stable across requests, which is intentional: # we cannot track the IP so we also cannot rate-limit by it meaningfully. direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__" if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS: forwarded_for = request.headers.get("X-Forwarded-For", "") ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()] # Walk right-to-left; skip IPs that belong to trusted proxies. for ip in reversed(ips): if ip not in _TRUSTED_PROXY_IPS: return ip # Edge case: every entry is a trusted proxy — fall back to leftmost. if ips: return ips[0] return direct_ip router = APIRouter(prefix="/auth", tags=["authentication"]) async def is_auth_enabled(db: AsyncSession) -> bool: """Check if authentication is enabled.""" result = await db.execute(select(Settings).where(Settings.key == "auth_enabled")) setting = result.scalar_one_or_none() if setting is None: return False return setting.value.lower() == "true" async def is_advanced_auth_enabled(db: AsyncSession) -> bool: """Check if advanced authentication is enabled.""" result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled")) setting = result.scalar_one_or_none() if setting is None: return False return setting.value.lower() == "true" async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None: """Set advanced authentication enabled status.""" from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "advanced_auth_enabled", "true" if enabled else "false") async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None: """Set authentication enabled status.""" from backend.app.core.auth import invalidate_auth_enabled_cache from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false") # Drop the cached auth-enabled flag so the change takes effect immediately # instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True # is ever cached, and the newly-enabled True isn't visible to other sessions # until this transaction commits, so no stale value can be re-cached here. invalidate_auth_enabled_cache() # Note: Don't commit here - let get_db handle it or commit explicitly in the route async def is_setup_completed(db: AsyncSession) -> bool: """Check if setup has been completed.""" result = await db.execute(select(Settings).where(Settings.key == "setup_completed")) setting = result.scalar_one_or_none() return setting and setting.value.lower() == "true" async def set_setup_completed(db: AsyncSession, completed: bool) -> None: """Set setup completed status.""" from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "setup_completed", "true" if completed else "false") # Note: Don't commit here - let get_db handle it or commit explicitly in the route @router.post("/setup", response_model=SetupResponse) async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)): """First-time setup: enable/disable authentication and create admin user.""" import logging logger = logging.getLogger(__name__) try: # If auth is currently enabled, block unauthenticated setup changes. # Use the admin panel (/disable endpoint) to modify auth when it's already on. if await is_auth_enabled(db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Authentication is already configured. Use the admin panel to modify auth settings.", ) admin_created = False if request.auth_enabled: # Check if admin users already exist admin_users_result = await db.execute(select(User).where(User.role == "admin")) existing_admin_users = list(admin_users_result.scalars().all()) has_admin_users = len(existing_admin_users) > 0 if has_admin_users: # Admin users already exist, just enable auth (don't create new admin) logger.info( f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin" ) admin_created = False else: # No admin users exist, require admin credentials to create first admin if not request.admin_username or not request.admin_password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Admin username and password are required when enabling authentication (no admin users exist)", ) # Enforce password complexity only when actually creating a new admin. # Schema-level validation was removed so that re-enabling auth with an # existing admin (or LDAP) doesn't reject whatever placeholder the form sends. try: _validate_password_complexity(request.admin_password) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) # Check if username already exists (shouldn't happen if no admin users exist, but check anyway) existing_user = await get_user_by_username(db, request.admin_username) if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User with this username already exists", ) # Create admin user FIRST (before enabling auth) try: logger.info("Creating admin user: %s", request.admin_username) admin_user = User( username=request.admin_username, password_hash=get_password_hash(request.admin_password), role="admin", is_active=True, ) # Try to add user to Administrators group if it exists admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators")) admin_group = admin_group_result.scalar_one_or_none() if admin_group: admin_user.groups.append(admin_group) logger.info("Added new admin user to Administrators group") db.add(admin_user) logger.info("Admin user added to session: %s", request.admin_username) admin_created = True except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); no user is created on error await db.rollback() logger.error("Failed to create admin user: %s", e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create admin user", ) if request.auth_enabled: # Enabling auth flips cloud-credential storage from the global # Settings rows to User.cloud_token. Carry any token linked while # auth was off across to the owning admin, or /cloud/* silently # degrades to local presets with no indication anything broke # (#2530). Only migrate when there is exactly one obvious owner: # handing another admin's session a Bambu credential is not a # guess worth making. from backend.app.api.routes.cloud import ( get_stored_token, migrate_global_cloud_token_to_user, ) if admin_created: cloud_owner = admin_user elif len(existing_admin_users) == 1: cloud_owner = existing_admin_users[0] else: cloud_owner = None if cloud_owner is not None: if await migrate_global_cloud_token_to_user(db, cloud_owner): logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username) else: global_token, _, _ = await get_stored_token(db, None) if global_token: logger.warning( "A Bambu Cloud account is linked globally but %s admins exist; " "leaving it unassigned. Re-link the account from Settings after login.", len(existing_admin_users), ) # Set auth enabled and mark setup as completed await set_auth_enabled(db, request.auth_enabled) await set_setup_completed(db, True) await db.commit() if admin_created: await db.refresh(admin_user) logger.info("Admin user created successfully: %s", admin_user.id) logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created) return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created) except HTTPException: raise except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); setup state stays unchanged logger.error("Setup error: %s", e, exc_info=True) await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Setup failed", ) @router.get("/status") async def get_auth_status(db: AsyncSession = Depends(get_db)): """Get authentication status (public endpoint).""" auth_enabled = await is_auth_enabled(db) setup_completed = await is_setup_completed(db) # Only require setup if it hasn't been completed yet requires_setup = not setup_completed return {"auth_enabled": auth_enabled, "requires_setup": requires_setup} @router.post("/disable", response_model=dict) async def disable_auth( current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db), ): """Disable authentication (admin only).""" import logging logger = logging.getLogger(__name__) # Reload user with groups for proper is_admin check result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups))) user = result.scalar_one() # Only admins can disable authentication if not user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can disable authentication", ) try: # Mirror of the migration in setup_auth: with auth off the cloud routes # read the global Settings rows and never look at User.cloud_token, so # hand this admin's credential over rather than stranding it (#2530). from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global if await migrate_user_cloud_token_to_global(db, user): logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username) await set_auth_enabled(db, False) await db.commit() logger.info("Authentication disabled by admin user: %s", user.username) return {"message": "Authentication disabled successfully", "auth_enabled": False} except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); auth_enabled stays at its prior value await db.rollback() logger.error("Failed to disable authentication: %s", e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to disable authentication", ) @router.post("/login", response_model=LoginResponse) async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): """Login and get access token. Supports username or email-based login. Username lookup is case-insensitive. When 2FA is enabled for the user the response contains ``requires_2fa=True`` and a short-lived ``pre_auth_token`` instead of the final JWT. The client must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send`` to trigger an email OTP) to obtain the real access token. """ # Check if auth is enabled auth_enabled = await is_auth_enabled(db) if not auth_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is not enabled", ) # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A): # 1. Per-username (10/15 min): prevents password brute-force on a known account. # 2. Per-IP (20/15 min): prevents an attacker from locking out arbitrary accounts # (DoS) by sending failures for many usernames from a single address. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS) client_ip = _get_client_ip(raw_request) await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20) # Initialize `user` up front so every downstream branch can read/write # it without UnboundLocalError. The LDAP success path sets it inside its # own block; the local-credentials and email-credentials paths set it # below. The original code relied on the local-credentials path running # unconditionally to bind `user`; #1589 made that path skippable, so the # init has to live here. user = None # Check if LDAP is enabled ldap_user = None ldap_settings = await _get_ldap_settings(db) if ldap_settings: try: from backend.app.services.ldap_service import ( authenticate_ldap_user, parse_ldap_config, ) ldap_config = parse_ldap_config(ldap_settings) if ldap_config: ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password) if ldap_user: # LDAP auth succeeded — find or create local user user = await get_user_by_username(db, ldap_user.username) if user and user.auth_source != "ldap": # Username exists as local user — don't override user = None ldap_user = None elif not user: if not ldap_config.auto_provision: # User doesn't exist and auto-provision is off ldap_user = None else: # Auto-provision LDAP user user = await _provision_ldap_user(db, ldap_user, ldap_config) if user and ldap_user: # Update email and group mappings on each login await _sync_ldap_user(db, user, ldap_user, ldap_config) # Keep finance defaults idempotently in sync for LDAP users # (wallet + private cost center + self-membership). await ensure_user_finance_defaults(db, user) except Exception as e: # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant) import logging logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e) ldap_user = None # #1589: local username/password gate. LDAP keeps its own switch # (ldap_enabled) and is not affected — a delegated directory has its # own policy and lockouts and is closer to SSO than to local creds. # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server # admin can recover an install whose SSO provider is unreachable # without editing the DB. from backend.app.models.settings import Settings as _Settings_for_local_login local_login_allowed = ldap_user is not None or _local_login_env_bypass() if not local_login_allowed: setting_row = await db.execute( select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled") ) row = setting_row.scalar_one_or_none() # Default True when the row is absent — matches AppSettings default # so fresh installs and tests behave like every release before #1589. local_login_allowed = row is None or row.value.lower() == "true" # Try username-based authentication (skip if already authenticated via LDAP) if not ldap_user and local_login_allowed: user = await authenticate_user(db, request.username, request.password) # If username auth failed and advanced auth is enabled, try email-based authentication if not user and not ldap_user and local_login_allowed: advanced_auth = await is_advanced_auth_enabled(db) if advanced_auth: user = await authenticate_user_by_email(db, request.username, request.password) if not user: await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT) await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP) # Same generic 401 either way — never tell the client whether the # username exists or whether local login was disabled. The Settings # UI and /auth/advanced-auth/status are the channels for that state; # leaking it here would help credential-stuffing distinguish "local # disabled" from "wrong password" across an install fleet. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) # Reload user with groups for proper permission calculation result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups))) user = result.scalar_one() # L-R6-A: Password was correct — reset login failure counters for both buckets from backend.app.api.routes.mfa import clear_failed_attempts await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT) await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP) # --- 2FA check --- # Determine which 2FA methods are active for this user. from backend.app.models.settings import Settings as _Settings from backend.app.models.user_totp import UserTOTP totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id)) user_totp = totp_result.scalar_one_or_none() totp_enabled = user_totp is not None and user_totp.is_enabled email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled")) email_2fa_setting = email_2fa_result.scalar_one_or_none() email_otp_enabled = ( email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None ) if totp_enabled or email_otp_enabled: # Import here to avoid circular imports from backend.app.api.routes.mfa import create_pre_auth_token # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the # token from JS memory and complete 2FA from a different client. challenge_id = secrets.token_urlsafe(32) pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id) response.set_cookie( key="2fa_challenge", value=challenge_id, httponly=True, # H-1: only transmit over HTTPS so the binding cookie can't be intercepted # on mixed-content deployments. Falls back to False on plain HTTP so tests # and local development still work (the client wouldn't send it otherwise). secure=raw_request.url.scheme == "https", samesite="lax", max_age=300, path="/api/v1/auth/2fa", ) methods: list[str] = [] if totp_enabled: methods.append("totp") if email_otp_enabled: methods.append("email") # Backup codes are always available when TOTP is set up if totp_enabled: methods.append("backup") return LoginResponse( requires_2fa=True, pre_auth_token=pre_auth_token, two_fa_methods=methods, ) # No 2FA — issue full token immediately. Session lifetime honours the # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h]. access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db)) access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires) return LoginResponse( access_token=access_token, token_type="bearer", user=_user_to_response(user), ) @router.post("/ws-token") async def mint_websocket_token( current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT), ): """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up). The WebSocket endpoint cannot read ``Authorization`` headers from browsers (the WebSocket handshake does not let JS attach custom headers), so we use the same opaque-token-in-query-param pattern as ``/camera/stream`` — the token is minted here behind the standard permission gate, then appended as ``?token=`` on the ``ws://...`` URL. The WebSocket endpoint validates it *before* calling ``websocket.accept()``. Returns ``{"token": }``. The token is valid for 60 minutes; the SPA refreshes it on reconnect if expired. API keys can mint tokens too — their scope flags decide whether ``WEBSOCKET_CONNECT`` passes via the standard allowlist (``can_read_status`` covers it). """ username = current_user.username if current_user is not None else None return {"token": await create_websocket_token(username)} @router.post("/media-token") async def mint_media_token( current_user: User | None = Depends(require_auth_if_enabled), ): """Mint a short-lived token for ```` / ``