-
Notifications
You must be signed in to change notification settings - Fork 125
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #374 from HackSoftware/blog-examples/admin_2fa
Blog examples: Admin 2FA
- Loading branch information
Showing
18 changed files
with
304 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
from django.contrib import admin | ||
from django.utils.html import format_html | ||
|
||
from styleguide_example.blog_examples.admin_2fa.models import UserTwoFactorAuthData | ||
|
||
|
||
@admin.register(UserTwoFactorAuthData) | ||
class UserTwoFactorAuthDataAdmin(admin.ModelAdmin): | ||
""" | ||
This admin is for example purposes and ease of development and debugging. | ||
Leaving this admin in production is a security risk. | ||
Please refer to the following blog post for more information: | ||
https://hacksoft.io/blog/adding-required-two-factor-authentication-2fa-to-the-django-admin | ||
""" | ||
|
||
def qr_code(self, obj): | ||
return format_html(obj.generate_qr_code()) | ||
|
||
def get_readonly_fields(self, request, obj=None): | ||
if obj is not None: | ||
return ["user", "otp_secret", "qr_code"] | ||
else: | ||
return () |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import uuid | ||
from typing import Optional | ||
|
||
import pyotp | ||
import qrcode | ||
import qrcode.image.svg | ||
from django.conf import settings | ||
from django.db import models | ||
|
||
|
||
class UserTwoFactorAuthData(models.Model): | ||
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="two_factor_auth_data", on_delete=models.CASCADE) | ||
|
||
otp_secret = models.CharField(max_length=255) | ||
session_identifier = models.UUIDField(blank=True, null=True) | ||
|
||
def generate_qr_code(self, name: Optional[str] = None) -> str: | ||
totp = pyotp.TOTP(self.otp_secret) | ||
qr_uri = totp.provisioning_uri(name=name, issuer_name="Styleguide Example Admin 2FA Demo") | ||
|
||
image_factory = qrcode.image.svg.SvgPathImage | ||
qr_code_image = qrcode.make(qr_uri, image_factory=image_factory) | ||
|
||
# The result is going to be an HTML <svg> tag | ||
return qr_code_image.to_string().decode("utf_8") | ||
|
||
def validate_otp(self, otp: str) -> bool: | ||
totp = pyotp.TOTP(self.otp_secret) | ||
|
||
return totp.verify(otp) | ||
|
||
def rotate_session_identifier(self): | ||
self.session_identifier = uuid.uuid4() | ||
|
||
self.save(update_fields=["session_identifier"]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import pyotp | ||
from django.core.exceptions import ValidationError | ||
|
||
from styleguide_example.users.models import BaseUser | ||
|
||
from .models import UserTwoFactorAuthData | ||
|
||
|
||
def user_two_factor_auth_data_create(*, user: BaseUser) -> UserTwoFactorAuthData: | ||
if hasattr(user, "two_factor_auth_data"): | ||
raise ValidationError("Can not have more than one 2FA related data.") | ||
|
||
two_factor_auth_data = UserTwoFactorAuthData.objects.create(user=user, otp_secret=pyotp.random_base32()) | ||
|
||
return two_factor_auth_data |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
from django import forms | ||
from django.core.exceptions import ValidationError | ||
from django.urls import reverse_lazy | ||
from django.views.generic import FormView, TemplateView | ||
|
||
from .models import UserTwoFactorAuthData | ||
from .services import user_two_factor_auth_data_create | ||
|
||
|
||
class AdminSetupTwoFactorAuthView(TemplateView): | ||
template_name = "admin_2fa/setup_2fa.html" | ||
|
||
def post(self, request): | ||
context = {} | ||
user = request.user | ||
|
||
try: | ||
two_factor_auth_data = user_two_factor_auth_data_create(user=user) | ||
otp_secret = two_factor_auth_data.otp_secret | ||
|
||
context["otp_secret"] = otp_secret | ||
context["qr_code"] = two_factor_auth_data.generate_qr_code(name=user.email) | ||
except ValidationError as exc: | ||
context["form_errors"] = exc.messages | ||
|
||
return self.render_to_response(context) | ||
|
||
|
||
class AdminConfirmTwoFactorAuthView(FormView): | ||
template_name = "admin_2fa/confirm_2fa.html" | ||
success_url = reverse_lazy("admin:index") | ||
|
||
class Form(forms.Form): | ||
otp = forms.CharField(required=True) | ||
|
||
def clean_otp(self): | ||
self.two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user=self.user).first() | ||
|
||
if self.two_factor_auth_data is None: | ||
raise ValidationError("2FA not set up.") | ||
|
||
otp = self.cleaned_data.get("otp") | ||
|
||
if not self.two_factor_auth_data.validate_otp(otp): | ||
raise ValidationError("Invalid 2FA code.") | ||
|
||
return otp | ||
|
||
def get_form_class(self): | ||
return self.Form | ||
|
||
def get_form(self, *args, **kwargs): | ||
form = super().get_form(*args, **kwargs) | ||
|
||
form.user = self.request.user | ||
|
||
return form | ||
|
||
def form_valid(self, form): | ||
form.two_factor_auth_data.rotate_session_identifier() | ||
|
||
self.request.session["2fa_token"] = str(form.two_factor_auth_data.session_identifier) | ||
|
||
return super().form_valid(form) |
25 changes: 25 additions & 0 deletions
25
styleguide_example/blog_examples/migrations/0003_usertwofactorauthdata.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Generated by Django 4.1.9 on 2023-07-05 08:49 | ||
|
||
from django.conf import settings | ||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
('blog_examples', '0002_somedatamodel'), | ||
] | ||
|
||
operations = [ | ||
migrations.CreateModel( | ||
name='UserTwoFactorAuthData', | ||
fields=[ | ||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
('otp_secret', models.CharField(max_length=255)), | ||
('session_identifier', models.UUIDField(blank=True, null=True)), | ||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='two_factor_auth_data', to=settings.AUTH_USER_MODEL)), | ||
], | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
24 changes: 24 additions & 0 deletions
24
styleguide_example/blog_examples/templates/admin_2fa/confirm_2fa.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
{% extends "admin/login.html" %} | ||
|
||
{% block content %} | ||
{% if form.non_field_errors %} | ||
{% for error in form.non_field_errors %} | ||
<p class="errornote"> | ||
{{ error }} | ||
</p> | ||
{% endfor %} | ||
{% endif %} | ||
|
||
<form action="" method="post"> | ||
{% csrf_token %} | ||
|
||
<div class="form-row"> | ||
{{ form.otp.errors }} | ||
{{ form.otp.label_tag }} {{ form.otp }} | ||
</div> | ||
|
||
<div class="submit-row"> | ||
<input type="submit" value="Submit"> | ||
</div> | ||
</form> | ||
{% endblock %} |
28 changes: 28 additions & 0 deletions
28
styleguide_example/blog_examples/templates/admin_2fa/setup_2fa.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
{% extends "admin/login.html" %} | ||
|
||
{% block content %} | ||
<form action="" method="post"> | ||
{% csrf_token %} | ||
|
||
{% if otp_secret %} | ||
<p><strong>OTP Secret:</strong></p> | ||
<p>{{ otp_secret }}</p> | ||
<p>Enter it inside a 2FA app (Google Authenticator, Authy) or scan the QR code below.</p> | ||
{{ qr_code|safe }} | ||
{% else %} | ||
{% if form_errors %} | ||
{% for error in form_errors %} | ||
<p class="errornote"> | ||
{{ error }} | ||
</p> | ||
{% endfor %} | ||
{% else %} | ||
<label>Click the button generate a 2FA application code.</label> | ||
{% endif %} | ||
{% endif %} | ||
|
||
<div class="submit-row"> | ||
<input type="submit" value="Generate"> | ||
</div> | ||
</form> | ||
{% endblock %} |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
from django.contrib.admin.apps import AdminConfig as BaseAdminConfig | ||
|
||
|
||
class CustomAdminConfig(BaseAdminConfig): | ||
default_site = "styleguide_example.custom_admin.sites.AdminSite" |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
from django.contrib import admin | ||
from django.contrib.auth import REDIRECT_FIELD_NAME | ||
from django.urls import path, reverse | ||
|
||
from styleguide_example.blog_examples.admin_2fa.models import UserTwoFactorAuthData | ||
from styleguide_example.blog_examples.admin_2fa.views import ( | ||
AdminConfirmTwoFactorAuthView, | ||
AdminSetupTwoFactorAuthView, | ||
) | ||
|
||
|
||
class AdminSite(admin.AdminSite): | ||
def get_urls(self): | ||
base_urlpatterns = super().get_urls() | ||
|
||
extra_urlpatterns = [ | ||
path("setup-2fa/", self.admin_view(AdminSetupTwoFactorAuthView.as_view()), name="setup-2fa"), | ||
path("confirm-2fa/", self.admin_view(AdminConfirmTwoFactorAuthView.as_view()), name="confirm-2fa"), | ||
] | ||
|
||
return extra_urlpatterns + base_urlpatterns | ||
|
||
def login(self, request, *args, **kwargs): | ||
if request.method != "POST": | ||
return super().login(request, *args, **kwargs) | ||
|
||
username = request.POST.get("username") | ||
|
||
two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user__email=username).first() | ||
|
||
request.POST._mutable = True | ||
request.POST[REDIRECT_FIELD_NAME] = reverse("admin:confirm-2fa") | ||
|
||
if two_factor_auth_data is None: | ||
request.POST[REDIRECT_FIELD_NAME] = reverse("admin:setup-2fa") | ||
|
||
request.POST._mutable = False | ||
|
||
return super().login(request, *args, **kwargs) | ||
|
||
def has_permission(self, request): | ||
has_perm = super().has_permission(request) | ||
|
||
if not has_perm: | ||
return has_perm | ||
|
||
two_factor_auth_data = UserTwoFactorAuthData.objects.filter(user=request.user).first() | ||
|
||
allowed_paths = [reverse("admin:confirm-2fa"), reverse("admin:setup-2fa")] | ||
|
||
if request.path in allowed_paths: | ||
return True | ||
|
||
if two_factor_auth_data is not None: | ||
two_factor_auth_token = request.session.get("2fa_token") | ||
|
||
return str(two_factor_auth_data.session_identifier) == two_factor_auth_token | ||
|
||
return False |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
{% extends "admin/base_site.html" %} | ||
|
||
{% block userlinks %} | ||
{% if user.is_active and user.is_staff %} | ||
<a href="{% url "admin:setup-2fa" %}"> Setup 2FA </a> / | ||
{% endif %} | ||
|
||
{{ block.super }} | ||
{% endblock %} |