Skip to content

Commit 9d5227c

Browse files
committed
✨ Enhance confirmation service integration: add methods for managing confirmation tokens in user workflows
1 parent e0d9c27 commit 9d5227c

File tree

4 files changed

+128
-51
lines changed

4 files changed

+128
-51
lines changed

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

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

99
import logging
1010
from datetime import UTC, datetime
11+
from typing import Any
1112

1213
from models_library.users import UserID
1314

@@ -85,3 +86,42 @@ async def validate_confirmation_code(self, code: str) -> Confirmation | None:
8586
)
8687
return None
8788
return confirmation
89+
90+
async def delete_confirmation(self, confirmation: Confirmation) -> None:
91+
"""Delete a confirmation token."""
92+
await self._repository.delete_confirmation(confirmation=confirmation)
93+
94+
async def delete_confirmation_and_update_user(
95+
self,
96+
confirmation: Confirmation,
97+
user_id: UserID,
98+
updates: dict[str, Any],
99+
) -> None:
100+
"""Atomically delete confirmation and update user."""
101+
await self._repository.delete_confirmation_and_update_user(
102+
confirmation=confirmation,
103+
user_id=user_id,
104+
updates=updates,
105+
)
106+
107+
async def get_confirmation(
108+
self, filter_dict: dict[str, Any]
109+
) -> Confirmation | None:
110+
"""Get a confirmation by filter criteria."""
111+
return await self._repository.get_confirmation(filter_dict=filter_dict)
112+
113+
async def create_confirmation(
114+
self, user_id: UserID, action: ActionLiteralStr, data: str | None = None
115+
) -> Confirmation:
116+
"""Create a new confirmation token for a user action."""
117+
return await self._repository.create_confirmation(
118+
user_id=user_id, action=action, data=data
119+
)
120+
121+
async def delete_confirmation_and_user(
122+
self, user_id: UserID, confirmation: Confirmation
123+
) -> None:
124+
"""Atomically delete confirmation and user."""
125+
await self._repository.delete_confirmation_and_user(
126+
user_id=user_id, confirmation=confirmation
127+
)

services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
from ....utils import HOUR
1616
from ....utils_rate_limiting import global_rate_limit_route
1717
from ....web_utils import flash_response
18-
from ... import _auth_service, _confirmation_service, _confirmation_web
18+
from ... import _auth_service, _confirmation_web
19+
from ..._confirmation_repository import ConfirmationRepository
20+
from ..._confirmation_service import ConfirmationService
1921
from ..._emails_service import get_template_path, send_email_from_template
20-
from ..._login_repository_legacy import AsyncpgStorage, get_plugin_storage
2122
from ..._login_service import (
2223
ACTIVE,
2324
CHANGE_EMAIL,
@@ -33,12 +34,20 @@
3334
)
3435
from ...decorators import login_required
3536
from ...errors import WrongPasswordError
36-
from ...settings import LoginOptions, get_plugin_options
37+
from ...settings import get_plugin_options
3738
from .change_schemas import ChangeEmailBody, ChangePasswordBody, ResetPasswordBody
3839

3940
_logger = logging.getLogger(__name__)
4041

4142

43+
def _get_confirmation_service(app: web.Application) -> ConfirmationService:
44+
"""Get confirmation service instance from app."""
45+
engine = app["postgres_db_engine"]
46+
repository = ConfirmationRepository(engine)
47+
options = get_plugin_options(app)
48+
return ConfirmationService(repository, options)
49+
50+
4251
routes = RouteTableDef()
4352

4453

