Compare commits

...

31 Commits

Author SHA1 Message Date
95de6a14fd bump version: 0.0.11-alpha -> 0.0.12-alpha 2019-02-27 13:18:28 +01:00
17132ebc19 Verify OAuth Username vuln and fix closes #9 2019-02-27 13:18:16 +01:00
289be46388 fix SAML Views not having LoginRequiredMixin 2019-02-27 12:36:18 +01:00
6c300b7b31 autofocus password field 2019-02-27 12:35:57 +01:00
b726583084 Keep GET parameters throughout entire login process 2019-02-27 12:35:48 +01:00
48055d1cfd fix CSRF Bug in SAML 2019-02-27 11:20:52 +01:00
436070f5bd fix redis connection issues in k8s 2019-02-27 09:59:01 +01:00
3ee79818db explicit version in helm values 2019-02-27 09:33:26 +01:00
e7a02104db fix display on mobile 2019-02-27 09:33:12 +01:00
556740d7bc add PasswordPolicyForm back in 2019-02-26 15:41:11 +01:00
421f51770c implement password policy checking on signup and password change closes #8 2019-02-26 15:40:58 +01:00
96f7e70f9e enable always_eager when unittesting 2019-02-26 14:24:50 +01:00
ad96f7dbb8 add E-Mail support via celery task, untested, closes #17 2019-02-26 14:10:53 +01:00
e7fb48eba2 bump version: 0.0.10-alpha -> 0.0.11-alpha 2019-02-26 13:06:26 +01:00
b19b5b644d remove hardcoded passwords 2019-02-26 13:06:22 +01:00
250b6691d4 bump version: 0.0.9-alpha -> 0.0.10-alpha 2019-02-26 12:44:02 +01:00
e3b02a6e78 fix isort/pylint issues 2019-02-26 12:43:59 +01:00
e94ef34d8f bump version: 0.0.8-alpha -> 0.0.9-alpha 2019-02-26 12:35:28 +01:00
49e945307a Re-enable OTP Disable View 2019-02-26 12:35:24 +01:00
edfe0e5450 fix broken Docker build and helm package 2019-02-26 12:34:51 +01:00
06b65a7882 add unittests, woo 2019-02-26 10:57:05 +01:00
ff9bc8aa70 Automatically create PasswordFactor on initial setup closes #16 2019-02-26 09:54:51 +01:00
28da67abe6 Improve partially broken Delete Views, show success message on deletion 2019-02-26 09:49:42 +01:00
39d9fe9bf0 add passbook.pretend to use passbook in applications which don't support generic OAuth 2019-02-26 09:10:37 +01:00
750117b0fd Cleanup templates, handle OAuth Provider without application better 2019-02-26 09:09:19 +01:00
983462f80d user/ -> _/user/ to prevent duplicate URLs 2019-02-26 09:08:49 +01:00
4ae31d409b directly use paths instead of including oauth2_provider's 2019-02-26 09:08:22 +01:00
98b414f3e2 add SignUp Confirmation (required by default, can be disabled in invitations) closes #6 2019-02-25 21:03:24 +01:00
a0d42092e3 add Nonce (one-time links), add password reset function (missing e-mail verification), closes #7 2019-02-25 20:46:23 +01:00
f2569b6424 improve placeholder on login template 2019-02-25 19:43:33 +01:00
9d344d887c add more information to administrator Overview 2019-02-25 17:52:51 +01:00
92 changed files with 1348 additions and 249 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.0.8-alpha
current_version = 0.0.12-alpha
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
@ -14,6 +14,8 @@ values =
beta
stable
[bumpversion:file:helm/passbook/values.yaml]
[bumpversion:file:helm/passbook/Chart.yaml]
[bumpversion:file:.gitlab-ci.yml]

View File

@ -16,7 +16,6 @@ variables:
POSTGRES_DB: passbook
POSTGRES_USER: passbook
POSTGRES_PASSWORD: 'EK-5jnKfjrGRm<77'
SUPERVISR_ENV: ci
include:
- /allauth/.gitlab-ci.yml
@ -52,9 +51,9 @@ package-docker:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
before_script:
- echo "{\"auths\":{\"https://docker.$NEXUS_URL/\":{\"username\":\"$NEXUS_USER\",\"password\":\"$NEXUS_PASS\"}}}" > /kaniko/.docker/config.json
- 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.8-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.0.12-alpha
stage: build
only:
- tags
@ -65,7 +64,7 @@ package-helm:
- curl https://raw.githubusercontent.com/helm/helm/master/scripts/get | bash
- helm init --client-only
- helm package helm/passbook
- ./manage.py nexus_upload --method put --url $NEXUS_URL --user $NEXUS_USER --password $NEXUS_PASS --repo helm *.tgz
- ./manage.py nexus_upload --method put --url $NEXUS_URL --auth $NEXUS_AUTH --repo helm *.tgz
only:
- tags
- /^version/.*$/

View File

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

View File

@ -36,7 +36,7 @@ data:
debug: false
secure_proxy_header:
HTTP_X_FORWARDED_PROTO: https
redis: {{ .Release.Name }}-redis
redis: ":{{ .Values.redis.password }}@{{ .Release.Name }}-redis-master"
# Error reporting, sends stacktrace to sentry.services.beryju.org
error_report_enabled: {{ .Values.config.error_reporting }}

View File

@ -5,7 +5,7 @@
replicaCount: 1
image:
tag: latest
tag: 0.0.12-alpha
nameOverride: ""

View File

@ -1,2 +1,2 @@
"""passbook"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -1,2 +1,2 @@
"""passbook admin"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -137,5 +137,43 @@
</div>
</div>
</div>
<div class="col-xs-6 col-sm-2 col-md-2">
<div class="card-pf card-pf-accented card-pf-aggregate-status">
<h2 class="card-pf-title">
<a href="#">
<span class="pficon-bundle"></span>
<span class="card-pf-aggregate-status-count"></span> {% trans 'Version' %}
</a>
</h2>
<div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification">
<a href="#">
{{ version }}
</a>
</span>
</p>
</div>
</div>
</div>
<div class="col-xs-6 col-sm-2 col-md-2">
<div class="card-pf card-pf-accented card-pf-aggregate-status">
<h2 class="card-pf-title">
<a href="#">
<span class="pficon-server"></span>
<span class="card-pf-aggregate-status-count"></span> {% trans 'Worker(s)' %}
</a>
</h2>
<div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification">
<a href="#">
<span class="pficon pficon-ok"></span>{{ worker_count }}
</a>
</span>
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -31,6 +31,8 @@
href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
</td>
</tr>
{% endfor %}

View File

@ -56,6 +56,8 @@ urlpatterns = [
users.UserUpdateView.as_view(), name='user-update'),
path('users/<int:pk>/delete/',
users.UserDeleteView.as_view(), name='user-delete'),
path('users/<int:pk>/reset/',
users.UserPasswordResetView.as_view(), name='user-password-reset'),
# Audit Log
path('audit/', audit.AuditEntryListView.as_view(), name='audit-log'),
# Groups

View File

@ -1,4 +1,5 @@
"""passbook Application administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
@ -45,5 +46,10 @@ class ApplicationDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView)
model = Application
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:applications')
success_message = _('Successfully updated Application')
success_message = _('Successfully deleted Application')
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)

View File

@ -1,4 +1,5 @@
"""passbook Factor administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.urls import reverse_lazy
@ -73,7 +74,11 @@ class FactorDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
model = Factor
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:factors')
success_message = _('Successfully updated Factor')
success_message = _('Successfully deleted Factor')
def get_object(self, queryset=None):
return Factor.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first()
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)

View File

@ -1,4 +1,5 @@
"""passbook Invitation administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
@ -42,4 +43,8 @@ class InvitationDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
model = Invitation
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:invitations')
success_message = _('Successfully updated Invitation')
success_message = _('Successfully deleted Invitation')
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)

View File

@ -2,6 +2,8 @@
from django.views.generic import TemplateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core import __version__
from passbook.core.celery import CELERY_APP
from passbook.core.models import (Application, Factor, Invitation, Policy,
Provider, Source, User)
@ -19,4 +21,6 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs['source_count'] = len(Source.objects.all())
kwargs['factor_count'] = len(Factor.objects.all())
kwargs['invitation_count'] = len(Invitation.objects.all())
kwargs['version'] = __version__
kwargs['worker_count'] = len(CELERY_APP.control.ping(timeout=0.5))
return super().get_context_data(**kwargs)

View File

@ -68,11 +68,15 @@ class PolicyDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
model = Policy
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:policies')
success_message = _('Successfully updated Policy')
success_message = _('Successfully deleted Policy')
def get_object(self, queryset=None):
return Policy.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first()
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)
class PolicyTestView(AdminRequiredMixin, DetailView, FormView):
"""View to test policy(s)"""

View File

@ -1,4 +1,5 @@
"""passbook Provider administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.urls import reverse_lazy
@ -64,7 +65,11 @@ class ProviderDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
model = Provider
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:providers')
success_message = _('Successfully updated Provider')
success_message = _('Successfully deleted Provider')
def get_object(self, queryset=None):
return Provider.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first()
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)

