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

1"""Shared in-app Auth0 password-change logic. 

2 

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""" 

8 

9import logging 

10import re 

11from typing import Optional 

12 

13import httpx 

14from fastapi import HTTPException, status 

15from pydantic import BaseModel, Field, field_validator 

16 

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) 

27 

28from server.authentication.auth0_config import get_auth0_settings 

29from server.services.common.auth0_management import auth0_management 

30 

31logger = logging.getLogger(__name__) 

32 

33 

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) 

40 

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 

47 

48 

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." 

61 

62 

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. 

73 

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 ) 

81 

82 settings = get_auth0_settings() 

83 

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 ) 

105 

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 ) 

115 

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 )