Skip to content

Commit e406b3e

Browse files
committed
implementation
1 parent fc40a0c commit e406b3e

File tree

2 files changed

+71
-34
lines changed

2 files changed

+71
-34
lines changed

services/web/server/src/simcore_service_webserver/login/_confirmation.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
from datetime import datetime
11+
from typing import Any
1112
from urllib.parse import quote
1213

1314
from aiohttp import web
@@ -61,23 +62,29 @@ def get_expiration_date(
6162
return confirmation["created_at"] + lifetime
6263

6364

64-
async def is_confirmation_valid(
65-
cfg: LoginOptions, db: AsyncpgStorage, user, action: ConfirmationAction
66-
) -> bool:
65+
async def get_or_create_confirmation(
66+
cfg: LoginOptions,
67+
db: AsyncpgStorage,
68+
user: dict[str, Any],
69+
action: ConfirmationAction,
70+
) -> ConfirmationTokenDict:
71+
6772
confirmation: ConfirmationTokenDict | None = await db.get_confirmation(
6873
{"user": user, "action": action}
6974
)
70-
if not confirmation:
71-
return False
7275

73-
if is_confirmation_expired(cfg, confirmation):
76+
if confirmation is not None and is_confirmation_expired(cfg, confirmation):
7477
await db.delete_confirmation(confirmation)
7578
log.warning(
7679
"Used expired token [%s]. Deleted from confirmations table.",
7780
confirmation,
7881
)
79-
return False
80-
return True
82+
confirmation = None
83+
84+
if confirmation is None:
85+
confirmation = await db.create_confirmation(user["id"], action=action.value)
86+
87+
return confirmation
8188

8289

8390
def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict):

services/web/server/src/simcore_service_webserver/login/handlers_change.py

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from contextlib import suppress
32

43
from aiohttp import web
54
from aiohttp.web import RouteTableDef
@@ -19,7 +18,7 @@
1918
from ..users import api as users_service
2019
from ..utils import HOUR
2120
from ..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
2322
from ._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)
5562
async 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

154184
class ChangeEmailBody(InputSchema):

0 commit comments

Comments
 (0)