feat(auth): Passwort-Login (1FA) für die App; Admin ebenfalls 1FA
Variante 1: Senioren behalten ihren persönlichen Link; zusätzlich ein Passwort-Einstieg ohne 2FA über Authelia. - Neuer Endpunkt GET /api/login (app_login): hinter Authelia (nginx-Location /login, one_factor), erkennt den per Remote-User weitergereichten Nutzer (Admin ODER normaler Authelia-Nutzer), mintet das va_token-Cookie und leitet in die App (/). Cookie-Mint in _issue_login_cookie() gemeinsam mit /api/admin/login refaktoriert. - "Persönlicher Zugang nötig"-Seite (main.py) bekommt einen großen Knopf „Mit Benutzername & Passwort anmelden" -> /login. Bare / leitet bewusst NICHT automatisch um (Senioren ohne Authelia-Konto würden sonst feststecken). Begleitende Infra (außerhalb des Repos): - nginx: location = /login (auth_request one_factor -> /api/login). - Authelia: access_control-Regel voice.jamulix.de -> one_factor (über der *.jamulix.de-Wildcard); damit sind App-Login UND Admin nur noch 1FA, andere Subdomains bleiben 2FA. Tests: /api/login für Nicht-Admin/Admin/ohne-Identität. 257 Offline-Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
904f7db82c
commit
6792e1be86
3 changed files with 63 additions and 21 deletions
|
|
@ -66,34 +66,50 @@ def _current_user_or_none(request: Request):
|
|||
return authenticate(request.headers, client_host, token)
|
||||
|
||||
|
||||
@router.get("/admin/login")
|
||||
async def admin_app_login(request: Request):
|
||||
"""Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink.
|
||||
|
||||
Liegt hinter Authelia (nginx: Location /api/admin/ bzw. /admin-login mit
|
||||
auth_request + error_page-Redirect). Erkennt den SSO-Admin via Remote-User,
|
||||
mintet ein frisches Capability-Token, setzt es als va_token-Cookie und leitet
|
||||
in die App (/). Damit meldet sich der Admin allein mit seinem Authelia-Passwort
|
||||
an, ohne persoenlichen Link.
|
||||
|
||||
Jeder Aufruf rotiert das Token (alte Links DIESES Admin-Nutzers werden
|
||||
ungueltig) — fuer eine Recovery-Funktion korrekt und ein Sicherheitsplus:
|
||||
jede SSO-Anmeldung erzeugt eine frische Capability.
|
||||
"""
|
||||
user = _current_user_or_none(request)
|
||||
if not is_admin_user(user):
|
||||
raise HTTPException(status_code=403, detail="Admin privileges required")
|
||||
def _issue_login_cookie(request: Request, user, action: str):
|
||||
"""Mintet ein frisches Capability-Token fuer `user`, setzt es als va_token-Cookie
|
||||
und leitet in die App (/). Gemeinsame Basis fuer /api/login und /api/admin/login.
|
||||
Jeder Aufruf rotiert das Token (ein Token pro Nutzer)."""
|
||||
result = get_store().reset_token(user.id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Admin-Nutzer nicht gefunden.")
|
||||
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden.")
|
||||
_, raw_token = result
|
||||
log_admin_action(request, "admin_app_login", user_id=user.id)
|
||||
log_admin_action(request, action, user_id=user.id)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(CAPABILITY_COOKIE, raw_token, httponly=True, secure=True,
|
||||
samesite="lax", max_age=31536000)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def app_login(request: Request):
|
||||
"""Anmeldung in die App mit Benutzername/Passwort (1FA) statt persoenlichem Link.
|
||||
|
||||
Liegt hinter Authelia (nginx: Location /login, one_factor). Erkennt den per
|
||||
Remote-User weitergereichten Authelia-Nutzer (Admin ODER normaler Nutzer),
|
||||
mintet das va_token-Cookie und leitet in die App. Senioren nutzen weiter ihren
|
||||
Link; dieser Weg ist der Passwort-Einstieg fuer Betreuer/Admins.
|
||||
"""
|
||||
user = _current_user_or_none(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=403, detail="Authentication required")
|
||||
return _issue_login_cookie(request, user, "app_login")
|
||||
|
||||
|
||||
@router.get("/admin/login")
|
||||
async def admin_app_login(request: Request):
|
||||
"""Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink.
|
||||
|
||||
Wie /api/login, aber zusaetzlich auf Admins beschraenkt (ADMIN_USERS). Liegt
|
||||
hinter Authelia (nginx: Location /admin-login). Mintet das va_token-Cookie und
|
||||
leitet in die App (/).
|
||||
"""
|
||||
user = _current_user_or_none(request)
|
||||
if not is_admin_user(user):
|
||||
raise HTTPException(status_code=403, detail="Admin privileges required")
|
||||
return _issue_login_cookie(request, user, "admin_app_login")
|
||||
|
||||
|
||||
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||
async def create_user(payload: UserCreate):
|
||||
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||
|
|
|
|||
|
|
@ -75,11 +75,14 @@ _NO_ACCESS_PAGE = """<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|||
<style>body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;color:#1e293b;
|
||||
display:grid;place-items:center;min-height:100vh;margin:0;padding:1.5rem}
|
||||
.box{max-width:30rem;text-align:center}h1{font-size:1.3rem;margin:0 0 .75rem}
|
||||
p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}</style></head>
|
||||
p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}
|
||||
.btn{display:inline-block;margin-top:1.25rem;background:#2563eb;color:#fff;text-decoration:none;
|
||||
font-weight:600;font-size:1.05rem;padding:.8rem 1.4rem;border-radius:.75rem}</style></head>
|
||||
<body><div class="box"><div class="emoji">🔒</div>
|
||||
<h1>Persönlicher Zugang nötig</h1>
|
||||
<p>Bitte öffne die App über deinen persönlichen Zugangslink.</p>
|
||||
<p>Wenn du keinen Link hast, wende dich an deine Betreuungsperson.</p>
|
||||
<p>Senioren: bitte die App über den persönlichen Zugangslink öffnen.</p>
|
||||
<p>Betreuer und Admins können sich mit Benutzername und Passwort anmelden:</p>
|
||||
<a class="btn" href="/login">Mit Benutzername & Passwort anmelden</a>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,26 @@ def test_admin_login_rejects_without_identity(forward_auth):
|
|||
# (In Produktion faengt nginx das vorher mit dem Authelia-Redirect ab.)
|
||||
r = client.get("/api/admin/login", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# --- /api/login (1FA-Passwort-Einstieg, auch fuer Nicht-Admins) ---
|
||||
|
||||
def test_app_login_non_admin_gets_cookie(forward_auth):
|
||||
# Normaler Authelia-Nutzer (kein Admin) bekommt trotzdem ein gueltiges Cookie.
|
||||
r = client.get("/api/login", headers={HEADER: "lieschen"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/"
|
||||
raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0]
|
||||
user = deps.get_store().get_user_by_token(raw)
|
||||
assert user is not None and user.external_id == "lieschen"
|
||||
|
||||
|
||||
def test_app_login_admin_also_works(forward_auth):
|
||||
r = client.get("/api/login", headers={HEADER: "dschlueter"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert f"{CAPABILITY_COOKIE}=" in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_app_login_rejects_without_identity(forward_auth):
|
||||
r = client.get("/api/login", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue