Coverage for src/lexigram/admin/auth/services/auth_service.py: 82%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Admin authentication orchestrator service. 

2 

3Coordinates the complete login flow: IP rate limiting, account lockout, 

4credential verification, session issuance, and audit logging. 

5""" 

6 

7from __future__ import annotations 

8 

9from datetime import UTC, datetime, timedelta 

10from typing import Any 

11 

12from lexigram.admin.auth.errors import ( 

13 AccountLockedError, 

14 AdminAuthError, 

15 InvalidCredentialsError, 

16 MfaNotEnabledError, 

17 MfaVerificationFailedError, 

18 RateLimitExceededError, 

19) 

20from lexigram.admin.auth.protocols import ( 

21 AdminAuditLogServiceProtocol, 

22 AdminEmailOtpServiceProtocol, 

23 AdminEmailVerificationServiceProtocol, 

24 AdminLoginAttemptServiceProtocol, 

25 AdminMfaServiceProtocol, 

26 AdminSessionServiceProtocol, 

27) 

28from lexigram.admin.auth.store.protocols import AdminUserStoreProtocol 

29from lexigram.admin.auth.types import AdminAuthResult, AdminSecurityEventType 

30from lexigram.di.decorators import inject 

31from lexigram.logging import get_logger 

32from lexigram.result import Err, Ok, Result 

33 

34logger = get_logger(__name__) 

35 

36 

37@inject 

38class AdminAuthService: 

39 """Top-level authentication orchestrator for the admin panel. 

40 

41 Coordinates the full login pipeline in strict order: 

42 

43 1. IP rate-limit check — blocks brute-force by origin. 

44 2. Account lockout check — blocks repeated per-account failures. 

45 3. Credential verification — validates email/password. 

46 3b. Email verification gate — blocks login until the email is 

47 verified (config-toggleable enforcement). 

48 3c. Second-factor challenge — TOTP or email OTP per config; returns a 

49 result with ``mfa_required=True`` (no session created). 

50 4. Attempt recording + lockout clearance on success. 

51 5. Session creation. 

52 6. Audit logging. 

53 

54 All audit calls use ``AdminAuditLogServiceProtocol``, whose implementations 

55 are guaranteed never to raise; audit failures are silently absorbed so they 

56 never interrupt the authentication response. 

57 

58 Args: 

59 user_store: Admin user persistence and credential verification. 

60 attempt_service: IP rate limiting and account lockout enforcement. 

61 audit_service: Security event recording (fire-and-forget). 

62 session_service: Session lifecycle management. 

63 mfa_service: Optional TOTP 2FA challenge service (None disables 2FA). 

64 email_verification_service: Optional email verification gate service. 

65 email_otp_service: Optional email OTP factor service. 

66 mfa_factor: Second factor selected at login (``"totp"`` or 

67 ``"email"``; default ``"totp"``). 

68 session_lifetime: Absolute session TTL in seconds (default 86400 = 24h). 

69 """ 

70 

71 def __init__( 

72 self, 

73 user_store: AdminUserStoreProtocol, 

74 attempt_service: AdminLoginAttemptServiceProtocol, 

75 audit_service: AdminAuditLogServiceProtocol, 

76 session_service: AdminSessionServiceProtocol, 

77 mfa_service: AdminMfaServiceProtocol | None = None, 

78 email_verification_service: AdminEmailVerificationServiceProtocol | None = None, 

79 email_otp_service: AdminEmailOtpServiceProtocol | None = None, 

80 mfa_factor: str = "totp", 

81 session_lifetime: int = 86400, 

82 ) -> None: 

83 self._user_store = user_store 

84 self._attempt_service = attempt_service 

85 self._audit_service = audit_service 

86 self._session_service = session_service 

87 self._mfa_service = mfa_service 

88 self._email_verification_service = email_verification_service 

89 self._email_otp_service = email_otp_service 

90 self._mfa_factor = mfa_factor 

91 self._session_lifetime = session_lifetime 

92 

93 # ------------------------------------------------------------------ 

94 # Public API 

95 # ------------------------------------------------------------------ 

96 

97 async def authenticate( 

98 self, 

99 email: str, 

100 password: str, 

101 ip_address: str, 

102 user_agent: str, 

103 ) -> Result[AdminAuthResult, AdminAuthError]: 

104 """Authenticate an admin user through the full security pipeline. 

