diff --git a/tests/functional/test_login.py b/tests/functional/test_login.py index 78d991025f65..31140a07b27b 100644 --- a/tests/functional/test_login.py +++ b/tests/functional/test_login.py @@ -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 diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 50c453a45ad0..0f4ca50c2123 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -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 diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index c97bdc43a9ff..b518536e7051 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -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( diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 3fe094a625a3..911ef201588e 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -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) diff --git a/warehouse/admin/views/ip_addresses.py b/warehouse/admin/views/ip_addresses.py index aaa543b186f5..36ff64b2ce79 100644 --- a/warehouse/admin/views/ip_addresses.py +++ b/warehouse/admin/views/ip_addresses.py @@ -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() ) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index e4f89f323946..3ed82ff47bc4 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -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 "" @@ -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 "" @@ -222,123 +222,123 @@ 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 Terms of Service." 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 "" @@ -346,22 +346,22 @@ msgid "" "#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 @@ -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 @@ -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 "" diff --git a/warehouse/templates/accounts/unrecognized-device.html b/warehouse/templates/accounts/unrecognized-device.html index 1bc6085b037e..f28b84caa3fc 100644 --- a/warehouse/templates/accounts/unrecognized-device.html +++ b/warehouse/templates/accounts/unrecognized-device.html @@ -4,8 +4,10 @@ {% block content %}
We did not recognize this device. Please check your email for a login confirmation link.
++ 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. +
You should have received an email from noreply@pypi.org with the subject line "Unrecognized login to your PyPI account".
diff --git a/warehouse/templates/email/unrecognized-login/body.html b/warehouse/templates/email/unrecognized-login/body.html index fee92e786e10..ae183c0e044d 100644 --- a/warehouse/templates/email/unrecognized-login/body.html +++ b/warehouse/templates/email/unrecognized-login/body.html @@ -2,7 +2,7 @@ {% extends "email/_base/body.html" %} {% block content %}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:
{% set link = request.route_url('accounts.confirm-login', _query={'token': token}) %}{{ link }} diff --git a/warehouse/templates/email/unrecognized-login/body.txt b/warehouse/templates/email/unrecognized-login/body.txt index 940ab266ef22..519cd9600180 100644 --- a/warehouse/templates/email/unrecognized-login/body.txt +++ b/warehouse/templates/email/unrecognized-login/body.txt @@ -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}) }}