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+
]

0 commit comments

Comments
 (0)