View File

@ -1,4 +1,5 @@
"""passbook Source administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.urls import reverse_lazy
@ -66,9 +67,13 @@ class SourceDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
"""Delete source"""
model = Source
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:sources')
success_message = _('Successfully updated Source')
success_message = _('Successfully deleted Source')
def get_object(self, queryset=None):
return Source.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first()
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)

View File

@ -1,12 +1,15 @@
"""passbook User administration"""
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import DeleteView, ListView, UpdateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.forms.users import UserDetailForm
from passbook.core.models import User
from passbook.core.models import Nonce, User
class UserListView(AdminRequiredMixin, ListView):
@ -31,6 +34,24 @@ class UserDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
"""Delete user"""
model = User
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:users')
success_message = _('Successfully updated User')
success_message = _('Successfully deleted User')
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)
class UserPasswordResetView(AdminRequiredMixin, View):
"""Get Password reset link for user"""
# pylint: disable=invalid-name
def get(self, request, pk):
"""Create nonce for user and return link"""
user = get_object_or_404(User, pk=pk)
nonce = Nonce.objects.create(user=user)
link = request.build_absolute_uri(reverse(
'passbook_core:auth-password-reset', kwargs={'nonce': nonce.uuid}))
messages.success(request, _('Password reset link: <pre>%(link)s</pre>' % {'link': link}))
return redirect('passbook_admin:users')

View File

@ -1,2 +1,2 @@
"""passbook api"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -1,2 +1,2 @@
"""passbook audit Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -51,7 +51,10 @@ class AuditEntry(UUIDModel):
def create(action, request, **kwargs):
"""Create AuditEntry from arguments"""
client_ip, _ = get_client_ip(request)
user = request.user
if not hasattr(request, 'user'):
user = None
else:
user = request.user
if isinstance(user, AnonymousUser):
user = kwargs.get('user', None)
entry = AuditEntry.objects.create(
@ -60,7 +63,7 @@ class AuditEntry(UUIDModel):
# User 255.255.255.255 as fallback if IP cannot be determined
request_ip=client_ip or '255.255.255.255',
context=kwargs)
LOGGER.debug("Logged %s from %s (%s)", action, request.user, client_ip)
LOGGER.debug("Logged %s from %s (%s)", action, user, client_ip)
return entry
def save(self, *args, **kwargs):

View File

@ -1,2 +1,2 @@
"""passbook captcha_factor Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -1,2 +1,2 @@
"""passbook core"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -5,13 +5,15 @@ from django.contrib import messages
from django.contrib.auth import authenticate
from django.core.exceptions import PermissionDenied
from django.forms.utils import ErrorList
from django.shortcuts import redirect
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from django.views.generic import FormView
from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.view import AuthenticationView
from passbook.core.forms.authentication import PasswordFactorForm
from passbook.core.models import Nonce
from passbook.core.tasks import send_email
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
@ -29,8 +31,18 @@ class PasswordFactor(FormView, AuthenticationFactor):
def get(self, request, *args, **kwargs):
if 'password-forgotten' in request.GET:
# TODO: Save nonce key in database for password reset
# TODO: Send email to user
nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# Send mail to user
send_email.delay(self.pending_user.email, _('Forgotten password'),
'email/account_password_reset.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:passbook_core:auth-password-reset',
kwargs={
'nonce': nonce.uuid
})
)
})
self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.'))
return redirect('passbook_core:auth-login')

View File

@ -4,6 +4,7 @@ from logging import getLogger
from django.contrib.auth import login
from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.http import urlencode
from django.views.generic import View
from passbook.core.models import Factor, User
@ -13,6 +14,12 @@ from passbook.lib.utils.urls import is_url_absolute
LOGGER = getLogger(__name__)
def _redirect_with_qs(view, get_query_set=None):
"""Wrapper to redirect whilst keeping GET Parameters"""
target = reverse(view)
if get_query_set:
target += '?' + urlencode({key: value for key, value in get_query_set.items()})
return redirect(target)
class AuthenticationView(UserPassesTestMixin, View):
"""Wizard-like Multi-factor authenticator"""
@ -37,7 +44,7 @@ class AuthenticationView(UserPassesTestMixin, View):
# Function from UserPassesTestMixin
if 'next' in self.request.GET:
return redirect(self.request.GET.get('next'))
return redirect(reverse('passbook_core:overview'))
return _redirect_with_qs('passbook_core:overview', self.request.GET)
def dispatch(self, request, *args, **kwargs):
# Extract pending user from session (only remember uid)
@ -46,7 +53,7 @@ class AuthenticationView(UserPassesTestMixin, View):
User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER])
else:
# No Pending user, redirect to login screen
return redirect(reverse('passbook_core:auth-login'))
return _redirect_with_qs('passbook_core:auth-login', request.GET)
# Write pending factors to session
if AuthenticationView.SESSION_PENDING_FACTORS in request.session:
self.pending_factors = request.session[AuthenticationView.SESSION_PENDING_FACTORS]
@ -101,8 +108,8 @@ class AuthenticationView(UserPassesTestMixin, View):
self.pending_factors
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
LOGGER.debug("Rendering Factor is %s", next_factor)
# return redirect(reverse('passbook_core:auth-process', kwargs={'factor': next_factor}))
return redirect(reverse('passbook_core:auth-process'))
# return _redirect_with_qs('passbook_core:auth-process', kwargs={'factor': next_factor})
return _redirect_with_qs('passbook_core:auth-process', self.request.GET)
# User passed all factors
LOGGER.debug("User passed all factors, logging in")
return self._user_passed()
@ -112,7 +119,7 @@ class AuthenticationView(UserPassesTestMixin, View):
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
LOGGER.debug("User invalid")
self.cleanup()
return redirect(reverse('passbook_core:auth-denied'))
return _redirect_with_qs('passbook_core:auth-denied', self.request.GET)
def _user_passed(self):
"""User Successfully passed all factors"""
@ -123,9 +130,9 @@ class AuthenticationView(UserPassesTestMixin, View):
# Cleanup
self.cleanup()
next_param = self.request.GET.get('next', None)
if next_param and is_url_absolute(next_param):
if next_param and not is_url_absolute(next_param):
return redirect(next_param)
return redirect(reverse('passbook_core:overview'))
return _redirect_with_qs('passbook_core:overview')
def cleanup(self):
"""Remove temporary data from session"""

View File

@ -0,0 +1,10 @@
"""passbook core exceptions"""
class PasswordPolicyInvalid(Exception):
"""Exception raised when a Password Policy fails"""
messages = []
def __init__(self, *messages):
super().__init__()
self.messages = messages

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
from passbook.core.models import User
from passbook.lib.config import CONFIG
from passbook.lib.utils.ui import human_list
LOGGER = getLogger(__name__)
@ -15,13 +16,16 @@ class LoginForm(forms.Form):
"""Allow users to login"""
title = _('Log in to your account')
uid_field = forms.CharField(widget=forms.TextInput(attrs={'placeholder': _('UID')}))
uid_field = forms.CharField()
remember_me = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if CONFIG.y('passbook.uid_fields') == ['email']:
if CONFIG.y('passbook.uid_fields') == ['e-mail']:
self.fields['uid_field'] = forms.EmailField()
self.fields['uid_field'].widget.attrs = {
'placeholder': _(human_list([x.title() for x in CONFIG.y('passbook.uid_fields')]))
}
def clean_uid_field(self):
"""Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
@ -87,4 +91,7 @@ class SignUpForm(forms.Form):
class PasswordFactorForm(forms.Form):
"""Password authentication form"""
password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _('Password')}))
password = forms.CharField(widget=forms.PasswordInput(attrs={
'placeholder': _('Password'),
'autofocus': 'autofocus'
}))

View File

