"""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)