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

View File

@ -53,7 +53,7 @@ package-docker:
before_script:
- echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json
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
only:
- tags

View File

@ -6,10 +6,13 @@ COPY ./requirements.txt /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 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
@ -20,9 +23,12 @@ COPY --from=build /app/static /app/static/
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 && \
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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
"""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">
<p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification">
<a href="{% url 'passbook_admin:factors' %}">
<span class="pficon pficon-ok"></span>{{ factor_count }}
</a>
{% if factor_count < 1 %}
<span class="pficon-error-circle-o" data-toggle="tooltip" data-placement="right"
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>
</p>
</div>
@ -95,9 +99,13 @@
<div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification">
<a href="{% url 'passbook_admin:policies' %}">
<span class="pficon pficon-ok"></span>{{ policy_count }}
</a>
{% if policies_without_attachment > 0 %}
<span class="pficon-warning-triangle-o" data-toggle="tooltip" data-placement="right"
title="{% trans 'Policies without attachment exist.' %}"></span>
{{ policy_count }}
{% else %}
<span class="pficon pficon-ok"></span>{{ policy_count }}
{% endif %}
</span>
</p>
</div>
@ -174,7 +182,7 @@
<a href="#">
{% if worker_count < 1%}
<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 %}
<span class="pficon pficon-ok"></span>{{ worker_count }}
{% endif %}

View File

@ -28,6 +28,7 @@
<table class="table table-striped table-bordered">
<thead>
<tr>
<th></th>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Type' %}</th>
<th></th>
@ -35,7 +36,14 @@
</thead>
<tbody>
{% 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|verbose_name }}</td>
<td>

View File

@ -24,4 +24,5 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs['version'] = __version__
kwargs['worker_count'] = len(CELERY_APP.control.ping(timeout=0.5))
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)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
"""passbook audit models"""
from datetime import timedelta
from logging import getLogger
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.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
from ipware import get_client_ip
from passbook.lib.models import CreatedUpdatedModel, UUIDModel
from passbook.lib.models import UUIDModel
LOGGER = getLogger(__name__)
@ -75,43 +73,3 @@ 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"""
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"""
from django.contrib.auth.signals import (user_logged_in, user_logged_out,
user_login_failed)
from django.contrib.auth.signals import user_logged_in, user_logged_out
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,
user_signed_up)
@ -34,8 +33,3 @@ def on_invitation_used(sender, request, invitation, **kwargs):
"""Log Invitation usage"""
AuditEntry.create(AuditEntry.ACTION_INVITE_USED, request,
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"""
__version__ = '0.0.13-alpha'
__version__ = '0.1.2-beta'

View File

@ -1,2 +1,2 @@
"""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 = []
for factor in _all_factors:
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]:
self.pending_factors.append((factor.uuid.hex, factor.type))
# Read and instantiate factor from session

View File

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

View File

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

View File

@ -22,10 +22,14 @@ class PasswordChangeForm(forms.Form):
"""Form to update 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'),
widget=forms.PasswordInput(attrs={
'placeholder': _('Repeat Password')
'placeholder': _('Repeat Password'),
'autocomplete': 'new-password'
}))
def clean_password_repeat(self):
@ -34,5 +38,4 @@ class PasswordChangeForm(forms.Form):
password_repeat = self.cleaned_data.get('password_repeat')
if password != password_repeat:
raise ValidationError(_("Passwords don't match"))
# TODO: Password policy check
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:
"""Check if user is authorized to use this application"""
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):
"""Get casted provider instance"""
if not self.provider:
return None
return Provider.objects.get_subclass(pk=self.provider.pk)
def __str__(self):
@ -284,8 +286,7 @@ class FieldMatcherPolicy(Policy):
if self.match_action == FieldMatcherPolicy.MATCH_REGEXP:
pattern = re.compile(self.value)
passes = bool(pattern.match(user_field_value))
if self.negate:
passes = not passes
LOGGER.debug("User got '%r'", passes)
return passes

View File

@ -2,6 +2,7 @@
from logging import getLogger
from celery import group
from ipware import get_client_ip
from passbook.core.celery import CELERY_APP
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)
LOGGER.debug("Running policy `%s`#%s for user %s...", policy_obj.name,
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:
"""Orchestrate policy checking, launch tasks and return result"""
policies = None
_group = None
_request = None
_user = None
def __init__(self, policies):
self.policies = policies
self._request = None
self._user = None
def for_user(self, 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 = []
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:
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)()
return self
@ -43,10 +71,11 @@ class PolicyEngine:
def result(self):
"""Get policy-checking result"""
messages = []
for policy_result in self._group.get():
if isinstance(policy_result, (tuple, list)):
policy_result, policy_message = policy_result
for policy_action, policy_result, policy_message in self._group.get():
passing = (policy_action == Policy.ACTION_ALLOW and policy_result) or \
(policy_action == Policy.ACTION_DENY and not policy_result)
if policy_message:
messages.append(policy_message)
if policy_result is False:
if not passing:
return False, messages
return True, messages

View File

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

View File

@ -62,6 +62,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'drf_yasg',
'raven.contrib.django.raven_compat',
'passbook.core.apps.PassbookCoreConfig',
'passbook.admin.apps.PassbookAdminConfig',
'passbook.api.apps.PassbookAPIConfig',
@ -75,6 +76,8 @@ INSTALLED_APPS = [
'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig',
'passbook.hibp_policy.apps.PassbookHIBPConfig',
'passbook.pretend.apps.PassbookPretendConfig',
'passbook.password_expiry_policy.apps.PassbookPasswordExpiryPolicyConfig',
'passbook.suspicious_policy.apps.PassbookSuspiciousPolicyConfig',
]
# Message Tag fix for bootstrap CSS Classes
@ -103,6 +106,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware',
]
ROOT_URLCONF = 'passbook.core.urls'
@ -183,6 +187,14 @@ CELERY_TASK_DEFAULT_QUEUE = 'passbook'
CELERY_BROKER_URL = '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
with CONFIG.cd('web'):
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')
for factor in _all_factors:
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
if not passing:
raise PasswordPolicyInvalid(*messages)

