bifroest/app/identity/service.py
2026-10-27 08:34:17 +01:00

124 lines
5.7 KiB
Python

"""Anmeldung und Sitzungen.
Identity-Provider-Adapter (→ Konzept 5.1): Ein Provider prüft die Anmeldung und
liefert einen Nutzer. Danach ist der Weg für alle Provider gleich.
Übergangslösung bis Keycloak: eigenes Passwort (Argon2id). Nutzer ohne eigenes
Passwort können sich nur anmelden, solange AIW_DEMO_PASSWORD gesetzt ist.
"""
from datetime import timedelta
from typing import Protocol
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app import audit
from app.core.config import Settings
from app.core.database import now
from app.core.errors import Invalid
from app.core.security import new_token, same, token_hash
from app.identity import Principal, passwords
from app.identity.models import TeamMembership, User, WebSession
class IdentityProvider(Protocol):
def authenticate(self, user: User | None, secret: str) -> bool: ...
class PasswordIdentityProvider:
"""Eigenes Passwort; ersatzweise das gemeinsame Demo-Passwort für Nutzer ohne eigenes."""
def __init__(self, demo_password: str):
self.demo_password = demo_password
def authenticate(self, user: User | None, secret: str) -> bool:
if user is not None and user.active and user.password_hash:
return passwords.verify(user.password_hash, secret)
if user is not None and user.active and self.demo_password:
return same(secret, self.demo_password)
passwords.verify(None, secret) # gleiche Antwortzeit wie bei bekannten Nutzern
return False
def provider(settings: Settings) -> IdentityProvider:
return PasswordIdentityProvider(settings.demo_password)
def login(session: Session, settings: Settings, username: str, secret: str) -> str | None:
"""Gibt das Sitzungstoken für das Cookie zurück, oder None bei falscher Anmeldung.
Die Meldung nach außen ist immer dieselbe, auch bei Sperre oder unbekanntem Nutzer.
"""
user = session.scalar(select(User).where(User.username == username.strip().lower()))
if user is not None and user.locked_until and user.locked_until > now():
audit.record(session, "auth.locked_attempt", user_id=user.id)
return None
if not provider(settings).authenticate(user, secret):
if user is not None:
user.failed_logins += 1
if user.failed_logins >= settings.login_max_failures:
user.locked_until = now() + timedelta(minutes=settings.login_lock_minutes)
user.failed_logins = 0
audit.record(session, "auth.locked", user_id=user.id)
audit.record(session, "auth.failed", username=username[:64])
return None
user.failed_logins, user.locked_until = 0, None
if user.password_hash and passwords.needs_rehash(user.password_hash):
user.password_hash = passwords.hash_password(secret)
token = new_token()
session.add(WebSession(id=token_hash(token), user_id=user.id, csrf=new_token(),
expires_at=now() + timedelta(hours=settings.session_hours)))
audit.record(session, "auth.login", user_id=user.id)
return token
def resolve(session: Session, token: str) -> Principal | None:
if not token:
return None
web_session = session.get(WebSession, token_hash(token))
if web_session is None or web_session.revoked_at or web_session.expires_at <= now():
return None
return principal_for(session, web_session.user_id, web_session)
def principal_for(session: Session, user_id: int | None, web_session: WebSession | None = None) -> Principal | None:
"""Aktueller Stand eines Nutzers; ohne Sitzung z. B. für erneute Prüfungen im Worker."""
user = session.get(User, user_id) if user_id else None
if user is None or not user.active:
return None
teams = session.scalars(select(TeamMembership.team_id).where(TeamMembership.user_id == user.id))
return Principal(user_id=user.id, username=user.username, display_name=user.display_name,
is_admin=user.is_admin, team_ids=frozenset(teams),
must_change_password=user.must_change_password,
session_id=web_session.id if web_session else "", csrf=web_session.csrf if web_session else "")
def logout(session: Session, principal: Principal) -> None:
web_session = session.get(WebSession, principal.session_id)
if web_session and not web_session.revoked_at:
web_session.revoked_at = now()
audit.record(session, "auth.logout", user_id=principal.user_id)
def revoke_sessions(session: Session, user_id: int, keep: str = "") -> None:
session.execute(update(WebSession)
.where(WebSession.user_id == user_id, WebSession.revoked_at.is_(None), WebSession.id != keep)
.values(revoked_at=now()))
def change_password(session: Session, principal: Principal, current: str, new: str, repeat: str) -> None:
"""Eigenes Passwort ändern; beendet alle anderen Sitzungen des Nutzers."""
user = session.get(User, principal.user_id)
valid = passwords.verify(user.password_hash, current) if user.password_hash else False
if not valid:
audit.record(session, "auth.password_change_failed", user_id=user.id)
raise Invalid("Das aktuelle Passwort stimmt nicht.")
if new != repeat:
raise Invalid("Die beiden neuen Passwörter stimmen nicht überein.")
if same(new, current):
raise Invalid("Das neue Passwort muss sich vom bisherigen unterscheiden.")
passwords.check_policy(new, user.username)
user.password_hash, user.must_change_password, user.password_changed = passwords.hash_password(new), False, now()
revoke_sessions(session, user.id, keep=principal.session_id)
audit.record(session, "auth.password_changed", user_id=user.id)