Skip to content

Commit 0427252

Browse files
authored
Merge pull request #374 from HackSoftware/blog-examples/admin_2fa
Blog examples: Admin 2FA
2 parents ea903b4 + 33e4455 commit 0427252

File tree

18 files changed

+304
-2
lines changed

18 files changed

+304
-2
lines changed

config/django/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import os
1414

15-
from config.env import BASE_DIR, env
15+
from config.env import BASE_DIR, APPS_DIR, env
1616

1717
env.read_env(os.path.join(BASE_DIR, ".env"))
1818

@@ -55,6 +55,9 @@
5555

5656
INSTALLED_APPS = [
5757
"django.contrib.admin",
58+
# If you want to have required 2FA for the Django admin
59+
# Uncomment the line below and comment out the default admin
60+
# "styleguide_example.custom_admin.apps.CustomAdminConfig",
5861
"django.contrib.auth",
5962
"django.contrib.contenttypes",
6063
"django.contrib.sessions",
@@ -80,10 +83,12 @@
8083

8184
ROOT_URLCONF = "config.urls"
8285

86+
print(os.path.join(APPS_DIR, "templates"))
87+
8388
TEMPLATES = [
8489
{
8590
"BACKEND": "django.template.backends.django.DjangoTemplates",
86-
"DIRS": [],
91+
"DIRS": [os.path.join(APPS_DIR, "templates")],
8792
"APP_DIRS": True,
8893
"OPTIONS": {
8994
"context_processors": [

config/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
env = environ.Env()
55

66
BASE_DIR = environ.Path(__file__) - 2
7+
APPS_DIR = BASE_DIR.path("styleguide_example")
78

89

910
def env_to_enum(enum_cls, value):

mypy.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ ignore_missing_imports = True
4141
[mypy-oauthlib.*]
4242
# Remove this when oauthlib stubs are present
4343
ignore_missing_imports = True
44+
45+
[mypy-qrcode.*]
46+
# Remove this when qrcode stubs are present
47+
ignore_missing_imports = True

requirements/base.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ google-api-python-client==2.86.0
2828
google-auth==2.21.0
2929
google-auth-httplib2==0.1.0
3030
google-auth-oauthlib==1.0.0
31+
32+
pyotp==2.8.0
33+
qrcode==7.4.2
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.contrib import admin
2+
from django.utils.html import format_html
3+
4+
from styleguide_example.blog_examples.admin_2fa.models import UserTwoFactorAuthData
5+
6+
7+
@admin.register(UserTwoFactorAuthData)
8+
class UserTwoFactorAuthDataAdmin(admin.ModelAdmin):
9+
"""
10+
This admin is for example purposes and ease of development and debugging.
11+
Leaving this admin in production is a security risk.
12+
13+
Please refer to the following blog post for more information:
14+
https://hacksoft.io/blog/adding-required-two-factor-authentication-2fa-to-the-django-admin
15+
"""
16+
17+
def qr_code(self, obj):
18+
return format_html(obj.generate_qr_code())
19+
20+
def get_readonly_fields(self, request, obj=None):
21+
if obj is not None:
22+
return ["user", "otp_secret", "qr_code"]
23+
else:
24+
return ()

styleguide_example/blog_examples/admin_2fa/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import uuid
2+
from typing import Optional
3+
4+
import pyotp
5+
import qrcode
6+
import qrcode.image.svg
7+
from django.conf import settings
8+
from django.db import models
9+
10+
11+
class UserTwoFactorAuthData(models.Model):
12+
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="two_factor_auth_data", on_delete=models.CASCADE)
13+
14+
otp_secret = models.CharField(max_length=255)
15+
session_identifier = models.UUIDField(blank=True, null=True)
16+
17+
def generate_qr_code(self, name: Optional[str] = None) -> str:
18+
totp = pyotp.TOTP(self.otp_secret)
19+
qr_uri = totp.provisioning_uri(name=name, issuer_name="Styleguide Example Admin 2FA Demo")
20+
21+
image_factory = qrcode.image.svg.SvgPathImage
22+
qr_code_image = qrcode.make(qr_uri, image_factory=image_factory)
23+
24+
# The result is going to be an HTML <svg> tag
25+
return qr_code_image.to_string().decode("utf_8")
26+
27+
def validate_otp(self, otp: str) -> bool:
28+
totp = pyotp.TOTP(self.otp_secret)
29+
30+
return totp.verify(otp)
31+
32+
def rotate_session_identifier(self):
33+
self.session_identifier = uuid.uuid4()
34+
35+
self.save(update_fields=["session_identifier"])
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pyotp
2+
from django.core.exceptions import ValidationError
3+
4+
from styleguide_example.users.models import BaseUser
5+
6+
from .models import UserTwoFactorAuthData
7+
8+
9+
def user_two_factor_auth_data_create(*, user: BaseUser) -> UserTwoFactorAuthData:
10+
if hasattr(user, "two_factor_auth_data"):
11+
raise ValidationError("Can not have more than one 2FA related data.")
12+
13+
two_factor_auth_data = UserTwoFactorAuthData.objects.create(user=user, otp_secret=pyotp.random_base32())
14+
15+
return two_factor_auth_data
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django import forms
2+
from django.core.exceptions import ValidationError
3+
from django.urls import reverse_lazy
4+
from django.views.generic import FormView, TemplateView
5+
6+
from .models import UserTwoFactorAuthData
7+
from .services import user_two_factor_auth_data_create
8+
9+
10+
class AdminSetupTwoFactorAuthView(TemplateView):
11+
template_name = "admin_2fa/setup_2fa.html"
12+
13+
def post(self, request):
14+
context = {}
15+
user = request.user
16+
17+
try:
18+
two_factor_auth_data = user_two_factor_auth_data_create(user=user)
19+
otp_secret = two_factor_auth_data.otp_secret
20+
21+
context["otp_secret"] = otp_secret
22+
context["qr_code"] = two_factor_auth_data.generate_qr_code(name=user.email)
23+
except ValidationError as exc:
24+
context["form_errors"] = exc.messages
25+
26+
return self.render_to_response(context)
27+
28+
29+
class AdminConfirmTwoFactorAuthView(FormView):
30+
template_name = "admin_2fa/confirm_2fa.html"
31+
success_url = reverse_lazy("admin:index")
32+
33+
class Form(forms.Form):
34+
otp = forms.CharField(required=True)
35+
36+
def clean_otp(self):
37+
self.two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user=self.user).first()
38+
39+
if self.two_factor_auth_data is None:
40+
raise ValidationError("2FA not set up.")
41+
42+
otp = self.cleaned_data.get("otp")
43+
44+
if not self.two_factor_auth_data.validate_otp(otp):
45+
raise ValidationError("Invalid 2FA code.")
46+
47+
return otp
48+
49+
def get_form_class(self):
50+
return self.Form
51+
52+
def get_form(self, *args, **kwargs):
53+
form = super().get_form(*args, **kwargs)
54+
55+
form.user = self.request.user
56+
57+
return form
58+
59+
def form_valid(self, form):
60+
form.two_factor_auth_data.rotate_session_identifier()
61+
62+
self.request.session["2fa_token"] = str(form.two_factor_auth_data.session_identifier)
63+
64+
return super().form_valid(form)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 4.1.9 on 2023-07-05 08:49
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('blog_examples', '0002_somedatamodel'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='UserTwoFactorAuthData',
18+
fields=[
19+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('otp_secret', models.CharField(max_length=255)),
21+
('session_identifier', models.UUIDField(blank=True, null=True)),
22+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='two_factor_auth_data', to=settings.AUTH_USER_MODEL)),
23+
],
24+
),
25+
]

