From b4c7cba95cdf49d32893f26c3ad35b8e03d815a2 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 18 Nov 2025 19:55:22 +0000 Subject: [PATCH 1/3] Increase timeout for local migrations to succeed --- .../versions/7cf64da2632a_add_reverse_id_index_for_journals.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/warehouse/migrations/versions/7cf64da2632a_add_reverse_id_index_for_journals.py b/warehouse/migrations/versions/7cf64da2632a_add_reverse_id_index_for_journals.py index 21d5ef962de7..a151cb2ac045 100644 --- a/warehouse/migrations/versions/7cf64da2632a_add_reverse_id_index_for_journals.py +++ b/warehouse/migrations/versions/7cf64da2632a_add_reverse_id_index_for_journals.py @@ -21,6 +21,9 @@ def upgrade(): op.get_bind().commit() with op.get_context().autocommit_block(): + op.execute(sa.text("SET statement_timeout = 200000")) + op.execute(sa.text("SET lock_timeout = 200000")) + op.create_index( "journals_name_id_idx", "journals", From d0c60098adc6afc365430e4a9aea9030102135f7 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 18 Nov 2025 20:09:02 +0000 Subject: [PATCH 2/3] Add 'expired' column to UniqueUserLogin --- warehouse/accounts/models.py | 1 + ...a_add_expires_column_to_useruniquelogin.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 warehouse/migrations/versions/537b63a29cea_add_expires_column_to_useruniquelogin.py diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 0834d8c8ba1d..c97bdc43a9ff 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -519,6 +519,7 @@ class UserUniqueLogin(db.Model): default=UniqueLoginStatus.PENDING, server_default=UniqueLoginStatus.PENDING.value, ) + expires: Mapped[datetime.datetime | None] = mapped_column(TZDateTime) def __repr__(self): return ( diff --git a/warehouse/migrations/versions/537b63a29cea_add_expires_column_to_useruniquelogin.py b/warehouse/migrations/versions/537b63a29cea_add_expires_column_to_useruniquelogin.py new file mode 100644 index 000000000000..9d1a420daa48 --- /dev/null +++ b/warehouse/migrations/versions/537b63a29cea_add_expires_column_to_useruniquelogin.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Add expires column to UserUniqueLogin + +Revision ID: 537b63a29cea +Revises: 7cf64da2632a +Create Date: 2025-11-18 14:38:31.355587 +""" + +import sqlalchemy as sa + +from alembic import op + +from warehouse.utils.db.types import TZDateTime + +revision = "537b63a29cea" +down_revision = "7cf64da2632a" + + +def upgrade(): + op.add_column( + "user_unique_logins", + sa.Column("expires", TZDateTime(), nullable=True), + ) + + op.execute( + """ + UPDATE user_unique_logins + SET expires = + CASE + WHEN status = 'confirmed' THEN NULL + ELSE created + INTERVAL '6 hours' + END + """ + ) + + +def downgrade(): + op.drop_column("user_unique_logins", "expires") From 306714752e74c03d6afa6b23198f6feb61e045b4 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 18 Nov 2025 20:09:20 +0000 Subject: [PATCH 3/3] Resend confirmation email if the link has expired --- tests/unit/accounts/test_services.py | 35 ++++++++++++++++++++++++++++ warehouse/accounts/services.py | 11 +++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/unit/accounts/test_services.py b/tests/unit/accounts/test_services.py index b8bb334be4c3..58ae56bbbb95 100644 --- a/tests/unit/accounts/test_services.py +++ b/tests/unit/accounts/test_services.py @@ -2081,6 +2081,41 @@ def test_device_is_pending_not_expired(self, user_service, monkeypatch): assert not user_service.device_is_known(user.id, user_service.request) assert send_email.calls == [] + def test_device_is_pending_and_expired(self, user_service, monkeypatch): + user = UserFactory.create(with_verified_primary_email=True) + UserUniqueLoginFactory.create( + user=user, + ip_address=REMOTE_ADDR, + status="pending", + created=datetime.datetime(1970, 1, 1), + expires=datetime.datetime(1970, 1, 1), + ) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(services, "send_unrecognized_login_email", send_email) + token_service = pretend.stub(dumps=lambda d: "fake_token", max_age=60) + user_service.request = pretend.stub( + db=user_service.db, + remote_addr=REMOTE_ADDR, + headers={ + "User-Agent": ( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) " + "Gecko/20100101 Firefox/15.0.1" + ) + }, + find_service=lambda *a, **kw: token_service, + ) + + assert not user_service.device_is_known(user.id, user_service.request) + assert send_email.calls == [ + pretend.call( + user_service.request, + user, + ip_address=REMOTE_ADDR, + user_agent="Firefox (Ubuntu)", + token="fake_token", + ) + ] + @pytest.mark.parametrize("ua_string", [None, "no bueno", "Python-urllib/3.7"]) def test_device_is_not_known_bad_user_agent( self, user_service, monkeypatch, ua_string diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py index d6959af4bac3..a10907b987f6 100644 --- a/warehouse/accounts/services.py +++ b/warehouse/accounts/services.py @@ -771,6 +771,17 @@ def device_is_known(self, userid, request): request.db.flush() # To get the ID for the token should_send_email = True + # Check if the login had expired + if unique_login.expires and unique_login.expires < datetime.datetime.now( + datetime.UTC + ): + # The previous token has expired, update the expiry for + # the login and re-send the email + unique_login.expires = datetime.datetime.now( + datetime.UTC + ) + datetime.timedelta(seconds=token_service.max_age) + should_send_email = True + # If we don't need to send an email, short-circuit if not should_send_email: return False