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:
		| @ -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> | ||||||
|  | |||||||
| @ -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: | ||||||
|  | |||||||
| @ -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): | ||||||
|  | |||||||
| @ -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): | ||||||
|  | |||||||
							
								
								
									
										25
									
								
								passbook/core/migrations/0024_ssologinpolicy.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								passbook/core/migrations/0024_ssologinpolicy.py
									
									
									
									
									
										Normal 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',), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -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""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -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) | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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): | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer