audit: rewrite to be independent of django http requests, allow custom actions

This commit is contained in:
Jens Langhammer 2019-12-05 16:14:08 +01:00
parent 6c358c4e0a
commit 807cbbeaaf
9 changed files with 146 additions and 101 deletions

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.8 on 2019-12-05 14:07
from django.db import migrations, models
import passbook.audit.models
class Migration(migrations.Migration):
dependencies = [
('passbook_audit', '0002_auto_20191028_0829'),
]
operations = [
migrations.AlterModelOptions(
name='event',
options={'verbose_name': 'Audit Event', 'verbose_name_plural': 'Audit Events'},
),
migrations.AlterField(
model_name='event',
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_CREATED', 'invitation_created'), ('INVITE_USED', 'invitation_used'), ('CUSTOM', 'custom')]),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.8 on 2019-12-05 15:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_audit', '0003_auto_20191205_1407'),
]
operations = [
migrations.RemoveField(
model_name='event',
name='request_ip',
),
migrations.AddField(
model_name='event',
name='client_ip',
field=models.GenericIPAddressField(null=True),
),
]

View File

@ -1,10 +1,16 @@
"""passbook audit models""" """passbook audit models"""
from enum import Enum
from inspect import getmodule, stack
from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from guardian.shortcuts import get_anonymous_user
from structlog import get_logger from structlog import get_logger
from passbook.lib.models import UUIDModel from passbook.lib.models import UUIDModel
@ -12,64 +18,86 @@ from passbook.lib.utils.http import get_client_ip
LOGGER = get_logger() LOGGER = get_logger()
class EventAction(Enum):
"""All possible actions to save into the audit log"""
LOGIN = 'login'
LOGIN_FAILED = 'login_failed'
LOGOUT = 'logout'
AUTHORIZE_APPLICATION = 'authorize_application'
SUSPICIOUS_REQUEST = 'suspicious_request'
SIGN_UP = 'sign_up'
PASSWORD_RESET = 'password_reset' # noqa # nosec
INVITE_CREATED = 'invitation_created'
INVITE_USED = 'invitation_used'
CUSTOM = 'custom'
@staticmethod
def as_choices():
"""Generate choices of actions used for database"""
return tuple((x, y.value) for x, y in EventAction.__members__.items())
class Event(UUIDModel): class Event(UUIDModel):
"""An individual audit log event""" """An individual audit log event"""
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' # noqa # nosec
ACTION_INVITE_CREATED = 'invitation_created'
ACTION_INVITE_USED = 'invitation_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_CREATED, ACTION_INVITE_CREATED),
(ACTION_INVITE_USED, ACTION_INVITE_USED),
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
action = models.TextField(choices=ACTIONS) action = models.TextField(choices=EventAction.as_choices())
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
app = models.TextField() app = models.TextField()
context = JSONField(default=dict, blank=True) context = JSONField(default=dict, blank=True)
request_ip = models.GenericIPAddressField() client_ip = models.GenericIPAddressField(null=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@staticmethod @staticmethod
def create(action, request, **kwargs): def _get_app_from_request(request: HttpRequest) -> str:
"""Create Event from arguments""" if not isinstance(request, HttpRequest):
client_ip = get_client_ip(request) return ""
if not hasattr(request, 'user'): return request.resolver_match.app_name
user = None
else: @staticmethod
user = request.user def new(action: EventAction,
if isinstance(user, AnonymousUser): app: Optional[str] = None,
user = kwargs.get('user', None) _inspect_offset: int = 1,
entry = Event.objects.create( **kwargs) -> 'Event':
action=action, """Create new Event instance from arguments. Instance is NOT saved."""
user=user, if not isinstance(action, EventAction):
# User 255.255.255.255 as fallback if IP cannot be determined raise ValueError(f"action must be EventAction instance but was {type(action)}")
request_ip=client_ip or '255.255.255.255', if not app:
app = getmodule(stack()[_inspect_offset][0]).__name__
event = Event(
action=action.value,
app=app,
context=kwargs) context=kwargs)
LOGGER.debug("Created Audit entry", action=action, LOGGER.debug("Created Audit event", action=action, context=kwargs)
user=user, from_ip=client_ip, context=kwargs) return event
return entry
def from_http(self, request: HttpRequest,
user: Optional[settings.AUTH_USER_MODEL] = None) -> 'Event':
"""Add data from a Django-HttpRequest, allowing the creation of
Events independently from requests.
`user` arguments optionally overrides user from requests."""
if hasattr(request, 'user'):
if isinstance(request.user, AnonymousUser):
self.user = get_anonymous_user()
else:
self.user = request.user
if user:
self.user = user
# User 255.255.255.255 as fallback if IP cannot be determined
self.client_ip = get_client_ip(request) or '255.255.255.255'
# If there's no app set, we get it from the requests too
if not self.app:
self.app = Event._get_app_from_request(request)
self.save()
return self
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self._state.adding: if not self._state.adding:
raise ValidationError("you may not edit an existing %s" % self._meta.model_name) raise ValidationError("you may not edit an existing %s" % self._meta.model_name)
super().save(*args, **kwargs) return super().save(*args, **kwargs)
class Meta: class Meta:
verbose_name = _('Audit Entry') verbose_name = _('Audit Event')
verbose_name_plural = _('Audit Entries') verbose_name_plural = _('Audit Events')

View File

@ -2,7 +2,7 @@
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.dispatch import receiver from django.dispatch import receiver
from passbook.audit.models import Event from passbook.audit.models import Event, EventAction
from passbook.core.signals import (invitation_created, invitation_used, from passbook.core.signals import (invitation_created, invitation_used,
user_signed_up) user_signed_up)
@ -10,26 +10,24 @@ from passbook.core.signals import (invitation_created, invitation_used,
@receiver(user_logged_in) @receiver(user_logged_in)
def on_user_logged_in(sender, request, user, **kwargs): def on_user_logged_in(sender, request, user, **kwargs):
"""Log successful login""" """Log successful login"""
Event.create(Event.ACTION_LOGIN, request) Event.new(EventAction.LOGIN).from_http(request)
@receiver(user_logged_out) @receiver(user_logged_out)
def on_user_logged_out(sender, request, user, **kwargs): def on_user_logged_out(sender, request, user, **kwargs):
"""Log successfully logout""" """Log successfully logout"""
Event.create(Event.ACTION_LOGOUT, request) Event.new(EventAction.LOGOUT).from_http(request)
@receiver(user_signed_up) @receiver(user_signed_up)
def on_user_signed_up(sender, request, user, **kwargs): def on_user_signed_up(sender, request, user, **kwargs):
"""Log successfully signed up""" """Log successfully signed up"""
Event.create(Event.ACTION_SIGN_UP, request) Event.new(EventAction.SIGN_UP).from_http(request)
@receiver(invitation_created) @receiver(invitation_created)
def on_invitation_created(sender, request, invitation, **kwargs): def on_invitation_created(sender, request, invitation, **kwargs):
"""Log Invitation creation""" """Log Invitation creation"""
Event.create(Event.ACTION_INVITE_CREATED, request, Event.new(EventAction.INVITE_CREATED, invitation_uuid=invitation.uuid.hex).from_http(request)
invitation_uuid=invitation.uuid.hex)
@receiver(invitation_used) @receiver(invitation_used)
def on_invitation_used(sender, request, invitation, **kwargs): def on_invitation_used(sender, request, invitation, **kwargs):
"""Log Invitation usage""" """Log Invitation usage"""
Event.create(Event.ACTION_INVITE_USED, request, Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.uuid.hex).from_http(request)
invitation_uuid=invitation.uuid.hex)

View File

@ -16,6 +16,7 @@ from qrcode import make
from qrcode.image.svg import SvgPathImage from qrcode.image.svg import SvgPathImage
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.factors.otp.forms import OTPSetupForm from passbook.factors.otp.forms import OTPSetupForm
from passbook.factors.otp.utils import otpauth_url from passbook.factors.otp.utils import otpauth_url
from passbook.lib.boilerplate import NeverCacheMixin from passbook.lib.boilerplate import NeverCacheMixin
@ -55,12 +56,7 @@ class DisableView(LoginRequiredMixin, View):
token.delete() token.delete()
messages.success(request, 'Successfully disabled OTP') messages.success(request, 'Successfully disabled OTP')
# Create event with email notification # Create event with email notification
# Event.create( Event.new(EventAction.CUSTOM, message='User disabled OTP.').from_http(request)
# user=request.user,
# message=_('You disabled TOTP.'),
# current=True,
# request=request,
# send_notification=True)
return redirect(reverse('passbook_factors_otp:otp-user-settings')) return redirect(reverse('passbook_factors_otp:otp-user-settings'))
class EnableView(LoginRequiredMixin, FormView): class EnableView(LoginRequiredMixin, FormView):
@ -77,7 +73,7 @@ class EnableView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs['config'] = CONFIG.y('passbook') kwargs['config'] = CONFIG.y('passbook')
kwargs['is_login'] = True kwargs['is_login'] = True
kwargs['title'] = _('Configue OTP') kwargs['title'] = _('Configure OTP')
kwargs['primary_action'] = _('Setup') kwargs['primary_action'] = _('Setup')
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -134,14 +130,7 @@ class EnableView(LoginRequiredMixin, FormView):
self.static_device.confirmed = True self.static_device.confirmed = True
self.static_device.save() self.static_device.save()
del self.request.session[OTP_SETTING_UP_KEY] del self.request.session[OTP_SETTING_UP_KEY]
# Create event with email notification Event.new(EventAction.CUSTOM, message='User enabled OTP.').from_http(self.request)
# TODO: Create Audit Log entry
# Event.create(
# user=self.request.user,
# message=_('You activated TOTP.'),
# current=True,
# request=self.request,
# send_notification=True)
return redirect('passbook_factors_otp:otp-user-settings') return redirect('passbook_factors_otp:otp-user-settings')
class QRView(NeverCacheMixin, View): class QRView(NeverCacheMixin, View):

View File

@ -8,7 +8,7 @@ from django.utils.translation import ugettext as _
from oauth2_provider.views.base import AuthorizationView from oauth2_provider.views.base import AuthorizationView
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event from passbook.audit.models import Event, EventAction
from passbook.core.models import Application from passbook.core.models import Application
from passbook.core.views.access import AccessMixin from passbook.core.views.access import AccessMixin
from passbook.core.views.utils import LoadingView, PermissionDeniedView from passbook.core.views.utils import LoadingView, PermissionDeniedView
@ -77,9 +77,8 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView):
def form_valid(self, form): def form_valid(self, form):
# User has clicked on "Authorize" # User has clicked on "Authorize"
Event.create( Event.new(EventAction.AUTHORIZE_APPLICATION,
action=Event.ACTION_AUTHORIZE_APPLICATION, authorized_application=self._application).from_http(self.request)
request=self.request, LOGGER.debug('User authorized Application',
app=str(self._application)) user=self.request.user, application=self._application)
LOGGER.debug('user %s authorized %s', self.request.user, self._application)
return super().form_valid(form) return super().form_valid(form)

