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