105 

106 Steps executed in order: 

107 

108 1. ``check_ip_rate_limit`` — raises ``RateLimitExceededError`` if the 

109 origin IP has exceeded the configured threshold. 

110 2. ``check_account_lockout`` — raises ``AccountLockedError`` if the 

111 account is temporarily or permanently locked. 

112 3. ``user_store.authenticate`` — returns ``None`` on invalid credentials. 

113 3b. Email verification gate — when enforcement is on and the email is 

114 unverified, returns a result with ``email_verification_required=True`` 

115 (no session created); the caller must run the verify flow. 

116 3c. Second-factor challenge — when the configured factor is active, 

117 returns a result with ``mfa_required=True`` (no session created); 

118 the caller must finish via ``complete_mfa_login``. 

119 4. Record success attempt and clear lockout state. 

120 5. Create a new session via ``session_service``. 

121 6. Emit ``LOGIN_SUCCESS`` audit event. 

122 

123 Args: 

124 email: Admin user email address. 

125 password: Plain-text password to verify. 

126 ip_address: Client IP address used for rate limiting and audit. 

127 user_agent: Client user-agent string used for audit. 

128 

129 Returns: 

130 ``Ok(AdminAuthResult)`` containing session details on success. 

131 ``Ok(AdminAuthResult)`` with ``mfa_required=True`` (empty 

132 ``session_id``) when the user must complete a 2FA challenge. 

133 ``Ok(AdminAuthResult)`` with ``email_verification_required=True`` 

134 (empty ``session_id``) when the email is unverified. 

135 ``Err(RateLimitExceededError)`` when the IP is rate-limited. 

136 ``Err(AccountLockedError)`` when the account is locked. 

137 ``Err(InvalidCredentialsError)`` when credentials are invalid. 

