feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from datetime import timedelta
|
||||
from getpass import getpass
|
||||
|
||||
from cloud.control_config import load_control_config
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.schema import require_current_schema
|
||||
from cloud.user_auth import UserAuthService, UserAuthSettings, normalize_username, utc_now
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="Administer Cloud Console user accounts")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
users = commands.add_parser("users", help="manage user accounts")
|
||||
user_commands = users.add_subparsers(dest="user_command", required=True)
|
||||
|
||||
create = user_commands.add_parser("create", help="create a user interactively")
|
||||
create.add_argument("--username", required=True)
|
||||
create.add_argument("--display-name")
|
||||
create.add_argument("--role", choices=("viewer", "operator", "admin"), required=True)
|
||||
|
||||
reset = user_commands.add_parser("reset-password", help="reset a user password")
|
||||
reset.add_argument("--username", required=True)
|
||||
|
||||
enable = user_commands.add_parser("enable", help="enable a disabled user")
|
||||
enable.add_argument("--username", required=True)
|
||||
|
||||
revoke = user_commands.add_parser("revoke-sessions", help="revoke a user's sessions")
|
||||
revoke.add_argument("--username", required=True)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
config = load_control_config()
|
||||
require_current_schema(config.database_url)
|
||||
database = CloudDatabase(config.database_url, create_schema=False)
|
||||
service = UserAuthService(
|
||||
database.repository,
|
||||
settings=UserAuthSettings(
|
||||
session_idle_ttl=timedelta(seconds=config.user_session_idle_seconds),
|
||||
session_absolute_ttl=timedelta(
|
||||
seconds=config.user_session_absolute_seconds
|
||||
),
|
||||
login_failure_limit=config.login_failure_limit,
|
||||
login_failure_window=timedelta(
|
||||
seconds=config.login_failure_window_seconds
|
||||
),
|
||||
login_block_duration=timedelta(seconds=config.login_block_seconds),
|
||||
cookie_secure=config.session_cookie_secure,
|
||||
),
|
||||
)
|
||||
try:
|
||||
_run_user_command(args, service)
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _run_user_command(args: argparse.Namespace, service: UserAuthService) -> None:
|
||||
username = normalize_username(args.username)
|
||||
if args.user_command == "create":
|
||||
password = _read_password()
|
||||
user = service.create_user(
|
||||
username=args.username,
|
||||
display_name=args.display_name or args.username,
|
||||
role=args.role,
|
||||
password=password,
|
||||
)
|
||||
service.record_admin_action(
|
||||
actor_principal_id="deployment-cli",
|
||||
target_user_id=user.id,
|
||||
action="user_create",
|
||||
metadata={"role": user.role},
|
||||
)
|
||||
print(f"created user {user.username!r} with role {user.role}")
|
||||
return
|
||||
|
||||
user = service.repository.get_user_by_normalized_username(username) # type: ignore[attr-defined]
|
||||
if user is None:
|
||||
raise SystemExit("user not found")
|
||||
if args.user_command == "reset-password":
|
||||
service.reset_password(
|
||||
user_id=user.id,
|
||||
new_password=_read_password(),
|
||||
actor_principal_id="deployment-cli",
|
||||
)
|
||||
print(f"reset password for {user.username!r}")
|
||||
return
|
||||
if args.user_command == "enable":
|
||||
updated = service.repository.update_user( # type: ignore[attr-defined]
|
||||
user.id,
|
||||
enabled=True,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
service.record_admin_action(
|
||||
actor_principal_id="deployment-cli",
|
||||
target_user_id=updated.id,
|
||||
action="user_enable",
|
||||
)
|
||||
print(f"enabled user {updated.username!r}")
|
||||
return
|
||||
if args.user_command == "revoke-sessions":
|
||||
service.repository.revoke_user_sessions( # type: ignore[attr-defined]
|
||||
user.id,
|
||||
revoked_at=utc_now(),
|
||||
)
|
||||
service.record_admin_action(
|
||||
actor_principal_id="deployment-cli",
|
||||
target_user_id=user.id,
|
||||
action="session_revoke",
|
||||
)
|
||||
print(f"revoked sessions for {user.username!r}")
|
||||
return
|
||||
raise AssertionError(f"unsupported command {args.user_command!r}")
|
||||
|
||||
|
||||
def _read_password() -> str:
|
||||
password = getpass("Password: ")
|
||||
confirmation = getpass("Confirm password: ")
|
||||
if password != confirmation:
|
||||
raise SystemExit("password confirmation did not match")
|
||||
return password
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -19,6 +20,7 @@ from cloud.auth import (
|
||||
ChainedAuthProvider,
|
||||
ConfiguredEnrollmentTokenProvider,
|
||||
RepositoryHostAuthProvider,
|
||||
UserSessionAuthProvider,
|
||||
create_auth_provider,
|
||||
)
|
||||
from cloud.config import CloudConfig
|
||||
@@ -42,6 +44,8 @@ from cloud.pool import DevicePool
|
||||
from cloud.scheduler import TaskScheduler
|
||||
from cloud.schema import require_current_schema
|
||||
from cloud.sdk.api import create_cloud_router
|
||||
from cloud.sdk.user_api import create_user_auth_router
|
||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
@@ -72,6 +76,7 @@ class CloudApplicationServices:
|
||||
scheduler: TaskScheduler
|
||||
plugin_registry: PluginRegistry
|
||||
auth_provider: Any
|
||||
user_auth_service: UserAuthService
|
||||
|
||||
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
@@ -107,9 +112,25 @@ def create_app(
|
||||
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
|
||||
)
|
||||
repository = _RepositoryProxy()
|
||||
user_auth_service = UserAuthService(
|
||||
repository,
|
||||
settings=UserAuthSettings(
|
||||
session_idle_ttl=timedelta(seconds=control_config.user_session_idle_seconds),
|
||||
session_absolute_ttl=timedelta(
|
||||
seconds=control_config.user_session_absolute_seconds
|
||||
),
|
||||
login_failure_limit=control_config.login_failure_limit,
|
||||
login_failure_window=timedelta(
|
||||
seconds=control_config.login_failure_window_seconds
|
||||
),
|
||||
login_block_duration=timedelta(seconds=control_config.login_block_seconds),
|
||||
cookie_secure=control_config.session_cookie_secure,
|
||||
),
|
||||
)
|
||||
auth_provider = ChainedAuthProvider(
|
||||
(
|
||||
configured_auth_provider,
|
||||
UserSessionAuthProvider(user_auth_service),
|
||||
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
@@ -128,6 +149,7 @@ def create_app(
|
||||
scheduler=scheduler,
|
||||
plugin_registry=plugin_registry,
|
||||
auth_provider=auth_provider,
|
||||
user_auth_service=user_auth_service,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -201,6 +223,12 @@ def create_app(
|
||||
correlation_token = bind_correlation_id(correlation_id)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
if (
|
||||
request.cookies.get(USER_SESSION_COOKIE)
|
||||
and not request.headers.get("authorization")
|
||||
and response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
):
|
||||
_clear_user_auth_cookies(response, control_config)
|
||||
logger.info(
|
||||
"cloud request completed",
|
||||
extra={
|
||||
@@ -251,6 +279,18 @@ def create_app(
|
||||
scheduler=scheduler,
|
||||
plugin_registry=plugin_registry,
|
||||
auth_provider=auth_provider,
|
||||
csrf_validator=lambda request, principal: _valid_csrf_request(
|
||||
request,
|
||||
principal,
|
||||
user_auth_service,
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_user_auth_router(
|
||||
user_auth_service=user_auth_service,
|
||||
auth_provider=auth_provider,
|
||||
config=control_config,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
@@ -289,6 +329,37 @@ def _default_database_factory(config: CloudControlConfig) -> CloudDatabase:
|
||||
)
|
||||
|
||||
|
||||
def _valid_csrf_request(
|
||||
request: Request,
|
||||
principal: Any,
|
||||
user_auth_service: UserAuthService,
|
||||
) -> bool:
|
||||
if principal.session_id is None:
|
||||
return True
|
||||
return 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"),
|
||||
)
|
||||
|
||||
|
||||
def _clear_user_auth_cookies(response: Any, config: CloudControlConfig) -> 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",
|
||||
)
|
||||
|
||||
|
||||
async def _run_scheduler_loop(
|
||||
services: CloudApplicationServices,
|
||||
stop: asyncio.Event,
|
||||
|
||||
Reference in New Issue
Block a user