Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions kobo/apps/accounts/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,11 @@
from allauth.account.forms import SignupForm
from constance import config
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, login
from django.db import transaction
from django.shortcuts import resolve_url
from django.template.response import TemplateResponse
from django.utils import timezone
from trench.utils import get_mfa_model, user_token_generator

from .mfa.forms import MfaTokenForm
from .mfa.models import MfaAvailableToUser
from .mfa.permissions import mfa_allowed_for_user
from .mfa.views import MfaTokenView
from .utils import user_has_inactive_paid_subscription


class AccountAdapter(DefaultAccountAdapter):

def is_open_for_signup(self, request):
return config.REGISTRATION_OPEN

Expand All @@ -26,44 +15,6 @@ def login(self, request, user):
user.backend = settings.AUTHENTICATION_BACKENDS[0]
super().login(request, user)

def pre_login(self, request, user, **kwargs):

if parent_response := super().pre_login(request, user, **kwargs):
# A response from the parent means the login process must be
# interrupted, e.g. due to the user being inactive or not having
# validated their email address
return parent_response

# If MFA is activated and allowed for the user, display the token form before letting them in
mfa_active = (
get_mfa_model().objects.filter(is_active=True, user=user).exists()
)
mfa_allowed = mfa_allowed_for_user(user)
inactive_subscription = user_has_inactive_paid_subscription(
user.username
)
if mfa_active and (mfa_allowed or inactive_subscription):
ephemeral_token_cache = user_token_generator.make_token(user)
mfa_token_form = MfaTokenForm(
initial={'ephemeral_token': ephemeral_token_cache}
)

next_url = kwargs.get('redirect_url') or resolve_url(
settings.LOGIN_REDIRECT_URL
)

context = {
REDIRECT_FIELD_NAME: next_url,
'view': MfaTokenView,
'form': mfa_token_form,
}

return TemplateResponse(
request=request,
template='mfa_token.html',
context=context,
)

def save_user(self, request, user, form, commit=True):
# Compare allauth SignupForm with our custom field
standard_fields = set(SignupForm().fields.keys())
Expand Down
13 changes: 11 additions & 2 deletions kobo/apps/accounts/mfa/adapter.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
from allauth.mfa.adapter import DefaultMFAAdapter
from constance import config

from ..utils import user_has_inactive_paid_subscription
from .models import MfaMethodsWrapper
from .permissions import mfa_allowed_for_user


class MfaAdapter(DefaultMFAAdapter):

def is_mfa_enabled(self, user, types=None) -> bool:
super_enabled = super().is_mfa_enabled(user, types)
return super_enabled and mfa_allowed_for_user(user)
mfa_active_super = super().is_mfa_enabled(user, types)
mfa_active = (
mfa_active_super
and MfaMethodsWrapper.objects.filter(user=user, is_active=True).first()
is not None
)
mfa_allowed = mfa_allowed_for_user(user)
inactive_subscription = user_has_inactive_paid_subscription(user.username)
return mfa_active and (mfa_allowed or inactive_subscription)