138 """ 

139 # Step 1 — IP rate limit 

140 try: 

141 await self._attempt_service.check_ip_rate_limit(ip_address) 

142 except RateLimitExceededError as exc: 

143 await self._attempt_service.record_attempt( 

144 email=email, 

145 ip_address=ip_address, 

146 user_agent=user_agent, 

147 success=False, 

148 failure_reason="ip_rate_limited", 

149 ) 

150 await self._audit_service.log_event( 

151 event_type=AdminSecurityEventType.LOGIN_BLOCKED_IP, 

152 ip_address=ip_address, 

153 user_agent=user_agent, 

154 success=False, 

155 metadata={"email": email}, 

156 ) 

157 logger.warning( 

158 "admin_login_blocked_ip", 

159 ip_address=ip_address, 

160 email=email, 

161 ) 

162 return Err(exc) 

163 

164 # Step 2 — Account lockout 

165 try: 

166 await self._attempt_service.check_account_lockout(email) 

167 except AccountLockedError as exc: 

168 await self._audit_service.log_event( 

169 event_type=AdminSecurityEventType.LOGIN_BLOCKED_LOCKOUT, 

170 ip_address=ip_address, 

171 user_agent=user_agent, 

172 success=False, 

173 metadata={"email": email}, 

174 ) 

175 logger.warning( 

176 "admin_login_blocked_lockout", 

177 email=email, 

178 ip_address=ip_address, 

179 ) 

180 return Err(exc) 

181 

182 # Step 3 — Credential verification 

183 user: Any | None = await self._user_store.authenticate(email, password) 

184 if user is None: 

185 await self._attempt_service.record_attempt( 

186 email=email, 

187 ip_address=ip_address, 

188 user_agent=user_agent, 

189 success=False, 

190 failure_reason="invalid_credentials", 

191 ) 

192 await self._audit_service.log_event( 

193 event_type=AdminSecurityEventType.LOGIN_FAILURE, 

194 ip_address=ip_address, 

195 user_agent=user_agent, 

196 success=False, 

197 metadata={"email": email}, 

198 ) 

199 logger.info( 

200 "admin_login_failure", 

201 email=email, 

202 ip_address=ip_address, 

203 ) 

204 return Err(InvalidCredentialsError("Invalid email or password.")) 

205 

206 # Step 3b — Email verification gate (when enforcement is on) 

207 if ( 

208 self._email_verification_service is not None 

209 and await self._email_verification_service.is_required(str(user.user_id)) 

210 ): 

211 await self._audit_service.log_event( 

212 event_type=AdminSecurityEventType.EMAIL_VERIFICATION_SENT, 

213 ip_address=ip_address, 

214 user_agent=user_agent, 

215 success=True, 

216 admin_user_id=str(user.user_id), 

217 metadata={"email": str(user.email)}, 

218 ) 

219 roles: list[str] = list(getattr(user, "roles", []) or []) 

220 return Ok( 

221 AdminAuthResult( 

222 session_id="", 

223 user_id=str(user.user_id), 

224 email=str(user.email), 

225 roles=roles, 

226 expires_at=datetime.now(UTC) 

227 + timedelta(seconds=self._session_lifetime), 

228 email_verification_required=True, 

229 ) 

230 ) 

231 

232 # Step 3c — Second-factor challenge (TOTP or email per config) 

233 if self._mfa_factor == "email": 

234 if self._email_otp_service is not None: 

235 await self._audit_service.log_event( 

236 event_type=AdminSecurityEventType.MFA_CHALLENGE_ISSUED, 

237 ip_address=ip_address, 

238 user_agent=user_agent, 

239 success=True, 

240 admin_user_id=str(user.user_id), 

241 metadata={"email": str(user.email)}, 

242 ) 

243 roles = list(getattr(user, "roles", []) or []) 

244 return Ok( 

245 AdminAuthResult( 

246 session_id="", 

247 user_id=str(user.user_id), 

248 email=str(user.email), 

249 roles=roles, 

250 expires_at=datetime.now(UTC) 

251 + timedelta(seconds=self._session_lifetime), 

252 mfa_required=True, 

253 ) 

254 ) 

255 elif self._mfa_factor == "totp" and self._mfa_service is not None: 

256 mfa_enabled = await self._mfa_service.is_enabled(str(user.user_id)) 

257 if mfa_enabled: 

258 await self._audit_service.log_event( 

259 event_type=AdminSecurityEventType.MFA_CHALLENGE_ISSUED, 

260 ip_address=ip_address, 

261 user_agent=user_agent, 

262 success=True, 

263 admin_user_id=str(user.user_id), 

264 metadata={"email": str(user.email)}, 

265 ) 

266 roles = list(getattr(user, "roles", []) or []) 

267 return Ok( 

268 AdminAuthResult( 

269 session_id="", 

270 user_id=str(user.user_id), 

271 email=str(user.email), 

272 roles=roles, 

273 expires_at=datetime.now(UTC) 

274 + timedelta(seconds=self._session_lifetime), 

275 mfa_required=True, 

276 ) 

277 ) 

278 

279 # Step 4 — Record success and clear lockout 

280 await self._attempt_service.record_attempt( 

281 email=email, 

282 ip_address=ip_address, 

283 user_agent=user_agent, 

284 success=True, 

285 ) 

286 await self._attempt_service.clear_lockout(email) 

287 

288 # Step 5 — Create session 

289 session_roles: list[str] = list(getattr(user, "roles", []) or []) 

290 session_id: str = await self._session_service.create_session( 

291 user_id=str(user.user_id), 

292 email=str(user.email), 

293 roles=session_roles, 

294 ip_address=ip_address, 

295 user_agent=user_agent, 

296 ) 

297 await self._audit_service.log_event( 

298 event_type=AdminSecurityEventType.SESSION_CREATED, 

299 ip_address=ip_address, 

300 user_agent=user_agent, 

301 success=True, 

302 admin_user_id=str(user.user_id), 

303 metadata={"email": str(user.email), "session_id": session_id}, 

304 ) 

305 

306 expires_at: datetime = datetime.now(UTC) + timedelta( 

307 seconds=self._session_lifetime 

308 ) 

309 

310 # Step 6 — Audit success 

311 await self._audit_service.log_event( 

312 event_type=AdminSecurityEventType.LOGIN_SUCCESS, 

313 ip_address=ip_address, 

314 user_agent=user_agent, 

315 success=True, 

316 admin_user_id=str(user.user_id), 

317 metadata={"email": str(user.email), "session_id": session_id}, 

318 ) 

319 logger.info( 

320 "admin_login_success", 

321 user_id=str(user.user_id), 

322 email=str(user.email), 

323 ip_address=ip_address, 

324 ) 

325 

326 return Ok( 

327 AdminAuthResult( 

328 session_id=session_id, 

329 user_id=str(user.user_id), 

330 email=str(user.email), 

331 roles=session_roles, 

332 expires_at=expires_at, 

333 ) 

334 ) 

335 

336 async def complete_mfa_login( 

337 self, 

338 user_id: str, 

339 email: str, 

340 roles: list[str], 

341 code: str, 

342 ip_address: str, 

343 user_agent: str, 

344 ) -> Result[AdminAuthResult, AdminAuthError]: 

345 """Complete a login after a successful second-factor challenge. 

