Coverage for server / services / common / password_update.py: 100%
57 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""Shared in-app Auth0 password-change logic.
3The student and teacher ``/password/update`` routes both verify the current
4password via Auth0 Resource Owner Password Grant (ROPC) and then update it via
5the Auth0 Management API. That flow lives here so it is defined once instead of
6copy-pasted per role.
7"""
9import logging
10import re
11from typing import Optional
13import httpx
14from fastapi import HTTPException, status
15from pydantic import BaseModel, Field, field_validator
17# Password policy: 10–25 chars AND all four character classes (lowercase,
18# uppercase, digit, special). Auth0's strongest built-in policy only requires
19# 3 of 4, so the mandatory-special-character rule is enforced here at the edge.
20_PASSWORD_COMPLEXITY_RE = re.compile(
21 r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{10,25}$"
22)
23_PASSWORD_COMPLEXITY_MESSAGE = (
24 "Password must be 10-25 characters and include an uppercase letter, a "
25 "lowercase letter, a number, and a special character."
26)
28from server.authentication.auth0_config import get_auth0_settings
29from server.services.common.auth0_management import auth0_management
31logger = logging.getLogger(__name__)
34class PasswordUpdateRequest(BaseModel):
35 current_password: str = Field(..., min_length=1)
36 # Canonical password length: 10–25 characters (matches the pre-Auth0 rule,
37 # the Auth0 tenant min, and the frontend Yup schemas). Auth0 itself cannot
38 # enforce a maximum, so the 25-char ceiling is guarded here at the API edge.
39 new_password: str = Field(..., min_length=10, max_length=25)
41 @field_validator("new_password")
42 @classmethod
43 def _enforce_complexity(cls, value: str) -> str:
44 if not _PASSWORD_COMPLEXITY_RE.match(value):
45 raise ValueError(_PASSWORD_COMPLEXITY_MESSAGE)
46 return value
49def _password_policy_detail(auth0_message: str) -> str:
50 """Map an Auth0 password-policy rejection to a clear, user-facing message."""
51 m = (auth0_message or "").lower()
52 if "history" in m or "previously been used" in m:
53 return "This password was used recently. Please choose a different one."
54 if "strength" in m or "too weak" in m:
55 return "Password is too weak. Please choose a stronger one."
56 if "dictionary" in m or "too common" in m:
57 return "This password is too common. Please choose a different one."
58 if "userinfo" in m or "user information" in m or "based on user" in m:
59 return "Password must not contain parts of your email or name."
60 return "Password rejected — please choose a different one."
63async def update_auth0_password(
64 auth0_user_id: Optional[str],
65 email: Optional[str],
66 current_password: str,
67 new_password: str,
68 *,
69 who: str = "user",
70) -> None:
71 """Verify the current password via Auth0 ROPC, then update it via the
72 Management API. Raises HTTPException on any failure; returns None on success.
74 No redirect is involved — the caller's modal stays in place.
75 """
76 if not auth0_user_id or not email:
77 raise HTTPException(
78 status_code=status.HTTP_401_UNAUTHORIZED,
79 detail="Could not determine user identity from token.",
80 )
82 settings = get_auth0_settings()
84 # Verify the current password via ROPC.
85 try:
86 async with httpx.AsyncClient(timeout=10.0) as client:
87 token_response = await client.post(
88 f"https://{settings.AUTH0_DOMAIN}/oauth/token",
89 json={
90 "grant_type": "password",
91 "username": email,
92 "password": current_password,
93 "client_id": settings.AUTH0_CLIENT_ID,
94 "client_secret": settings.AUTH0_CLIENT_SECRET,
95 "audience": settings.AUTH0_API_IDENTIFIER,
96 "scope": "openid",
97 },
98 )
99 except httpx.RequestError as e:
100 logger.error(f"Auth0 ROPC request failed: {e}")
101 raise HTTPException(
102 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
103 detail="Could not reach authentication service. Please try again.",
104 )
106 if token_response.status_code != 200:
107 # A wrong *current password* is a request-validation failure, not a
108 # session/auth failure. Return 400 (not 401) so the frontend's global
109 # "401 → session expired → redirect to /login" handler doesn't log the
110 # user out mid-form; the modal shows the error inline instead.
111 raise HTTPException(
112 status_code=status.HTTP_400_BAD_REQUEST,
113 detail="Current password is incorrect.",
114 )
116 # Update the password via the Management API.
117 try:
118 await auth0_management.update_user(auth0_user_id, password=new_password)
119 except httpx.HTTPStatusError as e:
120 # Auth0 rejected the new password (history / strength / dictionary / user-info).
121 # That's a client-correctable 400, not a server fault — surface the real reason
122 # so the change-password modal can show it (instead of a misleading 500).
123 if e.response is not None and e.response.status_code == 400:
124 try:
125 auth0_message = (e.response.json() or {}).get("message", "")
126 except Exception:
127 auth0_message = ""
128 raise HTTPException(
129 status_code=status.HTTP_400_BAD_REQUEST,
130 detail=_password_policy_detail(auth0_message),
131 )
132 logger.error(f"Failed to update {who} password in Auth0: {e}")
133 raise HTTPException(
134 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
135 detail="Failed to update password. Please try again later.",
136 )
137 except Exception as e:
138 logger.error(f"Failed to update {who} password in Auth0: {e}")
139 raise HTTPException(
140 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
141 detail="Failed to update password. Please try again later.",
142 )