Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/functional/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_unrecognized_login_with_totp(webtest):
assert resp.status_code == HTTPStatus.SEE_OTHER
assert resp.headers["Location"].endswith("/account/confirm-login/")
unrecognized_page = resp.follow()
assert "Unrecognized device" in unrecognized_page
assert "Please confirm this login" in unrecognized_page

# This is a hack because the functional test doesn't have another way to
# determine the magic link that was sent in the email. Instead, find the
Expand Down
64 changes: 0 additions & 64 deletions tests/unit/accounts/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5443,70 +5443,6 @@ def test_user_not_found(self, pyramid_request):
pretend.call("Invalid token: user not found", queue="error")
]

def test_user_logged_in_since_naive_datetime(self, db_request):
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
db_request.user = None
db_request.params = {"token": "foo"}
token_data = {
"action": "login-confirmation",
"user.id": str(user.id),
"user.last_login": (user.last_login - datetime.timedelta(seconds=1))
.replace(tzinfo=None)
.isoformat(),
}
token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data))
user_service = pretend.stub(get_user=pretend.call_recorder(lambda uid: user))

db_request.find_service = lambda interface, name=None, **kwargs: {
ITokenService: {"confirm_login": token_service},
IUserService: {None: user_service},
}[interface][name]
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
db_request.route_path = pretend.call_recorder(lambda r: f"/{r}")

result = views.confirm_login(db_request)

assert isinstance(result, HTTPSeeOther)
assert result.location == "/accounts.login"
assert db_request.session.flash.calls == [
pretend.call(
"Invalid token: user has logged in since this token was requested",
queue="error",
)
]

def test_user_logged_in_since(self, db_request):
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
db_request.user = None
db_request.params = {"token": "foo"}
token_data = {
"action": "login-confirmation",
"user.id": str(user.id),
"user.last_login": (
user.last_login - datetime.timedelta(seconds=1)
).isoformat(),
}
token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data))
user_service = pretend.stub(get_user=pretend.call_recorder(lambda uid: user))

db_request.find_service = lambda interface, name=None, **kwargs: {
ITokenService: {"confirm_login": token_service},
IUserService: {None: user_service},
}[interface][name]
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
db_request.route_path = pretend.call_recorder(lambda r: f"/{r}")

result = views.confirm_login(db_request)

assert isinstance(result, HTTPSeeOther)
assert result.location == "/accounts.login"
assert db_request.session.flash.calls == [
pretend.call(
"Invalid token: user has logged in since this token was requested",
queue="error",
)
]

def test_unique_login_not_found(self, db_request):
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
db_request.user = None
Expand Down
5 changes: 4 additions & 1 deletion warehouse/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ class User(SitemapMixin, HasObservers, HasObservations, HasEvents, db.Model):
)

unique_logins: Mapped[list[UserUniqueLogin]] = orm.relationship(
back_populates="user", cascade="all, delete-orphan", lazy=True
back_populates="user",
cascade="all, delete-orphan",
lazy=True,
order_by="UserUniqueLogin.created.desc()",
)

