Merge branch '27-rewrite-oauth-client-as-factor' into 'master'

Resolve "Rewrite OAuth Client as Factor"

Closes #27

See merge request BeryJu.org/passbook!14
This commit is contained in:
Jens Langhammer 2019-04-29 21:25:04 +00:00
commit 2c4fc56b49
9 changed files with 85 additions and 21 deletions

View File

@ -36,7 +36,7 @@
<tr> <tr>
<td>{{ source.name }}</td> <td>{{ source.name }}</td>
<td>{{ source|fieldtype }}</td> <td>{{ source|fieldtype }}</td>
<td>{{ source.additional_info }}</td> <td>{{ source.additional_info|safe }}</td>
<td> <td>
<a class="btn btn-default btn-sm" <a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:source-update' pk=source.uuid %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> href="{% url 'passbook_admin:source-update' pk=source.uuid %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>

View File

@ -29,6 +29,7 @@ class AuthenticationView(UserPassesTestMixin, View):
SESSION_PENDING_FACTORS = 'passbook_pending_factors' SESSION_PENDING_FACTORS = 'passbook_pending_factors'
SESSION_PENDING_USER = 'passbook_pending_user' SESSION_PENDING_USER = 'passbook_pending_user'
SESSION_USER_BACKEND = 'passbook_user_backend' SESSION_USER_BACKEND = 'passbook_user_backend'
SESSION_IS_SSO_LOGIN = 'passbook_sso_login'
pending_user = None pending_user = None
pending_factors = [] pending_factors = []
@ -79,6 +80,10 @@ class AuthenticationView(UserPassesTestMixin, View):
if AuthenticationView.SESSION_FACTOR not in request.session: if AuthenticationView.SESSION_FACTOR not in request.session:
# Case when no factors apply to user, return error denied # Case when no factors apply to user, return error denied
if not self.pending_factors: if not self.pending_factors:
# Case when user logged in from SSO provider and no more factors apply
if AuthenticationView.SESSION_IS_SSO_LOGIN in request.session:
LOGGER.debug("User authenticated with SSO, logging in...")
return self._user_passed()
return self.user_invalid() return self.user_invalid()
factor_uuid, factor_class = self.pending_factors[0] factor_uuid, factor_class = self.pending_factors[0]
else: else:

View File

@ -27,7 +27,8 @@ class PasswordFactorForm(forms.ModelForm):
'order': forms.NumberInput(), 'order': forms.NumberInput(),
'policies': FilteredSelectMultiple(_('policies'), False), 'policies': FilteredSelectMultiple(_('policies'), False),
'backends': FilteredSelectMultiple(_('backends'), False, 'backends': FilteredSelectMultiple(_('backends'), False,
choices=get_authentication_backends()) choices=get_authentication_backends()),
'password_policies': FilteredSelectMultiple(_('password policies'), False),
} }
class DummyFactorForm(forms.ModelForm): class DummyFactorForm(forms.ModelForm):

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from passbook.core.models import (DebugPolicy, FieldMatcherPolicy, from passbook.core.models import (DebugPolicy, FieldMatcherPolicy,
GroupMembershipPolicy, PasswordPolicy, GroupMembershipPolicy, PasswordPolicy,
WebhookPolicy) SSOLoginPolicy, WebhookPolicy)
GENERAL_FIELDS = ['name', 'action', 'negate', 'order', 'timeout'] GENERAL_FIELDS = ['name', 'action', 'negate', 'order', 'timeout']
@ -63,6 +63,19 @@ class GroupMembershipPolicyForm(forms.ModelForm):
fields = GENERAL_FIELDS + ['group', ] fields = GENERAL_FIELDS + ['group', ]
widgets = { widgets = {
'name': forms.TextInput(), 'name': forms.TextInput(),
'order': forms.NumberInput(),
}
class SSOLoginPolicyForm(forms.ModelForm):
"""Edit SSOLoginPolicy instances"""
class Meta:
model = SSOLoginPolicy
fields = GENERAL_FIELDS
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
} }
class PasswordPolicyForm(forms.ModelForm): class PasswordPolicyForm(forms.ModelForm):

View File

@ -0,0 +1,25 @@
# Generated by Django 2.2 on 2019-04-29 21:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0023_remove_user_applications'),
]
operations = [
migrations.CreateModel(
name='SSOLoginPolicy',
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')),
],
options={
'verbose_name': 'SSO Login Policy',
'verbose_name_plural': 'SSO Login Policies',
},
bases=('passbook_core.policy',),
),
]

View File

@ -165,9 +165,10 @@ class Source(PolicyModel):
name = models.TextField() name = models.TextField()
slug = models.SlugField() slug = models.SlugField()
form = '' # ModelForm-based class ued to create/edit instance
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
form = '' # ModelForm-based class ued to create/edit instance
objects = InheritanceManager() objects = InheritanceManager()
@property @property
@ -409,6 +410,21 @@ class GroupMembershipPolicy(Policy):
verbose_name = _('Group Membership Policy') verbose_name = _('Group Membership Policy')
verbose_name_plural = _('Group Membership Policies') verbose_name_plural = _('Group Membership Policies')
class SSOLoginPolicy(Policy):
"""Policy that applies to users that have authenticated themselves through SSO"""
form = 'passbook.core.forms.policies.SSOLoginPolicyForm'
def passes(self, user):
"""Check if user instance passes this policy"""
from passbook.core.auth.view import AuthenticationView
return user.session.get(AuthenticationView.SESSION_IS_SSO_LOGIN, False), ""
class Meta:
verbose_name = _('SSO Login Policy')
verbose_name_plural = _('SSO Login Policies')
class Invitation(UUIDModel): class Invitation(UUIDModel):
"""Single-use invitation link""" """Single-use invitation link"""