View File

@ -29,7 +29,7 @@
<div class="login-pf-page">
<div class="container-fluid">
<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">
<img class="login-pf-brand" style="max-height: 10rem;" src="{% static 'img/logo.svg' %}"
alt="passbook logo" />

View File

@ -16,7 +16,7 @@ def user_factors(context):
for factor in _all_factors:
_link = factor.has_user_settings()
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:
matching_factors.append(_link)
return matching_factors

View File

@ -46,6 +46,7 @@ class UserChangePasswordView(FormView):
def form_valid(self, form: PasswordChangeForm):
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.save()
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
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')
application = get_wsgi_application()
application = Sentry(get_wsgi_application())

View File

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

View File

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

View File

@ -1,2 +1,2 @@
"""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
def verbose_name(obj):
"""Return Object's Verbose Name"""
if not obj:
return ''
return obj._meta.verbose_name
@register.filter
def form_verbose_name(obj):
"""Return ModelForm's Object's Verbose Name"""
if not obj:
return ''
return obj._meta.model._meta.verbose_name

View File

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

View File

@ -1,2 +1,2 @@
"""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'
def __str__(self):
return self.name
return "OAuth2 Provider %s" % self.name
class Meta:

View File

@ -1,2 +1,2 @@
"""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"""
__version__ = '0.0.13-alpha'
__version__ = '0.1.2-beta'

View File

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

View File

@ -39,7 +39,7 @@
</section>
</div>
<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>

View File

@ -4,13 +4,14 @@ from django.urls import path
from passbook.saml_idp import views
urlpatterns = [
path('login/<slug:application>/',
views.LoginBeginView.as_view(), name="saml_login_begin"),
path('login/<slug:application>/initiate/',
views.InitiateLoginView.as_view(), name="saml_login_init"),
path('login/<slug:application>/process/',
views.LoginProcessView.as_view(), name='saml_login_process'),
path('logout/', views.LogoutView.as_view(), name="saml_logout"),
path('metadata/<slug:application>/',
views.DescriptorDownloadView.as_view(), name='metadata_xml'),
path('<slug:application>/login/',
views.LoginBeginView.as_view(), name="saml-login"),
path('<slug:application>/login/initiate/',
views.InitiateLoginView.as_view(), name="saml-login-initiate"),
path('<slug:application>/login/process/',
views.LoginProcessView.as_view(), name='saml-login-process'),
path('<slug:application>/logout/', views.LogoutView.as_view(), name="saml-logout"),
path('<slug:application>/logout/slo/', views.SLOLogout.as_view(), name="saml-logout-slo"),
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 signxml.util import strip_pem_header
from passbook.audit.models import AuditEntry
from passbook.core.models import Application
from passbook.core.policies import PolicyEngine
from passbook.lib.config import CONFIG
from passbook.lib.mixins import CSRFExemptMixin
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')
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
}))
@ -94,19 +96,29 @@ class RedirectToSPView(LoginRequiredMixin, View):
})
class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Processor-based login continuation.
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):
"""Handle get request, i.e. render form"""
LOGGER.debug("Request: %s", request)
# Check if user has access
access = True
# TODO: Check access here
if self.provider.application.skip_authorization and access:
if self.provider.application.skip_authorization and self._has_access():
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()(
request=request,
acs_url=ctx['acs_url'],
@ -122,11 +134,13 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request)
# Check if user has access
access = True
# TODO: Check access here
if request.POST.get('ACSUrl', None) and access:
if request.POST.get('ACSUrl', None) and self._has_access():
# 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()(
request=request,
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,
though it's technically not SAML 2.0)."""
def get(self, request):
def get(self, request, application):
"""Perform logout"""
logout(request)
@ -164,11 +178,10 @@ class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page."""
def post(self, request):
def post(self, request, application):
"""Perform logout"""
request.session['SAMLRequest'] = request.POST['SAMLRequest']
# 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: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser.
@ -183,8 +196,8 @@ class DescriptorDownloadView(ProviderMixin, View):
def get(self, request, application):
"""Replies with the XML Metadata IDSSODescriptor."""
entity_id = CONFIG.y('saml_idp.issuer')
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={
slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-logout'))
sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-login', kwargs={
'application': application
}))
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):
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
super().dispatch(request, application)
self.provider.processor.init_deep_link(request, '')
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/otp/requirements.txt
-r passbook/oauth_provider/requirements.txt
-r passbook/audit/requirements.txt
-r passbook/captcha_factor/requirements.txt
-r passbook/admin/requirements.txt
-r passbook/api/requirements.txt