Compare commits

..

32 Commits

Author SHA1 Message Date
4439378fd4 bump version: 0.1.1-beta -> 0.1.2-beta 2019-03-07 14:14:51 +01:00
acf65eafdd make naming of Providers more consistent 2019-03-07 14:14:49 +01:00
c2ebff55ef fix IDP-initiated login not working 2019-03-07 14:10:06 +01:00
99c82676b6 Add some more failsafe for administration 2019-03-07 14:09:52 +01:00
4991e9b825 Merge branch '1-suspicious-request' into 'master'
fix broken E-Mail templatetag

Closes #1

See merge request BeryJu.org/passbook!5
2019-03-03 20:18:23 +00:00
612f95c3ba fix broken E-Mail templatetag 2019-03-03 21:05:17 +01:00
cd91d5ca15 Merge branch '1-suspicious-request' into 'master'
Resolve "Suspicious request detector (many invalid logins from one IP, many attempts on one username, etc)"

Closes #1

See merge request BeryJu.org/passbook!3
2019-03-03 20:04:56 +00:00
cbbbb5dc08 Merge branch '20-sentry' into 'master'
Resolve "Sentry Error Tracking"

Closes #20

See merge request BeryJu.org/passbook!4
2019-03-03 19:58:18 +00:00
c1640b9411 fix prospector/isort errors 2019-03-03 20:54:23 +01:00
a4842c1f95 add sentry configuration 2019-03-03 20:48:31 +01:00
a4707ddc54 fix failing unittests 2019-03-03 20:34:00 +01:00
fb82d56307 create suspicious request detector and policy, add request to policy engine 2019-03-03 20:26:25 +01:00
1a1005f80d remove audit's LoginAttempt 2019-03-03 20:13:54 +01:00
e86cae6cac Merge branch '18-password-expiry' into 'master'
Resolve "Password Expiry"

Closes #18

See merge request BeryJu.org/passbook!2
2019-03-03 16:53:31 +00:00
0b282f45e0 fix pylint messages 2019-03-03 17:45:20 +01:00
791e88ffc1 Fix negate on FieldMatcherPolicy 2019-03-03 17:21:58 +01:00
7bd3c4bccf Better handle Policy.action and Policy.negate 2019-03-03 17:12:53 +01:00
722e2e4050 Show warning when un-attached policies exist 2019-03-03 17:12:35 +01:00
c7fc444c95 add password policy 2019-03-03 17:12:05 +01:00
20ad062814 Log SAML Authorization actions 2019-03-03 00:34:34 +01:00
fcb5d36e07 cleanup SAML urls 2019-03-03 00:07:40 +01:00
9b131b619f Show warning message when no Factor exists 2019-03-02 23:54:40 +01:00
54427f7c68 use HTML5 autocomplete values to better handle password managers 2019-03-02 23:19:58 +01:00
35eef9c28d improve worker warning 2019-03-02 22:41:25 +01:00
e88a82553d use separate Form for Admin user editing (allow is_staff and is_active) 2019-03-02 22:41:14 +01:00
01a9520140 add import_users script to import users from CSV with already hashed passwords 2019-03-02 22:40:47 +01:00
46667615c3 switch releases to beta 2019-02-27 17:47:41 +01:00
c6721a83a4 bump version: 0.1.1-alpha -> 0.1.1-beta 2019-02-27 17:45:10 +01:00
46866e8ef0 bump version: 0.1.0-beta -> 0.1.1-alpha 2019-02-27 17:43:28 +01:00
4a49681127 Fix docker build failing 2019-02-27 17:43:24 +01:00
4c3fced4e9 bump version: 0.1.0-alpha -> 0.1.0-beta 2019-02-27 16:45:52 +01:00
172347d90f bump version: 0.0.13-alpha -> 0.1.0-alpha 2019-02-27 16:42:52 +01:00
65 changed files with 584 additions and 147 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.0.13-alpha current_version = 0.1.2-beta
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
@ -9,6 +9,7 @@ tag_name = version/{new_version}
[bumpversion:part:release] [bumpversion:part:release]
optional_value = stable optional_value = stable
first_value = beta
values = values =
alpha alpha
beta beta
@ -36,6 +37,10 @@ values =
[bumpversion:file:passbook/lib/__init__.py] [bumpversion:file:passbook/lib/__init__.py]
[bumpversion:file:passbook/hibp_policy/__init__.py]
[bumpversion:file:passbook/password_expiry_policy/__init__.py]
[bumpversion:file:passbook/saml_idp/__init__.py] [bumpversion:file:passbook/saml_idp/__init__.py]
[bumpversion:file:passbook/audit/__init__.py] [bumpversion:file:passbook/audit/__init__.py]

View File

@ -53,7 +53,7 @@ package-docker:
before_script: before_script:
- echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json
script: script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.pkg.beryju.org/passbook:latest --destination docker.pkg.beryju.org/passbook:0.0.13-alpha - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.pkg.beryju.org/passbook:latest --destination docker.pkg.beryju.org/passbook:0.1.2-beta
stage: build stage: build
only: only:
- tags - tags

View File

