soulsync/core/security/login_gate.py
BoulderBadgeDad 92cbef90f9 Native login (increment 2/3): login/logout endpoints + require_login gate
The backend auth for opt-in username/password mode (security.require_login, default
off → zero change; the launch PIN + picker behave exactly as today).

- core/security/login_gate.py: pure gate (mirrors launch_lock) — when login mode is
  on, an unauthenticated session reaches only the page shell, /api/auth/login,
  /api/auth/logout, /api/profiles/current, /api/setup/status, and the key-authed
  /api/v1 API. Deliberately does NOT expose the profile list pre-auth (you type your
  name, not pick from a roster).
- _enforce_login before_request enforces it; _enforce_launch_pin no-ops when login
  mode is on (login replaces the shared PIN, per design).
- POST /api/auth/login (username = profile name, case-insensitive; brute-force
  limited per IP; generic error so names don't leak) + POST /api/auth/logout.
- Anti-lockout: the settings save refuses to turn ON login mode until the admin
  account has a password.

Tests: gate blocks→login→access→logout→blocked; case-insensitive username; wrong
password / passwordless profile / unknown user all 401 generically; login list not
exposed pre-auth; can't enable login without an admin password. 12 tests pass. Next:
the login screen + set-password UI + the toggle (increment 3).
2026-06-10 22:01:53 -07:00

53 lines
1.8 KiB
Python

"""Pure gate decision for opt-in username/password login mode.
When ``security.require_login`` is on, every request must come from an
authenticated session; unauthenticated requests are blocked except the page shell,
the login/logout flow, and the key-authed public API. This is the per-user
equivalent of (and replacement for) the shared launch-PIN gate.
Deliberately does NOT allowlist the profile LIST or picker — in login mode you log
in by typing your name + password, you don't pick from an exposed roster.
"""
from __future__ import annotations
# GET endpoints the login screen itself needs before auth.
_ALLOWED_GET = frozenset({
'/api/profiles/current', # how the frontend detects login state
'/api/setup/status', # first-run check runs before the login screen
})
# POST endpoints that drive the login flow.
_ALLOWED_POST = frozenset({
'/api/auth/login',
'/api/auth/logout',
})
def login_request_is_blocked(path: str, method: str, *,
require_login: bool, authenticated: bool) -> bool:
"""True when the login gate must reject this request (login mode on + the
session isn't authenticated and the path isn't part of the login flow)."""
if not require_login or authenticated:
return False
path = path or ''
method = (method or 'GET').upper()
# Page shell + assets needed to render the login screen.
if path == '/' or path.startswith('/static/') or path.startswith('/favicon'):
return False
# Key-authed public API governs itself (its own key auth).
if path.startswith('/api/v1/') and not path.startswith('/api/v1/api-keys-internal'):
return False
if method == 'GET' and path in _ALLOWED_GET:
return False
if method == 'POST' and path in _ALLOWED_POST:
return False
return True
__all__ = ['login_request_is_blocked']