Skip to content

Commit 7bdfe36

Browse files
authored
'Unrecognized login' UX tweaks (#19110)
* Tweak landing page to be more clear * Suggest clicking the link from the same device * We don't care whether the user has logged in * Order unique logins by created date * Update translations
1 parent 84bd36b commit 7bdfe36

File tree

9 files changed

+54
-124
lines changed

9 files changed

+54
-124
lines changed

tests/functional/test_login.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_unrecognized_login_with_totp(webtest):
4949
assert resp.status_code == HTTPStatus.SEE_OTHER
5050
assert resp.headers["Location"].endswith("/account/confirm-login/")
5151
unrecognized_page = resp.follow()
52-
assert "Unrecognized device" in unrecognized_page
52+
assert "Please confirm this login" in unrecognized_page
5353

5454
# This is a hack because the functional test doesn't have another way to
5555
# determine the magic link that was sent in the email. Instead, find the

tests/unit/accounts/test_views.py

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5443,70 +5443,6 @@ def test_user_not_found(self, pyramid_request):
54435443
pretend.call("Invalid token: user not found", queue="error")
54445444
]
54455445

5446-
def test_user_logged_in_since_naive_datetime(self, db_request):
5447-
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
5448-
db_request.user = None
5449-
db_request.params = {"token": "foo"}
5450-
token_data = {
5451-
"action": "login-confirmation",
5452-
"user.id": str(user.id),
5453-
"user.last_login": (user.last_login - datetime.timedelta(seconds=1))
5454-
.replace(tzinfo=None)
5455-
.isoformat(),
5456-
}
5457-
token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data))
5458-
user_service = pretend.stub(get_user=pretend.call_recorder(lambda uid: user))
5459-
5460-
db_request.find_service = lambda interface, name=None, **kwargs: {
5461-
ITokenService: {"confirm_login": token_service},
5462-
IUserService: {None: user_service},
5463-
}[interface][name]
5464-
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
5465-
db_request.route_path = pretend.call_recorder(lambda r: f"/{r}")
5466-
5467-
result = views.confirm_login(db_request)
5468-
5469-
assert isinstance(result, HTTPSeeOther)
5470-
assert result.location == "/accounts.login"
5471-
assert db_request.session.flash.calls == [
5472-
pretend.call(
5473-
"Invalid token: user has logged in since this token was requested",
5474-
queue="error",
5475-
)
5476-
]
5477-
5478-
def test_user_logged_in_since(self, db_request):
5479-
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
5480-
db_request.user = None
5481-
db_request.params = {"token": "foo"}
5482-
token_data = {
5483-
"action": "login-confirmation",
5484-
"user.id": str(user.id),
5485-
"user.last_login": (
5486-
user.last_login - datetime.timedelta(seconds=1)
5487-
).isoformat(),
5488-
}
5489-
token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data))
5490-
user_service = pretend.stub(get_user=pretend.call_recorder(lambda uid: user))
5491-
5492-
db_request.find_service = lambda interface, name=None, **kwargs: {
5493-
ITokenService: {"confirm_login": token_service},
5494-
IUserService: {None: user_service},
5495-
}[interface][name]
5496-
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
5497-
db_request.route_path = pretend.call_recorder(lambda r: f"/{r}")
5498-
5499-
result = views.confirm_login(db_request)
5500-
5501-
assert isinstance(result, HTTPSeeOther)
5502-
assert result.location == "/accounts.login"
5503-
assert db_request.session.flash.calls == [
5504-
pretend.call(
5505-
"Invalid token: user has logged in since this token was requested",
5506-
queue="error",
5507-
)
5508-
]
5509-
55105446
def test_unique_login_not_found(self, db_request):
55115447
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
55125448
db_request.user = None

warehouse/accounts/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ class User(SitemapMixin, HasObservers, HasObservations, HasEvents, db.Model):
122122
)
123123

124124
unique_logins: Mapped[list[UserUniqueLogin]] = orm.relationship(
125-
back_populates="user", cascade="all, delete-orphan", lazy=True
125+
back_populates="user",
126+
cascade="all, delete-orphan",
127+
lazy=True,
128+
order_by="UserUniqueLogin.created.desc()",
126129
)
127130