def get_totp_label(self, user) -> str:
"""Returns the label used for representing the given user in a TOTP QR
Expand Down
107 changes: 3 additions & 104 deletions kobo/apps/accounts/mfa/forms.py
Original file line number Diff line number Diff line change
@@ -1,106 +1,5 @@
# coding: utf-8
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as t
from trench.command.authenticate_second_factor import (
authenticate_second_step_command,
)
from trench.exceptions import MFAValidationError
from trench.serializers import CodeLoginSerializer
from trench.utils import get_mfa_model, user_token_generator
from allauth.mfa.base.forms import AuthenticateForm

from kobo.apps.accounts.forms import LoginForm


class MfaLoginForm(LoginForm):
"""
Authenticating users.
If 2FA is activated, first step (of two) of the login process.
"""

def __init__(self, *args, **kwargs):
self.ephemeral_token_cache = None
super().__init__(*args, **kwargs)

def clean(self, *args, **kwargs):
cleaned_data = super().clean(*args, **kwargs)
# `super().clean()` initialize the object `self.user` with
# the user object retrieved from authentication (if any)
# Because we only support one 2FA method, we do not filter on
# `is_primary` too (as django_trench does).
# ToDo Figure out why `is_primary` is False sometimes after reactivating
# 2FA
if get_mfa_model().objects.filter(is_active=True, user=self.user).exists():
self.ephemeral_token_cache = user_token_generator.make_token(
self.user
)

return cleaned_data

def get_ephemeral_token(self):
return self.ephemeral_token_cache


class MfaTokenForm(forms.Form):
"""
Validate 2FA token.
Second (and last) step of login process when MFA is activated.
"""

code = forms.CharField(
label='',
strip=True,
required=True,
widget=forms.TextInput(
attrs={
'placeholder': t(
'Enter the ##token length##-character token'
).replace(
'##token length##', str(settings.TRENCH_AUTH['CODE_LENGTH'])
)
}
),
)
ephemeral_token = forms.CharField(
required=True,
widget=forms.HiddenInput(),
)

error_messages = {'invalid_code': t('Your token is invalid')}

def __init__(self, request=None, *args, **kwargs):
self.user_cache = None
super().__init__(*args, **kwargs)

def clean(self):
code_login_serializer = CodeLoginSerializer(data=self.cleaned_data)
if not code_login_serializer.is_valid():
raise self.get_invalid_mfa_error()

try:
self.user_cache = authenticate_second_step_command(
code=code_login_serializer.validated_data['code'],
ephemeral_token=code_login_serializer.validated_data[
'ephemeral_token'
],
)
except MFAValidationError:
raise self.get_invalid_mfa_error()

# When login is successful, `django.contrib.auth.login()` expects the
# authentication backend class to be attached to user object.
# See https://github.com/django/django/blob/b87820668e7bd519dbc05f6ee46f551858fb1d6d/django/contrib/auth/__init__.py#L111
# Since we do not have a bullet-proof way to detect which authentication
# class is the good one, we use the first element of the list
self.user_cache.backend = settings.AUTHENTICATION_BACKENDS[0]

return self.cleaned_data

def get_invalid_mfa_error(self):
return forms.ValidationError(
self.error_messages['invalid_code'],
code='invalid_code',
)

def get_user(self):
return self.user_cache
class MfaAuthenticateForm(AuthenticateForm):
pass
57 changes: 57 additions & 0 deletions kobo/apps/accounts/mfa/templates/mfa/authenticate.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{% extends "account/base.html" %} {% load static %} {% load i18n %} {% block content %}
<!-- We use registration -->
<form
method="post"
action="{% url 'mfa_authenticate' %}"
class="registration registration--login mfa-token-form"
data-mfa-token-form-current-section="form"
autocomplete="off"
>
<div class="registration--logo">
<a href="/">{% block logo %}{{ block.super }}{% endblock %}</a>
</div>

{% csrf_token %}

<div class="mfa-token-form__sections">
<section class="mfa-token-form__section" data-mfa-token-form-section="form">
<h1 class="mfa-token-form__header">
{% trans "Please enter your verification token" %}
</h1>
<p class="mfa-token-form__description">
{% trans "Please enter the verification token displayed by your authenticator app." %}
</p>

{{ form.as_p }}

<div class="mfa-token-form__help-toggle-wrapper">
<a href="#" data-mfa-token-form-toggle="help" class="mfa-token-form__help-toggle">
{% trans "Problems with the token" %}
</a>
</div>

<button
type="submit"
name="continue"
class="kobo-button kobo-button--blue kobo-button--fullwidth"
>
{% trans "Continue" %}
</button>

<input type="hidden" name="next" value="{{ next }}" />
</section>

<section class="mfa-token-form__section" data-mfa-token-form-section="help">
<h1 class="mfa-token-form__header">{% trans "Verification issues" %}</h1>

<div class="mfa-token-form__help-text">{{ mfa_help_text | safe }}</div>

<a href="#" data-mfa-token-form-toggle="form" class="mfa-token-form__help-toggle">
{% trans "Back" %}
</a>
</section>
</div>
</form>
{% endblock %} {% block extra_javascript %}
<script src="{% static 'js/mfa_token.js' %}"></script>
{% endblock %}
62 changes: 0 additions & 62 deletions kobo/apps/accounts/mfa/templates/mfa_token.html

This file was deleted.

14 changes: 5 additions & 9 deletions kobo/apps/accounts/mfa/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from kobo.apps.kobo_auth.shortcuts import User
from kpi.tests.kpi_test_case import BaseTestCase
from ..models import MfaAvailableToUser, MfaMethodsWrapper
from .utils import get_mfa_code_for_user
from .utils import activate_mfa_for_user, get_mfa_code_for_user

METHOD = 'app'

Expand All @@ -22,14 +22,10 @@ def setUp(self):
self.someuser = User.objects.get(username='someuser')

# Activate MFA for someuser
self.client.login(username='someuser', password='someuser')
self.client.post(
reverse('mfa-activate', kwargs={'method': METHOD})
)
code = get_mfa_code_for_user(self.someuser)
self.client.post(
reverse('mfa-confirm', kwargs={'method': METHOD}), data={'code': str(code)}
)
activate_mfa_for_user(self.client, self.someuser)

# Log in
self.client.force_login(self.someuser)

def test_user_methods_with_date(self):

Expand Down
14 changes: 3 additions & 11 deletions kobo/apps/accounts/mfa/tests/test_login.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import pytest
from allauth.account.models import EmailAddress
from django.conf import settings
from django.shortcuts import resolve_url
Expand All @@ -7,7 +6,7 @@

from kobo.apps.kobo_auth.shortcuts import User
from kpi.tests.kpi_test_case import KpiTestCase
from .utils import get_mfa_code_for_user
from .utils import activate_mfa_for_user

METHOD = 'app'

Expand All @@ -29,17 +28,10 @@ def setUp(self):
email_address.save()

# Activate MFA for someuser
self.client.login(username='someuser', password='someuser')
self.client.post(reverse('mfa-activate', kwargs={'method': 'app'}))
self.client.post(reverse('mfa-activate', kwargs={'method': METHOD}))
code = get_mfa_code_for_user(self.someuser)
self.client.post(
reverse('mfa-confirm', kwargs={'method': METHOD}), data={'code': str(code)}
)
activate_mfa_for_user(self.client, self.someuser)
# Ensure `self.client` is not authenticated
self.client.logout()

@pytest.mark.skip(reason='MFA Forms not replaced yet...')
def test_login_with_mfa_enabled(self):
"""
Validate that multi-factor authentication form is displayed after
Expand All @@ -50,7 +42,7 @@ def test_login_with_mfa_enabled(self):
'password': 'someuser',
}
response = self.client.post(reverse('kobo_login'), data=data)
self.assertContains(response, 'verification token')
self.assertRedirects(response, reverse('mfa_authenticate'))

def test_login_with_mfa_disabled(self):
"""
Expand Down
Loading