Files
agentic-mobile-control/packages/cloud-platform/cloud/user_auth.py
T
q792602257andClaude Opus 4.6 9afbdc91fa
Tests / Test failed: 4, passed: 744
style(cloud): reformat modules and restore except-tuple parentheses
Apply consistent line-length formatting across governance, plugins, the
SDK routers (governance_api, user_api), user_auth, and migrations
0003/0005/0006.

Also restore the parentheses on two except clauses that had been dropped
into invalid Python 3 `except A, B:` syntax: plugins._coerce_to_manifest
(KeyError, ValueError) and PasswordHasher.verify (InvalidHashError,
VerificationError). Both modules now import cleanly again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:30:13 +08:00

534 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 {},
)
)