@@ -82,8 +91,6 @@ async def initiate_reset_password(request: web.Request):
8291
- 4. Who requested the reset?
8392
"""
8493

85-
db: AsyncpgStorage = get_plugin_storage(request.app)
86-
cfg: LoginOptions = get_plugin_options(request.app)
8794
product: Product = products_web.get_current_product(request)
8895

8996
request_body = await parse_request_body_as(ResetPasswordBody, request)
@@ -177,16 +184,15 @@ def _get_error_context(
177184
try:
178185
# Confirmation token that includes code to `complete_reset_password`.
179186
# Recreated if non-existent or expired (Guideline #2)
187+
confirmation_service = _get_confirmation_service(request.app)
180188
confirmation = (
181-
await _confirmation_service.get_or_create_confirmation_without_data(
182-
cfg, db, user_id=user["id"], action="RESET_PASSWORD"
189+
await confirmation_service.get_or_create_confirmation_without_data(
190+
user_id=user["id"], action="RESET_PASSWORD"
183191
)
184192
)
185193

186194
# Produce a link so that the front-end can hit `complete_reset_password`
187-
link = _confirmation_web.make_confirmation_link(
188-
request, confirmation["code"]
189-
)
195+
link = _confirmation_web.make_confirmation_link(request, confirmation.code)
190196

191197
# primary reset email with a URL and the normal instructions.
192198
await send_email_from_template(
@@ -220,8 +226,8 @@ def _get_error_context(
220226

221227
async def initiate_change_email(request: web.Request):
222228
# NOTE: This code have been intentially disabled in https://github.com/ITISFoundation/osparc-simcore/pull/5472
223-
db: AsyncpgStorage = get_plugin_storage(request.app)
224229
product: Product = products_web.get_current_product(request)
230+
confirmation_service = _get_confirmation_service(request.app)
225231

226232
request_body = await parse_request_body_as(ChangeEmailBody, request)
227233

@@ -238,15 +244,17 @@ async def initiate_change_email(request: web.Request):
238244
raise web.HTTPUnprocessableEntity(text="This email cannot be used")
239245

240246
# Reset if previously requested
241-
confirmation = await db.get_confirmation({"user": user, "action": CHANGE_EMAIL})
242-
if confirmation:
243-
await db.delete_confirmation(confirmation)
247+
existing_confirmation = await confirmation_service.get_confirmation(
248+
filter_dict={"user_id": user["id"], "action": CHANGE_EMAIL}
249+
)
250+
if existing_confirmation:
251+
await confirmation_service.delete_confirmation(existing_confirmation)
244252

245253
# create new confirmation to ensure email is actually valid
246-
confirmation = await db.create_confirmation(
254+
confirmation = await confirmation_service.create_confirmation(
247255
user_id=user["id"], action="CHANGE_EMAIL", data=request_body.email
248256
)
249-
link = _confirmation_web.make_confirmation_link(request, confirmation["code"])
257+
link = _confirmation_web.make_confirmation_link(request, confirmation.code)
250258
try:
251259
await send_email_from_template(
252260
request,
@@ -261,7 +269,7 @@ async def initiate_change_email(request: web.Request):
261269
)
262270
except Exception as err: # pylint: disable=broad-except
263271
_logger.exception("Can not send change_email_email")
264-
await db.delete_confirmation(confirmation)
272+
await confirmation_service.delete_confirmation(confirmation)
265273
raise web.HTTPServiceUnavailable(text=MSG_CANT_SEND_MAIL) from err
266274

267275
return flash_response(MSG_CHANGE_EMAIL_REQUESTED)

services/web/server/src/simcore_service_webserver/login/_controller/rest/confirmation.py

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,14 @@
2626
from ....web_utils import flash_response
2727
from ... import (
2828
_auth_service,
29-
_confirmation_service,
3029
_registration_service,
3130
_security_service,
3231
_twofa_service,
3332
)
33+
from ..._confirmation_repository import ConfirmationRepository
34+
from ..._confirmation_service import ConfirmationService
3435
from ..._login_repository_legacy import (
35-
AsyncpgStorage,
3636
ConfirmationTokenDict,
37-
get_plugin_storage,
3837
)
3938
from ..._login_service import (
4039
ACTIVE,
@@ -43,6 +42,7 @@
4342
RESET_PASSWORD,
4443
notify_user_confirmation,
4544
)
45+
from ..._models import Confirmation
4646
from ...constants import (
4747
MSG_PASSWORD_CHANGE_NOT_ALLOWED,
4848
MSG_PASSWORD_CHANGED,
@@ -64,47 +64,66 @@
6464
_logger = logging.getLogger(__name__)
6565

6666

67+
def _get_confirmation_service(app: web.Application) -> ConfirmationService:
68+
"""Get confirmation service instance from app."""
69+
engine = app["postgres_db_engine"]
70+
repository = ConfirmationRepository(engine)
71+
options = get_plugin_options(app)
72+
return ConfirmationService(repository, options)
73+
74+
75+
def _confirmation_to_legacy_dict(confirmation: Confirmation) -> ConfirmationTokenDict:
76+
"""Convert new Confirmation model to legacy ConfirmationTokenDict format."""
77+
return {
78+
"code": confirmation.code,
79+
"user_id": confirmation.user_id,
80+
"action": confirmation.action,
81+
"data": confirmation.data,
82+
"created_at": confirmation.created_at,
83+
}
84+
85+
6786
routes = RouteTableDef()
6887

6988

7089
async def _handle_confirm_registration(
7190
app: web.Application,
7291
product_name: ProductName,
73-
confirmation: ConfirmationTokenDict,
92+
confirmation: Confirmation,
7493
):
75-
db: AsyncpgStorage = get_plugin_storage(app)
76-
user_id = confirmation["user_id"]
94+
confirmation_service = _get_confirmation_service(app)
95+
user_id = confirmation.user_id
7796

7897
# activate user and consume confirmation token
79-
await db.delete_confirmation_and_update_user(
98+
await confirmation_service.delete_confirmation_and_update_user(
99+
confirmation=confirmation,
80100
user_id=user_id,
81101
updates={"status": ACTIVE},
82-
confirmation=confirmation,
83102
)
84103

85104
await notify_user_confirmation(
86105
app,
87106
user_id=user_id,
88107
product_name=product_name,
89-
extra_credits_in_usd=parse_extra_credits_in_usd_or_none(confirmation),
108+
extra_credits_in_usd=parse_extra_credits_in_usd_or_none(
109+
_confirmation_to_legacy_dict(confirmation)
110+
),
90111
)
91112

92113

93114
async def _handle_confirm_change_email(
94-
app: web.Application, confirmation: ConfirmationTokenDict
115+
app: web.Application, confirmation: Confirmation
95116
):
96-
db: AsyncpgStorage = get_plugin_storage(app)
97-
user_id = confirmation["user_id"]
117+
confirmation_service = _get_confirmation_service(app)
118+
user_id = confirmation.user_id
98119

99120
# update and consume confirmation token
100-
await db.delete_confirmation_and_update_user(
121+
await confirmation_service.delete_confirmation_and_update_user(
122+
confirmation=confirmation,
101123
user_id=user_id,
102124
updates={
103-
"email": TypeAdapter(LowerCaseEmailStr).validate_python(
104-
confirmation["data"]
105-
)
125+
"email": TypeAdapter(LowerCaseEmailStr).validate_python(confirmation.data)
106126
},
107-
confirmation=confirmation,
108127
)
109128

110129

@@ -125,22 +144,20 @@ async def validate_confirmation_and_redirect(request: web.Request):
125144
- show the reset-password page
126145
- use the token to submit a POST /v0/auth/confirmation/{code} and finalize reset action
127146
"""
128-
db: AsyncpgStorage = get_plugin_storage(request.app)
129147
cfg: LoginOptions = get_plugin_options(request.app)
130148
product: Product = products_web.get_current_product(request)
149+
confirmation_service = _get_confirmation_service(request.app)
131150

