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.
Files changed (32) hide show
  1. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
  2. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
  3. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
  4. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
  5. qodalis_cli_users/__init__.py +134 -0
  6. qodalis_cli_users/_http.py +162 -0
  7. qodalis_cli_users/cli_users_builder.py +385 -0
  8. qodalis_cli_users/controllers/__init__.py +13 -0
  9. qodalis_cli_users/controllers/_routing.py +25 -0
  10. qodalis_cli_users/controllers/audit_controller.py +40 -0
  11. qodalis_cli_users/controllers/groups_controller.py +109 -0
  12. qodalis_cli_users/controllers/sessions_controller.py +114 -0
  13. qodalis_cli_users/controllers/users_controller.py +183 -0
  14. qodalis_cli_users/errors.py +118 -0
  15. qodalis_cli_users/middleware/__init__.py +5 -0
  16. qodalis_cli_users/middleware/auth_middleware.py +77 -0
  17. qodalis_cli_users/models.py +282 -0
  18. qodalis_cli_users/permissions.py +58 -0
  19. qodalis_cli_users/server_protection.py +209 -0
  20. qodalis_cli_users/services/__init__.py +6 -0
  21. qodalis_cli_users/services/auth_service.py +786 -0
  22. qodalis_cli_users/services/password_hasher.py +98 -0
  23. qodalis_cli_users/services/users_service.py +330 -0
  24. qodalis_cli_users/shell_access.py +125 -0
  25. qodalis_cli_users/stores/__init__.py +27 -0
  26. qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
  27. qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
  28. qodalis_cli_users/stores/in_memory_group_store.py +65 -0
  29. qodalis_cli_users/stores/in_memory_session_store.py +71 -0
  30. qodalis_cli_users/stores/in_memory_user_store.py +102 -0
  31. qodalis_cli_users/stores/interfaces.py +208 -0
  32. 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
+ ]