django-mfa3

multi factor authentication for django
git clone https://git.ce9e.org/django-mfa3.git

commit
8194db4b25345b75002cd9e81ca27659b3f63294
parent
07110a1499d5305c358a0acf0b7aa51e2c45fcae
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2022-09-22 08:15
send email on failed login

this could indicate a case where an attacker has gained access to
username and password, but not to the second factor

inspired by:

- https://syslog.ravelin.com/2fa-is-missing-a-key-feature-c781c3861db
- https://docs.djangoproject.com/en/4.1/topics/auth/default/#django.contrib.auth.forms.PasswordResetForm

Diffstat

M README.md 17 +++++++++++++++++
A mfa/mail.py 41 +++++++++++++++++++++++++++++++++++++++++
A mfa/templates/mfa/login_failed_subject.txt 4 ++++
M mfa/views.py 2 ++
M tests/settings.py 4 +++-
A tests/templates/mfa/login_failed_email.txt 6 ++++++
M tests/tests.py 25 +++++++++++++++++++++++++

7 files changed, 98 insertions, 1 deletions


diff --git a/README.md b/README.md

@@ -38,6 +38,23 @@ Optionally, you can add `'mfa.middleware.MFAEnforceMiddleware'` to `MIDDLEWARE`
   38    38 requests to `'mfa:list'` as long as the user has no MFAKeys. You can use
   39    39 `mfa.decorators.public` to add exceptions.
   40    40 
   -1    41 ## Send email on failed login attempt
   -1    42 
   -1    43 If someone failes to login on the second factor that might indicate that the
   -1    44 first factor (password) has been compromised. django-mfa3 will automatically
   -1    45 send a warning to affected users under the following conditions:
   -1    46 
   -1    47 -   Django needs to be [configured for sending email](https://docs.djangoproject.com/en/4.1/topics/email/)
   -1    48 -   There must be an email address associated with the user account
   -1    49 -   You need to provide some templates
   -1    50     -   `mfa/login_failed_subject.txt`: optional, a default is included
   -1    51     -   `mfa/login_failed_email.txt`: required, an example is included in the
   -1    52         [tests](/tests/templates/mfa/login_failed_email.txt)
   -1    53     -   `mfa/login_failed_email.html`: optional
   -1    54 
   -1    55 All templates have access to the following context data: `email`, `domain`,
   -1    56 `site_name`, `user`, `method`.
   -1    57 
   41    58 ## Status
   42    59 
   43    60 I am not sure whether I will be able to maintain this library long-term. If you

diff --git a/mfa/mail.py b/mfa/mail.py

@@ -0,0 +1,41 @@
   -1     1 from django.core.mail import EmailMultiAlternatives
   -1     2 from django.template import TemplateDoesNotExist
   -1     3 from django.template import loader
   -1     4 
   -1     5 from . import settings
   -1     6 
   -1     7 SUBJECT_TEMPLATE = 'mfa/login_failed_subject.txt'
   -1     8 BODY_TEMPLATE = 'mfa/login_failed_email.txt'
   -1     9 HTML_TEMPLATE = 'mfa/login_failed_email.html'
   -1    10 
   -1    11 
   -1    12 def send_mail(user, method):
   -1    13     email_field_name = user.get_email_field_name()
   -1    14     user_email = getattr(user, email_field_name)
   -1    15     if not user_email:
   -1    16         return 0
   -1    17 
   -1    18     context = {
   -1    19         'email': user_email,
   -1    20         'domain': settings.DOMAIN,
   -1    21         'site_name': settings.SITE_TITLE,
   -1    22         'user': user,
   -1    23         'method': method.name,
   -1    24     }
   -1    25 
   -1    26     try:
   -1    27         subject = loader.render_to_string(SUBJECT_TEMPLATE, context)
   -1    28         subject = ' '.join(subject.splitlines()).strip()
   -1    29         body = loader.render_to_string(BODY_TEMPLATE, context)
   -1    30     except TemplateDoesNotExist:
   -1    31         return 0
   -1    32 
   -1    33     message = EmailMultiAlternatives(subject, body, to=[user_email])
   -1    34 
   -1    35     try:
   -1    36         html_body = loader.render_to_string(HTML_TEMPLATE, context)
   -1    37         message.attach_alternative(html_body, 'text/html')
   -1    38     except TemplateDoesNotExist:
   -1    39         pass
   -1    40 
   -1    41     return message.send()

diff --git a/mfa/templates/mfa/login_failed_subject.txt b/mfa/templates/mfa/login_failed_subject.txt

@@ -0,0 +1,3 @@
   -1     1 {% load i18n %}{% autoescape off %}
   -1     2 {% blocktranslate %}Failed login on {{ site_name }}{% endblocktranslate %}
   -1     3 {% endautoescape %
   -1     3 
\ No newline at end of file

diff --git a/mfa/views.py b/mfa/views.py

@@ -16,6 +16,7 @@ from django.views.generic import ListView
   16    16 from . import settings
   17    17 from .forms import MFAAuthForm
   18    18 from .forms import MFACreateForm
   -1    19 from .mail import send_mail
   19    20 from .mixins import MFAFormView
   20    21 from .models import MFAKey
   21    22 
@@ -125,6 +126,7 @@ class MFAAuthView(StrongholdPublicMixin, MFAFormView):
  125   126             credentials={'username': self.user.get_username()},
  126   127             request=self.request,
  127   128         )
   -1   129         send_mail(self.user, self.method)
  128   130         return super().form_invalid(form)
  129   131 
  130   132     def form_valid(self, form):

diff --git a/tests/settings.py b/tests/settings.py

@@ -1,3 +1,5 @@
   -1     1 from pathlib import Path
   -1     2 
    1     3 DATABASES = {
    2     4     'default': {
    3     5         'ENGINE': 'django.db.backends.sqlite3',
@@ -39,7 +41,7 @@ INSTALLED_APPS = [
   39    41 TEMPLATES = [
   40    42     {
   41    43         'BACKEND': 'django.template.backends.django.DjangoTemplates',
   42    -1         'DIRS': [],
   -1    44         'DIRS': [Path(__file__).parent / 'templates'],
   43    45         'APP_DIRS': True,
   44    46         'OPTIONS': {
   45    47             'context_processors': [

diff --git a/tests/templates/mfa/login_failed_email.txt b/tests/templates/mfa/login_failed_email.txt

@@ -0,0 +1,6 @@
   -1     1 Dear {{ user.username }},
   -1     2 
   -1     3 someone tried to log in to your account at {{ site_name }} ({{ domain }}).
   -1     4 They managed to enter the correct password, but failed at {{ method }}.
   -1     5 
   -1     6 If this wasn't you we strongly recommend to change your password.

diff --git a/tests/tests.py b/tests/tests.py

@@ -1,3 +1,5 @@
   -1     1 from django.core import mail
   -1     2 
    1     3 import pyotp
    2     4 from django.test import TestCase
    3     5 from django.contrib.auth.hashers import make_password
@@ -6,6 +8,7 @@ from django.contrib.auth.models import User
    6     8 from mfa.methods import fido2
    7     9 from mfa.models import MFAKey
    8    10 from mfa.templatetags.mfa import get_qrcode
   -1    11 from mfa.mail import send_mail
    9    12 
   10    13 
   11    14 class MFATestCase(TestCase):
@@ -292,3 +295,25 @@ class QRCodeTest(TestCase):
  292   295         code = get_qrcode('some_data')
  293   296         self.assertTrue(code.startswith('<svg'))
  294   297         self.assertTrue(code.endswith('</svg>'))
   -1   298 
   -1   299 
   -1   300 class MailTest(TestCase):
   -1   301     def setUp(self):
   -1   302         self.user = User.objects.create_user(
   -1   303             'test', password='password', email='test@example.com'
   -1   304         )
   -1   305 
   -1   306     def test_send_mail(self):
   -1   307         count = send_mail(self.user, fido2)
   -1   308         self.assertEqual(count, 1)
   -1   309 
   -1   310         message = mail.outbox[0]
   -1   311         self.assertEqual(message.to, ['test@example.com'])
   -1   312         self.assertEqual(message.subject, 'Failed login on Tests')
   -1   313         self.assertEqual(message.body, """Dear test,
   -1   314 
   -1   315 someone tried to log in to your account at Tests (localhost).
   -1   316 They managed to enter the correct password, but failed at FIDO2.
   -1   317 
   -1   318 If this wasn't you we strongly recommend to change your password.
   -1   319 """)