128131
role_invitations: Mapped[list[RoleInvitation]] = orm.relationship(

warehouse/accounts/views.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,19 +1020,6 @@ def _error(message):
10201020
if user is None:
10211021
return _error(request._("Invalid token: user not found"))
10221022

1023-
# Check whether the user has logged in since the token was created
1024-
last_login = datetime.datetime.fromisoformat(data.get("user.last_login"))
1025-
# Before updating itsdangerous to 2.x the last_login was naive,
1026-
# now it's localized to UTC
1027-
if not last_login.tzinfo:
1028-
last_login = pytz.UTC.localize(last_login)
1029-
if user.last_login and user.last_login > last_login:
1030-
return _error(
1031-
request._(
1032-
"Invalid token: user has logged in since this token was requested"
1033-
)
1034-
)
1035-
10361023
unique_login_id = data.get("unique_login_id")
10371024
unique_login = (
10381025
request.db.query(UserUniqueLogin)

warehouse/admin/views/ip_addresses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def ip_address_detail(request: Request) -> dict[str, IpAddress]:
6161
unique_logins = (
6262
request.db.query(UserUniqueLogin)
6363
.filter(UserUniqueLogin.ip_address == str(ip_address.ip_address))
64+
.order_by(UserUniqueLogin.created.desc())
6465
.all()
6566
)
6667

warehouse/locale/messages.pot

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ msgid "Invalid token: request a new password reset link"
183183
msgstr ""
184184

185185
#: warehouse/accounts/views.py:903 warehouse/accounts/views.py:1012
186-
#: warehouse/accounts/views.py:1090 warehouse/accounts/views.py:1196
187-
#: warehouse/accounts/views.py:1365
186+
#: warehouse/accounts/views.py:1077 warehouse/accounts/views.py:1183
187+
#: warehouse/accounts/views.py:1352
188188
msgid "Invalid token: no token supplied"
189189
msgstr ""
190190

@@ -196,7 +196,7 @@ msgstr ""
196196
msgid "Invalid token: user not found"
197197
msgstr ""
198198

199-
#: warehouse/accounts/views.py:923 warehouse/accounts/views.py:1032
199+
#: warehouse/accounts/views.py:923
200200
msgid "Invalid token: user has logged in since this token was requested"
201201
msgstr ""
202202

@@ -222,146 +222,146 @@ msgstr ""
222222
msgid "Invalid token: not a login confirmation token"
223223
msgstr ""
224224

225-
#: warehouse/accounts/views.py:1044
225+
#: warehouse/accounts/views.py:1031
226226
msgid "Invalid login attempt."
227227
msgstr ""
228228

229-
#: warehouse/accounts/views.py:1049
229+
#: warehouse/accounts/views.py:1036
230230
msgid ""
231231
"Device details didn't match, please try again from the device you "
232232
"originally used to log in."
233233
msgstr ""
234234

235-
#: warehouse/accounts/views.py:1060
235+
#: warehouse/accounts/views.py:1047
236236
msgid "Your login has been confirmed and this device is now recognized."
237237
msgstr ""
238238

239-
#: warehouse/accounts/views.py:1086
239+
#: warehouse/accounts/views.py:1073
240240
msgid "Expired token: request a new email verification link"
241241
msgstr ""
242242

243-
#: warehouse/accounts/views.py:1088
243+
#: warehouse/accounts/views.py:1075
244244
msgid "Invalid token: request a new email verification link"
245245
msgstr ""
246246

247-
#: warehouse/accounts/views.py:1094
247+
#: warehouse/accounts/views.py:1081
248248
msgid "Invalid token: not an email verification token"
249249
msgstr ""
250250

251-
#: warehouse/accounts/views.py:1103
251+
#: warehouse/accounts/views.py:1090
252252
msgid "Email not found"
253253
msgstr ""
254254

255-
#: warehouse/accounts/views.py:1106
255+
#: warehouse/accounts/views.py:1093
256256
msgid "Email already verified"
257257
msgstr ""
258258

259-
#: warehouse/accounts/views.py:1126
259+
#: warehouse/accounts/views.py:1113
260260
msgid "You can now set this email as your primary address"
261261
msgstr ""
262262

263-
#: warehouse/accounts/views.py:1129
263+
#: warehouse/accounts/views.py:1116
264264
msgid "This is your primary address"
265265
msgstr ""
266266

267-
#: warehouse/accounts/views.py:1135
267+
#: warehouse/accounts/views.py:1122
268268
#, python-brace-format
269269
msgid "Email address ${email_address} verified. ${confirm_message}."
270270
msgstr ""
271271

272-
#: warehouse/accounts/views.py:1192
272+
#: warehouse/accounts/views.py:1179
273273
msgid "Expired token: request a new organization invitation"
274274
msgstr ""
275275

276-
#: warehouse/accounts/views.py:1194
276+
#: warehouse/accounts/views.py:1181
277277
msgid "Invalid token: request a new organization invitation"
278278
msgstr ""
279279

280-
#: warehouse/accounts/views.py:1200
280+
#: warehouse/accounts/views.py:1187
281281
msgid "Invalid token: not an organization invitation token"
282282
msgstr ""
283283

284-
#: warehouse/accounts/views.py:1204
284+
#: warehouse/accounts/views.py:1191
285285
msgid "Organization invitation is not valid."
286286
msgstr ""
287287

288-
#: warehouse/accounts/views.py:1213
288+
#: warehouse/accounts/views.py:1200
289289
msgid "Organization invitation no longer exists."
290290
msgstr ""
291291

292-
#: warehouse/accounts/views.py:1265
292+
#: warehouse/accounts/views.py:1252
293293
#, python-brace-format
294294
msgid "Invitation for '${organization_name}' is declined."
295295
msgstr ""
296296

297-
#: warehouse/accounts/views.py:1328
297+
#: warehouse/accounts/views.py:1315
298298
#, python-brace-format
299299
msgid "You are now ${role} of the '${organization_name}' organization."
300300
msgstr ""
301301

302-
#: warehouse/accounts/views.py:1361
302+
#: warehouse/accounts/views.py:1348
303303
msgid "Expired token: request a new project role invitation"
304304
msgstr ""
305305

306-
#: warehouse/accounts/views.py:1363
306+
#: warehouse/accounts/views.py:1350
307307
msgid "Invalid token: request a new project role invitation"
308308
msgstr ""
309309

310-
#: warehouse/accounts/views.py:1369
310+
#: warehouse/accounts/views.py:1356
311311
msgid "Invalid token: not a collaboration invitation token"
312312
msgstr ""
313313

314-
#: warehouse/accounts/views.py:1373
314+
#: warehouse/accounts/views.py:1360
315315
msgid "Role invitation is not valid."
316316
msgstr ""
317317

318-
#: warehouse/accounts/views.py:1380
318+
#: warehouse/accounts/views.py:1367
319319
msgid "Invalid token: project does not exist"
320320
msgstr ""
321321

322-
#: warehouse/accounts/views.py:1391
322+
#: warehouse/accounts/views.py:1378
323323
msgid "Role invitation no longer exists."
324324
msgstr ""
325325

326-
#: warehouse/accounts/views.py:1423
326+
#: warehouse/accounts/views.py:1410
327327
#, python-brace-format
328328
msgid "Invitation for '${project_name}' is declined."
329329
msgstr ""
330330

331-
#: warehouse/accounts/views.py:1489
331+
#: warehouse/accounts/views.py:1476
332332
#, python-brace-format
333333
msgid "You are now ${role} of the '${project_name}' project."
334334
msgstr ""
335335

336-
#: warehouse/accounts/views.py:1601
336+
#: warehouse/accounts/views.py:1588
337337
#, python-brace-format
338338
msgid "Please review our updated <a href=\"${tos_url}\">Terms of Service</a>."
339339
msgstr ""
340340

341-
#: warehouse/accounts/views.py:1813 warehouse/accounts/views.py:2067
341+
#: warehouse/accounts/views.py:1800 warehouse/accounts/views.py:2054
342342
#: warehouse/manage/views/oidc_publishers.py:126
343343
#: warehouse/manage/views/organizations.py:1805
344344
msgid ""
345345
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
346346
"#admin-intervention for details."
347347
msgstr ""
348348

349-
#: warehouse/accounts/views.py:1834
349+
#: warehouse/accounts/views.py:1821
350350
#: warehouse/manage/views/organizations.py:1828
351351
msgid "disabled. See https://pypi.org/help#admin-intervention for details."
352352
msgstr ""
353353

354-
#: warehouse/accounts/views.py:1850
354+
#: warehouse/accounts/views.py:1837
355355
msgid ""
356356
"You must have a verified email in order to register a pending trusted "
357357
"publisher. See https://pypi.org/help#openid-connect for details."
358358
msgstr ""
359359

360-
#: warehouse/accounts/views.py:1863
360+
#: warehouse/accounts/views.py:1850
361361
msgid "You can't register more than 3 pending trusted publishers at once."
362362
msgstr ""
363363

364-
#: warehouse/accounts/views.py:1878
364+
#: warehouse/accounts/views.py:1865
365365
#: warehouse/manage/views/oidc_publishers.py:308
366366
#: warehouse/manage/views/oidc_publishers.py:423
367367
#: warehouse/manage/views/oidc_publishers.py:539
@@ -371,7 +371,7 @@ msgid ""
371371
"again later."
372372
msgstr ""
373373

374-
#: warehouse/accounts/views.py:1888
374+
#: warehouse/accounts/views.py:1875
375375
#: warehouse/manage/views/oidc_publishers.py:321
376376
#: warehouse/manage/views/oidc_publishers.py:436
377377
#: warehouse/manage/views/oidc_publishers.py:552
@@ -380,23 +380,23 @@ msgstr ""
380380
msgid "The trusted publisher could not be registered"
381381
msgstr ""
382382

383-
#: warehouse/accounts/views.py:1903
383+
#: warehouse/accounts/views.py:1890
384384
msgid ""
385385
"This trusted publisher has already been registered. Please contact PyPI's"
386386
" admins if this wasn't intentional."
387387
msgstr ""
388388

389-
#: warehouse/accounts/views.py:1937
389+
#: warehouse/accounts/views.py:1924
390390
#: warehouse/manage/views/organizations.py:1893
391391
msgid "Registered a new pending publisher to create "
392392
msgstr ""
393393

394-
#: warehouse/accounts/views.py:2080 warehouse/accounts/views.py:2093
395-
#: warehouse/accounts/views.py:2100
394+
#: warehouse/accounts/views.py:2067 warehouse/accounts/views.py:2080
395+
#: warehouse/accounts/views.py:2087
396396
msgid "Invalid publisher ID"
397397
msgstr ""
398398

399-
#: warehouse/accounts/views.py:2107
399+
#: warehouse/accounts/views.py:2094
400400
msgid "Removed trusted publisher for project "
401401
msgstr ""
402402

warehouse/templates/accounts/unrecognized-device.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
{% block content %}
55
<div class="horizontal-section">
66
<div class="site-container">
7-
<h1 class="page-title">Unrecognized device</h1>
8-
<p>We did not recognize this device. Please check your email for a login confirmation link.</p>
7+
<h1 class="page-title">Please confirm this login</h1>
8+
<p>
9+
We noticed you are attempting to log in from a new or previously unrecognized device. To ensure this is you, please check your email for a login confirmation link.
10+
</p>
911
<p>
1012
You should have received an email from <strong>noreply@pypi.org</strong> with the subject line "<strong>Unrecognized login to your PyPI account</strong>".
1113
</p>

warehouse/templates/email/unrecognized-login/body.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{% extends "email/_base/body.html" %}
33
{% block content %}
44
<p>A login attempt was made from an unrecognized device.</p>
5-
<p>To complete your login, please visit the following link:</p>
5+
<p>To complete your login, please visit the following link from the same device from which you attempted to log in:</p>
66
{% set link = request.route_url('accounts.confirm-login', _query={'token': token}) %}
77
<p>
88
<a href="{{ link }}">{{ link }}</a>

warehouse/templates/email/unrecognized-login/body.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{# SPDX-License-Identifier: Apache-2.0 -#}
22
A login attempt was made from an unrecognized device.
33

4-
To complete your login, please visit the following link:
4+
To complete your login, please visit the following link from the same device
5+
from which you attempted to log in:
56

67
{{ request.route_url('accounts.confirm-login', _query={'token': token}) }}
78

0 commit comments

Comments
 (0)