Skip to content

Commit

Permalink
Implement oauth2 authentication for SSO login from talk component (#310)
Browse files Browse the repository at this point in the history
* implement sso login feature

* format code
  • Loading branch information
odkhang authored Sep 17, 2024
1 parent f473ee4 commit 1f6f174
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 7 deletions.
44 changes: 44 additions & 0 deletions doc/development/sso_with_talk.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
=====================================================
Single Sign-On (SSO) Implementation with JWT and OAuth2
=====================================================

Overview
========
This document provides a step-by-step guide to implementing Single Sign-On (SSO) between the `ticket` and `talk` applications using JWT tokens and Django OAuth Toolkit. The configuration involves customizing the OAuth2 `AuthorizationView`, handling login processes to set JWT cookies, and ensuring a seamless SSO experience across applications.

Pre-requisites
==============
- Django >= 3.11
- Django OAuth Toolkit
- PyJWT
- HTTPS configuration for secure cookie handling

Step 1: Customize Authorization View
====================================
To bypass the OAuth2 consent screen for trusted applications and automatically set a JWT cookie upon successful authorization, we override the `AuthorizationView`.

src/pretix/control/views/auth.py


Step 2: Update URL Configuration
================================
Update the URL configuration to use the `CustomAuthorizationView`:

src/pretix/control/urls.py

Step 3: Modify Login View to Set JWT Cookie
===========================================
In the `ticket` application, modify the `login` view to set a JWT cookie after successful authentication.

Update `login` view in `src/pretix/control/views/auth.py`:


Step 4: Security and Configuration
==================================
Ensure that all configurations are secure and appropriate for your environment:

- Use `SECRET_KEY` in `settings.py` for JWT signing.

Conclusion
==========
By following these steps, you can implement SSO using JWT and Django OAuth Toolkit between the `ticket` and `talk` applications. This configuration allows for seamless user authentication while maintaining a secure environment. Ensure to review and follow security best practices to protect user data and session integrity.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ dependencies = [
'jsonschema',
'django-hijack==2.*',
'openpyxl==3.1.*',
'django-oauth-toolkit==2.3.*',
'django-oauth-toolkit==2.4.*',
'oauthlib==3.2.*',
'django-phonenumber-field==7.1.*',
'phonenumberslite==8.13.*',
Expand All @@ -96,7 +96,9 @@ dependencies = [
'importlib_metadata==7.*',
'qrcode==7.4.*',
'pretix-pages @ git+https://github.com/fossasia/eventyay-ticket-pages.git@master',
'pretix-venueless @ git+https://github.com/fossasia/eventyay-ticket-video.git@master'
'pretix-venueless @ git+https://github.com/fossasia/eventyay-ticket-video.git@master',
'django-sso==3.0.2',
'PyJWT~=2.8.0',
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Generated by Django 4.2.15 on 2024-08-30 08:15

from django.db import migrations, models
import oauth2_provider.generators
import oauth2_provider.models


class Migration(migrations.Migration):

dependencies = [
('pretixapi', '0003_oauthapplication_post_logout_redirect_uris_and_more'),
]

operations = [
migrations.AddField(
model_name='oauthapplication',
name='allowed_origins',
field=models.TextField(default=''),
),
migrations.AddField(
model_name='oauthapplication',
name='hash_client_secret',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='apicall',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='oauthaccesstoken',
name='token',
field=models.CharField(db_index=True, max_length=255, unique=True),
),
migrations.AlterField(
model_name='oauthapplication',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255),
),
migrations.AlterField(
model_name='webhook',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='webhookcall',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='webhookeventlistener',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
]
35 changes: 35 additions & 0 deletions src/pretix/base/management/commands/create_oauth_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import secrets

from django.core.management.base import BaseCommand

from pretix.api.models import OAuthApplication


class Command(BaseCommand):
help = "Create an OAuth2 Application for the Talk SSO Client"

def handle(self, *args, **options):
redirect_uris = input('Enter the redirect URI: ') # Get redirect URI from user input

# Check if the application already exists based on redirect_uri
if OAuthApplication.objects.filter(redirect_uris=redirect_uris).exists():
self.stdout.write(self.style.WARNING('OAuth2 Application with this redirect URI already exists.'))
return

# Create the OAuth2 Application
application = OAuthApplication(
name="Talk SSO Client",
client_type=OAuthApplication.CLIENT_CONFIDENTIAL,
authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE,
redirect_uris=redirect_uris,
user=None, # Set a specific user if you want this to be user-specific, else keep it None
client_id=secrets.token_urlsafe(32),
client_secret=secrets.token_urlsafe(64),
hash_client_secret=False,
skip_authorization=True,
)
application.save()

self.stdout.write(self.style.SUCCESS('Successfully created OAuth2 Application'))
self.stdout.write(f'Client ID: {application.client_id}')
self.stdout.write(f'Client Secret: {application.client_secret}')
9 changes: 8 additions & 1 deletion src/pretix/control/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class PermissionMiddleware:
"auth.forgot.recover",
"auth.invite",
"user.settings.notifications.off",
'oauth2_provider',
)

EXCEPTIONS_2FA = (
Expand Down Expand Up @@ -74,13 +75,19 @@ def __call__(self, request):
url = resolve(request.path_info)
url_name = url.url_name

if not request.path.startswith(get_script_prefix() + 'control'):
if (not request.path.startswith(get_script_prefix() + 'control')
and not request.path.startswith(get_script_prefix() + 'common')):
# This middleware should only touch the /control subpath
return self.get_response(request)

if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect(urljoin(settings.SITE_URL, request.get_full_path()))

# Add this condition to bypass middleware for 'oauth/' and its sub-URLs
if request.path.startswith(get_script_prefix() + 'control/oauth2/'):
return self.get_response(request)

if url_name in self.EXCEPTIONS:
return self.get_response(request)
if not request.user.is_authenticated:
Expand Down
9 changes: 9 additions & 0 deletions src/pretix/control/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@
orderimport, orders, organizer, pdf, search, shredder, subevents,
typeahead, user, users, vouchers, waitinglist,
)
from pretix.control.views.auth import CustomAuthorizationView

oauth2_patterns = ([
url(r'^user_info/', users.user_info, name='user_info'),
# other custom paths can be added here
], 'oauth2_provider.subdomain')

urlpatterns = [
url(r'^oauth2/authorize/', CustomAuthorizationView.as_view(), name='oauth2_provider.authorize'),
url(r'^oauth2/', include(oauth2_patterns)),
url(r'^oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')),
url(r'^logout$', auth.logout, name='auth.logout'),
url(r'^login$', auth.login, name='auth.login'),
url(r'^login/2fa$', auth.Login2FAView.as_view(), name='auth.login.2fa'),
Expand Down
67 changes: 63 additions & 4 deletions src/pretix/control/views/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import logging
import time
from urllib.parse import quote

import webauthn

from django.conf import settings
from django.contrib import messages
from django.contrib.auth import (
Expand All @@ -19,14 +19,18 @@
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django_otp import match_token
from oauth2_provider.views import AuthorizationView

from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import (
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
)
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.base.services.mail import SendMailException
from pretix.helpers.cookies import set_cookie_without_samesite
from pretix.helpers.jwt_generate import generate_sso_token
from pretix.helpers.webauthn import generate_challenge
from pretix.multidomain.middlewares import get_cookie_domain

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,6 +59,35 @@ def process_login(request, user, keep_logged_in):
return redirect(reverse('control:index'))


def process_login_and_set_cookie(request, user, keep_logged_in):
"""
Process user login and set a JWT cookie.
"""
# Perform login logic (e.g., set session, authenticate user)
response = process_login(request, user, keep_logged_in)

# Generate JWT token
response = set_cookie_after_logged_in(request, response)
return response


def set_cookie_after_logged_in(request, response):
if response.status_code == 302 and request.user.is_authenticated:
# Set JWT as a cookie in the response
token = generate_sso_token(request.user)
set_cookie_without_samesite(
request, response,
"sso_token",
token,
max_age=settings.CSRF_COOKIE_AGE,
domain=get_cookie_domain(request),
path=settings.CSRF_COOKIE_PATH,
secure=request.scheme == 'https',
httponly=settings.CSRF_COOKIE_HTTPONLY
)
return response


def login(request):
"""
Render and process a most basic login form. Takes an URL as GET
Expand All @@ -66,7 +99,7 @@ def login(request):
for b in backends:
u = b.request_authenticate(request)
if u and u.auth_backend == b.identifier:
return process_login(request, u, False)
return process_login_and_set_cookie(request, u, False)
b.url = b.authentication_url(request)

backend = backenddict.get(request.GET.get('backend', 'native'), backends[0])
Expand All @@ -80,7 +113,7 @@ def login(request):
if request.method == 'POST':
form = LoginForm(backend=backend, data=request.POST, request=request)
if form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier:
return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
return process_login_and_set_cookie(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
else:
form = LoginForm(backend=backend, request=request)
ctx['form'] = form
Expand Down Expand Up @@ -129,7 +162,9 @@ def register(request):
request.session['pretix_auth_long_session'] = (
settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False)
)
return redirect('control:index')
response = redirect('control:index')
set_cookie_after_logged_in(request, response)
return response
else:
form = RegistrationForm()
ctx['form'] = form
Expand Down Expand Up @@ -439,3 +474,27 @@ def get_context_data(self, **kwargs):

def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)


class CustomAuthorizationView(AuthorizationView):
"""
Override the AuthorizationView to set a JWT cookie after successful login.
"""
def get(self, request, *args, **kwargs):
# Call the parent method to handle the standard authorization flow
response = super().get(request, *args, **kwargs)
# Check if the response is a redirect, which indicates a successful login
if response.status_code == 302 and request.user.is_authenticated:
# Set JWT as a cookie in the response
token = generate_sso_token(request.user)
set_cookie_without_samesite(
request, response,
"sso_token",
token,
max_age=settings.CSRF_COOKIE_AGE,
domain=get_cookie_domain(request),
path=settings.CSRF_COOKIE_PATH,
secure=request.scheme == 'https',
httponly=settings.CSRF_COOKIE_HTTPONLY
)
return response
22 changes: 22 additions & 0 deletions src/pretix/control/views/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView, TemplateView
from hijack.helpers import login_user, release_hijack
from oauth2_provider.decorators import protected_resource

from pretix.base.auth import get_auth_backends
from pretix.base.models import User
Expand Down Expand Up @@ -198,3 +201,22 @@ def get_success_url(self):
def form_valid(self, form):
messages.success(self.request, _('The new user has been created.'))
return super().form_valid(form)


@require_http_methods(["GET"])
@protected_resource() # Ensures the endpoint is protected by OAuth2
def user_info(request):
"""
Return user information for the authenticated user.
"""
user = request.resource_owner
user_data = {
'email': user.email,
'name': user.get_full_name(),
'is_active': user.is_active,
'is_staff': user.is_staff,
'locale': user.locale,
'timezone': user.timezone,
# Add more user fields as necessary
}
return JsonResponse(user_data)
26 changes: 26 additions & 0 deletions src/pretix/helpers/jwt_generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from datetime import datetime, timedelta

import jwt
from django.conf import settings


def generate_sso_token(user):
"""
Generate a JWT token for the user.
@param user: User obj
@return: jwt token
"""
if user and user.is_authenticated:
jwt_payload = {
'email': user.email,
'name': user.get_full_name(),
'is_active': user.is_active,
'is_staff': user.is_staff,
'locale': user.locale,
'timezone': user.timezone,
'exp': datetime.utcnow() + timedelta(hours=1), # Token expiration
'iat': datetime.utcnow(),
}
jwt_token = jwt.encode(jwt_payload, settings.SECRET_KEY, algorithm='HS256')
return jwt_token
return None
Loading

0 comments on commit 1f6f174

Please sign in to comment.