@ -27,7 +27,7 @@ class InvitationForm(forms.ModelForm):
class Meta:
model = Invitation
fields = ['expires', 'fixed_username', 'fixed_email']
fields = ['expires', 'fixed_username', 'fixed_email', 'needs_confirmation']
labels = {
'fixed_username': "Force user's username (optional)",
'fixed_email': "Force user's email (optional)",

View File

@ -3,7 +3,8 @@
from django import forms
from django.utils.translation import gettext as _
from passbook.core.models import DebugPolicy, FieldMatcherPolicy, WebhookPolicy
from passbook.core.models import (DebugPolicy, FieldMatcherPolicy,
PasswordPolicy, WebhookPolicy)
GENERAL_FIELDS = ['name', 'action', 'negate', 'order', ]
@ -50,3 +51,25 @@ class DebugPolicyForm(forms.ModelForm):
labels = {
'result': _('Allow user')
}
class PasswordPolicyForm(forms.ModelForm):
"""PasswordPolicy Form"""
class Meta:
model = PasswordPolicy
fields = GENERAL_FIELDS + ['amount_uppercase', 'amount_lowercase',
'amount_symbols', 'length_min', 'symbol_charset',
'error_message']
widgets = {
'name': forms.TextInput(),
'symbol_charset': forms.TextInput(),
'error_message': forms.TextInput(),
}
labels = {
'amount_uppercase': _('Minimum amount of Uppercase Characters'),
'amount_lowercase': _('Minimum amount of Lowercase Characters'),
'amount_symbols': _('Minimum amount of Symbols Characters'),
'length_min': _('Minimum Length'),
}

View File

@ -1,5 +1,5 @@
"""passbook nexus_upload management command"""
from getpass import getpass
from base64 import b64decode
import requests
from django.core.management.base import BaseCommand
@ -24,9 +24,9 @@ class Command(BaseCommand):
help='Nexus root URL',
required=True)
parser.add_argument(
'--user',
'--auth',
action='store',
help='Username to use for Nexus upload',
help='base64-encoded string of username:password',
required=True)
parser.add_argument(
'--method',
@ -37,29 +37,21 @@ class Command(BaseCommand):
help=('Method used for uploading files to nexus. '
'Apt repositories use post, Helm uses put.'),
required=True)
parser.add_argument(
'--password',
action='store',
help=("Password to use for Nexus upload. "
"If parameter not given, we'll interactively ask"))
# Positional arguments
parser.add_argument('file', nargs='+', type=str)
def handle(self, *args, **options):
"""Upload debian package to nexus repository"""
if options.get('password') is None:
options['password'] = getpass()
auth = tuple(b64decode(options.get('auth')).decode('utf-8').split(':', 1))
responses = {}
url = 'https://%(url)s/repository/%(repo)s//' % options
url = 'https://%(url)s/repository/%(repo)s/' % options
method = options.get('method')
exit_code = 0
for file in options.get('file'):
if method == 'post':
responses[file] = requests.post(url, data=open(file, mode='rb'),
auth=(options.get('user'), options.get('password')))
responses[file] = requests.post(url, data=open(file, mode='rb'), auth=auth)
else:
responses[file] = requests.put(url+file, data=open(file, mode='rb'),
auth=(options.get('user'), options.get('password')))
responses[file] = requests.put(url+file, data=open(file, mode='rb'), auth=auth)
self.stdout.write('Upload results:\n')
sep = '-' * 60
self.stdout.write('%s\n' % sep)

View File

@ -0,0 +1,31 @@
# Generated by Django 2.1.7 on 2019-02-25 19:12
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import passbook.core.models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0011_auto_20190225_1438'),
]
operations = [
migrations.CreateModel(
name='Nonce',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('expires', models.DateTimeField(default=passbook.core.models.default_nonce_duration)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Nonce',
'verbose_name_plural': 'Nonces',
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.1.7 on 2019-02-25 19:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0012_nonce'),
]
operations = [
migrations.AddField(
model_name='invitation',
name='needs_confirmation',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 2.1.7 on 2019-02-26 08:50
from django.db import migrations
def create_initial_factor(apps, schema_editor):
"""Create initial PasswordFactor if none exists"""
PasswordFactor = apps.get_model("passbook_core", "PasswordFactor")
if not PasswordFactor.objects.exists():
PasswordFactor.objects.create(
name='password',
slug='password',
order=0,
backends=[]
)
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0013_invitation_needs_confirmation'),
]
operations = [
migrations.RunPython(create_initial_factor)
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.1.7 on 2019-02-26 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0014_auto_20190226_0850'),
]
operations = [
migrations.AddField(
model_name='passwordpolicy',
name='error_message',
field=models.TextField(default=''),
preserve_default=False,
),
]

View File

@ -1,8 +1,10 @@
"""passbook core models"""
import re
from datetime import timedelta
from logging import getLogger
from random import SystemRandom
from time import sleep
from typing import Tuple, Union
from uuid import uuid4
from django.contrib.auth.models import AbstractUser
@ -18,6 +20,11 @@ from passbook.lib.models import CreatedUpdatedModel, UUIDModel
LOGGER = getLogger(__name__)
def default_nonce_duration():
"""Default duration a Nonce is valid"""
return now() + timedelta(hours=4)
class Group(UUIDModel):
"""Custom Group model which supports a basic hierarchy"""
@ -43,7 +50,8 @@ class User(AbstractUser):
password_change_date = models.DateTimeField(auto_now_add=True)
def set_password(self, password):
password_changed.send(sender=self, user=self, password=password)
if self.pk:
password_changed.send(sender=self, user=self, password=password)
self.password_change_date = now()
return super().set_password(password)
@ -63,8 +71,9 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
policies = models.ManyToManyField('Policy', blank=True)
def passes(self, user: User) -> bool:
"""Return true if user passes, otherwise False or raise Exception"""
def passes(self, user: User) -> Union[bool, Tuple[bool, str]]:
"""Return False, str if a user fails where str is a
reasons shown to the user. Return True if user succeeds."""
for policy in self.policies.all():
if not policy.passes(user):
return False
@ -216,7 +225,7 @@ class Policy(UUIDModel, CreatedUpdatedModel):
return self.name
return "%s action %s" % (self.name, self.action)
def passes(self, user: User) -> bool:
def passes(self, user: User) -> Union[bool, Tuple[bool, str]]:
"""Check if user instance passes this policy"""
raise NotImplementedError()
@ -261,7 +270,7 @@ class FieldMatcherPolicy(Policy):
description = "%s: %s" % (self.name, description)
return description
def passes(self, user: User) -> bool:
def passes(self, user: User) -> Union[bool, Tuple[bool, str]]:
"""Check if user instance passes this role"""
if not hasattr(user, self.user_field):
raise ValueError("Field does not exist")
@ -296,10 +305,11 @@ class PasswordPolicy(Policy):
amount_symbols = models.IntegerField(default=0)
length_min = models.IntegerField(default=0)
symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
error_message = models.TextField()
form = 'passbook.core.forms.policies.PasswordPolicyForm'
def passes(self, user: User) -> bool:
def passes(self, user: User) -> Union[bool, Tuple[bool, str]]:
# Only check if password is being set
if not hasattr(user, '__password__'):
return True
@ -314,6 +324,8 @@ class PasswordPolicy(Policy):
filter_regex += r'[%s]{%d,}' % (self.symbol_charset, self.amount_symbols)
result = bool(re.compile(filter_regex).match(password))
LOGGER.debug("User got %r", result)
if not result:
return result, self.error_message
return result
class Meta:
@ -372,7 +384,7 @@ class DebugPolicy(Policy):
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
LOGGER.debug("Policy '%s' waiting for %ds", self.name, wait)
sleep(wait)
return self.result
return self.result, 'Debugging'
class Meta:
@ -386,6 +398,7 @@ class Invitation(UUIDModel):
expires = models.DateTimeField(default=None, blank=True, null=True)
fixed_username = models.TextField(blank=True, default=None)
fixed_email = models.TextField(blank=True, default=None)
needs_confirmation = models.BooleanField(default=True)
@property
def link(self):
@ -399,3 +412,17 @@ class Invitation(UUIDModel):
verbose_name = _('Invitation')
verbose_name_plural = _('Invitations')
class Nonce(UUIDModel):
"""One-time link for password resets/signup-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration)
user = models.ForeignKey('User', on_delete=models.CASCADE)
def __str__(self):
return "Nonce %s (expires=%s)" % (self.uuid.hex, self.expires)
class Meta:
verbose_name = _('Nonce')
verbose_name_plural = _('Nonces')

View File

@ -42,7 +42,11 @@ class PolicyEngine:
@property
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
messages.append(policy_message)
if policy_result is False:
return False
return True
return False, messages
return True, messages

View File

@ -74,6 +74,7 @@ INSTALLED_APPS = [
'passbook.otp.apps.PassbookOTPConfig',
'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig',
'passbook.hibp_policy.apps.PassbookHIBPConfig',
'passbook.pretend.apps.PassbookPretendConfig',
]
# Message Tag fix for bootstrap CSS Classes
@ -290,6 +291,7 @@ TEST_OUTPUT_FILE_NAME = 'unittest.xml'
if any('test' in arg for arg in sys.argv):
LOGGING = None
TEST = True
CELERY_TASK_ALWAYS_EAGER = True
_DISALLOWED_ITEMS = ['INSTALLED_APPS', 'MIDDLEWARE', 'AUTHENTICATION_BACKENDS']
# Load subapps's INSTALLED_APPS

View File

@ -1,12 +1,27 @@
"""passbook core signals"""
from django.core.signals import Signal
from django.dispatch import receiver
# from django.db.models.signals import post_save, pre_delete
# from django.dispatch import receiver
# from passbook.core.models import Invitation, User
from passbook.core.exceptions import PasswordPolicyInvalid
user_signed_up = Signal(providing_args=['request', 'user'])
invitation_created = Signal(providing_args=['request', 'invitation'])
invitation_used = Signal(providing_args=['request', 'invitation', 'user'])
password_changed = Signal(providing_args=['user', 'password'])
@receiver(password_changed)
# pylint: disable=unused-argument
def password_policy_checker(sender, password, **kwargs):
"""Run password through all password policies which are applied to the user"""
from passbook.core.models import PasswordFactor
from passbook.core.policies import PolicyEngine
setattr(sender, '__password__', password)
_all_factors = PasswordFactor.objects.filter(enabled=True).order_by('order')
for factor in _all_factors:
if factor.passes(sender):
policy_engine = PolicyEngine(factor.password_policies.all().select_subclasses())
policy_engine.for_user(sender)
passing, messages = policy_engine.result
if not passing:
raise PasswordPolicyInvalid(*messages)

17
passbook/core/tasks.py Normal file
View File

@ -0,0 +1,17 @@
"""passbook core tasks"""
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from passbook.core.celery import CELERY_APP
from passbook.lib.config import CONFIG
@CELERY_APP.task()
def send_email(to_address, subject, template, context):
"""Send Email to user(s)"""
html_content = render_to_string(template, context=context)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(subject, text_content, CONFIG.y('email.from'), [to_address])
msg.attach_alternative(html_content, "text/html")
msg.send()

View File

@ -6,6 +6,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% block title %}
{% title %}
@ -19,6 +20,7 @@
.login-pf {
background-attachment: fixed;
scroll-behavior: smooth;
background-size: cover;
}
</style>
{% block head %}

View File

@ -0,0 +1,84 @@
{% extends 'email/base.html' %}
{% load inline %}
{% load i18n %}
{% block pre_header %}
{% trans "We're thrilled to have you here! Get ready to dive into your new account." %}
{% endblock %}
{% block content %}
<!-- HERO -->
<tr>
<td bgcolor="#3625b7" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td bgcolor="#566572" align="center" valign="top"
style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #8F9BA3; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 32px; font-weight: 400; margin: 0; color: #E9ECEF;">{% trans 'Welcome!' %}
</h1>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<!-- COPY -->
<tr>
<td bgcolor="#566572" align="left"
style="padding: 20px 30px 40px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">
{% trans "We're excited to have you get started. First, you need to confirm your account. Just press the button below."%}
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#566572" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#566572" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3625b7"><a
href="{{ url }}" target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3625b7; display: inline-block;">{% trans 'Confirm Account' %}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#566572" align="left"
style="padding: 0px 30px 0px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">
{% trans "If that doesn't work, copy and paste the following link in your browser:" %}</p>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#566572" align="left"
style="padding: 20px 30px 20px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;"><a href="{{ url }}" target="_blank" style="color: #3625b7;">{{ url }}</a></p>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#566572" align="left"
style="padding: 0px 30px 20px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">
{% trans "If you have any questions, just reply to this email—we're always happy to help out." %}
</p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "email/base.html" %}
{% load utils %}
{% load i18n %}
{% block pre_header %}
{% trans "Looks like you tried signing in a few too many times. Let's see if we can get you back into your account." %}
{% endblock %}
{% block content %}
{% config 'passbook.branding' as branding %}
<!-- HERO -->
<tr>
<td bgcolor="#7c72dc" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">{% trans 'Trouble signing in?' %}</h1>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{% trans "Resetting your password is easy. Just press the button below and follow the instructions. We'll have you up and running in no time." %}</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#7c72dc"><a href="{{ url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #7c72dc; display: inline-block;">{% trans 'Reset Password' %}</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY CALLOUT -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<!-- HEADLINE -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 40px 30px 20px 30px; color: #ffffff; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<h2 style="font-size: 24px; font-weight: 400; margin: 0;">{% trans 'Want a more secure account?' %}</h2>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 0px 30px 20px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{% trans 'We support two-factor authentication to help keep your information private.' %}</p>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 0px 30px 40px 30px; border-radius: 0px 0px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #7c72dc;">{% trans 'See how easy it is to get started' %}</a></p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

@ -0,0 +1,129 @@
{% load inline %}
{% load utils %}
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<title>{% config passbook.branding %}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<style type="text/css">
/* CLIENT-SPECIFIC STYLES */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* RESET STYLES */
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
table {
border-collapse: collapse !important;
}
body {
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* ANDROID CENTER FIX */
div[style*="margin: 16px 0;"] {
margin: 0 !important;
}
</style>
</head>
<body style="background-color: #1b2a32; margin: 0 !important; padding: 0 !important;">
<!-- HIDDEN PREHEADER TEXT -->
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
{% block pre_header %}
{% endblock %}
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- LOGO -->
<tr>
<td bgcolor="#3625b7" align="center">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
<a href="" target="_blank">
<img alt="Logo" src="{% inline_static 'assets/dark.svg' %}" width="64" height="64"
style="display: block; width: 64px; max-width: 64px; min-width: 64px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
border="0">
</a>
</td>
</tr>
</table>
</td>
</tr>
{% block content %}
{% endblock %}
<!-- SUPPORT CALLOUT -->
<!-- <tr>
<td bgcolor="#1b2a32" align="center" style="padding: 30px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
HEADLINE
<tr>
<td bgcolor="#566572" align="center" style="padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<h2 style="font-size: 20px; font-weight: 400; color: ##E9ECEF; margin: 0;">Need more help?</h2>
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #3625b7;">We&rsquo;re
here, ready to talk</a></p>
</td>
</tr>
</table>
</td>
</tr> -->
<!-- FOOTER -->
<tr>
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<!-- NAVIGATION -->
<tr>
<td bgcolor="#1b2a32" align="left" style="padding: 30px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
<p style="margin: 0;">
</p>
</td>
</tr>
<!-- ADDRESS -->
<tr>
<td bgcolor="#1b2a32" align="left" style="padding: 0px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
<p style="margin: 0;"><a href="{% config 'passbook.branding' %}">{% config 'passbook.branding' %}</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,26 @@
{% extends "email/base.html" %}
{% block content %}
<tr>
<td bgcolor="#3625b7" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td bgcolor="#566572" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #8F9BA3; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 32px; font-weight: 400; margin: 0; color: #E9ECEF;">{{ title }}!</h1>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td bgcolor="#566572" align="left" style="padding: 20px 30px 40px 30px; color: #E9ECEF; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{{ body }}</p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

@ -5,16 +5,20 @@
{% block content %}
<div class="container">
{% block above_form %}
<h1>{% trans 'Delete' %}</h1>
{% endblock %}
<div class="">
<form method="post" class="form-horizontal">
{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<a href="{% back %}" class="btn btn-default">{% trans 'Back' %}</a>
<input type="submit" class="btn btn-danger" value="{% trans 'Delete' %}" />
</form>
</div>
{% block above_form %}
<h1>{% blocktrans with object_type=object|fieldtype|title %}Delete {{ object_type }}{% endblocktrans %}</h1>
{% endblock %}
<div class="">
<form method="post" class="form-horizontal">
{% csrf_token %}
<p>
{% blocktrans with object_type=object|fieldtype|title name=object %}
Are you sure you want to delete {{ object_type }} "{{ object }}"?
{% endblocktrans %}
</p>
<a href="{% back %}" class="btn btn-default">{% trans 'Back' %}</a>
<input type="submit" class="btn btn-danger" value="{% trans 'Delete' %}" />
</form>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -29,7 +29,7 @@
<div class="login-pf-page">
<div class="container-fluid">
<div class="row">
<div class="col-sm-6 col-sm-offset-3 col-md-6 col-md-offset-3 col-lg-4 col-lg-offset-4">
<div class="col-sm-12 col-md-8 col-md-offset-2 col-lg-4 col-lg-offset-4">
<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

@ -18,7 +18,6 @@
<header class="login-pf-header">
<h1>{% trans title %}</h1>
</header>
{% include 'partials/messages.html' %}
<form method="POST">
{% csrf_token %}
{% include 'partials/form_login.html' %}

View File

@ -3,45 +3,48 @@
{% csrf_token %}
{% for field in form %}
<div class="form-group login-pf-settings">
{% if field.field.widget|fieldtype == 'RadioSelect' %}
<label class="col-sm-2 control-label" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
<div class="form-group login-pf-settings {% if field.errors %} has-error {% endif %}">
{% if field.field.widget|fieldtype == 'RadioSelect' %}
<label class="col-sm-2 control-label" {% if field.field.required %}class="required" {% endif %}
for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
</label>
{% for c in field %}
<div class="radio col-sm-10">
<input type="radio" id="{{ field.name }}-{{ forloop.counter0 }}" name="{% if wizard %}{{ wizard.steps.current }}-{% endif %}{{ field.name }}" value="{{ c.data.value }}" {% if c.data.selected %} checked {% endif %}>
<label class="col-sm-2 control-label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ c.choice_label }}</label>
<input type="radio" id="{{ field.name }}-{{ forloop.counter0 }}"
name="{% if wizard %}{{ wizard.steps.current }}-{% endif %}{{ field.name }}" value="{{ c.data.value }}"
{% if c.data.selected %} checked {% endif %}>
<label class="col-sm-2 control-label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ c.choice_label }}</label>
</div>
{% endfor %}
{% elif field.field.widget|fieldtype == 'Select' %}
<label class="col-sm-2 control-label" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
{% elif field.field.widget|fieldtype == 'Select' %}
<label class="col-sm-2 control-label" {% if field.field.required %}class="required" {% endif %}
for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
</label>
<div class="select col-sm-10">
{{ field }}
{{ field }}
</div>
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
<label class="checkbox-label">
{{ field }} {{ field.label }}
{{ field }} {{ field.label }}
</label>
{% else %}
<label class="col-sm-2 sr-only" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
</label>
{{ field|css_class:'form-control input-lg' }}
{% if field.help_text %}
<span>
{{ field.help_text }}
</span>
{% endif %}
{% endif %}
{% for error in field.errors %}
<hr>
<div class="alert alert-danger alert-block">
<span class="pficon pficon-error-circle-o"></span>
<strong>{{ error }}</strong>
</div>
{% endfor %}
{% else %}
<label class="col-sm-2 sr-only" {% if field.field.required %}class="required" {% endif %}
for="{{ field.name }}-{{ forloop.counter0 }}">
{{ field.label }}
</label>
{{ field|css_class:'form-control input-lg' }}
{% if field.help_text %}
<span>
{{ field.help_text }}
</span>
{% endif %}
{% endif %}
{% for error in field.errors %}
<span class="help-block">
{{ error }}
</span>
{% endfor %}
</div>
{% endfor %}

View File

@ -1,10 +0,0 @@
"""passbook core login test"""
from django.test import TestCase
class LoginTest(TestCase):
"""Test login"""
def test(self):
"""Stub test"""

View File

@ -0,0 +1,151 @@
"""passbook Core Account Test"""
import string
from random import SystemRandom
from django.test import TestCase
from django.urls import reverse
from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import User
class TestAuthenticationViews(TestCase):
"""passbook Core Account Test"""
def setUp(self):
super().setUp()
self.sign_up_data = {
'first_name': 'Test',
'last_name': 'User',
'username': 'beryjuorg',
'email': 'unittest@passbook.beryju.org',
'password': 'B3ryju0rg!',
'password_repeat': 'B3ryju0rg!',
}
self.login_data = {
'uid_field': 'unittest@example.com',
}
self.user = User.objects.create_superuser(
username='unittest user',
email='unittest@example.com',
password=''.join(SystemRandom().choice(
string.ascii_uppercase + string.digits) for _ in range(8)))
def test_sign_up_view(self):
"""Test account.sign_up view (Anonymous)"""
self.client.logout()
response = self.client.get(reverse('passbook_core:auth-sign-up'))
self.assertEqual(response.status_code, 200)
def test_login_view(self):
"""Test account.login view (Anonymous)"""
self.client.logout()
response = self.client.get(reverse('passbook_core:auth-login'))
self.assertEqual(response.status_code, 200)
# test login with post
form = LoginForm(self.login_data)
self.assertTrue(form.is_valid())
response = self.client.post(reverse('passbook_core:auth-login'), data=form.cleaned_data)
self.assertEqual(response.status_code, 302)
def test_logout_view(self):
"""Test account.logout view"""
self.client.force_login(self.user)
response = self.client.get(reverse('passbook_core:auth-logout'))
self.assertEqual(response.status_code, 302)
def test_sign_up_view_auth(self):
"""Test account.sign_up view (Authenticated)"""
self.client.force_login(self.user)
response = self.client.get(reverse('passbook_core:auth-logout'))
self.assertEqual(response.status_code, 302)
def test_login_view_auth(self):
"""Test account.login view (Authenticated)"""
self.client.force_login(self.user)
response = self.client.get(reverse('passbook_core:auth-login'))
self.assertEqual(response.status_code, 302)
def test_login_view_post(self):
"""Test account.login view POST (Anonymous)"""
login_response = self.client.post(reverse('passbook_core:auth-login'), data=self.login_data)
self.assertEqual(login_response.status_code, 302)
self.assertEqual(login_response.url, reverse('passbook_core:auth-process'))
def test_sign_up_view_post(self):
"""Test account.sign_up view POST (Anonymous)"""
form = SignUpForm(self.sign_up_data)
self.assertTrue(form.is_valid())
response = self.client.post(reverse('passbook_core:auth-sign-up'), data=form.cleaned_data)
self.assertEqual(response.status_code, 302)
# def test_reset_password_init_view(self):
# """Test account.reset_password_init view POST (Anonymous)"""
# form = SignUpForm(self.sign_up_data)
# self.assertTrue(form.is_valid())
# res = test_request(accounts.SignUpView.as_view(),
# method='POST',
# req_kwargs=form.cleaned_data)
# self.assertEqual(res.status_code, 302)
# res = test_request(accounts.PasswordResetInitView.as_view())
# self.assertEqual(res.status_code, 200)
# def test_resend_confirmation(self):
# """Test AccountController.resend_confirmation"""
# form = SignUpForm(self.sign_up_data)
# self.assertTrue(form.is_valid())
# res = test_request(accounts.SignUpView.as_view(),
# method='POST',
# req_kwargs=form.cleaned_data)
# self.assertEqual(res.status_code, 302)
# user = User.objects.get(email=self.sign_up_data['email'])
# # Invalidate all other links for this user
# old_acs = AccountConfirmation.objects.filter(
# user=user)
# for old_ac in old_acs:
# old_ac.confirmed = True
# old_ac.save()
# # Create Account Confirmation UUID
# new_ac = AccountConfirmation.objects.create(user=user)
# self.assertFalse(new_ac.is_expired)
# on_user_confirm_resend.send(
# sender=None,
# user=user,
# request=None)
# def test_reset_passowrd(self):
# """Test reset password POST"""
# # Signup user first
# sign_up_form = SignUpForm(self.sign_up_data)
# self.assertTrue(sign_up_form.is_valid())
# sign_up_res = test_request(accounts.SignUpView.as_view(),
# method='POST',
# req_kwargs=sign_up_form.cleaned_data)
# self.assertEqual(sign_up_res.status_code, 302)
# user = User.objects.get(email=self.sign_up_data['email'])
# # Invalidate all other links for this user
# old_acs = AccountConfirmation.objects.filter(
# user=user)
# for old_ac in old_acs:
# old_ac.confirmed = True
# old_ac.save()
# # Create Account Confirmation UUID
# new_ac = AccountConfirmation.objects.create(user=user)
# self.assertFalse(new_ac.is_expired)
# uuid = AccountConfirmation.objects.filter(user=user).first().pk
# reset_res = test_request(accounts.PasswordResetFinishView.as_view(),
# method='POST',
# user=user,
# url_kwargs={'uuid': uuid},
# req_kwargs=self.change_data)
# self.assertEqual(reset_res.status_code, 302)
# self.assertEqual(reset_res.url, reverse('common-index'))

View File

@ -0,0 +1,25 @@
"""passbook user view tests"""
import string
from random import SystemRandom
from django.shortcuts import reverse
from django.test import TestCase
from passbook.core.models import User
class TestOverviewViews(TestCase):
"""Test Overview Views"""
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(
username='unittest user',
email='unittest@example.com',
password=''.join(SystemRandom().choice(
string.ascii_uppercase + string.digits) for _ in range(8)))
self.client.force_login(self.user)
def test_overview(self):
"""Test UserSettingsView"""
self.assertEqual(self.client.get(reverse('passbook_core:overview')).status_code, 200)

View File

@ -0,0 +1,47 @@
"""passbook user view tests"""
import string
from random import SystemRandom
from django.shortcuts import reverse
from django.test import TestCase
from passbook.core.forms.users import PasswordChangeForm
from passbook.core.models import User
class TestUserViews(TestCase):
"""Test User Views"""
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(
username='unittest user',
email='unittest@example.com',
password=''.join(SystemRandom().choice(
string.ascii_uppercase + string.digits) for _ in range(8)))
self.client.force_login(self.user)
def test_user_settings(self):
"""Test UserSettingsView"""
self.assertEqual(self.client.get(reverse('passbook_core:user-settings')).status_code, 200)
def test_user_delete(self):
"""Test UserDeleteView"""
self.assertEqual(self.client.post(reverse('passbook_core:user-delete')).status_code, 302)
self.assertEqual(User.objects.filter(username='unittest user').exists(), False)
self.setUp()
def test_user_change_password(self):
"""Test UserChangePasswordView"""
form_data = {
'password': 'test2',
'password_repeat': 'test2'
}
form = PasswordChangeForm(data=form_data)
self.assertTrue(form.is_valid())
self.assertEqual(self.client.get(
reverse('passbook_core:user-change-password')).status_code, 200)
self.assertEqual(self.client.post(
reverse('passbook_core:user-change-password'), data=form_data).status_code, 302)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('test2'))

View File

@ -0,0 +1,25 @@
"""passbook util view tests"""
from django.test import RequestFactory, TestCase
from passbook.core.views.utils import LoadingView, PermissionDeniedView
class TestUtilViews(TestCase):
"""Test Utility Views"""
def setUp(self):
self.factory = RequestFactory()
def test_loading_view(self):
"""Test loading view"""
request = self.factory.get('something')
response = LoadingView.as_view(target_url='somestring')(request)
response.render()
self.assertIn('somestring', response.content.decode('utf-8'))
def test_permission_denied_view(self):
"""Test PermissionDeniedView"""
request = self.factory.get('something')
response = PermissionDeniedView.as_view()(request)
self.assertEqual(response.status_code, 200)

View File

@ -19,13 +19,17 @@ core_urls = [
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'),
path('auth/sign_up/<uuid:nonce>/confirm/', authentication.SignUpConfirmView.as_view(),
name='auth-sign-up-confirm'),
path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'),
path('auth/password/reset/<uuid:nonce>/', authentication.PasswordResetView.as_view(),
name='auth-password-reset'),
path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'),
# User views
path('user/', user.UserSettingsView.as_view(), name='user-settings'),
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'),
path('user/change_password/', user.UserChangePasswordView.as_view(),
path('_/user/', user.UserSettingsView.as_view(), name='user-settings'),
path('_/user/delete/', user.UserDeleteView.as_view(), name='user-delete'),
path('_/user/change_password/', user.UserChangePasswordView.as_view(),
name='user-change-password'),
# Overview
path('', overview.OverviewView.as_view(), name='overview'),

View File

@ -1,7 +1,8 @@
"""passbook access helper classes"""
from logging import getLogger
from django.http import Http404
from django.contrib import messages
from django.utils.translation import gettext as _
from passbook.core.models import Application
@ -11,14 +12,18 @@ class AccessMixin:
"""Mixin class for usage in Authorization views.
Provider functions to check application access, etc"""
# request is set by view but since this Mixin has no base class
request = None
def provider_to_application(self, provider):
"""Lookup application assigned to provider, throw error if no application assigned"""
try:
return provider.application
except Application.DoesNotExist as exc:
# TODO: Log that no provider has no application assigned
LOGGER.warning('Provider "%s" has no application assigned...', provider)
raise Http404 from exc
messages.error(self.request, _('Provider "%(name)s" has no application assigned' % {
'name': provider
}))
raise exc
def user_has_access(self, application, user):
"""Check if user has access to application."""

View File

@ -1,24 +1,28 @@
"""Core views"""
"""passbook core authentication views"""
from logging import getLogger
from typing import Dict
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth import login, logout
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.forms.utils import ErrorList
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, reverse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import FormView
from passbook.core.auth.view import AuthenticationView
from passbook.core.auth.view import AuthenticationView, _redirect_with_qs
from passbook.core.exceptions import PasswordPolicyInvalid
from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Source, User
from passbook.core.models import Invitation, Nonce, Source, User
from passbook.core.signals import invitation_used, user_signed_up
from passbook.core.tasks import send_email
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
class LoginView(UserPassesTestMixin, FormView):
"""Allow users to sign in"""
@ -52,6 +56,9 @@ class LoginView(UserPassesTestMixin, FormView):
def get_user(self, uid_value) -> User:
"""Find user instance. Returns None if no user was found."""
for search_field in CONFIG.y('passbook.uid_fields'):
# Workaround for E-Mail -> email
if search_field == 'e-mail':
search_field = 'email'
users = User.objects.filter(**{search_field: uid_value})
if users.exists():
LOGGER.debug("Found user %s with uid_field %s", users.first(), search_field)
@ -66,13 +73,14 @@ class LoginView(UserPassesTestMixin, FormView):
return self.invalid_login(self.request)
self.request.session.flush()
self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk
return redirect(reverse('passbook_core:auth-process'))
return _redirect_with_qs('passbook_core:auth-process', self.request.GET)
def invalid_login(self, request: HttpRequest, disabled_user: User = None) -> HttpResponse:
"""Handle login for disabled users/invalid login attempts"""
messages.error(request, _('Failed to authenticate.'))
return self.render_to_response(self.get_context_data())
class LogoutView(LoginRequiredMixin, View):
"""Log current user out"""
@ -135,7 +143,32 @@ class SignUpView(UserPassesTestMixin, FormView):
def form_valid(self, form: SignUpForm) -> HttpResponse:
"""Create user"""
self._user = SignUpView.create_user(form.cleaned_data, self.request)
try:
self._user = SignUpView.create_user(form.cleaned_data, self.request)
except PasswordPolicyInvalid as exc:
# Manually inject error into form
# pylint: disable=protected-access
errors = form._errors.setdefault("password", ErrorList())
for error in exc.messages:
errors.append(error)
return self.form_invalid(form)
needs_confirmation = True
if self._invitation and not self._invitation.needs_confirmation:
needs_confirmation = False
if needs_confirmation:
nonce = Nonce.objects.create(user=self._user)
LOGGER.debug(str(nonce.uuid))
# Send email to user
send_email.delay(self._user.email, _('Confirm your account.'),
'email/account_confirm.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-sign-up-confirm', kwargs={
'nonce': nonce.uuid
})
)
})
self._user.is_active = False
self._user.save()
self.consume_invitation()
messages.success(self.request, _("Successfully signed up!"))
LOGGER.debug("Successfully signed up %s",
@ -164,26 +197,59 @@ class SignUpView(UserPassesTestMixin, FormView):
The user created
Raises:
SignalException: if any signals raise an exception. This also deletes the created user.
PasswordPolicyInvalid: if any policy are not fulfilled.
This also deletes the created user.
"""
# Create user
new_user = User.objects.create_user(
new_user = User.objects.create(
username=data.get('username'),
email=data.get('email'),
first_name=data.get('first_name'),
last_name=data.get('last_name'),
)
new_user.is_active = True
new_user.set_password(data.get('password'))
new_user.save()
request.user = new_user
# Send signal for other auth sources
user_signed_up.send(
sender=SignUpView,
user=new_user,
request=request)
# TODO: Implement Verification, via email or others
# if needs_confirmation:
# Create Account Confirmation UUID
# AccountConfirmation.objects.create(user=new_user)
return new_user
try:
new_user.set_password(data.get('password'))
new_user.save()
request.user = new_user
# Send signal for other auth sources
user_signed_up.send(
sender=SignUpView,
user=new_user,
request=request)
return new_user
except PasswordPolicyInvalid as exc:
new_user.delete()
raise exc
class SignUpConfirmView(View):
"""Confirm registration from Nonce"""
def get(self, request, nonce):
"""Verify UUID and activate user"""
nonce = get_object_or_404(Nonce, uuid=nonce)
nonce.user.is_active = True
nonce.user.save()
# Workaround: hardcoded reference to ModelBackend, needs testing
nonce.user.backend = 'django.contrib.auth.backends.ModelBackend'
login(request, nonce.user)
nonce.delete()
messages.success(request, _('Successfully confirmed registration.'))
return redirect('passbook_core:overview')
class PasswordResetView(View):
"""Temporarily authenticate User and allow them to reset their password"""
def get(self, request, nonce):
"""Authenticate user with nonce and redirect to password change view"""
# 3. (Optional) Trap user in password change view
nonce = get_object_or_404(Nonce, uuid=nonce)
# Workaround: hardcoded reference to ModelBackend, needs testing
nonce.user.backend = 'django.contrib.auth.backends.ModelBackend'
login(request, nonce.user)
nonce.delete()
messages.success(request, _(('Temporarily authenticated with Nonce, '
'please change your password')))
return redirect('passbook_core:user-change-password')

View File

@ -1,16 +1,19 @@
"""passbook core user views"""
from django.contrib import messages
from django.contrib.auth import logout, update_session_auth_hash
from django.forms.utils import ErrorList
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from django.views.generic import DeleteView, FormView, UpdateView
from passbook.core.exceptions import PasswordPolicyInvalid
from passbook.core.forms.users import PasswordChangeForm, UserDetailForm
from passbook.lib.config import CONFIG
class UserSettingsView(UpdateView):
"""Update User settings"""
template_name = 'user/settings.html'
form_class = UserDetailForm
@ -37,10 +40,20 @@ class UserChangePasswordView(FormView):
template_name = 'login/form_with_user.html'
def form_valid(self, form: PasswordChangeForm):
self.request.user.set_password(form.cleaned_data.get('password'))
self.request.user.save()
update_session_auth_hash(self.request, self.request.user)
messages.success(self.request, _('Successfully changed password'))
try:
self.request.user.set_password(form.cleaned_data.get('password'))
self.request.user.save()
update_session_auth_hash(self.request, self.request.user)
messages.success(self.request, _('Successfully changed password'))
except PasswordPolicyInvalid as exc:
# Manually inject error into form
# pylint: disable=protected-access
errors = form._errors.setdefault("password_repeat", ErrorList(''))
# pylint: disable=protected-access
errors = form._errors.setdefault("password", ErrorList())
for error in exc.messages:
errors.append(error)
return self.form_invalid(form)
return redirect('passbook_core:overview')
def get_context_data(self, **kwargs):

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.7 on 2019-02-25 19:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('passbook_hibp_policy', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='haveibeenpwendpolicy',
options={'verbose_name': 'have i been pwned Policy', 'verbose_name_plural': 'have i been pwned Policies'},
),
]

View File

@ -1,6 +1,6 @@
"""passbook HIBP Models"""
from hashlib import sha1
from logging import getLogger
from django.db import models
from django.utils.translation import gettext as _
@ -8,6 +8,7 @@ from requests import get
from passbook.core.models import Policy, User
LOGGER = getLogger(__name__)
class HaveIBeenPwendPolicy(Policy):
"""Check if password is on HaveIBeenPwned's list by upload the first
@ -33,8 +34,9 @@ class HaveIBeenPwendPolicy(Policy):
full_hash, count = line.split(':')
if pw_hash[5:] == full_hash.lower():
final_count = int(count)
LOGGER.debug("Got count %d for hash %s", final_count, pw_hash[:5])
if final_count > self.allowed_count:
return False
return False, _("Password exists on %(count)d online lists." % {'count': final_count})
return True
class Meta:

View File

@ -1,2 +1,2 @@
"""Passbook ldap app Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -39,7 +39,7 @@ class LDAPSourceForm(forms.ModelForm):
# (MODE_CREATE_USERS, _('Create Users'))
# )
# namespace = 'supervisr.mod.auth.ldap'
# namespace = 'passbook.ldap'
# settings = ['enabled', 'mode']
# widgets = {
@ -51,7 +51,7 @@ class LDAPSourceForm(forms.ModelForm):
# class ConnectionSettings(SettingsForm):
# """Connection settings form"""
# namespace = 'supervisr.mod.auth.ldap'
# namespace = 'passbook.ldap'
# settings = ['server', 'server:tls', 'bind:user', 'bind:password', 'domain']
# attrs_map = {
@ -68,7 +68,7 @@ class LDAPSourceForm(forms.ModelForm):
# class AuthenticationBackendSettings(SettingsForm):
# """Authentication backend settings"""
# namespace = 'supervisr.mod.auth.ldap'
# namespace = 'passbook.ldap'
# settings = ['base']
# attrs_map = {
@ -79,7 +79,7 @@ class LDAPSourceForm(forms.ModelForm):
# class CreateUsersSettings(SettingsForm):
# """Create users settings"""
# namespace = 'supervisr.mod.auth.ldap'
# namespace = 'passbook.ldap'
# settings = ['create_base']
# attrs_map = {

View File

@ -57,7 +57,7 @@ class LDAPSource(Source):
# class LDAPGroupMapping(UUIDModel, CreatedUpdatedModel):
# """Model to map an LDAP Group to a supervisr group"""
# """Model to map an LDAP Group to a passbook group"""
# ldap_dn = models.TextField()
# group = models.ForeignKey(Group, on_delete=models.CASCADE)

View File

@ -2,7 +2,7 @@
# from django.conf.urls import url
# from supervisr.mod.auth.ldap import views
# from passbook.mod.auth.ldap import views
# urlpatterns = [
# url(r'^settings/$', views.admin_settings, name='admin_settings'),

View File

@ -1,4 +1,4 @@
# """Supervisr Mod LDAP Views"""
# """passbook LDAP Views"""
# from django.contrib import messages
@ -8,7 +8,7 @@
# from django.urls import reverse
# from django.utils.translation import ugettext as _
# from supervisr.mod.auth.ldap.forms import (AuthenticationBackendSettings,
# from passbook.ldap.forms import (AuthenticationBackendSettings,
# ConnectionSettings,
# CreateUsersSettings,
# GeneralSettingsForm)
@ -34,5 +34,5 @@
# if form.is_valid():
# update_count += form.save()
# messages.success(request, _('Successfully updated %d settings.' % update_count))
# return redirect(reverse('supervisr_mod_auth_ldap:admin_settings'))
# return redirect(reverse('passbook_ldap:admin_settings'))
# return render(request, 'ldap/settings.html', render_data)

View File

@ -1,2 +1,2 @@
"""passbook lib"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -0,0 +1,20 @@
"""passbook core inlining template tags"""
import os
from django import template
from django.conf import settings
register = template.Library()
@register.simple_tag()
def inline_static(path):
"""Inline static asset. If file is binary, return b64 representation"""
prefix = 'data:image/svg+xml;utf8,'
data = ''
full_path = settings.STATIC_ROOT + '/' + path
if os.path.exists(full_path):
if full_path.endswith('.svg'):
with open(full_path) as _file:
data = _file.read()
return prefix + data

7
passbook/lib/utils/ui.py Normal file
View File

@ -0,0 +1,7 @@
"""passbook UI utils"""
def human_list(_list) -> str:
"""Convert a list of items into 'a, b or c'"""
last_item = _list.pop()
result = ', '.join(_list)
return '%s or %s' % (result, last_item)

View File

@ -1,2 +1,2 @@
"""passbook oauth_client Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -2,7 +2,6 @@
import json
from logging import getLogger
from django.contrib.auth import get_user_model
from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuth2Client
@ -50,12 +49,11 @@ class DiscordOAuth2Callback(OAuthCallback):
client_class = DiscordOAuth2Client
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('username'),
'username': info.get('username'),
'email': info.get('email', 'None'),
'first_name': info.get('username'),
'password': None,
}
discord_user = user_get_or_create(user_model=user, **user_data)
discord_user = user_get_or_create(**user_data)
return discord_user

View File

@ -1,7 +1,5 @@
"""Facebook OAuth Views"""
from django.contrib.auth import get_user_model
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
@ -22,12 +20,11 @@ class FacebookOAuth2Callback(OAuthCallback):
"""Facebook OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('name'),
'username': info.get('name'),
'email': info.get('email', ''),
'first_name': info.get('name'),
'password': None,
}
fb_user = user_get_or_create(user_model=user, **user_data)
fb_user = user_get_or_create(**user_data)
return fb_user

View File

@ -1,7 +1,5 @@
"""GitHub OAuth Views"""
from django.contrib.auth import get_user_model
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback
@ -12,12 +10,11 @@ class GitHubOAuth2Callback(OAuthCallback):
"""GitHub OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('login'),
'username': info.get('login'),
'email': info.get('email', ''),
'first_name': info.get('name'),
'password': None,
}
gh_user = user_get_or_create(user_model=user, **user_data)
gh_user = user_get_or_create(**user_data)
return gh_user

View File

@ -1,6 +1,4 @@
"""Google OAuth Views"""
from django.contrib.auth import get_user_model
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
@ -21,12 +19,11 @@ class GoogleOAuth2Callback(OAuthCallback):
"""Google OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('email'),
'username': info.get('email'),
'email': info.get('email', ''),
'first_name': info.get('name'),
'password': None,
}
google_user = user_get_or_create(user_model=user, **user_data)
google_user = user_get_or_create(**user_data)
return google_user

View File

@ -2,7 +2,6 @@
import json
from logging import getLogger
from django.contrib.auth import get_user_model
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
@ -59,12 +58,11 @@ class RedditOAuth2Callback(OAuthCallback):
client_class = RedditOAuth2Client
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('name'),
'username': info.get('name'),
'email': None,
'first_name': info.get('name'),
'password': None,
}
reddit_user = user_get_or_create(user_model=user, **user_data)
reddit_user = user_get_or_create(**user_data)
return reddit_user

View File

@ -3,7 +3,6 @@
import json
from logging import getLogger
from django.contrib.auth import get_user_model
from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuth2Client
@ -44,12 +43,11 @@ class SupervisrOAuthCallback(OAuthCallback):
return info['pk']
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('username'),
'username': info.get('username'),
'email': info.get('email', ''),
'first_name': info.get('first_name'),
'password': None,
}
sv_user = user_get_or_create(user_model=user, **user_data)
sv_user = user_get_or_create(**user_data)
return sv_user

View File

@ -2,7 +2,6 @@
from logging import getLogger
from django.contrib.auth import get_user_model
from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuthClient
@ -36,12 +35,11 @@ class TwitterOAuthCallback(OAuthCallback):
client_class = TwitterOAuthClient
def get_or_create_user(self, source, access, info):
user = get_user_model()
user_data = {
user.USERNAME_FIELD: info.get('screen_name'),
'username': info.get('screen_name'),
'email': info.get('email', ''),
'first_name': info.get('name'),
'password': None,
}
tw_user = user_get_or_create(user_model=user, **user_data)
tw_user = user_get_or_create(**user_data)
return tw_user

View File

@ -1,16 +1,17 @@
"""OAuth Client User Creation Utils"""
from django.contrib.auth import get_user_model
from django.db.utils import IntegrityError
from passbook.core.models import User
def user_get_or_create(user_model=None, **kwargs):
def user_get_or_create(**kwargs):
"""Create user or return existing user"""
if user_model is None:
user_model = get_user_model()
try:
new_user = user_model.objects.create_user(**kwargs)
new_user = User.objects.create_user(**kwargs)
except IntegrityError:
# TODO: Fix potential username change vuln
new_user = user_model.objects.get(username=kwargs['username'])
# At this point we've already checked that there is no existing connection
# to any user. Hence if we can't create the user,
kwargs['username'] = '%s_1' % kwargs['username']
new_user = User.objects.create_user(**kwargs)
return new_user

View File

@ -113,7 +113,9 @@ class OAuthCallback(OAuthClientMixin, View):
)
user = authenticate(source=self.source, identifier=identifier, request=request)
if user is None:
LOGGER.debug("Handling new user")
return self.handle_new_user(self.source, connection, info)
LOGGER.debug("Handling existing user")
return self.handle_existing_user(self.source, user, connection, info)
# pylint: disable=unused-argument

View File

@ -1,2 +1,2 @@
"""passbook oauth_provider Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -22,6 +22,8 @@ OAUTH2_PROVIDER = {
'SCOPES': {
'openid:userinfo': 'Access OpenID Userinfo',
# 'write': 'Write scope',
# 'groups': 'Access to your groups'
# 'groups': 'Access to your groups',
'user:email': 'GitHub Compatibility: User E-Mail',
'read:org': 'GitHub Compatibility: User Groups',
}
}

View File

@ -11,7 +11,6 @@
<header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
{% include 'partials/messages.html' %}
<form method="POST">
{% csrf_token %}
{% if not error %}

View File

@ -1,6 +1,7 @@
"""passbook oauth_provider urls"""
from django.urls import include, path
from django.urls import path
from oauth2_provider import views
from passbook.oauth_provider.views import oauth2
@ -13,5 +14,8 @@ urlpatterns = [
path('authorize/permission_denied/', oauth2.OAuthPermissionDenied.as_view(),
name='oauth2-permission-denied'),
# OAuth API
path('', include('oauth2_provider.urls', namespace='oauth2_provider')),
path("authorize/", views.AuthorizationView.as_view(), name="authorize"),
path("token/", views.TokenView.as_view(), name="token"),
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
]

View File

@ -7,6 +7,7 @@ from django.utils.translation import ugettext as _
from oauth2_provider.views.base import AuthorizationView
from passbook.audit.models import AuditEntry
from passbook.core.models import Application
from passbook.core.views.access import AccessMixin
from passbook.core.views.utils import LoadingView, PermissionDeniedView
from passbook.oauth_provider.models import OAuth2Provider
@ -38,14 +39,17 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView):
# Get client_id to get provider, so we can update skip_authorization field
client_id = request.GET.get('client_id')
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
application = self.provider_to_application(provider)
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return redirect('passbook_oauth_provider:oauth2-permission-denied')
# Update field here so oauth-toolkit does work for us
provider.skip_authorization = application.skip_authorization
provider.save()
self._application = application
# Check permissions
if not self.user_has_access(self._application, request.user):
return redirect(reverse('passbook_oauth_provider:oauth2-permission-denied'))
return redirect('passbook_oauth_provider:oauth2-permission-denied')
actual_response = super().dispatch(request, *args, **kwargs)
if actual_response.status_code == 400:
LOGGER.debug(request.GET.get('redirect_uri'))