styleguide_example/blog_examples/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.db import models
22
from django.utils import timezone
33

4+
from .admin_2fa.models import UserTwoFactorAuthData # noqa
45
from .f_expressions.models import SomeDataModel # noqa
56

67

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{% extends "admin/login.html" %}
2+
3+
{% block content %}
4+
{% if form.non_field_errors %}
5+
{% for error in form.non_field_errors %}
6+
<p class="errornote">
7+
{{ error }}
8+
</p>
9+
{% endfor %}
10+
{% endif %}
11+
12+
<form action="" method="post">
13+
{% csrf_token %}
14+
15+
<div class="form-row">
16+
{{ form.otp.errors }}
17+
{{ form.otp.label_tag }} {{ form.otp }}
18+
</div>
19+
20+
<div class="submit-row">
21+
<input type="submit" value="Submit">
22+
</div>
23+
</form>
24+
{% endblock %}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{% extends "admin/login.html" %}
2+
3+
{% block content %}
4+
<form action="" method="post">
5+
{% csrf_token %}
6+
7+
{% if otp_secret %}
8+
<p><strong>OTP Secret:</strong></p>
9+
<p>{{ otp_secret }}</p>
10+
<p>Enter it inside a 2FA app (Google Authenticator, Authy) or scan the QR code below.</p>
11+
{{ qr_code|safe }}
12+
{% else %}
13+
{% if form_errors %}
14+
{% for error in form_errors %}
15+
<p class="errornote">
16+
{{ error }}
17+
</p>
18+
{% endfor %}
19+
{% else %}
20+
<label>Click the button generate a 2FA application code.</label>
21+
{% endif %}
22+
{% endif %}
23+
24+
<div class="submit-row">
25+
<input type="submit" value="Generate">
26+
</div>
27+
</form>
28+
{% endblock %}