346 

347 Called by the 2FA form once the user supplies a valid code. Runs 

348 the post-credential pipeline that ``authenticate`` deferred when it 

349 returned ``mfa_required=True``: attempt recording, lockout 

350 clearance, session creation, and audit logging. 

351 

352 The code is verified against the configured factor: TOTP via 

353 ``mfa_service`` or email OTP via ``email_otp_service``. 

354 

355 Args: 

356 user_id: Admin user UUID (from the pending challenge). 

357 email: Admin user email (from the pending challenge). 

358 roles: Role names for the user (from the pending challenge). 

359 code: TOTP code or email OTP code to verify. 

360 ip_address: Client IP address used for rate limiting and audit. 

361 user_agent: Client user-agent string used for audit. 

362 

363 Returns: 

364 ``Ok(AdminAuthResult)`` with a real session on success. 

365 ``Err(MfaVerificationFailedError)`` when the code is invalid. 

366 ``Err(MfaNotEnabledError)`` when the selected factor is 

367 unavailable. 

368 ``Err(RateLimitExceededError)`` when the IP is rate-limited. 

369 ``Err(AccountLockedError)`` when the account is locked. 

370 """ 

371 # Step 0a — IP rate limit (mirrors authenticate()'s Step 1) 

372 try: 

373 await self._attempt_service.check_ip_rate_limit(ip_address) 

374 except RateLimitExceededError as exc: 

375 await self._attempt_service.record_attempt( 

376 email=email, 

377 ip_address=ip_address, 

378 user_agent=user_agent, 

379 success=False, 

380 failure_reason="mfa_ip_rate_limited", 

381 ) 

382 await self._audit_service.log_event( 

383 event_type=AdminSecurityEventType.LOGIN_BLOCKED_IP, 

384 ip_address=ip_address, 

385 user_agent=user_agent, 

386 success=False, 

387 metadata={"email": email, "stage": "mfa"}, 

388 ) 

389 return Err(exc) 

390 

391 # Step 0b — Account lockout (mirrors authenticate()'s Step 2) 

392 try: 

393 await self._attempt_service.check_account_lockout(email) 

394 except AccountLockedError as exc: 

395 await self._audit_service.log_event( 

396 event_type=AdminSecurityEventType.LOGIN_BLOCKED_LOCKOUT, 

397 ip_address=ip_address, 

398 user_agent=user_agent, 

399 success=False, 

400 metadata={"email": email, "stage": "mfa"}, 

401 ) 

402 return Err(exc) 

403 

404 if self._mfa_factor == "email": 

405 if self._email_otp_service is None: 

406 return Err( 

407 MfaNotEnabledError( 

408 "Email code authentication is not available for this account." 

409 ) 

410 ) 

411 verification = await self._email_otp_service.verify_otp(user_id, code) 

412 else: 

413 if self._mfa_service is None: 

414 return Err( 

415 MfaNotEnabledError( 

416 "Two-factor authentication is not enabled for this account." 

417 ) 

418 ) 

419 verification = await self._mfa_service.verify_code(user_id, code) 

420 

421 # Step 1 — Verify the second-factor code 

422 if verification.is_err(): 

423 await self._audit_service.log_event( 

424 event_type=AdminSecurityEventType.MFA_CHALLENGE_FAILED, 

425 ip_address=ip_address, 

426 user_agent=user_agent, 

427 success=False, 

428 admin_user_id=user_id, 

429 metadata={"email": email}, 

430 ) 

431 logger.warning("admin_mfa_code_not_available", user_id=user_id) 

432 return Err(verification.unwrap_err()) 

433 

434 if not verification.unwrap(): 

435 await self._audit_service.log_event( 

436 event_type=AdminSecurityEventType.MFA_CHALLENGE_FAILED, 

437 ip_address=ip_address, 

438 user_agent=user_agent, 

439 success=False, 

440 admin_user_id=user_id, 

441 metadata={"email": email}, 

442 ) 

443 await self._attempt_service.record_attempt( 

444 email=email, 

445 ip_address=ip_address, 

446 user_agent=user_agent, 

447 success=False, 

448 failure_reason="invalid_mfa_code", 

449 ) 

450 logger.warning("admin_mfa_code_failed", user_id=user_id, email=email) 

451 return Err(MfaVerificationFailedError("Invalid verification code.")) 

452 

453 await self._audit_service.log_event( 

454 event_type=AdminSecurityEventType.MFA_VERIFIED, 

455 ip_address=ip_address, 

456 user_agent=user_agent, 

457 success=True, 

458 admin_user_id=user_id, 

459 metadata={"email": email}, 

460 ) 

461 

462 # Step 2 — Record success and clear lockout 

463 await self._attempt_service.record_attempt( 

464 email=email, 

465 ip_address=ip_address, 

466 user_agent=user_agent, 

467 success=True, 

468 ) 

469 await self._attempt_service.clear_lockout(email) 

470 

471 # Step 3 — Create session 

472 session_id: str = await self._session_service.create_session( 

473 user_id=user_id, 

474 email=email, 

475 roles=roles, 

476 ip_address=ip_address, 

477 user_agent=user_agent, 

478 ) 

479 await self._audit_service.log_event( 

480 event_type=AdminSecurityEventType.SESSION_CREATED, 

481 ip_address=ip_address, 

482 user_agent=user_agent, 

483 success=True, 

484 admin_user_id=user_id, 

485 metadata={"email": email, "session_id": session_id}, 

486 ) 

487 

488 expires_at: datetime = datetime.now(UTC) + timedelta( 

489 seconds=self._session_lifetime 

490 ) 

491 

492 # Step 4 — Audit success 

493 await self._audit_service.log_event( 

494 event_type=AdminSecurityEventType.LOGIN_SUCCESS, 

495 ip_address=ip_address, 

496 user_agent=user_agent, 

497 success=True, 

498 admin_user_id=user_id, 

499 metadata={"email": email, "session_id": session_id}, 

500 ) 

501 logger.info( 

502 "admin_login_success", 

503 user_id=user_id, 

504 email=email, 

505 ip_address=ip_address, 

506 ) 

507 

508 return Ok( 

509 AdminAuthResult( 

510 session_id=session_id, 

511 user_id=user_id, 

512 email=email, 

513 roles=roles, 

514 expires_at=expires_at, 

515 ) 

516 ) 

517 

518 async def invalidate_session(self, session_id: str) -> None: 

519 """Revoke a single session (logout). 

