Skip to content

Commit 8e6076f

Browse files
authored
Two-Factor Authentication (#2497)
1 parent 4493a13 commit 8e6076f

File tree

19 files changed

+743
-70
lines changed

19 files changed

+743
-70
lines changed

app/config/settings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,8 @@
401401
"django.middleware.common.CommonMiddleware",
402402
"django.middleware.csrf.CsrfViewMiddleware",
403403
"django.contrib.auth.middleware.AuthenticationMiddleware",
404+
# Django-otp middleware must be after the AuthenticationMiddleware.
405+
"django_otp.middleware.OTPMiddleware",
404406
"django.contrib.messages.middleware.MessageMiddleware",
405407
"django.contrib.sites.middleware.CurrentSiteMiddleware",
406408
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -411,6 +413,14 @@
411413
"grandchallenge.subdomains.middleware.subdomain_urlconf_middleware",
412414
"grandchallenge.timezones.middleware.TimezoneMiddleware",
413415
"machina.apps.forum_permission.middleware.ForumPermissionMiddleware",
416+
# 2FA middleware, needs to be after subdomain middleware
417+
# TwoFactorMiddleware resets the login flow if another page is loaded
418+
# between login and successfully entering two-factor credentials. We're using
419+
# a modified version of the original allauth_2fa middleware to pass the
420+
# correct urlconf.
421+
"grandchallenge.core.middleware.TwoFactorMiddleware",
422+
# Force 2FA for staff users
423+
"grandchallenge.core.middleware.RequireStaffAndSuperuser2FAMiddleware",
414424
# Flatpage fallback almost last
415425
"django.contrib.flatpages.middleware.FlatpageFallbackMiddleware",
416426
# Redirects last as they're a last resort
@@ -480,6 +490,12 @@
480490
# Overridden apps
481491
"grandchallenge.forum_conversation",
482492
"grandchallenge.forum_member",
493+
# Configure the django-otp package
494+
"django_otp",
495+
"django_otp.plugins.otp_totp",
496+
"django_otp.plugins.otp_static",
497+
# Enable two-factor auth
498+
"allauth_2fa",
483499
]
484500

