diff --git a/dev/environment b/dev/environment index 778f5de819d5..0f22c75d5cbc 100644 --- a/dev/environment +++ b/dev/environment @@ -54,6 +54,7 @@ STATUSPAGE_URL=https://2p66nmmycsj3.statuspage.io 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" WAREHOUSE_LEGACY_DOMAIN=pypi.python.org diff --git a/tests/unit/accounts/test_core.py b/tests/unit/accounts/test_core.py index c81d2590396f..5614a4455bed 100644 --- a/tests/unit/accounts/test_core.py +++ b/tests/unit/accounts/test_core.py @@ -439,6 +439,11 @@ def test_includeme(monkeypatch): pretend.call( TokenServiceFactory(name="two_factor"), ITokenService, name="two_factor" ), + pretend.call( + TokenServiceFactory(name="remember_device"), + ITokenService, + name="remember_device", + ), pretend.call( HaveIBeenPwnedPasswordBreachedService.create_service, IPasswordBreachedService, diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 300b5dab0053..e32be58c4665 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -14,6 +14,8 @@ import json import uuid +from datetime import timedelta + import freezegun import pretend import pytest @@ -46,7 +48,10 @@ TooManyFailedLogins, TooManyPasswordResetRequests, ) -from warehouse.accounts.views import two_factor_and_totp_validate +from warehouse.accounts.views import ( + REMEMBER_DEVICE_COOKIE, + two_factor_and_totp_validate, +) from warehouse.admin.flags import AdminFlag, AdminFlagValue from warehouse.events.tags import EventTag from warehouse.metrics.interfaces import IMetricsService @@ -435,9 +440,15 @@ def test_redirect_authenticated_user(self): assert result.headers["Location"] == "/the-redirect" @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) - def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): + def test_two_factor_auth( + self, monkeypatch, pyramid_request, redirect_url, token_service + ): token_service.dumps = lambda d: "fake_token" + monkeypatch.setattr( + views, "_check_remember_device_token", lambda *a, **kw: False + ) + user_service = pretend.stub( find_userid=pretend.call_recorder(lambda username: 1), update_user=lambda *a, **k: None, @@ -590,7 +601,7 @@ def test_get_returns_totp_form(self, pyramid_request, redirect_url): ITokenService: token_service, IUserService: user_service, }[interface] - + pyramid_request.registry.settings = {"remember_device.days": 30} pyramid_request.query_string = pretend.stub() form_obj = pretend.stub() @@ -603,7 +614,7 @@ def test_get_returns_totp_form(self, pyramid_request, redirect_url): assert token_service.loads.calls == [ pretend.call(pyramid_request.query_string, return_timestamp=True) ] - assert result == {"totp_form": form_obj} + assert result == {"totp_form": form_obj, "remember_device_days": 30} assert form_class.calls == [ pretend.call( pyramid_request.POST, @@ -643,8 +654,9 @@ def test_get_returns_webauthn(self, pyramid_request, redirect_url): ITokenService: token_service, IUserService: user_service, }[interface] - + pyramid_request.registry.settings = {"remember_device.days": 30} pyramid_request.query_string = pretend.stub() + result = views.two_factor_and_totp_validate( pyramid_request, _form_class=pretend.stub() ) @@ -652,7 +664,7 @@ def test_get_returns_webauthn(self, pyramid_request, redirect_url): assert token_service.loads.calls == [ pretend.call(pyramid_request.query_string, return_timestamp=True) ] - assert result == {"has_webauthn": True} + assert result == {"has_webauthn": True, "remember_device_days": 30} @pytest.mark.parametrize("redirect_url", [None, "/foo/bar/", "/wat/"]) def test_get_returns_recovery_code_status(self, pyramid_request, redirect_url): @@ -683,7 +695,7 @@ def test_get_returns_recovery_code_status(self, pyramid_request, redirect_url): ITokenService: token_service, IUserService: user_service, }[interface] - + pyramid_request.registry.settings = {"remember_device.days": 30} pyramid_request.query_string = pretend.stub() result = views.two_factor_and_totp_validate( pyramid_request, _form_class=pretend.stub() @@ -692,16 +704,25 @@ def test_get_returns_recovery_code_status(self, pyramid_request, redirect_url): assert token_service.loads.calls == [ pretend.call(pyramid_request.query_string, return_timestamp=True) ] - assert result == {"has_recovery_codes": True} + assert result == {"has_recovery_codes": True, "remember_device_days": 30} @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) @pytest.mark.parametrize("has_recovery_codes", [True, False]) + @pytest.mark.parametrize("remember_device", [True, False]) def test_totp_auth( - self, monkeypatch, pyramid_request, redirect_url, has_recovery_codes + self, + monkeypatch, + pyramid_request, + redirect_url, + has_recovery_codes, + remember_device, ): 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)} if redirect_url: query_params["redirect_to"] = redirect_url @@ -751,10 +772,12 @@ def test_totp_auth( lambda *args: None ) pyramid_request.session.record_password_timestamp = lambda timestamp: None + pyramid_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=remember_device), ) form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) pyramid_request.route_path = pretend.call_recorder( @@ -793,6 +816,12 @@ def test_totp_auth( [] if has_recovery_codes else [pretend.call(pyramid_request, user)] ) + assert _remember_device.calls == ( + [] + if not remember_device + else [pretend.call(pyramid_request, result, str(1), "totp")] + ) + def test_totp_auth_already_authed(self): request = pretend.stub( authenticated_userid="not_none", @@ -836,6 +865,7 @@ def test_totp_form_invalid(self): IUserService: user_service, }[interface], query_string=pretend.stub(), + registry=pretend.stub(settings={"remember_device.days": 30}), ) form_obj = pretend.stub( @@ -849,7 +879,7 @@ def test_totp_form_invalid(self): assert token_service.loads.calls == [ pretend.call(request.query_string, return_timestamp=True) ] - assert result == {"totp_form": form_obj} + assert result == {"totp_form": form_obj, "remember_device_days": 30} def test_two_factor_token_missing_userid(self, pyramid_request): token_service = pretend.stub( @@ -1003,7 +1033,10 @@ def test_webauthn_validate_invalid_form(self, monkeypatch): assert result == {"fail": {"errors": ["Fake validation failure"]}} @pytest.mark.parametrize("has_recovery_codes", [True, False]) - def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_codes): + @pytest.mark.parametrize("remember_device", [True, False]) + def test_webauthn_validate( + self, monkeypatch, pyramid_request, has_recovery_codes, remember_device + ): _get_two_factor_data = pretend.call_recorder( lambda r: {"redirect_to": "foobar", "userid": 1} ) @@ -1012,6 +1045,9 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_code _login_user = pretend.call_recorder(lambda *a, **kw: pretend.stub()) monkeypatch.setattr(views, "_login_user", _login_user) + _remember_device = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr(views, "_remember_device", _remember_device) + user = pretend.stub( webauthn=pretend.stub(sign_count=pretend.stub()), has_recovery_codes=has_recovery_codes, @@ -1039,6 +1075,7 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_code credential_device_type="single_device", credential_backed_up=False, ), + remember_device=pretend.stub(data=remember_device), ) form_class = pretend.call_recorder(lambda *a, **kw: form_obj) monkeypatch.setattr(views, "WebAuthnAuthenticationForm", form_class) @@ -1053,7 +1090,7 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_code pretend.call( pyramid_request, 1, - two_factor_method="webauthn", + "webauthn", two_factor_label="webauthn_label", ) ] @@ -1065,12 +1102,89 @@ def test_webauthn_validate(self, monkeypatch, pyramid_request, has_recovery_code [] if has_recovery_codes else [pretend.call(pyramid_request, user)] ) + assert _remember_device.calls == ( + [] + if not remember_device + else [ + pretend.call(pyramid_request, pyramid_request.response, 1, "webauthn") + ] + ) + assert result == { "success": "Successful WebAuthn assertion", "redirect_to": "foobar", } +class TestRememberDevice: + def test_check_remember_device_token_valid(self): + token_service = pretend.stub(loads=lambda *a: {"user_id": str(1)}) + request = pretend.stub( + cookies=pretend.stub(get=lambda *a, **kw: "token"), + find_service=lambda interface, **kwargs: { + ITokenService: token_service, + }[interface], + ) + assert views._check_remember_device_token(request, 1) + + def test_check_remember_device_token_invalid_no_cookie(self): + request = pretend.stub( + cookies=pretend.stub(get=lambda *a, **kw: ""), + ) + assert not views._check_remember_device_token(request, 1) + + def test_check_remember_device_token_invalid_bad_token(self): + token_service = pretend.stub(loads=pretend.raiser(TokenException)) + request = pretend.stub( + cookies=pretend.stub(get=lambda *a, **kw: "token"), + find_service=lambda interface, **kwargs: { + ITokenService: token_service, + }[interface], + ) + assert not views._check_remember_device_token(request, 1) + + def test_check_remember_device_token_invalid_wrong_user(self): + token_service = pretend.stub(loads=lambda *a: {"user_id": str(999)}) + request = pretend.stub( + cookies=pretend.stub(get=lambda *a, **kw: "token"), + find_service=lambda interface, **kwargs: { + ITokenService: token_service, + }[interface], + ) + assert not views._check_remember_device_token(request, 1) + + def test_remember_device(self): + token_service = pretend.stub(dumps=lambda *a: "token_data") + pyramid_request = pretend.stub( + find_service=lambda interface, **kwargs: { + ITokenService: token_service, + }[interface], + scheme="https", + route_path=lambda *a, **kw: "/accounts/login", + user=pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + registry=pretend.stub( + settings={"remember_device.seconds": timedelta(days=30).total_seconds()} + ), + ) + response = pretend.stub(set_cookie=pretend.call_recorder(lambda *a, **kw: None)) + + views._remember_device(pyramid_request, response, 1, "webauthn") + + assert response.set_cookie.calls == [ + pretend.call( + REMEMBER_DEVICE_COOKIE, + "token_data", + max_age=timedelta(days=30).total_seconds(), + httponly=True, + secure=True, + samesite=b"strict", + path="/accounts/login", + ) + ] + + class TestRecoveryCode: def test_already_authenticated(self): request = pretend.stub( @@ -1129,7 +1243,6 @@ def test_get_returns_form(self, pyramid_request): ITokenService: token_service, IUserService: user_service, }[interface] - pyramid_request.query_string = pretend.stub() form_obj = pretend.stub() @@ -1276,6 +1389,7 @@ def test_recovery_code_form_invalid(self): IUserService: user_service, }[interface], query_string=pretend.stub(), + # registry=pretend.stub(settings={"remember_device.days": 30}), ) form_obj = pretend.stub( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 56e6238abc11..7c4acff56fde 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -12,6 +12,7 @@ import os +from datetime import timedelta from unittest import mock import orjson @@ -246,6 +247,9 @@ def __init__(self): "warehouse.commit": "null", "site.name": "Warehouse", "token.two_factor.max_age": 300, + "remember_device.days": 30, + "remember_device.seconds": timedelta(days=30).total_seconds(), + "token.remember_device.max_age": timedelta(days=30).total_seconds(), "token.default.max_age": 21600, "pythondotorg.host": "https://www.python.org", "warehouse.xmlrpc.client.ratelimit_string": "3600 per hour", diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py index a5a523011edf..c88cec823f2c 100644 --- a/warehouse/accounts/__init__.py +++ b/warehouse/accounts/__init__.py @@ -104,6 +104,11 @@ def includeme(config): config.register_service_factory( TokenServiceFactory(name="two_factor"), ITokenService, name="two_factor" ) + config.register_service_factory( + TokenServiceFactory(name="remember_device"), + ITokenService, + name="remember_device", + ) # Register our password breach detection service. breached_pw_class = config.maybe_dotted( diff --git a/warehouse/accounts/forms.py b/warehouse/accounts/forms.py index 297559b63aa3..2440a4c9842a 100644 --- a/warehouse/accounts/forms.py +++ b/warehouse/accounts/forms.py @@ -404,6 +404,8 @@ def __init__(self, *args, request, user_id, user_service, **kwargs): self.user_id = user_id self.user_service = user_service + remember_device = wtforms.BooleanField(default=False) + class TOTPAuthenticationForm(TOTPValueMixin, _TwoFactorAuthenticationForm): def validate_totp_value(self, field): diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index d166585e0332..cb855bf01eb5 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -93,6 +93,7 @@ from warehouse.utils.http import is_safe_url USER_ID_INSECURE_COOKIE = "user_id__insecure" +REMEMBER_DEVICE_COOKIE = "remember_device" @view_config(context=TooManyFailedLogins, has_translations=True) @@ -236,8 +237,12 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor username = form.username.data userid = user_service.find_userid(username) - # If the user has enabled two factor authentication. - if user_service.has_two_factor(userid): + # If the user has enabled two-factor authentication and they do not have + # a valid saved device. + two_factor_required = user_service.has_two_factor(userid) and ( + not _check_remember_device_token(request, userid) + ) + if two_factor_required: two_factor_data = {"userid": userid} if redirect_to: two_factor_data["redirect_to"] = redirect_to @@ -328,13 +333,15 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): two_factor_state["has_webauthn"] = True if user_service.has_recovery_codes(userid): two_factor_state["has_recovery_codes"] = True + two_factor_state["remember_device_days"] = request.registry.settings[ + "remember_device.days" + ] if request.method == "POST": form = two_factor_state["totp_form"] if form.validate(): - _login_user( - request, userid, two_factor_method="totp", two_factor_label="totp" - ) + 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) @@ -348,6 +355,9 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): 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: form.totp_value.data = "" @@ -425,12 +435,8 @@ def webauthn_authentication_validate(request): ) webauthn.sign_count = form.validated_credential.new_sign_count - _login_user( - request, - userid, - two_factor_method="webauthn", - two_factor_label=webauthn.label, - ) + two_factor_method = "webauthn" + _login_user(request, userid, two_factor_method, two_factor_label=webauthn.label) request.response.set_cookie( USER_ID_INSECURE_COOKIE, @@ -442,6 +448,9 @@ def webauthn_authentication_validate(request): if not request.user.has_recovery_codes: send_recovery_code_reminder_email(request, request.user) + if form.remember_device.data: + _remember_device(request, request.response, userid, two_factor_method) + return { "success": request._("Successful WebAuthn assertion"), "redirect_to": redirect_to, @@ -451,6 +460,47 @@ def webauthn_authentication_validate(request): return {"fail": {"errors": errors}} +def _check_remember_device_token(request, user_id) -> bool: + """ + Returns true if the given remember device cookie is valid for the given user. + """ + remember_device_token = request.cookies.get(REMEMBER_DEVICE_COOKIE) + if not remember_device_token: + return False + token_service = request.find_service(ITokenService, name="remember_device") + try: + data = token_service.loads(remember_device_token) + user_id_token = data.get("user_id") + return user_id_token == str(user_id) + except TokenException: + return False + + +def _remember_device(request, response, userid, two_factor_method) -> None: + """ + Generates and sets a cookie for remembering this device. + """ + remember_device_data = {"user_id": str(userid)} + token_service = request.find_service(ITokenService, name="remember_device") + token = token_service.dumps(remember_device_data) + response.set_cookie( + REMEMBER_DEVICE_COOKIE, + token, + max_age=request.registry.settings["remember_device.seconds"], + httponly=True, + secure=request.scheme == "https", + samesite=b"strict", + path=request.route_path("accounts.login"), + ) + request.user.record_event( + tag=EventTag.Account.TwoFactorDeviceRemembered, + request=request, + additional={ + "two_factor_method": two_factor_method, + }, + ) + + @view_config( route_name="accounts.recovery-code", renderer="accounts/recovery-code.html", diff --git a/warehouse/config.py b/warehouse/config.py index d7361f2b1b43..a9548430ad6a 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -17,6 +17,8 @@ import os import shlex +from datetime import timedelta + import orjson import transaction @@ -215,6 +217,7 @@ def configure(settings=None): maybe_set(settings, "token.password.secret", "TOKEN_PASSWORD_SECRET") 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, "warehouse.xmlrpc.search.enabled", @@ -238,6 +241,20 @@ def configure(settings=None): coercer=int, default=300, ) + maybe_set( + settings, + "remember_device.days", + "REMEMBER_DEVICE_DAYS", + coercer=int, + default=30, + ) + settings.setdefault( + "remember_device.seconds", + timedelta(days=settings.get("remember_device.days")).total_seconds(), + ) + settings.setdefault( + "token.remember_device.max_age", settings.get("remember_device.seconds") + ) maybe_set( settings, "token.default.max_age", diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py index a58713a85014..14aa16a4b85d 100644 --- a/warehouse/events/tags.py +++ b/warehouse/events/tags.py @@ -98,6 +98,7 @@ class Account(EventTagEnum): RoleRevokeInvite = "account:role:revoke_invite" TeamRoleAdd = "account:team_role:add" TeamRoleRemove = "account:team_role:remove" + TwoFactorDeviceRemembered = "account:two_factor:device_remembered" TwoFactorMethodAdded = "account:two_factor:method_added" TwoFactorMethodRemoved = "account:two_factor:method_removed" EmailSent = "account:email:sent" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 54961709fea8..719cfba5196c 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -94,241 +94,241 @@ msgstr "" msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" -#: warehouse/accounts/forms.py:419 +#: warehouse/accounts/forms.py:421 msgid "Invalid TOTP code." msgstr "" -#: warehouse/accounts/forms.py:436 +#: warehouse/accounts/forms.py:438 msgid "Invalid WebAuthn assertion: Bad payload" msgstr "" -#: warehouse/accounts/forms.py:505 +#: warehouse/accounts/forms.py:507 msgid "Invalid recovery code." msgstr "" -#: warehouse/accounts/forms.py:514 +#: warehouse/accounts/forms.py:516 msgid "Recovery code has been previously used." msgstr "" -#: warehouse/accounts/forms.py:533 +#: warehouse/accounts/forms.py:535 msgid "No user found with that username or email" msgstr "" -#: warehouse/accounts/views.py:105 +#: warehouse/accounts/views.py:106 msgid "" "There have been too many unsuccessful login attempts. You have been " "locked out for {}. Please try again later." msgstr "" -#: warehouse/accounts/views.py:122 +#: warehouse/accounts/views.py:123 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:134 +#: warehouse/accounts/views.py:135 msgid "" "Too many password resets have been requested for this account without " "completing them. Check your inbox and follow the verification links. (IP:" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:309 warehouse/accounts/views.py:373 -#: warehouse/accounts/views.py:375 warehouse/accounts/views.py:402 -#: warehouse/accounts/views.py:404 warehouse/accounts/views.py:470 +#: warehouse/accounts/views.py:314 warehouse/accounts/views.py:383 +#: warehouse/accounts/views.py:385 warehouse/accounts/views.py:412 +#: warehouse/accounts/views.py:414 warehouse/accounts/views.py:520 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:367 +#: warehouse/accounts/views.py:377 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:446 +#: warehouse/accounts/views.py:455 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:501 warehouse/manage/views/__init__.py:824 +#: warehouse/accounts/views.py:551 warehouse/manage/views/__init__.py:824 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:593 +#: warehouse/accounts/views.py:643 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:724 +#: warehouse/accounts/views.py:774 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:726 +#: warehouse/accounts/views.py:776 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:728 warehouse/accounts/views.py:830 -#: warehouse/accounts/views.py:934 warehouse/accounts/views.py:1103 +#: warehouse/accounts/views.py:778 warehouse/accounts/views.py:880 +#: warehouse/accounts/views.py:984 warehouse/accounts/views.py:1153 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:732 +#: warehouse/accounts/views.py:782 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:737 +#: warehouse/accounts/views.py:787 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:748 +#: warehouse/accounts/views.py:798 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:766 +#: warehouse/accounts/views.py:816 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:798 +#: warehouse/accounts/views.py:848 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:826 +#: warehouse/accounts/views.py:876 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:828 +#: warehouse/accounts/views.py:878 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:834 +#: warehouse/accounts/views.py:884 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:843 +#: warehouse/accounts/views.py:893 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:846 +#: warehouse/accounts/views.py:896 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:863 +#: warehouse/accounts/views.py:913 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:867 +#: warehouse/accounts/views.py:917 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:872 +#: warehouse/accounts/views.py:922 msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:930 +#: warehouse/accounts/views.py:980 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:932 +#: warehouse/accounts/views.py:982 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:938 +#: warehouse/accounts/views.py:988 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:942 +#: warehouse/accounts/views.py:992 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:951 +#: warehouse/accounts/views.py:1001 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1002 +#: warehouse/accounts/views.py:1052 msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1065 +#: warehouse/accounts/views.py:1115 msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1099 +#: warehouse/accounts/views.py:1149 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1101 +#: warehouse/accounts/views.py:1151 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1107 +#: warehouse/accounts/views.py:1157 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1111 +#: warehouse/accounts/views.py:1161 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1126 +#: warehouse/accounts/views.py:1176 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1157 +#: warehouse/accounts/views.py:1207 msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1223 +#: warehouse/accounts/views.py:1273 msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1444 warehouse/accounts/views.py:1592 +#: warehouse/accounts/views.py:1494 warehouse/accounts/views.py:1642 #: warehouse/manage/views/__init__.py:1237 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1461 warehouse/manage/views/__init__.py:1253 +#: warehouse/accounts/views.py:1511 warehouse/manage/views/__init__.py:1253 msgid "" "GitHub-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1475 +#: warehouse/accounts/views.py:1525 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:1488 +#: warehouse/accounts/views.py:1538 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1504 warehouse/manage/views/__init__.py:1272 +#: warehouse/accounts/views.py:1554 warehouse/manage/views/__init__.py:1272 msgid "" "There have been too many attempted trusted publisher registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1518 warehouse/manage/views/__init__.py:1286 +#: warehouse/accounts/views.py:1568 warehouse/manage/views/__init__.py:1286 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:1537 +#: warehouse/accounts/views.py:1587 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:1572 +#: warehouse/accounts/views.py:1622 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:1606 warehouse/accounts/views.py:1619 -#: warehouse/accounts/views.py:1626 +#: warehouse/accounts/views.py:1656 warehouse/accounts/views.py:1669 +#: warehouse/accounts/views.py:1676 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:1632 +#: warehouse/accounts/views.py:1682 msgid "Removed trusted publisher for project " msgstr "" @@ -1241,7 +1241,7 @@ msgstr "" #: warehouse/templates/accounts/request-password-reset.html:41 #: warehouse/templates/accounts/reset-password.html:40 #: warehouse/templates/accounts/reset-password.html:62 -#: warehouse/templates/accounts/two-factor.html:89 +#: warehouse/templates/accounts/two-factor.html:101 #: warehouse/templates/manage/account.html:270 #: warehouse/templates/manage/account.html:287 #: warehouse/templates/manage/account.html:344 @@ -1469,7 +1469,7 @@ msgid "Recovery codes" msgstr "" #: warehouse/templates/accounts/recovery-code.html:24 -#: warehouse/templates/accounts/two-factor.html:129 +#: warehouse/templates/accounts/two-factor.html:152 msgid "Login using recovery codes" msgstr "" @@ -1486,7 +1486,7 @@ msgid "" msgstr "" #: warehouse/templates/accounts/recovery-code.html:58 -#: warehouse/templates/accounts/two-factor.html:112 +#: warehouse/templates/accounts/two-factor.html:124 #: warehouse/templates/manage/account/recovery_codes-burn.html:83 msgid "Verify" msgstr "" @@ -1640,20 +1640,28 @@ msgid "" " (e.g. USB key)" msgstr "" -#: warehouse/templates/accounts/two-factor.html:60 +#: warehouse/templates/accounts/two-factor.html:64 +#: warehouse/templates/accounts/two-factor.html:130 +#, python-format +msgid "Remember this device for %(remember_device_days)s day" +msgid_plural "Remember this device for %(remember_device_days)s days" +msgstr[0] "" +msgstr[1] "" + +#: warehouse/templates/accounts/two-factor.html:72 #, python-format msgid "Lost your device? Not working? Get help." msgstr "" -#: warehouse/templates/accounts/two-factor.html:72 +#: warehouse/templates/accounts/two-factor.html:84 msgid "Authenticate with an app" msgstr "" -#: warehouse/templates/accounts/two-factor.html:87 +#: warehouse/templates/accounts/two-factor.html:99 msgid "Enter authentication code" msgstr "" -#: warehouse/templates/accounts/two-factor.html:115 +#: warehouse/templates/accounts/two-factor.html:138 #, python-format msgid "" "

Generate a code using the authentication application connected to your" @@ -1662,11 +1670,11 @@ msgid "" "help.

" msgstr "" -#: warehouse/templates/accounts/two-factor.html:127 +#: warehouse/templates/accounts/two-factor.html:150 msgid "Lost your security key or application?" msgstr "" -#: warehouse/templates/accounts/two-factor.html:132 +#: warehouse/templates/accounts/two-factor.html:155 #, python-format msgid "" "

You have not generated account recovery codes.

" diff --git a/warehouse/static/js/warehouse/utils/webauthn.js b/warehouse/static/js/warehouse/utils/webauthn.js index e6d27482e12e..191213aee6c3 100644 --- a/warehouse/static/js/warehouse/utils/webauthn.js +++ b/warehouse/static/js/warehouse/utils/webauthn.js @@ -142,10 +142,13 @@ const postCredential = async (label, credential, token) => { return await resp.json(); }; -const postAssertion = async (assertion, token) => { +const postAssertion = async (assertion, token, rememberDevice) => { const formData = new FormData(); formData.set("credential", JSON.stringify(assertion)); formData.set("csrf_token", token); + if (rememberDevice) { + formData.set("remember_device", "true"); + } const resp = await fetch( "/account/webauthn-authenticate/validate" + window.location.search, { @@ -225,13 +228,14 @@ export const AuthenticateWebAuthn = () => { return; } + const rememberDevice = document.getElementById("remember_device_webauthn").checked; const transformedOptions = transformAssertionOptions(assertionOptions); await navigator.credentials.get({ publicKey: transformedOptions, }).then(async (assertion) => { const transformedAssertion = transformAssertion(assertion); - const status = await postAssertion(transformedAssertion, csrfToken); + const status = await postAssertion(transformedAssertion, csrfToken, rememberDevice); if (status.fail) { populateWebAuthnErrorList(status.fail.errors); return; diff --git a/warehouse/templates/accounts/two-factor.html b/warehouse/templates/accounts/two-factor.html index 140396d3e945..485adbb8994d 100644 --- a/warehouse/templates/accounts/two-factor.html +++ b/warehouse/templates/accounts/two-factor.html @@ -57,6 +57,18 @@

{% endtrans %} + +
+ +
+

{% trans href='/help/#utfkey' %}Lost your device? Not working? Get help.{% endtrans %}

@@ -112,6 +124,17 @@

{% trans %}Authenticate with an app{% endtrans %}

+
+ +
+ {% trans href='/help/#totp' %}

Generate a code using the authentication application connected to your PyPI account. Enter this code in the form to verify your identity.

Lost your application? Not working? Get help.