Skip to content

Commit 4dcbf13

Browse files
committed
Finish with example
1 parent 5284534 commit 4dcbf13

File tree

5 files changed

+127
-5
lines changed

5 files changed

+127
-5
lines changed

styleguide_example/blog_examples/admin_2fa/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Optional
23

34
import pyotp
@@ -11,6 +12,7 @@ class UserTwoFactorAuthData(models.Model):
1112
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="two_factor_auth_data", on_delete=models.CASCADE)
1213

1314
otp_secret = models.CharField(max_length=255)
15+
session_identifier = models.UUIDField(blank=True, null=True)
1416

1517
def generate_qr_code(self, name: Optional[str] = None) -> str:
1618
totp = pyotp.TOTP(self.otp_secret)
@@ -21,3 +23,13 @@ def generate_qr_code(self, name: Optional[str] = None) -> str:
2123

2224
# The result is going to be an HTML <svg> tag
2325
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"])

styleguide_example/blog_examples/admin_2fa/views.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from django import forms
12
from django.core.exceptions import ValidationError
2-
from django.views.generic import TemplateView
3+
from django.urls import reverse_lazy
4+
from django.views.generic import FormView, TemplateView
35

6+
from .models import UserTwoFactorAuthData
47
from .services import user_two_factor_auth_data_create
58

69

@@ -21,3 +24,41 @@ def post(self, request):
2124
context["form_errors"] = exc.messages
2225

2326
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)

styleguide_example/blog_examples/migrations/0003_usertwofactorauthdata.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.1.9 on 2023-07-04 12:17
1+
# Generated by Django 4.1.9 on 2023-07-05 08:49
22

33
from django.conf import settings
44
from django.db import migrations, models
@@ -18,6 +18,7 @@ class Migration(migrations.Migration):
1818
fields=[
1919
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
2020
('otp_secret', models.CharField(max_length=255)),
21+
('session_identifier', models.UUIDField(blank=True, null=True)),
2122
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='two_factor_auth_data', to=settings.AUTH_USER_MODEL)),
2223
],
2324
),
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: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,59 @@
11
from django.contrib import admin
2-
from django.urls import path
2+
from django.contrib.auth import REDIRECT_FIELD_NAME
3+
from django.urls import path, reverse
34

4-
from styleguide_example.blog_examples.admin_2fa.views import AdminSetupTwoFactorAuthView
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+
)
510

611

712
class AdminSite(admin.AdminSite):
813
def get_urls(self):
914
base_urlpatterns = super().get_urls()
1015

1116
extra_urlpatterns = [
12-
path("setup-2fa/", self.admin_view(AdminSetupTwoFactorAuthView.as_view()), name="setup-2fa")
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"),
1319
]
1420

1521
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

0 commit comments

Comments
 (0)