Commit 173fd804 authored by Flavio TARSETTI's avatar Flavio TARSETTI

Merge branch '571_two_factors_auth' into 'master'

Two factor authentication

Closes #571

See merge request !396
parents 7af9588b be3b4da3
Pipeline #44397 passed with stages
in 17 minutes and 56 seconds
......@@ -200,6 +200,15 @@
</div>
<div class="col-sm-3">
<div class="panel panel-default">
<div class="panel-heading"><i class="fa fa-user-secret"></i> Account Security</div>
<div class="panel-body">
<a href="{% url 'two_factor:profile' %}">2FA Management</a>
</div>
</div>
</div>
{% if user.is_superuser %}
<div class="col-sm-3">
......
......@@ -208,7 +208,7 @@ ACCOUNT_EXPIRATION_DAYS = 365
ACCOUNT_BLOCKAGE_AFTER_FIRST_REJECTION_DAYS = 56
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGIN_URL = "two_factor:login"
SYSTEM_ACCOUNT = "system"
PLOT_ACCOUNT = "plot"
PREREGISTRATION_ONLY = False
......@@ -285,6 +285,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django_otp.middleware.OTPMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"beat.web.navigation.middleware.AgreementMiddleware",
]
......@@ -307,6 +308,10 @@ INSTALLED_APPS = (
"rest_framework.authtoken",
"jsonfield",
"actstream",
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
"two_factor",
"beat.web.ui.registration.apps.UiRegistrationConfig",
"beat.web.accounts.apps.AccountsConfig",
"beat.web.algorithms.apps.AlgorithmsConfig",
......
{% load i18n %}
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="pull-right btn btn-link">{% trans "Cancel" %}</a>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit"
value="{{ wizard.steps.prev }}"
class="btn btn-default">{% trans "Back" %}</button>
{% endif %}
<button type="submit" class="btn btn-primary">{% trans "Next" %}</button>
{% extends "two_factor/_base.html" %}
{% load i18n %}
{% block content_wrapper %}
<div class="container">
<div class="row">
<div class="col-sm-3">
{% include "_messages.html" %}
{% block content %}{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1>{% block title %}{% trans "Backup Tokens" %}{% endblock %}</h1>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<p>{% blocktrans trimmed %}Backup tokens can be used when your primary and backup
phone numbers aren't available. The backup tokens below can be used
for login verification. If you've used up all your backup tokens, you
can generate a new set of backup tokens. Only the backup tokens shown
below will be valid.{% endblocktrans %}</p>
{% if device.token_set.count %}
<ul>
{% for token in device.token_set.all %}
<li>{{ token.token }}</li>
{% endfor %}
</ul>
<p>{% blocktrans %}Print these tokens and keep them somewhere safe.{% endblocktrans %}</p>
{% else %}
<p>{% trans "You don't have any backup codes yet." %}</p>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<form method="post">{% csrf_token %}{{ form }}
<a href="{% url 'two_factor:profile'%}"
class="pull-right btn btn-link">{% trans "Back to Account Security" %}</a>
<button class="btn btn-primary" type="submit">{% trans "Generate Tokens" %}</button>
</form>
</div>
</div>
{% endblock %}
{% extends "two_factor/_base_focus.html" %}
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load i18n two_factor %}
{% block content %}
<div class="row">
<div class="col-sm-4 col-sm-offset-4">
<div class="panel panel-default login-panel">
<div class="panel-heading">Sign-in</div>
<div class="panel-body">
{% if wizard.steps.current == 'auth' %}
<p>{% blocktrans %}Enter your credentials.{% endblocktrans %}</p>
{% elif wizard.steps.current == 'token' %}
{% if device.method == 'call' %}
<p>{% blocktrans trimmed %}We are calling your phone right now, please enter the
digits you hear.{% endblocktrans %}</p>
{% elif device.method == 'sms' %}
<p>{% blocktrans trimmed %}We sent you a text message, please enter the tokens we
sent.{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}Please enter the tokens generated by your token
generator.{% endblocktrans %}</p>
{% endif %}
{% elif wizard.steps.current == 'backup' %}
<p>{% blocktrans trimmed %}Use this form for entering backup tokens for logging in.
These tokens have been generated for you to print and keep safe. Please
enter one of these backup tokens to login to your account.{% endblocktrans %}</p>
{% endif %}
<form action="" method="post" class="form">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}
{# hidden submit button to enable [enter] key #}
<div style="margin-left: -9999px"><input type="submit" value=""/></div>
{% if other_devices %}
<p>{% trans "Or, alternatively, use one of your backup phones:" %}</p>
<p>
{% for other in other_devices %}
<button name="challenge_device" value="{{ other.persistent_id }}"
class="btn btn-default btn-block" type="submit">
{{ other|device_action }}
</button>
{% endfor %}</p>
{% endif %}
{% if backup_tokens %}
<p>{% trans "As a last resort, you can use a backup token:" %}</p>
<p>
<button name="wizard_goto_step" type="submit" value="backup"
class="btn btn-default btn-block">{% trans "Use Backup Token" %}</button>
</p>
{% endif %}
{% include "two_factor/_wizard_actions.html" %}
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-4 col-sm-offset-4">
<p class="comment">In order to sign in, you must be <a href="{% url 'registration' %}">registered</a></p>
<p class="comment">Inactive/Blocked user need to get their account reactivated <a href="{% url 'blocked_user_reactivation' %}">reactivation</a></p>
<p class="comment">Forgot your password ? <a href="{% url 'password_reset' %}">Reset password</a></p>
</div>
</div>
{% endblock %}
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<p>{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor
authentication.{% endblocktrans %}</p>
{% if not phone_methods %}
<p><a href="{% url 'two_factor:profile' %}"
class="btn btn-block btn-default">{% trans "Back to Account Security" %}</a></p>
{% else %}
<p>{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p>
<a href="{% url 'two_factor:profile' %}"
class="pull-right btn btn-link">{% trans "Back to Account Security" %}</a>
<p><a href="{% url 'two_factor:phone_create' %}"
class="btn btn-success">{% trans "Add Phone Number" %}</a></p>
{% endif %}
</div>
</div>
{% endblock %}
......@@ -28,6 +28,33 @@ from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
def monkey_patch_2fa_authentication_form():
"""
This methods monkey patches the list of forms used by the 2fa LoginView.
It replaces the standard form with a custom one that sends an email on successful
login with an account that is blocked.
"""
from two_factor.views import LoginView
from .forms import AuthenticationFormSendingWarning
form_list = []
for form_tuple in LoginView.form_list:
if form_tuple[0] == "auth":
form_list.append(("auth", AuthenticationFormSendingWarning))
else:
form_list.append(form_tuple)
LoginView.form_list = form_list
class UiRegistrationConfig(AppConfig):
name = "beat.web.ui.registration"
verbose_name = _("Ui Registration")
def ready(self):
super().ready()
monkey_patch_2fa_authentication_form()
......@@ -32,11 +32,14 @@ Forms and validation code for user registration.
import datetime
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from ...accounts.models import Profile
from ...accounts.models import SupervisionTrack
from ...utils import mail
from .models import PreregistrationProfile
from .models import RegistrationProfile
......@@ -501,3 +504,45 @@ class RegistrationFormTermsOfServiceSupervisor(RegistrationSupervisorForm):
required=u"You must agree to the Terms of Service in order to register"
),
)
class AuthenticationFormSendingWarning(AuthenticationForm):
"""
Subclass that will send an email to the owner of the account if a successful login
attempt is done.
"""
def check_and_warn(self, username, password):
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
# No specific action is required here
# Possible future step: DOS/DDOS Brute-Force attack detection
pass
else:
authentication_match = user.check_password(password)
if authentication_match and user.profile.status == Profile.BLOCKED:
reactivation_url = self.request.build_absolute_uri(
reverse("blocked_user_reactivation")
)
context = {
"user": user,
"reactivation_url": reactivation_url,
}
mail.send_email(
"registration/mail.blocked_user_access_attempt.subject.txt",
"registration/mail.blocked_user_access_attempt.message.txt",
context,
[user.email],
)
def clean(self):
username = self.cleaned_data.get("username")
password = self.cleaned_data.get("password")
if username is not None and password:
self.check_and_warn(username, password)
super().clean()
......@@ -39,7 +39,7 @@
In the meantime you can remind your "supervisor" to validate
your account.
{% endif %}
You will then be able to <a href="{% url 'login' %}">sign in</a> and start your own experiments!</p>
You will then be able to <a href="{% url 'two_factor:login' %}">sign in</a> and start your own experiments!</p>
{% else %}
<p class="text">Your account wasn't activated. Check that the URL you entered is correct.
If you already clicked on the link sent to you by email, you need to wait
......
......@@ -72,7 +72,7 @@
</form>
<p class="comment">In order to sign in, you must be <a href="{% url 'registration' %}">registered</a></p>
<p class="comment">Already registered and not blocked? <a href="{% url 'login' %}">login</a></p>
<p class="comment">Already registered and not blocked? <a href="{% url 'two_factor:login' %}">login</a></p>
</div>
</div>
......
......@@ -119,7 +119,7 @@
{% else %}
<li><p class="navbar-btn"><a class="btn btn-sm btn-primary" title="Register now for free!" href="{% url 'registration' %}"><i class="fa fa-plus fa-fw fa-lg"></i> Sign-up</a></p></li>
{% endif %}
<li><p class="navbar-btn"><a class="btn btn-sm btn-success" title="Sign-in" href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in fa-fw fa-lg"></i> Sign-in</a></p></li>
<li><p class="navbar-btn"><a class="btn btn-sm btn-success" title="Sign-in" href="{% url 'two_factor:login' %}?next={{ request.path }}"><i class="fa fa-sign-in fa-fw fa-lg"></i> Sign-in</a></p></li>
{% else %}
<li class="visible-xs"><a title="Your homepage" href="{% url 'activity-stream' request.user.username %}"><i class="fa fa-home fa-fw"></i> Your homepage</a></li>
......
......@@ -42,7 +42,12 @@ class EmailSendingTestCase(ViewTestCase):
def run_email_check(self, prefix):
client = Client()
response = client.post(
reverse("login"), dict(username=self.blockeduser, password=self.password)
reverse("two_factor:login"),
data={
"auth-username": self.blockeduser,
"auth-password": self.password,
"login_view-current_step": "auth",
},
)
reference_url = response.wsgi_request.build_absolute_uri(
......
......@@ -40,7 +40,6 @@ app_name = "ui"
urlpatterns = [
path("", views.index, name="index"),
path("login/", views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(next_page="index"), name="logout"),
path(
"blocked_user_reactivation/",
......
......@@ -31,7 +31,6 @@ import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
......@@ -42,7 +41,6 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.template.loader import render_to_string
from django.urls import reverse
from rest_framework.authtoken.models import Token
from .. import __version__
......@@ -68,39 +66,6 @@ def index(request):
# ----------------------------------------------------------
class LoginView(auth_views.LoginView):
def post(self, request, *args, **kwargs):
authentication_match = False
try:
user = User.objects.get(username=request.POST["username"])
except User.DoesNotExist:
# No specific action is required here
# Possible future step: DOS/DDOS Brute-Force attack detection
pass
else:
authentication_match = user.check_password(request.POST["password"])
if authentication_match and user.profile.status == Profile.BLOCKED:
reactivation_url = request.build_absolute_uri(
reverse("blocked_user_reactivation")
)
context = {
"user": user,
"reactivation_url": reactivation_url,
}
mail.send_email(
"registration/mail.blocked_user_access_attempt.subject.txt",
"registration/mail.blocked_user_access_attempt.message.txt",
context,
[user.email],
)
return super().post(request, *args, **kwargs)
# ----------------------------------------------------------
def blocked_user_reactivation(request):
"""Reactivation page"""
......
......@@ -34,6 +34,7 @@ from django.urls import path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
from two_factor.urls import urlpatterns as tfa_urls
from .navigation import urls as navigation_urls
from .ui import urls as ui_urls
......@@ -59,6 +60,7 @@ unprefixed_patterns = ui_urls.urlpatterns
unprefixed_patterns += navigation_urls.urlpatterns
unprefixed_patterns += [
path("", include(tfa_urls)),
path("algorithms/", include("beat.web.algorithms.urls"),),
path("libraries/", include("beat.web.libraries.urls"),),
path("attestations/", include("beat.web.attestations.urls"),),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment