Skip to content

Commit 14d93df

Browse files
committed
Add 2FA using allauth
Most of the work here is templating and ensuring the settings are correct. There's no 2FA mandate and this doesn't enable webauthn/passkeys for now, strictly TOTP and recovery codes.
1 parent 3998a00 commit 14d93df

23 files changed

+459
-9
lines changed

.coveragerc

+6-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ omit =
1818

1919
*/migrations/*.py
2020
templates/admin/*.html
21-
templates/account/*.html
21+
22+
# Skip allauth templates
23+
templates/allauth/*
24+
templates/account/*
25+
templates/mfa/*
26+
2227
templates/includes/*.html
2328
adserver/templatetags/metabase.py
2429
adserver/regiontopics.py # Just data/constants

adserver/templates/adserver/base.html

+4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
<span class="fa fa-key fa-fw mr-2 text-muted" aria-hidden="true"></span>
4343
<span>{% trans 'Change password' %}</span>
4444
</a>
45+
<a class="dropdown-item" href="{% url 'mfa_index' %}">
46+
<span class="fa fa-unlock-alt fa-fw mr-2 text-muted" aria-hidden="true"></span>
47+
<span>{% trans 'MFA' %}</span>
48+
</a>
4549
<div class="dropdown-divider"></div>
4650
<a class="dropdown-item" href="{% url 'support' %}">
4751
<span class="fa fa-envelope fa-fw mr-2 text-muted" aria-hidden="true"></span>

config/settings/base.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"django.contrib.humanize",
7171
"allauth",
7272
"allauth.account",
73-
"allauth.socialaccount",
73+
"allauth.mfa",
7474
"crispy_forms",
7575
"crispy_bootstrap4",
7676
"rest_framework",
@@ -382,14 +382,18 @@
382382

383383

384384
# Django allauth
385-
# https://django-allauth.readthedocs.io
385+
# https://docs.allauth.org
386+
# https://docs.allauth.org/en/latest/account/advanced.html#custom-user-models
387+
# https://docs.allauth.org/en/latest/mfa/introduction.html
386388
# --------------------------------------------------------------------------
387389
ACCOUNT_ADAPTER = "adserver.auth.adapters.AdServerAccountAdapter"
388390
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
389391
ACCOUNT_EMAIL_REQUIRED = True
390392
ACCOUNT_USERNAME_REQUIRED = False
391-
ACCOUNT_AUTHENTICATION_METHOD = "email"
392393
ACCOUNT_LOGIN_ON_PASSWORD_RESET = True
394+
ACCOUNT_MAX_EMAIL_ADDRESSES = 1
395+
ACCOUNT_LOGIN_METHODS = {"email"}
396+
393397

394398
# Celery settings for asynchronous tasks
395399
# http://docs.celeryproject.org/en/latest/userguide/configuration.html

config/settings/production.py

-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@
8989
if not socket.gethostname().startswith("ethicalads-extra"):
9090
ENFORCE_HOST = env("ENFORCE_HOST", default=None)
9191

92-
9392
# Email settings
9493
# See: https://anymail.readthedocs.io
9594
# --------------------------------------------------------------------------

config/urls.py

+7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.urls import include
77
from django.urls import path
88
from django.views import defaults as default_views
9+
from django.views.generic import RedirectView
910

1011

1112
urlpatterns = []
@@ -56,6 +57,12 @@
5657
]
5758

5859
urlpatterns += [
60+
# Allauth overrides
61+
# Disable managing emails for now
62+
path(
63+
r"accounts/email/",
64+
RedirectView.as_view(pattern_name="dashboard-home", permanent=False),
65+
),
5966
path(r"accounts/", include("allauth.urls")),
6067
path(r"stripe/", include("djstripe.urls", namespace="djstripe")),
6168
path(r"", include("adserver.urls")),

requirements/base.in

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ crispy-bootstrap4
2424
django-crispy-forms
2525

2626
# Authentication
27-
django-allauth
27+
django-allauth[mfa]
2828

2929
# Reading Django settings environment variables
3030
django-environ

requirements/base.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ django==5.0.12
6363
# django-slack
6464
# djangorestframework
6565
# jsonfield
66-
django-allauth==65.4.1
66+
django-allauth[mfa]==65.4.1
6767
# via -r base.in
6868
django-cors-headers==4.7.0
6969
# via -r base.in

requirements/development.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ django==5.0.12
9696
# django-slack
9797
# djangorestframework
9898
# jsonfield
99-
django-allauth==65.4.1
99+
django-allauth[mfa]==65.4.1
100100
# via -r /home/david/ReadTheDocs/ethical-ad-server/requirements/base.in
101101
django-cors-headers==4.7.0
102102
# via -r /home/david/ReadTheDocs/ethical-ad-server/requirements/base.in

requirements/production.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ django==5.0.12
7878
# django-storages
7979
# djangorestframework
8080
# jsonfield
81-
django-allauth==65.4.1
81+
django-allauth[mfa]==65.4.1
8282
# via -r /home/david/ReadTheDocs/ethical-ad-server/requirements/base.in
8383
django-anymail==12.0
8484
# via -r production.in
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{% extends "account/base_entrance.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/account/base_reauthenticate.html #}
4+
5+
6+
{% load allauth %}
7+
{% load i18n %}
8+
9+
10+
{% block title %}{% trans 'Confirm Access' %}{% endblock title %}
11+
12+
13+
{% block content %}
14+
15+
{% block reauthenticate_content %}
16+
{% endblock reauthenticate_content %}
17+
18+
{% if reauthentication_alternatives %}
19+
<hr>
20+
21+
<h3>{% translate "Alternative options" %}</h3>
22+
23+
{% for alt in reauthentication_alternatives %}
24+
<a href="{{ alt.url }}">{{ alt.description }}</a>
25+
{% endfor %}
26+
{% endif %}
27+
{% endblock content %}

templates/account/reauthenticate.html

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% extends "account/base_reauthenticate.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/account/reauthenticate.html #}
4+
5+
{% load blocktrans trans from i18n %}
6+
{% load crispy from crispy_forms_tags %}
7+
8+
9+
{% block reauthenticate_content %}
10+
<h1>{% trans 'Confirm Access' %}</h1>
11+
12+
<p>
13+
{% blocktrans trimmed %}
14+
Enter your account password to confirm access.
15+
{% endblocktrans %}
16+
</p>
17+
18+
{% url 'account_reauthenticate' as action_url %}
19+
<form method="post" action="{{ action_url }}">
20+
{% csrf_token %}
21+
{{ form|crispy }}
22+
{{ redirect_field }}
23+
<input class="btn btn-primary" type="submit" value="{% trans 'Confirm ' %}">
24+
</form>
25+
{% endblock reauthenticate_content %}

templates/allauth/layouts/base.html

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{% extends "adserver/dashboard.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/allauth/layouts/base.html #}
4+
5+
{% load i18n %}
6+
{% load static %}
7+
{% load humanize %}
8+
{% load crispy_forms_tags %}
9+
10+
11+
{% block title %}{% trans "Account" %}{% endblock %}
12+
13+
14+
{% block breadcrumbs %}
15+
<li class="breadcrumb-item">
16+
<a href="{% url 'dashboard-home' %}">{% trans 'Home' %}</a>
17+
</li>
18+
<li class="breadcrumb-item active">{% trans 'Account' %}</li>
19+
{% endblock breadcrumbs %}
20+
21+
22+
{% block content_container %}
23+
24+
<h1>{% block heading %}{% trans "Account" %}{% endblock heading %}</h1>
25+
26+
<div class="row">
27+
<div class="col col-md-8">
28+
29+
{% block content %}{% endblock content %}
30+
31+
</div>
32+
</div>
33+
34+
{% endblock content_container %}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{% extends 'account/base.html' %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/allauth/layouts/entrance.html #}

templates/mfa/authenticate.html

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends "mfa/base_entrance.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/mfa/authenticate.html #}
4+
5+
{% load blocktrans trans from i18n %}
6+
{% load crispy from crispy_forms_tags %}
7+
8+
{% block title %}
9+
{% trans "Verify sign in" %}
10+
{% endblock title %}
11+
12+
{% block content %}
13+
14+
<h1 class="card-title">{% trans 'Verify sign in' %}</h1>
15+
16+
<p>
17+
{% blocktrans trimmed %}
18+
Your account is protected by two-factor authentication.
19+
Enter a two-factor authentication code to verify this sign in.
20+
{% endblocktrans %}
21+
</p>
22+
23+
{% url 'mfa_authenticate' as action_url %}
24+
<form method="post" action="{{ action_url }}">
25+
{% csrf_token %}
26+
{{ form|crispy }}
27+
{{ redirect_field }}
28+
29+
<input type="submit" value="{% trans 'Verify' %}" class="btn btn-primary" />
30+
</form>
31+
32+
{% if "webauthn" in MFA_SUPPORTED_TYPES %}
33+
{# WebAuthn (Passkeys) will require additional work to support #}
34+
{% endif %}
35+
{% endblock content %}

templates/mfa/base_manage.html

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends "adserver/dashboard.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/mfa/base_manage.html #}
4+
5+
{% load i18n %}
6+
{% load static %}
7+
{% load humanize %}
8+
{% load crispy_forms_tags %}
9+
10+
11+
{% block title %}{% trans "MFA" %}{% endblock %}
12+
13+
14+
{% block breadcrumbs %}
15+
<li class="breadcrumb-item">
16+
<a href="{% url 'dashboard-home' %}">{% trans 'Home' %}</a>
17+
</li>
18+
<li class="breadcrumb-item">
19+
<a href="{% url 'account' %}">{% trans 'Account' %}</a>
20+
</li>
21+
<li class="breadcrumb-item active">{% trans 'MFA' %}</li>
22+
{% endblock breadcrumbs %}
23+
24+
25+
{% block content_container %}
26+
27+
<div class="row">
28+
<div class="col">
29+
30+
{% block content %}{% endblock content %}
31+
32+
</div>
33+
</div>
34+
35+
{% endblock content_container %}

templates/mfa/index.html

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{% extends "mfa/base_manage.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/mfa/index.html #}
4+
5+
6+
{% load allauth %}
7+
{% load i18n %}
8+
9+
10+
{% block content %}
11+
12+
<h1>{% block heading %}{% trans "MFA" %}{% endblock heading %}</h1>
13+
14+
<p>{% trans 'Configure or update your multi-factor authentication settings.' %}</p>
15+
16+
17+
{% if "totp" in MFA_SUPPORTED_TYPES %}
18+
19+
<section class="my-5" id="mfa-totp">
20+
<h3>{% trans "Authenticator App" %}</h3>
21+
22+
{% url 'mfa_deactivate_totp' as deactivate_url %}
23+
{% url 'mfa_activate_totp' as activate_url %}
24+
25+
{% if authenticators.totp %}
26+
<p>
27+
{% blocktrans trimmed %}
28+
Two-factor authentication using an authenticator app is enabled.
29+
{% endblocktrans %}
30+
</p>
31+
<p class="mb-0"><a href="{{ deactivate_url }}" class="btn btn-sm btn-danger">{% trans 'Deactivate MFA' %}</a></p>
32+
<p class="small text-muted">{% trans '(You will have a chance to confirm)' %}</p>
33+
{% else %}
34+
<p>
35+
{% blocktrans trimmed %}
36+
Two-factor authentication using an authenticator app is not enabled.
37+
{% endblocktrans %}
38+
</p>
39+
<p><a href="{{ activate_url }}" class="btn btn-sm btn-outline-primary">{% trans 'Activate MFA' %}</a></p>
40+
{% endif %}
41+
</section>
42+
43+
{% endif %}
44+
45+
{% block mfa_webauthn %}
46+
{% if "webauthn" in MFA_SUPPORTED_TYPES %}
47+
{# TODO if we end up supporting webauthn/passkeys this in the future, this section will need ported #}
48+
{% endif %}
49+
{% endblock mfa_webauthn %}
50+
51+
{% block mfa_recovery %}
52+
{% if is_mfa_enabled and "recovery_codes" in MFA_SUPPORTED_TYPES %}
53+
54+
<section class="my-5" id="mfa-codes">
55+
<h3>{% translate "Recovery Codes" %}</h3>
56+
57+
<p>
58+
{% blocktrans trimmed %}
59+
Recovery codes are one-time use codes that can be used as backup two-factor authentication codes.
60+
{% endblocktrans %}
61+
</p>
62+
63+
{% url 'mfa_view_recovery_codes' as view_url %}
64+
<a href="{{ view_url }}" class="btn btn-sm btn-outline-primary">{% trans "Manage recovery codes" %}</a>
65+
</section>
66+
67+
{% endif %}
68+
{% endblock mfa_recovery %}
69+
{% endblock content %}

templates/mfa/reauthenticate.html

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{% extends "account/base_reauthenticate.html" %}
2+
3+
{# https://github.com/pennersr/django-allauth/blob/main/allauth/templates/mfa/reauthenticate.html #}
4+
5+
6+
{% load i18n %}
7+
{% load allauth %}
8+
{% load crispy from crispy_forms_tags %}
9+
10+
11+
{% block reauthenticate_content %}
12+
13+
<h1>{% trans 'Confirm Access' %}</h1>
14+
15+
<p>
16+
{% blocktrans trimmed %}
17+
Enter a two-factor authentication code to confirm access.
18+
{% endblocktrans %}
19+
</p>
20+
21+
{% url 'mfa_reauthenticate' as action_url %}
22+
<form method="post" action="{{ action_url }}">
23+
{% csrf_token %}
24+
{{ form|crispy }}
25+
{{ redirect_field }}
26+
<input type="submit" class="btn btn-primary" value="{% trans 'Confirm' %}">
27+
</form>
28+
29+
{% endblock %}

0 commit comments

Comments
 (0)