diff --git a/tests/unit/accounts/test_services.py b/tests/unit/accounts/test_services.py index 8a9ba5ae6949..b8bb334be4c3 100644 --- a/tests/unit/accounts/test_services.py +++ b/tests/unit/accounts/test_services.py @@ -48,6 +48,7 @@ EmailFactory, UserFactory, UserTermsOfServiceEngagementFactory, + UserUniqueLoginFactory, ) from ...common.db.ip_addresses import IpAddressFactory @@ -2014,3 +2015,97 @@ def test_factory(self): assert svc._http is request.http assert svc.client_id == "some_client_id" + + +class TestDeviceIsKnown: + def test_device_is_known(self, user_service): + user = UserFactory.create() + UserUniqueLoginFactory.create( + user=user, ip_address=REMOTE_ADDR, status="confirmed" + ) + request = pretend.stub( + db=user_service.db, + remote_addr=REMOTE_ADDR, + find_service=lambda *a, **kw: pretend.stub(), + ) + assert user_service.device_is_known(user.id, request) + + def test_device_is_not_known(self, user_service, monkeypatch): + user = UserFactory.create(with_verified_primary_email=True) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(services, "send_unrecognized_login_email", send_email) + token_service = pretend.stub(dumps=lambda d: "fake_token") + user_service.request = pretend.stub( + db=user_service.db, + remote_addr=REMOTE_ADDR, + headers={ + "User-Agent": ( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) " + "Gecko/20100101 Firefox/15.0.1" + ) + }, + find_service=lambda *a, **kw: token_service, + ) + + assert not user_service.device_is_known(user.id, user_service.request) + assert send_email.calls == [ + pretend.call( + user_service.request, + user, + ip_address=REMOTE_ADDR, + user_agent="Firefox (Ubuntu)", + token="fake_token", + ) + ] + + def test_device_is_pending_not_expired(self, user_service, monkeypatch): + user = UserFactory.create(with_verified_primary_email=True) + UserUniqueLoginFactory.create( + user=user, ip_address=REMOTE_ADDR, status="pending" + ) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(services, "send_unrecognized_login_email", send_email) + token_service = pretend.stub(dumps=lambda d: "fake_token") + user_service.request = pretend.stub( + db=user_service.db, + remote_addr=REMOTE_ADDR, + headers={ + "User-Agent": ( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) " + "Gecko/20100101 Firefox/15.0.1" + ) + }, + find_service=lambda *a, **kw: token_service, + ) + + assert not user_service.device_is_known(user.id, user_service.request) + assert send_email.calls == [] + + @pytest.mark.parametrize("ua_string", [None, "no bueno", "Python-urllib/3.7"]) + def test_device_is_not_known_bad_user_agent( + self, user_service, monkeypatch, ua_string + ): + user = UserFactory.create(with_verified_primary_email=True) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(services, "send_unrecognized_login_email", send_email) + token_service = pretend.stub(dumps=lambda d: "fake_token") + headers = {} + if ua_string: + headers["User-Agent"] = ua_string + user_service.request = pretend.stub( + db=user_service.db, + remote_addr=REMOTE_ADDR, + headers=headers, + find_service=lambda *a, **kw: token_service, + ) + + assert not user_service.device_is_known(user.id, user_service.request) + assert send_email.calls == [ + pretend.call( + user_service.request, + user, + ip_address=REMOTE_ADDR, + user_agent=ua_string or "Unknown User-Agent", + token="fake_token", + ) + ] diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index fe4c3e939dd7..50c453a45ad0 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -1019,6 +1019,7 @@ def test_totp_auth( check_totp_value=lambda userid, totp_value: True, get_password_timestamp=lambda userid: 0, needs_tos_flash=lambda userid, revision: False, + device_is_known=lambda *a: True, ) new_session = {} @@ -1055,12 +1056,6 @@ def test_totp_auth( ) 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) @@ -1077,373 +1072,33 @@ def test_totp_auth( assert db_request.session.new_csrf_token.calls == [pretend.call()] assert user.record_event.calls == [ pretend.call( - tag=EventTag.Account.LoginSuccess, - request=db_request, - additional={"two_factor_method": "totp", "two_factor_label": "totp"}, - ) - ] - assert db_request.session.record_auth_timestamp.calls == [pretend.call()] - assert send_email.calls == ( - [] if has_recovery_codes else [pretend.call(db_request, user)] - ) - - assert _remember_device.calls == ( - [] - if not remember_device - else [pretend.call(db_request, result, str(user.id), "totp")] - ) - - def test_totp_auth_already_authed(self): - request = pretend.stub( - identity=pretend.stub(), - route_path=pretend.call_recorder(lambda p: "redirect_to"), - ) - result = views.two_factor_and_totp_validate(request) - - assert request.route_path.calls == [pretend.call("manage.projects")] - - 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=ua_string or "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)) + tag=EventTag.Account.LoginSuccess, + request=db_request, + additional={"two_factor_method": "totp", "two_factor_label": "totp"}, + ) + ] + assert db_request.session.record_auth_timestamp.calls == [pretend.call()] + assert send_email.calls == ( + [] if has_recovery_codes else [pretend.call(db_request, user)] ) - db_request.user = user - UserUniqueLoginFactory.create( - user=user, - ip_address=db_request.remote_addr, - status=UniqueLoginStatus.PENDING, + assert _remember_device.calls == ( + [] + if not remember_device + else [pretend.call(db_request, result, str(user.id), "totp")] ) - send_email = pretend.call_recorder(lambda *a, **kw: None) - monkeypatch.setattr(views, "send_unrecognized_login_email", send_email) + def test_totp_auth_already_authed(self): + request = pretend.stub( + identity=pretend.stub(), + route_path=pretend.call_recorder(lambda p: "redirect_to"), + ) + result = views.two_factor_and_totp_validate(request) - result = views.two_factor_and_totp_validate(db_request, _form_class=form_class) + assert request.route_path.calls == [pretend.call("manage.projects")] assert isinstance(result, HTTPSeeOther) - assert result.headers["Location"] == "/account/confirm-login" - assert send_email.calls == [] + assert result.headers["Location"] == "redirect_to" def test_totp_form_invalid(self): token_data = {"userid": 1} @@ -1543,6 +1198,43 @@ def test_two_factor_token_invalid(self, pyramid_request): pretend.call("Invalid or expired two factor login.", queue="error") ] + def test_two_factor_and_totp_validate_device_not_known( + self, db_request, token_service + ): + user = UserFactory.create() + token_data = {"userid": str(user.id)} + token_service.loads = pretend.call_recorder( + lambda *args, **kwargs: ( + token_data, + datetime.datetime.now(datetime.UTC), + ) + ) + user_service = pretend.stub( + get_user=lambda userid: user, + has_totp=lambda uid: True, + has_webauthn=lambda uid: False, + has_recovery_codes=lambda uid: False, + device_is_known=lambda *a: False, + check_totp_value=lambda userid, totp_value: True, + ) + + db_request.find_service = lambda interface, **kwargs: { + ITokenService: token_service, + IUserService: user_service, + }[interface] + db_request.route_path = pretend.call_recorder( + lambda name: "/account/confirm-login/" + ) + db_request.query_string = token_service.dumps(token_data) + + db_request.registry.settings = {"remember_device.days": 30} + db_request.method = "POST" + db_request.POST = MultiDict({"totp_value": "123456"}) + result = two_factor_and_totp_validate(db_request) + + assert isinstance(result, HTTPSeeOther) + assert db_request.route_path.calls == [pretend.call("accounts.confirm-login")] + class TestWebAuthn: def test_webauthn_get_options_already_authenticated(self): @@ -1928,6 +1620,7 @@ def test_recovery_code_auth_with_confirmed_unique_login( check_recovery_code=lambda userid, recovery_code_value: True, get_password_timestamp=lambda userid: 0, needs_tos_flash=lambda userid, revision: False, + device_is_known=lambda *a: True, ) new_session = {} @@ -1964,12 +1657,6 @@ def test_recovery_code_auth_with_confirmed_unique_login( 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)} @@ -2004,232 +1691,6 @@ def test_recovery_code_auth_with_confirmed_unique_login( ] 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=ua_string or "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( @@ -2294,11 +1755,49 @@ def test_recovery_code_auth_invalid_token(self, pyramid_request): result = views.recovery_code(pyramid_request) assert isinstance(result, HTTPSeeOther) + assert pyramid_request.route_path.calls == [pretend.call("accounts.login")] assert result.headers["Location"] == "redirect_to" assert pyramid_request.session.flash.calls == [ pretend.call("Invalid or expired two factor login.", queue="error") ] + def test_recovery_code_device_not_known(self, db_request, token_service): + user = UserFactory.create() + token_data = {"userid": str(user.id)} + token_service.loads = pretend.call_recorder( + lambda *args, **kwargs: ( + token_data, + datetime.datetime.now(datetime.UTC), + ) + ) + user_service = pretend.stub( + get_user=lambda userid: user, + has_recovery_codes=lambda userid: True, + check_recovery_code=lambda userid, recovery_code_value: True, + device_is_known=lambda *a: False, + ) + + db_request.find_service = lambda interface, **kwargs: { + ITokenService: token_service, + IUserService: user_service, + }[interface] + db_request.route_path = pretend.call_recorder( + lambda name: "/account/confirm-login/" + ) + db_request.query_string = token_service.dumps(token_data) + db_request.method = "POST" + db_request.POST = MultiDict({"recovery_code_value": "test-recovery-code"}) + form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + recovery_code_value=pretend.stub(data="test-recovery-code"), + ) + form_class = pretend.call_recorder(lambda d, **kw: form_obj) + + result = views.recovery_code(db_request, _form_class=form_class) + + assert isinstance(result, HTTPSeeOther) + assert db_request.route_path.calls == [pretend.call("accounts.confirm-login")] + class TestLogout: @pytest.mark.parametrize("next_url", [None, "/foo/bar/", "/wat/"]) diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py index 37f6f6c944b5..d6959af4bac3 100644 --- a/warehouse/accounts/services.py +++ b/warehouse/accounts/services.py @@ -14,12 +14,15 @@ import urllib.parse import passlib.exc +import pytz import requests +from linehaul.ua import parser as linehaul_user_agent_parser from passlib.context import CryptContext from sqlalchemy import exists, select from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import joinedload +from ua_parser import user_agent_parser from webauthn.helpers import bytes_to_base64url from zope.interface import implementer @@ -47,10 +50,14 @@ ProhibitedUserName, RecoveryCode, TermsOfServiceEngagement, + UniqueLoginStatus, User, UserTermsOfServiceEngagement, + UserUniqueLogin, WebAuthn, ) +from warehouse.email import send_unrecognized_login_email +from warehouse.events.models import UserAgentInfo from warehouse.events.tags import EventTag from warehouse.metrics import IMetricsService from warehouse.rate_limiting import DummyRateLimiter, IRateLimiter @@ -736,6 +743,83 @@ def record_tos_engagement( ) ) + def device_is_known(self, userid, request): + user = self.get_user(userid) + token_service = request.find_service(ITokenService, name="confirm_login") + unique_login = ( + request.db.query(UserUniqueLogin) + .filter( + UserUniqueLogin.user_id == userid, + UserUniqueLogin.ip_address == request.remote_addr, + ) + .one_or_none() + ) + should_send_email = False + + # Check if we've seen this device and it's been confirmed + if unique_login and unique_login.status == UniqueLoginStatus.CONFIRMED: + return True + + # Create a new login if we haven't seen this device before + if not unique_login: + 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 + should_send_email = True + + # If we don't need to send an email, short-circuit + if not should_send_email: + return False + + # 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 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 raw user-agent string + + user_agent_info = UserAgentInfo(**user_agent_info_data) + + # Generate a token + 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, + } + ) + + # Send the email + send_unrecognized_login_email( + request, + user, + ip_address=request.remote_addr, + user_agent=user_agent_info.display(), + token=token, + ) + + return False + @implementer(ITokenService) class TokenService: diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 8f57f61bb60b..3fe094a625a3 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -8,7 +8,6 @@ import humanize import pytz -from linehaul.ua import parser as linehaul_user_agent_parser from more_itertools import first_true from psycopg.errors import UniqueViolation from pyramid.httpexceptions import ( @@ -24,7 +23,6 @@ from pyramid.view import view_config, view_defaults from sqlalchemy import and_, func, select from sqlalchemy.exc import NoResultFound -from ua_parser import user_agent_parser from webauthn.helpers import bytes_to_base64url from webob.multidict import MultiDict @@ -76,9 +74,7 @@ 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 ( @@ -414,104 +410,25 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): if request.method == "POST": form = two_factor_state["totp_form"] if form.validate(): - user = user_service.get_user(userid) + if user_service.device_is_known(userid, request): + # 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) - 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) + return resp 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"): - 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 ( - 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 raw user-agent string - - 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, - ) + # The devices is unknown, redirect to the confirm login page return HTTPSeeOther(request.route_path("accounts.confirm-login")) else: form.totp_value.data = "" @@ -698,104 +615,31 @@ def recovery_code(request, _form_class=RecoveryCodeAuthenticationForm): if request.method == "POST": if form.validate(): - user = user_service.get_user(userid) - - unique_login = ( - request.db.query(UserUniqueLogin) - .filter( - UserUniqueLogin.user_id == userid, - UserUniqueLogin.ip_address == request.remote_addr, + if user_service.device_is_known(userid, request): + # 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 = user_service.get_user(userid) + user.record_event( + tag=EventTag.Account.RecoveryCodesUsed, + request=request, ) - .one_or_none() - ) - 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.session.flash( + request._( + "Recovery code accepted. " + "The supplied code cannot be used again." + ), + queue="success", ) - 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, - } - ) + resp = HTTPSeeOther(redirect_to) + _set_userid_insecure_cookie(resp, userid) - # 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 ( - 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 raw user-agent string - - 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 resp + else: + # The devices is unknown, redirect to the confirm login page return HTTPSeeOther(request.route_path("accounts.confirm-login")) else: form.recovery_code_value.data = "" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 0215949eeb6e..e4f89f323946 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -128,21 +128,21 @@ msgstr "" msgid "The username isn't valid. Try again." msgstr "" -#: warehouse/accounts/views.py:123 +#: warehouse/accounts/views.py:119 #, 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:144 +#: warehouse/accounts/views.py:140 #, 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:156 +#: warehouse/accounts/views.py:152 #, python-brace-format msgid "" "Too many password resets have been requested for this account without " @@ -150,195 +150,195 @@ msgid "" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:388 warehouse/accounts/views.py:537 -#: warehouse/accounts/views.py:539 warehouse/accounts/views.py:568 -#: warehouse/accounts/views.py:570 warehouse/accounts/views.py:686 +#: warehouse/accounts/views.py:384 warehouse/accounts/views.py:454 +#: warehouse/accounts/views.py:456 warehouse/accounts/views.py:485 +#: warehouse/accounts/views.py:487 warehouse/accounts/views.py:603 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:531 +#: warehouse/accounts/views.py:448 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:605 +#: warehouse/accounts/views.py:522 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:725 warehouse/manage/views/__init__.py:855 +#: warehouse/accounts/views.py:631 warehouse/manage/views/__init__.py:855 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:886 +#: warehouse/accounts/views.py:730 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1055 +#: warehouse/accounts/views.py:899 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:1057 +#: warehouse/accounts/views.py:901 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:1059 warehouse/accounts/views.py:1168 -#: warehouse/accounts/views.py:1246 warehouse/accounts/views.py:1352 -#: warehouse/accounts/views.py:1521 +#: warehouse/accounts/views.py:903 warehouse/accounts/views.py:1012 +#: warehouse/accounts/views.py:1090 warehouse/accounts/views.py:1196 +#: warehouse/accounts/views.py:1365 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:1063 +#: warehouse/accounts/views.py:907 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:1068 warehouse/accounts/views.py:1177 +#: warehouse/accounts/views.py:912 warehouse/accounts/views.py:1021 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:1079 warehouse/accounts/views.py:1188 +#: warehouse/accounts/views.py:923 warehouse/accounts/views.py:1032 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:1097 +#: warehouse/accounts/views.py:941 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:1128 +#: warehouse/accounts/views.py:972 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:1164 +#: warehouse/accounts/views.py:1008 msgid "Expired token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1166 +#: warehouse/accounts/views.py:1010 msgid "Invalid token: please try to login again" msgstr "" -#: warehouse/accounts/views.py:1172 +#: warehouse/accounts/views.py:1016 msgid "Invalid token: not a login confirmation token" msgstr "" -#: warehouse/accounts/views.py:1200 +#: warehouse/accounts/views.py:1044 msgid "Invalid login attempt." msgstr "" -#: warehouse/accounts/views.py:1205 +#: warehouse/accounts/views.py:1049 msgid "" "Device details didn't match, please try again from the device you " "originally used to log in." msgstr "" -#: warehouse/accounts/views.py:1216 +#: warehouse/accounts/views.py:1060 msgid "Your login has been confirmed and this device is now recognized." msgstr "" -#: warehouse/accounts/views.py:1242 +#: warehouse/accounts/views.py:1086 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1244 +#: warehouse/accounts/views.py:1088 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:1250 +#: warehouse/accounts/views.py:1094 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:1259 +#: warehouse/accounts/views.py:1103 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:1262 +#: warehouse/accounts/views.py:1106 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:1282 +#: warehouse/accounts/views.py:1126 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:1285 +#: warehouse/accounts/views.py:1129 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:1291 +#: warehouse/accounts/views.py:1135 #, python-brace-format msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1348 +#: warehouse/accounts/views.py:1192 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1350 +#: warehouse/accounts/views.py:1194 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1356 +#: warehouse/accounts/views.py:1200 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1360 +#: warehouse/accounts/views.py:1204 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1369 +#: warehouse/accounts/views.py:1213 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1421 +#: warehouse/accounts/views.py:1265 #, python-brace-format msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1484 +#: warehouse/accounts/views.py:1328 #, python-brace-format msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1517 +#: warehouse/accounts/views.py:1361 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1519 +#: warehouse/accounts/views.py:1363 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1525 +#: warehouse/accounts/views.py:1369 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1529 +#: warehouse/accounts/views.py:1373 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1536 +#: warehouse/accounts/views.py:1380 msgid "Invalid token: project does not exist" msgstr "" -#: warehouse/accounts/views.py:1547 +#: warehouse/accounts/views.py:1391 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1579 +#: warehouse/accounts/views.py:1423 #, python-brace-format msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1645 +#: warehouse/accounts/views.py:1489 #, python-brace-format msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1757 +#: warehouse/accounts/views.py:1601 #, python-brace-format msgid "Please review our updated Terms of Service." msgstr "" -#: warehouse/accounts/views.py:1969 warehouse/accounts/views.py:2223 +#: warehouse/accounts/views.py:1813 warehouse/accounts/views.py:2067 #: warehouse/manage/views/oidc_publishers.py:126 #: warehouse/manage/views/organizations.py:1805 msgid "" @@ -346,22 +346,22 @@ msgid "" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1990 +#: warehouse/accounts/views.py:1834 #: warehouse/manage/views/organizations.py:1828 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:2006 +#: warehouse/accounts/views.py:1850 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:2019 +#: warehouse/accounts/views.py:1863 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:2034 +#: warehouse/accounts/views.py:1878 #: warehouse/manage/views/oidc_publishers.py:308 #: warehouse/manage/views/oidc_publishers.py:423 #: warehouse/manage/views/oidc_publishers.py:539 @@ -371,7 +371,7 @@ msgid "" "again later." msgstr "" -#: warehouse/accounts/views.py:2044 +#: warehouse/accounts/views.py:1888 #: warehouse/manage/views/oidc_publishers.py:321 #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 @@ -380,23 +380,23 @@ msgstr "" msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:2059 +#: warehouse/accounts/views.py:1903 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:2093 +#: warehouse/accounts/views.py:1937 #: warehouse/manage/views/organizations.py:1893 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:2236 warehouse/accounts/views.py:2249 -#: warehouse/accounts/views.py:2256 +#: warehouse/accounts/views.py:2080 warehouse/accounts/views.py:2093 +#: warehouse/accounts/views.py:2100 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:2263 +#: warehouse/accounts/views.py:2107 msgid "Removed trusted publisher for project " msgstr ""