role_invitations: Mapped[list[RoleInvitation]] = orm.relationship(
Expand Down
13 changes: 0 additions & 13 deletions warehouse/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,19 +1020,6 @@ def _error(message):
if user is None:
return _error(request._("Invalid token: user not found"))

# Check whether the user has logged in since the token was created
last_login = datetime.datetime.fromisoformat(data.get("user.last_login"))
# Before updating itsdangerous to 2.x the last_login was naive,
# now it's localized to UTC
if not last_login.tzinfo:
last_login = pytz.UTC.localize(last_login)
if user.last_login and user.last_login > last_login:
return _error(
request._(
"Invalid token: user has logged in since this token was requested"
)
)

unique_login_id = data.get("unique_login_id")
unique_login = (
request.db.query(UserUniqueLogin)
Expand Down
1 change: 1 addition & 0 deletions warehouse/admin/views/ip_addresses.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def ip_address_detail(request: Request) -> dict[str, IpAddress]:
unique_logins = (
request.db.query(UserUniqueLogin)
.filter(UserUniqueLogin.ip_address == str(ip_address.ip_address))
.order_by(UserUniqueLogin.created.desc())
.all()
)

Expand Down
82 changes: 41 additions & 41 deletions warehouse/locale/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ msgid "Invalid token: request a new password reset link"
msgstr ""

#: warehouse/accounts/views.py:903 warehouse/accounts/views.py:1012
#: warehouse/accounts/views.py:1090 warehouse/accounts/views.py:1196
#: warehouse/accounts/views.py:1365
#: warehouse/accounts/views.py:1077 warehouse/accounts/views.py:1183
#: warehouse/accounts/views.py:1352
msgid "Invalid token: no token supplied"
msgstr ""

Expand All @@ -196,7 +196,7 @@ msgstr ""
msgid "Invalid token: user not found"
msgstr ""

#: warehouse/accounts/views.py:923 warehouse/accounts/views.py:1032
#: warehouse/accounts/views.py:923
msgid "Invalid token: user has logged in since this token was requested"
msgstr ""

Expand All @@ -222,146 +222,146 @@ msgstr ""
msgid "Invalid token: not a login confirmation token"
msgstr ""

#: warehouse/accounts/views.py:1044
#: warehouse/accounts/views.py:1031
msgid "Invalid login attempt."
msgstr ""

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

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

#: warehouse/accounts/views.py:1086
#: warehouse/accounts/views.py:1073
msgid "Expired token: request a new email verification link"
msgstr ""

#: warehouse/accounts/views.py:1088
#: warehouse/accounts/views.py:1075
msgid "Invalid token: request a new email verification link"
msgstr ""

#: warehouse/accounts/views.py:1094
#: warehouse/accounts/views.py:1081
msgid "Invalid token: not an email verification token"
msgstr ""

#: warehouse/accounts/views.py:1103
#: warehouse/accounts/views.py:1090
msgid "Email not found"
msgstr ""

#: warehouse/accounts/views.py:1106
#: warehouse/accounts/views.py:1093
msgid "Email already verified"
msgstr ""

#: warehouse/accounts/views.py:1126
#: warehouse/accounts/views.py:1113
msgid "You can now set this email as your primary address"
msgstr ""

#: warehouse/accounts/views.py:1129
#: warehouse/accounts/views.py:1116
msgid "This is your primary address"
msgstr ""

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

#: warehouse/accounts/views.py:1192
#: warehouse/accounts/views.py:1179
msgid "Expired token: request a new organization invitation"
msgstr ""

#: warehouse/accounts/views.py:1194
#: warehouse/accounts/views.py:1181
msgid "Invalid token: request a new organization invitation"
msgstr ""

#: warehouse/accounts/views.py:1200
#: warehouse/accounts/views.py:1187
msgid "Invalid token: not an organization invitation token"
msgstr ""

#: warehouse/accounts/views.py:1204
#: warehouse/accounts/views.py:1191
msgid "Organization invitation is not valid."
msgstr ""

#: warehouse/accounts/views.py:1213
#: warehouse/accounts/views.py:1200
msgid "Organization invitation no longer exists."
msgstr ""

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

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

#: warehouse/accounts/views.py:1361
#: warehouse/accounts/views.py:1348
msgid "Expired token: request a new project role invitation"
msgstr ""

#: warehouse/accounts/views.py:1363
#: warehouse/accounts/views.py:1350
msgid "Invalid token: request a new project role invitation"
msgstr ""

#: warehouse/accounts/views.py:1369
#: warehouse/accounts/views.py:1356
msgid "Invalid token: not a collaboration invitation token"
msgstr ""

#: warehouse/accounts/views.py:1373
#: warehouse/accounts/views.py:1360
msgid "Role invitation is not valid."
msgstr ""

#: warehouse/accounts/views.py:1380
#: warehouse/accounts/views.py:1367
msgid "Invalid token: project does not exist"
msgstr ""

#: warehouse/accounts/views.py:1391
#: warehouse/accounts/views.py:1378
msgid "Role invitation no longer exists."
msgstr ""

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

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

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

#: warehouse/accounts/views.py:1813 warehouse/accounts/views.py:2067
#: warehouse/accounts/views.py:1800 warehouse/accounts/views.py:2054
#: warehouse/manage/views/oidc_publishers.py:126
#: warehouse/manage/views/organizations.py:1805
msgid ""
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
"#admin-intervention for details."
msgstr ""

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

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

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

#: warehouse/accounts/views.py:1878
#: warehouse/accounts/views.py:1865
#: warehouse/manage/views/oidc_publishers.py:308
#: warehouse/manage/views/oidc_publishers.py:423
#: warehouse/manage/views/oidc_publishers.py:539
Expand All @@ -371,7 +371,7 @@ msgid ""
"again later."
msgstr ""

#: warehouse/accounts/views.py:1888
#: warehouse/accounts/views.py:1875
#: warehouse/manage/views/oidc_publishers.py:321
#: warehouse/manage/views/oidc_publishers.py:436
#: warehouse/manage/views/oidc_publishers.py:552
Expand All @@ -380,23 +380,23 @@ msgstr ""
msgid "The trusted publisher could not be registered"
msgstr ""

#: warehouse/accounts/views.py:1903
#: warehouse/accounts/views.py:1890
msgid ""
"This trusted publisher has already been registered. Please contact PyPI's"
" admins if this wasn't intentional."
msgstr ""

#: warehouse/accounts/views.py:1937
#: warehouse/accounts/views.py:1924
#: warehouse/manage/views/organizations.py:1893
msgid "Registered a new pending publisher to create "
msgstr ""

#: warehouse/accounts/views.py:2080 warehouse/accounts/views.py:2093
#: warehouse/accounts/views.py:2100
#: warehouse/accounts/views.py:2067 warehouse/accounts/views.py:2080
#: warehouse/accounts/views.py:2087
msgid "Invalid publisher ID"
msgstr ""

#: warehouse/accounts/views.py:2107
#: warehouse/accounts/views.py:2094
msgid "Removed trusted publisher for project "
msgstr ""

Expand Down
6 changes: 4 additions & 2 deletions warehouse/templates/accounts/unrecognized-device.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
{% block content %}
<div class="horizontal-section">
<div class="site-container">
<h1 class="page-title">Unrecognized device</h1>
<p>We did not recognize this device. Please check your email for a login confirmation link.</p>
<h1 class="page-title">Please confirm this login</h1>
<p>
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.
</p>
<p>
You should have received an email from <strong>noreply@pypi.org</strong> with the subject line "<strong>Unrecognized login to your PyPI account</strong>".
</p>
Expand Down
2 changes: 1 addition & 1 deletion warehouse/templates/email/unrecognized-login/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{% extends "email/_base/body.html" %}
{% block content %}
<p>A login attempt was made from an unrecognized device.</p>
<p>To complete your login, please visit the following link:</p>
<p>To complete your login, please visit the following link from the same device from which you attempted to log in:</p>
{% set link = request.route_url('accounts.confirm-login', _query={'token': token}) %}
<p>
<a href="{{ link }}">{{ link }}</a>
Expand Down
3 changes: 2 additions & 1 deletion warehouse/templates/email/unrecognized-login/body.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{# SPDX-License-Identifier: Apache-2.0 -#}
A login attempt was made from an unrecognized device.

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

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

Expand Down