feat(cloud-console): add user authentication and administration

This commit is contained in:
2026-07-13 17:54:53 +08:00
parent 035b177128
commit cdef630e67
35 changed files with 4126 additions and 113 deletions
+35
View File
@@ -15,6 +15,7 @@ TASKS_READ_SCOPE = "tasks:read"
POOL_READ_SCOPE = "pool:read"
PLUGINS_READ_SCOPE = "plugins:read"
PLUGINS_ADMIN_SCOPE = "plugins:admin"
USERS_ADMIN_SCOPE = "users:admin"
@dataclass(frozen=True)
@@ -22,6 +23,8 @@ class Principal:
id: str = "anonymous"
scopes: frozenset[str] = field(default_factory=frozenset)
host_id: str | None = None
session_id: str | None = None
must_change_password: bool = False
def has_scope(self, scope: str) -> bool:
return "*" in self.scopes or scope in self.scopes
@@ -167,6 +170,30 @@ class RepositoryHostAuthProvider:
return Principal(id=f"enrolled-host:{host_id}", host_id=host_id)
class UserSessionAuthProvider:
"""Resolve the opaque browser session cookie to an existing Principal."""
def __init__(self, user_auth_service: object) -> None:
self.user_auth_service = user_auth_service
def authenticate(self, request: object) -> Principal | None:
from cloud.user_auth import USER_SESSION_COOKIE
token = _extract_cookie(request, USER_SESSION_COOKIE)
if token is None:
return None
authenticated = self.user_auth_service.authenticate_session(token) # type: ignore[attr-defined]
if authenticated is None:
return None
user = authenticated.user
return Principal(
id=f"user:{user.id}",
scopes=user.scopes,
session_id=authenticated.session.id,
must_change_password=user.must_change_password,
)
class ChainedAuthProvider:
def __init__(self, providers: Iterable[AuthProvider]) -> None:
self.providers = tuple(providers)
@@ -205,6 +232,14 @@ def _extract_bearer_token(request: object) -> str | None:
return token
def _extract_cookie(request: object, name: str) -> str | None:
cookies = getattr(request, "cookies", None)
if cookies is None:
return None
value = cookies.get(name)
return value if isinstance(value, str) and value else None
def bearer_token_digest(request: object) -> str | None:
token = _extract_bearer_token(request)
return digest_token(token) if token is not None else None
@@ -34,6 +34,13 @@ class CloudControlConfig:
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
cors_allowed_origins: tuple[str, ...] = ()
console_static_dir: str | None = None
user_session_idle_seconds: int = 28_800
user_session_absolute_seconds: int = 604_800
login_failure_limit: int = 5
login_failure_window_seconds: int = 900
login_block_seconds: int = 900
session_cookie_secure: bool = False
trust_proxy_headers: bool = False
def load_control_config(
@@ -98,6 +105,31 @@ def load_control_config(
console_static_dir=_parse_optional_string(
values.get("CLOUD_CONSOLE_STATIC_DIR")
),
user_session_idle_seconds=_positive_int(
values,
"CLOUD_USER_SESSION_IDLE_SECONDS",
28_800,
),
user_session_absolute_seconds=_positive_int(
values,
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS",
604_800,
),
login_failure_limit=_positive_int(values, "CLOUD_LOGIN_FAILURE_LIMIT", 5),
login_failure_window_seconds=_positive_int(
values,
"CLOUD_LOGIN_FAILURE_WINDOW_SECONDS",
900,
),
login_block_seconds=_positive_int(values, "CLOUD_LOGIN_BLOCK_SECONDS", 900),
session_cookie_secure=_parse_bool(
values.get("CLOUD_SESSION_COOKIE_SECURE"),
default=environment == "production",
),
trust_proxy_headers=_parse_bool(
values.get("CLOUD_TRUST_PROXY_HEADERS"),
default=False,
),
)
validate_control_config(config)
return config
@@ -112,6 +144,14 @@ def validate_control_config(config: CloudControlConfig) -> None:
raise CloudConfigurationError(
"production requires at least one configured bearer credential"
)
if config.user_session_absolute_seconds < config.user_session_idle_seconds:
raise CloudConfigurationError(
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
)
if config.environment == "production" and not config.session_cookie_secure:
raise CloudConfigurationError(
"production requires secure user session cookies"
)
def _parse_credentials(
+91 -1
View File
@@ -1,6 +1,14 @@
from __future__ import annotations
from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy import (
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -125,3 +133,85 @@ class PluginRow(Base):
entry_point_kind: Mapped[str] = mapped_column(String, nullable=False)
target: Mapped[str] = mapped_column(String, nullable=False)
wired: Mapped[int] = mapped_column(Integer, nullable=False)
class UserRow(Base):
__tablename__ = "cloud_users"
__table_args__ = (
UniqueConstraint("username_normalized", name="uq_cloud_users_username_normalized"),
Index("ix_cloud_users_enabled_role", "enabled", "role"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
username: Mapped[str] = mapped_column(String, nullable=False)
username_normalized: Mapped[str] = mapped_column(String, nullable=False)
display_name: Mapped[str] = mapped_column(String, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String, nullable=False)
enabled: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1"))
must_change_password: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default=text("0"),
)
authentication_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
server_default=text("1"),
)
created_at: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
last_login_at: Mapped[str | None] = mapped_column(String, nullable=True)
class UserSessionRow(Base):
__tablename__ = "cloud_user_sessions"
__table_args__ = (
UniqueConstraint("token_digest", name="uq_cloud_user_sessions_token_digest"),
Index("ix_cloud_user_sessions_user_id", "user_id"),
Index("ix_cloud_user_sessions_absolute_expires_at", "absolute_expires_at"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
ForeignKey("cloud_users.id", ondelete="CASCADE"),
nullable=False,
)
token_digest: Mapped[str] = mapped_column(String, nullable=False)
csrf_digest: Mapped[str] = mapped_column(String, nullable=False)
authentication_version: Mapped[int] = mapped_column(Integer, nullable=False)
issued_at: Mapped[str] = mapped_column(String, nullable=False)
last_seen_at: Mapped[str] = mapped_column(String, nullable=False)
idle_expires_at: Mapped[str] = mapped_column(String, nullable=False)
absolute_expires_at: Mapped[str] = mapped_column(String, nullable=False)
revoked_at: Mapped[str | None] = mapped_column(String, nullable=True)
class LoginThrottleRow(Base):
__tablename__ = "cloud_login_throttles"
username_normalized: Mapped[str] = mapped_column(String, primary_key=True)
client_bucket: Mapped[str] = mapped_column(String, primary_key=True)
failure_count: Mapped[int] = mapped_column(Integer, nullable=False)
window_started_at: Mapped[str] = mapped_column(String, nullable=False)
last_attempt_at: Mapped[str] = mapped_column(String, nullable=False)
blocked_until: Mapped[str | None] = mapped_column(String, nullable=True)
class AuthAuditRow(Base):
__tablename__ = "cloud_auth_audit_events"
__table_args__ = (
Index("ix_cloud_auth_audit_events_occurred_at", "occurred_at"),
Index("ix_cloud_auth_audit_events_target_user_id", "target_user_id"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
occurred_at: Mapped[str] = mapped_column(String, nullable=False)
actor_principal_id: Mapped[str | None] = mapped_column(String, nullable=True)
target_user_id: Mapped[str | None] = mapped_column(String, nullable=True)
action: Mapped[str] = mapped_column(String, nullable=False)
outcome: Mapped[str] = mapped_column(String, nullable=False)
correlation_id: Mapped[str | None] = mapped_column(String, nullable=True)
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
@@ -0,0 +1,147 @@
"""Add persistent Cloud Console user authentication state."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0003_cloud_user_authentication"
down_revision = "0002_edge_host_enrollment"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "cloud_users" not in tables:
op.create_table(
"cloud_users",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("username", sa.String(), nullable=False),
sa.Column("username_normalized", sa.String(), nullable=False),
sa.Column("display_name", sa.String(), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.String(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column(
"must_change_password",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"authentication_version",
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column("created_at", sa.String(), nullable=False),
sa.Column("updated_at", sa.String(), nullable=False),
sa.Column("last_login_at", sa.String(), nullable=True),
sa.UniqueConstraint(
"username_normalized",
name="uq_cloud_users_username_normalized",
),
)
op.create_index(
"ix_cloud_users_enabled_role",
"cloud_users",
["enabled", "role"],
)
if "cloud_user_sessions" not in tables:
op.create_table(
"cloud_user_sessions",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"user_id",
sa.String(),
sa.ForeignKey("cloud_users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_digest", sa.String(), nullable=False),
sa.Column("csrf_digest", sa.String(), nullable=False),
sa.Column("authentication_version", sa.Integer(), nullable=False),
sa.Column("issued_at", sa.String(), nullable=False),
sa.Column("last_seen_at", sa.String(), nullable=False),
sa.Column("idle_expires_at", sa.String(), nullable=False),
sa.Column("absolute_expires_at", sa.String(), nullable=False),
sa.Column("revoked_at", sa.String(), nullable=True),
sa.UniqueConstraint(
"token_digest",
name="uq_cloud_user_sessions_token_digest",
),
)
op.create_index(
"ix_cloud_user_sessions_user_id",
"cloud_user_sessions",
["user_id"],
)
op.create_index(
"ix_cloud_user_sessions_absolute_expires_at",
"cloud_user_sessions",
["absolute_expires_at"],
)
if "cloud_login_throttles" not in tables:
op.create_table(
"cloud_login_throttles",
sa.Column("username_normalized", sa.String(), primary_key=True),
sa.Column("client_bucket", sa.String(), primary_key=True),
sa.Column("failure_count", sa.Integer(), nullable=False),
sa.Column("window_started_at", sa.String(), nullable=False),
sa.Column("last_attempt_at", sa.String(), nullable=False),
sa.Column("blocked_until", sa.String(), nullable=True),
)
if "cloud_auth_audit_events" not in tables:
op.create_table(
"cloud_auth_audit_events",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("occurred_at", sa.String(), nullable=False),
sa.Column("actor_principal_id", sa.String(), nullable=True),
sa.Column("target_user_id", sa.String(), nullable=True),
sa.Column("action", sa.String(), nullable=False),
sa.Column("outcome", sa.String(), nullable=False),
sa.Column("correlation_id", sa.String(), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=False, server_default=sa.text("'{}'")),
)
op.create_index(
"ix_cloud_auth_audit_events_occurred_at",
"cloud_auth_audit_events",
["occurred_at"],
)
op.create_index(
"ix_cloud_auth_audit_events_target_user_id",
"cloud_auth_audit_events",
["target_user_id"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "cloud_auth_audit_events" in tables:
op.drop_index(
"ix_cloud_auth_audit_events_target_user_id",
table_name="cloud_auth_audit_events",
)
op.drop_index(
"ix_cloud_auth_audit_events_occurred_at",
table_name="cloud_auth_audit_events",
)
op.drop_table("cloud_auth_audit_events")
if "cloud_login_throttles" in tables:
op.drop_table("cloud_login_throttles")
if "cloud_user_sessions" in tables:
op.drop_index(
"ix_cloud_user_sessions_absolute_expires_at",
table_name="cloud_user_sessions",
)
op.drop_index(
"ix_cloud_user_sessions_user_id",
table_name="cloud_user_sessions",
)
op.drop_table("cloud_user_sessions")
if "cloud_users" in tables:
op.drop_index("ix_cloud_users_enabled_role", table_name="cloud_users")
op.drop_table("cloud_users")
+101 -1
View File
@@ -1,13 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Literal, Protocol
if TYPE_CHECKING:
from cloud.plugins import PluginManifest
from cloud.pool import HostRegistration, PooledDevice
from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
from cloud.user_auth import (
AuthAuditEvent,
AuthenticatedUserSession,
LoginThrottle,
UserAccount,
UserSession,
)
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -28,6 +35,14 @@ class DeviceEnrollmentConflictError(RuntimeError):
"""Raised when a local device enrollment conflicts with stored identity."""
class UserConflictError(RuntimeError):
"""Raised when a user operation violates a durable identity invariant."""
class LastAdministratorConflictError(UserConflictError):
"""Raised when a write would remove the last enabled administrator."""
@dataclass(frozen=True)
class HostEnrollment:
host_id: str
@@ -183,6 +198,91 @@ class CloudRepository(Protocol):
def get_plugin(self, name: str) -> tuple[PluginManifest, bool] | None: ...
def create_user(self, user: UserAccount) -> UserAccount: ...
def get_user(self, user_id: str) -> UserAccount | None: ...
def get_user_by_normalized_username(
self,
username_normalized: str,
) -> UserAccount | None: ...
def list_users(self, *, limit: int, offset: int) -> list[UserAccount]: ...
def update_user(
self,
user_id: str,
*,
display_name: str | None = None,
role: str | None = None,
enabled: bool | None = None,
updated_at: datetime,
) -> UserAccount: ...
def rehash_user_password(
self,
user_id: str,
*,
password_hash: str,
updated_at: datetime,
) -> UserAccount: ...
def update_user_password(
self,
user_id: str,
*,
password_hash: str,
must_change_password: bool,
updated_at: datetime,
revoke_sessions: bool,
) -> UserAccount: ...
def mark_user_login(self, user_id: str, *, now: datetime) -> UserAccount: ...
def create_user_session(self, session: UserSession) -> None: ...
def get_authenticated_user_session(
self,
token_digest: str,
*,
now: datetime,
) -> AuthenticatedUserSession | None: ...
def touch_user_session(
self,
session_id: str,
*,
last_seen_at: datetime,
idle_expires_at: datetime,
) -> UserSession: ...
def revoke_user_session(self, session_id: str, *, revoked_at: datetime) -> bool: ...
def revoke_user_sessions(self, user_id: str, *, revoked_at: datetime) -> int: ...
def get_login_throttle(
self,
username_normalized: str,
client_bucket: str,
) -> LoginThrottle | None: ...
def record_login_failure(
self,
*,
username_normalized: str,
client_bucket: str,
now: datetime,
failure_limit: int,
failure_window: timedelta,
block_duration: timedelta,
) -> LoginThrottle: ...
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: ...
def record_auth_audit(self, event: AuthAuditEvent) -> None: ...
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ...
def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ...
def assign_task(
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0002_edge_host_enrollment"
HEAD_REVISION = "0003_cloud_user_authentication"
class SchemaVersionError(RuntimeError):
+16 -1
View File
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Callable, Literal
from cloud.auth import (
PLUGINS_ADMIN_SCOPE,
@@ -49,6 +49,7 @@ def create_cloud_router(
scheduler: "TaskScheduler",
plugin_registry: "PluginRegistry",
auth_provider: AuthProvider | None = None,
csrf_validator: Callable[[Request, Principal], bool] | None = None,
version_prefix: str = "/v1",
) -> APIRouter:
"""Build the ``/v1`` APIRouter exposing the platform SDK surface."""
@@ -63,11 +64,25 @@ def create_cloud_router(
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="password must be changed before accessing this resource",
)
if not principal.has_scope(required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {required_scope}",
)
if (
principal.session_id is not None
and request.method in {"POST", "PUT", "PATCH", "DELETE"}
and (csrf_validator is None or not csrf_validator(request, principal))
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF validation failed",
)
return principal
@router.post(
+84 -1
View File
@@ -139,6 +139,76 @@ class CloudClient:
resp = self._request("POST", "/plugins", json=payload)
return resp.json()
# --------------------------------------------------------------- user auth
def login(self, *, username: str, password: str) -> dict[str, Any]:
resp = self._request(
"POST",
"/auth/login",
json={"username": username, "password": password},
)
return resp.json()
def current_user(self) -> dict[str, Any]:
resp = self._request("GET", "/auth/me")
return resp.json()
def logout(self) -> None:
self._request("POST", "/auth/logout")
def change_password(self, *, current_password: str, new_password: str) -> None:
self._request(
"POST",
"/auth/password",
json={
"current_password": current_password,
"new_password": new_password,
},
)
def list_users(self, *, limit: int = 50, offset: int = 0) -> dict[str, Any]:
resp = self._request(
"GET",
"/users",
params={"limit": limit, "offset": offset},
)
return resp.json()
def create_user(
self,
*,
username: str,
display_name: str,
role: str,
password: str,
) -> dict[str, Any]:
resp = self._request(
"POST",
"/users",
json={
"username": username,
"display_name": display_name,
"role": role,
"password": password,
},
)
return resp.json()
def update_user(self, user_id: str, **changes: Any) -> dict[str, Any]:
resp = self._request("PATCH", f"/users/{user_id}", json=changes)
return resp.json()
def reset_user_password(self, user_id: str, *, password: str) -> dict[str, Any]:
resp = self._request(
"POST",
f"/users/{user_id}/password",
json={"password": password},
)
return resp.json()
def revoke_user_sessions(self, user_id: str) -> None:
self._request("DELETE", f"/users/{user_id}/sessions")
# ------------------------------------------------------------------ helpers
def _url(self, path: str) -> str:
@@ -152,12 +222,17 @@ class CloudClient:
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
headers = dict(self._headers or {})
if method in {"POST", "PUT", "PATCH", "DELETE"} and not headers:
csrf_token = _cookie_value(self._http, "amcp_csrf")
if csrf_token:
headers["X-CSRF-Token"] = csrf_token
response = self._http.request(
method,
self._url(path),
json=json,
params=params,
headers=self._headers,
headers=headers or None,
auth=self._auth,
)
if response.is_success:
@@ -173,3 +248,11 @@ class CloudClient:
else CloudAPIError
)
raise error_type(response, str(detail))
def _cookie_value(client: Any, name: str) -> str | None:
cookies = getattr(client, "cookies", None)
if cookies is None:
return None
value = cookies.get(name)
return value if isinstance(value, str) else None
@@ -97,5 +97,51 @@ class PluginResponse(BaseModel):
wired: bool
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=256)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
new_password: str = Field(min_length=1, max_length=256)
class UserResponse(BaseModel):
id: str
username: str
display_name: str
role: Literal["viewer", "operator", "admin"]
enabled: bool
must_change_password: bool
scopes: list[str]
created_at: datetime
updated_at: datetime
last_login_at: datetime | None = None
class UserListResponse(BaseModel):
items: list[UserResponse]
limit: int
offset: int
class UserCreateRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
display_name: str = Field(min_length=1, max_length=120)
role: Literal["viewer", "operator", "admin"]
password: str = Field(min_length=1, max_length=256)
class UserUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
role: Literal["viewer", "operator", "admin"] | None = None
enabled: bool | None = None
class PasswordResetRequest(BaseModel):
password: str = Field(min_length=1, max_length=256)
class ErrorResponse(BaseModel):
detail: str
@@ -0,0 +1,323 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
from cloud.auth import AuthProvider, Principal, USERS_ADMIN_SCOPE
from cloud.observability import current_correlation_id
from cloud.repository import LastAdministratorConflictError, UserConflictError
from cloud.sdk.models import (
LoginRequest,
PasswordChangeRequest,
PasswordResetRequest,
UserCreateRequest,
UserListResponse,
UserResponse,
UserUpdateRequest,
)
from cloud.user_auth import (
USER_CSRF_COOKIE,
USER_SESSION_COOKIE,
UserAuthenticationError,
UserValidationError,
utc_now,
validate_display_name,
validate_role,
)
if TYPE_CHECKING:
from cloud.control_config import CloudControlConfig
from cloud.user_auth import UserAccount, UserAuthService
def create_user_auth_router(
*,
user_auth_service: UserAuthService,
auth_provider: AuthProvider,
config: CloudControlConfig,
version_prefix: str = "/v1",
) -> APIRouter:
router = APIRouter(prefix=version_prefix, tags=["cloud-user-authentication"])
def _principal(
request: Request,
*,
required_scope: str | None = None,
allow_password_change: bool = False,
) -> Principal:
principal = auth_provider.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password and not allow_password_change:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="password must be changed before accessing this resource",
)
if required_scope is not None and not principal.has_scope(required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {required_scope}",
)
return principal
def _require_session(principal: Principal) -> tuple[str, str]:
if principal.session_id is None or not principal.id.startswith("user:"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="a user session is required",
)
return principal.id.removeprefix("user:"), principal.session_id
def _require_csrf(request: Request, principal: Principal) -> None:
if principal.session_id is None:
return
if not user_auth_service.validate_csrf(
session_token=request.cookies.get(USER_SESSION_COOKIE),
csrf_cookie=request.cookies.get(USER_CSRF_COOKIE),
csrf_header=request.headers.get("x-csrf-token"),
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF validation failed",
)
def _clear_cookies(response: Response) -> None:
response.delete_cookie(
USER_SESSION_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=True,
samesite="lax",
)
response.delete_cookie(
USER_CSRF_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=False,
samesite="lax",
)
@router.post("/auth/login", response_model=UserResponse)
def login(payload: LoginRequest, request: Request, response: Response) -> UserResponse:
try:
result = user_auth_service.login(
username=payload.username,
password=payload.password,
client_bucket=_client_bucket(request, config.trust_proxy_headers),
correlation_id=current_correlation_id(),
)
except UserAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
) from exc
response.set_cookie(
USER_SESSION_COOKIE,
result.session_token,
max_age=config.user_session_absolute_seconds,
path="/",
secure=config.session_cookie_secure,
httponly=True,
samesite="lax",
)
response.set_cookie(
USER_CSRF_COOKIE,
result.csrf_token,
max_age=config.user_session_absolute_seconds,
path="/",
secure=config.session_cookie_secure,
httponly=False,
samesite="lax",
)
return _user_response(result.user)
@router.get("/auth/me", response_model=UserResponse)
def current_user(request: Request) -> UserResponse:
principal = _principal(request, allow_password_change=True)
user_id, _ = _require_session(principal)
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
return _user_response(user)
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(request: Request, response: Response) -> Response:
principal = _principal(request, allow_password_change=True)
user_id, session_id = _require_session(principal)
_require_csrf(request, principal)
user_auth_service.logout(
session_id=session_id,
user_id=user_id,
correlation_id=current_correlation_id(),
)
_clear_cookies(response)
response.status_code = status.HTTP_204_NO_CONTENT
return response
@router.post("/auth/password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(
payload: PasswordChangeRequest,
request: Request,
response: Response,
) -> Response:
principal = _principal(request, allow_password_change=True)
user_id, _ = _require_session(principal)
_require_csrf(request, principal)
try:
user_auth_service.change_password(
user_id=user_id,
current_password=payload.current_password,
new_password=payload.new_password,
correlation_id=current_correlation_id(),
)
except UserAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
) from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
_clear_cookies(response)
response.status_code = status.HTTP_204_NO_CONTENT
return response
@router.get("/users", response_model=UserListResponse)
def list_users(
request: Request,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> UserListResponse:
_principal(request, required_scope=USERS_ADMIN_SCOPE)
users = user_auth_service.repository.list_users(limit=limit, offset=offset) # type: ignore[attr-defined]
return UserListResponse(
items=[_user_response(user) for user in users],
limit=limit,
offset=offset,
)
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreateRequest, request: Request) -> UserResponse:
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
_require_csrf(request, principal)
try:
user = user_auth_service.create_user(
username=payload.username,
display_name=payload.display_name,
role=payload.role,
password=payload.password,
)
except (UserValidationError, UserConflictError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
user_auth_service.record_admin_action(
actor_principal_id=principal.id,
target_user_id=user.id,
action="user_create",
correlation_id=current_correlation_id(),
metadata={"role": user.role},
)
return _user_response(user)
@router.patch("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: str,
payload: UserUpdateRequest,
request: Request,
) -> UserResponse:
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
_require_csrf(request, principal)
try:
user = user_auth_service.repository.update_user( # type: ignore[attr-defined]
user_id,
display_name=(
validate_display_name(payload.display_name)
if payload.display_name is not None
else None
),
role=validate_role(payload.role) if payload.role is not None else None,
enabled=payload.enabled,
updated_at=utc_now(),
)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
except LastAdministratorConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
user_auth_service.record_admin_action(
actor_principal_id=principal.id,
target_user_id=user.id,
action="user_update",
correlation_id=current_correlation_id(),
metadata={"role": user.role, "enabled": str(user.enabled)},
)
return _user_response(user)
@router.post("/users/{user_id}/password", response_model=UserResponse)
def reset_password(
user_id: str,
payload: PasswordResetRequest,
request: Request,
) -> UserResponse:
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
_require_csrf(request, principal)
try:
user = user_auth_service.reset_password(
user_id=user_id,
new_password=payload.password,
actor_principal_id=principal.id,
correlation_id=current_correlation_id(),
)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
return _user_response(user)
@router.delete("/users/{user_id}/sessions", status_code=status.HTTP_204_NO_CONTENT)
def revoke_user_sessions(user_id: str, request: Request) -> Response:
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
_require_csrf(request, principal)
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found")
user_auth_service.repository.revoke_user_sessions( # type: ignore[attr-defined]
user_id,
revoked_at=utc_now(),
)
user_auth_service.record_admin_action(
actor_principal_id=principal.id,
target_user_id=user_id,
action="session_revoke",
correlation_id=current_correlation_id(),
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
return router
def _user_response(user: UserAccount) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
role=user.role,
enabled=user.enabled,
must_change_password=user.must_change_password,
scopes=sorted(user.scopes),
created_at=user.created_at,
updated_at=user.updated_at,
last_login_at=user.last_login_at,
)
def _client_bucket(request: Request, trust_proxy_headers: bool) -> str:
if trust_proxy_headers:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",", maxsplit=1)[0].strip() or "unknown"
return request.client.host if request.client is not None else "unknown"
+399 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
from dataclasses import asdict
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import Engine, delete, func, select
@@ -18,6 +18,10 @@ from cloud.db_models import (
PooledDeviceRow,
ScheduledTaskRow,
TaskAttemptRow,
AuthAuditRow,
LoginThrottleRow,
UserRow,
UserSessionRow,
)
from cloud.observability import current_correlation_id
from core.models import utc_now
@@ -460,6 +464,337 @@ class SQLAlchemyCloudRepository:
row = session.get(PluginRow, name)
return _plugin_from_row(row) if row else None
# --------------------------------------------------------------- user auth
def create_user(self, user: Any) -> Any:
from cloud.repository import UserConflictError
try:
with self._sessions.begin() as session:
row = UserRow(
id=user.id,
username=user.username,
username_normalized=user.username_normalized,
display_name=user.display_name,
password_hash=user.password_hash,
role=user.role,
enabled=1 if user.enabled else 0,
must_change_password=1 if user.must_change_password else 0,
authentication_version=user.authentication_version,
created_at=_iso(user.created_at),
updated_at=_iso(user.updated_at),
last_login_at=(
_iso(user.last_login_at) if user.last_login_at is not None else None
),
)
session.add(row)
session.flush()
return _user_from_row(row)
except IntegrityError as exc:
raise UserConflictError("username is already in use") from exc
def get_user(self, user_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(UserRow, user_id)
return _user_from_row(row) if row else None
def get_user_by_normalized_username(self, username_normalized: str) -> Any | None:
with self._sessions() as session:
row = session.scalar(
select(UserRow)
.where(UserRow.username_normalized == username_normalized)
.limit(1)
)
return _user_from_row(row) if row else None
def list_users(self, *, limit: int, offset: int) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(UserRow)
.order_by(UserRow.created_at, UserRow.id)
.limit(limit)
.offset(offset)
).all()
return [_user_from_row(row) for row in rows]
def update_user(
self,
user_id: str,
*,
display_name: str | None = None,
role: str | None = None,
enabled: bool | None = None,
updated_at: datetime,
) -> Any:
from cloud.repository import LastAdministratorConflictError
with self._sessions.begin() as session:
row = session.get(
UserRow,
user_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(f"user {user_id!r} not found")
next_role = role if role is not None else row.role
next_enabled = enabled if enabled is not None else bool(row.enabled)
removes_administrator = (
bool(row.enabled)
and row.role == "admin"
and (next_role != "admin" or not next_enabled)
)
if removes_administrator:
other_admins = session.scalar(
select(func.count())
.select_from(UserRow)
.where(
UserRow.id != user_id,
UserRow.enabled == 1,
UserRow.role == "admin",
)
)
if int(other_admins or 0) == 0:
raise LastAdministratorConflictError(
"cannot remove the last enabled administrator"
)
security_changed = next_role != row.role or next_enabled != bool(row.enabled)
if display_name is not None:
row.display_name = display_name
row.role = next_role
row.enabled = 1 if next_enabled else 0
row.updated_at = _iso(updated_at)
if security_changed:
row.authentication_version += 1
_revoke_user_session_rows(session, user_id, updated_at)
session.flush()
return _user_from_row(row)
def rehash_user_password(
self,
user_id: str,
*,
password_hash: str,
updated_at: datetime,
) -> Any:
with self._sessions.begin() as session:
row = session.get(UserRow, user_id)
if row is None:
raise KeyError(f"user {user_id!r} not found")
row.password_hash = password_hash
row.updated_at = _iso(updated_at)
session.flush()
return _user_from_row(row)
def update_user_password(
self,
user_id: str,
*,
password_hash: str,
must_change_password: bool,
updated_at: datetime,
revoke_sessions: bool,
) -> Any:
with self._sessions.begin() as session:
row = session.get(
UserRow,
user_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(f"user {user_id!r} not found")
row.password_hash = password_hash
row.must_change_password = 1 if must_change_password else 0
row.authentication_version += 1
row.updated_at = _iso(updated_at)
if revoke_sessions:
_revoke_user_session_rows(session, user_id, updated_at)
session.flush()
return _user_from_row(row)
def mark_user_login(self, user_id: str, *, now: datetime) -> Any:
with self._sessions.begin() as session:
row = session.get(UserRow, user_id)
if row is None:
raise KeyError(f"user {user_id!r} not found")
row.last_login_at = _iso(now)
row.updated_at = _iso(now)
session.flush()
return _user_from_row(row)
def create_user_session(self, user_session: Any) -> None:
with self._sessions.begin() as session:
session.add(
UserSessionRow(
id=user_session.id,
user_id=user_session.user_id,
token_digest=user_session.token_digest,
csrf_digest=user_session.csrf_digest,
authentication_version=user_session.authentication_version,
issued_at=_iso(user_session.issued_at),
last_seen_at=_iso(user_session.last_seen_at),
idle_expires_at=_iso(user_session.idle_expires_at),
absolute_expires_at=_iso(user_session.absolute_expires_at),
revoked_at=(
_iso(user_session.revoked_at)
if user_session.revoked_at is not None
else None
),
)
)
def get_authenticated_user_session(
self,
token_digest: str,
*,
now: datetime,
) -> Any | None:
from cloud.user_auth import AuthenticatedUserSession
with self._sessions() as session:
match = session.execute(
select(UserSessionRow, UserRow)
.join(UserRow, UserRow.id == UserSessionRow.user_id)
.where(
UserSessionRow.token_digest == token_digest,
UserSessionRow.revoked_at.is_(None),
UserRow.enabled == 1,
)
.limit(1)
).first()
if match is None:
return None
session_row, user_row = match
user = _user_from_row(user_row)
user_session = _user_session_from_row(session_row)
if (
user.authentication_version != user_session.authentication_version
or user_session.idle_expires_at <= now
or user_session.absolute_expires_at <= now
):
return None
return AuthenticatedUserSession(user=user, session=user_session)
def touch_user_session(
self,
session_id: str,
*,
last_seen_at: datetime,
idle_expires_at: datetime,
) -> Any:
with self._sessions.begin() as session:
row = session.get(UserSessionRow, session_id)
if row is None:
raise KeyError(f"session {session_id!r} not found")
row.last_seen_at = _iso(last_seen_at)
row.idle_expires_at = _iso(idle_expires_at)
session.flush()
return _user_session_from_row(row)
def revoke_user_session(self, session_id: str, *, revoked_at: datetime) -> bool:
with self._sessions.begin() as session:
row = session.get(UserSessionRow, session_id)
if row is None or row.revoked_at is not None:
return False
row.revoked_at = _iso(revoked_at)
return True
def revoke_user_sessions(self, user_id: str, *, revoked_at: datetime) -> int:
with self._sessions.begin() as session:
return _revoke_user_session_rows(session, user_id, revoked_at)
def get_login_throttle(
self,
username_normalized: str,
client_bucket: str,
) -> Any | None:
with self._sessions() as session:
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
return _login_throttle_from_row(row) if row else None
def record_login_failure(
self,
*,
username_normalized: str,
client_bucket: str,
now: datetime,
failure_limit: int,
failure_window: timedelta,
block_duration: timedelta,
) -> Any:
with self._sessions.begin() as session:
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
if row is None:
row = LoginThrottleRow(
username_normalized=username_normalized,
client_bucket=client_bucket,
failure_count=0,
window_started_at=_iso(now),
last_attempt_at=_iso(now),
blocked_until=None,
)
session.add(row)
window_started = _parse_dt(row.window_started_at) or now
if now - window_started > failure_window:
row.failure_count = 0
row.window_started_at = _iso(now)
row.blocked_until = None
row.failure_count += 1
row.last_attempt_at = _iso(now)
if row.failure_count >= failure_limit:
row.blocked_until = _iso(now + block_duration)
session.flush()
return _login_throttle_from_row(row)
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None:
with self._sessions.begin() as session:
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
if row is not None:
session.delete(row)
def record_auth_audit(self, event: Any) -> None:
with self._sessions.begin() as session:
session.add(
AuthAuditRow(
id=event.id,
occurred_at=_iso(event.occurred_at),
actor_principal_id=event.actor_principal_id,
target_user_id=event.target_user_id,
action=event.action,
outcome=event.outcome,
correlation_id=event.correlation_id,
metadata_json=json.dumps(event.metadata, ensure_ascii=False),
)
)
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int:
removed = 0
with self._sessions.begin() as session:
expired_sessions = session.scalars(
select(UserSessionRow)
.where(
(UserSessionRow.idle_expires_at <= _iso(now))
| (UserSessionRow.absolute_expires_at <= _iso(now))
)
.order_by(UserSessionRow.absolute_expires_at)
.limit(limit)
).all()
for row in expired_sessions:
session.delete(row)
removed += len(expired_sessions)
remaining = max(0, limit - removed)
if remaining:
stale_before = now - timedelta(days=1)
stale_throttles = session.scalars(
select(LoginThrottleRow)
.where(LoginThrottleRow.last_attempt_at <= _iso(stale_before))
.order_by(LoginThrottleRow.last_attempt_at)
.limit(remaining)
).all()
for row in stale_throttles:
session.delete(row)
removed += len(stale_throttles)
return removed
def list_reserved_device_ids(self, *, now: datetime) -> set[str]:
with self._sessions() as session:
device_ids = session.scalars(
@@ -951,6 +1286,69 @@ def _log_task_lifecycle(event: str, task: ScheduledTaskRow) -> None:
)
def _revoke_user_session_rows(session: Any, user_id: str, revoked_at: datetime) -> int:
rows = session.scalars(
select(UserSessionRow).where(
UserSessionRow.user_id == user_id,
UserSessionRow.revoked_at.is_(None),
)
).all()
for row in rows:
row.revoked_at = _iso(revoked_at)
return len(rows)
def _user_from_row(row: UserRow) -> Any:
from cloud.user_auth import UserAccount
return UserAccount(
id=row.id,
username=row.username,
username_normalized=row.username_normalized,
display_name=row.display_name,
role=row.role,
enabled=bool(row.enabled),
must_change_password=bool(row.must_change_password),
authentication_version=row.authentication_version,
created_at=_parse_dt(row.created_at) or utc_now(),
updated_at=_parse_dt(row.updated_at) or utc_now(),
last_login_at=_parse_dt(row.last_login_at),
password_hash=row.password_hash,
)
def _user_session_from_row(row: UserSessionRow) -> Any:
from cloud.user_auth import UserSession
now = utc_now()
return UserSession(
id=row.id,
user_id=row.user_id,
token_digest=row.token_digest,
csrf_digest=row.csrf_digest,
authentication_version=row.authentication_version,
issued_at=_parse_dt(row.issued_at) or now,
last_seen_at=_parse_dt(row.last_seen_at) or now,
idle_expires_at=_parse_dt(row.idle_expires_at) or now,
absolute_expires_at=_parse_dt(row.absolute_expires_at) or now,
revoked_at=_parse_dt(row.revoked_at),
)
def _login_throttle_from_row(row: LoginThrottleRow) -> Any:
from cloud.user_auth import LoginThrottle
now = utc_now()
return LoginThrottle(
username_normalized=row.username_normalized,
client_bucket=row.client_bucket,
failure_count=row.failure_count,
window_started_at=_parse_dt(row.window_started_at) or now,
last_attempt_at=_parse_dt(row.last_attempt_at) or now,
blocked_until=_parse_dt(row.blocked_until),
)
def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
from cloud.plugins import PluginManifest
+527
View File
@@ -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 {},
)
)
+1
View File
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"alembic>=1.14.0",
"argon2-cffi>=25.0.0",
"device-agent-runtime==0.1.0",
"psycopg[binary]>=3.2.0",
"sqlalchemy>=2.0.0",