View File

@ -1,2 +1,2 @@
"""passbook otp Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -6,7 +6,7 @@ from logging import getLogger
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views import View
@ -41,28 +41,27 @@ class UserSettingsView(LoginRequiredMixin, TemplateView):
kwargs['state'] = totp_devices.exists() and static.exists()
return kwargs
class DisableView(LoginRequiredMixin, TemplateView):
class DisableView(LoginRequiredMixin, View):
"""Disable TOTP for user"""
# TODO: Use Django DeleteView with custom delete?
# def
# # Delete all the devices for user
# static = get_object_or_404(StaticDevice, user=request.user, confirmed=True)
# static_tokens = StaticToken.objects.filter(device=static).order_by('token')
# totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
# static.delete()
# totp.delete()
# for token in static_tokens:
# token.delete()
# messages.success(request, 'Successfully disabled TOTP')
# # Create event with email notification
# # Event.create(
# # user=request.user,
# # message=_('You disabled TOTP.'),
# # current=True,
# # request=request,
# # send_notification=True)
# return redirect(reverse('passbook_core:overview'))
def get(self, request, *args, **kwargs):
"""Delete all the devices for user"""
static = get_object_or_404(StaticDevice, user=request.user, confirmed=True)
static_tokens = StaticToken.objects.filter(device=static).order_by('token')
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
static.delete()
totp.delete()
for token in static_tokens:
token.delete()
messages.success(request, 'Successfully disabled OTP')
# Create event with email notification
# Event.create(
# user=request.user,
# message=_('You disabled TOTP.'),
# current=True,
# request=request,
# send_notification=True)
return redirect(reverse('passbook_otp:otp-user-settings'))
class EnableView(LoginRequiredMixin, FormView):
"""View to set up OTP"""

View File

11
passbook/pretend/apps.py Normal file
View File

@ -0,0 +1,11 @@
"""passbook pretend config"""
from django.apps import AppConfig
class PassbookPretendConfig(AppConfig):
"""passbook pretend config"""
name = 'passbook.pretend'
label = 'passbook_pretend'
verbose_name = 'passbook Pretender'
mountpoint = ''

16
passbook/pretend/urls.py Normal file
View File

@ -0,0 +1,16 @@
"""passbook pretend urls"""
from django.urls import include, path
from oauth2_provider.views import TokenView
from passbook.oauth_provider.views.oauth2 import PassbookAuthorizationView
from passbook.pretend.views.github import GitHubUserView
github_urlpatterns = [
path('login/oauth/authorize', PassbookAuthorizationView.as_view(), name='github-authorize'),
path('login/oauth/access_token', TokenView.as_view(), name='github-access-token'),
path('user', GitHubUserView.as_view(), name='github-user'),
]
urlpatterns = [
path('', include(github_urlpatterns))
]

View File

View File

@ -0,0 +1,55 @@
"""passbook pretend GitHub Views"""
from django.http import JsonResponse
from django.views import View
class GitHubUserView(View):
"""Emulate GitHub's /user API Endpoint"""
def get(self, request):
"""Emulate GitHub's /user API Endpoint"""
return JsonResponse({
"login": request.user.username,
"id": request.user.pk,
"node_id": "",
"avatar_url": "",
"gravatar_id": "",
"url": "",
"html_url": "",
"followers_url": "",
"following_url": "",
"gists_url": "",
"starred_url": "",
"subscriptions_url": "",
"organizations_url": "",
"repos_url": "",
"events_url": "",
"received_events_url": "",
"type": "User",
"site_admin": False,
"name": "%s %s" % (request.user.first_name, request.user.last_name),
"company": "",
"blog": "",
"location": "",
"email": request.user.email,
"hireable": False,
"bio": "",
"public_repos": 0,
"public_gists": 0,
"followers": 0,
"following": 0,
"created_at": request.user.date_joined,
"updated_at": request.user.date_joined,
"private_gists": 0,
"total_private_repos": 0,
"owned_private_repos": 0,
"disk_usage": 0,
"collaborators": 0,
"two_factor_authentication": True,
"plan": {
"name": "None",
"space": 0,
"private_repos": 0,
"collaborators": 0
}
})

