Coverage for src/lexigram/auth/authn/services.py: 84%

176 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Authentication services for user login, registration, and token management.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from dataclasses import dataclass, field 

7from typing import TYPE_CHECKING, Any 

8 

9from lexigram import serialization as _json 

10from lexigram.auth.authn.security import ( 

11 DUMMY_PASSWORD_HASH, 

12 PasswordHasher, 

13 PasswordPolicy, 

14) 

15from lexigram.auth.events import ( 

16 UserLockedOut, 

17 UserLoggedIn, 

18 UserLoginFailed, 

19 UserRegistered, 

20) 

21from lexigram.auth.exceptions import ( 

22 AccountLockedError, 

23 EmailExistsError, 

24 InvalidCredentialsError, 

25 PasswordPolicyError, 

26) 

27from lexigram.auth.models.user import User, UserCredentials 

28from lexigram.auth.services.activity_tracker import AuthActivityTracker 

29from lexigram.contracts.auth.protocols import ( 

30 LoginAttemptTrackerProtocol, 

31 PasswordHasherProtocol, 

32) 

33from lexigram.di.decorators import inject 

34from lexigram.logging import get_logger 

35from lexigram.primitives import clock as ambient_clock 

36 

37if TYPE_CHECKING: 

38 from lexigram.auth.authn.jwt import JWTTokenManager 

39 from lexigram.auth.authn.schemas import RegisterRequest 

40 from lexigram.auth.models import AuthToken 

41 from lexigram.auth.storage.token_store import UserStoreProtocol 

42 from lexigram.contracts.auth.exceptions import TokenError 

43 from lexigram.contracts.auth.token import VerifiedToken 

44 from lexigram.contracts.core import HookRegistryProtocol 

45 from lexigram.contracts.events.protocols import EventBusProtocol 

46 from lexigram.contracts.infra.cache.protocols import CacheBackendProtocol 

47 from lexigram.result import Result 

48 

49logger = get_logger(__name__) 

50 

51 

52@dataclass 

53class LockoutConfig: 

54 """Configuration for account lockout on repeated failed login attempts. 

55 

56 Attributes: 

57 max_failed_attempts: Number of failed attempts within the observation 

58 window that triggers a lockout. Defaults to 5. 

59 lockout_duration_seconds: Length of the rolling observation window in 

60 seconds. The lockout is lifted automatically once all recorded 

61 failures fall outside this window. Defaults to 300 (5 minutes). 

62 max_attempts: Canonical alias for ``max_failed_attempts``. Both 

63 fields default to 5 and are kept in sync by ``__post_init__``. 

64 

65 Note: 

66 For distributed deployments pass a ``CacheBackendProtocol`` to 

67 :class:`LoginAttemptTracker` instead of relying on the in-process 

68 dict managed by ``AuthenticationService``. 

69 """ 

70 

71 max_failed_attempts: int = 5 

72 lockout_duration_seconds: int = 300 

73 max_attempts: int = field(default=5) 

74 

75 def __post_init__(self) -> None: 

76 # max_attempts is the canonical name; max_failed_attempts exists for 

77 # backward compatibility. Sync rules: 

78 # • If max_attempts was set to a non-default value → it wins. 

79 # • Otherwise max_failed_attempts is the source of truth. 

80 if self.max_attempts != 5: 

81 self.max_failed_attempts = self.max_attempts 

82 else: 

83 self.max_attempts = self.max_failed_attempts 

84 

85 

86class LoginAttemptTracker(LoginAttemptTrackerProtocol): 

87 """Tracks failed login attempts and enforces account lockout. 

88 

89 Supports both in-process state (no external dependency) and distributed 

90 state via an injected :class:`~lexigram.contracts.cache.protocols.CacheBackendProtocol`. 

91 When a cache backend is provided all attempt records are stored there so 

92 that multiple application instances share the same view — preventing an 

93 attacker from bypassing lockout by rotating across instances. 

94 

95 Args: 

96 max_attempts: Consecutive failures within *lockout_duration_seconds* 

97 that trigger a lockout. Defaults to 5. 

98 lockout_duration_seconds: Rolling window size **and** cache TTL. 

99 Defaults to 900 seconds (15 minutes). 

100 cache: Optional cache backend for distributed state. When ``None`` 

101 an in-process ``dict`` is used instead. 

102 

103 Example:: 

104 

105 tracker = LoginAttemptTracker(max_attempts=5, lockout_duration_seconds=900) 

106 

107 await tracker.record_failure("user@example.com") 

108 locked = await tracker.is_locked("user@example.com") # False until 5 failures 

109 

110 await tracker.clear("user@example.com") # reset on successful login 

111 """ 

112 

113 _CACHE_KEY_PREFIX = "lexigram:auth:attempts:" 

114 

115 def __init__( 

116 self, 

117 max_attempts: int = 5, 

118 lockout_duration_seconds: int = 900, 

119 cache: CacheBackendProtocol | None = None, 

120 ) -> None: 

121 self.max_attempts = max_attempts 

122 self.lockout_duration_seconds = lockout_duration_seconds 

123 self._cache = cache 

124 # In-memory fallback: identifier → list of monotonic timestamps 

125 self._local: dict[str, list[float]] = {} 

126 

127 # ------------------------------------------------------------------ 

128 # Public API 

129 # ------------------------------------------------------------------ 

130 

131 async def is_locked(self, identifier: str) -> bool: 

132 """Return ``True`` if *identifier* has exceeded the failure threshold. 

133 

134 Args: 

135 identifier: Username, e-mail address, or IP used as the tracking key. 

136 """ 

137 if self._cache is not None: 

138 return await self._is_locked_cache(identifier) 

139 return self._is_locked_local(identifier) 

140 

141 async def record_failure(self, identifier: str) -> None: 

142 """Record a failed authentication attempt for *identifier*. 

143 

144 Args: 

145 identifier: Username, e-mail address, or IP used as the tracking key. 

146 """ 

147 if self._cache is not None: 

148 await self._record_failure_cache(identifier) 

149 else: 

150 self._record_failure_local(identifier) 

151 

152 async def clear(self, identifier: str) -> None: 

153 """Remove all recorded failures for *identifier* (call on success). 

154 

155 Args: 

156 identifier: Username, e-mail address, or IP used as the tracking key. 

157 """ 

158 if self._cache is not None: 

159 await self._cache.delete(self._CACHE_KEY_PREFIX + identifier) 

160 else: 

161 self._local.pop(identifier, None) 

162 

163 # ------------------------------------------------------------------ 

164 # Cache-backed helpers 

165 # ------------------------------------------------------------------ 

166 

167 async def _is_locked_cache(self, identifier: str) -> bool: 

168 key = self._CACHE_KEY_PREFIX + identifier 

169 raw: Any = await self._cache.get(key) # type: ignore[union-attr] 

170 if raw is None: 

171 return False 

172 data: dict[str, Any] = ( 

173 _json.loads(raw) if isinstance(raw, (str, bytes)) else raw 

174 ) 

175 now = ambient_clock.monotonic() 

176 window_start = now - self.lockout_duration_seconds 

177 recent = [t for t in data.get("timestamps", []) if t > window_start] 

178 return len(recent) >= self.max_attempts 

179 

180 async def _record_failure_cache(self, identifier: str) -> None: 

181 key = self._CACHE_KEY_PREFIX + identifier 

182 raw: Any = await self._cache.get(key) # type: ignore[union-attr] 

183 now = ambient_clock.monotonic() 

184 window_start = now - self.lockout_duration_seconds 

185 if raw is None: 

186 timestamps: list[float] = [] 

187 else: 

188 data: dict[str, Any] = ( 

189 _json.loads(raw) if isinstance(raw, (str, bytes)) else raw 

190 ) 

191 timestamps = [t for t in data.get("timestamps", []) if t > window_start] 

192 timestamps.append(now) 

193 await self._cache.set( # type: ignore[union-attr] 

194 key, 

195 _json.dumps({"timestamps": timestamps}), 

196 ttl=self.lockout_duration_seconds, 

197 ) 

198 

199 # ------------------------------------------------------------------ 

200 # In-memory helpers 

201 # ------------------------------------------------------------------ 

202 

203 def _is_locked_local(self, identifier: str) -> bool: 

204 now = ambient_clock.monotonic() 

205 window_start = now - self.lockout_duration_seconds 

206 recent = [t for t in self._local.get(identifier, []) if t > window_start] 

207 return len(recent) >= self.max_attempts 

208 

209 def _record_failure_local(self, identifier: str) -> None: 

210 now = ambient_clock.monotonic() 

211 window_start = now - self.lockout_duration_seconds 

212 existing = self._local.get(identifier, []) 

213 self._local[identifier] = [t for t in existing if t > window_start] + [now] 

214 

215 

216@inject 

217class AuthenticationService: 

218 """Service for core authentication operations. 

219 

220 Handles user login, registration, token creation and validation. 

221 """ 

222 

223 def __init__( 

224 self, 

225 password_policy: PasswordPolicy, 

226 user_store: UserStoreProtocol, 

227 token_manager: JWTTokenManager, 

228 lockout_config: LockoutConfig | None = None, 

229 event_bus: EventBusProtocol | None = None, 

230 tracker: LoginAttemptTracker | None = None, 

231 hooks: HookRegistryProtocol | None = None, 

232 password_hasher: PasswordHasherProtocol | None = None, 

233 activity_tracker: AuthActivityTracker | None = None, 

234 ) -> None: 