@ -6,10 +6,13 @@ COPY ./requirements.txt /app/
WORKDIR /app/ WORKDIR /app/
RUN mkdir /app/static/ && \ RUN apt-get update && apt-get install build-essential libssl-dev libffi-dev -y && \
mkdir /app/static/ && \
pip install -r requirements.txt && \ pip install -r requirements.txt && \
pip install psycopg2 && \ pip install psycopg2 && \
./manage.py collectstatic --no-input ./manage.py collectstatic --no-input && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge -y
FROM python:3.6-slim-stretch FROM python:3.6-slim-stretch
@ -20,9 +23,12 @@ COPY --from=build /app/static /app/static/
WORKDIR /app/ WORKDIR /app/
RUN pip install -r requirements.txt && \ RUN apt-get update && apt-get install build-essential libssl-dev libffi-dev -y && \
pip install -r requirements.txt && \
pip install psycopg2 && \ pip install psycopg2 && \
adduser --system --home /app/ passbook && \ adduser --system --home /app/ passbook && \
chown -R passbook /app/ chown -R passbook /app/ && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge -y
USER passbook USER passbook

View File

@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
appVersion: "0.0.13-alpha" appVersion: "0.1.2-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.0.13-alpha" version: "0.1.2-beta"
icon: https://passbook.beryju.org/images/logo.png icon: https://passbook.beryju.org/images/logo.png

View File

@ -5,7 +5,7 @@
replicaCount: 1 replicaCount: 1
image: image:
tag: 0.0.13-alpha tag: 0.1.2-beta
nameOverride: "" nameOverride: ""

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""passbook admin""" """passbook admin"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -0,0 +1,17 @@
"""passbook administrative user forms"""
from django import forms
from passbook.core.models import User
class UserForm(forms.ModelForm):
"""Update User Details"""
class Meta:
model = User
fields = ['username', 'name', 'email', 'is_staff', 'is_active']
widgets = {
'name': forms.TextInput
}

View File

@ -76,9 +76,13 @@
<div class="card-pf-body"> <div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications"> <p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification"> <span class="card-pf-aggregate-status-notification">
<a href="{% url 'passbook_admin:factors' %}"> {% if factor_count < 1 %}
<span class="pficon pficon-ok"></span>{{ factor_count }} <span class="pficon-error-circle-o" data-toggle="tooltip" data-placement="right"
</a> title="{% trans 'No Factors configured. No Users will be able to login.' %}"></span>
{{ factor_count }}
{% else %}
<span class="pficon pficon-ok"></span>{{ factor_count }}
{% endif %}
</span> </span>
</p> </p>
</div> </div>
@ -95,9 +99,13 @@
<div class="card-pf-body"> <div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications"> <p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification"> <span class="card-pf-aggregate-status-notification">
<a href="{% url 'passbook_admin:policies' %}"> {% if policies_without_attachment > 0 %}
<span class="pficon pficon-ok"></span>{{ policy_count }} <span class="pficon-warning-triangle-o" data-toggle="tooltip" data-placement="right"
</a> title="{% trans 'Policies without attachment exist.' %}"></span>
{{ policy_count }}
{% else %}
<span class="pficon pficon-ok"></span>{{ policy_count }}
{% endif %}
</span> </span>
</p> </p>
</div> </div>
@ -174,7 +182,7 @@
<a href="#"> <a href="#">
{% if worker_count < 1%} {% if worker_count < 1%}
<span class="pficon-error-circle-o" data-toggle="tooltip" data-placement="right" <span class="pficon-error-circle-o" data-toggle="tooltip" data-placement="right"
title="{% trans 'No workers connected. Policies may not work.' %}"></span> {{ worker_count }} title="{% trans 'No workers connected. Policies will not work and you may expect other issues.' %}"></span> {{ worker_count }}
{% else %} {% else %}
<span class="pficon pficon-ok"></span>{{ worker_count }} <span class="pficon pficon-ok"></span>{{ worker_count }}
{% endif %} {% endif %}

View File

@ -28,6 +28,7 @@
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
<thead> <thead>
<tr> <tr>
<th></th>
<th>{% trans 'Name' %}</th> <th>{% trans 'Name' %}</th>
<th>{% trans 'Type' %}</th> <th>{% trans 'Type' %}</th>
<th></th> <th></th>
@ -35,7 +36,14 @@
</thead> </thead>
<tbody> <tbody>
{% for policy in object_list %} {% for policy in object_list %}
<tr> <tr {% if not policy.policymodel_set.exists %} class="warning" {% endif %}>
<th>
{% if not policy.policymodel_set.exists %}
<span class="pficon-warning-triangle-o" data-toggle="tooltip" data-placement="right" title="{% trans 'Warning: Policy is not assigned.' %}"></span>
{% else %}
<span class="pficon-ok" data-toggle="tooltip" data-placement="right" title="{% blocktrans with objects=policy.policymodel_set.all|join:', ' %}Assigned to objects {{ objects }}{% endblocktrans %}"></span>
{% endif %}
</th>
<td>{{ policy.name }}</td> <td>{{ policy.name }}</td>
<td>{{ policy|verbose_name }}</td> <td>{{ policy|verbose_name }}</td>
<td> <td>

View File

@ -24,4 +24,5 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs['version'] = __version__ kwargs['version'] = __version__
kwargs['worker_count'] = len(CELERY_APP.control.ping(timeout=0.5)) kwargs['worker_count'] = len(CELERY_APP.control.ping(timeout=0.5))
kwargs['providers_without_application'] = Provider.objects.filter(application=None) kwargs['providers_without_application'] = Provider.objects.filter(application=None)
kwargs['policies_without_attachment'] = len(Policy.objects.filter(policymodel__isnull=True))
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -7,8 +7,8 @@ from django.utils.translation import ugettext as _
from django.views import View from django.views import View
from django.views.generic import DeleteView, ListView, UpdateView from django.views.generic import DeleteView, ListView, UpdateView
from passbook.admin.forms.users import UserForm
from passbook.admin.mixins import AdminRequiredMixin from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.forms.users import UserDetailForm
from passbook.core.models import Nonce, User from passbook.core.models import Nonce, User
@ -23,7 +23,7 @@ class UserUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView):
"""Update user""" """Update user"""
model = User model = User
form_class = UserDetailForm form_class = UserForm
template_name = 'generic/update.html' template_name = 'generic/update.html'
success_url = reverse_lazy('passbook_admin:users') success_url = reverse_lazy('passbook_admin:users')