520 

521 Revokes the session via ``session_service`` and emits a ``LOGOUT`` 

522 audit event. Audit failure is absorbed and never propagated. 

523 

524 Args: 

525 session_id: Identifier of the session to revoke. 

526 """ 

527 await self._session_service.revoke_session(session_id) 

528 await self._audit_service.log_event( 

529 event_type=AdminSecurityEventType.LOGOUT, 

530 ip_address="", 

531 user_agent="", 

532 success=True, 

533 metadata={"session_id": session_id}, 

534 ) 

535 logger.info("admin_session_invalidated", session_id=session_id) 

536 

537 async def invalidate_all_user_sessions(self, user_id: str) -> None: 

538 """Revoke all active sessions for an admin user. 

539 

540 Called after a password change or administrative action requiring 

541 full session teardown. Emits a ``SESSION_REVOKED`` audit event. 

542 Audit failure is absorbed and never propagated. 

543 

544 Args: 

545 user_id: UUID of the admin user whose sessions to revoke. 

546 """ 

547 await self._session_service.revoke_all_user_sessions(user_id) 

548 await self._audit_service.log_event( 

549 event_type=AdminSecurityEventType.SESSION_REVOKED, 

550 ip_address="", 

551 user_agent="", 

552 success=True, 

553 admin_user_id=user_id, 

554 metadata={"reason": "all_sessions_revoked"}, 

555 ) 

556 logger.info("admin_all_sessions_invalidated", user_id=user_id) 

557 

558 

559__all__ = ["AdminAuthService"]