235 self.password_policy = password_policy 

236 self.user_store = user_store 

237 self.token_manager = token_manager 

238 self.lockout_config: LockoutConfig = lockout_config or LockoutConfig() 

239 self._event_bus = event_bus 

240 # Use the explicitly supplied tracker, or build a default in-memory one 

241 # from lockout_config so callers that pre-date LoginAttemptTracker are 

242 # not affected. 

243 self._tracker: LoginAttemptTracker = tracker or LoginAttemptTracker( 

244 max_attempts=self.lockout_config.max_attempts, 

245 lockout_duration_seconds=self.lockout_config.lockout_duration_seconds, 

246 ) 

247 self._hooks = hooks 

248 # The composed hasher injected by the provider; falls back to the 

249 # bcrypt hasher for callers that construct the service directly. 

250 self._password_hasher: PasswordHasherProtocol = ( 

251 password_hasher or PasswordHasher() 

252 ) 

253 # Tracks dashboard auth activity when bound by the DI container; 

254 # ``None`` disables recording (e.g. tests or bare constructions). 

255 self._activity_tracker = activity_tracker 

256 # Background tasks kept alive to prevent GC before completion 

257 self._background_tasks: set[asyncio.Task[object]] = set() 

258 

259 def __repr__(self) -> str: 

260 """Return a string representation of this service.""" 

261 return f"AuthenticationService(user_store={type(self.user_store).__name__})" 

262 

263 def _emit(self, event: object) -> None: 

264 """Fire-and-forget event publication. 

265 

266 Schedules a background task to publish *event* via the event bus (if 

267 one is configured). The task reference is stored in 

268 ``_background_tasks`` to prevent premature garbage collection; it 

269 removes itself from the set upon completion. 

270 """ 

271 if self._event_bus is None: 

272 return 

273 task: asyncio.Task[object] = asyncio.create_task(self._event_bus.publish(event)) 

274 self._background_tasks.add(task) 

275 task.add_done_callback(self._background_tasks.discard) 

276 

277 def set_hook_registry(self, hooks: HookRegistryProtocol | None) -> None: 

278 """Attach an optional hook registry after provider boot wiring.""" 

279 self._hooks = hooks 

280 

281 async def _emit_action(self, hook_name: str, payload: object) -> None: 

282 """Emit an auth action hook when a registry is available.""" 

283 if self._hooks is None: 

284 return 

285 

286 await self._hooks.call_action(hook_name, payload=payload) 

287 

288 async def authenticate_user( 

289 self, 

290 email: str, 

291 password: str, 

292 ) -> Result[User, InvalidCredentialsError | AccountLockedError]: 

293 """Authenticate a user with email and password. 

294 

295 Always performs a password hash verification regardless of whether the 

296 user exists. This constant-time behaviour prevents user-enumeration 

297 attacks via timing side-channels. 

298 

299 If the account has exceeded ``LockoutConfig.max_attempts`` 

300 within the observation window an ``AccountLockedError`` is returned 

301 immediately without performing credential verification. 

302 

303 Returns: 

304 ``Ok(User)`` if authentication succeeds, 

305 ``Err(AccountLockedError)`` if the account is temporarily locked, 

306 ``Err(InvalidCredentialsError)`` otherwise. 

307 """ 

308 from lexigram.result import Err, Ok 

309 

310 # Lockout check — must happen before any credential work 

311 if await self._tracker.is_locked(email): 

312 # Best-effort user fetch to populate the richer UserLockedOut event. 

313 locked_user = await self.user_store.get_user_by_email(email) 

314 if locked_user: 

315 self._emit(UserLockedOut(user_id=locked_user.user_id, email=email)) 

316 else: 

317 self._emit(UserLoginFailed(email=email, reason="Account locked")) 

318 if self._activity_tracker is not None: 

319 self._activity_tracker.record_failed_login() 

320 return Err(AccountLockedError(email)) 

321 

322 user = await self.user_store.get_user_by_email(email) 

323 

324 # Resolve credentials separately — credentials are never stored on User. 

325 creds = await self.user_store.get_credentials(user.user_id) if user else None 

326 

327 # Always verify against *some* hash so the code path takes constant 

328 # time whether the user exists or not. 

329 hashed = ( 

330 creds.hashed_password 

331 if creds and creds.hashed_password 

332 else DUMMY_PASSWORD_HASH 

333 ) 

334 verified = await self._password_hasher.verify(password, hashed) 

335 

336 if user and user.is_active and verified: 

337 from lexigram.auth.hooks import AuthUserAuthenticatedHook 

338 

339 # Successful login — clear failure history 

340 await self._tracker.clear(email) 

341 user.record_login() 

342 await self._rehash_password_if_needed(user, password, creds) 

343 self._emit(UserLoggedIn(user_id=user.user_id, email=user.email)) 

