Skip to content

Commit b70f937

Browse files
authored
Resend TOTP login confirmation emails that have expired (#19075)
* Increase timeout for local migrations to succeed * Add 'expired' column to UniqueUserLogin * Resend confirmation email if the link has expired
1 parent e7dfaa8 commit b70f937

File tree

5 files changed

+89
-0
lines changed

5 files changed

+89
-0
lines changed

tests/unit/accounts/test_services.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,6 +2081,41 @@ def test_device_is_pending_not_expired(self, user_service, monkeypatch):
20812081
assert not user_service.device_is_known(user.id, user_service.request)
20822082
assert send_email.calls == []
20832083

2084+
def test_device_is_pending_and_expired(self, user_service, monkeypatch):
2085+
user = UserFactory.create(with_verified_primary_email=True)
2086+
UserUniqueLoginFactory.create(
2087+
user=user,
2088+
ip_address=REMOTE_ADDR,
2089+
status="pending",
2090+
created=datetime.datetime(1970, 1, 1),
2091+
expires=datetime.datetime(1970, 1, 1),
2092+
)
2093+
send_email = pretend.call_recorder(lambda *a, **kw: None)
2094+
monkeypatch.setattr(services, "send_unrecognized_login_email", send_email)
2095+
token_service = pretend.stub(dumps=lambda d: "fake_token", max_age=60)
2096+
user_service.request = pretend.stub(
2097+
db=user_service.db,
2098+
remote_addr=REMOTE_ADDR,
2099+
headers={
2100+
"User-Agent": (
2101+
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) "
2102+
"Gecko/20100101 Firefox/15.0.1"
2103+
)
2104+
},
2105+
find_service=lambda *a, **kw: token_service,
2106+
)
2107+
2108+
assert not user_service.device_is_known(user.id, user_service.request)
2109+
assert send_email.calls == [
2110+
pretend.call(
2111+
user_service.request,
2112+
user,
2113+
ip_address=REMOTE_ADDR,
2114+
user_agent="Firefox (Ubuntu)",
2115+
token="fake_token",
2116+
)
2117+
]
2118+
20842119
@pytest.mark.parametrize("ua_string", [None, "no bueno", "Python-urllib/3.7"])
20852120
def test_device_is_not_known_bad_user_agent(
20862121
self, user_service, monkeypatch, ua_string

warehouse/accounts/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ class UserUniqueLogin(db.Model):
519519
default=UniqueLoginStatus.PENDING,
520520
server_default=UniqueLoginStatus.PENDING.value,
521521
)
522+
expires: Mapped[datetime.datetime | None] = mapped_column(TZDateTime)
522523

523524
def __repr__(self):
524525
return (

warehouse/accounts/services.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,17 @@ def device_is_known(self, userid, request):
771771
request.db.flush() # To get the ID for the token
772772
should_send_email = True
773773

774+
# Check if the login had expired
775+
if unique_login.expires and unique_login.expires < datetime.datetime.now(
776+
datetime.UTC
777+
):
778+
# The previous token has expired, update the expiry for
779+
# the login and re-send the email
780+
unique_login.expires = datetime.datetime.now(
781+
datetime.UTC
782+
) + datetime.timedelta(seconds=token_service.max_age)
783+
should_send_email = True
784+
774785
# If we don't need to send an email, short-circuit
775786
if not should_send_email:
776787
return False
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""
3+
Add expires column to UserUniqueLogin
4+
5+
Revision ID: 537b63a29cea
6+
Revises: 7cf64da2632a
7+
Create Date: 2025-11-18 14:38:31.355587
8+
"""
9+
10+
import sqlalchemy as sa
11+
12+
from alembic import op
13+
14+
from warehouse.utils.db.types import TZDateTime
15+
16+
revision = "537b63a29cea"
17+
down_revision = "7cf64da2632a"
18+
19+
20+
def upgrade():
21+
op.add_column(
22+
"user_unique_logins",
23+
sa.Column("expires", TZDateTime(), nullable=True),
24+
)
25+
26+
op.execute(
27+
"""
28+
UPDATE user_unique_logins
29+
SET expires =
30+
CASE
31+
WHEN status = 'confirmed' THEN NULL
32+
ELSE created + INTERVAL '6 hours'
33+
END
34+
"""
35+
)
36+
37+
38+
def downgrade():
39+
op.drop_column("user_unique_logins", "expires")

warehouse/migrations/versions/7cf64da2632a_add_reverse_id_index_for_journals.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ def upgrade():
2121
op.get_bind().commit()
2222

2323
with op.get_context().autocommit_block():
24+
op.execute(sa.text("SET statement_timeout = 200000"))
25+
op.execute(sa.text("SET lock_timeout = 200000"))
26+
2427
op.create_index(
2528
"journals_name_id_idx",
2629
"journals",

0 commit comments

Comments
 (0)