from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from hashlib import sha256 from hmac import compare_digest from secrets import token_urlsafe from typing import Literal from uuid import uuid4 from argon2 import PasswordHasher as Argon2PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError UserRole = Literal["viewer", "operator", "admin"] USER_SESSION_COOKIE = "amcp_session" USER_CSRF_COOKIE = "amcp_csrf" USERS_ADMIN_SCOPE = "users:admin" VIEWER_SCOPES = frozenset({"tasks:read", "pool:read", "plugins:read"}) OPERATOR_SCOPES = frozenset({*VIEWER_SCOPES, "tasks:submit"}) ROLE_SCOPES: dict[UserRole, frozenset[str]] = { "viewer": VIEWER_SCOPES, "operator": OPERATOR_SCOPES, "admin": frozenset({"*"}), } class UserAuthenticationError(PermissionError): """A deliberately non-specific user-authentication failure.""" class UserValidationError(ValueError): pass class UsernameConflictError(UserValidationError): pass class LastAdministratorError(UserValidationError): pass @dataclass(frozen=True) class UserAccount: id: str username: str username_normalized: str display_name: str role: UserRole enabled: bool must_change_password: bool authentication_version: int created_at: datetime updated_at: datetime last_login_at: datetime | None = None password_hash: str = field(repr=False, default="") @property def scopes(self) -> frozenset[str]: return ROLE_SCOPES[self.role] @dataclass(frozen=True) class UserSession: id: str user_id: str token_digest: str = field(repr=False) csrf_digest: str = field(repr=False) authentication_version: int = 1 issued_at: datetime = field(default_factory=lambda: datetime.now(UTC)) last_seen_at: datetime = field(default_factory=lambda: datetime.now(UTC)) idle_expires_at: datetime = field(default_factory=lambda: datetime.now(UTC)) absolute_expires_at: datetime = field(default_factory=lambda: datetime.now(UTC)) revoked_at: datetime | None = None @dataclass(frozen=True) class AuthenticatedUserSession: user: UserAccount session: UserSession @dataclass(frozen=True) class LoginThrottle: username_normalized: str client_bucket: str failure_count: int window_started_at: datetime last_attempt_at: datetime blocked_until: datetime | None = None @dataclass(frozen=True) class AuthAuditEvent: id: str occurred_at: datetime actor_principal_id: str | None target_user_id: str | None action: str outcome: str correlation_id: str | None metadata: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) class UserAuthSettings: session_idle_ttl: timedelta = timedelta(hours=8) session_absolute_ttl: timedelta = timedelta(days=7) login_failure_limit: int = 5 login_failure_window: timedelta = timedelta(minutes=15) login_block_duration: timedelta = timedelta(minutes=15) cookie_secure: bool = False @dataclass(frozen=True) class LoginResult: user: UserAccount session_token: str = field(repr=False) csrf_token: str = field(repr=False) class PasswordHasher: """Argon2id password hashing behind a testable small interface.""" def __init__(self) -> None: self._hasher = Argon2PasswordHasher() self._dummy_hash = self._hasher.hash("not-a-valid-user-password") def validate(self, password: str) -> None: if len(password) < 12: raise UserValidationError("password must be at least 12 characters") if len(password) > 256: raise UserValidationError("password must be at most 256 characters") def hash(self, password: str) -> str: self.validate(password) return self._hasher.hash(password) def verify(self, password_hash: str, password: str) -> bool: try: return self._hasher.verify(password_hash, password) except (InvalidHashError, VerificationError): return False def verify_dummy(self, password: str) -> None: self.verify(self._dummy_hash, password) def needs_rehash(self, password_hash: str) -> bool: try: return self._hasher.check_needs_rehash(password_hash) except InvalidHashError: return False def normalize_username(username: str) -> str: normalized = username.strip().casefold() if not 3 <= len(normalized) <= 64: raise UserValidationError("username must contain 3 to 64 characters") if not all(char.isalnum() or char in {".", "_", "-"} for char in normalized): raise UserValidationError("username contains unsupported characters") return normalized def validate_display_name(display_name: str) -> str: value = display_name.strip() if not 1 <= len(value) <= 120: raise UserValidationError("display name must contain 1 to 120 characters") return value def validate_role(role: str) -> UserRole: if role not in ROLE_SCOPES: raise UserValidationError("role must be viewer, operator, or admin") return role # type: ignore[return-value] def digest_secret(value: str) -> str: return sha256(value.encode("utf-8")).hexdigest() def generate_secret() -> str: return token_urlsafe(32) def new_user_id() -> str: return f"user-{uuid4()}" def new_session_id() -> str: return f"session-{uuid4()}" def new_audit_event_id() -> str: return f"audit-{uuid4()}" def utc_now() -> datetime: return datetime.now(UTC) def csrf_matches( *, csrf_cookie: str | None, csrf_header: str | None, session: UserSession ) -> bool: if not csrf_cookie or not csrf_header: return False if not compare_digest(csrf_cookie, csrf_header): return False return compare_digest(digest_secret(csrf_header), session.csrf_digest) class UserAuthService: """Application service for passwords, sessions, throttling, and audit state.""" def __init__( self, repository: object, *, settings: UserAuthSettings, password_hasher: PasswordHasher | None = None, ) -> None: self.repository = repository self.settings = settings self.password_hasher = password_hasher or PasswordHasher() def create_user( self, *, username: str, display_name: str, role: str, password: str, must_change_password: bool = True, now: datetime | None = None, ) -> UserAccount: now = now or utc_now() normalized = normalize_username(username) account = UserAccount( id=new_user_id(), username=username.strip(), username_normalized=normalized, display_name=validate_display_name(display_name), role=validate_role(role), enabled=True, must_change_password=must_change_password, authentication_version=1, created_at=now, updated_at=now, password_hash=self.password_hasher.hash(password), ) return self.repository.create_user(account) # type: ignore[attr-defined,no-any-return] def login( self, *, username: str, password: str, client_bucket: str, correlation_id: str | None = None, now: datetime | None = None, ) -> LoginResult: now = now or utc_now() self.repository.cleanup_auth_state(now=now, limit=100) # type: ignore[attr-defined] try: normalized = normalize_username(username) except UserValidationError: self.password_hasher.verify_dummy(password) raise UserAuthenticationError("invalid username or password") from None throttle = self.repository.get_login_throttle( # type: ignore[attr-defined] normalized, client_bucket, ) if ( throttle is not None and throttle.blocked_until and throttle.blocked_until > now ): self.password_hasher.verify_dummy(password) self._audit( action="login", outcome="throttled", correlation_id=correlation_id, metadata={"client_bucket": client_bucket}, now=now, ) raise UserAuthenticationError("invalid username or password") account = self.repository.get_user_by_normalized_username(normalized) # type: ignore[attr-defined] if account is None: self.password_hasher.verify_dummy(password) self._record_failed_login(normalized, client_bucket, correlation_id, now) raise UserAuthenticationError("invalid username or password") valid_password = self.password_hasher.verify(account.password_hash, password) if not account.enabled or not valid_password: self._record_failed_login( normalized, client_bucket, correlation_id, now, target_user_id=account.id, ) raise UserAuthenticationError("invalid username or password") if self.password_hasher.needs_rehash(account.password_hash): account = self.repository.rehash_user_password( # type: ignore[attr-defined] account.id, password_hash=self.password_hasher.hash(password), updated_at=now, ) self.repository.clear_login_throttle(normalized, client_bucket) # type: ignore[attr-defined] session_token = generate_secret() csrf_token = generate_secret() session = UserSession( id=new_session_id(), user_id=account.id, token_digest=digest_secret(session_token), csrf_digest=digest_secret(csrf_token), authentication_version=account.authentication_version, issued_at=now, last_seen_at=now, idle_expires_at=now + self.settings.session_idle_ttl, absolute_expires_at=now + self.settings.session_absolute_ttl, ) self.repository.create_user_session(session) # type: ignore[attr-defined] account = self.repository.mark_user_login(account.id, now=now) # type: ignore[attr-defined] self._audit( action="login", outcome="success", actor_principal_id=f"user:{account.id}", target_user_id=account.id, correlation_id=correlation_id, metadata={"client_bucket": client_bucket}, now=now, ) return LoginResult(account, session_token, csrf_token) def authenticate_session( self, session_token: str, *, now: datetime | None = None, ) -> AuthenticatedUserSession | None: now = now or utc_now() authenticated = self.repository.get_authenticated_user_session( # type: ignore[attr-defined] digest_secret(session_token), now=now, ) if authenticated is None: return None session = authenticated.session if session.idle_expires_at <= now or session.absolute_expires_at <= now: self.repository.revoke_user_session(session.id, revoked_at=now) # type: ignore[attr-defined] return None if session.last_seen_at + timedelta(minutes=5) <= now: session = self.repository.touch_user_session( # type: ignore[attr-defined] session.id, last_seen_at=now, idle_expires_at=min( now + self.settings.session_idle_ttl, session.absolute_expires_at, ), ) authenticated = AuthenticatedUserSession(authenticated.user, session) return authenticated def change_password( self, *, user_id: str, current_password: str, new_password: str, correlation_id: str | None = None, now: datetime | None = None, ) -> UserAccount: now = now or utc_now() account = self.repository.get_user(user_id) # type: ignore[attr-defined] if account is None or not self.password_hasher.verify( account.password_hash, current_password, ): raise UserAuthenticationError("invalid username or password") updated = self.repository.update_user_password( # type: ignore[attr-defined] user_id, password_hash=self.password_hasher.hash(new_password), must_change_password=False, updated_at=now, revoke_sessions=True, ) self._audit( action="password_change", outcome="success", actor_principal_id=f"user:{user_id}", target_user_id=user_id, correlation_id=correlation_id, now=now, ) return updated def logout( self, *, session_id: str, user_id: str, correlation_id: str | None = None, now: datetime | None = None, ) -> None: now = now or utc_now() self.repository.revoke_user_session(session_id, revoked_at=now) # type: ignore[attr-defined] self._audit( action="logout", outcome="success", actor_principal_id=f"user:{user_id}", target_user_id=user_id, correlation_id=correlation_id, now=now, ) def validate_csrf( self, *, session_token: str | None, csrf_cookie: str | None, csrf_header: str | None, ) -> bool: if session_token is None: return False authenticated = self.authenticate_session(session_token) return authenticated is not None and csrf_matches( csrf_cookie=csrf_cookie, csrf_header=csrf_header, session=authenticated.session, ) def record_admin_action( self, *, actor_principal_id: str, target_user_id: str, action: str, correlation_id: str | None = None, metadata: dict[str, str] | None = None, now: datetime | None = None, ) -> None: self._audit( action=action, outcome="success", actor_principal_id=actor_principal_id, target_user_id=target_user_id, correlation_id=correlation_id, metadata=metadata, now=now or utc_now(), ) def reset_password( self, *, user_id: str, new_password: str, actor_principal_id: str, correlation_id: str | None = None, now: datetime | None = None, ) -> UserAccount: now = now or utc_now() updated = self.repository.update_user_password( # type: ignore[attr-defined] user_id, password_hash=self.password_hasher.hash(new_password), must_change_password=True, updated_at=now, revoke_sessions=True, ) self._audit( action="password_reset", outcome="success", actor_principal_id=actor_principal_id, target_user_id=user_id, correlation_id=correlation_id, now=now, ) return updated def _record_failed_login( self, normalized: str, client_bucket: str, correlation_id: str | None, now: datetime, *, target_user_id: str | None = None, ) -> None: self.repository.record_login_failure( # type: ignore[attr-defined] username_normalized=normalized, client_bucket=client_bucket, now=now, failure_limit=self.settings.login_failure_limit, failure_window=self.settings.login_failure_window, block_duration=self.settings.login_block_duration, ) self._audit( action="login", outcome="failed", target_user_id=target_user_id, correlation_id=correlation_id, metadata={"client_bucket": client_bucket}, now=now, ) def _audit( self, *, action: str, outcome: str, now: datetime, actor_principal_id: str | None = None, target_user_id: str | None = None, correlation_id: str | None = None, metadata: dict[str, str] | None = None, ) -> None: self.repository.record_auth_audit( # type: ignore[attr-defined] AuthAuditEvent( id=new_audit_event_id(), occurred_at=now, actor_principal_id=actor_principal_id, target_user_id=target_user_id, action=action, outcome=outcome, correlation_id=correlation_id, metadata=metadata or {}, ) )