View File

@ -3,7 +3,7 @@ from django.contrib import messages
from django.shortcuts import redirect from django.shortcuts import redirect
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event from passbook.audit.models import Event, EventAction
from passbook.core.models import Application from passbook.core.models import Application
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
@ -28,9 +28,7 @@ def check_permissions(request, user, client):
messages.error(request, policy_message) messages.error(request, policy_message)
return redirect('passbook_providers_oauth:oauth2-permission-denied') return redirect('passbook_providers_oauth:oauth2-permission-denied')
Event.create( Event.new(EventAction.AUTHORIZE_APPLICATION,
action=Event.ACTION_AUTHORIZE_APPLICATION, authorized_application=application,
request=request, skipped_authorization=False).from_http(request)
app=application.name,
skipped_authorization=False)
return None return None

View File

@ -13,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header from signxml.util import strip_pem_header
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event from passbook.audit.models import Event, EventAction
from passbook.core.models import Application from passbook.core.models import Application
from passbook.lib.mixins import CSRFExemptMixin from passbook.lib.mixins import CSRFExemptMixin
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
@ -123,11 +123,9 @@ class LoginProcessView(AccessRequiredView):
if self.provider.application.skip_authorization: if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response() ctx = self.provider.processor.generate_response()
# Log Application Authorization # Log Application Authorization
Event.create( Event.new(EventAction.AUTHORIZE_APPLICATION,
action=Event.ACTION_AUTHORIZE_APPLICATION, authorized_application=self.provider.application,
request=request, skipped_authorization=True).from_http(request)
app=self.provider.application.name,
skipped_authorization=True)
return RedirectToSPView.as_view()( return RedirectToSPView.as_view()(
request=request, request=request,
acs_url=ctx['acs_url'], acs_url=ctx['acs_url'],
@ -145,11 +143,9 @@ class LoginProcessView(AccessRequiredView):
# Check if user has access # Check if user has access
if request.POST.get('ACSUrl', None): if request.POST.get('ACSUrl', None):
# User accepted request # User accepted request
Event.create( Event.new(EventAction.AUTHORIZE_APPLICATION,
action=Event.ACTION_AUTHORIZE_APPLICATION, authorized_application=self.provider.application,
request=request, skipped_authorization=False).from_http(request)
app=self.provider.application.name,
skipped_authorization=False)
return RedirectToSPView.as_view()( return RedirectToSPView.as_view()(
request=request, request=request,
acs_url=request.POST.get('ACSUrl'), acs_url=request.POST.get('ACSUrl'),

View File

@ -11,8 +11,8 @@ from django.utils.translation import ugettext as _
from django.views.generic import RedirectView, View from django.views.generic import RedirectView, View
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.factors.view import AuthenticationView, _redirect_with_qs from passbook.factors.view import AuthenticationView, _redirect_with_qs
from passbook.lib.utils.reflection import app
from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.models import (OAuthSource, from passbook.sources.oauth.models import (OAuthSource,
UserOAuthSourceConnection) UserOAuthSourceConnection)
@ -180,17 +180,8 @@ class OAuthCallback(OAuthClientMixin, View):
access.user = user access.user = user
access.save() access.save()
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
if app('passbook_audit'): Event.new(EventAction.CUSTOM, message="Linked OAuth Source",
pass source=source).from_http(self.request)
# TODO: Create audit entry
# from passbook.audit.models import something
# something.event(user=user,)
# Event.create(
# user=user,
# message=_("Linked user with OAuth source %s" % self.source.name),
# request=self.request,
# hidden=True,
# current=False)
if was_authenticated: if was_authenticated:
messages.success(self.request, _("Successfully linked %(source)s!" % { messages.success(self.request, _("Successfully linked %(source)s!" % {
'source': self.source.name 'source': self.source.name