View File

@ -1,2 +1,2 @@
"""passbook api""" """passbook api"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""passbook audit Header""" """passbook audit Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,5 +1,4 @@
"""passbook audit models""" """passbook audit models"""
from datetime import timedelta
from logging import getLogger from logging import getLogger
from django.conf import settings from django.conf import settings
@ -7,11 +6,10 @@ 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.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from ipware import get_client_ip from ipware import get_client_ip
from passbook.lib.models import CreatedUpdatedModel, UUIDModel from passbook.lib.models import UUIDModel
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@ -75,43 +73,3 @@ class AuditEntry(UUIDModel):
verbose_name = _('Audit Entry') verbose_name = _('Audit Entry')
verbose_name_plural = _('Audit Entries') 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"""
if not target_uid:
return
client_ip, _ = get_client_ip(request)
# Since we can only use 254 chars for target_uid, truncate target_uid.
target_uid = target_uid[:254]
time_threshold = timezone.now() - timedelta(minutes=10)
existing_attempts = LoginAttempt.objects.filter(
target_uid=target_uid,
request_ip=client_ip,
last_updated__gt=time_threshold).order_by('created')
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'),)

View File

@ -1 +0,0 @@
django-ipware

View File

@ -1,9 +1,8 @@
"""passbook audit signal listener""" """passbook audit signal listener"""
from django.contrib.auth.signals import (user_logged_in, user_logged_out, from django.contrib.auth.signals import user_logged_in, user_logged_out
user_login_failed)
from django.dispatch import receiver from django.dispatch import receiver
from passbook.audit.models import AuditEntry, LoginAttempt from passbook.audit.models import AuditEntry
from passbook.core.signals import (invitation_created, invitation_used, from passbook.core.signals import (invitation_created, invitation_used,
user_signed_up) user_signed_up)
@ -34,8 +33,3 @@ def on_invitation_used(sender, request, invitation, **kwargs):
"""Log Invitation usage""" """Log Invitation usage"""
AuditEntry.create(AuditEntry.ACTION_INVITE_USED, request, AuditEntry.create(AuditEntry.ACTION_INVITE_USED, request,
invitation_uuid=invitation.uuid.hex) invitation_uuid=invitation.uuid.hex)
@receiver(user_login_failed)
def on_user_login_failed(sender, request, credentials, **kwargs):
"""Log failed login attempt"""
LoginAttempt.attempt(target_uid=credentials.get('username'), request=request)

View File

@ -1,2 +1,2 @@
"""passbook captcha_factor Header""" """passbook captcha_factor Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""passbook core""" """passbook core"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -65,7 +65,7 @@ class AuthenticationView(UserPassesTestMixin, View):
self.pending_factors = [] self.pending_factors = []
for factor in _all_factors: for factor in _all_factors:
policy_engine = PolicyEngine(factor.policies.all()) policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(self.pending_user) policy_engine.for_user(self.pending_user).with_request(request).build()
if policy_engine.result[0]: if policy_engine.result[0]:
self.pending_factors.append((factor.uuid.hex, factor.type)) self.pending_factors.append((factor.uuid.hex, factor.type))
# Read and instantiate factor from session # Read and instantiate factor from session

View File

@ -5,9 +5,8 @@ import os
import celery import celery
from django.conf import settings from django.conf import settings
from raven import Client
# from raven import Client from raven.contrib.celery import register_logger_signal, register_signal
# from raven.contrib.celery import register_logger_signal, register_signal
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.core.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.core.settings")
@ -18,16 +17,17 @@ LOGGER = logging.getLogger(__name__)
class Celery(celery.Celery): class Celery(celery.Celery):
"""Custom Celery class with Raven configured""" """Custom Celery class with Raven configured"""
# def on_configure(self): # pylint: disable=method-hidden
# """Update raven client""" def on_configure(self):
# try: """Update raven client"""
# client = Client(settings.RAVEN_CONFIG.get('dsn')) try:
# # register a custom filter to filter out duplicate logs client = Client(settings.RAVEN_CONFIG.get('dsn'))
# register_logger_signal(client) # register a custom filter to filter out duplicate logs
# # hook into the Celery error handler register_logger_signal(client)
# register_signal(client) # hook into the Celery error handler
# except RecursionError: # This error happens when pdoc is running register_signal(client)
# pass except RecursionError: # This error happens when pdoc is running
pass
# pylint: disable=unused-argument # pylint: disable=unused-argument

View File

@ -81,8 +81,6 @@ class SignUpForm(forms.Form):
password_repeat = self.cleaned_data.get('password_repeat') password_repeat = self.cleaned_data.get('password_repeat')
if password != password_repeat: if password != password_repeat:
raise ValidationError(_("Passwords don't match")) raise ValidationError(_("Passwords don't match"))
# TODO: Password policy? Via Plugin? via Policy?
# return check_password(self)
return self.cleaned_data.get('password_repeat') return self.cleaned_data.get('password_repeat')
@ -91,5 +89,6 @@ class PasswordFactorForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput(attrs={ password = forms.CharField(widget=forms.PasswordInput(attrs={
'placeholder': _('Password'), 'placeholder': _('Password'),
'autofocus': 'autofocus' 'autofocus': 'autofocus',
'autocomplete': 'current-password'
})) }))

View File

@ -22,10 +22,14 @@ class PasswordChangeForm(forms.Form):
"""Form to update password""" """Form to update password"""
password = forms.CharField(label=_('Password'), password = forms.CharField(label=_('Password'),
widget=forms.PasswordInput(attrs={'placeholder': _('New Password')})) widget=forms.PasswordInput(attrs={
'placeholder': _('New Password'),
'autocomplete': 'new-password'
}))
password_repeat = forms.CharField(label=_('Repeat Password'), password_repeat = forms.CharField(label=_('Repeat Password'),
widget=forms.PasswordInput(attrs={ widget=forms.PasswordInput(attrs={
'placeholder': _('Repeat Password') 'placeholder': _('Repeat Password'),
'autocomplete': 'new-password'
})) }))
def clean_password_repeat(self): def clean_password_repeat(self):
@ -34,5 +38,4 @@ class PasswordChangeForm(forms.Form):
password_repeat = self.cleaned_data.get('password_repeat') password_repeat = self.cleaned_data.get('password_repeat')
if password != password_repeat: if password != password_repeat:
raise ValidationError(_("Passwords don't match")) raise ValidationError(_("Passwords don't match"))
# TODO: Password policy check
return self.cleaned_data.get('password_repeat') return self.cleaned_data.get('password_repeat')

View File

@ -0,0 +1,44 @@
"""passbook import_users management command"""
from csv import DictReader
from logging import getLogger
from django.core.management.base import BaseCommand
from django.core.validators import EmailValidator, ValidationError
from passbook.core.models import User
LOGGER = getLogger(__name__)
class Command(BaseCommand):
"""Import users from CSV file"""
def add_arguments(self, parser):
# Positional arguments
parser.add_argument('file', nargs='+', type=str)
def handle(self, *args, **options):
"""Create Users from CSV file"""
for file in options.get('file'):
with open(file, 'r') as _file:
reader = DictReader(_file)
for user in reader:
LOGGER.debug('User %s', user.get('username'))
try:
# only import users with valid email addresses
if user.get('email'):
validator = EmailValidator()
validator(user.get('email'))
# use combination of username and email to check for existing user
if User.objects.filter(
username=user.get('username'),
email=user.get('email')).exists():
LOGGER.debug('User %s exists already, skipping', user.get('username'))
# Create user
User.objects.create(
username=user.get('username'),
email=user.get('email'),
name=user.get('name'))
LOGGER.debug('Created User %s', user.get('username'))
except ValidationError as exc:
LOGGER.warning('User %s caused %r, skipping', user.get('username'), exc)
continue

View File

@ -153,10 +153,12 @@ class Application(PolicyModel):
def user_is_authorized(self, user: User) -> bool: def user_is_authorized(self, user: User) -> bool:
"""Check if user is authorized to use this application""" """Check if user is authorized to use this application"""
from passbook.core.policies import PolicyEngine from passbook.core.policies import PolicyEngine
return PolicyEngine(self.policies.all()).for_user(user).result return PolicyEngine(self.policies.all()).for_user(user).build().result
def get_provider(self): def get_provider(self):
"""Get casted provider instance""" """Get casted provider instance"""
if not self.provider:
return None
return Provider.objects.get_subclass(pk=self.provider.pk) return Provider.objects.get_subclass(pk=self.provider.pk)
def __str__(self): def __str__(self):
@ -284,8 +286,7 @@ class FieldMatcherPolicy(Policy):
if self.match_action == FieldMatcherPolicy.MATCH_REGEXP: if self.match_action == FieldMatcherPolicy.MATCH_REGEXP:
pattern = re.compile(self.value) pattern = re.compile(self.value)
passes = bool(pattern.match(user_field_value)) passes = bool(pattern.match(user_field_value))
if self.negate:
passes = not passes
LOGGER.debug("User got '%r'", passes) LOGGER.debug("User got '%r'", passes)
return passes return passes

View File

@ -2,6 +2,7 @@
from logging import getLogger from logging import getLogger
from celery import group from celery import group
from ipware import get_client_ip
from passbook.core.celery import CELERY_APP from passbook.core.celery import CELERY_APP
from passbook.core.models import Policy, User from passbook.core.models import Policy, User
@ -17,25 +18,52 @@ def _policy_engine_task(user_pk, policy_pk, **kwargs):
setattr(user_obj, key, value) setattr(user_obj, key, value)
LOGGER.debug("Running policy `%s`#%s for user %s...", policy_obj.name, LOGGER.debug("Running policy `%s`#%s for user %s...", policy_obj.name,
policy_obj.pk.hex, user_obj) policy_obj.pk.hex, user_obj)
return policy_obj.passes(user_obj) policy_result = policy_obj.passes(user_obj)
# Handle policy result correctly if result, message or just result
message = None
if isinstance(policy_result, (tuple, list)):
policy_result, message = policy_result
# Invert result if policy.negate is set
if policy_obj.negate:
policy_result = not policy_result
LOGGER.debug("Policy %r#%s got %s", policy_obj.name, policy_obj.pk.hex, policy_result)
return policy_obj.action, policy_result, message
class PolicyEngine: class PolicyEngine:
"""Orchestrate policy checking, launch tasks and return result""" """Orchestrate policy checking, launch tasks and return result"""
policies = None policies = None
_group = None _group = None
_request = None
_user = None
def __init__(self, policies): def __init__(self, policies):
self.policies = policies self.policies = policies
self._request = None
self._user = None
def for_user(self, user): def for_user(self, user):
"""Check policies for user""" """Check policies for user"""
self._user = user
return self
def with_request(self, request):
"""Set request"""
self._request = request
return self
def build(self):
"""Build task group"""
signatures = [] signatures = []
kwargs = { kwargs = {
'__password__': getattr(user, '__password__', None) '__password__': getattr(self._user, '__password__', None),
} }
if self._request:
kwargs['remote_ip'], _ = get_client_ip(self._request)
if not kwargs['remote_ip']:
kwargs['remote_ip'] = '255.255.255.255'
for policy in self.policies: for policy in self.policies:
signatures.append(_policy_engine_task.s(user.pk, policy.pk.hex, **kwargs)) signatures.append(_policy_engine_task.s(self._user.pk, policy.pk.hex, **kwargs))
self._group = group(signatures)() self._group = group(signatures)()
return self return self
@ -43,10 +71,11 @@ class PolicyEngine:
def result(self): def result(self):
"""Get policy-checking result""" """Get policy-checking result"""
messages = [] messages = []
for policy_result in self._group.get(): for policy_action, policy_result, policy_message in self._group.get():
if isinstance(policy_result, (tuple, list)): passing = (policy_action == Policy.ACTION_ALLOW and policy_result) or \
policy_result, policy_message = policy_result (policy_action == Policy.ACTION_DENY and not policy_result)
if policy_message:
messages.append(policy_message) messages.append(policy_message)
if policy_result is False: if not passing:
return False, messages return False, messages
return True, messages return True, messages

View File

@ -1,5 +1,6 @@
django>=2.0 django>=2.0
django-model-utils django-model-utils
django-ipware
djangorestframework djangorestframework
PyYAML PyYAML
raven raven

View File

@ -62,6 +62,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'rest_framework', 'rest_framework',
'drf_yasg', 'drf_yasg',
'raven.contrib.django.raven_compat',
'passbook.core.apps.PassbookCoreConfig', 'passbook.core.apps.PassbookCoreConfig',
'passbook.admin.apps.PassbookAdminConfig', 'passbook.admin.apps.PassbookAdminConfig',
'passbook.api.apps.PassbookAPIConfig', 'passbook.api.apps.PassbookAPIConfig',
@ -75,6 +76,8 @@ INSTALLED_APPS = [
'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig', 'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig',
'passbook.hibp_policy.apps.PassbookHIBPConfig', 'passbook.hibp_policy.apps.PassbookHIBPConfig',
'passbook.pretend.apps.PassbookPretendConfig', 'passbook.pretend.apps.PassbookPretendConfig',
'passbook.password_expiry_policy.apps.PassbookPasswordExpiryPolicyConfig',
'passbook.suspicious_policy.apps.PassbookSuspiciousPolicyConfig',
] ]
# Message Tag fix for bootstrap CSS Classes # Message Tag fix for bootstrap CSS Classes
@ -103,6 +106,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware',
] ]
ROOT_URLCONF = 'passbook.core.urls' ROOT_URLCONF = 'passbook.core.urls'
@ -183,6 +187,14 @@ CELERY_TASK_DEFAULT_QUEUE = 'passbook'
CELERY_BROKER_URL = 'redis://%s' % CONFIG.get('redis') CELERY_BROKER_URL = 'redis://%s' % CONFIG.get('redis')
CELERY_RESULT_BACKEND = 'redis://%s' % CONFIG.get('redis') CELERY_RESULT_BACKEND = 'redis://%s' % CONFIG.get('redis')
# Raven settings
RAVEN_CONFIG = {
'dsn': ('https://55b5dd780bc14f4c96bba69b7a9abbcc:449af483bd0745'
'0d83be640d834e5458@sentry.services.beryju.org/8'),
'release': VERSION,
'environment': 'dev' if DEBUG else 'production',
}
# CherryPY settings # CherryPY settings
with CONFIG.cd('web'): with CONFIG.cd('web'):
CHERRYPY_SERVER = { CHERRYPY_SERVER = {

View File

@ -20,7 +20,7 @@ def password_policy_checker(sender, password, **kwargs):
_all_factors = PasswordFactor.objects.filter(enabled=True).order_by('order') _all_factors = PasswordFactor.objects.filter(enabled=True).order_by('order')
for factor in _all_factors: for factor in _all_factors:
policy_engine = PolicyEngine(factor.password_policies.all().select_subclasses()) policy_engine = PolicyEngine(factor.password_policies.all().select_subclasses())
policy_engine.for_user(sender) policy_engine.for_user(sender).build()
passing, messages = policy_engine.result passing, messages = policy_engine.result
if not passing: if not passing:
raise PasswordPolicyInvalid(*messages) raise PasswordPolicyInvalid(*messages)

View File

@ -29,7 +29,7 @@
<div class="login-pf-page"> <div class="login-pf-page">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-sm-12 col-md-8 col-md-offset-2 col-lg-4 col-lg-offset-4"> <div class="col-sm-12 col-md-8 col-md-offset-2 col-lg-6 col-lg-offset-3">
<header class="login-pf-page-header"> <header class="login-pf-page-header">
<img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}" <img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}"
alt="passbook logo" /> alt="passbook logo" />

View File

@ -16,7 +16,7 @@ def user_factors(context):
for factor in _all_factors: for factor in _all_factors:
_link = factor.has_user_settings() _link = factor.has_user_settings()
policy_engine = PolicyEngine(factor.policies.all()) policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user) policy_engine.for_user(user).with_request(context.get('request')).build()
if policy_engine.result[0] and _link: if policy_engine.result[0] and _link:
matching_factors.append(_link) matching_factors.append(_link)
return matching_factors return matching_factors

View File

@ -46,6 +46,7 @@ class UserChangePasswordView(FormView):
def form_valid(self, form: PasswordChangeForm): def form_valid(self, form: PasswordChangeForm):
try: try:
# user.set_password checks against Policies so we don't need to manually do it here
self.request.user.set_password(form.cleaned_data.get('password')) self.request.user.set_password(form.cleaned_data.get('password'))
self.request.user.save() self.request.user.save()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)

View File

@ -10,7 +10,8 @@ https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
from raven.contrib.django.raven_compat.middleware.wsgi import Sentry
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'passbook.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'passbook.settings')
application = get_wsgi_application() application = Sentry(get_wsgi_application())

View File

@ -1,2 +1,2 @@
"""passbook hibp_policy""" """passbook hibp_policy"""
__version__ = '0.0.7-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""Passbook ldap app Header""" """Passbook ldap app Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""passbook lib""" """passbook lib"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -212,10 +212,14 @@ def gravatar(email, size=None, rating=None):
@register.filter @register.filter
def verbose_name(obj): def verbose_name(obj):
"""Return Object's Verbose Name""" """Return Object's Verbose Name"""
if not obj:
return ''
return obj._meta.verbose_name return obj._meta.verbose_name
@register.filter @register.filter
def form_verbose_name(obj): def form_verbose_name(obj):
"""Return ModelForm's Object's Verbose Name""" """Return ModelForm's Object's Verbose Name"""
if not obj:
return ''
return obj._meta.model._meta.verbose_name return obj._meta.model._meta.verbose_name

View File

@ -1,2 +1,2 @@
"""passbook oauth_client Header""" """passbook oauth_client Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""passbook oauth_provider Header""" """passbook oauth_provider Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -12,7 +12,7 @@ class OAuth2Provider(Provider, AbstractApplication):
form = 'passbook.oauth_provider.forms.OAuth2ProviderForm' form = 'passbook.oauth_provider.forms.OAuth2ProviderForm'
def __str__(self): def __str__(self):
return self.name return "OAuth2 Provider %s" % self.name
class Meta: class Meta:

