Skip to content

Commit e0d9c27

Browse files
committed
✨ Refactor confirmation service and repository integration for improved token management
1 parent 95417c0 commit e0d9c27

File tree

8 files changed

+197
-140
lines changed

8 files changed

+197
-140
lines changed

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

Lines changed: 67 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,74 +7,81 @@
77
"""
88

99
import logging
10-
from datetime import datetime
10+
from datetime import UTC, datetime
1111

1212
from models_library.users import UserID
1313

14-
from ._login_repository_legacy import (
15-
ActionLiteralStr,
16-
AsyncpgStorage,
17-
ConfirmationTokenDict,
18-
)
14+
from ._confirmation_repository import ConfirmationRepository
15+
from ._models import ActionLiteralStr, Confirmation
1916
from .settings import LoginOptions
2017

2118
_logger = logging.getLogger(__name__)
2219

2320

24-
async def get_or_create_confirmation_without_data(
25-
cfg: LoginOptions,
26-
db: AsyncpgStorage,
27-
user_id: UserID,
28-
action: ActionLiteralStr,
29-
) -> ConfirmationTokenDict:
30-
31-
confirmation: ConfirmationTokenDict | None = await db.get_confirmation(
32-
{"user": {"id": user_id}, "action": action}
33-
)
34-
35-
if confirmation is not None and is_confirmation_expired(cfg, confirmation):
36-
await db.delete_confirmation(confirmation)
37-
_logger.warning(
38-
"Used expired token [%s]. Deleted from confirmations table.",
39-
confirmation,
21+
class ConfirmationService:
22+
"""Service for managing confirmation tokens and codes."""
23+
24+
def __init__(
25+
self,
26+
confirmation_repository: ConfirmationRepository,
27+
login_options: LoginOptions,
28+
) -> None:
29+
self._repository = confirmation_repository
30+
self._options = login_options
31+
32+
@property
33+
def options(self) -> LoginOptions:
34+
"""Access to login options for testing purposes."""
35+
return self._options
36+
37+
async def get_or_create_confirmation_without_data(
38+
self,
39+
user_id: UserID,
40+
action: ActionLiteralStr,
41+
) -> Confirmation:
42+
"""Get existing or create new confirmation token for user action."""
43+
confirmation = await self._repository.get_confirmation(
44+
filter_dict={"user_id": user_id, "action": action}
4045
)
41-
confirmation = None
42-
43-
if confirmation is None:
44-
confirmation = await db.create_confirmation(user_id, action=action)
45-
46-
return confirmation
47-
48-
49-
def get_expiration_date(
50-
cfg: LoginOptions, confirmation: ConfirmationTokenDict
51-
) -> datetime:
52-
lifetime = cfg.get_confirmation_lifetime(confirmation["action"])
53-
return confirmation["created_at"] + lifetime
54-
55-
56-
def is_confirmation_expired(cfg: LoginOptions, confirmation: ConfirmationTokenDict):
57-
age = datetime.utcnow() - confirmation["created_at"]
58-
lifetime = cfg.get_confirmation_lifetime(confirmation["action"])
59-
return age > lifetime
60-
61-
62-
async def validate_confirmation_code(
63-
code: str, db: AsyncpgStorage, cfg: LoginOptions
64-
) -> ConfirmationTokenDict | None:
65-
"""
66-
Returns None if validation fails
67-
"""
68-
assert not code.startswith("***"), "forgot .get_secret_value()??" # nosec
6946

70-
confirmation: ConfirmationTokenDict | None = await db.get_confirmation(
71-
{"code": code}
72-
)
73-
if confirmation and is_confirmation_expired(cfg, confirmation):
74-
await db.delete_confirmation(confirmation)
75-
_logger.warning(
76-
"Used expired token [%s]. Deleted from confirmations table.",
77-
confirmation,
47+
if confirmation is not None and self.is_confirmation_expired(confirmation):
48+
await self._repository.delete_confirmation(confirmation=confirmation)
49+
_logger.warning(
50+
"Used expired token [%s]. Deleted from confirmations table.",
51+
confirmation,
52+
)
53+
confirmation = None
54+
55+
if confirmation is None:
56+
confirmation = await self._repository.create_confirmation(
57+
user_id=user_id, action=action
58+
)
59+
60+
return confirmation
61+
62+
def get_expiration_date(self, confirmation: Confirmation) -> datetime:
63+
"""Get expiration date for confirmation token."""
64+
lifetime = self._options.get_confirmation_lifetime(confirmation.action)
65+
return confirmation.created_at + lifetime
66+
67+
def is_confirmation_expired(self, confirmation: Confirmation) -> bool:
68+
"""Check if confirmation token has expired."""
69+
age = datetime.now(tz=UTC) - confirmation.created_at
70+
lifetime = self._options.get_confirmation_lifetime(confirmation.action)
71+
return age > lifetime
72+
73+
async def validate_confirmation_code(self, code: str) -> Confirmation | None:
74+
"""Validate confirmation code and return confirmation if valid."""
75+
assert not code.startswith("***"), "forgot .get_secret_value()??" # nosec
76+
77+
confirmation = await self._repository.get_confirmation(
78+
filter_dict={"code": code}
7879
)
79-
return None
80-
return confirmation
80+
if confirmation and self.is_confirmation_expired(confirmation):
81+
await self._repository.delete_confirmation(confirmation=confirmation)
82+
_logger.warning(
83+
"Used expired token [%s]. Deleted from confirmations table.",
84+
confirmation,
85+
)
86+
return None
87+
return confirmation

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@
1313

1414

1515
class Confirmation(BaseModel):
16-
model_config = ConfigDict(from_attributes=True)
17-
1816
code: str
1917
user_id: UserID
2018
action: ActionLiteralStr
21-
data: str | None
19+
data: str | None = None
2220
created_at: datetime
2321

2422

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict
1818
from simcore_postgres_database.models.users import users
1919
from simcore_postgres_database.models.wallets import wallets
20+
from simcore_service_webserver.login._confirmation_repository import (
21+
ConfirmationRepository,
22+
)
23+
from simcore_service_webserver.login._confirmation_service import ConfirmationService
2024
from simcore_service_webserver.login._login_repository_legacy import (
2125
AsyncpgStorage,
2226
get_plugin_storage,
@@ -84,14 +88,35 @@ def fake_weak_password(faker: Faker) -> str:
8488

8589

8690
@pytest.fixture
87-
def db(client: TestClient) -> AsyncpgStorage:
91+
def db_storage_deprecated(client: TestClient) -> AsyncpgStorage:
8892
"""login database repository instance"""
8993
assert client.app
9094
db: AsyncpgStorage = get_plugin_storage(client.app)
9195
assert db
9296
return db
9397

9498

99+
@pytest.fixture
100+
def confirmation_repository(client: TestClient) -> ConfirmationRepository:
101+
"""Modern confirmation repository instance"""
102+
assert client.app
103+
# Get the async engine from the application
104+
engine = client.app["postgres_db_engine"]
105+
return ConfirmationRepository(engine)
106+
107+
108+
@pytest.fixture
109+
def confirmation_service(
110+
confirmation_repository: ConfirmationRepository, login_options: LoginOptions
111+
) -> ConfirmationService:
112+
"""Confirmation service instance"""
113+
from simcore_service_webserver.login._confirmation_service import (
114+
ConfirmationService,
115+
)
116+
117+
return ConfirmationService(confirmation_repository, login_options)
118+
119+
95120
@pytest.fixture
96121
def login_options(client: TestClient) -> LoginOptions:
97122
"""app's login options"""
Lines changed: 41 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,47 @@
11
from datetime import timedelta
22

