soulsync/core/security/login_gate.py
BoulderBadgeDad 613688a9ad Login recovery (DB + backend): security question to reset a forgotten password
Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.

- DB: additive recovery_question + recovery_answer_hash columns (idempotent
  migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
  matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
  never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
  GET /api/auth/recovery-question?username= (generic 404 when absent),
  POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
  limited; a correct answer sets the new password + authenticates the session.
  POST /api/profiles/<id>/set-recovery (admin or self) to configure it.

Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
2026-06-10 22:24:54 -07:00

55 lines
2 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
'/api/auth/recovery-question', # forgot-password: fetch the security question
})
# POST endpoints that drive the login flow.
_ALLOWED_POST = frozenset({
'/api/auth/login',
'/api/auth/logout',
'/api/auth/recovery-reset', # forgot-password: answer + set a new password
})
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']