View File

@ -1,2 +1,2 @@
"""passbook otp Header""" """passbook otp Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -0,0 +1,2 @@
"""passbook password_expiry"""
__version__ = '0.1.2-beta'

View File

@ -0,0 +1,5 @@
"""Passbook password_expiry_policy Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_password_expiry_policy')

View File

@ -0,0 +1,11 @@
"""Passbook password_expiry_policy app config"""
from django.apps import AppConfig
class PassbookPasswordExpiryPolicyConfig(AppConfig):
"""Passbook password_expiry_policy app config"""
name = 'passbook.password_expiry_policy'
label = 'passbook_password_expiry_policy'
verbose_name = 'passbook Password Expiry Policy'

View File

@ -0,0 +1,24 @@
"""passbook PasswordExpiry Policy forms"""
from django import forms
from django.utils.translation import gettext as _
from passbook.core.forms.policies import GENERAL_FIELDS
from passbook.password_expiry_policy.models import PasswordExpiryPolicy
class PasswordExpiryPolicyForm(forms.ModelForm):
"""Edit PasswordExpiryPolicy instances"""
class Meta:
model = PasswordExpiryPolicy
fields = GENERAL_FIELDS + ['days', 'deny_only']
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
'days': forms.NumberInput(),
}
labels = {
'deny_only': _("Only fail the policy, don't set user's password.")
}

