Skip to content

Commit 6746eef

Browse files
Implement a dedicated plugin for SSO functionalities (#475)
* Add UI for generating sso keys * Check isort and flake8 * Add sign-in button of MediaWiki, Google, Github * Add back url * Refactor with sourcery * Add name url * Fix test_urls and minor refactor * Remove print * Change message log * Resolve review conversations * Refactor code * Remove blank * Change quote type * Remove str() usage
1 parent 78dd4b7 commit 6746eef

File tree

15 files changed

+350
-11
lines changed

15 files changed

+350
-11
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ dependencies = [
100100
# Access is required to the private repositories, if you don't have access, you can remove the dependencies
101101
'eventyay-paypal @ git+https://[email protected]/fossasia/eventyay-tickets-paypal.git@master',
102102
'django_celery_beat==2.7.0',
103-
'cron-descriptor==1.4.5'
103+
'cron-descriptor==1.4.5',
104+
'django-allauth[socialaccount]==65.3.0'
104105
]
105106

106107
[project.optional-dependencies]

src/pretix/control/forms/global_settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,15 @@ class UpdateSettingsForm(SettingsForm):
183183
def __init__(self, *args, **kwargs):
184184
self.obj = GlobalSettingsObject()
185185
super().__init__(*args, obj=self.obj, **kwargs)
186+
187+
188+
class SSOConfigForm(SettingsForm):
189+
redirect_url = forms.URLField(
190+
required=True,
191+
label=_("Redirect URL"),
192+
help_text=_("e.g. {sample}").format(sample="https://app-test.eventyay.com/talk/")
193+
)
194+
195+
def __init__(self, *args, **kwargs):
196+
self.obj = GlobalSettingsObject()
197+
super().__init__(*args, obj=self.obj, **kwargs)

src/pretix/control/navigation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,11 @@ def get_admin_navigation(request):
565565
'url': reverse('control:admin.global.update'),
566566
'active': (url.url_name == 'admin.global.update'),
567567
},
568+
{
569+
'label': _('Generate keys for SSO'),
570+
'url': reverse('control:admin.global.sso'),
571+
'active': (url.url_name == 'admin.global.sso'),
572+
},
568573
]
569574
},
570575
]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{% extends "pretixcontrol/admin/base.html" %}
2+
{% load i18n %}
3+
{% load bootstrap3 %}
4+
{% load rich_text %}
5+
6+
7+
{% block title %}{% trans "Generate keys for SSO" %}{% endblock %}
8+
{% block content %}
9+
<h1>{% trans "Generate keys for SSO" %}</h1>
10+
{{ global_settings.banner_message_detail|rich_text }}
11+
{% block inner %}
12+
<form role="form" action="" method="post" class="form-horizontal" enctype="multipart/form-data">
13+
{% csrf_token %}
14+
{% bootstrap_form_errors form %}
15+
{% bootstrap_form form layout='control' %}
16+
<div class="form-group submit-group">
17+
<button type="submit" class="btn btn-primary btn-save">
18+
{% trans "Submit" %}
19+
</button>
20+
</div>
21+
</form>
22+
{% endblock %}
23+
24+
{% if result.error_message %}
25+
<h2>Error:</h2>
26+
<pre>{{ result.error_message }}</pre>
27+
{% elif result.success_message %}
28+
<h2>{{ result.success_message }}</h2>
29+
<div class="row">
30+
<div class="col-md-12">
31+
<label>Client ID:</label>
32+
<input type="text" value="{{ result.client_id }}" disabled class="form-control" style="width: 300px;">
33+
</div>
34+
<div class="col-md-12">
35+
<label>Client Secret:</label>
36+
<input type="text" value="{{ result.client_secret }}" disabled class="form-control" style="width: 300px;">
37+
</div>
38+
</div>
39+
{% endif %}
40+
41+
{% if oauth_applications %}
42+
<h2>OAuth Applications</h2>
43+
<ul>
44+
{% for application in oauth_applications %}
45+
<li class="list-group-item">
46+
<h5>OAuth Application</h5>
47+
<p><strong>URL:</strong> {{ application.redirect_uris }}</p>
48+
<div class="row">
49+
<div class="col-md-12">
50+
<label>Client ID:</label>
51+
<input type="text" value="{{ application.client_id }}" disabled class="form-control" style="width: 300px;">
52+
</div>
53+
<div class="col-md-12">
54+
<label>Client Secret:</label>
55+
<input type="text" value="{{ application.client_secret }}" disabled class="form-control" style="width: 300px;">
56+
</div>
57+
</div>
58+
<br>
59+
<form action="{% url 'control:admin.global.sso.delete' application.pk %}" method="post" class="text-right">
60+
{% csrf_token %}
61+
<button type="submit" class="btn btn-danger">Delete</button>
62+
</form>
63+
</li>
64+
{% endfor %}
65+
</ul>
66+
{% endif %}
67+
68+
{% endblock %}