View File

@ -74,6 +74,7 @@ class PolicyEngine:
cached_policies = [] cached_policies = []
kwargs = { kwargs = {
'__password__': getattr(self.__user, '__password__', None), '__password__': getattr(self.__user, '__password__', None),
'session': dict(getattr(self.__request, 'session', {}).items()),
} }
if self.__request: if self.__request:
kwargs['remote_ip'], _ = get_client_ip(self.__request) kwargs['remote_ip'], _ = get_client_ip(self.__request)

View File

@ -29,14 +29,13 @@ class OAuthSource(Source):
def get_login_button(self): def get_login_button(self):
url = reverse_lazy('passbook_oauth_client:oauth-client-login', url = reverse_lazy('passbook_oauth_client:oauth-client-login',
kwargs={'source_slug': self.slug}) kwargs={'source_slug': self.slug})
# if self.provider_type == 'github':
# return url, 'github-logo', _('GitHub')
return url, self.provider_type, self.name return url, self.provider_type, self.name
@property @property
def additional_info(self): def additional_info(self):
return "Callback URL: '%s'" % reverse_lazy('passbook_oauth_client:oauth-client-callback', return "Callback URL: <pre>%s</pre>" % \
kwargs={'source_slug': self.slug}) reverse_lazy('passbook_oauth_client:oauth-client-callback',
kwargs={'source_slug': self.slug})
def has_user_settings(self): def has_user_settings(self):
"""Entrypoint to integrate with User settings. Can either return False if no """Entrypoint to integrate with User settings. Can either return False if no

View File

@ -4,7 +4,7 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -12,6 +12,7 @@ from django.urls import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import RedirectView, View from django.views.generic import RedirectView, View
from passbook.core.auth.view import AuthenticationView, _redirect_with_qs
from passbook.lib.utils.reflection import app from passbook.lib.utils.reflection import app
from passbook.oauth_client.clients import get_client from passbook.oauth_client.clients import get_client
from passbook.oauth_client.models import OAuthSource, UserOAuthSourceConnection from passbook.oauth_client.models import OAuthSource, UserOAuthSourceConnection
@ -128,11 +129,6 @@ class OAuthCallback(OAuthClientMixin, View):
"Return url to redirect on login failure." "Return url to redirect on login failure."
return settings.LOGIN_URL return settings.LOGIN_URL
# pylint: disable=unused-argument
def get_login_redirect(self, source, user, access, new=False):
"Return url to redirect authenticated users."
return 'passbook_core:overview'
def get_or_create_user(self, source, access, info): def get_or_create_user(self, source, access, info):
"Create a shell auth.User." "Create a shell auth.User."
raise NotImplementedError() raise NotImplementedError()
@ -149,14 +145,22 @@ class OAuthCallback(OAuthClientMixin, View):
except KeyError: except KeyError:
return None return None
def handle_login(self, user, source, access):
"""Prepare AuthenticationView, redirect users to remaining Factors"""
user = authenticate(source=access.source,
identifier=access.identifier, request=self.request)
self.request.session[AuthenticationView.SESSION_PENDING_USER] = user.pk
self.request.session[AuthenticationView.SESSION_USER_BACKEND] = user.backend
self.request.session[AuthenticationView.SESSION_IS_SSO_LOGIN] = True
return _redirect_with_qs('passbook_core:auth-process', self.request.GET)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def handle_existing_user(self, source, user, access, info): def handle_existing_user(self, source, user, access, info):
"Login user and redirect." "Login user and redirect."
login(self.request, user)
messages.success(self.request, _("Successfully authenticated with %(source)s!" % { messages.success(self.request, _("Successfully authenticated with %(source)s!" % {
'source': self.source.name 'source': self.source.name
})) }))
return redirect(self.get_login_redirect(source, user, access)) return self.handle_login(user, source, access)
def handle_login_failure(self, source, reason): def handle_login_failure(self, source, reason):
"Message user and redirect on error." "Message user and redirect on error."
@ -176,12 +180,9 @@ class OAuthCallback(OAuthClientMixin, View):
access.user = user access.user = user
access.save() access.save()
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
if not was_authenticated:
user = authenticate(source=access.source,
identifier=access.identifier, request=self.request)
login(self.request, user)
if app('passbook_audit'): if app('passbook_audit'):
pass pass
# TODO: Create audit entry
# from passbook.audit.models import something # from passbook.audit.models import something
# something.event(user=user,) # something.event(user=user,)
# Event.create( # Event.create(
@ -197,10 +198,13 @@ class OAuthCallback(OAuthClientMixin, View):
return redirect(reverse('passbook_oauth_client:oauth-client-user', kwargs={ return redirect(reverse('passbook_oauth_client:oauth-client-user', kwargs={
'source_slug': self.source.slug 'source_slug': self.source.slug
})) }))
# User was not authenticated, new user has been created
user = authenticate(source=access.source,
identifier=access.identifier, request=self.request)
messages.success(self.request, _("Successfully authenticated with %(source)s!" % { messages.success(self.request, _("Successfully authenticated with %(source)s!" % {
'source': self.source.name 'source': self.source.name
})) }))
return redirect(self.get_login_redirect(source, user, access, True)) return self.handle_login(user, source, access)
class DisconnectView(LoginRequiredMixin, View): class DisconnectView(LoginRequiredMixin, View):