View File

@ -0,0 +1,29 @@
# Generated by Django 2.1.7 on 2019-03-03 13:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('passbook_core', '0016_auto_20190227_1355'),
]
operations = [
migrations.CreateModel(
name='PasswordExpiryPolicy',
fields=[
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('deny_only', models.BooleanField(default=False)),
('days', models.IntegerField()),
],
options={
'verbose_name': 'Password Expiry Policy',
'verbose_name_plural': 'Password Expiry Policies',
},
bases=('passbook_core.policy',),
),
]

View File

@ -0,0 +1,42 @@
"""passbook password_expiry_policy Models"""
from datetime import timedelta
from logging import getLogger
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext as _
from passbook.core.models import Policy, User
LOGGER = getLogger(__name__)
class PasswordExpiryPolicy(Policy):
"""If password change date is more than x days in the past, call set_unusable_password
and show a notice"""
deny_only = models.BooleanField(default=False)
days = models.IntegerField()
form = 'passbook.password_expiry_policy.forms.PasswordExpiryPolicyForm'
def passes(self, user: User) -> bool:
"""If password change date is more than x days in the past, call set_unusable_password
and show a notice"""
actual_days = (now() - user.password_change_date).days
days_since_expiry = (now() - (user.password_change_date + timedelta(days=self.days))).days
if actual_days >= self.days:
if not self.deny_only:
user.set_unusable_password()
user.save()
return False, _(('Password expired %(days)d days ago. '
'Please update your password.') % {
'days': days_since_expiry
})
return False, _('Password has expired.')
return True
class Meta:
verbose_name = _('Password Expiry Policy')
verbose_name_plural = _('Password Expiry Policies')

View File

@ -1,2 +1,2 @@
"""passbook saml_idp Header""" """passbook saml_idp Header"""
__version__ = '0.0.13-alpha' __version__ = '0.1.2-beta'

View File

@ -36,13 +36,13 @@ class SAMLProvider(Provider):
return self._processor return self._processor
def __str__(self): def __str__(self):
return "SAMLProvider %s (processor=%s)" % (self.name, self.processor_path) return "SAML Provider %s" % self.name
def link_download_metadata(self): def link_download_metadata(self):
"""Get link to download XML metadata for admin interface""" """Get link to download XML metadata for admin interface"""
try: try:
# pylint: disable=no-member # pylint: disable=no-member
return reverse('passbook_saml_idp:metadata_xml', return reverse('passbook_saml_idp:saml-metadata',
kwargs={'application': self.application.slug}) kwargs={'application': self.application.slug})
except Provider.application.RelatedObjectDoesNotExist: except Provider.application.RelatedObjectDoesNotExist:
return None return None

