diff --git a/passbook/audit/admin.py b/passbook/audit/admin.py new file mode 100644 index 000000000..1d67655ac --- /dev/null +++ b/passbook/audit/admin.py @@ -0,0 +1,5 @@ +"""passbook audit model admin""" + +from passbook.lib.admin import admin_autoregister + +admin_autoregister('passbook_audit') diff --git a/passbook/audit/apps.py b/passbook/audit/apps.py index 6d62ebbc4..ac83dbcf3 100644 --- a/passbook/audit/apps.py +++ b/passbook/audit/apps.py @@ -1,4 +1,6 @@ """passbook audit app""" +from importlib import import_module + from django.apps import AppConfig @@ -8,3 +10,6 @@ class PassbookAuditConfig(AppConfig): name = 'passbook.audit' label = 'passbook_audit' mountpoint = 'audit/' + + def ready(self): + import_module('passbook.audit.signals') diff --git a/passbook/audit/migrations/0002_auto_20181210_1039.py b/passbook/audit/migrations/0002_auto_20181210_1039.py new file mode 100644 index 000000000..67f88bbb6 --- /dev/null +++ b/passbook/audit/migrations/0002_auto_20181210_1039.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.4 on 2018-12-10 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_audit', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='auditentry', + name='context', + field=models.TextField(default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='auditentry', + name='request_ip', + field=models.GenericIPAddressField(default=''), + preserve_default=False, + ), + migrations.AlterField( + model_name='auditentry', + name='action', + field=models.TextField(choices=[('login', 'login'), ('login_failed', 'login_failed'), ('logout', 'logout'), ('authorize_application', 'authorize_application'), ('suspicious_request', 'suspicious_request'), ('sign_up', 'sign_up'), ('password_reset', 'password_reset')]), + ), + ] diff --git a/passbook/audit/migrations/0003_auto_20181210_1213.py b/passbook/audit/migrations/0003_auto_20181210_1213.py new file mode 100644 index 000000000..b401addd2 --- /dev/null +++ b/passbook/audit/migrations/0003_auto_20181210_1213.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.4 on 2018-12-10 12:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_audit', '0002_auto_20181210_1039'), + ] + + operations = [ + migrations.AlterModelOptions( + name='auditentry', + options={'verbose_name': 'Audit Entry', 'verbose_name_plural': 'Audit Entries'}, + ), + migrations.RenameField( + model_name='auditentry', + old_name='context', + new_name='_context', + ), + migrations.AlterField( + model_name='auditentry', + name='action', + field=models.TextField(choices=[('login', 'login'), ('login_failed', 'login_failed'), ('logout', 'logout'), ('authorize_application', 'authorize_application'), ('suspicious_request', 'suspicious_request'), ('sign_up', 'sign_up'), ('password_reset', 'password_reset'), ('invite_used', 'invite_used')]), + ), + ] diff --git a/passbook/audit/models.py b/passbook/audit/models.py index b1fd2e268..a6da47718 100644 --- a/passbook/audit/models.py +++ b/passbook/audit/models.py @@ -1,22 +1,75 @@ """passbook audit models""" +from logging import getLogger +from json import dumps, loads from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models +from django.utils.translation import gettext as _ +from ipware import get_client_ip from reversion import register from passbook.lib.models import UUIDModel +LOGGER = getLogger(__name__) @register() class AuditEntry(UUIDModel): """An individual audit log entry""" + ACTION_LOGIN = 'login' + ACTION_LOGIN_FAILED = 'login_failed' + ACTION_LOGOUT = 'logout' + ACTION_AUTHORIZE_APPLICATION = 'authorize_application' + ACTION_SUSPICIOUS_REQUEST = 'suspicious_request' + ACTION_SIGN_UP = 'sign_up' + ACTION_PASSWORD_RESET = 'password_reset' + ACTION_INVITE_USED = 'invite_used' + ACTIONS = ( + (ACTION_LOGIN, ACTION_LOGIN), + (ACTION_LOGIN_FAILED, ACTION_LOGIN_FAILED), + (ACTION_LOGOUT, ACTION_LOGOUT), + (ACTION_AUTHORIZE_APPLICATION, ACTION_AUTHORIZE_APPLICATION), + (ACTION_SUSPICIOUS_REQUEST, ACTION_SUSPICIOUS_REQUEST), + (ACTION_SIGN_UP, ACTION_SIGN_UP), + (ACTION_PASSWORD_RESET, ACTION_PASSWORD_RESET), + (ACTION_INVITE_USED, ACTION_INVITE_USED), + ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) - action = models.TextField() + action = models.TextField(choices=ACTIONS) date = models.DateTimeField(auto_now_add=True) app = models.TextField() + _context = models.TextField() + _context_cache = None + request_ip = models.GenericIPAddressField() + + @property + def context(self): + """Load context data and load json""" + if not self._context_cache: + self._context_cache = loads(self._context) + return self._context_cache + + @staticmethod + def create(action, request, **kwargs): + """Create AuditEntry from arguments""" + client_ip, _ = get_client_ip(request) + entry = AuditEntry.objects.create( + action=action, + user=request.user, + # User 0.0.0.0 as fallback if IP cannot be determined + request_ip=client_ip or '0.0.0.0', + _context=dumps(kwargs)) + LOGGER.debug("Logged %s from %s (%s)", action, request.user, client_ip) + return entry def save(self, *args, **kwargs): - if self.pk: - raise NotImplementedError("you may not edit an existing %s" % self._meta.model_name) + if not self._state.adding: + raise ValidationError("you may not edit an existing %s" % self._meta.model_name) super().save(*args, **kwargs) + + class Meta: + + verbose_name = _('Audit Entry') + verbose_name_plural = _('Audit Entries') diff --git a/passbook/audit/requirements.txt b/passbook/audit/requirements.txt new file mode 100644 index 000000000..0b89a70b7 --- /dev/null +++ b/passbook/audit/requirements.txt @@ -0,0 +1 @@ +django-ipware diff --git a/passbook/audit/signals.py b/passbook/audit/signals.py new file mode 100644 index 000000000..5f31122be --- /dev/null +++ b/passbook/audit/signals.py @@ -0,0 +1,24 @@ +"""passbook audit signal listener""" +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 + + +@receiver(user_logged_in) +def on_user_logged_in(sender, request, user, **kwargs): + """Log successful login""" + AuditEntry.create(AuditEntry.ACTION_LOGIN, request) + +@receiver(user_logged_out) +def on_user_logged_out(sender, request, user, **kwargs): + """Log successfully logout""" + AuditEntry.create(AuditEntry.ACTION_LOGOUT, request) + +@receiver(user_login_failed) +def on_user_login_failed(sender, request, user, **kwargs): + """Log failed login attempt""" + # TODO: Implement sumarizing of signals here for brute-force attempts + # AuditEntry.create(AuditEntry.ACTION_LOGOUT, request) + pass