Skip to content

Commit

Permalink
Merge pull request #374 from HackSoftware/blog-examples/admin_2fa
Browse files Browse the repository at this point in the history
Blog examples: Admin 2FA
  • Loading branch information
RadoRado authored Jul 11, 2023
2 parents ea903b4 + 33e4455 commit 0427252
Show file tree
Hide file tree
Showing 18 changed files with 304 additions and 2 deletions.
9 changes: 7 additions & 2 deletions config/django/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import os

from config.env import BASE_DIR, env
from config.env import BASE_DIR, APPS_DIR, env

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

Expand Down Expand Up @@ -55,6 +55,9 @@

INSTALLED_APPS = [
"django.contrib.admin",
# If you want to have required 2FA for the Django admin
# Uncomment the line below and comment out the default admin
# "styleguide_example.custom_admin.apps.CustomAdminConfig",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
Expand All @@ -80,10 +83,12 @@

ROOT_URLCONF = "config.urls"

print(os.path.join(APPS_DIR, "templates"))

TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"DIRS": [os.path.join(APPS_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
Expand Down
1 change: 1 addition & 0 deletions config/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
env = environ.Env()

BASE_DIR = environ.Path(__file__) - 2
APPS_DIR = BASE_DIR.path("styleguide_example")


def env_to_enum(enum_cls, value):
Expand Down
4 changes: 4 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ ignore_missing_imports = True
[mypy-oauthlib.*]
# Remove this when oauthlib stubs are present
ignore_missing_imports = True

[mypy-qrcode.*]
# Remove this when qrcode stubs are present
ignore_missing_imports = True
3 changes: 3 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ google-api-python-client==2.86.0
google-auth==2.21.0
google-auth-httplib2==0.1.0
google-auth-oauthlib==1.0.0

pyotp==2.8.0
qrcode==7.4.2
24 changes: 24 additions & 0 deletions styleguide_example/blog_examples/admin.py
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.
35 changes: 35 additions & 0 deletions styleguide_example/blog_examples/admin_2fa/models.py
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"])
15 changes: 15 additions & 0 deletions styleguide_example/blog_examples/admin_2fa/services.py
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
64 changes: 64 additions & 0 deletions styleguide_example/blog_examples/admin_2fa/views.py
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)
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)),
],
),
]
1 change: 1 addition & 0 deletions styleguide_example/blog_examples/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.db import models
from django.utils import timezone

from .admin_2fa.models import UserTwoFactorAuthData # noqa
from .f_expressions.models import SomeDataModel # noqa


Expand Down
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 %}
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.
5 changes: 5 additions & 0 deletions styleguide_example/custom_admin/apps.py
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.
59 changes: 59 additions & 0 deletions styleguide_example/custom_admin/sites.py
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
9 changes: 9 additions & 0 deletions styleguide_example/templates/admin/base_site.html
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 %}

0 comments on commit 0427252

Please sign in to comment.