Coverage for src/lexigram/admin/controllers/auth/mfa.py: 85%
159 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
1from __future__ import annotations
3from typing import TYPE_CHECKING, Any
4from urllib.parse import quote_plus
6from starlette.requests import Request
7from starlette.responses import HTMLResponse, RedirectResponse
9from lexigram.admin.controllers.auth.core import (
10 _CACHE_CONTROL_NO_STORE,
11 AuthCoreMixin,
12 _humanize_error,
13 logger,
14)
15from lexigram.admin.lib.template import render_mfa_challenge_page, render_mfa_setup_page
16from lexigram.contracts.web import get, post
18if TYPE_CHECKING:
19 from lexigram.admin.auth.protocols import (
20 AdminAuthServiceProtocol,
21 AdminCsrfServiceProtocol,
22 AdminEmailOtpServiceProtocol,
23 AdminEmailVerificationServiceProtocol,
24 AdminMfaServiceProtocol,
25 )
26 from lexigram.admin.observability.admin_metrics import AdminMetrics
29class AuthMfaMixin(AuthCoreMixin):
30 """AuthController mfa endpoints."""
32 _auth_service: AdminAuthServiceProtocol
33 _csrf_service: AdminCsrfServiceProtocol
34 _email_otp_service: AdminEmailOtpServiceProtocol | None
35 _email_verification_service: AdminEmailVerificationServiceProtocol | None
36 _metrics: AdminMetrics
37 _mfa_service: AdminMfaServiceProtocol | None
39 @get("/login/2fa")
40 async def mfa_challenge_form(
41 self, request: Request
42 ) -> HTMLResponse | RedirectResponse:
43 """Display the standalone TOTP challenge form.
45 Only reachable when a pending 2FA challenge exists in the session
46 (parked by ``login_submit``); otherwise the user is redirected to
47 the login page. A fresh CSRF token is embedded in the form.
49 Args:
50 request: Incoming HTTP request.
52 Returns:
53 HTMLResponse with the rendered challenge page, or a
54 RedirectResponse to /admin/ or /admin/login.
55 """
56 user = getattr(request.state, "user", None)
57 if user and user.user_id != "guest":
58 return RedirectResponse(url="/admin/", status_code=302)
60 pending_user_id = request.session.get("mfa_pending_user_id", "")
61 if not pending_user_id:
62 return RedirectResponse(url="/admin/login", status_code=302)
64 email = request.session.get("mfa_pending_email", "")
65 next_url = request.session.get("mfa_pending_next", "/admin/")
66 factor = request.session.get("mfa_pending_factor", "totp")
67 error = _humanize_error(request.query_params.get("error", ""))
68 notice = request.query_params.get("notice", "")
69 csrf_token = self._fresh_csrf(request)
71 html = render_mfa_challenge_page(
72 email=email,
73 error=error,
74 csrf_token=csrf_token,
75 next_url=next_url,
76 factor=factor,
77 resend_notice=notice,
78 )
79 return HTMLResponse(content=html, headers=_CACHE_CONTROL_NO_STORE)
81 @post("/login/2fa")
82 async def mfa_challenge_submit(self, request: Request) -> RedirectResponse:
83 """Complete a 2FA challenge and finish the login.
85 Verifies the CSRF token, delegates code verification to
86 ``AdminAuthServiceProtocol.complete_mfa_login``, and on success
87 populates the session exactly like ``login_submit``. On failure
88 the challenge page is re-shown with an error message.
90 Args:
91 request: Incoming HTTP request carrying form data.
93 Returns:
94 RedirectResponse to the pending destination on success, or back
95 to the challenge page with an error query parameter.
96 """
97 form_data = request.scope.get("admin_form_data") or await request.form()
98 code = str(form_data.get("code", ""))
99 csrf_token = str(form_data.get("csrf_token", ""))
101 pending_user_id = request.session.get("mfa_pending_user_id", "")
102 if not pending_user_id:
103 return RedirectResponse(url="/admin/login", status_code=302)
105 csrf_session_id = request.session.get("csrf_session_id", "")
106 if not csrf_session_id or not self._csrf_service.validate_token(
107 csrf_session_id, csrf_token
108 ):
109 logger.warning(
110 "auth.csrf_validation_failed", ip=self._get_client_ip(request)
111 )
112 return RedirectResponse(
113 url=f"/admin/login/2fa?error={quote_plus('Invalid or expired security token. Please try again.')}",
114 status_code=302,
115 )
117 if not code:
118 return RedirectResponse(
119 url=f"/admin/login/2fa?error={quote_plus('Verification code is required.')}",
120 status_code=302,
121 )
123 ip = self._get_client_ip(request)
124 user_agent = request.headers.get("user-agent", "")
126 result = await self._auth_service.complete_mfa_login(
127 user_id=pending_user_id,
128 email=request.session.get("mfa_pending_email", ""),
129 roles=request.session.get("mfa_pending_roles", []),
130 code=code,
131 ip_address=ip,
132 user_agent=user_agent,
133 )
135 if result.is_err():
136 self._metrics.record_login(status="failure")
137 error_msg = str(result.unwrap_err())
138 logger.warning(
139 "auth.mfa_code_failed", user_id=pending_user_id, reason=error_msg
140 )
141 return RedirectResponse(
142 url=f"/admin/login/2fa?error={quote_plus(error_msg)}",
143 status_code=302,
144 )
146 auth_result = result.unwrap()
147 next_url = request.session.get("mfa_pending_next", "/admin/")
148 request.session["admin_user_id"] = auth_result.user_id
149 request.session["admin_user_email"] = auth_result.email
150 request.session["admin_session_expires_at"] = auth_result.expires_at.isoformat()
151 request.session["session_id"] = auth_result.session_id
152 request.session.pop("csrf_session_id", None)
153 for key in (
154 "mfa_pending_user_id",
155 "mfa_pending_email",
156 "mfa_pending_roles",
157 "mfa_pending_next",
158 "mfa_pending_factor",
159 ):
160 request.session.pop(key, None)
161 self._metrics.record_login(status="success")
162 logger.info(
163 "auth.login_success",
164 user_id=auth_result.user_id,
165 email=auth_result.email,
166 redirect=next_url,
167 )
168 return RedirectResponse(url=self._safe_next_url(next_url), status_code=302)
170 @post("/login/2fa/resend")
171 async def mfa_challenge_resend(self, request: Request) -> RedirectResponse:
172 """Resend the email verification code for a pending challenge.
174 Only valid for the email factor and while a challenge is parked in
175 the session. Cooldown and delivery errors (e.g. "please wait") are
176 surfaced on the challenge page.
178 Args:
179 request: Incoming HTTP request carrying form data.
181 Returns:
182 RedirectResponse to /admin/login/2fa with a notice on success or
183 an error query parameter on failure.
184 """
185 pending_user_id = request.session.get("mfa_pending_user_id", "")
186 if not pending_user_id:
187 return RedirectResponse(url="/admin/login", status_code=302)
189 form_data = request.scope.get("admin_form_data") or await request.form()
190 csrf_token = str(form_data.get("csrf_token", ""))
191 csrf_session_id = request.session.get("csrf_session_id", "")
192 if not csrf_session_id or not self._csrf_service.validate_token(
193 csrf_session_id, csrf_token
194 ):
195 logger.warning(
196 "auth.csrf_validation_failed", ip=self._get_client_ip(request)
197 )
198 return RedirectResponse(
199 url=f"/admin/login/2fa?error={quote_plus('Invalid or expired security token. Please try again.')}",
200 status_code=302,
201 )
203 factor = request.session.get("mfa_pending_factor", "totp")
204 if factor != "email" or self._email_otp_service is None:
205 return RedirectResponse(
206 url=f"/admin/login/2fa?error={quote_plus('Resending codes is not available for this factor.')}",
207 status_code=302,
208 )
210 email = str(form_data.get("email", "")) or request.session.get(
211 "mfa_pending_email", ""
212 )
213 result = await self._email_otp_service.send_otp(
214 user_id=pending_user_id,
215 email=email,
216 user_name=email,
217 )
218 if result.is_err():
219 logger.warning(
220 "auth.email_otp_resend_failed",
221 user_id=pending_user_id,
222 reason=str(result.unwrap_err()),
223 )
224 return RedirectResponse(
225 url=f"/admin/login/2fa?error={quote_plus(_humanize_error(str(result.unwrap_err())))}",
226 status_code=302,
227 )
228 return RedirectResponse(
229 url="/admin/login/2fa?notice=" + quote_plus("A new code has been sent."),
230 status_code=302,
231 )
233 @get("/profile/mfa")
234 async def mfa_profile_form(
235 self, request: Request
236 ) -> HTMLResponse | RedirectResponse:
237 """Display the authenticated user's 2FA settings page.
239 Shows the disable form when 2FA is active; otherwise generates a
240 fresh secret, QR code, and confirm form. Requires an authenticated
241 request (``request.state.user`` provided by the auth guard).
243 Args:
244 request: Incoming HTTP request.
246 Returns:
247 HTMLResponse with the rendered setup page, or a RedirectResponse
248 when unauthenticated or 2FA is disabled in configuration.
249 """
250 user = getattr(request.state, "user", None)
251 if not user or user.user_id == "guest":
252 return RedirectResponse(
253 url="/admin/login?next=/admin/profile/mfa", status_code=302
254 )
255 if self._mfa_service is None:
256 return RedirectResponse(url="/admin/", status_code=302)
258 error = _humanize_error(request.query_params.get("error", ""))
259 notice = request.query_params.get("notice", "")
260 csrf_token = self._fresh_csrf(request)
261 user_id = str(user.user_id)
263 email_verified: bool | None = None
264 if self._email_verification_service is not None:
265 email_verified = await self._email_verification_service.is_verified(user_id)
267 if await self._mfa_service.is_enabled(user_id):
268 html = render_mfa_setup_page(
269 enabled=True,
270 csrf_token=csrf_token,
271 email_verified=email_verified,
272 )
273 else:
274 result = await self._mfa_service.start_setup(user_id, str(user.email))
275 if result.is_err():
276 error = _humanize_error(str(result.unwrap_err()))
277 html = render_mfa_setup_page(
278 enabled=False,
279 csrf_token=csrf_token,
280 email_verified=email_verified,
281 )
282 else:
283 secret, _, svg = result.unwrap()
284 request.session["mfa_pending_secret"] = secret
285 html = render_mfa_setup_page(
286 enabled=False,
287 qr_svg=svg,
288 secret=secret,
289 csrf_token=csrf_token,
290 email_verified=email_verified,
291 )
293 from lexigram.admin.state.context import AdminContextManager
295 async with AdminContextManager(request) as ctx:
296 if error:
297 ctx.add_flash(error, "error")
298 if notice:
299 ctx.add_flash(notice, "success")
300 response = await self.render_admin(
301 request,
302 html,
303 title="Two-Factor Authentication",
304 breadcrumbs=self.generate_breadcrumbs(
305 ("Home", "/admin/"),
306 ("Profile", "/admin/profile"),
307 current="Two-Factor Authentication",
308 ),
309 )
310 response.headers.update(_CACHE_CONTROL_NO_STORE)
311 return response
313 @post("/profile/mfa/setup")
314 async def mfa_setup_submit(self, request: Request) -> RedirectResponse:
315 """Confirm a newly generated TOTP secret.
317 Validates the code from the setup form against the pending secret
318 stashed in the session; only a valid code persists the secret.
320 Args:
321 request: Incoming HTTP request carrying form data.
323 Returns:
324 RedirectResponse to /admin/profile/mfa with a notice on success
325 or an error query parameter on failure.
326 """
327 user = getattr(request.state, "user", None)
328 if not user or user.user_id == "guest":
329 return RedirectResponse(
330 url="/admin/login?next=/admin/profile/mfa", status_code=302
331 )
332 if self._mfa_service is None:
333 return RedirectResponse(url="/admin/", status_code=302)
335 form_data = request.scope.get("admin_form_data") or await request.form()
336 csrf_token = str(form_data.get("csrf_token", ""))
337 csrf_session_id = request.session.get("csrf_session_id") or request.session.get(
338 "admin_user_id", ""
339 )
340 if not csrf_session_id or not self._csrf_service.validate_token(
341 csrf_session_id, csrf_token
342 ):
343 logger.warning(
344 "auth.csrf_validation_failed", ip=self._get_client_ip(request)
345 )
346 return RedirectResponse(
347 url=f"/admin/profile/mfa?error={quote_plus('Invalid or expired security token. Please try again.')}",
348 status_code=302,
349 )
351 code = str(form_data.get("code", ""))
352 secret = request.session.pop("mfa_pending_secret", "")
353 if not code or not secret:
354 return RedirectResponse(
355 url=f"/admin/profile/mfa?error={quote_plus('Verification code is required.')}",
356 status_code=302,
357 )
359 result = await self._mfa_service.confirm_setup(str(user.user_id), secret, code)
360 if result.is_err():
361 return RedirectResponse(
362 url=f"/admin/profile/mfa?error={quote_plus(_humanize_error(str(result.unwrap_err())))}",
363 status_code=302,
364 )
365 return RedirectResponse(
366 url="/admin/profile/mfa?notice="
367 + quote_plus("Two-factor authentication enabled."),
368 status_code=302,
369 )
371 @post("/profile/mfa/disable")
372 async def mfa_disable_submit(self, request: Request) -> RedirectResponse:
373 """Disable 2FA after validating the current TOTP code.
375 Requires the user to prove possession of the secret by entering a
376 valid current code.
378 Args:
379 request: Incoming HTTP request carrying form data.
381 Returns:
382 RedirectResponse to /admin/profile/mfa with a notice on success
383 or an error query parameter on failure.
384 """
385 user = getattr(request.state, "user", None)
386 if not user or user.user_id == "guest":
387 return RedirectResponse(
388 url="/admin/login?next=/admin/profile/mfa", status_code=302
389 )
390 if self._mfa_service is None:
391 return RedirectResponse(url="/admin/", status_code=302)
393 form_data = request.scope.get("admin_form_data") or await request.form()
394 csrf_token = str(form_data.get("csrf_token", ""))
395 csrf_session_id = request.session.get("csrf_session_id") or request.session.get(
396 "admin_user_id", ""
397 )
398 if not csrf_session_id or not self._csrf_service.validate_token(
399 csrf_session_id, csrf_token
400 ):
401 logger.warning(
402 "auth.csrf_validation_failed", ip=self._get_client_ip(request)
403 )
404 return RedirectResponse(
405 url=f"/admin/profile/mfa?error={quote_plus('Invalid or expired security token. Please try again.')}",
406 status_code=302,
407 )
409 code = str(form_data.get("code", ""))
410 if not code:
411 return RedirectResponse(
412 url=f"/admin/profile/mfa?error={quote_plus('Verification code is required.')}",
413 status_code=302,
414 )
416 result = await self._mfa_service.disable(str(user.user_id), code)
417 if result.is_err():
418 return RedirectResponse(
419 url=f"/admin/profile/mfa?error={quote_plus(_humanize_error(str(result.unwrap_err())))}",
420 status_code=302,
421 )
422 request.session.pop("mfa_pending_secret", None)
423 return RedirectResponse(
424 url="/admin/profile/mfa?notice="
425 + quote_plus("Two-factor authentication disabled."),
426 status_code=302,
427 )