diff --git a/passbook/audit/migrations/0006_auto_20181218_1252.py b/passbook/audit/migrations/0006_auto_20181218_1252.py new file mode 100644 index 000000000..5317e3f6e --- /dev/null +++ b/passbook/audit/migrations/0006_auto_20181218_1252.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1.4 on 2018-12-18 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_audit', '0005_auditentry_created'), + ] + + operations = [ + migrations.CreateModel( + name='LoginAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('target_uid', models.CharField(max_length=254)), + ('request_ip', models.GenericIPAddressField()), + ('attempts', models.IntegerField(default=1)), + ], + ), + migrations.AlterUniqueTogether( + name='loginattempt', + unique_together={('target_uid', 'request_ip', 'created')}, + ), + ] diff --git a/passbook/audit/models.py b/passbook/audit/models.py index 3c957f515..398e513ea 100644 --- a/passbook/audit/models.py +++ b/passbook/audit/models.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext as _ from ipware import get_client_ip from reversion import register -from passbook.lib.models import UUIDModel +from passbook.lib.models import CreatedUpdatedModel, UUIDModel LOGGER = getLogger(__name__) @@ -80,3 +80,40 @@ class AuditEntry(UUIDModel): verbose_name = _('Audit Entry') verbose_name_plural = _('Audit Entries') + + +class LoginAttempt(CreatedUpdatedModel): + """Track failed login-attempts""" + + target_uid = models.CharField(max_length=254) + request_ip = models.GenericIPAddressField() + attempts = models.IntegerField(default=1) + + @staticmethod + def attempt(target_uid, request): + """Helper function to create attempt or count up existing one""" + client_ip, _ = get_client_ip(request) + # Since we can only use 254 chars for target_uid, truncate target_uid. + target_uid = target_uid[:254] + existing_attempts = LoginAttempt.objects.filter( + target_uid=target_uid, + request_ip=client_ip).order_by('created') + # TODO: Add logic to group attempts by timeframe, i.e. within 10 minutes + if existing_attempts.exists(): + attempt = existing_attempts.first() + attempt.attempts += 1 + attempt.save() + LOGGER.debug("Increased attempts on %s", attempt) + else: + attempt = LoginAttempt.objects.create( + target_uid=target_uid, + request_ip=client_ip) + LOGGER.debug("Created new attempt %s", attempt) + + def __str__(self): + return "LoginAttempt to %s from %s (x%d)" % (self.target_uid, + self.request_ip, self.attempts) + + class Meta: + + unique_together = (('target_uid', 'request_ip', 'created'),) diff --git a/passbook/audit/signals.py b/passbook/audit/signals.py index 0bd69a11c..d26374326 100644 --- a/passbook/audit/signals.py +++ b/passbook/audit/signals.py @@ -3,7 +3,7 @@ from django.contrib.auth.signals import (user_logged_in, user_logged_out, user_login_failed) from django.dispatch import receiver -from passbook.audit.models import AuditEntry +from passbook.audit.models import AuditEntry, LoginAttempt from passbook.core.signals import (invitation_created, invitation_used, user_signed_up) @@ -36,8 +36,6 @@ def on_invitation_used(sender, request, invitation, **kwargs): invitation_uuid=invitation.uuid.hex) @receiver(user_login_failed) -def on_user_login_failed(sender, request, **kwargs): +def on_user_login_failed(sender, request, credentials, **kwargs): """Log failed login attempt""" - # TODO: Implement sumarizing of signals here for brute-force attempts - # AuditEntry.create(AuditEntry.ACTION_LOGOUT, request) - pass + LoginAttempt.attempt(target_uid=credentials.get('username'), request=request)