485501
LOCAL_APPS = [
@@ -577,6 +593,9 @@
577593
LOGOUT_URL = "/accounts/logout/"
578594
LOGIN_REDIRECT_URL = "/users/profile/"
579595

596+
# django-allauth-2fa
597+
ALLAUTH_2FA_ALWAYS_REVEAL_BACKUP_TOKENS = False
598+
580599
##############################################################################
581600
#
582601
# stdimage

app/config/urls/root.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.auth.decorators import login_required
55
from django.contrib.sitemaps.views import sitemap
66
from django.template.response import TemplateResponse
7-
from django.urls import path
7+
from django.urls import path, re_path
88
from django.views.generic import TemplateView
99
from machina import urls as machina_urls
1010

@@ -17,6 +17,7 @@
1717
from grandchallenge.pages.sitemaps import PagesSitemap
1818
from grandchallenge.policies.sitemaps import PoliciesSitemap
1919
from grandchallenge.products.sitemaps import CompaniesSitemap, ProductsSitemap
20+
from grandchallenge.profiles.views import TwoFactorRemove, TwoFactorSetup
2021
from grandchallenge.reader_studies.sitemaps import ReaderStudiesSiteMap
2122

2223
admin.autodiscover()
@@ -58,6 +59,17 @@ def handler500(request):
5859
name="django.contrib.sitemaps.views.sitemap",
5960
),
6061
path(settings.ADMIN_URL, admin.site.urls),
62+
re_path(
63+
r"accounts/two_factor/setup/?$",
64+
TwoFactorSetup.as_view(),
65+
name="two-factor-setup",
66+
),
67+
re_path(
68+
r"accounts/two_factor/remove/?$",
69+
TwoFactorRemove.as_view(),
70+
name="two-factor-remove",
71+
),
72+
path("accounts/", include("allauth_2fa.urls")),
6173
path("accounts/", include("allauth.urls")),
6274
path(
6375
"stats/",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from allauth_2fa.middleware import BaseRequire2FAMiddleware
2+
from django.urls import Resolver404, get_resolver
3+
from django.utils.deprecation import MiddlewareMixin
4+
5+
6+
class RequireStaffAndSuperuser2FAMiddleware(BaseRequire2FAMiddleware):
7+
def require_2fa(self, request):
8+
# Staff users and superusers are required to have 2FA.
9+
return request.user.is_staff or request.user.is_superuser
10+
11+
12+
class TwoFactorMiddleware(MiddlewareMixin):
13+
"""
14+
Reset the login flow if another page is loaded halfway through the login.
15+
(I.e. if the user has logged in with a username/password, but not yet
16+
entered their two-factor credentials.) This makes sure a user does not stay
17+
half logged in by mistake.
18+
"""
19+
20+
def __init__(self, get_response):
21+
self.get_response = get_response
22+
23+
def process_request(self, request):
24+
try:
25+
match = get_resolver(request.urlconf).resolve(request.path)
26+
if (
27+
match
28+
and not match.url_name
29+
or not match.url_name.startswith("two-factor-authenticate")
30+
):
31+
try:
32+
del request.session["allauth_2fa_user_id"]
33+
except KeyError:
34+
pass
35+
except Resolver404:
36+
return self.get_response(request)

app/grandchallenge/profiles/adapters.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
from allauth.account.adapter import DefaultAccountAdapter
21
from allauth.account.utils import user_email, user_username
2+
from allauth.exceptions import ImmediateHttpResponse
33
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
4+
from allauth_2fa.adapter import OTPAdapter
5+
from allauth_2fa.utils import user_has_valid_totp_device
46
from django import forms
57
from django.conf import settings
8+
from django.http import HttpResponseRedirect
69
from django.utils.http import url_has_allowed_host_and_scheme
710

811
from grandchallenge.challenges.models import Challenge
12+
from grandchallenge.subdomains.utils import reverse
913

1014

11-
class AccountAdapter(DefaultAccountAdapter):
15+
class AccountAdapter(OTPAdapter):
1216
def is_safe_url(self, url):
1317
challenge_domains = {
1418
f"{c.short_name.lower()}{settings.SESSION_COOKIE_DOMAIN}"
@@ -50,3 +54,23 @@ def populate_user(self, *args, **kwargs):
5054
user_username(user, user_email(user).split("@")[0])
5155

5256
return user
57+
58+
def pre_social_login(self, request, sociallogin):
59+
if user_has_valid_totp_device(sociallogin.user):
60+
# Cast to string for the case when this is not a JSON serializable
61+
# object, e.g. a UUID.
62+
request.session["allauth_2fa_user_id"] = str(sociallogin.user.id)
63+
redirect_url = reverse("two-factor-authenticate")
64+
redirect_url += "?next=" + request.get_full_path()
65+
raise ImmediateHttpResponse(
66+
response=HttpResponseRedirect(redirect_url)
67+
)
68+
elif sociallogin.user.is_staff and not user_has_valid_totp_device(
69+
sociallogin.user
70+
):
71+
redirect_url = reverse("two-factor-setup")
72+
redirect_url += "?next=" + request.get_full_path()
73+
raise ImmediateHttpResponse(
74+
response=HttpResponseRedirect(redirect_url)
75+
)
76+
return super().pre_social_login(request, sociallogin)

app/grandchallenge/profiles/templates/account/login.html

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ <h1>{% trans "Sign In" %}</h1>
2525
{% include "socialaccount/snippets/provider_list.html" with process="login" %}
2626
</ul>
2727

28-
{% include "profiles/partials/or.html" %}
28+
<div class="row">
29+
<div class="col-lg-6 col-md-8 mx-auto">
30+
{% include "profiles/partials/or.html" %}
31+
</div>
32+
</div>
2933

3034
</div>
3135

@@ -36,14 +40,19 @@ <h1>{% trans "Sign In" %}</h1>
3640
<a href="{{ signup_url }}">sign up</a> first.{% endblocktrans %}</p>
3741
{% endif %}
3842

39-
<form class="login" method="POST" action="{% url 'account_login' %}">
40-
{% csrf_token %}
41-
{{ form|crispy }}
42-
{% if redirect_field_value %}
43-
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
44-
{% endif %}
45-
<button class="btn btn-primary" type="submit">{% trans "Sign In" %}</button>
46-
<a class="btn btn-secondary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
47-
</form>
43+
<div class="row">
44+
<div class="col-lg-6 col-md-8 mx-auto">
45+
<form class="login" method="POST" action="{% url 'account_login' %}">
46+
{% csrf_token %}
47+
{{ form|crispy }}
48+
{% if redirect_field_value %}
49+
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
50+
{% endif %}
51+
<button class="btn btn-primary" type="submit">{% trans "Sign In" %}</button>
52+
<a class="btn btn-secondary"
53+
href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
54+
</form>
55+
</div>
56+
</div>
4857

4958
{% endblock %}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% extends "account/base.html" %}
2+
{% load i18n %}
3+
4+
{% block content %}
5+
<h1>
6+
{% trans "Two-Factor Authentication" %}
7+
</h1>
8+
<p>{% trans "Enter the token from your authenticator app below." %}</p>
9+
<form method="post" class="mt-3">
10+
{% csrf_token %}
11+
{{ form.non_field_errors }}
12+
{{ form.otp_token.label }}:
13+
{{ form.otp_token }}
14+
<br>
15+
<button class="btn btn-primary mt-3" type="submit">
16+
{% trans 'Authenticate' %}
17+
</button>
18+
</form>
19+
{% endblock %}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{% extends "base.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}Two-Factor Authentication{% endblock %}
5+
6+
{% block breadcrumbs %}
7+
<ol class="breadcrumb">
8+
<li class="breadcrumb-item text-light">Users</li>
9+
<li class="breadcrumb-item"><a
10+
href="{% url 'profile-detail' username=request.user.username %}">{{ request.user.username }}</a></li>
11+
<li class="breadcrumb-item active" aria-current="page">Two Factor Authentication Settings</li>
12+
</ol>
13+
{% endblock %}
14+
15+
{% block content %}
16+
<h1 class="mb-3">
17+
{% trans "Two-Factor Authentication" %}
18+
</h1>
19+
<p>
20+
<i class="fas fa-check-circle text-success mr-1"></i>{% trans 'Two-Factor Authentication is enabled for your account.' %}
21+
</p>
22+
23+
<h3 class="mt-4">
24+
{% trans "Back-Up Tokens" %}
25+
</h3>
26+
<p>{% trans "If you have lost access to your authentication device, you can use back-up tokens for authentication instead. Back-up tokens can be used in the same way as the tokens generated by your authentication device. <b>Make sure to keep your back-up tokens secret and store them in a secure place</b>. Should you run out of tokens, you can generate new ones on this page." %}</p>
27+
28+
{% if backup_tokens %}
29+
{% if reveal_tokens %}
30+
<p>{% trans "We have generated the following back-up tokens. These will only be displayed once. <b>Please keep them secret and store them securely</b>." %}</p>
31+
<ul>
32+
{% for token in backup_tokens %}
33+
<li>{{ token.token }}</li>
34+
{% endfor %}
35+
</ul>
36+
{% else %}
37+
<p> {% trans 'Backup tokens have been generated, but are not revealed here for security reasons. Press the button below to generate new ones.' %} </p>
38+
{% endif %}
39+
{% else %}
40+
<p> {% trans 'No tokens. Press the button below to generate some.' %}</p>
41+
{% endif %}
42+
43+
<form method="post">
44+
{% csrf_token %}
45+
<button class="btn btn-primary" type="submit">
46+
{% trans 'Generate backup tokens' %}
47+
</button>
48+
</form>
49+
50+
<h3 class="mt-4">
51+
{% trans "Disable Two-Factor Authentication" %}
52+
</h3>
53+
<p>{% trans 'You can disable two-factor authentication for your account at any time.' %}</p>
54+
<a class="btn btn-primary" href="{% url 'two-factor-remove' %}">Disable Two Factor Authentication</a>
55+
56+
{% endblock %}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{% extends "base.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}Disable Two-Factor Authentication{% endblock %}
5+
6+
{% block breadcrumbs %}
7+
<ol class="breadcrumb">
8+
<li class="breadcrumb-item text-light">Users</li>
9+
<li class="breadcrumb-item"><a
10+
href="{% url 'profile-detail' username=request.user.username %}">{{ request.user.username }}</a></li>
11+
<li class="breadcrumb-item active" aria-current="page">Disable Two Factor Authentication</li>
12+
</ol>
13+
{% endblock %}
14+
15+
{% block content %}
16+
<h1>
17+
{% trans "Disable Two-Factor Authentication" %}
18+
</h1>
19+
20+
<p>{% trans "Are you sure you want to disable Two-Factor Authentication from your account?" %}</p>
21+
<p>{% trans "Confirm by entering the token from your authenticator app." %}</p>
22+
<form method="post" class="mt-3">
23+
{% csrf_token %}
24+
{{ form.non_field_errors }}
25+
{{ form.otp_token.label }}:
26+
{{ form.otp_token }}
27+
<br>
28+
<button class="btn btn-primary mt-3" type="submit">
29+
{% trans 'Yes, disable 2FA' %}
30+
</button>
31+
</form>
32+
{% endblock %}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{% extends "base.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}Enable Two-Factor Authentication{% endblock %}
5+
6+
{% block breadcrumbs %}
7+
<ol class="breadcrumb">
8+
<li class="breadcrumb-item text-light">Users</li>
9+
<li class="breadcrumb-item"><a
10+
href="{% url 'profile-detail' username=request.user.username %}">{{ request.user.username }}</a></li>
11+
<li class="breadcrumb-item active" aria-current="page">Set-up Two Factor Authentication</li>
12+
</ol>
13+
{% endblock %}
14+
15+
{% block content %}
16+
<h1>
17+
{% trans "Setup Two-Factor Authentication" %}
18+
</h1>
19+
20+
<h4>
21+
{% trans 'Step 1' %}:
22+
</h4>
23+
24+
<p>
25+
{% trans "Scan the QR code below with a token generator of your choice (e.g., Google Authenticator, Microsoft Authenticator)." %}
26+
</p>
27+
28+
<img src="{{ qr_code_url }}"/>
29+
<p>
30+
{% trans "If you can't use the QR code, enter " %}
31+
<a href="#secret-modal" data-toggle="modal" data-target="#secret-modal">{% trans "this code instead." %}</a>
32+
</p>
33+
<h4>
34+
{% trans 'Step 2' %}:
35+
</h4>
36+
37+
<p>
38+
{% trans 'Input the token generated by the app:' %}
39+
</p>
40+
41+
<form method="post">
42+
{% csrf_token %}
43+
{{ form.non_field_errors }}
44+
{{ form.token.label }}: {{ form.token }}
45+
46+
<button class="btn btn-primary btn-sm" type="submit">
47+
{% trans 'Verify' %}
48+
</button>
49+
</form>
50+
51+
{# modal #}
52+
<div id="secret-modal" class="modal fade" tabindex="-1" role="dialog">
53+
<div class="modal-dialog modal-dialog-centered" role="document">
54+
<div class="modal-content">
55+
<div class="modal-header">
56+
<h5 class="modal-title">Your two-factor secret</h5>
57+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
58+
<span aria-hidden="true">&times;</span>
59+
</button>
60+
</div>
61+
<div class="modal-body">
62+
<p>{{ secret_key }}</p>
63+
</div>
64+
</div>
65+
</div>
66+
</div>
67+
{% endblock %}

0 commit comments

Comments
 (0)