diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 7a55011358b0..a79eddf5a218 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -123,6 +123,7 @@ def add_policy(name, filename): domain=warehouse, ), pretend.call("accounts.login", "/account/login/", domain=warehouse), + pretend.call("accounts.two-factor", "/account/two-factor/", domain=warehouse), pretend.call("accounts.logout", "/account/logout/", domain=warehouse), pretend.call("accounts.register", "/account/register/", domain=warehouse), pretend.call( diff --git a/warehouse/accounts/forms.py b/warehouse/accounts/forms.py index 9fb2f4f940e7..a8d73d93359c 100644 --- a/warehouse/accounts/forms.py +++ b/warehouse/accounts/forms.py @@ -32,6 +32,11 @@ def validate_username(self, field): raise wtforms.validators.ValidationError("No user found with that username") +class OtpCodeMixin: + + otp_code = wtforms.StringField(validators=[wtforms.validators.DataRequired()]) + + class NewUsernameMixin: username = wtforms.StringField( @@ -219,6 +224,12 @@ def validate_password(self, field): ) +class TwoFactorForm(OtpCodeMixin, forms.Form): + def __init__(self, *args, user_service, **kwargs): + super().__init__(*args, **kwargs) + self.user_service = user_service + + class RequestPasswordResetForm(forms.Form): username_or_email = wtforms.StringField( validators=[wtforms.validators.DataRequired()] diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index e9e4bb9bed44..eba598846cfe 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -30,6 +30,7 @@ RegistrationForm, RequestPasswordResetForm, ResetPasswordForm, + TwoFactorForm, ) from warehouse.accounts.interfaces import ( IPasswordBreachedService, @@ -156,6 +157,45 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor } +@view_config( + route_name="accounts.two-factor", + renderer="accounts/two-factor.html", + uses_session=True, + require_csrf=True, + require_methods=False, +) +def two_factor(request, redirect_field_name=REDIRECT_FIELD_NAME, + _form_class=TwoFactorForm): + if request.authenticated_userid is not None: + return HTTPSeeOther(request.route_path("manage.projects")) + + user_service = request.find_service(IUserService, context=None) + + redirect_to = request.POST.get( + redirect_field_name, request.GET.get(redirect_field_name) + ) + + form = _form_class(request.POST, user_service=user_service) + + if request.method == "POST": + request.registry.datadog.increment( + "warehouse.authentication.two-factor.start", + tags=["auth_method:two_factor_form"] + ) + if form.validate(): + pass # TODO: replace by call of OTP validation method + else: + request.registry.datadog.increment( + "warehouse.authentication.two-factor.failure", + tags=["auth_method:two_factor_form"] + ) + + return { + "form": form, + "redirect": {"field": REDIRECT_FIELD_NAME, "data": redirect_to}, + } + + @view_config( route_name="accounts.logout", renderer="accounts/logout.html", @@ -429,8 +469,8 @@ def _login_user(request, userid): # that we create a new session (which will cause it to get a new # session identifier). if ( - request.unauthenticated_userid is not None - and request.unauthenticated_userid != userid + request.unauthenticated_userid is not None + and request.unauthenticated_userid != userid ): # There is already a userid associated with this request and it is # a different userid than the one we're trying to remember now. In diff --git a/warehouse/routes.py b/warehouse/routes.py index 07b09a6a53f6..15f94ae32e5c 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -98,6 +98,7 @@ def includeme(config): domain=warehouse, ) config.add_route("accounts.login", "/account/login/", domain=warehouse) + config.add_route("accounts.two-factor", "/account/two-factor/", domain=warehouse) config.add_route("accounts.logout", "/account/logout/", domain=warehouse) config.add_route("accounts.register", "/account/register/", domain=warehouse) config.add_route( diff --git a/warehouse/templates/accounts/two-factor.html b/warehouse/templates/accounts/two-factor.html new file mode 100644 index 000000000000..21bd55a04c26 --- /dev/null +++ b/warehouse/templates/accounts/two-factor.html @@ -0,0 +1,67 @@ +{# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +-#} +{% extends "base.html" %} + +{% block title %}Log in{% endblock %} + +{% block content %} +{% if testPyPI %} +{% set title = "TestPyPI" %} +{% else %} +{% set title = "PyPI" %} +{% endif %} + +
+
+

Two-factor authentication

+ +
+ + + {% if redirect.data %} + + {% endif %} + + {% if form.errors.__all__ %} +
    + {% for error in form.errors.__all__ %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + +
+ + {{ form.otp_code(placeholder="Authentication code", autocorrect="off", autocapitalize="off", spellcheck="false", required="required", class_="form-group__input", tabindex="1", autofocus=true) }} + {% if form.otp_code.errors %} +
    + {% for error in form.otp_code.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+
+
+ +
+ +
+
+
+
+
+{% endblock %}