src/pretix/control/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@
347347
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='admin.global.settings'),
348348
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='admin.global.update'),
349349
url(r'^global/message/$', global_settings.MessageView.as_view(), name='admin.global.message'),
350+
url(r'^global/sso/$', global_settings.SSOView.as_view(), name='admin.global.sso'),
351+
url(r'^global/sso/(?P<pk>\d+)/delete/$', global_settings.DeleteOAuthApplicationView.as_view(), name='admin.global.sso.delete'),
350352
url(r'^pages/$', pages.PageList.as_view(), name="admin.pages"),
351353
url(r'^pages/add$', pages.PageCreate.as_view(), name="admin.pages.add"),
352354
url(r'^pages/(?P<id>\d+)/edit$', pages.PageUpdate.as_view(), name="admin.pages.edit"),

src/pretix/control/views/global_settings.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
1+
import logging
2+
import secrets
3+
14
from django.contrib import messages
5+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
6+
from django.db import IntegrityError
27
from django.http import JsonResponse
38
from django.shortcuts import get_object_or_404, redirect, reverse
9+
from django.urls import reverse_lazy
410
from django.utils.translation import gettext_lazy as _
511
from django.views import View
6-
from django.views.generic import FormView, TemplateView
12+
from django.views.generic import DeleteView, FormView, TemplateView
713

14+
from pretix.api.models import OAuthApplication
815
from pretix.base.models import LogEntry, OrderPayment, OrderRefund
916
from pretix.base.services.update_check import check_result_table, update_check
1017
from pretix.base.settings import GlobalSettingsObject
1118
from pretix.control.forms.global_settings import (
12-
GlobalSettingsForm, UpdateSettingsForm,
19+
GlobalSettingsForm, SSOConfigForm, UpdateSettingsForm,
1320
)
1421
from pretix.control.permissions import (
1522
AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin,
1623
)
1724

25+
logger = logging.getLogger(__name__)
26+
1827

1928
class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView):
2029
template_name = 'pretixcontrol/global_settings.html'
@@ -26,13 +35,77 @@ def form_valid(self, form):
2635
return super().form_valid(form)
2736

2837
def form_invalid(self, form):
29-
messages.error(self.request, _('Your changes have not been saved, see below for errors.'))
38+
messages.error(
39+
self.request, _('Your changes have not been saved, see below for errors.')
40+
)
3041
return super().form_invalid(form)
3142

3243
def get_success_url(self):
3344
return reverse('control:admin.global.settings')
3445

3546

47+
class SSOView(AdministratorPermissionRequiredMixin, FormView):
48+
template_name = 'pretixcontrol/global_sso.html'
49+
form_class = SSOConfigForm
50+
51+
def get_context_data(self, **kwargs):
52+
context = super().get_context_data(**kwargs)
53+
oauth_applications = OAuthApplication.objects.all()
54+
context['oauth_applications'] = oauth_applications
55+
return context
56+
57+
def form_valid(self, form):
58+
url = form.cleaned_data['redirect_url']
59+
60+
try:
61+
result = self.create_oauth_application(url)
62+
except (IntegrityError, ValidationError, ObjectDoesNotExist) as e:
63+
error_type = type(e).__name__
64+
logger.error('Error while creating OAuth2 application: %s - %s', error_type, e)
65+
return self.render_to_response({'error_message': f'{error_type}: {e}'})
66+
67+
return self.render_to_response(self.get_context_data(form=form, result=result))
68+
69+
def form_invalid(self, form):
70+
messages.error(
71+
self.request, _('Your changes have not been saved, see below for errors.')
72+
)
73+
return super().form_invalid(form)
74+
75+
def get_success_url(self):
76+
return reverse('control:admin.global.sso')
77+
78+
def create_oauth_application(self, redirect_uris):
79+
application, created = OAuthApplication.objects.get_or_create(
80+
redirect_uris=redirect_uris,
81+
defaults={
82+
'name': "Talk SSO Client",
83+
'client_type': OAuthApplication.CLIENT_CONFIDENTIAL,
84+
'authorization_grant_type': OAuthApplication.GRANT_AUTHORIZATION_CODE,
85+
'user': None,
86+
'client_id': secrets.token_urlsafe(32),
87+
'client_secret': secrets.token_urlsafe(64),
88+
'hash_client_secret': False,
89+
'skip_authorization': True,
90+
},
91+
)
92+
93+
return {
94+
"success_message": (
95+
"Successfully created OAuth2 Application"
96+
if created
97+
else "OAuth2 Application with this redirect URI already exists"
98+
),
99+
"client_id": application.client_id,
100+
"client_secret": application.client_secret,
101+
}
102+
103+
104+
class DeleteOAuthApplicationView(AdministratorPermissionRequiredMixin, DeleteView):
105+
model = OAuthApplication
106+
success_url = reverse_lazy('control:admin.global.sso')
107+
108+
36109
class UpdateCheckView(StaffMemberRequiredMixin, FormView):
37110
template_name = 'pretixcontrol/global_update.html'
38111
form_class = UpdateSettingsForm
@@ -49,7 +122,9 @@ def form_valid(self, form):
49122
return super().form_valid(form)
50123

