feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
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 {},
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user