Skip to content

Commit 3521a80

Browse files
committed
moving confirmation web helpers
1 parent 1478ee3 commit 3521a80

File tree

10 files changed

+110
-51
lines changed

10 files changed

+110
-51
lines changed

api/specs/web-server/_projects_groups.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
@router.post(
2828
"/projects/{project_id}:share",
2929
response_model=Envelope[ProjectGroupGet],
30-
status_code=status.HTTP_201_CREATED,
30+
status_code=status.HTTP_204_NO_CONTENT,
3131
)
3232
async def share_project(
3333
_path: Annotated[ProjectPathParams, Depends()],

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

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,8 @@
88

99
import logging
1010
from datetime import datetime
11-
from urllib.parse import quote
1211

13-
from aiohttp import web
1412
from models_library.users import UserID
15-
from yarl import URL
1613

1714
from ._login_repository_legacy import (
1815
ActionLiteralStr,
@@ -56,19 +53,6 @@ def get_expiration_date(
5653
return confirmation["created_at"] + lifetime
5754

5855

59-
def _url_for_confirmation(app: web.Application, code: str) -> URL:
60-
# NOTE: this is in a query parameter, and can contain ? for example.
61-
safe_code = quote(code, safe="")
62-
return app.router["auth_confirmation"].url_for(code=safe_code)
63-
64-
65-
def make_confirmation_link(
66-
request: web.Request, confirmation: ConfirmationTokenDict
67-
) -> str:
68-
link = _url_for_confirmation(request.app, code=confirmation["code"])
69-
return f"{request.scheme}://{request.host}{link}"
70-
71-
7256
def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict):
7357
age = datetime.utcnow() - confirmation["created_at"]
7458
lifetime = cfg.get_confirmation_lifetime(confirmation["action"])
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from urllib.parse import quote
2+
3+
from aiohttp import web
4+
from models_library.basic_types import IDStr
5+
from yarl import URL
6+
7+
8+
def _url_for_confirmation(app: web.Application, code: IDStr) -> URL:
9+
# NOTE: this is in a query parameter, and can contain ? for example.
10+
safe_code = quote(code, safe="")
11+
return app.router["auth_confirmation"].url_for(code=safe_code)
12+
13+
14+
def make_confirmation_link(request: web.Request, code: IDStr) -> str:
15+
link = _url_for_confirmation(request.app, code=code)
16+
return f"{request.scheme}://{request.host}{link}"

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ....users import api as users_service
1919
from ....utils import HOUR
2020
from ....utils_rate_limiting import global_rate_limit_route
21-
from ... import _confirmation_service
21+
from ... import _confirmation_service, _confirmation_web
2222
from ..._constants import (
2323
MSG_CANT_SEND_MAIL,
2424
MSG_CHANGE_EMAIL_REQUESTED,
@@ -187,7 +187,9 @@ def _get_error_context(
187187
)
188188

189189
# Produce a link so that the front-end can hit `complete_reset_password`
190-
link = _confirmation_service.make_confirmation_link(request, confirmation)
190+
link = _confirmation_web.make_confirmation_link(
191+
request, confirmation["code"]
192+
)
191193

192194
# primary reset email with a URL and the normal instructions.
193195
await send_email_from_template(
@@ -249,7 +251,7 @@ async def initiate_change_email(request: web.Request):
249251
confirmation = await db.create_confirmation(
250252
user_id=user["id"], action="CHANGE_EMAIL", data=request_body.email
251253
)
252-
link = _confirmation_service.make_confirmation_link(request, confirmation)
254+
link = _confirmation_web.make_confirmation_link(request, confirmation["code"])
253255
try:
254256
await send_email_from_template(
255257
request,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from ....utils import MINUTE
3333
from ....utils_aiohttp import NextPage, envelope_json_response
3434
from ....utils_rate_limiting import global_rate_limit_route
35-
from ... import _auth_service, _confirmation_service, _security_service, _twofa_service
35+
from ... import _auth_service, _confirmation_web, _security_service, _twofa_service
3636
from ..._constants import (
3737
CODE_2FA_SMS_CODE_REQUIRED,
3838
MAX_2FA_CODE_RESEND,
@@ -257,8 +257,8 @@ async def register(request: web.Request):
257257
)
258258

259259
try:
260-
email_confirmation_url = _confirmation_service.make_confirmation_link(
261-
request, _confirmation
260+
email_confirmation_url = _confirmation_web.make_confirmation_link(
261+
request, _confirmation["code"]
262262
)
263263
email_template_path = await get_template_path(
264264
request, "registration_email.jinja2"

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncpg
66
from aiohttp import web
7+
from models_library.basic_types import IDStr
78
from servicelib.utils_secrets import generate_passcode
89

910
from . import _login_repository_legacy_sql
@@ -21,7 +22,7 @@
2122

2223

2324
class BaseConfirmationTokenDict(TypedDict):
24-
code: str
25+
code: IDStr
2526
action: ActionLiteralStr
2627

2728

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ._confirmation_web import make_confirmation_link
2+
3+
__all__: tuple[str, ...] = ("make_confirmation_link",)
4+
5+
# nopycln: file

services/web/server/src/simcore_service_webserver/projects/_controller/groups_rest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from aiohttp import web
44
from models_library.api_schemas_webserver._base import InputSchema
5+
from models_library.basic_types import IDStr
56
from models_library.groups import GroupID
67
from models_library.projects import ProjectID
78
from pydantic import BaseModel, ConfigDict, EmailStr
@@ -14,6 +15,7 @@
1415

1516
from ..._meta import api_version_prefix as VTAG
1617
from ...application_settings_utils import requires_dev_feature_enabled
18+
from ...login import login_web
1719
from ...login.decorators import login_required
1820
from ...security.decorators import permission_required
1921
from ...utils_aiohttp import envelope_json_response
@@ -60,7 +62,7 @@ async def share_project(request: web.Request):
6062
body_params.sharee_email,
6163
):
6264

63-
await _groups_service.share_project_by_email(
65+
code: IDStr = await _groups_service.create_confirmation_action_to_share_project(
6466
app=request.app,
6567
user_id=req_ctx.user_id,
6668
project_id=path_params.project_id,
@@ -71,6 +73,10 @@ async def share_project(request: web.Request):
7173
product_name=req_ctx.product_name,
7274
)
7375

76+
confirmation_link: str = login_web.make_confirmation_link(request, code=code)
77+
78+
_logger.debug("Send email with confirmation link: %s", confirmation_link)
79+
7480
return web.json_response(status=status.HTTP_204_NO_CONTENT)
7581

7682

services/web/server/src/simcore_service_webserver/projects/_groups_service.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import hashlib
12
import logging
23
from datetime import datetime
34

5+
import arrow
46
from aiohttp import web
7+
from models_library.access_rights import AccessRights
8+
from models_library.basic_types import IDStr
59
from models_library.groups import GroupID
610
from models_library.products import ProductName
711
from models_library.projects import ProjectID
812
from models_library.users import UserID
9-
from pydantic import BaseModel, EmailStr
13+
from pydantic import BaseModel, EmailStr, TypeAdapter
1014

1115
from ..users import api as users_service
1216
from . import _groups_repository
@@ -170,7 +174,7 @@ async def delete_project_group(
170174
)
171175

172176

173-
async def share_project_by_email(
177+
async def create_confirmation_action_to_share_project(
174178
app: web.Application,
175179
*,
176180
product_name: ProductName,
@@ -181,8 +185,35 @@ async def share_project_by_email(
181185
read: bool,
182186
write: bool,
183187
delete: bool,
184-
):
185-
raise NotImplementedError
188+
) -> IDStr:
189+
assert app # nosec
190+
191+
_logger.debug(
192+
"Checking that %s in %s has enough access rights (ownership) to %s for sharing",
193+
f"{user_id=}",
194+
f"{product_name=}",
195+
f"{project_id=}",
196+
)
197+
198+
sharer_user_id = user_id
199+
shared_resource_type = "project"
200+
shared_resource_id = project_id
201+
shared_resource_access_rights = AccessRights(read=read, write=write, delete=delete)
202+
shared_at = arrow.utcnow().datetime
203+
204+
_logger.debug(
205+
"Creating confirmation token for action=SHARE with and producing a code:"
206+
"\n %s," * 6,
207+
sharer_user_id,
208+
shared_resource_type,
209+
shared_resource_id,
210+
shared_resource_access_rights,
211+
shared_at,
212+
sharee_email,
213+
)
214+
215+
fake_code = hashlib.sha256(sharee_email.encode()).hexdigest()
216+
return TypeAdapter(IDStr).validate_python(f"fake{fake_code}")
186217

187218

188219
### Operations without checking permissions

services/web/server/tests/unit/with_dbs/03/login/test_login_confirmation_service.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@
33
from aiohttp.test_utils import make_mocked_request
44
from aiohttp.web import Application
55
from pytest_simcore.helpers.webserver_login import UserInfoDict
6-
from simcore_service_webserver.login._confirmation_service import (
7-
get_expiration_date,
8-
get_or_create_confirmation_without_data,
9-
is_confirmation_expired,
10-
make_confirmation_link,
11-
validate_confirmation_code,
12-
)
6+
from simcore_service_webserver.login import _confirmation_service, _confirmation_web
137
from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage
148
from simcore_service_webserver.login.settings import LoginOptions
159

@@ -20,7 +14,7 @@ async def test_confirmation_token_workflow(
2014
# Step 1: Create a new confirmation token
2115
user_id = registered_user["id"]
2216
action = "RESET_PASSWORD"
23-
confirmation = await get_or_create_confirmation_without_data(
17+
confirmation = await _confirmation_service.get_or_create_confirmation_without_data(
2418
login_options, db, user_id=user_id, action=action
2519
)
2620

@@ -29,11 +23,15 @@ async def test_confirmation_token_workflow(
2923
assert confirmation["action"] == action
3024

3125
# Step 2: Check that the token is not expired
32-
assert not is_confirmation_expired(login_options, confirmation)
26+
assert not _confirmation_service.is_confirmation_expired(
27+
login_options, confirmation
28+
)
3329

3430
# Step 3: Validate the confirmation code
3531
code = confirmation["code"]
36-
validated_confirmation = await validate_confirmation_code(code, db, login_options)
32+
validated_confirmation = await _confirmation_service.validate_confirmation_code(
33+
code, db, login_options
34+
)
3735

3836
assert validated_confirmation is not None
3937
assert validated_confirmation["code"] == code
@@ -53,7 +51,9 @@ async def test_confirmation_token_workflow(
5351
)
5452

5553
# Create confirmation link
56-
confirmation_link = make_confirmation_link(request, confirmation)
54+
confirmation_link = _confirmation_web.make_confirmation_link(
55+
request, confirmation["code"]
56+
)
5757

5858
# Assertions
5959
assert confirmation_link.startswith("http://example.com/auth/confirmation/")
@@ -67,20 +67,26 @@ async def test_expired_confirmation_token(
6767
action = "CHANGE_EMAIL"
6868

6969
# Create a brand new confirmation token
70-
confirmation_1 = await get_or_create_confirmation_without_data(
71-
login_options, db, user_id=user_id, action=action
70+
confirmation_1 = (
71+
await _confirmation_service.get_or_create_confirmation_without_data(
72+
login_options, db, user_id=user_id, action=action
73+
)
7274
)
7375

7476
assert confirmation_1 is not None
7577
assert confirmation_1["user_id"] == user_id
7678
assert confirmation_1["action"] == action
7779

7880
# Check that the token is not expired
79-
assert not is_confirmation_expired(login_options, confirmation_1)
80-
assert get_expiration_date(login_options, confirmation_1)
81+
assert not _confirmation_service.is_confirmation_expired(
82+
login_options, confirmation_1
83+
)
84+
assert _confirmation_service.get_expiration_date(login_options, confirmation_1)
8185

82-
confirmation_2 = await get_or_create_confirmation_without_data(
83-
login_options, db, user_id=user_id, action=action
86+
confirmation_2 = (
87+
await _confirmation_service.get_or_create_confirmation_without_data(
88+
login_options, db, user_id=user_id, action=action
89+
)
8490
)
8591

8692
assert confirmation_2 == confirmation_1
@@ -89,23 +95,31 @@ async def test_expired_confirmation_token(
8995
login_options.CHANGE_EMAIL_CONFIRMATION_LIFETIME = 0
9096
assert login_options.get_confirmation_lifetime(action) == timedelta(seconds=0)
9197

92-
confirmation_3 = await get_or_create_confirmation_without_data(
93-
login_options, db, user_id=user_id, action=action
98+
confirmation_3 = (
99+
await _confirmation_service.get_or_create_confirmation_without_data(
100+
login_options, db, user_id=user_id, action=action
101+
)
94102
)
95103

96104
# when expired, it gets renewed
97105
assert confirmation_3 != confirmation_1
98106

99107
# now all have expired
100108
assert (
101-
await validate_confirmation_code(confirmation_1["code"], db, login_options)
109+
await _confirmation_service.validate_confirmation_code(
110+
confirmation_1["code"], db, login_options
111+
)
102112
is None
103113
)
104114
assert (
105-
await validate_confirmation_code(confirmation_2["code"], db, login_options)
115+
await _confirmation_service.validate_confirmation_code(
116+
confirmation_2["code"], db, login_options
117+
)
106118
is None
107119
)
108120
assert (
109-
await validate_confirmation_code(confirmation_3["code"], db, login_options)
121+
await _confirmation_service.validate_confirmation_code(
122+
confirmation_3["code"], db, login_options
123+
)
110124
is None
111125
)

0 commit comments

Comments
 (0)