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