From da6c6eae2d578de999940f7771d5c3861ea941f7 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 29 Jul 2025 01:02:31 +0000 Subject: [PATCH 01/16] Add UserUniqueLogin model --- warehouse/accounts/models.py | 46 +++++++++++- .../4c20f2342bba_add_useruniquelogin.py | 71 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100755 warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index e762d64d28c6..7bfbe1a6130c 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -11,6 +11,7 @@ from pyramid.authorization import Allow, Authenticated from sqlalchemy import ( CheckConstraint, + Enum, ForeignKey, Index, LargeBinary, @@ -20,7 +21,7 @@ select, sql, ) -from sqlalchemy.dialects.postgresql import ARRAY, CITEXT, UUID as PG_UUID +from sqlalchemy.dialects.postgresql import ARRAY, CITEXT, JSONB, UUID as PG_UUID from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column @@ -120,6 +121,10 @@ class User(SitemapMixin, HasObservers, HasObservations, HasEvents, db.Model): order_by="Macaroon.created.desc()", ) + unique_logins: Mapped[list[UserUniqueLogin]] = orm.relationship( + back_populates="user", cascade="all, delete-orphan", lazy=True + ) + role_invitations: Mapped[list[RoleInvitation]] = orm.relationship( "RoleInvitation", back_populates="user", @@ -475,3 +480,42 @@ class ProhibitedUserName(db.Model): ) prohibited_by: Mapped[User] = orm.relationship(User) comment: Mapped[str] = mapped_column(server_default="") + + +class UniqueLoginStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + + +class UserUniqueLogin(db.Model): + __tablename__ = "user_unique_logins" + __table_args__ = ( + UniqueConstraint( + "user_id", "ip_address", name="_user_unique_logins_user_id_ip_address_uc" + ), + ) + + user_id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user: Mapped[User] = orm.relationship(back_populates="unique_logins") + + ip_address: Mapped[str] = mapped_column(String, nullable=False) + created: Mapped[datetime_now] + device_information: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + status: Mapped[UniqueLoginStatus] = mapped_column( + Enum(UniqueLoginStatus, values_callable=lambda x: [e.value for e in x]), + nullable=False, + default=UniqueLoginStatus.PENDING, + server_default=UniqueLoginStatus.PENDING.value, + ) + + def __repr__(self): + return ( + f"" + ) diff --git a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py new file mode 100755 index 000000000000..5b297e249fa0 --- /dev/null +++ b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Refactor UserUniqueLogin to use mapped_column + +Revision ID: 4c20f2342bba +Revises: a6994b8bed95 +Create Date: 2025-07-29 00:55:39.682180 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "4c20f2342bba" +down_revision = "a6994b8bed95" + + +def upgrade(): + sa.Enum("pending", "confirmed", "expired", name="uniqueloginstatus").create( + op.get_bind() + ) + op.create_table( + "user_unique_logins", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("ip_address", sa.String(), nullable=False), + sa.Column( + "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "device_information", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column( + "status", + postgresql.ENUM( + "pending", + "confirmed", + "expired", + name="uniqueloginstatus", + create_type=False, + ), + server_default="pending", + nullable=False, + ), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", "ip_address", name="_user_unique_logins_user_id_ip_address_uc" + ), + ) + op.create_index( + op.f("ix_user_unique_logins_user_id"), + "user_unique_logins", + ["user_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index( + op.f("ix_user_unique_logins_user_id"), table_name="user_unique_logins" + ) + op.drop_table("user_unique_logins") + sa.Enum("pending", "confirmed", "expired", name="uniqueloginstatus").drop( + op.get_bind() + ) From 87f9f64881fe35caf445e1dfd66b82b90eaa0668 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 29 Jul 2025 01:30:18 +0000 Subject: [PATCH 02/16] Send an email on TOTP login and store a UniqueLogin --- dev/environment | 1 + warehouse/accounts/__init__.py | 3 + warehouse/accounts/views.py | 218 +++++++++++++++++- warehouse/config.py | 1 + warehouse/email/__init__.py | 10 + warehouse/routes.py | 3 + .../accounts/unrecognized-device.html | 17 ++ .../email/unrecognized-login/body.html | 23 ++ .../email/unrecognized-login/body.txt | 12 + .../email/unrecognized-login/subject.txt | 5 + 10 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 warehouse/templates/accounts/unrecognized-device.html create mode 100644 warehouse/templates/email/unrecognized-login/body.html create mode 100644 warehouse/templates/email/unrecognized-login/body.txt create mode 100644 warehouse/templates/email/unrecognized-login/subject.txt diff --git a/dev/environment b/dev/environment index 2f3ae12e94ff..6022e8f8abb4 100644 --- a/dev/environment +++ b/dev/environment @@ -59,6 +59,7 @@ TOKEN_PASSWORD_SECRET="an insecure password reset secret key" TOKEN_EMAIL_SECRET="an insecure email verification secret key" TOKEN_TWO_FACTOR_SECRET="an insecure two-factor auth secret key" TOKEN_REMEMBER_DEVICE_SECRET="an insecure remember device auth secret key" +TOKEN_CONFIRM_LOGIN_SECRET="an insecure confirm login auth secret key" WAREHOUSE_LEGACY_DOMAIN=pypi.python.org diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py index 0765f3bb9058..ff9b74477012 100644 --- a/warehouse/accounts/__init__.py +++ b/warehouse/accounts/__init__.py @@ -103,6 +103,9 @@ def includeme(config): config.register_service_factory( TokenServiceFactory(name="two_factor"), ITokenService, name="two_factor" ) + config.register_service_factory( + TokenServiceFactory(name="confirm_login"), ITokenService, name="confirm_login" + ) config.register_service_factory( TokenServiceFactory(name="remember_device"), ITokenService, diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 736a6ee88ecb..45d1985da09c 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -8,6 +8,7 @@ import humanize import pytz +from linehaul.ua import parser as linehaul_user_agent_parser from more_itertools import first_true from pyramid.httpexceptions import ( HTTPBadRequest, @@ -22,6 +23,7 @@ from pyramid.view import view_config, view_defaults from sqlalchemy import and_, func, select from sqlalchemy.exc import IntegrityError, NoResultFound +from ua_parser import user_agent_parser from webauthn.helpers import bytes_to_base64url from webob.multidict import MultiDict @@ -49,7 +51,13 @@ TooManyFailedLogins, TooManyPasswordResetRequests, ) -from warehouse.accounts.models import Email, TermsOfServiceEngagement, User +from warehouse.accounts.models import ( + Email, + TermsOfServiceEngagement, + UniqueLoginStatus, + User, + UserUniqueLogin, +) from warehouse.accounts.utils import update_email_domain_status from warehouse.admin.flags import AdminFlagValue from warehouse.authnz import Permissions @@ -67,7 +75,9 @@ send_password_reset_email, send_password_reset_unverified_email, send_recovery_code_reminder_email, + send_unrecognized_login_email, ) +from warehouse.events.models import UserAgentInfo from warehouse.events.tags import EventTag from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.forms import ( @@ -401,20 +411,101 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): if request.method == "POST": form = two_factor_state["totp_form"] if form.validate(): - two_factor_method = "totp" - _login_user(request, userid, two_factor_method, two_factor_label="totp") - user_service.update_user(userid, last_totp_value=form.totp_value.data) + user = user_service.get_user(userid) - resp = HTTPSeeOther(redirect_to) - _set_userid_insecure_cookie(resp, userid) + unique_login = ( + request.db.query(UserUniqueLogin) + .filter( + UserUniqueLogin.user_id == userid, + UserUniqueLogin.ip_address == request.remote_addr, + ) + .one_or_none() + ) - if not two_factor_state.get("has_recovery_codes", False): - send_recovery_code_reminder_email(request, request.user) + if unique_login: + if unique_login.status == UniqueLoginStatus.CONFIRMED: + # We've seen this device before for this user and they've + # confirmed it, log in the user + two_factor_method = "totp" + _login_user( + request, userid, two_factor_method, two_factor_label="totp" + ) + user_service.update_user( + userid, last_totp_value=form.totp_value.data + ) + + resp = HTTPSeeOther(redirect_to) + _set_userid_insecure_cookie(resp, userid) + + if not two_factor_state.get("has_recovery_codes", False): + send_recovery_code_reminder_email(request, request.user) + + if form.remember_device.data: + _remember_device(request, resp, userid, two_factor_method) + + return resp + else: + # We've seen this device before for this user but they haven't + # confirmed it, don't send another email, just send them to + # the generic page + return HTTPSeeOther(request.route_path("accounts.confirm-login")) - if form.remember_device.data: - _remember_device(request, resp, userid, two_factor_method) + else: + # We haven't seen this device before from this user or they + # haven't confirmed it, make them confirm it + unique_login = UserUniqueLogin( + user_id=userid, + ip_address=request.remote_addr, + status=UniqueLoginStatus.PENDING, + ) + request.db.add(unique_login) + request.db.flush() # To get the ID for the token - return resp + token_service = request.find_service( + ITokenService, name="confirm_login" + ) + token = token_service.dumps( + { + "action": "login-confirmation", + "user.id": str(user.id), + "user.last_login": str( + user.last_login + or datetime.datetime.min.replace(tzinfo=pytz.UTC) + ), + "unique_login_id": unique_login.id, + } + ) + + # Get User Agent Information + user_agent_info_data = {} + if user_agent_str := request.headers.get("User-Agent"): + try: + parsed = linehaul_user_agent_parser.parse(user_agent_str) + if ( + parsed + and parsed.installer + and parsed.installer.name == "Browser" + ): + parsed_ua = user_agent_parser.Parse(user_agent_str) + user_agent_info_data = { + "installer": "Browser", + "device": parsed_ua["device"]["family"], + "os": parsed_ua["os"]["family"], + "user_agent": parsed_ua["user_agent"]["family"], + } + except linehaul_user_agent_parser.UnknownUserAgentError: + pass # Fallback to default empty dict + + user_agent_info = UserAgentInfo(**user_agent_info_data) + + send_unrecognized_login_email( + request, + user, + ip_address=request.remote_addr, + user_agent=user_agent_info.display(), + token=token, + ) + return HTTPSeeOther(request.route_path("accounts.confirm-login")) else: form.totp_value.data = "" @@ -955,6 +1046,87 @@ def _error(message): return {"form": form} +@view_config( + route_name="accounts.confirm-login", + renderer="warehouse:templates/accounts/unrecognized-device.html", + uses_session=True, + require_csrf=True, + require_methods=False, + has_translations=True, +) +def confirm_login(request): + if request.user is not None: + return HTTPSeeOther(request.route_path("index")) + + if not request.params.get("token"): + # Show a generic page for when a non-logged-in user lands here without a token + return {} + + user_service = request.find_service(IUserService, context=None) + token_service = request.find_service(ITokenService, name="confirm_login") + + def _error(message): + request.session.flash(message, queue="error") + return HTTPSeeOther(request.route_path("accounts.login")) + + try: + token = request.params.get("token") + data = token_service.loads(token) + except TokenExpired: + return _error(request._("Expired token: please try to login again")) + except TokenInvalid: + return _error(request._("Invalid token: please try to login again")) + except TokenMissing: + return _error(request._("Invalid token: no token supplied")) + + # Check whether this token is being used correctly + if data.get("action") != "login-confirmation": + return _error(request._("Invalid token: not a login confirmation token")) + + # Check whether a user with the given user ID exists + user = user_service.get_user(uuid.UUID(data.get("user.id"))) + 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) + .filter(UserUniqueLogin.id == unique_login_id) + .one_or_none() + ) + + if unique_login is None: + return _error(request._("Invalid login attempt.")) + + if unique_login.ip_address != request.remote_addr: + return _error(request._("Device details didn't match, please try again")) + + unique_login.status = UniqueLoginStatus.CONFIRMED + + headers = _login_user(request, user.id) + resp = HTTPSeeOther(request.route_path("manage.projects"), headers=dict(headers)) + _set_userid_insecure_cookie(resp, user.id) + request.session.flash( + request._("Your login has been confirmed and this device is now recognized."), + queue="success", + ) + + return resp + + @view_config( route_name="accounts.verify-email", uses_session=True, @@ -1442,6 +1614,30 @@ def _login_user(request, userid, two_factor_method=None, two_factor_label=None): "two_factor_label": two_factor_label, }, ) + + # Create a new UserUniqueLogin if one doesn't already exist for this IP + unique_login = ( + request.db.query(UserUniqueLogin) + .filter( + UserUniqueLogin.user_id == userid, + UserUniqueLogin.ip_address == request.remote_addr, + ) + .one_or_none() + ) + if unique_login is None and two_factor_method != "totp": + # We haven't seen this login before. Create a new one and mark it as confirmed + # if this is non-TOTP. + unique_login = UserUniqueLogin( + user_id=userid, + ip_address=request.remote_addr, + status=UniqueLoginStatus.CONFIRMED, + ) + request.db.add(unique_login) + if unique_login.status == UniqueLoginStatus.PENDING and two_factor_method != "totp": + # The user had a pending login, but has since logged in with a non-TOTP method, + # so mark it as confirmed. + unique_login.status = UniqueLoginStatus.CONFIRMED + request.session.record_auth_timestamp() request.session.record_password_timestamp( user_service.get_password_timestamp(userid) diff --git a/warehouse/config.py b/warehouse/config.py index da2923e96ac1..aa677803f04a 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -410,6 +410,7 @@ def configure(settings=None): maybe_set(settings, "token.email.secret", "TOKEN_EMAIL_SECRET") maybe_set(settings, "token.two_factor.secret", "TOKEN_TWO_FACTOR_SECRET") maybe_set(settings, "token.remember_device.secret", "TOKEN_REMEMBER_DEVICE_SECRET") + maybe_set(settings, "token.confirm_login.secret", "TOKEN_CONFIRM_LOGIN_SECRET") maybe_set_redis(settings, "warehouse.xmlrpc.cache.url", "REDIS_URL", db=4) maybe_set( settings, diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index 43dea91c09a5..12cc9a6daafc 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -983,6 +983,16 @@ def send_recovery_code_reminder_email(request, user): return {"username": user.username} +@_email("unrecognized-login") +def send_unrecognized_login_email(request, user, *, ip_address, user_agent, token): + return { + "username": user.username, + "ip_address": ip_address, + "user_agent": user_agent, + "token": token, + } + + @_email("trusted-publisher-added") def send_trusted_publisher_added_email(request, user, project_name, publisher): # We use the request's user, since they're the one triggering the action. diff --git a/warehouse/routes.py b/warehouse/routes.py index b4e79bc27dee..da5e5d2dce13 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -201,6 +201,9 @@ def includeme(config): config.add_route( "accounts.reset-password", "/account/reset-password/", domain=warehouse ) + config.add_route( + "accounts.confirm-login", "/account/confirm-login/", domain=warehouse + ) config.add_route( "accounts.verify-email", "/account/verify-email/", domain=warehouse ) diff --git a/warehouse/templates/accounts/unrecognized-device.html b/warehouse/templates/accounts/unrecognized-device.html new file mode 100644 index 000000000000..51817e33ea0c --- /dev/null +++ b/warehouse/templates/accounts/unrecognized-device.html @@ -0,0 +1,17 @@ +{# SPDX-License-Identifier: Apache-2.0 -#} +{% extends "base.html" %} +{% block title %}Unrecognized device{% endblock %} +{% block content %} +
+
+

Unrecognized device

+

We did not recognize this device. 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". +

+

+ If you did not make this change, you can email admin@pypi.org to communicate with the PyPI administrators. +

+
+
+{% endblock %} diff --git a/warehouse/templates/email/unrecognized-login/body.html b/warehouse/templates/email/unrecognized-login/body.html new file mode 100644 index 000000000000..20ec4d9bbe52 --- /dev/null +++ b/warehouse/templates/email/unrecognized-login/body.html @@ -0,0 +1,23 @@ +{# SPDX-License-Identifier: Apache-2.0 -#} +{% extends "email/base.html" %} +{% block content %} +

A login attempt was made from an unrecognized device.

+

To complete your login, please visit the following link:

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

+ {{ link }} +

+

This login attempt was made from:

+
    +
  • + IP address: {{ ip_address }} +
  • +
  • + User agent: {{ user_agent }} +
  • +
+

+ If you did not make this change, you can email admin@pypi.org to + communicate with the PyPI administrators. +

+{% endblock %} diff --git a/warehouse/templates/email/unrecognized-login/body.txt b/warehouse/templates/email/unrecognized-login/body.txt new file mode 100644 index 000000000000..a5fe4fea3a79 --- /dev/null +++ b/warehouse/templates/email/unrecognized-login/body.txt @@ -0,0 +1,12 @@ +A login attempt was made from an unrecognized device. + +To complete your login, please visit the following link: + +{{ request.route_url('accounts.confirm-login', _query={'token': token}) }} + +This login attempt was made from: +- IP address: {{ ip_address }} +- User agent: {{ user_agent }} + +If you did not make this change, you can email admin@pypi.org to +communicate with the PyPI administrators. diff --git a/warehouse/templates/email/unrecognized-login/subject.txt b/warehouse/templates/email/unrecognized-login/subject.txt new file mode 100644 index 000000000000..fbbeafbf55b7 --- /dev/null +++ b/warehouse/templates/email/unrecognized-login/subject.txt @@ -0,0 +1,5 @@ +{# SPDX-License-Identifier: Apache-2.0 -#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %}Unrecognized login to your PyPI account{% endblock %} From f8f6f4a897f1966f71ff279a6f7a7d3b165f0224 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 9 Sep 2025 16:18:17 +0000 Subject: [PATCH 03/16] Tests --- tests/common/db/accounts.py | 10 + tests/functional/manage/test_views.py | 6 +- tests/unit/accounts/test_core.py | 5 + tests/unit/accounts/test_models.py | 20 +- tests/unit/accounts/test_views.py | 902 ++++++++++++++++++++++---- tests/unit/email/test_init.py | 101 +++ tests/unit/test_routes.py | 3 + 7 files changed, 926 insertions(+), 121 deletions(-) diff --git a/tests/common/db/accounts.py b/tests/common/db/accounts.py index 468e1bd73f11..19913cd2b0a0 100644 --- a/tests/common/db/accounts.py +++ b/tests/common/db/accounts.py @@ -14,8 +14,10 @@ TermsOfServiceEngagement, User, UserTermsOfServiceEngagement, + UserUniqueLogin, ) +from ...common.constants import REMOTE_ADDR from .base import WarehouseFactory fake = faker.Faker() @@ -130,3 +132,11 @@ class Meta: # TODO: Replace when factory_boy supports `unique`. # See https://github.com/FactoryBoy/factory_boy/pull/997 name = factory.Sequence(lambda _: fake.unique.user_name()) + + +class UserUniqueLoginFactory(WarehouseFactory): + class Meta: + model = UserUniqueLogin + + user = factory.SubFactory(UserFactory) + ip_address = REMOTE_ADDR diff --git a/tests/functional/manage/test_views.py b/tests/functional/manage/test_views.py index 4e53ea6e2b81..8b66246b4e7d 100644 --- a/tests/functional/manage/test_views.py +++ b/tests/functional/manage/test_views.py @@ -11,13 +11,14 @@ from webob.multidict import MultiDict from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService +from warehouse.accounts.models import UniqueLoginStatus from warehouse.manage import views from warehouse.manage.views import organizations as org_views from warehouse.organizations.interfaces import IOrganizationService from warehouse.organizations.models import OrganizationType from warehouse.utils.otp import _get_totp -from ...common.db.accounts import EmailFactory, UserFactory +from ...common.db.accounts import EmailFactory, UserFactory, UserUniqueLoginFactory class TestManageAccount: @@ -52,6 +53,9 @@ def test_changing_password_succeeds(self, webtest, socket_enabled): with_terms_of_service_agreement=True, clear_pwd="password", ) + UserUniqueLoginFactory.create( + user=user, ip_address="1.2.3.4", status=UniqueLoginStatus.CONFIRMED + ) # visit login page login_page = webtest.get("/account/login/", status=HTTPStatus.OK) diff --git a/tests/unit/accounts/test_core.py b/tests/unit/accounts/test_core.py index 14cd5dde9c9f..d4ea5fe44428 100644 --- a/tests/unit/accounts/test_core.py +++ b/tests/unit/accounts/test_core.py @@ -167,6 +167,11 @@ def test_includeme(monkeypatch): pretend.call( TokenServiceFactory(name="two_factor"), ITokenService, name="two_factor" ), + pretend.call( + TokenServiceFactory(name="confirm_login"), + ITokenService, + name="confirm_login", + ), pretend.call( TokenServiceFactory(name="remember_device"), ITokenService, diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index d13f097f7347..9f2b32ef3433 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -7,7 +7,13 @@ from pyramid.authorization import Authenticated -from warehouse.accounts.models import Email, RecoveryCode, User, UserFactory, WebAuthn +from warehouse.accounts.models import ( + Email, + RecoveryCode, + User, + UserFactory, + WebAuthn, +) from warehouse.authnz import Permissions from warehouse.utils.security_policy import principals_for @@ -15,6 +21,7 @@ EmailFactory as DBEmailFactory, UserEventFactory as DBUserEventFactory, UserFactory as DBUserFactory, + UserUniqueLoginFactory, ) from ...common.db.packaging import ( ProjectFactory as DBProjectFactory, @@ -309,3 +316,14 @@ def test_user_projects_is_ordered_by_name(self, db_session): DBRoleFactory.create(project=project3, user=user) assert user.projects == [project2, project3, project1] + + +class TestUserUniqueLogin: + def test_repr(self, db_session): + unique_login = UserUniqueLoginFactory.create() + assert ( + repr(unique_login) + == f"" + ) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index d1c89a35789f..918b37c60dbb 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -36,7 +36,11 @@ TooManyFailedLogins, TooManyPasswordResetRequests, ) -from warehouse.accounts.models import TermsOfServiceEngagement +from warehouse.accounts.models import ( + TermsOfServiceEngagement, + UniqueLoginStatus, + UserUniqueLogin, +) from warehouse.accounts.views import ( REMEMBER_DEVICE_COOKIE, two_factor_and_totp_validate, @@ -61,7 +65,11 @@ from warehouse.packaging.models import Role, RoleInvitation from warehouse.rate_limiting.interfaces import IRateLimiter -from ...common.db.accounts import EmailFactory, UserFactory +from ...common.db.accounts import ( + EmailFactory, + UserFactory, + UserUniqueLoginFactory, +) from ...common.db.ip_addresses import IpAddressFactory from ...common.db.organizations import ( OrganizationFactory, @@ -325,7 +333,7 @@ def test_post_invalid_returns_form( @pytest.mark.parametrize("with_user", [True, False]) def test_post_validate_redirects( - self, monkeypatch, pyramid_request, pyramid_services, metrics, with_user + self, monkeypatch, db_request, pyramid_services, metrics, with_user ): remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) monkeypatch.setattr(views, "remember", remember) @@ -351,23 +359,21 @@ def test_post_validate_redirects( breach_service, IPasswordBreachedService, None ) - pyramid_request.method = "POST" - pyramid_request.session = pretend.stub( + db_request.method = "POST" + db_request.session = pretend.stub( items=lambda: [("a", "b"), ("foo", "bar")], update=new_session.update, invalidate=pretend.call_recorder(lambda: None), new_csrf_token=pretend.call_recorder(lambda: None), ) - pyramid_request._unauthenticated_userid = ( - str(uuid.uuid4()) if with_user else None - ) + db_request._unauthenticated_userid = str(uuid.uuid4()) if with_user else None - pyramid_request.registry.settings = {"sessions.secret": "dummy_secret"} - pyramid_request.session.record_auth_timestamp = pretend.call_recorder( + db_request.registry.settings = {"sessions.secret": "dummy_secret"} + db_request.session.record_auth_timestamp = pretend.call_recorder( lambda *args: None ) - pyramid_request.session.record_password_timestamp = lambda timestamp: None + db_request.session.record_password_timestamp = lambda timestamp: None form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -376,25 +382,25 @@ def test_post_validate_redirects( ) form_class = pretend.call_recorder(lambda d, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") + db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") now = datetime.datetime.now(datetime.UTC) with freezegun.freeze_time(now): - result = views.login(pyramid_request, _form_class=form_class) + result = views.login(db_request, _form_class=form_class) assert metrics.increment.calls == [] assert isinstance(result, HTTPSeeOther) - assert pyramid_request.route_path.calls == [pretend.call("manage.projects")] + assert db_request.route_path.calls == [pretend.call("manage.projects")] assert result.headers["Location"] == "/the-redirect" assert result.headers["Set-Cookie"].startswith("user_id__insecure=") assert result.headers["foo"] == "bar" assert form_class.calls == [ pretend.call( - pyramid_request.POST, - request=pyramid_request, + db_request.POST, + request=db_request, user_service=user_service, breach_service=breach_service, check_password_metrics_tags=["method:auth", "auth_method:login_form"], @@ -407,7 +413,7 @@ def test_post_validate_redirects( assert user.record_event.calls == [ pretend.call( tag=EventTag.Account.LoginSuccess, - request=pyramid_request, + request=db_request, additional={"two_factor_method": None, "two_factor_label": None}, ) ] @@ -417,18 +423,17 @@ def test_post_validate_redirects( else: assert new_session == {"a": "b", "foo": "bar"} - assert remember.calls == [pretend.call(pyramid_request, str(user_id))] - assert pyramid_request.session.invalidate.calls == [pretend.call()] - assert pyramid_request.session.new_csrf_token.calls == [pretend.call()] - assert pyramid_request.session.record_auth_timestamp.calls == [pretend.call()] + assert remember.calls == [pretend.call(db_request, str(user_id))] + assert db_request.session.invalidate.calls == [pretend.call()] + assert db_request.session.new_csrf_token.calls == [pretend.call()] + assert db_request.session.record_auth_timestamp.calls == [pretend.call()] - def test_post_validate_flash_tos(self, pyramid_request, pyramid_services): - user = pretend.stub( - record_event=pretend.call_recorder(lambda *a, **kw: None), - ) + def test_post_validate_flash_tos(self, db_request, pyramid_services): + user = UserFactory.create() + user.record_event = pretend.call_recorder(lambda *a, **kw: None) user_service = pretend.stub( get_user=pretend.call_recorder(lambda userid: user), - find_userid=pretend.call_recorder(lambda username: 1), + find_userid=pretend.call_recorder(lambda username: user.id), update_user=lambda *a, **k: None, has_two_factor=lambda userid: False, get_password_timestamp=lambda userid: 0, @@ -444,21 +449,21 @@ def test_post_validate_flash_tos(self, pyramid_request, pyramid_services): breach_service, IPasswordBreachedService, None ) - pyramid_request.method = "POST" + db_request.method = "POST" - pyramid_request.session.record_auth_timestamp = pretend.call_recorder( + db_request.session.record_auth_timestamp = pretend.call_recorder( lambda *args: None ) - pyramid_request.session.record_password_timestamp = lambda timestamp: None - pyramid_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) + db_request.session.record_password_timestamp = lambda timestamp: None + db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) security_policy = pretend.stub( identity=lambda r: None, remember=lambda r, u, **kw: [], reset=pretend.call_recorder(lambda r: None), ) - pyramid_request.registry.queryUtility = lambda iface: security_policy - pyramid_request.registry.settings = {"terms.revision": "the-revision"} + db_request.registry.queryUtility = lambda iface: security_policy + db_request.registry.settings = {"terms.revision": "the-revision"} form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -466,11 +471,11 @@ def test_post_validate_flash_tos(self, pyramid_request, pyramid_services): password=pretend.stub(data="password"), ) form_class = pretend.call_recorder(lambda d, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") + db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") - views.login(pyramid_request, _form_class=form_class) + views.login(db_request, _form_class=form_class) - assert pyramid_request.session.flash.calls == [ + assert db_request.session.flash.calls == [ pretend.call( ( "Please review our updated " @@ -481,7 +486,7 @@ def test_post_validate_flash_tos(self, pyramid_request, pyramid_services): ) ] assert user_service.record_tos_engagement.calls == [ - pretend.call(1, "the-revision", TermsOfServiceEngagement.Flashed) + pretend.call(user.id, "the-revision", TermsOfServiceEngagement.Flashed) ] @pytest.mark.parametrize( @@ -491,14 +496,14 @@ def test_post_validate_flash_tos(self, pyramid_request, pyramid_services): [("/security/", "/security/"), ("http://example.com", "/the-redirect")], ) def test_post_validate_no_redirects( - self, pyramid_request, pyramid_services, expected_next_url, observed_next_url + self, db_request, pyramid_services, expected_next_url, observed_next_url ): - user = pretend.stub( - record_event=pretend.call_recorder(lambda *a, **kw: None), - ) + user = UserFactory.create() + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) user_service = pretend.stub( get_user=pretend.call_recorder(lambda userid: user), - find_userid=pretend.call_recorder(lambda username: 1), + find_userid=pretend.call_recorder(lambda username: user.id), update_user=lambda *a, **k: None, has_two_factor=lambda userid: False, get_password_timestamp=lambda userid: 0, @@ -511,20 +516,20 @@ def test_post_validate_no_redirects( breach_service, IPasswordBreachedService, None ) - pyramid_request.method = "POST" - pyramid_request.POST["next"] = expected_next_url + db_request.method = "POST" + db_request.POST["next"] = expected_next_url - pyramid_request.session.record_auth_timestamp = pretend.call_recorder( + db_request.session.record_auth_timestamp = pretend.call_recorder( lambda *args: None ) - pyramid_request.session.record_password_timestamp = lambda timestamp: None + db_request.session.record_password_timestamp = lambda timestamp: None security_policy = pretend.stub( identity=lambda r: None, remember=lambda r, u, **kw: [], reset=pretend.call_recorder(lambda r: None), ) - pyramid_request.registry.queryUtility = lambda iface: security_policy + db_request.registry.queryUtility = lambda iface: security_policy form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -532,21 +537,21 @@ def test_post_validate_no_redirects( password=pretend.stub(data="password"), ) form_class = pretend.call_recorder(lambda d, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") + db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") - result = views.login(pyramid_request, _form_class=form_class) + result = views.login(db_request, _form_class=form_class) assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == observed_next_url assert user.record_event.calls == [ pretend.call( tag=EventTag.Account.LoginSuccess, - request=pyramid_request, + request=db_request, additional={"two_factor_method": None, "two_factor_label": None}, ) ] - assert pyramid_request.session.record_auth_timestamp.calls == [pretend.call()] - assert security_policy.reset.calls == [pretend.call(pyramid_request)] + assert db_request.session.record_auth_timestamp.calls == [pretend.call()] + assert security_policy.reset.calls == [pretend.call(db_request)] def test_redirect_authenticated_user(self): pyramid_request = pretend.stub(user=pretend.stub()) @@ -608,6 +613,65 @@ def test_two_factor_auth( ("Location", "/account/two-factor"), ] + def test_login_with_remembered_device_confirms_unique_login( + self, monkeypatch, db_request, pyramid_services + ): + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + monkeypatch.setattr(views, "_check_remember_device_token", lambda r, uid: True) + + user = UserFactory.create() + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + + user_service = pretend.stub( + find_userid=pretend.call_recorder(lambda username: user.id), + update_user=pretend.call_recorder(lambda *a, **kw: None), + get_user=pretend.call_recorder(lambda userid: user), + has_two_factor=lambda userid: True, + get_password_timestamp=lambda userid: 0, + needs_tos_flash=lambda userid, revision: False, + ) + breach_service = pretend.stub(check_password=lambda password, tags=None: False) + + pyramid_services.register_service(user_service, IUserService, None) + pyramid_services.register_service( + breach_service, IPasswordBreachedService, None + ) + + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.PENDING, + ) + + db_request.method = "POST" + db_request.session = pretend.stub( + items=lambda: [], + update=lambda d: None, + invalidate=pretend.call_recorder(lambda: None), + new_csrf_token=pretend.call_recorder(lambda: None), + record_auth_timestamp=pretend.call_recorder(lambda: None), + record_password_timestamp=lambda ts: None, + ) + db_request.registry.settings = {"sessions.secret": "dummy_secret"} + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + username=pretend.stub(data=user.username), + password=pretend.stub(data="password"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") + + views.login(db_request, _form_class=form_class) + + unique_login = ( + db_request.db.query(UserUniqueLogin) + .filter(UserUniqueLogin.user == user) + .one() + ) + assert unique_login.status == UniqueLoginStatus.CONFIRMED + class TestTwoFactor: def test_get_two_factor_data_invalid_after_login(self, pyramid_request): @@ -847,22 +911,40 @@ def test_get_returns_recovery_code_status(self, pyramid_request, redirect_url): def test_totp_auth( self, monkeypatch, - pyramid_request, + db_request, redirect_url, has_recovery_codes, remember_device, + make_email_renderers, + metrics, ): + make_email_renderers("unrecognized-login") remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) monkeypatch.setattr(views, "remember", remember) _remember_device = pretend.call_recorder(lambda *a, **kw: None) monkeypatch.setattr(views, "_remember_device", _remember_device) - query_params = {"userid": str(1)} + user = UserFactory.create( + with_verified_primary_email=True, + username="testuser", + name="Test User", + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + monkeypatch.setattr( + type(user), + "has_recovery_codes", + property(lambda u: has_recovery_codes), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} if redirect_url: query_params["redirect_to"] = redirect_url - token_service = pretend.stub( + two_factor_token_service = pretend.stub( loads=pretend.call_recorder( lambda *args, **kwargs: ( query_params, @@ -870,16 +952,8 @@ def test_totp_auth( ) ) ) - - user = pretend.stub( - last_login=( - datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) - ), - has_recovery_codes=has_recovery_codes, - record_event=pretend.call_recorder(lambda *a, **kw: None), - ) user_service = pretend.stub( - find_userid=pretend.call_recorder(lambda username: 1), + find_userid=pretend.call_recorder(lambda username: user.id), get_user=pretend.call_recorder(lambda userid: user), update_user=lambda *a, **k: None, has_totp=lambda userid: True, @@ -892,13 +966,13 @@ def test_totp_auth( new_session = {} - pyramid_request.find_service = lambda interface, **kwargs: { - ITokenService: token_service, + db_request.find_service = lambda interface, **kwargs: { + ITokenService: two_factor_token_service, IUserService: user_service, }[interface] - pyramid_request.method = "POST" - pyramid_request.session = pretend.stub( + db_request.method = "POST" + db_request.session = pretend.stub( items=lambda: [("a", "b"), ("foo", "bar")], update=new_session.update, invalidate=pretend.call_recorder(lambda: None), @@ -906,11 +980,11 @@ def test_totp_auth( get_password_timestamp=lambda userid: 0, ) - pyramid_request.session.record_auth_timestamp = pretend.call_recorder( + db_request.session.record_auth_timestamp = pretend.call_recorder( lambda *args: None ) - pyramid_request.session.record_password_timestamp = lambda timestamp: None - pyramid_request.registry.settings = {"remember_device.days": 30} + db_request.session.record_password_timestamp = lambda timestamp: None + db_request.registry.settings = {"remember_device.days": 30} form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -918,46 +992,48 @@ def test_totp_auth( remember_device=pretend.stub(data=remember_device), ) form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder( - lambda a: "/account/two-factor" - ) - pyramid_request.params = pretend.stub( + db_request.route_path = pretend.call_recorder(lambda a: "/account/two-factor") + db_request.params = pretend.stub( get=pretend.call_recorder(lambda k: query_params.get(k)) ) - pyramid_request.user = user + db_request.user = user + + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.CONFIRMED, + ) send_email = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(views, "send_recovery_code_reminder_email", send_email) - result = views.two_factor_and_totp_validate( - pyramid_request, _form_class=form_class - ) + result = views.two_factor_and_totp_validate(db_request, _form_class=form_class) - token_expected_data = {"userid": str(1)} + token_expected_data = {"userid": str(user.id)} if redirect_url: token_expected_data["redirect_to"] = redirect_url assert isinstance(result, HTTPSeeOther) - assert remember.calls == [pretend.call(pyramid_request, str(1))] - assert pyramid_request.session.invalidate.calls == [pretend.call()] - assert pyramid_request.session.new_csrf_token.calls == [pretend.call()] + assert remember.calls == [pretend.call(db_request, str(user.id))] + assert db_request.session.invalidate.calls == [pretend.call()] + assert db_request.session.new_csrf_token.calls == [pretend.call()] assert user.record_event.calls == [ pretend.call( tag=EventTag.Account.LoginSuccess, - request=pyramid_request, + request=db_request, additional={"two_factor_method": "totp", "two_factor_label": "totp"}, ) ] - assert pyramid_request.session.record_auth_timestamp.calls == [pretend.call()] + assert db_request.session.record_auth_timestamp.calls == [pretend.call()] assert send_email.calls == ( - [] if has_recovery_codes else [pretend.call(pyramid_request, user)] + [] if has_recovery_codes else [pretend.call(db_request, user)] ) assert _remember_device.calls == ( [] if not remember_device - else [pretend.call(pyramid_request, result, str(1), "totp")] + else [pretend.call(db_request, result, str(user.id), "totp")] ) def test_totp_auth_already_authed(self): @@ -972,6 +1048,346 @@ def test_totp_auth_already_authed(self): assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "redirect_to" + def test_totp_auth_no_unique_login( + self, + monkeypatch, + db_request, + make_email_renderers, + metrics, + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + _remember_device = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "_remember_device", _remember_device) + + user = UserFactory.create( + with_verified_primary_email=True, + username="testuser", + name="Test User", + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + monkeypatch.setattr( + type(user), + "has_recovery_codes", + property(lambda u: False), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + confirm_login_token_service = pretend.stub( + dumps=pretend.call_recorder(lambda d: "fake_token") + ) + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + find_userid=pretend.call_recorder(lambda username: user.id), + get_user=pretend.call_recorder(lambda userid: user), + update_user=lambda *a, **k: None, + has_totp=lambda userid: True, + has_webauthn=lambda userid: False, + has_recovery_codes=lambda userid: False, + check_totp_value=lambda userid, totp_value: True, + get_password_timestamp=lambda userid: 0, + needs_tos_flash=lambda userid, revision: False, + ) + + new_session = {} + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "confirm_login": confirm_login_token_service, + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + db_request.session = pretend.stub( + items=lambda: [("a", "b"), ("foo", "bar")], + update=new_session.update, + invalidate=pretend.call_recorder(lambda: None), + new_csrf_token=pretend.call_recorder(lambda: None), + get_password_timestamp=lambda userid: 0, + ) + + db_request.session.record_auth_timestamp = pretend.call_recorder( + lambda *args: None + ) + db_request.session.record_password_timestamp = lambda timestamp: None + db_request.registry.settings = {"remember_device.days": 30} + db_request.headers["User-Agent"] = ( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 " + "Firefox/15.0.1" + ) + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + totp_value=pretend.stub(data="test-otp-secret"), + remember_device=pretend.stub(data=False), + ) + form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + db_request.user = user + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.two_factor_and_totp_validate(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [ + pretend.call( + db_request, + user, + ip_address=db_request.remote_addr, + user_agent="Firefox (Ubuntu)", + token="fake_token", + ) + ] + + @pytest.mark.parametrize("ua_string", [None, "no bueno", "Python-urllib/3.7"]) + def test_totp_auth_no_unique_login_bad_user_agent( + self, + monkeypatch, + db_request, + make_email_renderers, + metrics, + ua_string, + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + _remember_device = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "_remember_device", _remember_device) + + user = UserFactory.create( + with_verified_primary_email=True, + username="testuser", + name="Test User", + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + monkeypatch.setattr( + type(user), + "has_recovery_codes", + property(lambda u: False), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + confirm_login_token_service = pretend.stub( + dumps=pretend.call_recorder(lambda d: "fake_token") + ) + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + find_userid=pretend.call_recorder(lambda username: user.id), + get_user=pretend.call_recorder(lambda userid: user), + update_user=lambda *a, **k: None, + has_totp=lambda userid: True, + has_webauthn=lambda userid: False, + has_recovery_codes=lambda userid: False, + check_totp_value=lambda userid, totp_value: True, + get_password_timestamp=lambda userid: 0, + needs_tos_flash=lambda userid, revision: False, + ) + + new_session = {} + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "confirm_login": confirm_login_token_service, + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + db_request.session = pretend.stub( + items=lambda: [("a", "b"), ("foo", "bar")], + update=new_session.update, + invalidate=pretend.call_recorder(lambda: None), + new_csrf_token=pretend.call_recorder(lambda: None), + get_password_timestamp=lambda userid: 0, + ) + + db_request.session.record_auth_timestamp = pretend.call_recorder( + lambda *args: None + ) + db_request.session.record_password_timestamp = lambda timestamp: None + db_request.registry.settings = {"remember_device.days": 30} + if ua_string: + db_request.headers["User-Agent"] = ua_string + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + totp_value=pretend.stub(data="test-otp-secret"), + remember_device=pretend.stub(data=False), + ) + form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + db_request.user = user + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.two_factor_and_totp_validate(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [ + pretend.call( + db_request, + user, + ip_address=db_request.remote_addr, + user_agent="Unknown User-Agent", + token="fake_token", + ) + ] + + def test_totp_auth_redirect_with_pending_unique_login( + self, + monkeypatch, + db_request, + make_email_renderers, + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + _remember_device = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "_remember_device", _remember_device) + + user = UserFactory.create( + with_verified_primary_email=True, + username="testuser", + name="Test User", + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + monkeypatch.setattr( + type(user), + "has_recovery_codes", + property(lambda u: False), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + find_userid=pretend.call_recorder(lambda username: user.id), + get_user=pretend.call_recorder(lambda userid: user), + update_user=lambda *a, **k: None, + has_totp=lambda userid: True, + has_webauthn=lambda userid: False, + has_recovery_codes=lambda userid: False, + check_totp_value=lambda userid, totp_value: True, + get_password_timestamp=lambda userid: 0, + needs_tos_flash=lambda userid, revision: False, + ) + + new_session = {} + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + db_request.session = pretend.stub( + items=lambda: [("a", "b"), ("foo", "bar")], + update=new_session.update, + invalidate=pretend.call_recorder(lambda: None), + new_csrf_token=pretend.call_recorder(lambda: None), + get_password_timestamp=lambda userid: 0, + ) + + db_request.session.record_auth_timestamp = pretend.call_recorder( + lambda *args: None + ) + db_request.session.record_password_timestamp = lambda timestamp: None + db_request.registry.settings = {"remember_device.days": 30} + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + totp_value=pretend.stub(data="test-otp-secret"), + remember_device=pretend.stub(data=False), + ) + form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + db_request.user = user + + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.PENDING, + ) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.two_factor_and_totp_validate(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [] + def test_totp_form_invalid(self): token_data = {"userid": 1} token_service = pretend.stub( @@ -1420,11 +1836,12 @@ def test_get_returns_form(self, pyramid_request): ] @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) - def test_recovery_code_auth(self, monkeypatch, pyramid_request, redirect_url): + def test_recovery_code_auth(self, monkeypatch, db_request, redirect_url): remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) monkeypatch.setattr(views, "remember", remember) - query_params = {"userid": str(1)} + user_id = uuid.uuid4() + query_params = {"userid": str(user_id)} if redirect_url: query_params["redirect_to"] = redirect_url @@ -1438,13 +1855,14 @@ def test_recovery_code_auth(self, monkeypatch, pyramid_request, redirect_url): ) user = pretend.stub( + id=user_id, last_login=( datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) ), record_event=pretend.call_recorder(lambda *a, **kw: None), ) user_service = pretend.stub( - find_userid=pretend.call_recorder(lambda username: 1), + find_userid=pretend.call_recorder(lambda username: user_id), get_user=pretend.call_recorder(lambda userid: user), update_user=lambda *a, **k: None, has_recovery_codes=lambda userid: True, @@ -1455,13 +1873,13 @@ def test_recovery_code_auth(self, monkeypatch, pyramid_request, redirect_url): new_session = {} - pyramid_request.find_service = lambda interface, **kwargs: { + db_request.find_service = lambda interface, **kwargs: { ITokenService: token_service, IUserService: user_service, }[interface] - pyramid_request.method = "POST" - pyramid_request.session = pretend.stub( + db_request.method = "POST" + db_request.session = pretend.stub( items=lambda: [("a", "b"), ("foo", "bar")], update=new_session.update, invalidate=pretend.call_recorder(lambda: None), @@ -1469,41 +1887,39 @@ def test_recovery_code_auth(self, monkeypatch, pyramid_request, redirect_url): flash=pretend.call_recorder(lambda message, queue: None), ) - pyramid_request.set_property( + db_request.set_property( lambda r: str(uuid.uuid4()), name="unauthenticated_userid" ) - pyramid_request.session.record_auth_timestamp = pretend.call_recorder( + db_request.session.record_auth_timestamp = pretend.call_recorder( lambda *args: None ) - pyramid_request.session.record_password_timestamp = lambda timestamp: None + db_request.session.record_password_timestamp = lambda timestamp: None form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), recovery_code_value=pretend.stub(data="recovery-code"), ) form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder( - lambda a: "/account/two-factor" - ) - pyramid_request.params = pretend.stub( + db_request.route_path = pretend.call_recorder(lambda a: "/account/two-factor") + db_request.params = pretend.stub( get=pretend.call_recorder(lambda k: query_params.get(k)) ) - result = views.recovery_code(pyramid_request, _form_class=form_class) + result = views.recovery_code(db_request, _form_class=form_class) - token_expected_data = {"userid": str(1)} + token_expected_data = {"userid": str(user_id)} if redirect_url: token_expected_data["redirect_to"] = redirect_url assert isinstance(result, HTTPSeeOther) assert result.headers["Set-Cookie"].startswith("user_id__insecure=") - assert remember.calls == [pretend.call(pyramid_request, str(1))] - assert pyramid_request.session.invalidate.calls == [pretend.call()] - assert pyramid_request.session.new_csrf_token.calls == [pretend.call()] + assert remember.calls == [pretend.call(db_request, str(user_id))] + assert db_request.session.invalidate.calls == [pretend.call()] + assert db_request.session.new_csrf_token.calls == [pretend.call()] assert user.record_event.calls == [ pretend.call( tag=EventTag.Account.LoginSuccess, - request=pyramid_request, + request=db_request, additional={ "two_factor_method": "recovery-code", "two_factor_label": None, @@ -1511,16 +1927,16 @@ def test_recovery_code_auth(self, monkeypatch, pyramid_request, redirect_url): ), pretend.call( tag=EventTag.Account.RecoveryCodesUsed, - request=pyramid_request, + request=db_request, ), ] - assert pyramid_request.session.flash.calls == [ + assert db_request.session.flash.calls == [ pretend.call( "Recovery code accepted. The supplied code cannot be used again.", queue="success", ) ] - assert pyramid_request.session.record_auth_timestamp.calls == [pretend.call()] + assert db_request.session.record_auth_timestamp.calls == [pretend.call()] def test_recovery_code_form_invalid(self): token_data = {"userid": 1} @@ -1677,23 +2093,23 @@ def test_get(self, db_request): result = views.register(db_request, _form_class=form) assert result["form"] is form_inst - def test_redirect_authenticated_user(self): - pyramid_request = pretend.stub(user=pretend.stub()) + def test_redirect_authenticated_user(self, pyramid_request): + pyramid_request.user = pretend.stub() pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") result = views.register(pyramid_request) assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/the-redirect" - def test_register_honeypot(self, pyramid_request, monkeypatch): - pyramid_request.method = "POST" + def test_register_honeypot(self, db_request, monkeypatch): + db_request.method = "POST" create_user = pretend.call_recorder(lambda *args, **kwargs: None) add_email = pretend.call_recorder(lambda *args, **kwargs: None) - pyramid_request.route_path = pretend.call_recorder(lambda name: "/") - pyramid_request.POST = {"confirm_form": "fuzzywuzzy@bears.com"} + db_request.route_path = pretend.call_recorder(lambda name: "/") + db_request.POST = {"confirm_form": "fuzzywuzzy@bears.com"} send_email = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(views, "send_email_verification_email", send_email) - result = views.register(pyramid_request) + result = views.register(db_request) assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/" @@ -1705,10 +2121,8 @@ def test_register_redirect(self, db_request, monkeypatch): db_request.method = "POST" record_event = pretend.call_recorder(lambda *a, **kw: None) - user = pretend.stub( - id=pretend.stub(), - record_event=record_event, - ) + user = UserFactory.create() + user.record_event = record_event email = pretend.stub() create_user = pretend.call_recorder(lambda *args, **kwargs: user) add_email = pretend.call_recorder(lambda *args, **kwargs: email) @@ -5102,3 +5516,253 @@ def test_delete_pending_oidc_publisher( ) ] assert db_request.db.query(publisher_class).all() == [] + + +class TestConfirmLogin: + def test_already_logged_in(self, pyramid_request): + pyramid_request.user = UserFactory.create() + pyramid_request.route_path = pretend.call_recorder(lambda route: f"/{route}") + result = views.confirm_login(pyramid_request) + assert isinstance(result, HTTPSeeOther) + assert result.location == "/index" + assert pyramid_request.route_path.calls == [pretend.call("index")] + + def test_no_token(self, pyramid_request): + pyramid_request.user = None + pyramid_request.params = {} + result = views.confirm_login(pyramid_request) + assert result == {} + + @pytest.mark.parametrize( + ("exception", "message"), + [ + (TokenInvalid, "Invalid token: please try to login again"), + (TokenExpired, "Expired token: please try to login again"), + (TokenMissing, "Invalid token: no token supplied"), + ], + ) + def test_token_error(self, pyramid_request, exception, message): + pyramid_request.user = None + pyramid_request.params = {"token": "foo"} + token_service = pretend.stub(loads=pretend.raiser(exception)) + user_service = pretend.stub() + pyramid_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: {"confirm_login": token_service}, + IUserService: {None: user_service}, + }[interface][name] + pyramid_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) + pyramid_request.route_path = pretend.call_recorder(lambda r: f"/{r}") + + result = views.confirm_login(pyramid_request) + + assert isinstance(result, HTTPSeeOther) + assert result.location == "/accounts.login" + assert pyramid_request.session.flash.calls == [ + pretend.call(message, queue="error") + ] + + def test_invalid_action(self, pyramid_request): + pyramid_request.user = None + pyramid_request.params = {"token": "foo"} + token_data = {"action": "wrong-action"} + token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data)) + user_service = pretend.stub() + pyramid_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: {"confirm_login": token_service}, + IUserService: {None: user_service}, + }[interface][name] + pyramid_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) + pyramid_request.route_path = pretend.call_recorder(lambda r: f"/{r}") + + result = views.confirm_login(pyramid_request) + + assert isinstance(result, HTTPSeeOther) + assert result.location == "/accounts.login" + assert pyramid_request.session.flash.calls == [ + pretend.call("Invalid token: not a login confirmation token", queue="error") + ] + + def test_user_not_found(self, pyramid_request): + pyramid_request.user = None + pyramid_request.params = {"token": "foo"} + token_data = { + "action": "login-confirmation", + "user.id": str(uuid.uuid4()), + } + token_service = pretend.stub(loads=pretend.call_recorder(lambda t: token_data)) + user_service = pretend.stub(get_user=pretend.call_recorder(lambda uid: None)) + + pyramid_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: {"confirm_login": token_service}, + IUserService: {None: user_service}, + }[interface][name] + pyramid_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) + pyramid_request.route_path = pretend.call_recorder(lambda r: f"/{r}") + + result = views.confirm_login(pyramid_request) + + assert isinstance(result, HTTPSeeOther) + assert result.location == "/accounts.login" + assert pyramid_request.session.flash.calls == [ + 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 + db_request.params = {"token": "foo"} + token_data = { + "action": "login-confirmation", + "user.id": str(user.id), + "user.last_login": user.last_login.isoformat(), + "unique_login_id": str(uuid.uuid4()), + } + 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 login attempt.", queue="error") + ] + + def test_ip_address_mismatch(self, db_request): + user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC)) + unique_login = UserUniqueLoginFactory.create(user=user, ip_address="1.1.1.1") + 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.isoformat(), + "unique_login_id": unique_login.id, + } + 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("Device details didn't match, please try again", queue="error") + ] + + def test_success(self, monkeypatch, db_request): + user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC)) + unique_login = UserUniqueLoginFactory.create( + user=user, ip_address=db_request.remote_addr + ) + 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.isoformat(), + "unique_login_id": str(unique_login.id), + } + 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] + + _login_user = pretend.call_recorder(lambda request, userid: [("foo", "bar")]) + monkeypatch.setattr(views, "_login_user", _login_user) + _set_userid_insecure_cookie = pretend.call_recorder(lambda resp, userid: None) + monkeypatch.setattr( + views, "_set_userid_insecure_cookie", _set_userid_insecure_cookie + ) + + db_request.route_path = pretend.call_recorder(lambda r: f"/{r}") + + result = views.confirm_login(db_request) + + assert isinstance(result, HTTPSeeOther) + assert result.location == "/manage.projects" + assert unique_login.status == UniqueLoginStatus.CONFIRMED + assert _login_user.calls == [pretend.call(db_request, user.id)] + assert _set_userid_insecure_cookie.calls == [pretend.call(result, user.id)] diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index 8c9fc824ade3..98b2270509ee 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -6254,3 +6254,104 @@ def test_user_terms_of_service_updated( }, ) ] + + +class TestSendUnrecognizedLoginEmail: + def test_send_unrecognized_login_email( + self, + pyramid_request, + pyramid_config, + monkeypatch, + ): + stub_user = pretend.stub( + id="id", + username="username", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + ip_address = "127.0.0.1" + user_agent = "Test Browser" + token = "test-token" + + subject_renderer = pyramid_config.testing_add_renderer( + "email/unrecognized-login/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + "email/unrecognized-login/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + "email/unrecognized-login/body.html" + ) + html_renderer.string_response = "Email HTML Body" + + send_email = pretend.stub( + delay=pretend.call_recorder(lambda *args, **kwargs: None) + ) + pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) + monkeypatch.setattr(email, "send_email", send_email) + + pyramid_request.db = pretend.stub( + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub( + one=lambda: pretend.stub(user_id=stub_user.id) + ) + ), + ) + pyramid_request.user = stub_user + pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} + + result = email.send_unrecognized_login_email( + pyramid_request, + stub_user, + ip_address=ip_address, + user_agent=user_agent, + token=token, + ) + + assert result == { + "username": stub_user.username, + "ip_address": ip_address, + "user_agent": user_agent, + "token": token, + } + subject_renderer.assert_() + body_renderer.assert_( + username=stub_user.username, + ip_address=ip_address, + user_agent=user_agent, + token=token, + ) + html_renderer.assert_( + username=stub_user.username, + ip_address=ip_address, + user_agent=user_agent, + token=token, + ) + assert pyramid_request.task.calls == [pretend.call(send_email)] + assert send_email.delay.calls == [ + pretend.call( + f"{stub_user.username} <{stub_user.email}>", + { + "sender": None, + "subject": "Email Subject", + "body_text": "Email Body", + "body_html": ( + "\n\n" + "

Email HTML Body

\n\n" + ), + }, + { + "tag": "account:email:sent", + "user_id": stub_user.id, + "additional": { + "from_": "noreply@example.com", + "to": stub_user.email, + "subject": "Email Subject", + "redact_ip": False, + }, + }, + ) + ] diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 480fe8f2660e..f5d46c909d3a 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -189,6 +189,9 @@ def add_redirect_rule(*args, **kwargs): pretend.call( "accounts.reset-password", "/account/reset-password/", domain=warehouse ), + pretend.call( + "accounts.confirm-login", "/account/confirm-login/", domain=warehouse + ), pretend.call( "accounts.verify-email", "/account/verify-email/", domain=warehouse ), From 4bc984d42df2acb9c50ce9b69d5e4fed44017371 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Fri, 12 Sep 2025 16:19:55 +0000 Subject: [PATCH 04/16] Basic admin interface --- .../admin/templates/admin/users/detail.html | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/warehouse/admin/templates/admin/users/detail.html b/warehouse/admin/templates/admin/users/detail.html index a13e40673350..08afbf23cfc3 100644 --- a/warehouse/admin/templates/admin/users/detail.html +++ b/warehouse/admin/templates/admin/users/detail.html @@ -988,6 +988,41 @@

Pending OpenID Connect Publishers

+
+
+

Unique logins

+
+ +
+ {% if user.unique_logins %} +
+ + + + + + + + + + + {% for login in user.unique_logins %} + + + + + + + {% endfor %} + +
CreatedIP addressStatusDevice Information
{{ login.created }}{{ login.ip_address }}{{ login.status.value }}{{ login.device_information }}
+
+ {% else %} + No known logins. + {% endif %} +
+
+

Account activity

From 1b5391ac5015c7b006b3a0f8f47dfc6e8df9eca1 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Fri, 12 Sep 2025 17:03:42 +0000 Subject: [PATCH 05/16] Update translations --- warehouse/locale/messages.pot | 131 ++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 53 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 9f21b9d24ca4..21a507b49ae8 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -122,21 +122,21 @@ msgstr "" msgid "The username isn't valid. Try again." msgstr "" -#: warehouse/accounts/views.py:110 +#: warehouse/accounts/views.py:120 #, python-brace-format msgid "" "There have been too many unsuccessful login attempts. You have been " "locked out for {}. Please try again later." msgstr "" -#: warehouse/accounts/views.py:131 +#: warehouse/accounts/views.py:141 #, python-brace-format msgid "" "Too many emails have been added to this account without verifying them. " "Check your inbox and follow the verification links. (IP: ${ip})" msgstr "" -#: warehouse/accounts/views.py:143 +#: warehouse/accounts/views.py:153 #, python-brace-format msgid "" "Too many password resets have been requested for this account without " @@ -144,185 +144,210 @@ msgid "" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:375 warehouse/accounts/views.py:439 -#: warehouse/accounts/views.py:441 warehouse/accounts/views.py:470 -#: warehouse/accounts/views.py:472 warehouse/accounts/views.py:588 +#: warehouse/accounts/views.py:385 warehouse/accounts/views.py:530 +#: warehouse/accounts/views.py:532 warehouse/accounts/views.py:561 +#: warehouse/accounts/views.py:563 warehouse/accounts/views.py:679 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:433 +#: warehouse/accounts/views.py:524 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:507 +#: warehouse/accounts/views.py:598 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:615 warehouse/manage/views/__init__.py:871 +#: warehouse/accounts/views.py:706 warehouse/manage/views/__init__.py:871 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:707 +#: warehouse/accounts/views.py:798 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:876 +#: warehouse/accounts/views.py:967 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:878 +#: warehouse/accounts/views.py:969 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:880 warehouse/accounts/views.py:981 -#: warehouse/accounts/views.py:1087 warehouse/accounts/views.py:1256 +#: warehouse/accounts/views.py:971 warehouse/accounts/views.py:1080 +#: warehouse/accounts/views.py:1153 warehouse/accounts/views.py:1259 +#: warehouse/accounts/views.py:1428 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:884 +#: warehouse/accounts/views.py:975 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:889 +#: warehouse/accounts/views.py:980 warehouse/accounts/views.py:1089 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:900 +#: warehouse/accounts/views.py:991 warehouse/accounts/views.py:1100 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:918 +#: warehouse/accounts/views.py:1009 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:949 +#: warehouse/accounts/views.py:1040 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:977 +#: warehouse/accounts/views.py:1076 +msgid "Expired token: please try to login again" +msgstr "" + +#: warehouse/accounts/views.py:1078 +msgid "Invalid token: please try to login again" +msgstr "" + +#: warehouse/accounts/views.py:1084 +msgid "Invalid token: not a login confirmation token" +msgstr "" + +#: warehouse/accounts/views.py:1112 +msgid "Invalid login attempt." +msgstr "" + +#: warehouse/accounts/views.py:1115 +msgid "Device details didn't match, please try again" +msgstr "" + +#: warehouse/accounts/views.py:1123 +msgid "Your login has been confirmed and this device is now recognized." +msgstr "" + +#: warehouse/accounts/views.py:1149 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:979 +#: warehouse/accounts/views.py:1151 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:985 +#: warehouse/accounts/views.py:1157 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:994 +#: warehouse/accounts/views.py:1166 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:997 +#: warehouse/accounts/views.py:1169 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:1017 +#: warehouse/accounts/views.py:1189 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:1020 +#: warehouse/accounts/views.py:1192 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:1026 +#: warehouse/accounts/views.py:1198 #, python-brace-format msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1083 +#: warehouse/accounts/views.py:1255 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1085 +#: warehouse/accounts/views.py:1257 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1091 +#: warehouse/accounts/views.py:1263 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1095 +#: warehouse/accounts/views.py:1267 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1104 +#: warehouse/accounts/views.py:1276 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1156 +#: warehouse/accounts/views.py:1328 #, python-brace-format msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1219 +#: warehouse/accounts/views.py:1391 #, python-brace-format msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1252 +#: warehouse/accounts/views.py:1424 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1254 +#: warehouse/accounts/views.py:1426 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1260 +#: warehouse/accounts/views.py:1432 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1264 +#: warehouse/accounts/views.py:1436 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1279 +#: warehouse/accounts/views.py:1451 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1311 +#: warehouse/accounts/views.py:1483 #, python-brace-format msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1377 +#: warehouse/accounts/views.py:1549 #, python-brace-format msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1457 +#: warehouse/accounts/views.py:1653 #, python-brace-format msgid "Please review our updated Terms of Service." msgstr "" -#: warehouse/accounts/views.py:1669 warehouse/accounts/views.py:1922 +#: warehouse/accounts/views.py:1865 warehouse/accounts/views.py:2118 #: warehouse/manage/views/__init__.py:1409 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1690 +#: warehouse/accounts/views.py:1886 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1706 +#: warehouse/accounts/views.py:1902 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:1719 +#: warehouse/accounts/views.py:1915 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1734 warehouse/manage/views/__init__.py:1590 +#: warehouse/accounts/views.py:1930 warehouse/manage/views/__init__.py:1590 #: warehouse/manage/views/__init__.py:1705 #: warehouse/manage/views/__init__.py:1819 #: warehouse/manage/views/__init__.py:1931 @@ -331,29 +356,29 @@ msgid "" "again later." msgstr "" -#: warehouse/accounts/views.py:1744 warehouse/manage/views/__init__.py:1603 +#: warehouse/accounts/views.py:1940 warehouse/manage/views/__init__.py:1603 #: warehouse/manage/views/__init__.py:1718 #: warehouse/manage/views/__init__.py:1832 #: warehouse/manage/views/__init__.py:1944 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:1759 +#: warehouse/accounts/views.py:1955 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:1793 +#: warehouse/accounts/views.py:1989 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:1935 warehouse/accounts/views.py:1948 -#: warehouse/accounts/views.py:1955 +#: warehouse/accounts/views.py:2131 warehouse/accounts/views.py:2144 +#: warehouse/accounts/views.py:2151 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:1962 +#: warehouse/accounts/views.py:2158 msgid "Removed trusted publisher for project " msgstr "" From d22f50bf84f5071ef26a9d1851b40044e9531da1 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Fri, 12 Sep 2025 17:25:06 +0000 Subject: [PATCH 06/16] Update migration to match model --- .../versions/4c20f2342bba_add_useruniquelogin.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py index 5b297e249fa0..7c126da62461 100755 --- a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py +++ b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py @@ -17,9 +17,7 @@ def upgrade(): - sa.Enum("pending", "confirmed", "expired", name="uniqueloginstatus").create( - op.get_bind() - ) + sa.Enum("pending", "confirmed", name="uniqueloginstatus").create(op.get_bind()) op.create_table( "user_unique_logins", sa.Column("user_id", sa.UUID(), nullable=False), @@ -35,7 +33,6 @@ def upgrade(): postgresql.ENUM( "pending", "confirmed", - "expired", name="uniqueloginstatus", create_type=False, ), @@ -66,6 +63,4 @@ def downgrade(): op.f("ix_user_unique_logins_user_id"), table_name="user_unique_logins" ) op.drop_table("user_unique_logins") - sa.Enum("pending", "confirmed", "expired", name="uniqueloginstatus").drop( - op.get_bind() - ) + sa.Enum("pending", "confirmed", name="uniqueloginstatus").drop(op.get_bind()) From 7c698be2453cb84d476e54cb4fefc9812637cec0 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 18 Sep 2025 15:54:35 -0400 Subject: [PATCH 07/16] Update warehouse/templates/email/unrecognized-login/body.txt --- warehouse/templates/email/unrecognized-login/body.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/warehouse/templates/email/unrecognized-login/body.txt b/warehouse/templates/email/unrecognized-login/body.txt index a5fe4fea3a79..05133711ebb9 100644 --- a/warehouse/templates/email/unrecognized-login/body.txt +++ b/warehouse/templates/email/unrecognized-login/body.txt @@ -1,3 +1,4 @@ +{# SPDX-License-Identifier: Apache-2.0 -#} A login attempt was made from an unrecognized device. To complete your login, please visit the following link: From 9f16701429579e6e2347e193249543472d52eebb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 23 Sep 2025 19:21:49 +0000 Subject: [PATCH 08/16] Handle recovery code usage too --- tests/unit/accounts/test_views.py | 255 ++++++++++++++++++++++++++++-- warehouse/accounts/views.py | 121 +++++++++++--- warehouse/locale/messages.pot | 120 +++++++------- 3 files changed, 406 insertions(+), 90 deletions(-) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 918b37c60dbb..25efa60fe0dd 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -1836,11 +1836,20 @@ def test_get_returns_form(self, pyramid_request): ] @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) - def test_recovery_code_auth(self, monkeypatch, db_request, redirect_url): + def test_recovery_code_auth_with_confirmed_unique_login( + self, monkeypatch, db_request, redirect_url + ): remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) monkeypatch.setattr(views, "remember", remember) - user_id = uuid.uuid4() + user = UserFactory.create( + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} if redirect_url: query_params["redirect_to"] = redirect_url @@ -1854,13 +1863,6 @@ def test_recovery_code_auth(self, monkeypatch, db_request, redirect_url): ) ) - user = pretend.stub( - id=user_id, - last_login=( - datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) - ), - record_event=pretend.call_recorder(lambda *a, **kw: None), - ) user_service = pretend.stub( find_userid=pretend.call_recorder(lambda username: user_id), get_user=pretend.call_recorder(lambda userid: user), @@ -1899,11 +1901,18 @@ def test_recovery_code_auth(self, monkeypatch, db_request, redirect_url): validate=pretend.call_recorder(lambda: True), recovery_code_value=pretend.stub(data="recovery-code"), ) - form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) db_request.route_path = pretend.call_recorder(lambda a: "/account/two-factor") db_request.params = pretend.stub( get=pretend.call_recorder(lambda k: query_params.get(k)) ) + + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.CONFIRMED, + ) + result = views.recovery_code(db_request, _form_class=form_class) token_expected_data = {"userid": str(user_id)} @@ -1938,6 +1947,232 @@ def test_recovery_code_auth(self, monkeypatch, db_request, redirect_url): ] assert db_request.session.record_auth_timestamp.calls == [pretend.call()] + def test_recovery_code_auth_no_unique_login( + self, monkeypatch, db_request, make_email_renderers + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + user = UserFactory.create( + with_verified_primary_email=True, + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + confirm_login_token_service = pretend.stub( + dumps=pretend.call_recorder(lambda d: "fake_token") + ) + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + get_user=pretend.call_recorder(lambda userid: user), + check_recovery_code=lambda userid, recovery_code_value: True, + ) + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "confirm_login": confirm_login_token_service, + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + db_request.headers["User-Agent"] = ( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 " + "Firefox/15.0.1" + ) + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + recovery_code_value=pretend.stub(data="recovery-code"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.recovery_code(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [ + pretend.call( + db_request, + user, + ip_address=db_request.remote_addr, + user_agent="Firefox (Ubuntu)", + token="fake_token", + ) + ] + + @pytest.mark.parametrize("ua_string", [None, "no bueno", "Python-urllib/3.7"]) + def test_recovery_code_auth_no_unique_login_bad_user_agent( + self, monkeypatch, db_request, make_email_renderers, ua_string + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + user = UserFactory.create( + with_verified_primary_email=True, + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + confirm_login_token_service = pretend.stub( + dumps=pretend.call_recorder(lambda d: "fake_token") + ) + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + get_user=pretend.call_recorder(lambda userid: user), + check_recovery_code=lambda userid, recovery_code_value: True, + ) + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "confirm_login": confirm_login_token_service, + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + if ua_string: + db_request.headers["User-Agent"] = ua_string + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + recovery_code_value=pretend.stub(data="recovery-code"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.recovery_code(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [ + pretend.call( + db_request, + user, + ip_address=db_request.remote_addr, + user_agent="Unknown User-Agent", + token="fake_token", + ) + ] + + def test_recovery_code_auth_with_pending_unique_login( + self, monkeypatch, db_request, make_email_renderers + ): + make_email_renderers("unrecognized-login") + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + user = UserFactory.create( + with_verified_primary_email=True, + last_login=( + datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) + ), + ) + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + user_id = user.id + query_params = {"userid": str(user_id)} + + two_factor_token_service = pretend.stub( + loads=pretend.call_recorder( + lambda *args, **kwargs: ( + query_params, + datetime.datetime.now(datetime.UTC), + ) + ) + ) + user_service = pretend.stub( + get_user=pretend.call_recorder(lambda userid: user), + check_recovery_code=lambda userid, recovery_code_value: True, + ) + + db_request.find_service = lambda interface, name=None, **kwargs: { + ITokenService: { + "two_factor": two_factor_token_service, + None: two_factor_token_service, + }, + IUserService: { + None: user_service, + }, + }[interface][name] + + db_request.method = "POST" + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + recovery_code_value=pretend.stub(data="recovery-code"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + db_request.route_path = pretend.call_recorder( + lambda a: "/account/confirm-login" + ) + db_request.params = pretend.stub( + get=pretend.call_recorder(lambda k: query_params.get(k)) + ) + + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.PENDING, + ) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + + result = views.recovery_code(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/account/confirm-login" + assert send_email.calls == [] + def test_recovery_code_form_invalid(self): token_data = {"userid": 1} token_service = pretend.stub( diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 45d1985da09c..c83b3b5c839a 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -111,6 +111,7 @@ USER_ID_INSECURE_COOKIE = "user_id__insecure" REMEMBER_DEVICE_COOKIE = "remember_device" +PHISHABLE_METHODS = {"totp", "recovery-code"} @view_config(context=TooManyFailedLogins, has_translations=True) @@ -681,6 +682,7 @@ def recovery_code(request, _form_class=RecoveryCodeAuthenticationForm): return HTTPSeeOther(request.route_path("accounts.login")) userid = two_factor_data.get("userid") + redirect_to = two_factor_data.get("redirect_to") user_service = request.find_service(IUserService, context=None) @@ -690,25 +692,101 @@ def recovery_code(request, _form_class=RecoveryCodeAuthenticationForm): if request.method == "POST": if form.validate(): - _login_user(request, userid, two_factor_method="recovery-code") - - resp = HTTPSeeOther(request.route_path("manage.account")) - _set_userid_insecure_cookie(resp, userid) - user = user_service.get_user(userid) - user.record_event( - tag=EventTag.Account.RecoveryCodesUsed, - request=request, - ) - request.session.flash( - request._( - "Recovery code accepted. The supplied code cannot be used again." - ), - queue="success", + unique_login = ( + request.db.query(UserUniqueLogin) + .filter( + UserUniqueLogin.user_id == userid, + UserUniqueLogin.ip_address == request.remote_addr, + ) + .one_or_none() ) - return resp + if unique_login: + if unique_login.status == UniqueLoginStatus.CONFIRMED: + # We've seen this device before for this user and they've + # confirmed it, log in the user + _login_user(request, userid, two_factor_method="recovery-code") + + user.record_event( + tag=EventTag.Account.RecoveryCodesUsed, + request=request, + ) + + request.session.flash( + request._( + "Recovery code accepted. " + "The supplied code cannot be used again." + ), + queue="success", + ) + + resp = HTTPSeeOther(redirect_to) + _set_userid_insecure_cookie(resp, userid) + + return resp + else: + # We've seen this device before for this user but they haven't + # confirmed it, don't send another email, just send them to + # the generic page + return HTTPSeeOther(request.route_path("accounts.confirm-login")) + else: + # We haven't seen this device before from this user or they + # haven't confirmed it, make them confirm it + unique_login = UserUniqueLogin( + user_id=userid, + ip_address=request.remote_addr, + status=UniqueLoginStatus.PENDING, + ) + request.db.add(unique_login) + request.db.flush() # To get the ID for the token + + token_service = request.find_service( + ITokenService, name="confirm_login" + ) + token = token_service.dumps( + { + "action": "login-confirmation", + "user.id": str(user.id), + "user.last_login": str( + user.last_login + or datetime.datetime.min.replace(tzinfo=pytz.UTC) + ), + "unique_login_id": unique_login.id, + } + ) + + # Get User Agent Information + user_agent_info_data = {} + if user_agent_str := request.headers.get("User-Agent"): + try: + parsed = linehaul_user_agent_parser.parse(user_agent_str) + if ( + parsed + and parsed.installer + and parsed.installer.name == "Browser" + ): + parsed_ua = user_agent_parser.Parse(user_agent_str) + user_agent_info_data = { + "installer": "Browser", + "device": parsed_ua["device"]["family"], + "os": parsed_ua["os"]["family"], + "user_agent": parsed_ua["user_agent"]["family"], + } + except linehaul_user_agent_parser.UnknownUserAgentError: + pass # Fallback to default empty dict + + user_agent_info = UserAgentInfo(**user_agent_info_data) + + send_unrecognized_login_email( + request, + user, + ip_address=request.remote_addr, + user_agent=user_agent_info.display(), + token=token, + ) + return HTTPSeeOther(request.route_path("accounts.confirm-login")) else: form.recovery_code_value.data = "" @@ -1624,18 +1702,21 @@ def _login_user(request, userid, two_factor_method=None, two_factor_label=None): ) .one_or_none() ) - if unique_login is None and two_factor_method != "totp": + if unique_login is None and two_factor_method not in PHISHABLE_METHODS: # We haven't seen this login before. Create a new one and mark it as confirmed - # if this is non-TOTP. + # if this is non-phishable. unique_login = UserUniqueLogin( user_id=userid, ip_address=request.remote_addr, status=UniqueLoginStatus.CONFIRMED, ) request.db.add(unique_login) - if unique_login.status == UniqueLoginStatus.PENDING and two_factor_method != "totp": - # The user had a pending login, but has since logged in with a non-TOTP method, - # so mark it as confirmed. + if ( + unique_login.status == UniqueLoginStatus.PENDING + and two_factor_method not in PHISHABLE_METHODS + ): + # The user had a pending login, but has since logged in with a non-phishable + # method, so mark it as confirmed. unique_login.status = UniqueLoginStatus.CONFIRMED request.session.record_auth_timestamp() diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 21a507b49ae8..a99dedb5c4e9 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -122,21 +122,21 @@ msgstr "" msgid "The username isn't valid. Try again." msgstr "" -#: warehouse/accounts/views.py:120 +#: warehouse/accounts/views.py:121 #, python-brace-format msgid "" "There have been too many unsuccessful login attempts. You have been " "locked out for {}. Please try again later." msgstr "" -#: warehouse/accounts/views.py:141 +#: warehouse/accounts/views.py:142 #, python-brace-format msgid "" "Too many emails have been added to this account without verifying them. " "Check your inbox and follow the verification links. (IP: ${ip})" msgstr "" -#: warehouse/accounts/views.py:153 +#: warehouse/accounts/views.py:154 #, python-brace-format msgid "" "Too many password resets have been requested for this account without " @@ -144,210 +144,210 @@ msgid "" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:385 warehouse/accounts/views.py:530 -#: warehouse/accounts/views.py:532 warehouse/accounts/views.py:561 -#: warehouse/accounts/views.py:563 warehouse/accounts/views.py:679 +#: warehouse/accounts/views.py:386 warehouse/accounts/views.py:531 +#: warehouse/accounts/views.py:533 warehouse/accounts/views.py:562 +#: warehouse/accounts/views.py:564 warehouse/accounts/views.py:680 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:524 +#: warehouse/accounts/views.py:525 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:598 +#: warehouse/accounts/views.py:599 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:706 warehouse/manage/views/__init__.py:871 +#: warehouse/accounts/views.py:719 warehouse/manage/views/__init__.py:871 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:798 +#: warehouse/accounts/views.py:876 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:967 +#: warehouse/accounts/views.py:1045 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:969 +#: warehouse/accounts/views.py:1047 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:971 warehouse/accounts/views.py:1080 -#: warehouse/accounts/views.py:1153 warehouse/accounts/views.py:1259 -#: warehouse/accounts/views.py:1428 +#: warehouse/accounts/views.py:1049 warehouse/accounts/views.py:1158 +#: warehouse/accounts/views.py:1231 warehouse/accounts/views.py:1337 +#: warehouse/accounts/views.py:1506 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:975 +#: warehouse/accounts/views.py:1053 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:980 warehouse/accounts/views.py:1089 +#: warehouse/accounts/views.py:1058 warehouse/accounts/views.py:1167 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:991 warehouse/accounts/views.py:1100 +#: warehouse/accounts/views.py:1069 warehouse/accounts/views.py:1178 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:1009 +#: warehouse/accounts/views.py:1087 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:1040 +#: warehouse/accounts/views.py:1118 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:1076 +#: warehouse/accounts/views.py:1154 msgid "Expired token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1078 +#: warehouse/accounts/views.py:1156 msgid "Invalid token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1084 +#: warehouse/accounts/views.py:1162 msgid "Invalid token: not a login confirmation token" msgstr "" -#: warehouse/accounts/views.py:1112 +#: warehouse/accounts/views.py:1190 msgid "Invalid login attempt." msgstr "" -#: warehouse/accounts/views.py:1115 +#: warehouse/accounts/views.py:1193 msgid "Device details didn't match, please try again" msgstr "" -#: warehouse/accounts/views.py:1123 +#: warehouse/accounts/views.py:1201 msgid "Your login has been confirmed and this device is now recognized." msgstr "" -#: warehouse/accounts/views.py:1149 +#: warehouse/accounts/views.py:1227 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1151 +#: warehouse/accounts/views.py:1229 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1157 +#: warehouse/accounts/views.py:1235 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:1166 +#: warehouse/accounts/views.py:1244 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:1169 +#: warehouse/accounts/views.py:1247 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:1189 +#: warehouse/accounts/views.py:1267 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:1192 +#: warehouse/accounts/views.py:1270 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:1198 +#: warehouse/accounts/views.py:1276 #, python-brace-format msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1255 +#: warehouse/accounts/views.py:1333 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1257 +#: warehouse/accounts/views.py:1335 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1263 +#: warehouse/accounts/views.py:1341 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1267 +#: warehouse/accounts/views.py:1345 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1276 +#: warehouse/accounts/views.py:1354 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1328 +#: warehouse/accounts/views.py:1406 #, python-brace-format msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1391 +#: warehouse/accounts/views.py:1469 #, python-brace-format msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1424 +#: warehouse/accounts/views.py:1502 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1426 +#: warehouse/accounts/views.py:1504 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1432 +#: warehouse/accounts/views.py:1510 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1436 +#: warehouse/accounts/views.py:1514 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1451 +#: warehouse/accounts/views.py:1529 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1483 +#: warehouse/accounts/views.py:1561 #, python-brace-format msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1549 +#: warehouse/accounts/views.py:1627 #, python-brace-format msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1653 +#: warehouse/accounts/views.py:1734 #, python-brace-format msgid "Please review our updated Terms of Service." msgstr "" -#: warehouse/accounts/views.py:1865 warehouse/accounts/views.py:2118 +#: warehouse/accounts/views.py:1946 warehouse/accounts/views.py:2199 #: warehouse/manage/views/__init__.py:1409 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1886 +#: warehouse/accounts/views.py:1967 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1902 +#: warehouse/accounts/views.py:1983 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:1915 +#: warehouse/accounts/views.py:1996 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1930 warehouse/manage/views/__init__.py:1590 +#: warehouse/accounts/views.py:2011 warehouse/manage/views/__init__.py:1590 #: warehouse/manage/views/__init__.py:1705 #: warehouse/manage/views/__init__.py:1819 #: warehouse/manage/views/__init__.py:1931 @@ -356,29 +356,29 @@ msgid "" "again later." msgstr "" -#: warehouse/accounts/views.py:1940 warehouse/manage/views/__init__.py:1603 +#: warehouse/accounts/views.py:2021 warehouse/manage/views/__init__.py:1603 #: warehouse/manage/views/__init__.py:1718 #: warehouse/manage/views/__init__.py:1832 #: warehouse/manage/views/__init__.py:1944 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:1955 +#: warehouse/accounts/views.py:2036 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:1989 +#: warehouse/accounts/views.py:2070 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:2131 warehouse/accounts/views.py:2144 -#: warehouse/accounts/views.py:2151 +#: warehouse/accounts/views.py:2212 warehouse/accounts/views.py:2225 +#: warehouse/accounts/views.py:2232 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:2158 +#: warehouse/accounts/views.py:2239 msgid "Removed trusted publisher for project " msgstr "" From b0bcc60a46d4dd46529ea37bb24bb0881462f4b9 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 23 Sep 2025 19:30:35 +0000 Subject: [PATCH 09/16] Add a composite index --- warehouse/accounts/models.py | 6 ++++++ .../versions/4c20f2342bba_add_useruniquelogin.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 7bfbe1a6130c..1ae1f1a08a0b 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -493,6 +493,12 @@ class UserUniqueLogin(db.Model): UniqueConstraint( "user_id", "ip_address", name="_user_unique_logins_user_id_ip_address_uc" ), + Index( + "user_unique_logins_user_id_ip_address_idx", + "user_id", + "ip_address", + unique=True, + ), ) user_id: Mapped[UUID] = mapped_column( diff --git a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py index 7c126da62461..3d495c5dfb78 100755 --- a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py +++ b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py @@ -56,11 +56,20 @@ def upgrade(): ["user_id"], unique=False, ) + op.create_index( + "user_unique_logins_user_id_ip_address_idx", + "user_unique_logins", + ["user_id", "ip_address"], + unique=True, + ) def downgrade(): op.drop_index( op.f("ix_user_unique_logins_user_id"), table_name="user_unique_logins" ) + op.drop_index( + "user_unique_logins_user_id_ip_address_idx", table_name="user_unique_logins" + ) op.drop_table("user_unique_logins") sa.Enum("pending", "confirmed", name="uniqueloginstatus").drop(op.get_bind()) From b625aa9343c8e31814d58b7479fdeac2603c5a2c Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 23 Sep 2025 19:31:09 +0000 Subject: [PATCH 10/16] Update error message --- warehouse/accounts/views.py | 7 ++- warehouse/locale/messages.pot | 80 ++++++++++++++++++----------------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index c83b3b5c839a..7754e881edf6 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -1190,7 +1190,12 @@ def _error(message): return _error(request._("Invalid login attempt.")) if unique_login.ip_address != request.remote_addr: - return _error(request._("Device details didn't match, please try again")) + return _error( + request._( + "Device details didn't match, please try again from the device " + "you originally used to log in." + ) + ) unique_login.status = UniqueLoginStatus.CONFIRMED diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index a99dedb5c4e9..3d81e31fcdad 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -177,8 +177,8 @@ msgid "Invalid token: request a new password reset link" msgstr "" #: warehouse/accounts/views.py:1049 warehouse/accounts/views.py:1158 -#: warehouse/accounts/views.py:1231 warehouse/accounts/views.py:1337 -#: warehouse/accounts/views.py:1506 +#: warehouse/accounts/views.py:1236 warehouse/accounts/views.py:1342 +#: warehouse/accounts/views.py:1511 msgid "Invalid token: no token supplied" msgstr "" @@ -220,134 +220,136 @@ msgstr "" msgid "Invalid login attempt." msgstr "" -#: warehouse/accounts/views.py:1193 -msgid "Device details didn't match, please try again" +#: warehouse/accounts/views.py:1195 +msgid "" +"Device details didn't match, please try again from the device you " +"originally used to log in." msgstr "" -#: warehouse/accounts/views.py:1201 +#: warehouse/accounts/views.py:1206 msgid "Your login has been confirmed and this device is now recognized." msgstr "" -#: warehouse/accounts/views.py:1227 +#: warehouse/accounts/views.py:1232 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1229 +#: warehouse/accounts/views.py:1234 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1235 +#: warehouse/accounts/views.py:1240 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:1244 +#: warehouse/accounts/views.py:1249 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:1247 +#: warehouse/accounts/views.py:1252 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:1267 +#: warehouse/accounts/views.py:1272 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:1270 +#: warehouse/accounts/views.py:1275 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:1276 +#: warehouse/accounts/views.py:1281 #, python-brace-format msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1333 +#: warehouse/accounts/views.py:1338 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1335 +#: warehouse/accounts/views.py:1340 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1341 +#: warehouse/accounts/views.py:1346 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1345 +#: warehouse/accounts/views.py:1350 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1354 +#: warehouse/accounts/views.py:1359 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1406 +#: warehouse/accounts/views.py:1411 #, python-brace-format msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1469 +#: warehouse/accounts/views.py:1474 #, python-brace-format msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1502 +#: warehouse/accounts/views.py:1507 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1504 +#: warehouse/accounts/views.py:1509 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1510 +#: warehouse/accounts/views.py:1515 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1514 +#: warehouse/accounts/views.py:1519 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1529 +#: warehouse/accounts/views.py:1534 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1561 +#: warehouse/accounts/views.py:1566 #, python-brace-format msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1627 +#: warehouse/accounts/views.py:1632 #, python-brace-format msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1734 +#: warehouse/accounts/views.py:1739 #, python-brace-format msgid "Please review our updated Terms of Service." msgstr "" -#: warehouse/accounts/views.py:1946 warehouse/accounts/views.py:2199 +#: warehouse/accounts/views.py:1951 warehouse/accounts/views.py:2204 #: warehouse/manage/views/__init__.py:1409 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1967 +#: warehouse/accounts/views.py:1972 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1983 +#: warehouse/accounts/views.py:1988 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:1996 +#: warehouse/accounts/views.py:2001 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:2011 warehouse/manage/views/__init__.py:1590 +#: warehouse/accounts/views.py:2016 warehouse/manage/views/__init__.py:1590 #: warehouse/manage/views/__init__.py:1705 #: warehouse/manage/views/__init__.py:1819 #: warehouse/manage/views/__init__.py:1931 @@ -356,29 +358,29 @@ msgid "" "again later." msgstr "" -#: warehouse/accounts/views.py:2021 warehouse/manage/views/__init__.py:1603 +#: warehouse/accounts/views.py:2026 warehouse/manage/views/__init__.py:1603 #: warehouse/manage/views/__init__.py:1718 #: warehouse/manage/views/__init__.py:1832 #: warehouse/manage/views/__init__.py:1944 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:2036 +#: warehouse/accounts/views.py:2041 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:2070 +#: warehouse/accounts/views.py:2075 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:2212 warehouse/accounts/views.py:2225 -#: warehouse/accounts/views.py:2232 +#: warehouse/accounts/views.py:2217 warehouse/accounts/views.py:2230 +#: warehouse/accounts/views.py:2237 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:2239 +#: warehouse/accounts/views.py:2244 msgid "Removed trusted publisher for project " msgstr "" From 32c6d331fba1e5a1e9011fc169c193c63de9cf80 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Wed, 24 Sep 2025 00:10:03 +0000 Subject: [PATCH 11/16] Update test --- tests/unit/accounts/test_views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 25efa60fe0dd..4fc1ea0456a4 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -5960,7 +5960,11 @@ def test_ip_address_mismatch(self, db_request): assert isinstance(result, HTTPSeeOther) assert result.location == "/accounts.login" assert db_request.session.flash.calls == [ - pretend.call("Device details didn't match, please try again", queue="error") + pretend.call( + "Device details didn't match, please try again from the device you " + "originally used to log in.", + queue="error", + ) ] def test_success(self, monkeypatch, db_request): From 14a7b4b69c31d8c51e8aae8386f3ba777596ce64 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Fri, 3 Oct 2025 16:46:57 +0000 Subject: [PATCH 12/16] Add UserUniqueLogin.last_used --- tests/unit/accounts/test_views.py | 56 +++++++++++++++++++ warehouse/accounts/models.py | 1 + warehouse/accounts/views.py | 9 ++- .../4c20f2342bba_add_useruniquelogin.py | 6 ++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 4fc1ea0456a4..774a8efbad46 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -672,6 +672,62 @@ def test_login_with_remembered_device_confirms_unique_login( ) assert unique_login.status == UniqueLoginStatus.CONFIRMED + def test_login_updates_last_used(self, monkeypatch, db_request, pyramid_services): + remember = pretend.call_recorder(lambda request, user_id: [("foo", "bar")]) + monkeypatch.setattr(views, "remember", remember) + + user = UserFactory.create() + user.record_event = pretend.call_recorder(lambda *a, **kw: None) + + user_service = pretend.stub( + find_userid=pretend.call_recorder(lambda username: user.id), + update_user=pretend.call_recorder(lambda *a, **kw: None), + get_user=pretend.call_recorder(lambda userid: user), + has_two_factor=lambda userid: False, + get_password_timestamp=lambda userid: 0, + needs_tos_flash=lambda userid, revision: False, + ) + breach_service = pretend.stub(check_password=lambda password, tags=None: False) + + pyramid_services.register_service(user_service, IUserService, None) + pyramid_services.register_service( + breach_service, IPasswordBreachedService, None + ) + + # Create a unique login with a timestamp in the distant past. + past_timestamp = datetime.datetime(1970, 1, 1) + UserUniqueLoginFactory.create( + user=user, + ip_address=db_request.remote_addr, + status=UniqueLoginStatus.CONFIRMED, + last_used=past_timestamp, + ) + + db_request.method = "POST" + db_request.session = pretend.stub( + items=lambda: [], + update=lambda d: None, + invalidate=pretend.call_recorder(lambda: None), + new_csrf_token=pretend.call_recorder(lambda: None), + record_auth_timestamp=pretend.call_recorder(lambda: None), + record_password_timestamp=lambda ts: None, + ) + db_request.registry.settings = {"sessions.secret": "dummy_secret"} + + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + username=pretend.stub(data=user.username), + password=pretend.stub(data="password"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + db_request.route_path = pretend.call_recorder(lambda a: "/the-redirect") + + # Simulate the login. + views.login(db_request, _form_class=form_class) + + unique_login = db_request.db.query(UserUniqueLogin).one() + assert unique_login.last_used > past_timestamp + class TestTwoFactor: def test_get_two_factor_data_invalid_after_login(self, pyramid_request): diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 1ae1f1a08a0b..0834d8c8ba1d 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -511,6 +511,7 @@ class UserUniqueLogin(db.Model): ip_address: Mapped[str] = mapped_column(String, nullable=False) created: Mapped[datetime_now] + last_used: Mapped[datetime_now] device_information: Mapped[dict | None] = mapped_column(JSONB, nullable=True) status: Mapped[UniqueLoginStatus] = mapped_column( Enum(UniqueLoginStatus, values_callable=lambda x: [e.value for e in x]), diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 7754e881edf6..4ac7698f3d2f 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -1687,7 +1687,8 @@ def _login_user(request, userid, two_factor_method=None, two_factor_label=None): # Whenever we log in the user, we want to update their user so that it # records when the last login was. user_service = request.find_service(IUserService, context=None) - user_service.update_user(userid, last_login=datetime.datetime.now(datetime.UTC)) + now = datetime.datetime.now(datetime.UTC) + user_service.update_user(userid, last_login=now) user = user_service.get_user(userid) user.record_event( tag=EventTag.Account.LoginSuccess, @@ -1707,6 +1708,9 @@ def _login_user(request, userid, two_factor_method=None, two_factor_label=None): ) .one_or_none() ) + if unique_login: + unique_login.last_used = now + if unique_login is None and two_factor_method not in PHISHABLE_METHODS: # We haven't seen this login before. Create a new one and mark it as confirmed # if this is non-phishable. @@ -1717,7 +1721,8 @@ def _login_user(request, userid, two_factor_method=None, two_factor_label=None): ) request.db.add(unique_login) if ( - unique_login.status == UniqueLoginStatus.PENDING + unique_login is not None + and unique_login.status == UniqueLoginStatus.PENDING and two_factor_method not in PHISHABLE_METHODS ): # The user had a pending login, but has since logged in with a non-phishable diff --git a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py index 3d495c5dfb78..e3bbc56d0a02 100755 --- a/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py +++ b/warehouse/migrations/versions/4c20f2342bba_add_useruniquelogin.py @@ -25,6 +25,12 @@ def upgrade(): sa.Column( "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False ), + sa.Column( + "last_used", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column( "device_information", postgresql.JSONB(astext_type=sa.Text()), nullable=True ), From 7353e69a4adc1193de8b2c3241a4b9896d73717b Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 6 Oct 2025 19:00:16 +0000 Subject: [PATCH 13/16] Fix HTML email template --- warehouse/templates/email/unrecognized-login/body.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/templates/email/unrecognized-login/body.html b/warehouse/templates/email/unrecognized-login/body.html index 20ec4d9bbe52..cd3fbe199dcf 100644 --- a/warehouse/templates/email/unrecognized-login/body.html +++ b/warehouse/templates/email/unrecognized-login/body.html @@ -1,5 +1,5 @@ {# SPDX-License-Identifier: Apache-2.0 -#} -{% extends "email/base.html" %} +{% 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:

From 9740bf428e6e717f922eae82848cdc94be2f1703 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 7 Oct 2025 14:14:53 +0000 Subject: [PATCH 14/16] Fall back to raw user agent string --- tests/unit/accounts/test_views.py | 4 ++-- warehouse/accounts/views.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 774a8efbad46..6f45e3726ceb 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -1333,7 +1333,7 @@ def test_totp_auth_no_unique_login_bad_user_agent( db_request, user, ip_address=db_request.remote_addr, - user_agent="Unknown User-Agent", + user_agent=ua_string or "Unknown User-Agent", token="fake_token", ) ] @@ -2155,7 +2155,7 @@ def test_recovery_code_auth_no_unique_login_bad_user_agent( db_request, user, ip_address=db_request.remote_addr, - user_agent="Unknown User-Agent", + user_agent=ua_string or "Unknown User-Agent", token="fake_token", ) ] diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 4ac7698f3d2f..f942e587d946 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -480,6 +480,10 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): # Get User Agent Information user_agent_info_data = {} if user_agent_str := request.headers.get("User-Agent"): + user_agent_info_data = { + # A hack to get it to fall back to the raw user agent + "installer": user_agent_str, + } try: parsed = linehaul_user_agent_parser.parse(user_agent_str) if ( @@ -495,7 +499,7 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): "user_agent": parsed_ua["user_agent"]["family"], } except linehaul_user_agent_parser.UnknownUserAgentError: - pass # Fallback to default empty dict + pass # Fallback to raw user-agent string user_agent_info = UserAgentInfo(**user_agent_info_data) @@ -760,6 +764,10 @@ def recovery_code(request, _form_class=RecoveryCodeAuthenticationForm): # Get User Agent Information user_agent_info_data = {} if user_agent_str := request.headers.get("User-Agent"): + user_agent_info_data = { + # A hack to get it to fall back to the raw user agent + "installer": user_agent_str, + } try: parsed = linehaul_user_agent_parser.parse(user_agent_str) if ( @@ -775,7 +783,7 @@ def recovery_code(request, _form_class=RecoveryCodeAuthenticationForm): "user_agent": parsed_ua["user_agent"]["family"], } except linehaul_user_agent_parser.UnknownUserAgentError: - pass # Fallback to default empty dict + pass # Fallback to raw user-agent string user_agent_info = UserAgentInfo(**user_agent_info_data) From 464bbd758f486932f2ded701d3fda2064b5f255f Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 7 Oct 2025 14:15:21 +0000 Subject: [PATCH 15/16] Update translations --- warehouse/locale/messages.pot | 114 +++++++++++++++++----------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 3d81e31fcdad..cfe3a4c32171 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -144,212 +144,212 @@ msgid "" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:386 warehouse/accounts/views.py:531 -#: warehouse/accounts/views.py:533 warehouse/accounts/views.py:562 -#: warehouse/accounts/views.py:564 warehouse/accounts/views.py:680 +#: warehouse/accounts/views.py:386 warehouse/accounts/views.py:535 +#: warehouse/accounts/views.py:537 warehouse/accounts/views.py:566 +#: warehouse/accounts/views.py:568 warehouse/accounts/views.py:684 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:525 +#: warehouse/accounts/views.py:529 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:599 +#: warehouse/accounts/views.py:603 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:719 warehouse/manage/views/__init__.py:871 +#: warehouse/accounts/views.py:723 warehouse/manage/views/__init__.py:871 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:876 +#: warehouse/accounts/views.py:884 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1045 +#: warehouse/accounts/views.py:1053 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:1047 +#: warehouse/accounts/views.py:1055 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:1049 warehouse/accounts/views.py:1158 -#: warehouse/accounts/views.py:1236 warehouse/accounts/views.py:1342 -#: warehouse/accounts/views.py:1511 +#: warehouse/accounts/views.py:1057 warehouse/accounts/views.py:1166 +#: warehouse/accounts/views.py:1244 warehouse/accounts/views.py:1350 +#: warehouse/accounts/views.py:1519 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:1053 +#: warehouse/accounts/views.py:1061 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:1058 warehouse/accounts/views.py:1167 +#: warehouse/accounts/views.py:1066 warehouse/accounts/views.py:1175 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:1069 warehouse/accounts/views.py:1178 +#: warehouse/accounts/views.py:1077 warehouse/accounts/views.py:1186 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:1087 +#: warehouse/accounts/views.py:1095 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:1118 +#: warehouse/accounts/views.py:1126 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:1154 +#: warehouse/accounts/views.py:1162 msgid "Expired token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1156 +#: warehouse/accounts/views.py:1164 msgid "Invalid token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1162 +#: warehouse/accounts/views.py:1170 msgid "Invalid token: not a login confirmation token" msgstr "" -#: warehouse/accounts/views.py:1190 +#: warehouse/accounts/views.py:1198 msgid "Invalid login attempt." msgstr "" -#: warehouse/accounts/views.py:1195 +#: warehouse/accounts/views.py:1203 msgid "" "Device details didn't match, please try again from the device you " "originally used to log in." msgstr "" -#: warehouse/accounts/views.py:1206 +#: warehouse/accounts/views.py:1214 msgid "Your login has been confirmed and this device is now recognized." msgstr "" -#: warehouse/accounts/views.py:1232 +#: warehouse/accounts/views.py:1240 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1234 +#: warehouse/accounts/views.py:1242 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1240 +#: warehouse/accounts/views.py:1248 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:1249 +#: warehouse/accounts/views.py:1257 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:1252 +#: warehouse/accounts/views.py:1260 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:1272 +#: warehouse/accounts/views.py:1280 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:1275 +#: warehouse/accounts/views.py:1283 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:1281 +#: warehouse/accounts/views.py:1289 #, python-brace-format msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1338 +#: warehouse/accounts/views.py:1346 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1340 +#: warehouse/accounts/views.py:1348 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1346 +#: warehouse/accounts/views.py:1354 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1350 +#: warehouse/accounts/views.py:1358 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1359 +#: warehouse/accounts/views.py:1367 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1411 +#: warehouse/accounts/views.py:1419 #, python-brace-format msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1474 +#: warehouse/accounts/views.py:1482 #, python-brace-format msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1507 +#: warehouse/accounts/views.py:1515 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1509 +#: warehouse/accounts/views.py:1517 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1515 +#: warehouse/accounts/views.py:1523 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1519 +#: warehouse/accounts/views.py:1527 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1534 +#: warehouse/accounts/views.py:1542 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1566 +#: warehouse/accounts/views.py:1574 #, python-brace-format msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1632 +#: warehouse/accounts/views.py:1640 #, python-brace-format msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1739 +#: warehouse/accounts/views.py:1752 #, python-brace-format msgid "Please review our updated Terms of Service." msgstr "" -#: warehouse/accounts/views.py:1951 warehouse/accounts/views.py:2204 +#: warehouse/accounts/views.py:1964 warehouse/accounts/views.py:2217 #: warehouse/manage/views/__init__.py:1409 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1972 +#: warehouse/accounts/views.py:1985 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1988 +#: warehouse/accounts/views.py:2001 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:2001 +#: warehouse/accounts/views.py:2014 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:2016 warehouse/manage/views/__init__.py:1590 +#: warehouse/accounts/views.py:2029 warehouse/manage/views/__init__.py:1590 #: warehouse/manage/views/__init__.py:1705 #: warehouse/manage/views/__init__.py:1819 #: warehouse/manage/views/__init__.py:1931 @@ -358,29 +358,29 @@ msgid "" "again later." msgstr "" -#: warehouse/accounts/views.py:2026 warehouse/manage/views/__init__.py:1603 +#: warehouse/accounts/views.py:2039 warehouse/manage/views/__init__.py:1603 #: warehouse/manage/views/__init__.py:1718 #: warehouse/manage/views/__init__.py:1832 #: warehouse/manage/views/__init__.py:1944 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:2041 +#: warehouse/accounts/views.py:2054 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:2075 +#: warehouse/accounts/views.py:2088 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:2217 warehouse/accounts/views.py:2230 -#: warehouse/accounts/views.py:2237 +#: warehouse/accounts/views.py:2230 warehouse/accounts/views.py:2243 +#: warehouse/accounts/views.py:2250 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:2244 +#: warehouse/accounts/views.py:2257 msgid "Removed trusted publisher for project " msgstr "" From 264e0ac17cb3fa08ebf856d80bd00ef9032f08b9 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 7 Oct 2025 14:42:09 +0000 Subject: [PATCH 16/16] Add geo data when available --- warehouse/templates/email/unrecognized-login/body.html | 3 +++ warehouse/templates/email/unrecognized-login/body.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/warehouse/templates/email/unrecognized-login/body.html b/warehouse/templates/email/unrecognized-login/body.html index cd3fbe199dcf..34de5d12acce 100644 --- a/warehouse/templates/email/unrecognized-login/body.html +++ b/warehouse/templates/email/unrecognized-login/body.html @@ -12,6 +12,9 @@
  • IP address: {{ ip_address }}
  • +
  • + Location: {{ ip_address.geo_ip or "Unknown" }} +
  • User agent: {{ user_agent }}
  • diff --git a/warehouse/templates/email/unrecognized-login/body.txt b/warehouse/templates/email/unrecognized-login/body.txt index 05133711ebb9..47e8634369fa 100644 --- a/warehouse/templates/email/unrecognized-login/body.txt +++ b/warehouse/templates/email/unrecognized-login/body.txt @@ -7,6 +7,7 @@ To complete your login, please visit the following link: This login attempt was made from: - IP address: {{ ip_address }} +- Location: {{ ip_address.geo_ip or "Unknown"}} - User agent: {{ user_agent }} If you did not make this change, you can email admin@pypi.org to