528 lines
17 KiB
Python
528 lines
17 KiB
Python
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 {},
|
|
)
|
|
)
|