messagefoundry 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""AuthService — orchestrates authentication, sessions, role resolution, and first-run bootstrap.
|
|
4
|
+
|
|
5
|
+
Pure engine-side code (no FastAPI): the API layer composes it. It ties together the store (users,
|
|
6
|
+
roles, sessions, audit), password hashing/policy, opaque session tokens, and the LDAP/Kerberos
|
|
7
|
+
authenticators. Local and AD users share one identity model; an AD user's roles are re-synced from
|
|
8
|
+
their directory groups on every login, so :meth:`identity_for_token` can resolve everyone uniformly
|
|
9
|
+
from ``user_roles``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, TypeVar
|
|
24
|
+
from uuid import uuid4
|
|
25
|
+
|
|
26
|
+
from messagefoundry.auth import totp
|
|
27
|
+
from messagefoundry.auth.identity import AuthProvider, Identity
|
|
28
|
+
from messagefoundry.auth.ldap import AdPrincipal, LdapAuthenticator, LdapError, kerberos_principal
|
|
29
|
+
from messagefoundry.auth.notifications import (
|
|
30
|
+
ACCOUNT_DISABLED,
|
|
31
|
+
ACCOUNT_LOCKED,
|
|
32
|
+
ADMIN_NEW_IP,
|
|
33
|
+
EMAIL_CHANGED,
|
|
34
|
+
LOGIN_AFTER_FAILURES,
|
|
35
|
+
MFA_DISABLED,
|
|
36
|
+
MFA_ENABLED,
|
|
37
|
+
PASSWORD_CHANGED,
|
|
38
|
+
PASSWORD_RESET,
|
|
39
|
+
ROLES_CHANGED,
|
|
40
|
+
SUSPICIOUS_LOGIN_FAILURE_THRESHOLD,
|
|
41
|
+
SecurityEvent,
|
|
42
|
+
SecurityNotifier,
|
|
43
|
+
)
|
|
44
|
+
from messagefoundry.auth.passwords import hash_password, needs_rehash, verify_password
|
|
45
|
+
from messagefoundry.auth.permissions import ROLE_METADATA, Permission, Role
|
|
46
|
+
from messagefoundry.auth.policy import PasswordPolicy, _operator_corpus
|
|
47
|
+
from messagefoundry.auth.ratelimit import SlidingWindowRateLimiter
|
|
48
|
+
from messagefoundry.auth.tokens import hash_token, mint_token
|
|
49
|
+
from messagefoundry.config.settings import AuthSettings
|
|
50
|
+
from messagefoundry.store.base import AdminStore
|
|
51
|
+
from messagefoundry.store.store import SessionRecord, UserRecord
|
|
52
|
+
|
|
53
|
+
_log = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
#: The account created on first run when the store has no users (HIPAA unique-user bootstrap).
|
|
56
|
+
BOOTSTRAP_USERNAME = "admin"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn_if_corpus_unreadable(path: str | None) -> None:
|
|
60
|
+
"""Eagerly load (and cache) an operator breach corpus at startup so a misconfigured path surfaces
|
|
61
|
+
as a clear warning rather than silently disabling the check on every later password change."""
|
|
62
|
+
if not path:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
entries, hashed = _operator_corpus(path)
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
_log.warning(
|
|
68
|
+
"password_breach_corpus_file %r could not be read (%s); the larger breach corpus is "
|
|
69
|
+
"disabled (the bundled top-10k list still applies)",
|
|
70
|
+
path,
|
|
71
|
+
exc,
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
_log.info(
|
|
75
|
+
"loaded operator breach corpus from %r (%d %s entries)",
|
|
76
|
+
path,
|
|
77
|
+
len(entries),
|
|
78
|
+
"hashed" if hashed else "plaintext",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
#: A fixed argon2 hash used to equalize login timing for unknown/disabled accounts (anti-enumeration).
|
|
83
|
+
_DUMMY_PASSWORD_HASH = hash_password("mf-login-timing-equalizer")
|
|
84
|
+
|
|
85
|
+
#: Cap on concurrent argon2 hashes/verifies so an unauthenticated login flood can't exhaust the
|
|
86
|
+
#: thread-pool executor (and starve all login/AD/password work). Argon2 is deliberately CPU-heavy.
|
|
87
|
+
_ARGON2_MAX_CONCURRENCY = max(2, min(8, os.cpu_count() or 2))
|
|
88
|
+
|
|
89
|
+
# Bound on the per-process new-client-IP dedup cache (WP-L3-13). It only debounces the audit/notify
|
|
90
|
+
# side effects of the 8.4.2 signal; the step-up decision never depends on it, so eviction is harmless.
|
|
91
|
+
_NEW_IP_DEDUP_MAX = 4096
|
|
92
|
+
|
|
93
|
+
_T = TypeVar("_T")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class LoginOutcome:
|
|
98
|
+
"""Result of a login attempt. ``error`` is for logs/audit — never leak the reason to clients."""
|
|
99
|
+
|
|
100
|
+
ok: bool
|
|
101
|
+
token: str | None = None
|
|
102
|
+
identity: Identity | None = None
|
|
103
|
+
must_change_password: bool = False
|
|
104
|
+
error: str | None = None
|
|
105
|
+
#: The password was accepted but the session still needs a second factor (TOTP / recovery code)
|
|
106
|
+
#: before it may perform step-up (sensitive) operations — the client should prompt for a code and
|
|
107
|
+
#: call ``POST /auth/mfa-verify`` (WP-14, ASVS 6.3.3). Always False for an MFA-delegated AD login.
|
|
108
|
+
mfa_required: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class MfaEnrollment:
|
|
113
|
+
"""A staged (not-yet-confirmed) TOTP enrollment: the base32 secret to render as a QR + the
|
|
114
|
+
``otpauth://`` URI. Returned **once**; confirmed by proving a live code."""
|
|
115
|
+
|
|
116
|
+
secret: str
|
|
117
|
+
otpauth_uri: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class MfaStatus:
|
|
122
|
+
"""A local user's current MFA posture, for ``GET /me/mfa``."""
|
|
123
|
+
|
|
124
|
+
enabled: bool
|
|
125
|
+
enrolled_at: float | None
|
|
126
|
+
recovery_codes_remaining: int
|
|
127
|
+
required: bool
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class BootstrapAdmin:
|
|
132
|
+
"""Credentials for the one-time bootstrap admin (printed once, then must be changed)."""
|
|
133
|
+
|
|
134
|
+
username: str
|
|
135
|
+
password: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _roles_from_ids(ids: Iterable[str]) -> frozenset[Role]:
|
|
139
|
+
"""Map stored role ids to :class:`Role`; silently drop unknown ids (deny-by-default)."""
|
|
140
|
+
out: set[Role] = set()
|
|
141
|
+
for rid in ids:
|
|
142
|
+
try:
|
|
143
|
+
out.add(Role(rid))
|
|
144
|
+
except ValueError:
|
|
145
|
+
continue
|
|
146
|
+
return frozenset(out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _json(obj: Any) -> str:
|
|
150
|
+
return json.dumps(obj, sort_keys=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _allowed_channels(user: UserRecord, roles: frozenset[Role]) -> frozenset[str] | None:
|
|
154
|
+
"""Resolve a user's per-channel RBAC scope to a frozenset, or ``None`` for all channels.
|
|
155
|
+
|
|
156
|
+
Administrators are always all-channels. A NULL ``channel_scope`` is all; a JSON list is exactly
|
|
157
|
+
those connections; anything malformed is treated as **no** channels (deny-by-default)."""
|
|
158
|
+
if Role.ADMINISTRATOR in roles or user.channel_scope is None:
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
names = json.loads(user.channel_scope)
|
|
162
|
+
except (ValueError, TypeError):
|
|
163
|
+
return frozenset()
|
|
164
|
+
if not isinstance(names, list):
|
|
165
|
+
return frozenset()
|
|
166
|
+
return frozenset(str(n) for n in names)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AuthService:
|
|
170
|
+
"""Authentication + RBAC orchestration over an :class:`AuthStore` and the configured directory."""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
store: AdminStore,
|
|
175
|
+
settings: AuthSettings,
|
|
176
|
+
*,
|
|
177
|
+
ldap: LdapAuthenticator | None = None,
|
|
178
|
+
security_notifier: SecurityNotifier | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._store = store
|
|
181
|
+
self._settings = settings
|
|
182
|
+
self.enabled = settings.enabled
|
|
183
|
+
# Out-of-band security-event push (ASVS 6.3.5/6.3.7), injected by the API lifespan. None = no
|
|
184
|
+
# email push (the audited /me/security-events feed still records everything). Best-effort.
|
|
185
|
+
self._security_notifier = security_notifier
|
|
186
|
+
self._policy = PasswordPolicy(
|
|
187
|
+
min_length=settings.password_min_length,
|
|
188
|
+
require_uppercase=settings.password_require_uppercase,
|
|
189
|
+
require_lowercase=settings.password_require_lowercase,
|
|
190
|
+
require_digit=settings.password_require_digit,
|
|
191
|
+
require_symbol=settings.password_require_symbol,
|
|
192
|
+
check_breached=settings.password_check_breached,
|
|
193
|
+
check_context=settings.password_check_context,
|
|
194
|
+
check_username=settings.password_check_username,
|
|
195
|
+
breach_corpus_file=settings.password_breach_corpus_file,
|
|
196
|
+
lockout_threshold=settings.lockout_threshold,
|
|
197
|
+
lockout_minutes=settings.lockout_minutes,
|
|
198
|
+
)
|
|
199
|
+
_warn_if_corpus_unreadable(settings.password_breach_corpus_file)
|
|
200
|
+
if ldap is not None:
|
|
201
|
+
self._ldap: LdapAuthenticator | None = ldap
|
|
202
|
+
elif settings.ad_enabled:
|
|
203
|
+
self._ldap = LdapAuthenticator(settings)
|
|
204
|
+
else:
|
|
205
|
+
self._ldap = None
|
|
206
|
+
# Instance-scoped (one event loop per AuthService) so it never crosses loops in tests.
|
|
207
|
+
self._argon2_sem = asyncio.Semaphore(_ARGON2_MAX_CONCURRENCY)
|
|
208
|
+
self._login_limiter: SlidingWindowRateLimiter | None = (
|
|
209
|
+
SlidingWindowRateLimiter(
|
|
210
|
+
per_key=settings.login_rate_limit_per_ip,
|
|
211
|
+
glob=settings.login_rate_limit_global,
|
|
212
|
+
window_seconds=settings.login_rate_limit_window_seconds,
|
|
213
|
+
)
|
|
214
|
+
if settings.login_rate_limit_enabled
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
# Per-actor anti-automation throttle for the PHI-read endpoints (WP-8, ASVS 2.4.1).
|
|
218
|
+
self._phi_read_limiter: SlidingWindowRateLimiter | None = (
|
|
219
|
+
SlidingWindowRateLimiter(
|
|
220
|
+
per_key=settings.phi_read_rate_limit_per_actor,
|
|
221
|
+
glob=settings.phi_read_rate_limit_global,
|
|
222
|
+
window_seconds=settings.phi_read_rate_limit_window_seconds,
|
|
223
|
+
)
|
|
224
|
+
if settings.phi_read_rate_limit_enabled
|
|
225
|
+
else None
|
|
226
|
+
)
|
|
227
|
+
# Per-process dedup of the WP-L3-13 new-client-IP audit/notify side effects: token_hash → the
|
|
228
|
+
# last new client address already flagged for that session. Bounded (_NEW_IP_DEDUP_MAX).
|
|
229
|
+
self._new_ip_seen: dict[str, str] = {}
|
|
230
|
+
|
|
231
|
+
async def _argon2(self, fn: Callable[..., _T], *args: Any) -> _T:
|
|
232
|
+
"""Run a (CPU-heavy) argon2 hash/verify off-thread under the concurrency cap."""
|
|
233
|
+
async with self._argon2_sem:
|
|
234
|
+
return await asyncio.to_thread(fn, *args)
|
|
235
|
+
|
|
236
|
+
def allow_login_attempt(self, client: str | None) -> bool:
|
|
237
|
+
"""Rate-limit gate for the unauthenticated auth surface (AUTH-RATE). True = proceed."""
|
|
238
|
+
if self._login_limiter is None:
|
|
239
|
+
return True
|
|
240
|
+
return self._login_limiter.allow(client or "unknown")
|
|
241
|
+
|
|
242
|
+
def allow_phi_read(self, actor: str) -> bool:
|
|
243
|
+
"""Per-actor anti-automation gate for the PHI-read endpoints (WP-8, ASVS 2.4.1). True =
|
|
244
|
+
proceed; False = throttle. Always True when the limiter is disabled."""
|
|
245
|
+
if self._phi_read_limiter is None:
|
|
246
|
+
return True
|
|
247
|
+
return self._phi_read_limiter.allow(actor)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def policy(self) -> PasswordPolicy:
|
|
251
|
+
return self._policy
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def ad_enabled(self) -> bool:
|
|
255
|
+
return self._ldap is not None
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def kerberos_enabled(self) -> bool:
|
|
259
|
+
return self._settings.kerberos_enabled and self._ldap is not None
|
|
260
|
+
|
|
261
|
+
# --- lifecycle -----------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
async def initialize(self) -> BootstrapAdmin | None:
|
|
264
|
+
"""Seed the built-in roles and, on an empty store, create the bootstrap admin. Also retires an
|
|
265
|
+
unclaimed bootstrap that became superseded/expired while the service was down (WP-3)."""
|
|
266
|
+
await self._seed_roles()
|
|
267
|
+
created = await self._ensure_bootstrap_admin()
|
|
268
|
+
await self._retire_superseded_bootstrap()
|
|
269
|
+
return created
|
|
270
|
+
|
|
271
|
+
async def _seed_roles(self) -> None:
|
|
272
|
+
for role in Role:
|
|
273
|
+
label, description = ROLE_METADATA[role]
|
|
274
|
+
await self._store.upsert_role(
|
|
275
|
+
role_id=role.value, display_name=label, description=description, builtin=True
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def _ensure_bootstrap_admin(self) -> BootstrapAdmin | None:
|
|
279
|
+
if await self._store.count_users() > 0:
|
|
280
|
+
return None
|
|
281
|
+
password = self._generate_policy_password()
|
|
282
|
+
user_id = uuid4().hex
|
|
283
|
+
await self._store.create_user(
|
|
284
|
+
user_id=user_id,
|
|
285
|
+
username=BOOTSTRAP_USERNAME,
|
|
286
|
+
auth_provider=AuthProvider.LOCAL.value,
|
|
287
|
+
display_name="Bootstrap Administrator",
|
|
288
|
+
password_hash=await self._argon2(hash_password, password),
|
|
289
|
+
must_change_password=True,
|
|
290
|
+
)
|
|
291
|
+
await self._store.set_user_roles(
|
|
292
|
+
user_id, [Role.ADMINISTRATOR.value], assigned_by="bootstrap"
|
|
293
|
+
)
|
|
294
|
+
await self._audit("auth.bootstrap_admin_created", actor="bootstrap")
|
|
295
|
+
return BootstrapAdmin(username=BOOTSTRAP_USERNAME, password=password)
|
|
296
|
+
|
|
297
|
+
def _generate_policy_password(self) -> str:
|
|
298
|
+
"""A random password that satisfies the active policy — so the printed bootstrap credential
|
|
299
|
+
is held to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is
|
|
300
|
+
guaranteed ≥ ``min_length``); the loop covers the astronomically-unlikely breach/context hit
|
|
301
|
+
or an opt-in character-class requirement a given token happens to miss."""
|
|
302
|
+
length = max(16, self._policy.min_length)
|
|
303
|
+
for _ in range(16):
|
|
304
|
+
candidate = secrets.token_urlsafe(length)
|
|
305
|
+
if not self._policy.violations(candidate):
|
|
306
|
+
return candidate
|
|
307
|
+
return secrets.token_urlsafe(length) + "aA1!" # defensive: satisfies any class requirement
|
|
308
|
+
|
|
309
|
+
async def _other_enabled_admin_exists(self, exclude_id: str) -> bool:
|
|
310
|
+
"""True iff some enabled administrator other than ``exclude_id`` exists."""
|
|
311
|
+
for user in await self._store.list_users():
|
|
312
|
+
if user.disabled or user.id == exclude_id:
|
|
313
|
+
continue
|
|
314
|
+
if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id):
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
async def _retire_superseded_bootstrap(self, now: float | None = None) -> None:
|
|
319
|
+
"""Disable the first-run bootstrap admin once it's no longer needed (WP-3): when a **second**
|
|
320
|
+
administrator exists, or — while still **unclaimed** (never password-changed) — once its expiry
|
|
321
|
+
window lapses. Only ever touches an unclaimed bootstrap (``must_change_password`` still set): if
|
|
322
|
+
the operator changed its password it is a normal admin account and is left alone, so this can't
|
|
323
|
+
lock out a legitimate single-admin deployment."""
|
|
324
|
+
now = time.time() if now is None else now
|
|
325
|
+
boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME)
|
|
326
|
+
if boot is None or boot.disabled or not boot.must_change_password:
|
|
327
|
+
return # gone, already disabled, or claimed (a real account now)
|
|
328
|
+
expiry_hours = self._settings.bootstrap_expiry_hours
|
|
329
|
+
expired = expiry_hours > 0 and now >= boot.created_at + expiry_hours * 3600
|
|
330
|
+
superseded = await self._other_enabled_admin_exists(boot.id)
|
|
331
|
+
if not (expired or superseded):
|
|
332
|
+
return
|
|
333
|
+
await self._store.set_user_disabled(boot.id, disabled=True)
|
|
334
|
+
await self._store.revoke_user_sessions(boot.id)
|
|
335
|
+
await self._audit(
|
|
336
|
+
"auth.bootstrap_admin_retired",
|
|
337
|
+
actor="system",
|
|
338
|
+
detail=_json({"reason": "superseded" if superseded else "expired"}),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# --- login ---------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async def login(
|
|
344
|
+
self,
|
|
345
|
+
username: str,
|
|
346
|
+
password: str,
|
|
347
|
+
*,
|
|
348
|
+
provider: AuthProvider = AuthProvider.LOCAL,
|
|
349
|
+
client: str | None = None,
|
|
350
|
+
) -> LoginOutcome:
|
|
351
|
+
if provider is AuthProvider.AD:
|
|
352
|
+
return await self._login_ad(username, password, client=client)
|
|
353
|
+
return await self._login_local(username, password, client=client)
|
|
354
|
+
|
|
355
|
+
async def _login_local(
|
|
356
|
+
self, username: str, password: str, *, client: str | None
|
|
357
|
+
) -> LoginOutcome:
|
|
358
|
+
# Enforce bootstrap expiry/supersession before the credential check: an unclaimed bootstrap
|
|
359
|
+
# that lapsed (or was superseded) is disabled here, so the disabled-account path below refuses
|
|
360
|
+
# it like any other invalid login (WP-3). Scoped to the bootstrap username to keep normal
|
|
361
|
+
# logins free of the extra lookups.
|
|
362
|
+
if username == BOOTSTRAP_USERNAME:
|
|
363
|
+
await self._retire_superseded_bootstrap()
|
|
364
|
+
user = await self._store.get_user_by_username(username)
|
|
365
|
+
if user is None or user.auth_provider != AuthProvider.LOCAL.value or user.disabled:
|
|
366
|
+
# Equalize timing with the real-password path so a missing/disabled/AD account is not
|
|
367
|
+
# distinguishable from a wrong password (defeats username enumeration via latency).
|
|
368
|
+
await self._argon2(verify_password, _DUMMY_PASSWORD_HASH, password)
|
|
369
|
+
await self._audit(
|
|
370
|
+
"auth.login_failed",
|
|
371
|
+
actor=username,
|
|
372
|
+
detail=_json({"provider": "local", "reason": "unknown_or_disabled"}),
|
|
373
|
+
)
|
|
374
|
+
return LoginOutcome(ok=False, error="invalid credentials")
|
|
375
|
+
now = time.time()
|
|
376
|
+
if user.locked_until is not None and now < user.locked_until:
|
|
377
|
+
await self._argon2(verify_password, _DUMMY_PASSWORD_HASH, password)
|
|
378
|
+
await self._audit("auth.login_locked", actor=username)
|
|
379
|
+
return LoginOutcome(ok=False, error="account locked")
|
|
380
|
+
if user.password_hash is None or not await self._argon2(
|
|
381
|
+
verify_password, user.password_hash, password
|
|
382
|
+
):
|
|
383
|
+
attempts, just_locked = await self._register_failure(user, now)
|
|
384
|
+
await self._audit(
|
|
385
|
+
"auth.login_failed",
|
|
386
|
+
actor=username,
|
|
387
|
+
detail=_json({"provider": "local", "reason": "bad_password"}),
|
|
388
|
+
)
|
|
389
|
+
if just_locked:
|
|
390
|
+
await self._notify_security(
|
|
391
|
+
ACCOUNT_LOCKED,
|
|
392
|
+
username=user.username,
|
|
393
|
+
email=user.email,
|
|
394
|
+
client=client,
|
|
395
|
+
detail={"failed_attempts": attempts},
|
|
396
|
+
)
|
|
397
|
+
return LoginOutcome(ok=False, error="invalid credentials")
|
|
398
|
+
if await asyncio.to_thread(needs_rehash, user.password_hash):
|
|
399
|
+
await self._store.set_password(
|
|
400
|
+
user.id,
|
|
401
|
+
password_hash=await self._argon2(hash_password, password),
|
|
402
|
+
must_change_password=user.must_change_password,
|
|
403
|
+
)
|
|
404
|
+
prior_failures = user.failed_attempts # captured before record_login_success resets it
|
|
405
|
+
await self._store.record_login_success(user.id, now=now)
|
|
406
|
+
identity = await self._build_identity(user)
|
|
407
|
+
# A second factor (TOTP / recovery code) is pending for an enrolled user — or an Administrator
|
|
408
|
+
# when require_mfa is on. Issue the session un-MFA'd; the client completes via /auth/mfa-verify.
|
|
409
|
+
mfa_required = self._mfa_required_for(user, identity.roles)
|
|
410
|
+
token = await self._issue_session(user.id, client, mfa_verified=not mfa_required)
|
|
411
|
+
await self._audit(
|
|
412
|
+
"auth.login_success",
|
|
413
|
+
actor=user.username,
|
|
414
|
+
detail=_json({"provider": "local", "mfa_required": mfa_required}),
|
|
415
|
+
)
|
|
416
|
+
if prior_failures >= SUSPICIOUS_LOGIN_FAILURE_THRESHOLD:
|
|
417
|
+
# A successful login right after a run of failures is the classic compromised/attacked
|
|
418
|
+
# signal (ASVS 6.3.5) — notify the owner out-of-band so they can react if it wasn't them.
|
|
419
|
+
await self._notify_security(
|
|
420
|
+
LOGIN_AFTER_FAILURES,
|
|
421
|
+
username=user.username,
|
|
422
|
+
email=user.email,
|
|
423
|
+
client=client,
|
|
424
|
+
detail={"failed_attempts": prior_failures},
|
|
425
|
+
)
|
|
426
|
+
return LoginOutcome(
|
|
427
|
+
ok=True,
|
|
428
|
+
token=token,
|
|
429
|
+
identity=identity,
|
|
430
|
+
must_change_password=user.must_change_password,
|
|
431
|
+
mfa_required=mfa_required,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
async def _register_failure(self, user: UserRecord, now: float) -> tuple[int, bool]:
|
|
435
|
+
"""Record a failed attempt; return ``(attempts, just_locked)``. ``just_locked`` is True only on
|
|
436
|
+
the attempt that crosses the threshold (the caller reaches here only when not already locked),
|
|
437
|
+
so it fires exactly one lockout notification per lockout."""
|
|
438
|
+
# A lapsed lockout window restarts the counter, so one post-lockout failure cannot re-lock
|
|
439
|
+
# immediately (and the stale lock is cleared whenever the count is back below threshold).
|
|
440
|
+
prior = (
|
|
441
|
+
0
|
|
442
|
+
if (user.locked_until is not None and now >= user.locked_until)
|
|
443
|
+
else user.failed_attempts
|
|
444
|
+
)
|
|
445
|
+
attempts = prior + 1
|
|
446
|
+
locked_until = (
|
|
447
|
+
now + self._policy.lockout_minutes * 60
|
|
448
|
+
if attempts >= self._policy.lockout_threshold
|
|
449
|
+
else None
|
|
450
|
+
)
|
|
451
|
+
await self._store.record_login_failure(
|
|
452
|
+
user.id, failed_attempts=attempts, locked_until=locked_until, now=now
|
|
453
|
+
)
|
|
454
|
+
return attempts, locked_until is not None
|
|
455
|
+
|
|
456
|
+
async def _login_ad(self, username: str, password: str, *, client: str | None) -> LoginOutcome:
|
|
457
|
+
if self._ldap is None:
|
|
458
|
+
return LoginOutcome(ok=False, error="AD authentication is not configured")
|
|
459
|
+
try:
|
|
460
|
+
principal = await asyncio.to_thread(self._ldap.authenticate, username, password)
|
|
461
|
+
except LdapError as exc:
|
|
462
|
+
await self._audit(
|
|
463
|
+
"auth.login_error",
|
|
464
|
+
actor=username,
|
|
465
|
+
detail=_json({"provider": "ad", "error": str(exc)}),
|
|
466
|
+
)
|
|
467
|
+
return LoginOutcome(ok=False, error="directory unavailable")
|
|
468
|
+
if principal is None:
|
|
469
|
+
await self._audit("auth.login_failed", actor=username, detail=_json({"provider": "ad"}))
|
|
470
|
+
return LoginOutcome(ok=False, error="invalid credentials")
|
|
471
|
+
return await self._complete_ad_login(principal, client)
|
|
472
|
+
|
|
473
|
+
async def authenticate_kerberos(
|
|
474
|
+
self, token: bytes, *, client: str | None = None
|
|
475
|
+
) -> LoginOutcome:
|
|
476
|
+
# Audit every reject path so blocked/failed Windows-SSO attempts are not invisible to a
|
|
477
|
+
# defender (AUTH-K-AUDIT). A sentinel actor is used until the principal is known.
|
|
478
|
+
if self._ldap is None or not self._settings.kerberos_enabled:
|
|
479
|
+
await self._kerberos_reject_audit("<kerberos>", "not_configured")
|
|
480
|
+
return LoginOutcome(ok=False, error="Windows SSO is not configured")
|
|
481
|
+
try:
|
|
482
|
+
username = await asyncio.to_thread(kerberos_principal, token, self._settings)
|
|
483
|
+
if username is None:
|
|
484
|
+
await self._kerberos_reject_audit("<kerberos>", "no_principal")
|
|
485
|
+
return LoginOutcome(ok=False, error="SSO authentication failed")
|
|
486
|
+
principal = await asyncio.to_thread(self._ldap.resolve_principal, username)
|
|
487
|
+
except LdapError as exc:
|
|
488
|
+
await self._audit(
|
|
489
|
+
"auth.login_error",
|
|
490
|
+
actor="<kerberos>",
|
|
491
|
+
detail=_json({"provider": "ad", "mech": "kerberos", "error": str(exc)}),
|
|
492
|
+
)
|
|
493
|
+
return LoginOutcome(ok=False, error="directory unavailable")
|
|
494
|
+
if principal is None:
|
|
495
|
+
await self._kerberos_reject_audit(username, "not_in_directory")
|
|
496
|
+
return LoginOutcome(ok=False, error="user not found in directory")
|
|
497
|
+
return await self._complete_ad_login(principal, client)
|
|
498
|
+
|
|
499
|
+
async def _kerberos_reject_audit(self, actor: str, reason: str) -> None:
|
|
500
|
+
await self._audit(
|
|
501
|
+
"auth.login_failed",
|
|
502
|
+
actor=actor,
|
|
503
|
+
detail=_json({"provider": "ad", "mech": "kerberos", "reason": reason}),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
async def _complete_ad_login(self, principal: AdPrincipal, client: str | None) -> LoginOutcome:
|
|
507
|
+
existing = await self._store.get_user_by_username(principal.username)
|
|
508
|
+
if existing is not None and existing.auth_provider != AuthProvider.AD.value:
|
|
509
|
+
# Never let an AD login adopt/overwrite a like-named LOCAL account (provider confusion).
|
|
510
|
+
await self._audit(
|
|
511
|
+
"auth.login_failed",
|
|
512
|
+
actor=principal.username,
|
|
513
|
+
detail=_json({"provider": "ad", "reason": "local_account_conflict"}),
|
|
514
|
+
)
|
|
515
|
+
return LoginOutcome(ok=False, error="account conflict")
|
|
516
|
+
user = await self._upsert_ad_user(principal)
|
|
517
|
+
role_ids = sorted(await self._store.roles_for_ad_groups(principal.groups))
|
|
518
|
+
previous = set(await self._store.get_user_role_ids(user.id))
|
|
519
|
+
await self._store.set_user_roles(user.id, role_ids, assigned_by="ad-sync")
|
|
520
|
+
if set(role_ids) != previous:
|
|
521
|
+
# Directory-side role change (often a downgrade): revoke the user's other live sessions
|
|
522
|
+
# so stale elevated tokens don't linger until expiry (AUTH-AD-REVOKE). The new session
|
|
523
|
+
# is issued below, after this, so the current login is unaffected.
|
|
524
|
+
await self._store.revoke_user_sessions(user.id)
|
|
525
|
+
await self._audit(
|
|
526
|
+
"auth.ad_roles_resynced",
|
|
527
|
+
actor=user.username,
|
|
528
|
+
detail=_json({"from": sorted(previous), "to": role_ids}),
|
|
529
|
+
)
|
|
530
|
+
# A directory-pushed privilege change is the same privilege change to the same user as a
|
|
531
|
+
# local one, so notify the affected user out-of-band too (ASVS 6.3.7), matching set_roles().
|
|
532
|
+
# Best-effort; the change is also visible in the audited /me/security-events feed.
|
|
533
|
+
await self._notify_security(
|
|
534
|
+
ROLES_CHANGED,
|
|
535
|
+
username=user.username,
|
|
536
|
+
email=user.email,
|
|
537
|
+
client=client,
|
|
538
|
+
detail={"roles": role_ids},
|
|
539
|
+
)
|
|
540
|
+
await self._store.record_login_success(user.id)
|
|
541
|
+
ad_roles = _roles_from_ids(role_ids)
|
|
542
|
+
user = await self._sync_ad_channel_scope(user, ad_roles, principal.groups)
|
|
543
|
+
identity = Identity.build(
|
|
544
|
+
user_id=user.id,
|
|
545
|
+
username=user.username,
|
|
546
|
+
auth_provider=AuthProvider.AD,
|
|
547
|
+
roles=ad_roles,
|
|
548
|
+
allowed_channels=_allowed_channels(user, ad_roles),
|
|
549
|
+
)
|
|
550
|
+
# AD/Kerberos MFA is delegated to the directory (Entra Conditional Access / MFA proxy), so the
|
|
551
|
+
# session is MFA-satisfied at issuance — an engine TOTP is never prompted for a directory login.
|
|
552
|
+
token = await self._issue_session(user.id, client, mfa_verified=True)
|
|
553
|
+
await self._audit(
|
|
554
|
+
"auth.login_success",
|
|
555
|
+
actor=user.username,
|
|
556
|
+
detail=_json({"provider": "ad", "roles": role_ids}),
|
|
557
|
+
)
|
|
558
|
+
return LoginOutcome(ok=True, token=token, identity=identity)
|
|
559
|
+
|
|
560
|
+
async def _sync_ad_channel_scope(
|
|
561
|
+
self, user: UserRecord, roles: frozenset[Role], groups: Iterable[str]
|
|
562
|
+
) -> UserRecord:
|
|
563
|
+
"""Persist a user's AD-group-derived per-channel scope (C3) so it's durable for later
|
|
564
|
+
requests (mirrors role sync). Administrators are always all-channels. If no group mapping
|
|
565
|
+
matches, the per-user scope is left untouched — opt-in, so it never clobbers a manual scope
|
|
566
|
+
or the all-channels default. Returns the (possibly refreshed) user record."""
|
|
567
|
+
if Role.ADMINISTRATOR in roles:
|
|
568
|
+
return user
|
|
569
|
+
channels = await self._store.channels_for_ad_groups(groups)
|
|
570
|
+
if not channels:
|
|
571
|
+
return user
|
|
572
|
+
specific = sorted(c for c in channels if c != "*")
|
|
573
|
+
scope_json = None if "*" in channels else _json(specific)
|
|
574
|
+
if user.channel_scope == scope_json:
|
|
575
|
+
return user
|
|
576
|
+
await self._store.set_user_channel_scope(user.id, scope_json)
|
|
577
|
+
await self._store.revoke_user_sessions(
|
|
578
|
+
user.id
|
|
579
|
+
) # drop stale-scope tokens (new one issued after)
|
|
580
|
+
await self._audit(
|
|
581
|
+
"auth.ad_scope_resynced",
|
|
582
|
+
actor=user.username,
|
|
583
|
+
detail=_json({"channels": "*" if scope_json is None else specific}),
|
|
584
|
+
)
|
|
585
|
+
return await self._store.get_user(user.id) or user
|
|
586
|
+
|
|
587
|
+
async def _upsert_ad_user(self, principal: AdPrincipal) -> UserRecord:
|
|
588
|
+
existing = await self._store.get_user_by_username(principal.username)
|
|
589
|
+
if existing is None:
|
|
590
|
+
user_id = uuid4().hex
|
|
591
|
+
await self._store.create_user(
|
|
592
|
+
user_id=user_id,
|
|
593
|
+
username=principal.username,
|
|
594
|
+
auth_provider=AuthProvider.AD.value,
|
|
595
|
+
display_name=principal.display_name,
|
|
596
|
+
email=principal.email,
|
|
597
|
+
)
|
|
598
|
+
else:
|
|
599
|
+
user_id = existing.id
|
|
600
|
+
await self._store.update_user_profile(
|
|
601
|
+
user_id, display_name=principal.display_name, email=principal.email
|
|
602
|
+
)
|
|
603
|
+
user = await self._store.get_user(user_id)
|
|
604
|
+
assert user is not None # just upserted
|
|
605
|
+
return user
|
|
606
|
+
|
|
607
|
+
# --- sessions ------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
async def _issue_session(self, user_id: str, client: str | None, *, mfa_verified: bool) -> str:
|
|
610
|
+
token = mint_token()
|
|
611
|
+
token_hash = hash_token(token)
|
|
612
|
+
expires_at = time.time() + self._settings.session_absolute_hours * 3600
|
|
613
|
+
await self._store.create_session(
|
|
614
|
+
token_hash=token_hash,
|
|
615
|
+
user_id=user_id,
|
|
616
|
+
expires_at=expires_at,
|
|
617
|
+
client=client,
|
|
618
|
+
# Seed the step-up window from login ONLY for a fully-authenticated session. An MFA-pending
|
|
619
|
+
# session gets no step-up freshness, so enrolling a first authenticator (or any step-up op)
|
|
620
|
+
# requires an explicit password re-verify — a stolen pre-MFA token can't ride login's
|
|
621
|
+
# freshness to bind an attacker-controlled authenticator (WP-14).
|
|
622
|
+
seed_reauth=mfa_verified,
|
|
623
|
+
)
|
|
624
|
+
if mfa_verified:
|
|
625
|
+
# No second factor pending (MFA not required for this user, or delegated to AD/Kerberos):
|
|
626
|
+
# mark the session's 2nd factor satisfied at issuance so the step-up gate never blocks it.
|
|
627
|
+
# An MFA-required local login leaves it NULL until POST /auth/mfa-verify (WP-14).
|
|
628
|
+
await self._store.mark_session_mfa_verified(token_hash)
|
|
629
|
+
cap = self._settings.max_sessions_per_user
|
|
630
|
+
if cap and cap > 0:
|
|
631
|
+
# Evict the oldest sessions beyond the cap (the just-created one is newest, so survives).
|
|
632
|
+
await self._store.enforce_session_cap(user_id, keep=cap)
|
|
633
|
+
return token
|
|
634
|
+
|
|
635
|
+
async def identity_for_token(
|
|
636
|
+
self, token: str | None, *, activity: bool = True
|
|
637
|
+
) -> Identity | None:
|
|
638
|
+
"""Validate a bearer token (existence, revocation, clock, absolute + idle timeout) and
|
|
639
|
+
resolve the caller's :class:`Identity`.
|
|
640
|
+
|
|
641
|
+
``activity=True`` (the default, for user-driven requests) refreshes the session's idle
|
|
642
|
+
clock; pass ``activity=False`` for background re-checks (e.g. a long-lived WebSocket) so a
|
|
643
|
+
passively-polled token still ages out against real user activity (AUTH-IDLE).
|
|
644
|
+
"""
|
|
645
|
+
if not token:
|
|
646
|
+
return None
|
|
647
|
+
session = await self._store.get_session(hash_token(token))
|
|
648
|
+
if session is None or session.revoked_at is not None:
|
|
649
|
+
return None
|
|
650
|
+
now = time.time()
|
|
651
|
+
# Fail closed on a backward wall-clock step (NTP step-back, VM snapshot revert): a session
|
|
652
|
+
# stamped in the "future" can't be aged correctly, so revoke rather than silently revive an
|
|
653
|
+
# already-expired one or reset its idle window (AUTH-CLOCK).
|
|
654
|
+
if now < session.created_at or now < session.last_used_at:
|
|
655
|
+
await self._store.revoke_session(session.token_hash, now=now)
|
|
656
|
+
return None
|
|
657
|
+
if now > session.expires_at:
|
|
658
|
+
await self._store.revoke_session(session.token_hash, now=now)
|
|
659
|
+
return None
|
|
660
|
+
if now - session.last_used_at > self._settings.session_idle_timeout_minutes * 60:
|
|
661
|
+
await self._store.revoke_session(session.token_hash, now=now)
|
|
662
|
+
return None
|
|
663
|
+
if activity:
|
|
664
|
+
await self._store.touch_session(session.token_hash, now=now)
|
|
665
|
+
user = await self._store.get_user(session.user_id)
|
|
666
|
+
if user is None or user.disabled:
|
|
667
|
+
return None
|
|
668
|
+
return await self._build_identity(user)
|
|
669
|
+
|
|
670
|
+
async def logout(self, token: str | None, *, actor: str | None = None) -> None:
|
|
671
|
+
if token:
|
|
672
|
+
await self._store.revoke_session(hash_token(token))
|
|
673
|
+
# Emit the documented auth.logout event (SECURITY.md, ASVS 16.3.3) — previously the
|
|
674
|
+
# session was revoked silently, contradicting the doc and leaving a gap in the trail.
|
|
675
|
+
await self._audit("auth.logout", actor=actor)
|
|
676
|
+
|
|
677
|
+
# --- session inventory + targeted revoke (WP-10, ASVS 7.5.2/7.4.5) -------
|
|
678
|
+
|
|
679
|
+
async def list_sessions(self, user_id: str) -> list[SessionRecord]:
|
|
680
|
+
"""A user's active sessions — the self-service session inventory."""
|
|
681
|
+
return await self._store.list_sessions(user_id)
|
|
682
|
+
|
|
683
|
+
async def revoke_own_session(self, identity: Identity, session_id: str, *, actor: str) -> bool:
|
|
684
|
+
"""Revoke one of ``identity``'s **own** sessions by id (its ``token_hash``). Returns ``False``
|
|
685
|
+
if the session doesn't exist or isn't the caller's — so the API answers 404 without revealing
|
|
686
|
+
or letting a user touch another's session. Audited."""
|
|
687
|
+
session = await self._store.get_session(session_id)
|
|
688
|
+
if session is None or session.user_id != identity.user_id:
|
|
689
|
+
return False
|
|
690
|
+
await self._store.revoke_session(session_id)
|
|
691
|
+
await self._audit(
|
|
692
|
+
"auth.session_revoked",
|
|
693
|
+
actor=actor,
|
|
694
|
+
detail=_json({"scope": "self", "session": session_id[:12]}),
|
|
695
|
+
)
|
|
696
|
+
return True
|
|
697
|
+
|
|
698
|
+
async def revoke_other_sessions(
|
|
699
|
+
self, identity: Identity, current_token_hash: str, *, actor: str
|
|
700
|
+
) -> int:
|
|
701
|
+
"""Revoke all of ``identity``'s sessions **except** the caller's current one ("sign out
|
|
702
|
+
everywhere else"). Returns the count revoked. Audited when any were revoked."""
|
|
703
|
+
revoked = await self._store.revoke_user_sessions(
|
|
704
|
+
identity.user_id, except_token_hash=current_token_hash
|
|
705
|
+
)
|
|
706
|
+
if revoked:
|
|
707
|
+
await self._audit(
|
|
708
|
+
"auth.session_revoked",
|
|
709
|
+
actor=actor,
|
|
710
|
+
detail=_json({"scope": "self_others", "count": revoked}),
|
|
711
|
+
)
|
|
712
|
+
return revoked
|
|
713
|
+
|
|
714
|
+
async def revoke_sessions_for_user(self, user_id: str, *, actor: str) -> int:
|
|
715
|
+
"""Admin: revoke **all** of a user's sessions (force sign-out everywhere). Returns the count.
|
|
716
|
+
Audited."""
|
|
717
|
+
revoked = await self._store.revoke_user_sessions(user_id)
|
|
718
|
+
await self._audit(
|
|
719
|
+
"auth.session_revoked",
|
|
720
|
+
actor=actor,
|
|
721
|
+
detail=_json({"scope": "admin", "user_id": user_id, "count": revoked}),
|
|
722
|
+
)
|
|
723
|
+
return revoked
|
|
724
|
+
|
|
725
|
+
async def _build_identity(self, user: UserRecord) -> Identity:
|
|
726
|
+
role_ids = await self._store.get_user_role_ids(user.id)
|
|
727
|
+
roles = _roles_from_ids(role_ids)
|
|
728
|
+
provider = (
|
|
729
|
+
AuthProvider(user.auth_provider)
|
|
730
|
+
if user.auth_provider in (AuthProvider.LOCAL.value, AuthProvider.AD.value)
|
|
731
|
+
else AuthProvider.LOCAL
|
|
732
|
+
)
|
|
733
|
+
return Identity.build(
|
|
734
|
+
user_id=user.id,
|
|
735
|
+
username=user.username,
|
|
736
|
+
auth_provider=provider,
|
|
737
|
+
roles=roles,
|
|
738
|
+
must_change_password=user.must_change_password,
|
|
739
|
+
allowed_channels=_allowed_channels(user, roles),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# --- password management -------------------------------------------------
|
|
743
|
+
|
|
744
|
+
def password_violations(self, password: str, *, username: str | None = None) -> list[str]:
|
|
745
|
+
return self._policy.violations(password, username=username)
|
|
746
|
+
|
|
747
|
+
async def verify_current_password(self, identity: Identity, password: str) -> bool:
|
|
748
|
+
"""True iff ``password`` matches the local user's stored hash (self-service reauth)."""
|
|
749
|
+
user = await self._store.get_user(identity.user_id)
|
|
750
|
+
if user is None or user.password_hash is None:
|
|
751
|
+
return False
|
|
752
|
+
return await self._argon2(verify_password, user.password_hash, password)
|
|
753
|
+
|
|
754
|
+
async def reauth(
|
|
755
|
+
self, identity: Identity, password: str, *, token: str, client: str | None = None
|
|
756
|
+
) -> bool:
|
|
757
|
+
"""Step-up re-verification (ASVS 7.5.3): re-prove the caller's credential and, on success,
|
|
758
|
+
refresh the current session's ``reauth_at`` so it may perform highly sensitive operations for
|
|
759
|
+
the configured window. Local accounts re-verify the password (argon2); **AD accounts do a live
|
|
760
|
+
re-bind** against the directory so AD operators aren't locked out. Always audited."""
|
|
761
|
+
if identity.auth_provider is AuthProvider.AD:
|
|
762
|
+
ok = await self._reauth_ad(identity.username, password)
|
|
763
|
+
else:
|
|
764
|
+
ok = await self.verify_current_password(identity, password)
|
|
765
|
+
if ok:
|
|
766
|
+
# Re-anchor the session to the address it re-verified from, so a forced step-up triggered
|
|
767
|
+
# by a roamed/new client IP (WP-L3-13) clears once the caller re-proves from there.
|
|
768
|
+
await self._store.mark_session_reauthed(hash_token(token), client=client)
|
|
769
|
+
await self._audit(
|
|
770
|
+
"auth.reauth",
|
|
771
|
+
actor=identity.username,
|
|
772
|
+
detail=_json({"ok": ok, "provider": identity.auth_provider.value}),
|
|
773
|
+
)
|
|
774
|
+
return ok
|
|
775
|
+
|
|
776
|
+
async def _reauth_ad(self, username: str, password: str) -> bool:
|
|
777
|
+
"""Re-verify an AD credential via a live directory re-bind (no session adopted)."""
|
|
778
|
+
if self._ldap is None:
|
|
779
|
+
return False
|
|
780
|
+
try:
|
|
781
|
+
principal = await asyncio.to_thread(self._ldap.authenticate, username, password)
|
|
782
|
+
except LdapError:
|
|
783
|
+
return False
|
|
784
|
+
return principal is not None
|
|
785
|
+
|
|
786
|
+
async def has_recent_step_up(self, token: str | None) -> bool:
|
|
787
|
+
"""Whether the caller's session re-verified its credential within
|
|
788
|
+
``[auth].step_up_max_age_seconds`` (login is the first verification) — the gate for sensitive
|
|
789
|
+
operations (ASVS 7.5.3)."""
|
|
790
|
+
if not token:
|
|
791
|
+
return False
|
|
792
|
+
session = await self._store.get_session(hash_token(token))
|
|
793
|
+
if session is None or session.reauth_at is None:
|
|
794
|
+
return False
|
|
795
|
+
return (time.time() - session.reauth_at) <= self._settings.step_up_max_age_seconds
|
|
796
|
+
|
|
797
|
+
@staticmethod
|
|
798
|
+
def _same_host(a: str, b: str) -> bool:
|
|
799
|
+
"""Whether two client addresses denote the same host: an exact match, **or** both loopback (so a
|
|
800
|
+
dual-stack box that presents ``::1`` on one connection and ``127.0.0.1`` on another is treated as
|
|
801
|
+
one host — this keeps the loopback default a genuine no-op rather than a string mismatch).
|
|
802
|
+
Unparseable values fall back to exact match."""
|
|
803
|
+
if a == b:
|
|
804
|
+
return True
|
|
805
|
+
try:
|
|
806
|
+
return ipaddress.ip_address(a).is_loopback and ipaddress.ip_address(b).is_loopback
|
|
807
|
+
except ValueError:
|
|
808
|
+
return False
|
|
809
|
+
|
|
810
|
+
def _remember_new_ip(self, token_hash: str, client_ip: str) -> None:
|
|
811
|
+
"""Record the last new client IP flagged for a session — best-effort, per-process dedup of the
|
|
812
|
+
audit/notify side effects only. Bounded so session/address churn can't grow it without limit;
|
|
813
|
+
the step-up decision never depends on this cache (eviction only risks one extra audit row)."""
|
|
814
|
+
if len(self._new_ip_seen) >= _NEW_IP_DEDUP_MAX and token_hash not in self._new_ip_seen:
|
|
815
|
+
self._new_ip_seen.pop(next(iter(self._new_ip_seen)))
|
|
816
|
+
self._new_ip_seen[token_hash] = client_ip
|
|
817
|
+
|
|
818
|
+
async def flag_new_client_ip(
|
|
819
|
+
self, token: str | None, client_ip: str | None, *, path: str
|
|
820
|
+
) -> bool:
|
|
821
|
+
"""Admin-interface contextual-risk signal (ASVS 8.4.2, WP-L3-13): return ``True`` when this
|
|
822
|
+
sensitive request arrives from a client address that differs from the one the caller's session
|
|
823
|
+
last verified from. On the **first** observation of a given (session, address) it emits an
|
|
824
|
+
``auth.admin_action_new_ip`` audit event + a best-effort out-of-band notice; **repeat** hits from
|
|
825
|
+
the same un-cleared address still return ``True`` (so the step-up stays forced) but only log to
|
|
826
|
+
the rotating ops log — so a token replayed in a tight loop from one address cannot inflate the
|
|
827
|
+
audit table / notification channel (mirrors the ``_rate_limited`` precedent). The step-up
|
|
828
|
+
dependencies treat ``True`` as "force a fresh step-up"; a successful re-verify (``POST
|
|
829
|
+
/me/reauth`` **or** ``/auth/mfa-verify``) re-anchors the session to the new address (see
|
|
830
|
+
:meth:`reauth` / :meth:`verify_mfa`), so the signal clears and the caller proceeds. It is
|
|
831
|
+
**advisory + step-up-forcing only** — it never changes an authorization decision and never
|
|
832
|
+
blocks the non-admin request path.
|
|
833
|
+
|
|
834
|
+
Disabled (returns ``False`` with no side effects) unless ``[auth].admin_new_ip_step_up`` is on,
|
|
835
|
+
so loopback behavior is byte-identical by default; and even on, a single-host loopback session
|
|
836
|
+
never trips it because the request and the session resolve to the same loopback host (IPv4 or
|
|
837
|
+
IPv6 — see :meth:`_same_host`)."""
|
|
838
|
+
if not self._settings.admin_new_ip_step_up or not token:
|
|
839
|
+
return False
|
|
840
|
+
token_hash = hash_token(token)
|
|
841
|
+
session = await self._store.get_session(token_hash)
|
|
842
|
+
if session is None or session.revoked_at is not None:
|
|
843
|
+
return False
|
|
844
|
+
# No baseline address (older session / unknown login source) or the same host → not new. A
|
|
845
|
+
# session with no recorded address is not penalized, to avoid spurious admin friction.
|
|
846
|
+
if not session.client or not client_ip or self._same_host(client_ip, session.client):
|
|
847
|
+
return False
|
|
848
|
+
# New address → force a step-up (return True unconditionally). Emit the audit + notice once per
|
|
849
|
+
# (session, address); suppress repeats from the same un-cleared address so a replayed token
|
|
850
|
+
# cannot amplify the audit log / notifications.
|
|
851
|
+
if self._new_ip_seen.get(token_hash) == client_ip:
|
|
852
|
+
_log.warning(
|
|
853
|
+
"admin action from already-flagged new client IP (repeat suppressed): path=%s", path
|
|
854
|
+
)
|
|
855
|
+
return True
|
|
856
|
+
self._remember_new_ip(token_hash, client_ip)
|
|
857
|
+
user = await self._store.get_user(session.user_id)
|
|
858
|
+
username = user.username if user is not None else session.user_id
|
|
859
|
+
await self._audit(
|
|
860
|
+
"auth.admin_action_new_ip",
|
|
861
|
+
actor=username,
|
|
862
|
+
detail=_json({"path": path, "known_ip": session.client, "seen_ip": client_ip}),
|
|
863
|
+
)
|
|
864
|
+
await self._notify_security(
|
|
865
|
+
ADMIN_NEW_IP,
|
|
866
|
+
username=username,
|
|
867
|
+
email=user.email if user is not None else None,
|
|
868
|
+
client=client_ip,
|
|
869
|
+
detail={"known_ip": session.client},
|
|
870
|
+
)
|
|
871
|
+
return True
|
|
872
|
+
|
|
873
|
+
async def change_password(
|
|
874
|
+
self,
|
|
875
|
+
identity: Identity,
|
|
876
|
+
new_password: str,
|
|
877
|
+
*,
|
|
878
|
+
must_change: bool = False,
|
|
879
|
+
client: str | None = None,
|
|
880
|
+
) -> list[str]:
|
|
881
|
+
"""Set a local user's password (after policy check) and revoke their other sessions.
|
|
882
|
+
|
|
883
|
+
Returns policy violations (empty list = changed). No-op-safe for AD identities at the API
|
|
884
|
+
layer, which rejects password changes for AD users before calling this.
|
|
885
|
+
"""
|
|
886
|
+
violations = self._policy.violations(new_password, username=identity.username)
|
|
887
|
+
if violations:
|
|
888
|
+
return violations
|
|
889
|
+
await self._store.set_password(
|
|
890
|
+
identity.user_id,
|
|
891
|
+
password_hash=await self._argon2(hash_password, new_password),
|
|
892
|
+
must_change_password=must_change,
|
|
893
|
+
)
|
|
894
|
+
await self._store.revoke_user_sessions(identity.user_id)
|
|
895
|
+
await self._audit("auth.password_changed", actor=identity.username)
|
|
896
|
+
user = await self._store.get_user(identity.user_id)
|
|
897
|
+
await self._notify_security(
|
|
898
|
+
PASSWORD_CHANGED,
|
|
899
|
+
username=identity.username,
|
|
900
|
+
email=user.email if user is not None else None,
|
|
901
|
+
client=client,
|
|
902
|
+
)
|
|
903
|
+
return []
|
|
904
|
+
|
|
905
|
+
# --- MFA: native TOTP second factor (local accounts, WP-14, ASVS 6.3.3) --
|
|
906
|
+
|
|
907
|
+
def _mfa_required_for(self, user: UserRecord, roles: frozenset[Role]) -> bool:
|
|
908
|
+
"""Whether ``user`` must satisfy a second factor. **Local accounts only** — AD/Kerberos MFA is
|
|
909
|
+
delegated to the directory. An enrolled user always must; an un-enrolled user must when
|
|
910
|
+
``[auth].require_mfa`` is on and they hold the Administrator role (the chosen enforcement
|
|
911
|
+
target — regular users opt in by enrolling)."""
|
|
912
|
+
if user.auth_provider != AuthProvider.LOCAL.value:
|
|
913
|
+
return False
|
|
914
|
+
if user.totp_enabled:
|
|
915
|
+
return True
|
|
916
|
+
return self._settings.require_mfa and Role.ADMINISTRATOR in roles
|
|
917
|
+
|
|
918
|
+
async def mfa_satisfied(self, token: str | None) -> bool:
|
|
919
|
+
"""Whether the caller's session has met its second-factor requirement — True when the session
|
|
920
|
+
is MFA-verified, **or** when MFA isn't required for this user. Composed with
|
|
921
|
+
:meth:`has_recent_step_up` by the API to gate sensitive operations (WP-14). A required-but-
|
|
922
|
+
unverified session returns False, so the step-up routes 403 until ``POST /auth/mfa-verify``."""
|
|
923
|
+
if not token:
|
|
924
|
+
return False
|
|
925
|
+
session = await self._store.get_session(hash_token(token))
|
|
926
|
+
if session is None or session.revoked_at is not None:
|
|
927
|
+
return False
|
|
928
|
+
if session.mfa_verified_at is not None:
|
|
929
|
+
return True
|
|
930
|
+
user = await self._store.get_user(session.user_id)
|
|
931
|
+
if user is None:
|
|
932
|
+
return False
|
|
933
|
+
roles = _roles_from_ids(await self._store.get_user_role_ids(user.id))
|
|
934
|
+
return not self._mfa_required_for(user, roles)
|
|
935
|
+
|
|
936
|
+
async def begin_mfa_enrollment(self, identity: Identity) -> MfaEnrollment:
|
|
937
|
+
"""Stage a fresh TOTP secret for a local user and return it + the ``otpauth://`` URI for the
|
|
938
|
+
QR. Not active until proven via :meth:`confirm_mfa_enrollment`. Raises :class:`ValueError` for
|
|
939
|
+
an AD account or when MFA is already enabled (disable it first to re-enroll)."""
|
|
940
|
+
user = await self._store.get_user(identity.user_id)
|
|
941
|
+
if user is None or user.auth_provider != AuthProvider.LOCAL.value:
|
|
942
|
+
raise ValueError("only local users can enroll a TOTP authenticator")
|
|
943
|
+
if user.totp_enabled:
|
|
944
|
+
raise ValueError("MFA is already enabled; disable it before re-enrolling")
|
|
945
|
+
secret = totp.generate_secret()
|
|
946
|
+
await self._store.set_totp_secret(identity.user_id, secret=secret)
|
|
947
|
+
await self._audit("auth.mfa_enroll_started", actor=identity.username)
|
|
948
|
+
return MfaEnrollment(secret=secret, otpauth_uri=totp.otpauth_uri(secret, identity.username))
|
|
949
|
+
|
|
950
|
+
async def confirm_mfa_enrollment(
|
|
951
|
+
self, identity: Identity, code: str, *, token: str, client: str | None = None
|
|
952
|
+
) -> list[str] | None:
|
|
953
|
+
"""Confirm a staged enrollment by proving a live TOTP code. On success: activate MFA, mint the
|
|
954
|
+
single-use recovery codes (returned **once**, plaintext, for the user to save), mark the
|
|
955
|
+
current session MFA-verified, audit + notify. Returns the recovery codes, or ``None`` when the
|
|
956
|
+
code was wrong. Raises :class:`ValueError` if no enrollment is staged / the user isn't local."""
|
|
957
|
+
user = await self._store.get_user(identity.user_id)
|
|
958
|
+
if user is None or user.auth_provider != AuthProvider.LOCAL.value:
|
|
959
|
+
raise ValueError("only local users can enroll a TOTP authenticator")
|
|
960
|
+
secret = await self._store.get_totp_secret(identity.user_id)
|
|
961
|
+
if not secret:
|
|
962
|
+
raise ValueError("no enrollment in progress")
|
|
963
|
+
if not totp.verify_totp(secret, code.strip()):
|
|
964
|
+
await self._audit(
|
|
965
|
+
"auth.mfa_failed", actor=identity.username, detail=_json({"phase": "enroll"})
|
|
966
|
+
)
|
|
967
|
+
return None
|
|
968
|
+
plain = totp.generate_recovery_codes(self._settings.mfa_recovery_code_count)
|
|
969
|
+
hashes = [await self._argon2(hash_password, c) for c in plain]
|
|
970
|
+
await self._store.enable_totp(identity.user_id, recovery_code_hashes=hashes)
|
|
971
|
+
await self._store.mark_session_mfa_verified(hash_token(token))
|
|
972
|
+
await self._audit("auth.mfa_enrolled", actor=identity.username)
|
|
973
|
+
await self._notify_security(
|
|
974
|
+
MFA_ENABLED, username=user.username, email=user.email, client=client
|
|
975
|
+
)
|
|
976
|
+
return plain
|
|
977
|
+
|
|
978
|
+
async def verify_mfa(self, token: str | None, code: str, *, client: str | None = None) -> bool:
|
|
979
|
+
"""Validate a TOTP code (or a single-use recovery code) for the caller's session and, on
|
|
980
|
+
success, mark the session's second factor satisfied. Always audited; the API gates this behind
|
|
981
|
+
the login rate limiter. Returns False (never raises) for any invalid input."""
|
|
982
|
+
if not token:
|
|
983
|
+
return False
|
|
984
|
+
session = await self._store.get_session(hash_token(token))
|
|
985
|
+
if session is None or session.revoked_at is not None:
|
|
986
|
+
return False
|
|
987
|
+
user = await self._store.get_user(session.user_id)
|
|
988
|
+
if user is None or user.disabled or not user.totp_enabled:
|
|
989
|
+
return False
|
|
990
|
+
now = time.time()
|
|
991
|
+
# Per-account lockout covers the SECOND factor too (parity with the password path): a run of
|
|
992
|
+
# wrong codes locks the account, so MFA guessing isn't bounded only by the shared per-IP login
|
|
993
|
+
# limiter (which IP-rotation can sidestep). A locked account is refused before any verify.
|
|
994
|
+
if user.locked_until is not None and now < user.locked_until:
|
|
995
|
+
await self._audit(
|
|
996
|
+
"auth.mfa_failed", actor=user.username, detail=_json({"reason": "locked"})
|
|
997
|
+
)
|
|
998
|
+
return False
|
|
999
|
+
if await self._verify_second_factor(user, code):
|
|
1000
|
+
# The 2nd factor is now satisfied; also seed the step-up window (the session has completed
|
|
1001
|
+
# password + MFA) and clear the failure counter. (Initial enrollment has no factor to verify,
|
|
1002
|
+
# so this never fires there — keeping the enrollment step-up gate honest, WP-14.)
|
|
1003
|
+
await self._store.mark_session_mfa_verified(hash_token(token))
|
|
1004
|
+
# Re-anchor the session to the address that completed the second factor (parity with
|
|
1005
|
+
# reauth), so an MFA-required admin who roamed clears the WP-L3-13 new-client-IP signal with
|
|
1006
|
+
# one credential proof rather than being forced into a separate password step-up.
|
|
1007
|
+
await self._store.mark_session_reauthed(hash_token(token), client=client)
|
|
1008
|
+
await self._store.record_login_success(user.id, now=now)
|
|
1009
|
+
await self._audit("auth.mfa_verified", actor=user.username)
|
|
1010
|
+
return True
|
|
1011
|
+
# Wrong code: register the failure through the SAME machinery the password path uses, so the
|
|
1012
|
+
# per-account lockout + ACCOUNT_LOCKED notification fire on sustained MFA guessing.
|
|
1013
|
+
attempts, just_locked = await self._register_failure(user, now)
|
|
1014
|
+
await self._audit("auth.mfa_failed", actor=user.username)
|
|
1015
|
+
if just_locked:
|
|
1016
|
+
await self._notify_security(
|
|
1017
|
+
ACCOUNT_LOCKED,
|
|
1018
|
+
username=user.username,
|
|
1019
|
+
email=user.email,
|
|
1020
|
+
client=client,
|
|
1021
|
+
detail={"failed_attempts": attempts},
|
|
1022
|
+
)
|
|
1023
|
+
return False
|
|
1024
|
+
|
|
1025
|
+
async def _verify_second_factor(self, user: UserRecord, code: str) -> bool:
|
|
1026
|
+
"""True iff ``code`` is the user's current TOTP **or** an unused recovery code (consumed on
|
|
1027
|
+
match). TOTP is checked first (fast, no argon2); recovery codes are argon2id-hashed and
|
|
1028
|
+
single-use. Codes never collide (TOTP is 6 digits; recovery codes are dashed alphanumerics)."""
|
|
1029
|
+
code = code.strip()
|
|
1030
|
+
if not code:
|
|
1031
|
+
return False
|
|
1032
|
+
secret = await self._store.get_totp_secret(user.id)
|
|
1033
|
+
if secret:
|
|
1034
|
+
matched_step = totp.verify_totp_step(secret, code)
|
|
1035
|
+
if matched_step is not None:
|
|
1036
|
+
# Single-use within the step window (ASVS 6.5.1): the store advances the user's
|
|
1037
|
+
# highest-consumed time-step atomically, so a code captured and replayed inside its
|
|
1038
|
+
# ~30 s verify window resolves to a non-greater step and is rejected. Mirrors the
|
|
1039
|
+
# recovery-code compare-and-set; a genuine code always advances the step as time
|
|
1040
|
+
# moves forward, so a legitimate later login is unaffected.
|
|
1041
|
+
return await self._store.consume_totp_step(user.id, matched_step)
|
|
1042
|
+
normalized = code.upper() # recovery codes are minted uppercase
|
|
1043
|
+
hashes = await self._store.get_recovery_code_hashes(user.id)
|
|
1044
|
+
for h in hashes:
|
|
1045
|
+
if await self._argon2(verify_password, h, normalized):
|
|
1046
|
+
# Atomic compare-and-delete: only the caller that actually removes the hash wins, so a
|
|
1047
|
+
# concurrent verify of the same single-use code can't double-spend it (WP-14).
|
|
1048
|
+
return await self._store.consume_recovery_code_hash(user.id, h)
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
async def disable_mfa(self, identity: Identity, *, client: str | None = None) -> None:
|
|
1052
|
+
"""Self-service: turn off the caller's TOTP MFA (the API gates this behind step-up). Audited +
|
|
1053
|
+
the user is notified out-of-band (ASVS 6.3.7)."""
|
|
1054
|
+
user = await self._store.get_user(identity.user_id)
|
|
1055
|
+
await self._store.disable_totp(identity.user_id)
|
|
1056
|
+
await self._audit(
|
|
1057
|
+
"auth.mfa_disabled", actor=identity.username, detail=_json({"scope": "self"})
|
|
1058
|
+
)
|
|
1059
|
+
await self._notify_security(
|
|
1060
|
+
MFA_DISABLED,
|
|
1061
|
+
username=identity.username,
|
|
1062
|
+
email=user.email if user is not None else None,
|
|
1063
|
+
client=client,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
async def admin_reset_mfa(self, user_id: str, *, actor: str) -> None:
|
|
1067
|
+
"""Admin: clear a user's TOTP MFA (lost authenticator + no recovery codes) and revoke their
|
|
1068
|
+
sessions so they re-enroll. Raises :class:`ValueError` for an unknown or non-local user."""
|
|
1069
|
+
user = await self._store.get_user(user_id)
|
|
1070
|
+
if user is None:
|
|
1071
|
+
raise ValueError("no such user")
|
|
1072
|
+
if user.auth_provider != AuthProvider.LOCAL.value:
|
|
1073
|
+
raise ValueError("only local users have MFA to reset")
|
|
1074
|
+
await self._store.disable_totp(user_id)
|
|
1075
|
+
await self._store.revoke_user_sessions(user_id)
|
|
1076
|
+
await self._audit(
|
|
1077
|
+
"auth.mfa_reset",
|
|
1078
|
+
actor=actor,
|
|
1079
|
+
detail=_json({"user_id": user_id, "username": user.username}),
|
|
1080
|
+
)
|
|
1081
|
+
await self._notify_security(
|
|
1082
|
+
MFA_DISABLED, username=user.username, email=user.email, detail={"reset": True}
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
async def mfa_status(self, identity: Identity) -> MfaStatus:
|
|
1086
|
+
"""The caller's current MFA posture for ``GET /me/mfa``."""
|
|
1087
|
+
user = await self._store.get_user(identity.user_id)
|
|
1088
|
+
if user is None:
|
|
1089
|
+
return MfaStatus(
|
|
1090
|
+
enabled=False, enrolled_at=None, recovery_codes_remaining=0, required=False
|
|
1091
|
+
)
|
|
1092
|
+
remaining = (
|
|
1093
|
+
len(await self._store.get_recovery_code_hashes(identity.user_id))
|
|
1094
|
+
if user.totp_enabled
|
|
1095
|
+
else 0
|
|
1096
|
+
)
|
|
1097
|
+
return MfaStatus(
|
|
1098
|
+
enabled=user.totp_enabled,
|
|
1099
|
+
enrolled_at=user.totp_enrolled_at,
|
|
1100
|
+
recovery_codes_remaining=remaining,
|
|
1101
|
+
required=self._mfa_required_for(user, identity.roles),
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# --- administration (audited) -------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
@property
|
|
1107
|
+
def store(self) -> AdminStore:
|
|
1108
|
+
"""Read access to the backing store for admin list/read endpoints (users + audit)."""
|
|
1109
|
+
return self._store
|
|
1110
|
+
|
|
1111
|
+
async def security_events_for(self, username: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
|
1112
|
+
"""The caller's own security-event history (audited ``auth.*`` actions, most-recent-first) for
|
|
1113
|
+
``GET /me/security-events`` — normalized to plain dicts so the API doesn't see backend Row
|
|
1114
|
+
types. PHI-free (the audit ``detail`` carries metadata only)."""
|
|
1115
|
+
rows = await self._store.security_events_for_user(username, limit=limit)
|
|
1116
|
+
return [
|
|
1117
|
+
{"ts": float(r["ts"]), "action": str(r["action"]), "detail": r["detail"]} for r in rows
|
|
1118
|
+
]
|
|
1119
|
+
|
|
1120
|
+
async def create_local_user(
|
|
1121
|
+
self,
|
|
1122
|
+
*,
|
|
1123
|
+
username: str,
|
|
1124
|
+
password: str,
|
|
1125
|
+
display_name: str | None,
|
|
1126
|
+
email: str | None,
|
|
1127
|
+
roles: Sequence[str],
|
|
1128
|
+
actor: str,
|
|
1129
|
+
) -> str:
|
|
1130
|
+
user_id = uuid4().hex
|
|
1131
|
+
await self._store.create_user(
|
|
1132
|
+
user_id=user_id,
|
|
1133
|
+
username=username,
|
|
1134
|
+
auth_provider=AuthProvider.LOCAL.value,
|
|
1135
|
+
display_name=display_name,
|
|
1136
|
+
email=email,
|
|
1137
|
+
password_hash=await self._argon2(hash_password, password),
|
|
1138
|
+
# Admin-set the credential is a one-time temp: force rotation on first login so the
|
|
1139
|
+
# operator never sets a lasting password the user keeps (ASVS 6.4.6 / WP-L3-12).
|
|
1140
|
+
must_change_password=True,
|
|
1141
|
+
)
|
|
1142
|
+
await self._store.set_user_roles(user_id, roles, assigned_by=actor)
|
|
1143
|
+
await self._audit(
|
|
1144
|
+
"user.created", actor=actor, detail=_json({"username": username, "roles": list(roles)})
|
|
1145
|
+
)
|
|
1146
|
+
# If this created a second administrator, retire the now-redundant bootstrap admin (WP-3).
|
|
1147
|
+
await self._retire_superseded_bootstrap()
|
|
1148
|
+
return user_id
|
|
1149
|
+
|
|
1150
|
+
async def update_user(
|
|
1151
|
+
self,
|
|
1152
|
+
user_id: str,
|
|
1153
|
+
*,
|
|
1154
|
+
display_name: str | None,
|
|
1155
|
+
email: str | None,
|
|
1156
|
+
disabled: bool | None,
|
|
1157
|
+
actor: str,
|
|
1158
|
+
) -> None:
|
|
1159
|
+
before = await self._store.get_user(user_id) # capture old email/disabled for notifications
|
|
1160
|
+
await self._store.update_user_profile(user_id, display_name=display_name, email=email)
|
|
1161
|
+
if disabled is not None:
|
|
1162
|
+
await self._store.set_user_disabled(user_id, disabled=disabled)
|
|
1163
|
+
if disabled:
|
|
1164
|
+
await self._store.revoke_user_sessions(user_id)
|
|
1165
|
+
await self._audit("user.updated", actor=actor, detail=_json({"user_id": user_id}))
|
|
1166
|
+
if before is not None:
|
|
1167
|
+
if email is not None and email != before.email:
|
|
1168
|
+
# Notify the OLD address — so the legitimate owner is alerted even if an attacker (or a
|
|
1169
|
+
# mistaken admin) repointed the account's email to one they control (ASVS 6.3.7).
|
|
1170
|
+
await self._notify_security(
|
|
1171
|
+
EMAIL_CHANGED,
|
|
1172
|
+
username=before.username,
|
|
1173
|
+
email=before.email,
|
|
1174
|
+
detail={"new_email": email},
|
|
1175
|
+
)
|
|
1176
|
+
if disabled and not before.disabled:
|
|
1177
|
+
await self._notify_security(
|
|
1178
|
+
ACCOUNT_DISABLED, username=before.username, email=before.email
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
async def delete_user(self, user_id: str, *, actor: str) -> None:
|
|
1182
|
+
await self._store.delete_user(user_id)
|
|
1183
|
+
await self._audit("user.deleted", actor=actor, detail=_json({"user_id": user_id}))
|
|
1184
|
+
|
|
1185
|
+
async def set_roles(self, user_id: str, roles: Sequence[str], *, actor: str) -> None:
|
|
1186
|
+
user = await self._store.get_user(user_id) # for the notification address
|
|
1187
|
+
await self._store.set_user_roles(user_id, roles, assigned_by=actor)
|
|
1188
|
+
await self._store.revoke_user_sessions(user_id) # re-resolve permissions on next login
|
|
1189
|
+
await self._audit(
|
|
1190
|
+
"user.roles_changed",
|
|
1191
|
+
actor=actor,
|
|
1192
|
+
detail=_json({"user_id": user_id, "roles": list(roles)}),
|
|
1193
|
+
)
|
|
1194
|
+
if user is not None:
|
|
1195
|
+
await self._notify_security(
|
|
1196
|
+
ROLES_CHANGED,
|
|
1197
|
+
username=user.username,
|
|
1198
|
+
email=user.email,
|
|
1199
|
+
detail={"roles": list(roles)},
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
async def admin_reset_password(self, user_id: str, *, actor: str) -> str:
|
|
1203
|
+
"""Admin-initiated password reset (ASVS 6.4.6 / WP-L3-12). Generate a CSPRNG one-time password
|
|
1204
|
+
through the active policy, set it with ``must_change_password`` (forces a change on first
|
|
1205
|
+
login), and revoke the user's sessions. Returns the one-time credential **once** so the caller
|
|
1206
|
+
can convey it out-of-band — the administrator never sets a lasting password the user keeps. The
|
|
1207
|
+
affected user is also notified out-of-band by email. Raises :class:`ValueError` for an unknown
|
|
1208
|
+
user or a non-local (AD) account; the API maps these to 4xx."""
|
|
1209
|
+
user = await self._store.get_user(user_id)
|
|
1210
|
+
if user is None:
|
|
1211
|
+
raise ValueError("no such user")
|
|
1212
|
+
if user.auth_provider != AuthProvider.LOCAL.value:
|
|
1213
|
+
raise ValueError("only local users have a password to reset")
|
|
1214
|
+
temp = self._generate_policy_password()
|
|
1215
|
+
await self._store.set_password(
|
|
1216
|
+
user_id,
|
|
1217
|
+
password_hash=await self._argon2(hash_password, temp),
|
|
1218
|
+
must_change_password=True,
|
|
1219
|
+
)
|
|
1220
|
+
await self._store.revoke_user_sessions(user_id) # invalidate any live sessions on reset
|
|
1221
|
+
await self._audit(
|
|
1222
|
+
"auth.password_reset",
|
|
1223
|
+
actor=actor,
|
|
1224
|
+
detail=_json({"user_id": user_id, "username": user.username}),
|
|
1225
|
+
)
|
|
1226
|
+
await self._notify_security(PASSWORD_RESET, username=user.username, email=user.email)
|
|
1227
|
+
return temp
|
|
1228
|
+
|
|
1229
|
+
async def set_channel_scope(
|
|
1230
|
+
self, user_id: str, channels: Sequence[str] | None, *, actor: str
|
|
1231
|
+
) -> None:
|
|
1232
|
+
"""Set a user's per-channel RBAC scope (``None`` = all). Revokes their sessions so the new
|
|
1233
|
+
scope takes effect immediately, and audits the change."""
|
|
1234
|
+
scope_json = None if channels is None else _json(sorted(set(channels)))
|
|
1235
|
+
await self._store.set_user_channel_scope(user_id, scope_json)
|
|
1236
|
+
await self._store.revoke_user_sessions(user_id)
|
|
1237
|
+
await self._audit(
|
|
1238
|
+
"user.channel_scope_changed",
|
|
1239
|
+
actor=actor,
|
|
1240
|
+
detail=_json(
|
|
1241
|
+
{
|
|
1242
|
+
"user_id": user_id,
|
|
1243
|
+
"channels": None if channels is None else sorted(set(channels)),
|
|
1244
|
+
}
|
|
1245
|
+
),
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
async def is_last_enabled_admin(self, user_id: str) -> bool:
|
|
1249
|
+
"""True iff ``user_id`` is an enabled administrator and the only one remaining.
|
|
1250
|
+
|
|
1251
|
+
Guards the role-removal path so the deployment can never be left with no usable admin
|
|
1252
|
+
account (the bootstrap admin only regenerates against a fully empty users table).
|
|
1253
|
+
"""
|
|
1254
|
+
admins: set[str] = set()
|
|
1255
|
+
for user in await self._store.list_users():
|
|
1256
|
+
if user.disabled:
|
|
1257
|
+
continue
|
|
1258
|
+
if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id):
|
|
1259
|
+
admins.add(user.id)
|
|
1260
|
+
return admins == {user_id}
|
|
1261
|
+
|
|
1262
|
+
async def set_ad_group_map(self, entries: Sequence[tuple[str, str]], *, actor: str) -> None:
|
|
1263
|
+
await self._store.set_ad_group_role_map(entries)
|
|
1264
|
+
await self._audit(
|
|
1265
|
+
"ad_group_map.updated", actor=actor, detail=_json({"count": len(entries)})
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
async def set_ad_group_scope_map(
|
|
1269
|
+
self, entries: Sequence[tuple[str, str]], *, actor: str
|
|
1270
|
+
) -> None:
|
|
1271
|
+
"""Replace the AD-group → channel-scope map (C3). Takes effect on each AD user's next login."""
|
|
1272
|
+
await self._store.set_ad_group_scope_map(entries)
|
|
1273
|
+
await self._audit(
|
|
1274
|
+
"ad_group_scope_map.updated", actor=actor, detail=_json({"count": len(entries)})
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
# --- audit ---------------------------------------------------------------
|
|
1278
|
+
|
|
1279
|
+
async def audit_permission_denied(
|
|
1280
|
+
self, identity: Identity, permission: Permission, path: str
|
|
1281
|
+
) -> None:
|
|
1282
|
+
await self._audit(
|
|
1283
|
+
"auth.permission_denied",
|
|
1284
|
+
actor=identity.username,
|
|
1285
|
+
detail=_json({"permission": permission.value, "path": path}),
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
async def _audit(
|
|
1289
|
+
self, action: str, *, actor: str | None = None, detail: str | None = None
|
|
1290
|
+
) -> None:
|
|
1291
|
+
await self._store.record_audit(action, actor=actor, detail=detail)
|
|
1292
|
+
|
|
1293
|
+
async def _notify_security(
|
|
1294
|
+
self,
|
|
1295
|
+
event_type: str,
|
|
1296
|
+
*,
|
|
1297
|
+
username: str,
|
|
1298
|
+
email: str | None,
|
|
1299
|
+
client: str | None = None,
|
|
1300
|
+
detail: dict[str, Any] | None = None,
|
|
1301
|
+
) -> None:
|
|
1302
|
+
"""Best-effort out-of-band security-event push (ASVS 6.3.5/6.3.7). A missing notifier or a
|
|
1303
|
+
notifier failure is swallowed (logged) — a notification must never break a login or an admin
|
|
1304
|
+
action. The event is also already in the audit log (the /me/security-events feed)."""
|
|
1305
|
+
if self._security_notifier is None:
|
|
1306
|
+
return
|
|
1307
|
+
try:
|
|
1308
|
+
await self._security_notifier.notify(
|
|
1309
|
+
SecurityEvent(
|
|
1310
|
+
event_type=event_type,
|
|
1311
|
+
username=username,
|
|
1312
|
+
email=email,
|
|
1313
|
+
client_ip=client,
|
|
1314
|
+
detail=detail or {},
|
|
1315
|
+
)
|
|
1316
|
+
)
|
|
1317
|
+
except Exception: # noqa: BLE001 - best-effort; never propagate into auth
|
|
1318
|
+
_log.warning(
|
|
1319
|
+
"security-event notification failed (%s for %s)",
|
|
1320
|
+
event_type,
|
|
1321
|
+
username,
|
|
1322
|
+
exc_info=True,
|
|
1323
|
+
)
|