132151
path_params = parse_request_path_parameters_as(CodePathParam, request)
133152

134-
confirmation: ConfirmationTokenDict | None = (
135-
await _confirmation_service.validate_confirmation_code(
136-
path_params.code.get_secret_value(),
137-
db=db,
138-
cfg=cfg,
153+
confirmation: Confirmation | None = (
154+
await confirmation_service.validate_confirmation_code(
155+
path_params.code.get_secret_value()
139156
)
140157
)
141158

142159
redirect_to_login_url = URL(cfg.LOGIN_REDIRECT)
143-
if confirmation and (action := confirmation["action"]):
160+
if confirmation and (action := confirmation.action):
144161
try:
145162
if action == REGISTRATION:
146163
await _handle_confirm_registration(
@@ -249,20 +266,19 @@ async def complete_reset_password(request: web.Request):
249266
- Changes password using a token code without login
250267
- Code is provided via email by calling first initiate_reset_password
251268
"""
252-
db: AsyncpgStorage = get_plugin_storage(request.app)
253-
cfg: LoginOptions = get_plugin_options(request.app)
254269
product: Product = products_web.get_current_product(request)
270+
confirmation_service = _get_confirmation_service(request.app)
255271

256272
path_params = parse_request_path_parameters_as(CodePathParam, request)
257273
request_body = await parse_request_body_as(ResetPasswordConfirmation, request)
258274

259-
confirmation = await _confirmation_service.validate_confirmation_code(
260-
code=path_params.code.get_secret_value(), db=db, cfg=cfg
275+
confirmation = await confirmation_service.validate_confirmation_code(
276+
code=path_params.code.get_secret_value()
261277
)
262278

263279
if confirmation:
264280
user = await _auth_service.get_user_or_none(
265-
request.app, user_id=confirmation["user_id"]
281+
request.app, user_id=confirmation.user_id
266282
)
267283
assert user # nosec
268284

@@ -274,7 +290,7 @@ async def complete_reset_password(request: web.Request):
274290
verify_current_password=False, # confirmed by code
275291
)
276292

277-
await db.delete_confirmation(confirmation)
293+
await confirmation_service.delete_confirmation(confirmation)
278294

279295
return flash_response(MSG_PASSWORD_CHANGED)
280296

services/web/server/src/simcore_service_webserver/login/_controller/rest/registration.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
_security_service,
3131
_twofa_service,
3232
)
33+
from ..._confirmation_repository import ConfirmationRepository
34+
from ..._confirmation_service import ConfirmationService
3335
from ..._emails_service import get_template_path, send_email_from_template
3436
from ..._invitations_service import (
3537
ConfirmedInvitationData,
@@ -39,12 +41,12 @@
3941
)
4042
from ..._login_repository_legacy import (
4143
AsyncpgStorage,
42-
ConfirmationTokenDict,
4344
get_plugin_storage,
4445
)
4546
from ..._login_service import (
4647
notify_user_confirmation,
4748
)
49+
from ..._models import Confirmation
4850
from ...constants import (
4951
CODE_2FA_SMS_CODE_REQUIRED,
5052
MAX_2FA_CODE_RESEND,
@@ -70,6 +72,14 @@
7072
_logger = logging.getLogger(__name__)
7173

7274

75+
def _get_confirmation_service(app: web.Application) -> ConfirmationService:
76+
"""Get confirmation service instance from app."""
77+
engine = app["postgres_db_engine"]
78+
repository = ConfirmationRepository(engine)
79+
options = get_plugin_options(app)
80+
return ConfirmationService(repository, options)
81+
82+
7383
routes = RouteTableDef()
7484

7585

@@ -224,15 +234,16 @@ async def register(request: web.Request):
224234

225235
if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED:
226236
# Confirmation required: send confirmation email
227-
_confirmation: ConfirmationTokenDict = await db.create_confirmation(
237+
confirmation_service = _get_confirmation_service(request.app)
238+
_confirmation: Confirmation = await confirmation_service.create_confirmation(
228239
user_id=user["id"],
229240
action="REGISTRATION",
230241
data=invitation.model_dump_json() if invitation else None,
231242
)
232243

233244
try:
234245
email_confirmation_url = _confirmation_web.make_confirmation_link(
235-
request, _confirmation["code"]
246+
request, _confirmation.code
236247
)
237248
email_template_path = await get_template_path(
238249
request, "registration_email.jinja2"
@@ -270,7 +281,9 @@ async def register(request: web.Request):
270281
)
271282
)
272283

273-
await db.delete_confirmation_and_user(user["id"], _confirmation)
284+
await confirmation_service.delete_confirmation_and_user(
285+
user_id=user["id"], confirmation=_confirmation
286+
)
274287

275288
raise web.HTTPServiceUnavailable(text=user_error_msg) from err
276289

0 commit comments

Comments
 (0)