51124
def form_invalid(self, form):
52-
messages.error(self.request, _('Your changes have not been saved, see below for errors.'))
125+
messages.error(
126+
self.request, _('Your changes have not been saved, see below for errors.')
127+
)
53128
return super().form_invalid(form)
54129

55130
def get_context_data(self, **kwargs):

src/pretix/plugins/socialauth/__init__.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from allauth.core.exceptions import ImmediateHttpResponse
2+
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
3+
from django.http import HttpResponseRedirect
4+
from django.urls import reverse
5+
6+
7+
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
8+
def on_authentication_error(
9+
self, request, provider, error=None, exception=None, extra_context=None
10+
):
11+
raise ImmediateHttpResponse(HttpResponseRedirect(reverse("control:index")))
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.apps import AppConfig
2+
from django.utils.translation import gettext_lazy as _
3+
4+
from pretix import __version__ as version
5+
6+
7+
class SocialAuthApp(AppConfig):
8+
name = 'pretix.plugins.socialauth'
9+
verbose_name = _("SocialAuth")
10+
11+
class PretixPluginMeta:
12+
name = _("SocialAuth")
13+
author = _("the pretix team")
14+
version = version
15+
featured = True
16+
description = _("This plugin allows you to login via social networks")
17+
18+
19+
default_app_config = 'pretix.plugins.socialauth.PaypalApp'
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from urllib.parse import urlencode, urlparse, urlunparse
2+
3+
from allauth.socialaccount.adapter import get_adapter
4+
5+
from pretix.base.auth import BaseAuthBackend
6+
from pretix.helpers.urls import build_absolute_uri
7+
8+
adapter = get_adapter()
9+
10+
11+
class MediaWikiBackend(BaseAuthBackend):
12+
identifier = 'mediawiki'
13+
14+
@property
15+
def verbose_name(self):
16+
return "Login with MediaWiki"
17+
18+
def authentication_url(self, request):
19+
base_url = adapter.get_provider(request, 'mediawiki').get_login_url(request)
20+
query_params = {
21+
"next": build_absolute_uri("plugins:socialauth:social.oauth.return")
22+
}
23+
24+
parsed_url = urlparse(base_url)
25+
updated_url = parsed_url._replace(query=urlencode(query_params))
26+
return urlunparse(updated_url)
27+
28+
29+
class GoogleBackend(BaseAuthBackend):
30+
identifier = 'google'
31+
32+
@property
33+
def verbose_name(self):
34+
return "Login with Google"
35+
36+
def authentication_url(self, request):
37+
base_url = adapter.get_provider(request, 'google').get_login_url(request)
38+
query_params = {
39+
"next": build_absolute_uri("plugins:socialauth:social.oauth.return")
40+
}
41+
42+
parsed_url = urlparse(base_url)
43+
updated_url = parsed_url._replace(query=urlencode(query_params))
44+
return urlunparse(updated_url)
45+
46+
47+
class GithubBackend(BaseAuthBackend):
48+
identifier = 'github'
49+
50+
@property
51+
def verbose_name(self):
52+
return "Login with Github"
53+
54+
def authentication_url(self, request):
55+
base_url = adapter.get_provider(request, 'github').get_login_url(request)
56+
query_params = {
57+
"next": build_absolute_uri("plugins:socialauth:social.oauth.return")
58+
}
59+
60+
parsed_url = urlparse(base_url)
61+
updated_url = parsed_url._replace(query=urlencode(query_params))
62+
return urlunparse(updated_url)

0 commit comments

Comments
 (0)