344 await self._emit_action( 

345 "auth.login", 

346 AuthUserAuthenticatedHook(user_id=user.user_id, method="password"), 

347 ) 

348 return Ok(user) 

349 

350 # Record the failure 

351 await self._tracker.record_failure(email) 

352 self._emit(UserLoginFailed(email=email, reason="Invalid credentials")) 

353 if self._activity_tracker is not None: 

354 self._activity_tracker.record_failed_login() 

355 return Err(InvalidCredentialsError()) 

356 

357 async def _rehash_password_if_needed( 

358 self, 

359 user: User, 

360 password: str, 

361 creds: UserCredentials | None, 

362 ) -> None: 

363 """Rehash password in background if needed.""" 

364 if not creds or not creds.hashed_password: 

365 return 

366 

367 try: 

368 new_hash = await self._password_hasher.rehash_if_needed( 

369 password, 

370 creds.hashed_password, 

371 ) 

372 if new_hash: 

373 updated_creds = UserCredentials( 

374 user_id=user.user_id, 

375 hashed_password=new_hash, 

376 previous_hashes=creds.previous_hashes, 

377 ) 

378 await self.user_store.update_credentials(updated_creds) 

379 except (RuntimeError, ValueError) as exc: 

380 logger.warning("password_rehash_failed", reason=str(exc)) 

381 

382 async def register_user( 

383 self, request: RegisterRequest 

384 ) -> Result[User, EmailExistsError | PasswordPolicyError]: 

385 """Register a new user. 

386 

387 Args: 

388 request: Registration request with email, name, and password. 

389 

390 Returns: 

391 ``Ok(User)`` on success. 

392 ``Err(PasswordPolicyError)`` if passwords do not match or the 

393 password violates the policy. 

394 ``Err(EmailExistsError)`` if the email is already registered. 

395 """ 

396 from lexigram.result import Err, Ok 

397 

398 if request.password != request.confirm_password: 

399 return Err(PasswordPolicyError("Passwords do not match")) 

400 

401 try: 

402 self.password_policy.validate(request.password) 

403 except ValueError as e: 

404 return Err(PasswordPolicyError(str(e))) 

405 

406 if await self.user_store.get_user_by_email(request.email): 

407 return Err(EmailExistsError(request.email)) 

408 

409 hashed_password = await self._password_hasher.hash(request.password) 

410 

411 user = await self.user_store.create_user( 

412 name=request.name, 

413 email=request.email, 

414 hashed_password=hashed_password, 

415 roles=["user"], 

416 profile=request.profile, 

417 ) 

418 self._emit(UserRegistered(user_id=user.user_id, email=user.email)) 

419 return Ok(user) 

420 

421 def create_token(self, user: User) -> AuthToken: 

422 """Create an authentication token for a user.""" 

423 return self.token_manager.create_token_pair(user) 

424 

425 async def verify_token(self, token: str) -> Result[VerifiedToken, TokenError]: 

426 """Verify and decode an authentication token. 

427 

428 Returns: 

429 ``Ok(VerifiedToken)`` if valid, ``Err(TokenError)`` otherwise. 

430 """ 

431 return await self.token_manager.verify_token(token) 

432 

433 async def refresh_token(self, refresh_token: str) -> Result[AuthToken, TokenError]: 

434 """Refresh an access token using a refresh token. 

435 

436 Args: 

437 refresh_token: The refresh token string. 

438 

439 Returns: 

440 ``Ok(AuthToken)`` if the refresh succeeds. 

441 ``Err(TokenError)`` if the refresh token is invalid or expired. 

442 """ 

443 from lexigram.contracts.auth.exceptions import TokenError as _TokenError 

444 from lexigram.result import Err, Ok 

445 

446 try: 

447 token = await self.token_manager.refresh_access_token(refresh_token) 

448 if self._activity_tracker is not None: 

449 self._activity_tracker.record_refresh() 

450 return Ok(token) 

451 except _TokenError as e: 

452 return Err(e) 

453 

454 async def get_user_from_token( 

455 self, token: str 

456 ) -> Result[VerifiedToken, TokenError]: 

457 """Get user information from token. 

458 

459 Returns: 

460 ``Ok(VerifiedToken)`` if valid, ``Err(TokenError)`` otherwise. 

461 """ 

462 return await self.token_manager.get_user_from_token(token) 

463 

464 async def shutdown(self) -> None: 

465 """Cancel and await all pending background event tasks.""" 

466 tasks = list(self._background_tasks) 

467 for task in tasks: 

468 task.cancel() 

469 if tasks: 

470 await asyncio.gather(*tasks, return_exceptions=True) 

471 self._background_tasks.clear() 

472 

473 

474__all__ = ["AuthenticationService", "LockoutConfig", "LoginAttemptTracker"]