33
from aiohttp.test_utils import make_mocked_request
4-
from aiohttp.web import Application
4+
from aiohttp.web import Application, Response
55
from pytest_simcore.helpers.webserver_users import UserInfoDict
6-
from simcore_service_webserver.login import _confirmation_service, _confirmation_web
7-
from simcore_service_webserver.login._login_repository_legacy import AsyncpgStorage
8-
from simcore_service_webserver.login.settings import LoginOptions
6+
from simcore_service_webserver.login import _confirmation_web
7+
from simcore_service_webserver.login._confirmation_service import ConfirmationService
98

109

1110
async def test_confirmation_token_workflow(
12-
db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict
11+
confirmation_service: ConfirmationService,
12+
registered_user: UserInfoDict,
1313
):
1414
# Step 1: Create a new confirmation token
1515
user_id = registered_user["id"]
1616
action = "RESET_PASSWORD"
17-
confirmation = await _confirmation_service.get_or_create_confirmation_without_data(
18-
login_options, db, user_id=user_id, action=action
17+
confirmation = await confirmation_service.get_or_create_confirmation_without_data(
18+
user_id=user_id, action=action
1919
)
2020

2121
assert confirmation is not None
22-
assert confirmation["user_id"] == user_id
23-
assert confirmation["action"] == action
22+
assert confirmation.user_id == user_id
23+
assert confirmation.action == action
2424

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

3028
# Step 3: Validate the confirmation code
31-
code = confirmation["code"]
32-
validated_confirmation = await _confirmation_service.validate_confirmation_code(
33-
code, db, login_options
34-
)
29+
code = confirmation.code
30+
validated_confirmation = await confirmation_service.validate_confirmation_code(code)
3531

3632
assert validated_confirmation is not None
37-
assert validated_confirmation["code"] == code
38-
assert validated_confirmation["user_id"] == user_id
39-
assert validated_confirmation["action"] == action
33+
assert validated_confirmation.code == code
34+
assert validated_confirmation.user_id == user_id
35+
assert validated_confirmation.action == action
4036

4137
# Step 4: Create confirmation link
4238
app = Application()
39+
40+
async def mock_handler(request):
41+
return Response()
42+
4343
app.router.add_get(
44-
"/auth/confirmation/{code}", lambda request: None, name="auth_confirmation"
44+
"/auth/confirmation/{code}", mock_handler, name="auth_confirmation"
4545
)
4646
request = make_mocked_request(
4747
"GET",
@@ -52,74 +52,63 @@ async def test_confirmation_token_workflow(
5252

5353
# Create confirmation link
5454
confirmation_link = _confirmation_web.make_confirmation_link(
55-
request, confirmation["code"]
55+
request, confirmation.code
5656
)
5757

5858
# Assertions
5959
assert confirmation_link.startswith("https://example.com/auth/confirmation/")
60-
assert confirmation["code"] in confirmation_link
60+
assert confirmation.code in confirmation_link
6161

6262

6363
async def test_expired_confirmation_token(
64-
db: AsyncpgStorage, login_options: LoginOptions, registered_user: UserInfoDict
64+
confirmation_service: ConfirmationService,
65+
registered_user: UserInfoDict,
6566
):
6667
user_id = registered_user["id"]
6768
action = "CHANGE_EMAIL"
6869

6970
# Create a brand new confirmation token
70-
confirmation_1 = (
71-
await _confirmation_service.get_or_create_confirmation_without_data(
72-
login_options, db, user_id=user_id, action=action
73-
)
71+
confirmation_1 = await confirmation_service.get_or_create_confirmation_without_data(
72+
user_id=user_id, action=action
7473
)
7574

7675
assert confirmation_1 is not None
77-
assert confirmation_1["user_id"] == user_id
78-
assert confirmation_1["action"] == action
76+
assert confirmation_1.user_id == user_id
77+
assert confirmation_1.action == action
7978

8079
# Check that the token is not expired
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)
80+
assert not confirmation_service.is_confirmation_expired(confirmation_1)
81+
assert confirmation_service.get_expiration_date(confirmation_1)
8582

86-
confirmation_2 = (
87-
await _confirmation_service.get_or_create_confirmation_without_data(
88-
login_options, db, user_id=user_id, action=action
89-
)
83+
confirmation_2 = await confirmation_service.get_or_create_confirmation_without_data(
84+
user_id=user_id, action=action
9085
)
9186

9287
assert confirmation_2 == confirmation_1
9388

9489
# Enforce ALL EXPIRED
95-
login_options.CHANGE_EMAIL_CONFIRMATION_LIFETIME = 0
96-
assert login_options.get_confirmation_lifetime(action) == timedelta(seconds=0)
90+
confirmation_service.options.CHANGE_EMAIL_CONFIRMATION_LIFETIME = 0
91+
assert confirmation_service.options.get_confirmation_lifetime(action) == timedelta(
92+
seconds=0
93+
)
9794

98-
confirmation_3 = (
99-
await _confirmation_service.get_or_create_confirmation_without_data(
100-
login_options, db, user_id=user_id, action=action
101-
)
95+
confirmation_3 = await confirmation_service.get_or_create_confirmation_without_data(
96+
user_id=user_id, action=action
10297
)
10398

10499
# when expired, it gets renewed
105100
assert confirmation_3 != confirmation_1
106101

107102
# now all have expired
108103
assert (
109-
await _confirmation_service.validate_confirmation_code(
110-
confirmation_1["code"], db, login_options
111-
)
104+
await confirmation_service.validate_confirmation_code(confirmation_1.code)
112105
is None
113106
)
114107
assert (
115-
await _confirmation_service.validate_confirmation_code(
116-
confirmation_2["code"], db, login_options
117-
)
108+
await confirmation_service.validate_confirmation_code(confirmation_2.code)
118109
is None
119110
)
120111
assert (
121-
await _confirmation_service.validate_confirmation_code(
122-
confirmation_3["code"], db, login_options
123-
)
112+
await confirmation_service.validate_confirmation_code(confirmation_3.code)
124113
is None
125114
)

0 commit comments

Comments
 (0)