styleguide_example/custom_admin/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.contrib.admin.apps import AdminConfig as BaseAdminConfig
2+
3+
4+
class CustomAdminConfig(BaseAdminConfig):
5+
default_site = "styleguide_example.custom_admin.sites.AdminSite"

styleguide_example/custom_admin/migrations/__init__.py

Whitespace-only changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from django.contrib import admin
2+
from django.contrib.auth import REDIRECT_FIELD_NAME
3+
from django.urls import path, reverse
4+
5+
from styleguide_example.blog_examples.admin_2fa.models import UserTwoFactorAuthData
6+
from styleguide_example.blog_examples.admin_2fa.views import (
7+
AdminConfirmTwoFactorAuthView,
8+
AdminSetupTwoFactorAuthView,
9+
)
10+
11+
12+
class AdminSite(admin.AdminSite):
13+
def get_urls(self):
14+
base_urlpatterns = super().get_urls()
15+
16+
extra_urlpatterns = [
17+
path("setup-2fa/", self.admin_view(AdminSetupTwoFactorAuthView.as_view()), name="setup-2fa"),
18+
path("confirm-2fa/", self.admin_view(AdminConfirmTwoFactorAuthView.as_view()), name="confirm-2fa"),
19+
]
20+
21+
return extra_urlpatterns + base_urlpatterns
22+
23+
def login(self, request, *args, **kwargs):
24+
if request.method != "POST":
25+
return super().login(request, *args, **kwargs)
26+
27+
username = request.POST.get("username")
28+
29+
two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user__email=username).first()
30+
31+
request.POST._mutable = True
32+
request.POST[REDIRECT_FIELD_NAME] = reverse("admin:confirm-2fa")
33+
34+
if two_factor_auth_data is None:
35+
request.POST[REDIRECT_FIELD_NAME] = reverse("admin:setup-2fa")
36+
37+
request.POST._mutable = False
38+
39+
return super().login(request, *args, **kwargs)
40+
41+
def has_permission(self, request):
42+
has_perm = super().has_permission(request)
43+
44+
if not has_perm:
45+
return has_perm
46+
47+
two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user=request.user).first()
48+
49+
allowed_paths = [reverse("admin:confirm-2fa"), reverse("admin:setup-2fa")]
50+
51+
if request.path in allowed_paths:
52+
return True
53+
54+
if two_factor_auth_data is not None:
55+
two_factor_auth_token = request.session.get("2fa_token")
56+
57+
return str(two_factor_auth_data.session_identifier) == two_factor_auth_token
58+
59+
return False
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends "admin/base_site.html" %}
2+
3+
{% block userlinks %}
4+
{% if user.is_active and user.is_staff %}
5+
<a href="{% url "admin:setup-2fa" %}"> Setup 2FA </a> /
6+
{% endif %}
7+
8+
{{ block.super }}
9+
{% endblock %}

0 commit comments

Comments
 (0)