124 lines
5.7 KiB
Python
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)
|