View File

@ -39,7 +39,7 @@
</section> </section>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a href="{% url 'passbook_saml_idp:metadata_xml' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a> <a href="{% url 'passbook_saml_idp:saml-metadata' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,13 +4,14 @@ from django.urls import path
from passbook.saml_idp import views from passbook.saml_idp import views
urlpatterns = [ urlpatterns = [
path('login/<slug:application>/', path('<slug:application>/login/',
views.LoginBeginView.as_view(), name="saml_login_begin"), views.LoginBeginView.as_view(), name="saml-login"),
path('login/<slug:application>/initiate/', path('<slug:application>/login/initiate/',
views.InitiateLoginView.as_view(), name="saml_login_init"), views.InitiateLoginView.as_view(), name="saml-login-initiate"),
path('login/<slug:application>/process/', path('<slug:application>/login/process/',
views.LoginProcessView.as_view(), name='saml_login_process'), views.LoginProcessView.as_view(), name='saml-login-process'),
path('logout/', views.LogoutView.as_view(), name="saml_logout"), path('<slug:application>/logout/', views.LogoutView.as_view(), name="saml-logout"),
path('metadata/<slug:application>/', path('<slug:application>/logout/slo/', views.SLOLogout.as_view(), name="saml-logout-slo"),
views.DescriptorDownloadView.as_view(), name='metadata_xml'), path('<slug:application>/metadata/',
views.DescriptorDownloadView.as_view(), name='saml-metadata'),
] ]

View File

@ -13,7 +13,9 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header from signxml.util import strip_pem_header
from passbook.audit.models import AuditEntry
from passbook.core.models import Application from passbook.core.models import Application
from passbook.core.policies import PolicyEngine
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
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
@ -75,7 +77,7 @@ class LoginBeginView(LoginRequiredMixin, View):
return HttpResponseBadRequest('the SAML request payload is missing') return HttpResponseBadRequest('the SAML request payload is missing')
request.session['RelayState'] = source.get('RelayState', '') request.session['RelayState'] = source.get('RelayState', '')
return redirect(reverse('passbook_saml_idp:saml_login_process', kwargs={ return redirect(reverse('passbook_saml_idp:saml-login-process', kwargs={
'application': application 'application': application
})) }))
@ -94,19 +96,29 @@ class RedirectToSPView(LoginRequiredMixin, View):
}) })
class LoginProcessView(ProviderMixin, LoginRequiredMixin, View): class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Processor-based login continuation. """Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider.""" Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.result
def get(self, request, application): def get(self, request, application):
"""Handle get request, i.e. render form""" """Handle get request, i.e. render form"""
LOGGER.debug("Request: %s", request) LOGGER.debug("Request: %s", request)
# Check if user has access # Check if user has access
access = True if self.provider.application.skip_authorization and self._has_access():
# TODO: Check access here
if self.provider.application.skip_authorization and access:
ctx = self.provider.processor.generate_response() ctx = self.provider.processor.generate_response()
# TODO: AuditLog Skipped Authz # Log Application Authorization
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=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'],
@ -122,11 +134,13 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Handle post request, return back to ACS""" """Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request) LOGGER.debug("Request: %s", request)
# Check if user has access # Check if user has access
access = True if request.POST.get('ACSUrl', None) and self._has_access():
# TODO: Check access here
if request.POST.get('ACSUrl', None) and access:
# User accepted request # User accepted request
# TODO: AuditLog accepted AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=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'),
@ -144,7 +158,7 @@ class LogoutView(CSRFExemptMixin, LoginRequiredMixin, View):
returns a standard logged-out page. (SalesForce and others use this method, returns a standard logged-out page. (SalesForce and others use this method,
though it's technically not SAML 2.0).""" though it's technically not SAML 2.0)."""
def get(self, request): def get(self, request, application):
"""Perform logout""" """Perform logout"""
logout(request) logout(request)
@ -164,11 +178,10 @@ class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider, """Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page.""" logs out the user and returns a standard logged-out page."""
def post(self, request): def post(self, request, application):
"""Perform logout""" """Perform logout"""
request.session['SAMLRequest'] = request.POST['SAMLRequest'] request.session['SAMLRequest'] = request.POST['SAMLRequest']
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process(). # TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Add a URL dispatch for this view.
# TODO: Modify the base processor to handle logouts? # TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar? # TODO: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser. # TODO: Format a LogoutResponse and return it to the browser.
@ -183,8 +196,8 @@ class DescriptorDownloadView(ProviderMixin, View):
def get(self, request, application): def get(self, request, application):
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
entity_id = CONFIG.y('saml_idp.issuer') entity_id = CONFIG.y('saml_idp.issuer')
slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_logout')) slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-logout'))
sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_login_begin', kwargs={ sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-login', kwargs={
'application': application 'application': application
})) }))
pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '') pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '')
@ -206,6 +219,5 @@ class InitiateLoginView(ProviderMixin, LoginRequiredMixin, View):
def dispatch(self, request, application): def dispatch(self, request, application):
"""Initiates an IdP-initiated link to a simple SP resource/target URL.""" """Initiates an IdP-initiated link to a simple SP resource/target URL."""
super().dispatch(request, application)
self.provider.processor.init_deep_link(request, '') self.provider.processor.init_deep_link(request, '')
return _generate_response(request, self.provider) return _generate_response(request, self.provider)

