11import logging
2- from contextlib import suppress
32
43from aiohttp import web
54from aiohttp .web import RouteTableDef
1918from ..users import api as users_service
2019from ..utils import HOUR
2120from ..utils_rate_limiting import global_rate_limit_route
22- from ._confirmation import is_confirmation_valid , make_confirmation_link
21+ from ._confirmation import get_or_create_confirmation , make_confirmation_link
2322from ._constants import (
2423 MSG_CANT_SEND_MAIL ,
2524 MSG_CHANGE_EMAIL_REQUESTED ,
@@ -50,6 +49,14 @@ class ResetPasswordBody(InputSchema):
5049 email : str
5150
5251
52+ def _get_request_context (request : web .Request ) -> dict [str , str ]:
53+ return {
54+ "request.remote" : f"{ request .remote } " ,
55+ "request.method" : f"{ request .method } " ,
56+ "request.path" : f"{ request .path } " ,
57+ }
58+
59+
5360@routes .post (f"/{ API_VTAG } /auth/reset-password" , name = "initiate_reset_password" )
5461@global_rate_limit_route (number_of_requests = 10 , interval_seconds = HOUR )
5562async def initiate_reset_password (request : web .Request ):
@@ -75,28 +82,44 @@ async def initiate_reset_password(request: web.Request):
7582
7683 # NOTE: Always same response: never want to confirm or deny the existence of an account
7784 # with a given email or username.
78- response = flash_response (MSG_EMAIL_SENT .format (email = request_body .email ), "INFO" )
79-
85+ initiated_response = flash_response (
86+ MSG_EMAIL_SENT .format (email = request_body .email ), "INFO"
87+ )
8088 # CHECK user exists
8189 user = await db .get_user ({"email" : request_body .email })
8290 if not user :
8391 _logger .warning (
84- "Password reset requested for non-existent email '%s' from IP: %s" ,
85- request_body .email ,
86- request .remote ,
92+ ** create_troubleshotting_log_kwargs (
93+ "Password reset initiated for non-existent email. Ignoring request." ,
94+ error = Exception ("No user found with this email" ),
95+ error_context = {
96+ "user_email" : request_body .email ,
97+ "product_name" : product .name ,
98+ ** _get_request_context (request ),
99+ },
100+ )
87101 )
88- return response
102+ return initiated_response
89103
90104 # CHECK user state
91105 try :
92106 validate_user_status (user = dict (user ), support_email = product .support_email )
93107 except web .HTTPError as err :
108+ # NOTE: we abuse here by reusing `validate_user_status` and catching http errors that we
109+ # do not want to forward but rather log due to the special rules in this entrypoint
94110 _logger .warning (
95- "User status invalidated for email '%s'. Details: %s" ,
96- request_body .email ,
97- err ,
111+ ** create_troubleshotting_log_kwargs (
112+ "Password reset initiated for invalid user. Ignoring request." ,
113+ error = err ,
114+ error_context = {
115+ "user_id" : user ["id" ],
116+ "user" : user ,
117+ "product_name" : product .name ,
118+ ** _get_request_context (request ),
119+ },
120+ )
98121 )
99- return response
122+ return initiated_response
100123
101124 assert user ["status" ] == ACTIVE # nosec
102125 assert user ["email" ] == request_body .email # nosec
@@ -107,21 +130,26 @@ async def initiate_reset_password(request: web.Request):
107130 request .app , user_id = user ["id" ], product_name = product .name
108131 ):
109132 _logger .warning (
110- "Password rest requested for a registered user but wihtout access to product"
133+ ** create_troubleshotting_log_kwargs (
134+ "Password reset initiated for a user with NO access to this product. Ignoring request." ,
135+ error = Exception ("User cannot access this product" ),
136+ error_context = {
137+ "user_id" : user ["id" ],
138+ "user" : user ,
139+ "product_name" : product .name ,
140+ ** _get_request_context (request ),
141+ },
142+ )
111143 )
112- return response
144+ return initiated_response
113145
114- if await is_confirmation_valid ( cfg , db , user , action = RESET_PASSWORD ) :
115- _logger . warning (
116- "User is requesting password reset but already a valid confirmation code for this action"
117- # Extend expiration and resend email?
146+ try :
147+ # confirmation token that includes code to complete_reset_password
148+ confirmation = await get_or_create_confirmation (
149+ cfg , db , dict ( user ), action = RESET_PASSWORD
118150 )
119- # delete previous and resend new?
120- return response
121151
122- try :
123152 # Produce a link so that the front-end can hit `complete_reset_password`
124- confirmation = await db .create_confirmation (user ["id" ], action = RESET_PASSWORD )
125153 link = make_confirmation_link (request , confirmation )
126154
127155 # primary reset email with a URL and the normal instructions.
@@ -141,14 +169,16 @@ async def initiate_reset_password(request: web.Request):
141169 ** create_troubleshotting_log_kwargs (
142170 "Unable to send email" ,
143171 error = err ,
144- error_context = {"product_name" : product .name , "user_id" : user ["id" ]},
172+ error_context = {
173+ "product_name" : product .name ,
174+ "user_id" : user ["id" ],
175+ ** _get_request_context (request ),
176+ },
145177 )
146178 )
147- with suppress (Exception ):
148- await db .delete_confirmation (confirmation )
149179 raise web .HTTPServiceUnavailable (reason = MSG_CANT_SEND_MAIL ) from err
150180
151- return response
181+ return initiated_response
152182
153183
154184class ChangeEmailBody (InputSchema ):
0 commit comments