qodalis-cli-server-plugin-users 1.0.0b6__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.
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
- qodalis_cli_users/__init__.py +134 -0
- qodalis_cli_users/_http.py +162 -0
- qodalis_cli_users/cli_users_builder.py +385 -0
- qodalis_cli_users/controllers/__init__.py +13 -0
- qodalis_cli_users/controllers/_routing.py +25 -0
- qodalis_cli_users/controllers/audit_controller.py +40 -0
- qodalis_cli_users/controllers/groups_controller.py +109 -0
- qodalis_cli_users/controllers/sessions_controller.py +114 -0
- qodalis_cli_users/controllers/users_controller.py +183 -0
- qodalis_cli_users/errors.py +118 -0
- qodalis_cli_users/middleware/__init__.py +5 -0
- qodalis_cli_users/middleware/auth_middleware.py +77 -0
- qodalis_cli_users/models.py +282 -0
- qodalis_cli_users/permissions.py +58 -0
- qodalis_cli_users/server_protection.py +209 -0
- qodalis_cli_users/services/__init__.py +6 -0
- qodalis_cli_users/services/auth_service.py +786 -0
- qodalis_cli_users/services/password_hasher.py +98 -0
- qodalis_cli_users/services/users_service.py +330 -0
- qodalis_cli_users/shell_access.py +125 -0
- qodalis_cli_users/stores/__init__.py +27 -0
- qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
- qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
- qodalis_cli_users/stores/in_memory_group_store.py +65 -0
- qodalis_cli_users/stores/in_memory_session_store.py +71 -0
- qodalis_cli_users/stores/in_memory_user_store.py +102 -0
- qodalis_cli_users/stores/interfaces.py +208 -0
- qodalis_cli_users/validation.py +100 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Authentication service — credentials, login/logout, lockout, rate limits,
|
|
2
|
+
sessions, WebSocket tickets, effective permissions, revocation and audit."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import secrets
|
|
11
|
+
import string
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from collections import OrderedDict, deque
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from typing import Any, Awaitable, Callable, Optional, Union
|
|
17
|
+
|
|
18
|
+
from .._http import forwarded_client_ip
|
|
19
|
+
from ..errors import UsersApiError, forbidden, unauthorized
|
|
20
|
+
from ..models import (
|
|
21
|
+
AuditEntry,
|
|
22
|
+
ExternalAuthResult,
|
|
23
|
+
Session,
|
|
24
|
+
SessionMetadata,
|
|
25
|
+
User,
|
|
26
|
+
now_ms,
|
|
27
|
+
)
|
|
28
|
+
from ..permissions import ADMIN_GROUP, USERS_GROUP, has_permission, merge_permissions, permission_matches
|
|
29
|
+
from ..stores.interfaces import (
|
|
30
|
+
IAuditStore,
|
|
31
|
+
ICredentialStore,
|
|
32
|
+
IGroupStore,
|
|
33
|
+
ISessionStore,
|
|
34
|
+
IUserStore,
|
|
35
|
+
)
|
|
36
|
+
from ..validation import PASSWORD_MAX, has_lone_surrogates, sanitize
|
|
37
|
+
from . import password_hasher
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000
|
|
42
|
+
DEFAULT_MAX_SESSION_LIFETIME_MS = 12 * 60 * 60 * 1000
|
|
43
|
+
DEFAULT_MIN_PASSWORD_LENGTH = 8
|
|
44
|
+
DEFAULT_MAX_LOGIN_ATTEMPTS = 5
|
|
45
|
+
DEFAULT_LOCKOUT_MS = 5 * 60 * 1000
|
|
46
|
+
DEFAULT_RATE_LIMIT_ATTEMPTS = 20
|
|
47
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 1000
|
|
48
|
+
TICKET_TTL_MS = 30 * 1000
|
|
49
|
+
DEFAULT_USERS_GROUP_PERMISSIONS = ["commands:execute"]
|
|
50
|
+
WEAK_ADMIN_PASSWORDS = {"root", "admin", "password"}
|
|
51
|
+
|
|
52
|
+
ACCOUNT_LOCKED = "account locked, try again later"
|
|
53
|
+
ACCOUNT_DISABLED = "account disabled"
|
|
54
|
+
INVALID_CREDENTIALS = "invalid credentials"
|
|
55
|
+
PASSWORD_CHANGE_REQUIRED = "password change required"
|
|
56
|
+
TOO_MANY_REQUESTS = "too many requests, try again later"
|
|
57
|
+
INSUFFICIENT_PRIVILEGES = "insufficient privileges for this user"
|
|
58
|
+
|
|
59
|
+
_MAX_TRACKED_KEYS = 10_000
|
|
60
|
+
|
|
61
|
+
#: Receives the JSON-encoded event; may be sync or async.
|
|
62
|
+
BroadcastFn = Callable[[str], Union[Awaitable[None], None]]
|
|
63
|
+
#: ``(user_id, json_message)`` — delivers to sockets of that user only.
|
|
64
|
+
UserEventSender = Callable[[str, str], Awaitable[None]]
|
|
65
|
+
|
|
66
|
+
#: Audit events that never change who may do what (no listener notification).
|
|
67
|
+
_NON_CHANGING_EVENTS = frozenset({"login", "login_failed", "shell_opened", "shell_closed"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hash_token(token: str) -> str:
|
|
71
|
+
"""SHA-256 (hex) of a bearer token or ticket."""
|
|
72
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def covers(granted: list[str], required: list[str]) -> bool:
|
|
76
|
+
"""True when every permission in ``required`` is matched by ``granted``."""
|
|
77
|
+
return all(has_permission(granted, p) for p in required)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def uncovered(granted: list[str], required: list[str]) -> list[str]:
|
|
81
|
+
return [p for p in required if not has_permission(granted, p)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AuthContext:
|
|
86
|
+
"""The authenticated caller of a request."""
|
|
87
|
+
|
|
88
|
+
user: User
|
|
89
|
+
groups: list[str]
|
|
90
|
+
permissions: list[str]
|
|
91
|
+
session: Session | None = None
|
|
92
|
+
token: str | None = None
|
|
93
|
+
external: bool = False
|
|
94
|
+
|
|
95
|
+
def can(self, permission: str) -> bool:
|
|
96
|
+
return has_permission(self.permissions, permission)
|
|
97
|
+
|
|
98
|
+
def is_self(self, user: User | None) -> bool:
|
|
99
|
+
return user is not None and user.id == self.user.id
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def must_change_password(self) -> bool:
|
|
103
|
+
return bool(self.user.must_change_password)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class LoginResult:
|
|
108
|
+
token: str
|
|
109
|
+
session: Session
|
|
110
|
+
user: User
|
|
111
|
+
permissions: list[str]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class _LockoutState:
|
|
116
|
+
failures: int = 0
|
|
117
|
+
locked_until: int = 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class _Ticket:
|
|
122
|
+
session_id: str | None
|
|
123
|
+
user_id: str
|
|
124
|
+
expires_at: int
|
|
125
|
+
external_ctx: AuthContext | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class AuthServiceOptions:
|
|
130
|
+
session_timeout_ms: int = DEFAULT_SESSION_TIMEOUT_MS
|
|
131
|
+
max_session_lifetime_ms: int = DEFAULT_MAX_SESSION_LIFETIME_MS
|
|
132
|
+
min_password_length: int = DEFAULT_MIN_PASSWORD_LENGTH
|
|
133
|
+
max_login_attempts: int = DEFAULT_MAX_LOGIN_ATTEMPTS
|
|
134
|
+
lockout_ms: int = DEFAULT_LOCKOUT_MS
|
|
135
|
+
rate_limit_attempts: int = DEFAULT_RATE_LIMIT_ATTEMPTS
|
|
136
|
+
rate_limit_window_ms: int = DEFAULT_RATE_LIMIT_WINDOW_MS
|
|
137
|
+
#: Trusted reverse-proxy hops for the client IP: ``False``/``0`` uses the
|
|
138
|
+
#: socket peer, ``True`` = 1 hop, an int ``n`` = ``n`` hops (the client is
|
|
139
|
+
#: the ``X-Forwarded-For`` entry at ``len - n``).
|
|
140
|
+
trust_proxy: bool | int = False
|
|
141
|
+
#: Origins accepted for state-changing requests without a bearer token
|
|
142
|
+
#: (CSRF protection in adapter/cookie mode). Same-origin requests are
|
|
143
|
+
#: always accepted.
|
|
144
|
+
allowed_origins: list[str] | None = None
|
|
145
|
+
ticket_ttl_ms: int = TICKET_TTL_MS
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AuthService:
|
|
149
|
+
"""Handles authentication, sessions and the security bookkeeping shared by
|
|
150
|
+
the controllers and the server-protection middleware."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
user_store: IUserStore,
|
|
155
|
+
session_store: ISessionStore,
|
|
156
|
+
credential_store: ICredentialStore,
|
|
157
|
+
audit_store: IAuditStore,
|
|
158
|
+
group_store: IGroupStore | None = None,
|
|
159
|
+
*,
|
|
160
|
+
options: AuthServiceOptions | None = None,
|
|
161
|
+
broadcast_fn: BroadcastFn | None = None,
|
|
162
|
+
auth_adapter: Optional[Callable[[Any], Any]] = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
if group_store is None:
|
|
165
|
+
from ..stores.in_memory_group_store import InMemoryGroupStore
|
|
166
|
+
group_store = InMemoryGroupStore()
|
|
167
|
+
self.user_store = user_store
|
|
168
|
+
self.session_store = session_store
|
|
169
|
+
self.credential_store = credential_store
|
|
170
|
+
self.audit_store = audit_store
|
|
171
|
+
self.group_store = group_store
|
|
172
|
+
self.options = options or AuthServiceOptions()
|
|
173
|
+
self.broadcast_fn = broadcast_fn
|
|
174
|
+
#: Set when mounted on a CLI server: user-scoped event delivery.
|
|
175
|
+
self.user_event_sender: UserEventSender | None = None
|
|
176
|
+
self.auth_adapter = auth_adapter
|
|
177
|
+
self._lockouts: OrderedDict[str, _LockoutState] = OrderedDict()
|
|
178
|
+
self._rate: OrderedDict[str, deque[int]] = OrderedDict()
|
|
179
|
+
self._tickets: dict[str, _Ticket] = {}
|
|
180
|
+
self._change_listeners: list[Callable[[], Any]] = []
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Options
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def session_timeout_ms(self) -> int:
|
|
188
|
+
return self.options.session_timeout_ms
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def max_session_lifetime_ms(self) -> int:
|
|
192
|
+
return self.options.max_session_lifetime_ms
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def external_auth(self) -> bool:
|
|
196
|
+
return self.auth_adapter is not None
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Passwords
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def generate_token() -> str:
|
|
204
|
+
return secrets.token_hex(32)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def generate_password(length: int = 16) -> str:
|
|
208
|
+
alphabet = string.ascii_letters + string.digits
|
|
209
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
210
|
+
|
|
211
|
+
def validate_password_policy(self, password: str | None) -> str | None:
|
|
212
|
+
"""Return an error message when ``password`` violates the policy."""
|
|
213
|
+
if password is None or password == "":
|
|
214
|
+
return "password is required"
|
|
215
|
+
if len(password) < self.options.min_password_length:
|
|
216
|
+
return f"password must be at least {self.options.min_password_length} characters"
|
|
217
|
+
if len(password) > PASSWORD_MAX:
|
|
218
|
+
return f"password must be at most {PASSWORD_MAX} characters"
|
|
219
|
+
if has_lone_surrogates(password):
|
|
220
|
+
return "invalid password"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
async def set_password(self, user_id: str, password: str) -> None:
|
|
224
|
+
encoded, salt = await password_hasher.hash_password_async(password)
|
|
225
|
+
await self.credential_store.set_password(user_id, encoded, salt)
|
|
226
|
+
|
|
227
|
+
async def verify_password(self, user_id: str, password: str) -> bool:
|
|
228
|
+
creds = await self.credential_store.get_credentials(user_id)
|
|
229
|
+
if not creds:
|
|
230
|
+
await password_hasher.dummy_verify_async(password)
|
|
231
|
+
return False
|
|
232
|
+
if len(password) > PASSWORD_MAX:
|
|
233
|
+
return False
|
|
234
|
+
valid = await password_hasher.verify_password_async(password, creds.password_hash, creds.salt)
|
|
235
|
+
if valid and password_hasher.needs_rehash(creds.password_hash):
|
|
236
|
+
await self.set_password(user_id, password)
|
|
237
|
+
return valid
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Lockout (atomic: the attempt is reserved before PBKDF2 runs)
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def lockout_key(user: User | None, attempted: str) -> str:
|
|
245
|
+
if user is not None:
|
|
246
|
+
return user.id
|
|
247
|
+
return "unknown:" + attempted.strip().lower()[:256]
|
|
248
|
+
|
|
249
|
+
def _state(self, key: str, create: bool) -> _LockoutState | None:
|
|
250
|
+
state = self._lockouts.get(key)
|
|
251
|
+
if state is None and create:
|
|
252
|
+
state = _LockoutState()
|
|
253
|
+
self._lockouts[key] = state
|
|
254
|
+
while len(self._lockouts) > _MAX_TRACKED_KEYS:
|
|
255
|
+
self._lockouts.popitem(last=False)
|
|
256
|
+
return state
|
|
257
|
+
|
|
258
|
+
def is_locked(self, key: str) -> bool:
|
|
259
|
+
state = self._lockouts.get(key)
|
|
260
|
+
if not state or not state.locked_until:
|
|
261
|
+
return False
|
|
262
|
+
if state.locked_until > now_ms():
|
|
263
|
+
return True
|
|
264
|
+
self._lockouts.pop(key, None) # lock expired
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
def reserve_attempt(self, key: str) -> bool:
|
|
268
|
+
"""Count an attempt *before* verifying it. Returns ``False`` when the
|
|
269
|
+
key is locked. Synchronous, so concurrent requests cannot interleave
|
|
270
|
+
between the check and the increment."""
|
|
271
|
+
if self.is_locked(key):
|
|
272
|
+
return False
|
|
273
|
+
state = self._state(key, True)
|
|
274
|
+
assert state is not None
|
|
275
|
+
state.failures += 1
|
|
276
|
+
if state.failures >= self.options.max_login_attempts:
|
|
277
|
+
state.failures = 0
|
|
278
|
+
state.locked_until = now_ms() + self.options.lockout_ms
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
def reset_failures(self, key: str) -> None:
|
|
282
|
+
self._lockouts.pop(key, None)
|
|
283
|
+
|
|
284
|
+
# Backwards-compatible alias
|
|
285
|
+
def register_failure(self, key: str) -> None:
|
|
286
|
+
self.reserve_attempt(key)
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
# Per-IP rate limit
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def client_ip(self, request: Any) -> str:
|
|
293
|
+
forwarded = forwarded_client_ip(request.headers, self.options.trust_proxy)
|
|
294
|
+
if forwarded:
|
|
295
|
+
return forwarded
|
|
296
|
+
client = getattr(request, "client", None)
|
|
297
|
+
return (client.host if client else None) or "unknown"
|
|
298
|
+
|
|
299
|
+
def check_rate_limit(self, request: Any) -> None:
|
|
300
|
+
"""Count one attempt for the caller's IP; 429 above the limit."""
|
|
301
|
+
ip = self.client_ip(request)
|
|
302
|
+
now = now_ms()
|
|
303
|
+
window = self._rate.get(ip)
|
|
304
|
+
if window is None:
|
|
305
|
+
window = deque()
|
|
306
|
+
self._rate[ip] = window
|
|
307
|
+
while len(self._rate) > _MAX_TRACKED_KEYS:
|
|
308
|
+
self._rate.popitem(last=False)
|
|
309
|
+
cutoff = now - self.options.rate_limit_window_ms
|
|
310
|
+
while window and window[0] <= cutoff:
|
|
311
|
+
window.popleft()
|
|
312
|
+
if len(window) >= self.options.rate_limit_attempts:
|
|
313
|
+
raise UsersApiError(429, TOO_MANY_REQUESTS)
|
|
314
|
+
window.append(now)
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# Users / groups / permissions
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
async def find_user(self, id_name_or_email: str) -> User | None:
|
|
321
|
+
"""Resolve a user by id, name or email."""
|
|
322
|
+
user = await self.user_store.get_user(id_name_or_email)
|
|
323
|
+
if user is None and "@" in id_name_or_email:
|
|
324
|
+
user = await self.user_store.get_user_by_email(id_name_or_email)
|
|
325
|
+
return user
|
|
326
|
+
|
|
327
|
+
async def effective_permissions(self, groups: list[str]) -> list[str]:
|
|
328
|
+
lists: list[list[str]] = []
|
|
329
|
+
for name in groups:
|
|
330
|
+
group = await self.group_store.get_group(name)
|
|
331
|
+
if group:
|
|
332
|
+
lists.append(group.permissions)
|
|
333
|
+
return merge_permissions(*lists)
|
|
334
|
+
|
|
335
|
+
async def user_permissions(self, user: User) -> list[str]:
|
|
336
|
+
return await self.effective_permissions(list(user.groups or []))
|
|
337
|
+
|
|
338
|
+
async def ensure_covers_user(self, ctx: AuthContext, target: User) -> None:
|
|
339
|
+
"""B3: a caller may only act on users whose permissions it holds."""
|
|
340
|
+
if ctx.is_self(target):
|
|
341
|
+
return
|
|
342
|
+
if not covers(ctx.permissions, await self.user_permissions(target)):
|
|
343
|
+
raise forbidden(INSUFFICIENT_PRIVILEGES)
|
|
344
|
+
|
|
345
|
+
async def ensure_can_grant_groups(self, ctx: AuthContext, groups: list[str]) -> None:
|
|
346
|
+
"""B3: adding a user to ``groups`` requires holding their permissions."""
|
|
347
|
+
missing = uncovered(ctx.permissions, await self.effective_permissions(groups))
|
|
348
|
+
if missing:
|
|
349
|
+
raise forbidden(f"cannot grant permissions you do not hold: {', '.join(missing)}")
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def ensure_can_grant_permissions(ctx: AuthContext, permissions: list[str]) -> None:
|
|
353
|
+
missing = uncovered(ctx.permissions, permissions)
|
|
354
|
+
if missing:
|
|
355
|
+
raise forbidden(f"cannot grant permissions you do not hold: {', '.join(missing)}")
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def in_group(user: User, group: str) -> bool:
|
|
359
|
+
g = group.lower()
|
|
360
|
+
return any(x.lower() == g for x in user.groups or [])
|
|
361
|
+
|
|
362
|
+
async def is_last_enabled_admin(self, user: User) -> bool:
|
|
363
|
+
if user.disabled or not self.in_group(user, ADMIN_GROUP):
|
|
364
|
+
return False
|
|
365
|
+
admins = [
|
|
366
|
+
u for u in await self.user_store.get_users()
|
|
367
|
+
if not u.disabled and self.in_group(u, ADMIN_GROUP)
|
|
368
|
+
]
|
|
369
|
+
return len(admins) <= 1
|
|
370
|
+
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
# Auditing / events
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
async def audit(
|
|
376
|
+
self,
|
|
377
|
+
event: str,
|
|
378
|
+
username: str,
|
|
379
|
+
*,
|
|
380
|
+
details: str | None = None,
|
|
381
|
+
actor: str | None = None,
|
|
382
|
+
metadata: dict[str, Any] | None = None,
|
|
383
|
+
user_id: str | None = None,
|
|
384
|
+
actor_id: str | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
try:
|
|
387
|
+
await self.audit_store.log(AuditEntry(
|
|
388
|
+
event=event,
|
|
389
|
+
username=sanitize(username, 256) or "",
|
|
390
|
+
details=sanitize(details, 1024),
|
|
391
|
+
actor=sanitize(actor, 256),
|
|
392
|
+
metadata=metadata,
|
|
393
|
+
user_id=user_id,
|
|
394
|
+
actor_id=actor_id,
|
|
395
|
+
))
|
|
396
|
+
except Exception:
|
|
397
|
+
logger.exception("Failed to write audit entry %s", event)
|
|
398
|
+
|
|
399
|
+
if event not in _NON_CHANGING_EVENTS:
|
|
400
|
+
self.notify_change()
|
|
401
|
+
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
# Change listeners (e.g. open shells re-check their session)
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
def add_change_listener(self, listener: Callable[[], Any]) -> None:
|
|
407
|
+
"""Call ``listener()`` after sessions, users, groups or permissions
|
|
408
|
+
change (logout, revoke, disable, delete, group/permission edits,
|
|
409
|
+
expiry sweep). Listeners must not block."""
|
|
410
|
+
if listener not in self._change_listeners:
|
|
411
|
+
self._change_listeners.append(listener)
|
|
412
|
+
|
|
413
|
+
def remove_change_listener(self, listener: Callable[[], Any]) -> None:
|
|
414
|
+
if listener in self._change_listeners:
|
|
415
|
+
self._change_listeners.remove(listener)
|
|
416
|
+
|
|
417
|
+
def notify_change(self) -> None:
|
|
418
|
+
for listener in list(self._change_listeners):
|
|
419
|
+
try:
|
|
420
|
+
listener()
|
|
421
|
+
except Exception:
|
|
422
|
+
logger.exception("Change listener failed")
|
|
423
|
+
|
|
424
|
+
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
425
|
+
if not self.broadcast_fn:
|
|
426
|
+
return
|
|
427
|
+
try:
|
|
428
|
+
result = self.broadcast_fn(json.dumps(message))
|
|
429
|
+
if inspect.isawaitable(result):
|
|
430
|
+
await result
|
|
431
|
+
except Exception:
|
|
432
|
+
logger.exception("Failed to broadcast %s", message.get("type"))
|
|
433
|
+
|
|
434
|
+
async def notify_user(self, user_id: str, message: dict[str, Any]) -> None:
|
|
435
|
+
"""Deliver a user-scoped event only to that user's event sockets
|
|
436
|
+
(falls back to ``broadcast_fn`` when not mounted on a CLI server)."""
|
|
437
|
+
if self.user_event_sender is not None:
|
|
438
|
+
try:
|
|
439
|
+
await self.user_event_sender(user_id, json.dumps(message))
|
|
440
|
+
except Exception:
|
|
441
|
+
logger.exception("Failed to send %s", message.get("type"))
|
|
442
|
+
return
|
|
443
|
+
await self.broadcast(message)
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
# Sessions
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def is_expired(self, session: Session) -> bool:
|
|
450
|
+
return now_ms() > session.expires_at(self.session_timeout_ms, self.max_session_lifetime_ms)
|
|
451
|
+
|
|
452
|
+
def expires_at(self, session: Session) -> int:
|
|
453
|
+
return session.expires_at(self.session_timeout_ms, self.max_session_lifetime_ms)
|
|
454
|
+
|
|
455
|
+
async def revoke_session(
|
|
456
|
+
self,
|
|
457
|
+
session: Session,
|
|
458
|
+
reason: str,
|
|
459
|
+
*,
|
|
460
|
+
actor: str | None = None,
|
|
461
|
+
actor_id: str | None = None,
|
|
462
|
+
username: str | None = None,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Delete a session, notify the user's sockets and audit it."""
|
|
465
|
+
await self.session_store.delete_session_by_id(session.id)
|
|
466
|
+
self._drop_tickets(session.id)
|
|
467
|
+
await self.notify_user(session.user_id, {
|
|
468
|
+
"type": "session-kicked",
|
|
469
|
+
"data": {"sessionId": session.id, "userId": session.user_id, "reason": reason},
|
|
470
|
+
})
|
|
471
|
+
await self.audit(
|
|
472
|
+
"session_revoked",
|
|
473
|
+
username or session.user_name,
|
|
474
|
+
details=reason,
|
|
475
|
+
actor=actor,
|
|
476
|
+
actor_id=actor_id,
|
|
477
|
+
user_id=session.user_id,
|
|
478
|
+
metadata={"sessionId": session.id},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def revoke_user_sessions(
|
|
482
|
+
self,
|
|
483
|
+
user_id: str,
|
|
484
|
+
reason: str,
|
|
485
|
+
*,
|
|
486
|
+
actor: str | None = None,
|
|
487
|
+
actor_id: str | None = None,
|
|
488
|
+
except_session_id: str | None = None,
|
|
489
|
+
) -> int:
|
|
490
|
+
count = 0
|
|
491
|
+
for session in await self.session_store.get_active_sessions():
|
|
492
|
+
if session.user_id == user_id and session.id != except_session_id:
|
|
493
|
+
await self.revoke_session(session, reason, actor=actor, actor_id=actor_id)
|
|
494
|
+
count += 1
|
|
495
|
+
return count
|
|
496
|
+
|
|
497
|
+
async def cleanup_sessions(self) -> None:
|
|
498
|
+
await self.session_store.cleanup(self.session_timeout_ms)
|
|
499
|
+
for session in await self.session_store.get_active_sessions():
|
|
500
|
+
if self.is_expired(session):
|
|
501
|
+
await self.session_store.delete_session_by_id(session.id)
|
|
502
|
+
self.notify_change()
|
|
503
|
+
now = now_ms()
|
|
504
|
+
for key, ticket in list(self._tickets.items()):
|
|
505
|
+
if ticket.expires_at <= now:
|
|
506
|
+
self._tickets.pop(key, None)
|
|
507
|
+
|
|
508
|
+
async def is_context_active(self, ctx: Any) -> bool:
|
|
509
|
+
"""Whether the session behind an authenticated WebSocket is still
|
|
510
|
+
alive (not logged out, revoked, expired; user neither disabled nor
|
|
511
|
+
deleted). Never extends the session. Adapter identities have no
|
|
512
|
+
stored session and stay valid."""
|
|
513
|
+
if not isinstance(ctx, AuthContext):
|
|
514
|
+
return True
|
|
515
|
+
if ctx.external:
|
|
516
|
+
return True
|
|
517
|
+
if ctx.session is None:
|
|
518
|
+
return False
|
|
519
|
+
session = await self.session_store.get_session_by_id(ctx.session.id)
|
|
520
|
+
if session is None or session.user_id != ctx.user.id or self.is_expired(session):
|
|
521
|
+
return False
|
|
522
|
+
user = await self.user_store.get_user(ctx.user.id)
|
|
523
|
+
return user is not None and user.id == ctx.user.id and not user.disabled
|
|
524
|
+
|
|
525
|
+
def session_payload(
|
|
526
|
+
self, session: Session, user: User, permissions: list[str],
|
|
527
|
+
) -> dict[str, Any]:
|
|
528
|
+
"""The ``session`` object of the login / current-session responses."""
|
|
529
|
+
return {
|
|
530
|
+
"id": session.id,
|
|
531
|
+
"user": user.to_dict(),
|
|
532
|
+
"permissions": list(permissions),
|
|
533
|
+
"loginTime": session.created_at,
|
|
534
|
+
"lastActivity": session.last_activity,
|
|
535
|
+
"expiresAt": self.expires_at(session),
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
# Request authentication
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
async def _context_for_session(self, session: Session) -> AuthContext:
|
|
543
|
+
if self.is_expired(session):
|
|
544
|
+
await self.session_store.delete_session_by_id(session.id)
|
|
545
|
+
raise unauthorized("session expired")
|
|
546
|
+
user = await self.user_store.get_user(session.user_id)
|
|
547
|
+
if not user or user.id != session.user_id or user.disabled:
|
|
548
|
+
raise unauthorized("invalid or expired token")
|
|
549
|
+
groups = list(user.groups or [])
|
|
550
|
+
return AuthContext(
|
|
551
|
+
user=user,
|
|
552
|
+
groups=groups,
|
|
553
|
+
permissions=await self.effective_permissions(groups),
|
|
554
|
+
session=session,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
async def authenticate_token(self, token: str) -> AuthContext:
|
|
558
|
+
"""Resolve a bearer token to the live user; raises 401 otherwise."""
|
|
559
|
+
token_hash = hash_token(token)
|
|
560
|
+
session = await self.session_store.get_session(token_hash)
|
|
561
|
+
if not session:
|
|
562
|
+
raise unauthorized("invalid or expired token")
|
|
563
|
+
ctx = await self._context_for_session(session)
|
|
564
|
+
await self.session_store.update_last_activity(token_hash)
|
|
565
|
+
session.last_activity = now_ms()
|
|
566
|
+
ctx.token = token
|
|
567
|
+
return ctx
|
|
568
|
+
|
|
569
|
+
async def authenticate_external(self, request: Any) -> AuthContext:
|
|
570
|
+
assert self.auth_adapter is not None
|
|
571
|
+
result = self.auth_adapter(request)
|
|
572
|
+
if inspect.isawaitable(result):
|
|
573
|
+
result = await result
|
|
574
|
+
if not isinstance(result, ExternalAuthResult) or result.user is None:
|
|
575
|
+
raise unauthorized()
|
|
576
|
+
groups = list(result.user.groups or [])
|
|
577
|
+
if result.is_admin and not any(g.lower() == ADMIN_GROUP for g in groups):
|
|
578
|
+
groups.append(ADMIN_GROUP)
|
|
579
|
+
# Copy — never mutate the adapter's user object.
|
|
580
|
+
user = replace(result.user, groups=groups)
|
|
581
|
+
now = now_ms()
|
|
582
|
+
session = Session(
|
|
583
|
+
id=f"external-{uuid.uuid4()}",
|
|
584
|
+
user_id=user.id,
|
|
585
|
+
user_name=user.name,
|
|
586
|
+
created_at=now,
|
|
587
|
+
last_activity=now,
|
|
588
|
+
)
|
|
589
|
+
return AuthContext(
|
|
590
|
+
user=user,
|
|
591
|
+
groups=groups,
|
|
592
|
+
permissions=await self.effective_permissions(groups),
|
|
593
|
+
session=session,
|
|
594
|
+
external=True,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
@staticmethod
|
|
598
|
+
def ensure_password_changed(ctx: AuthContext) -> None:
|
|
599
|
+
if ctx.must_change_password:
|
|
600
|
+
raise forbidden(PASSWORD_CHANGE_REQUIRED)
|
|
601
|
+
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
# WebSocket tickets
|
|
604
|
+
# ------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def issue_ticket(self, ctx: AuthContext) -> tuple[str, int]:
|
|
607
|
+
"""Single-use ticket bound to the caller's session; returns
|
|
608
|
+
``(ticket, expires_in_seconds)``."""
|
|
609
|
+
now = now_ms()
|
|
610
|
+
for key, t in list(self._tickets.items()):
|
|
611
|
+
if t.expires_at <= now:
|
|
612
|
+
self._tickets.pop(key, None)
|
|
613
|
+
ticket = secrets.token_hex(32)
|
|
614
|
+
self._tickets[hash_token(ticket)] = _Ticket(
|
|
615
|
+
session_id=None if ctx.external else (ctx.session.id if ctx.session else None),
|
|
616
|
+
user_id=ctx.user.id,
|
|
617
|
+
expires_at=now + self.options.ticket_ttl_ms,
|
|
618
|
+
external_ctx=ctx if ctx.external else None,
|
|
619
|
+
)
|
|
620
|
+
return ticket, self.options.ticket_ttl_ms // 1000
|
|
621
|
+
|
|
622
|
+
def _drop_tickets(self, session_id: str) -> None:
|
|
623
|
+
for key, t in list(self._tickets.items()):
|
|
624
|
+
if t.session_id == session_id:
|
|
625
|
+
self._tickets.pop(key, None)
|
|
626
|
+
|
|
627
|
+
async def consume_ticket(self, ticket: str | None) -> AuthContext | None:
|
|
628
|
+
"""Validate and consume a ticket; ``None`` when invalid/expired/used."""
|
|
629
|
+
if not ticket:
|
|
630
|
+
return None
|
|
631
|
+
entry = self._tickets.pop(hash_token(ticket), None)
|
|
632
|
+
if entry is None or entry.expires_at <= now_ms():
|
|
633
|
+
return None
|
|
634
|
+
if entry.external_ctx is not None:
|
|
635
|
+
return entry.external_ctx
|
|
636
|
+
if entry.session_id is None:
|
|
637
|
+
return None
|
|
638
|
+
session = await self.session_store.get_session_by_id(entry.session_id)
|
|
639
|
+
if session is None or session.user_id != entry.user_id:
|
|
640
|
+
return None
|
|
641
|
+
try:
|
|
642
|
+
return await self._context_for_session(session)
|
|
643
|
+
except UsersApiError:
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
# ------------------------------------------------------------------
|
|
647
|
+
# Login / logout
|
|
648
|
+
# ------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
async def login(
|
|
651
|
+
self,
|
|
652
|
+
username: str,
|
|
653
|
+
password: str,
|
|
654
|
+
metadata: SessionMetadata | None = None,
|
|
655
|
+
) -> LoginResult:
|
|
656
|
+
"""Authenticate by name or email and create a session.
|
|
657
|
+
|
|
658
|
+
Raises :class:`UsersApiError` (401 invalid credentials, 403 disabled,
|
|
659
|
+
429 locked). Unknown names are locked out like real ones and take the
|
|
660
|
+
same time (dummy PBKDF2).
|
|
661
|
+
"""
|
|
662
|
+
metadata = metadata or SessionMetadata()
|
|
663
|
+
audit_meta = {"remoteAddress": metadata.remote_address}
|
|
664
|
+
|
|
665
|
+
user = await self.find_user(username)
|
|
666
|
+
key = self.lockout_key(user, username)
|
|
667
|
+
shown = user.name if user else (sanitize(username, 64) or "")
|
|
668
|
+
user_id = user.id if user else None
|
|
669
|
+
|
|
670
|
+
if not self.reserve_attempt(key):
|
|
671
|
+
await self.audit("login_failed", shown, details="account locked",
|
|
672
|
+
metadata=audit_meta, user_id=user_id)
|
|
673
|
+
raise UsersApiError(429, ACCOUNT_LOCKED)
|
|
674
|
+
|
|
675
|
+
if user is None:
|
|
676
|
+
await password_hasher.dummy_verify_async(password)
|
|
677
|
+
await self.audit("login_failed", shown, details="unknown user", metadata=audit_meta)
|
|
678
|
+
raise UsersApiError(401, INVALID_CREDENTIALS)
|
|
679
|
+
|
|
680
|
+
if not await self.verify_password(user.id, password):
|
|
681
|
+
await self.audit("login_failed", shown, details="wrong password",
|
|
682
|
+
metadata=audit_meta, user_id=user_id)
|
|
683
|
+
raise UsersApiError(401, INVALID_CREDENTIALS)
|
|
684
|
+
|
|
685
|
+
self.reset_failures(key)
|
|
686
|
+
|
|
687
|
+
if user.disabled:
|
|
688
|
+
await self.audit("login_failed", shown, details="account disabled",
|
|
689
|
+
metadata=audit_meta, user_id=user_id)
|
|
690
|
+
raise UsersApiError(403, ACCOUNT_DISABLED)
|
|
691
|
+
|
|
692
|
+
user = await self.user_store.update_user(user.id, last_login_at=now_ms())
|
|
693
|
+
token = self.generate_token()
|
|
694
|
+
session = await self.session_store.create_session(
|
|
695
|
+
user.id, hash_token(token), metadata, user_name=user.name,
|
|
696
|
+
)
|
|
697
|
+
await self.audit(
|
|
698
|
+
"login", user.name, user_id=user.id, actor=user.name, actor_id=user.id,
|
|
699
|
+
metadata={**audit_meta, "sessionId": session.id},
|
|
700
|
+
)
|
|
701
|
+
return LoginResult(
|
|
702
|
+
token=token,
|
|
703
|
+
session=session,
|
|
704
|
+
user=user,
|
|
705
|
+
permissions=await self.user_permissions(user),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
async def check_password_attempt(self, ctx: AuthContext, target: User, password: str) -> bool:
|
|
709
|
+
"""Verify a password as a lockout-tracked attempt (verify-password,
|
|
710
|
+
self password change). Raises 429 when locked; audits failures."""
|
|
711
|
+
key = target.id
|
|
712
|
+
if not self.reserve_attempt(key):
|
|
713
|
+
raise UsersApiError(429, ACCOUNT_LOCKED)
|
|
714
|
+
valid = await self.verify_password(target.id, password)
|
|
715
|
+
if valid:
|
|
716
|
+
self.reset_failures(key)
|
|
717
|
+
else:
|
|
718
|
+
await self.audit(
|
|
719
|
+
"login_failed", target.name,
|
|
720
|
+
details="password verification failed",
|
|
721
|
+
actor=ctx.user.name, actor_id=ctx.user.id, user_id=target.id,
|
|
722
|
+
)
|
|
723
|
+
return valid
|
|
724
|
+
|
|
725
|
+
async def logout(self, token: str) -> None:
|
|
726
|
+
session = await self.session_store.get_session(hash_token(token))
|
|
727
|
+
await self.session_store.delete_session(hash_token(token))
|
|
728
|
+
if session:
|
|
729
|
+
self._drop_tickets(session.id)
|
|
730
|
+
user = await self.user_store.get_user(session.user_id)
|
|
731
|
+
name = user.name if user else session.user_name
|
|
732
|
+
await self.audit("logout", name, user_id=session.user_id, actor=name,
|
|
733
|
+
actor_id=session.user_id, metadata={"sessionId": session.id})
|
|
734
|
+
|
|
735
|
+
# ------------------------------------------------------------------
|
|
736
|
+
# Seeding
|
|
737
|
+
# ------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
async def seed(self, default_admin_password: str | None) -> None:
|
|
740
|
+
"""Seed the built-in groups and (unless external auth) the root user.
|
|
741
|
+
|
|
742
|
+
Without a configured password a random one is generated, logged once
|
|
743
|
+
and the root account must change it at first login."""
|
|
744
|
+
if not await self.group_store.get_group(ADMIN_GROUP):
|
|
745
|
+
await self.group_store.create_group(
|
|
746
|
+
ADMIN_GROUP, "Administrators", ["*"], built_in=True,
|
|
747
|
+
)
|
|
748
|
+
if not await self.group_store.get_group(USERS_GROUP):
|
|
749
|
+
await self.group_store.create_group(
|
|
750
|
+
USERS_GROUP, "Default group for new accounts",
|
|
751
|
+
list(DEFAULT_USERS_GROUP_PERMISSIONS), built_in=True,
|
|
752
|
+
)
|
|
753
|
+
if self.external_auth:
|
|
754
|
+
return
|
|
755
|
+
if len(await self.user_store.get_users()) != 0:
|
|
756
|
+
return
|
|
757
|
+
generated = default_admin_password is None
|
|
758
|
+
password = self.generate_password() if generated else default_admin_password
|
|
759
|
+
root = await self.user_store.create_user(
|
|
760
|
+
name="root", email="root@localhost", groups=[ADMIN_GROUP],
|
|
761
|
+
must_change_password=generated,
|
|
762
|
+
)
|
|
763
|
+
await self.set_password(root.id, password)
|
|
764
|
+
if generated:
|
|
765
|
+
logger.warning(
|
|
766
|
+
"Users plugin: no default admin password configured. Generated a one-time "
|
|
767
|
+
"password for 'root': %s (it must be changed at first login)",
|
|
768
|
+
password,
|
|
769
|
+
extra={"qcli_sensitive": True},
|
|
770
|
+
)
|
|
771
|
+
elif password.lower() in WEAK_ADMIN_PASSWORDS:
|
|
772
|
+
logger.warning(
|
|
773
|
+
"Users plugin: the 'root' account uses a well-known default password. "
|
|
774
|
+
"Change it before exposing this server."
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
__all__ = [
|
|
779
|
+
"AuthContext",
|
|
780
|
+
"AuthService",
|
|
781
|
+
"AuthServiceOptions",
|
|
782
|
+
"LoginResult",
|
|
783
|
+
"covers",
|
|
784
|
+
"hash_token",
|
|
785
|
+
"permission_matches",
|
|
786
|
+
]
|