View File

@ -0,0 +1,2 @@
"""passbook suspicious_policy"""
__version__ = '0.1.1-beta'

View File

@ -0,0 +1,5 @@
"""Passbook suspicious_policy Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_suspicious_policy')

View File

@ -0,0 +1,15 @@
"""Passbook suspicious_policy app config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookSuspiciousPolicyConfig(AppConfig):
"""Passbook suspicious_policy app config"""
name = 'passbook.suspicious_policy'
label = 'passbook_suspicious_policy'
verbose_name = 'passbook Suspicious Request Detector'
def ready(self):
import_module('passbook.suspicious_policy.signals')

View File

@ -0,0 +1,18 @@
"""passbook suspicious request forms"""
from django import forms
from passbook.core.forms.policies import GENERAL_FIELDS
from passbook.suspicious_policy.models import SuspiciousRequestPolicy
class SuspiciousRequestPolicyForm(forms.ModelForm):
"""Form to edit SuspiciousRequestPolicy"""
class Meta:
model = SuspiciousRequestPolicy
fields = GENERAL_FIELDS + ['check_ip', 'check_username', 'threshold']
widgets = {
'name': forms.TextInput(),
'value': forms.TextInput(),
}

View File

@ -0,0 +1,49 @@
# Generated by Django 2.1.7 on 2019-03-03 18:17
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('passbook_core', '0016_auto_20190227_1355'),
]
operations = [
migrations.CreateModel(
name='IPScore',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField()),
('score', models.IntegerField(default=0)),
('updated', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='SuspiciousRequestPolicy',
fields=[
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('check_ip', models.BooleanField(default=True)),
('check_username', models.BooleanField(default=True)),
('threshold', models.IntegerField(default=-5)),
],
options={
'abstract': False,
},
bases=('passbook_core.policy',),
),
migrations.CreateModel(
name='UserScore',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.IntegerField(default=0)),
('updated', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.7 on 2019-03-03 18:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('passbook_suspicious_policy', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='suspiciousrequestpolicy',
options={'verbose_name': 'Suspicious Request Policy', 'verbose_name_plural': 'Suspicious Request Policies'},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 2.1.7 on 2019-03-03 18:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_suspicious_policy', '0002_auto_20190303_1820'),
]
operations = [
migrations.AlterField(
model_name='ipscore',
name='ip',
field=models.GenericIPAddressField(unique=True),
),
migrations.AlterField(
model_name='userscore',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,51 @@
"""passbook suspicious request policy"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Policy, User
class SuspiciousRequestPolicy(Policy):
"""Return true if request IP/target username's score is below a certain threshold"""
check_ip = models.BooleanField(default=True)
check_username = models.BooleanField(default=True)
threshold = models.IntegerField(default=-5)
form = 'passbook.suspicious_policy.forms.SuspiciousRequestPolicyForm'
def passes(self, user: User):
remote_ip = user.remote_ip
passing = True
if self.check_ip:
ip_scores = IPScore.objects.filter(ip=remote_ip, score__lte=self.threshold)
passing = passing and ip_scores.exists()
if self.check_username:
user_scores = UserScore.objects.filter(user=user, score__lte=self.threshold)
passing = passing and user_scores.exists()
return passing
class Meta:
verbose_name = _('Suspicious Request Policy')
verbose_name_plural = _('Suspicious Request Policies')
class IPScore(models.Model):
"""Store score coming from the same IP"""
ip = models.GenericIPAddressField(unique=True)
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
def __str__(self):
return "IPScore for %s @ %d" % (self.ip, self.score)
class UserScore(models.Model):
"""Store score attempting to log in as the same username"""
user = models.OneToOneField(User, on_delete=models.CASCADE)
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
def __str__(self):
return "UserScore for %s @ %d" % (self.user, self.score)

View File

@ -0,0 +1,39 @@
"""passbook suspicious request signals"""
from logging import getLogger
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.dispatch import receiver
from ipware import get_client_ip
from passbook.core.models import User
from passbook.suspicious_policy.models import IPScore, UserScore
LOGGER = getLogger(__name__)
def update_score(request, username, amount):
"""Update score for IP and User"""
remote_ip, _ = get_client_ip(request)
if not remote_ip:
remote_ip = '255.255.255.255'
ip_score, _ = IPScore.objects.update_or_create(ip=remote_ip)
ip_score.score += amount
ip_score.save()
LOGGER.debug("Added %s to score of IP %s", amount, remote_ip)
user = User.objects.filter(username=username)
if not user.exists():
return
user_score, _ = UserScore.objects.update_or_create(user=user.first())
user_score.score += amount
user_score.save()
LOGGER.debug("Added %s to score of User %s", amount, username)
@receiver(user_login_failed)
def handle_failed_login(sender, request, credentials, **kwargs):
"""Lower Score for failed loging attempts"""
update_score(request, credentials.get('username'), -1)
@receiver(user_logged_in)
def handle_successful_login(sender, request, user, **kwargs):
"""Raise score for successful attempts"""
update_score(request, user.username, 1)

View File

@ -4,7 +4,6 @@
-r passbook/saml_idp/requirements.txt -r passbook/saml_idp/requirements.txt
-r passbook/otp/requirements.txt -r passbook/otp/requirements.txt
-r passbook/oauth_provider/requirements.txt -r passbook/oauth_provider/requirements.txt
-r passbook/audit/requirements.txt
-r passbook/captcha_factor/requirements.txt -r passbook/captcha_factor/requirements.txt
-r passbook/admin/requirements.txt -r passbook/admin/requirements.txt
-r passbook/api/requirements.txt -r passbook/api/requirements.txt