Skip to content

Commit 1dbb64b

Browse files
authored
♻️ webserver: Enhance Action Confirmation Token Logic (🚨🗃️) (#8150)
1 parent 3941b09 commit 1dbb64b

29 files changed

+675
-610
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""update confirmation created column
2+
3+
Revision ID: 9dddb16914a4
4+
Revises: 06eafd25d004
5+
Create Date: 2025-07-28 17:25:06.534720+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "9dddb16914a4"
15+
down_revision = "7e92447558e0"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# Step 1: Add new column as nullable first
22+
op.add_column(
23+
"confirmations",
24+
sa.Column(
25+
"created",
26+
sa.DateTime(timezone=True),
27+
nullable=True,
28+
),
29+
)
30+
31+
# Step 2: Copy data from created_at to created, assuming UTC timezone for existing data
32+
op.execute(
33+
"UPDATE confirmations SET created = created_at AT TIME ZONE 'UTC' WHERE created_at IS NOT NULL"
34+
)
35+
36+
# Step 3: Make the column non-nullable with default
37+
op.alter_column(
38+
"confirmations",
39+
"created",
40+
nullable=False,
41+
server_default=sa.text("now()"),
42+
)
43+
44+
# Step 4: Drop old column
45+
op.drop_column("confirmations", "created_at")
46+
47+
48+
def downgrade():
49+
# Step 1: Add back the old column
50+
op.add_column(
51+
"confirmations",
52+
sa.Column(
53+
"created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
54+
),
55+
)
56+
57+
# Step 2: Copy data back, converting timezone-aware to naive timestamp
58+
op.execute(
59+
"UPDATE confirmations SET created_at = created AT TIME ZONE 'UTC' WHERE created IS NOT NULL"
60+
)
61+
62+
# Step 3: Make the column non-nullable
63+
op.alter_column(
64+
"confirmations",
65+
"created_at",
66+
nullable=False,
67+
)
68+
69+
# Step 4: Drop new column
70+
op.drop_column("confirmations", "created")

packages/postgres-database/src/simcore_postgres_database/models/confirmations.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
""" User's confirmations table
1+
"""User's confirmations table
22
3-
- Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized
4-
by link to a a user in the framework
5-
- These tokens have an expiration date defined by configuration
3+
- Keeps a list of tokens to identify an action (registration, invitation, reset, etc) authorized
4+
by link to a a user in the framework
5+
- These tokens have an expiration date defined by configuration
66
77
"""
8+
89
import enum
910

1011
import sqlalchemy as sa
1112

12-
from ._common import RefActions
13+
from ._common import RefActions, column_created_datetime
1314
from .base import metadata
1415
from .users import users
1516

@@ -47,12 +48,8 @@ class ConfirmationAction(enum.Enum):
4748
sa.Text,
4849
doc="Extra data associated to the action. SEE handlers_confirmation.py::email_confirmation",
4950
),
50-
sa.Column(
51-
"created_at",
52-
sa.DateTime(),
53-
nullable=False,
54-
# NOTE: that here it would be convenient to have a server_default=now()!
55-
doc="Creation date of this code."
51+
column_created_datetime(
52+
doc="Creation date of this code. "
5653
"Can be used as reference to determine the expiration date. SEE ${ACTION}_CONFIRMATION_LIFETIME",
5754
),
5855
# constraints ----------------

packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
from aiohttp.test_utils import TestClient
77
from servicelib.aiohttp import status
8-
from simcore_service_webserver.login._invitations_service import create_invitation_token
9-
from simcore_service_webserver.login._login_repository_legacy import (
10-
get_plugin_storage,
8+
from simcore_service_webserver.login._controller.rest._rest_dependencies import (
9+
get_confirmation_service,
1110
)
11+
from simcore_service_webserver.login._invitations_service import create_invitation_token
1212
from simcore_service_webserver.login.constants import MSG_LOGGED_IN
1313
from simcore_service_webserver.security import security_service
1414
from yarl import URL
@@ -132,15 +132,15 @@ def __init__(
132132
self.confirmation = None
133133
self.trial_days = trial_days
134134
self.extra_credits_in_usd = extra_credits_in_usd
135-
self.db = get_plugin_storage(self.app)
135+
self.confirmation_service = get_confirmation_service(client.app)
136136

137137
async def __aenter__(self) -> "NewInvitation":
138138
# creates host user
139139
assert self.client.app
140140
self.user = await super().__aenter__()
141141

142142
self.confirmation = await create_invitation_token(
143-
self.db,
143+
self.client.app,
144144
user_id=self.user["id"],
145145
user_email=self.user["email"],
146146
tag=self.tag,
@@ -150,5 +150,11 @@ async def __aenter__(self) -> "NewInvitation":
150150
return self
151151

152152
async def __aexit__(self, *args):
153-
if await self.db.get_confirmation(self.confirmation):
154-
await self.db.delete_confirmation(self.confirmation)
153+
if self.confirmation:
154+
# Try to get confirmation by filter and delete if it exists
155+
confirmation = await self.confirmation_service.get_confirmation(
156+
filter_dict={"code": self.confirmation["code"]}
157+
)
158+
if confirmation:
159+
await self.confirmation_service.delete_confirmation(confirmation)
160+
return await super().__aexit__(*args)

services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from ..application_settings import get_application_settings
77
from ..application_setup import ModuleCategory, app_setup_func
8-
from ..login.plugin import setup_login_storage
98
from ..products.plugin import setup_products
109
from ..projects._projects_repository_legacy import setup_projects_db
1110
from ..redis import setup_redis
@@ -34,8 +33,6 @@ def setup_garbage_collector(app: web.Application) -> None:
3433

3534
# - project needs access to socketio via notify_project_state_update
3635
setup_socketio(app)
37-
# - project needs access to user-api that is connected to login plugin
38-
setup_login_storage(app)
3936

4037
settings = get_plugin_settings(app)
4138

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from aiohttp import web
2+
3+
from ._confirmation_service import ConfirmationService
4+
5+
CONFIRMATION_SERVICE_APPKEY = web.AppKey("CONFIRMATION_SERVICE", ConfirmationService)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import logging
2+
from typing import Any
3+
4+
import sqlalchemy as sa
5+
from models_library.users import UserID
6+
from servicelib.utils_secrets import generate_passcode
7+
from simcore_postgres_database.models.confirmations import confirmations
8+
from simcore_postgres_database.models.users import users
9+
from simcore_postgres_database.utils_repos import (
10+
pass_or_acquire_connection,
11+
transaction_context,
12+
)
13+
from sqlalchemy.engine import Row
14+
from sqlalchemy.ext.asyncio import AsyncConnection
15+
16+
from ..db.base_repository import BaseRepository
17+
from ._models import ActionLiteralStr, Confirmation
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
22+
def _to_domain(confirmation_row: Row) -> Confirmation:
23+
return Confirmation.model_validate(
24+
{
25+
"code": confirmation_row.code,
26+
"user_id": confirmation_row.user_id,
27+
"action": confirmation_row.action.value, # conversion to literal string
28+
"data": confirmation_row.data,
29+
"created_at": confirmation_row.created, # renames
30+
}
31+
)
32+
33+
34+
class ConfirmationRepository(BaseRepository):
35+
36+
async def create_confirmation(
37+
self,
38+
connection: AsyncConnection | None = None,
39+
*,
40+
user_id: UserID,
41+
action: ActionLiteralStr,
42+
data: str | None = None,
43+
) -> Confirmation:
44+
"""Create a new confirmation token for a user action."""
45+
46+
async with transaction_context(self.engine, connection) as conn:
47+
# We want the same connection checking uniqueness and inserting
48+
while True: # Generate unique code
49+
50+
# NOTE: use only numbers since front-end does not handle well url encoding
51+
numeric_code: str = generate_passcode(20)
52+
53+
# Check if code already exists
54+
check_query = sa.select(confirmations.c.code).where(
55+
confirmations.c.code == numeric_code
56+
)
57+
result = await conn.execute(check_query)
58+
if result.one_or_none() is None:
59+
break
60+
61+
# Insert confirmation
62+
insert_query = (
63+
sa.insert(confirmations)
64+
.values(
65+
code=numeric_code,
66+
user_id=user_id,
67+
action=action,
68+
data=data,
69+
)
70+
.returning(*confirmations.c)
71+
)
72+
73+
result = await conn.execute(insert_query)
74+
row = result.one()
75+
return _to_domain(row)
76+
77+
async def get_confirmation(
78+
self,
79+
connection: AsyncConnection | None = None,
80+
*,
81+
filter_dict: dict[str, Any],
82+
) -> Confirmation | None:
83+
"""Get a confirmation by filter criteria."""
84+
# Handle legacy "user" key
85+
if "user" in filter_dict:
86+
filter_dict["user_id"] = filter_dict.pop("user")["id"]
87+
88+
# Build where conditions
89+
where_conditions = []
90+
for key, value in filter_dict.items():
91+
if hasattr(confirmations.c, key):
92+
where_conditions.append(getattr(confirmations.c, key) == value)
93+
94+
query = sa.select(*confirmations.c).where(sa.and_(*where_conditions))
95+
96+
async with pass_or_acquire_connection(self.engine, connection) as conn:
97+
result = await conn.execute(query)
98+
if row := result.one_or_none():
99+
return _to_domain(row)
100+
return None
101+
102+
async def delete_confirmation(
103+
self,
104+
connection: AsyncConnection | None = None,
105+
*,
106+
confirmation: Confirmation,
107+
) -> None:
108+
"""Delete a confirmation token."""
109+
query = sa.delete(confirmations).where(
110+
confirmations.c.code == confirmation.code
111+
)
112+
113+
async with transaction_context(self.engine, connection) as conn:
114+
await conn.execute(query)
115+
116+
async def delete_confirmation_and_user(
117+
self,
118+
connection: AsyncConnection | None = None,
119+
*,
120+
user_id: UserID,
121+
confirmation: Confirmation,
122+
) -> None:
123+
"""Atomically delete confirmation and user."""
124+
async with transaction_context(self.engine, connection) as conn:
125+
# Delete confirmation
126+
await conn.execute(
127+
sa.delete(confirmations).where(
128+
confirmations.c.code == confirmation.code
129+
)
130+
)
131+
132+
# Delete user
133+
await conn.execute(sa.delete(users).where(users.c.id == user_id))
134+
135+
async def delete_confirmation_and_update_user(
136+
self,
137+
connection: AsyncConnection | None = None,
138+
*,
139+
user_id: UserID,
140+
updates: dict[str, Any],
141+
confirmation: Confirmation,
142+
) -> None:
143+
"""Atomically delete confirmation and update user."""
144+
async with transaction_context(self.engine, connection) as conn:
145+
# Delete confirmation
146+
await conn.execute(
147+
sa.delete(confirmations).where(
148+
confirmations.c.code == confirmation.code
149+
)
150+
)
151+
152+
# Update user
153+
await conn.execute(
154+
sa.update(users).where(users.c.id == user_id).values(**updates)
155+
)

0 commit comments

Comments
 (0)