View File

@ -1,2 +1,2 @@
"""passbook saml_idp Header"""
__version__ = '0.0.8-alpha'
__version__ = '0.0.12-alpha'

View File

@ -11,7 +11,6 @@
<header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
{% include 'partials/messages.html' %}
<form method="POST" action="{{ acs_url }}">>
{% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}">

View File

@ -8,7 +8,9 @@ from django.core.validators import URLValidator
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from passbook.core.models import Application
@ -26,6 +28,7 @@ def _generate_response(request, provider: SAMLProvider):
"""Generate a SAML response using processor_instance and return it in the proper Django
response."""
try:
provider.processor.init_deep_link(request, '')
ctx = provider.processor.generate_response()
ctx['remote'] = provider
ctx['is_login'] = True
@ -54,10 +57,11 @@ class ProviderMixin:
return self._provider
class LoginBeginView(CSRFExemptMixin, View):
class LoginBeginView(LoginRequiredMixin, View):
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login."""
@method_decorator(csrf_exempt)
def dispatch(self, request, application):
if request.method == 'POST':
source = request.POST
@ -71,12 +75,12 @@ class LoginBeginView(CSRFExemptMixin, 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
})
}))
class RedirectToSPView(View):
class RedirectToSPView(LoginRequiredMixin, View):
"""Return autosubmit form"""
def get(self, request, acs_url, saml_response, relay_state):
@ -90,16 +94,17 @@ class RedirectToSPView(View):
})
class LoginProcessView(ProviderMixin, View):
class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def dispatch(self, request, application):
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.skip_authorization and access:
if self.provider.application.skip_authorization and access:
ctx = self.provider.processor.generate_response()
# TODO: AuditLog Skipped Authz
return RedirectToSPView.as_view()(
@ -107,7 +112,19 @@ class LoginProcessView(ProviderMixin, View):
acs_url=ctx['acs_url'],
saml_response=ctx['saml_response'],
relay_state=ctx['relay_state'])
if request.method == 'POST' and request.POST.get('ACSUrl', None) and access:
try:
full_res = _generate_response(request, self.provider)
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
def post(self, request, application):
"""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:
# User accepted request
# TODO: AuditLog accepted
return RedirectToSPView.as_view()(
@ -122,7 +139,7 @@ class LoginProcessView(ProviderMixin, View):
LOGGER.debug(exc)
class LogoutView(CSRFExemptMixin, View):
class LogoutView(CSRFExemptMixin, LoginRequiredMixin, View):
"""Allows a non-SAML 2.0 URL to log out the user and
returns a standard logged-out page. (SalesForce and others use this method,
though it's technically not SAML 2.0)."""