diff --git a/src/pretix/control/templates/pretixcontrol/auth/login.html b/src/pretix/control/templates/pretixcontrol/auth/login.html index 64e049e67e..20eb0007c8 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login.html @@ -55,7 +55,8 @@ {% if login_providers %} {% for provider, settings in login_providers.items %} {% if settings.state %} - + {% with provider|capfirst as provider_capitalized %} {% blocktrans %}Login with {{ provider_capitalized }}{% endblocktrans %} {% endwith %} diff --git a/src/pretix/plugins/socialauth/schemas/oauth2_params.py b/src/pretix/plugins/socialauth/schemas/oauth2_params.py new file mode 100644 index 0000000000..96ac44d8dd --- /dev/null +++ b/src/pretix/plugins/socialauth/schemas/oauth2_params.py @@ -0,0 +1,11 @@ +from typing import Annotated + +from pydantic import BaseModel, StringConstraints + + +class OAuth2Params(BaseModel): + response_type: Annotated[str, StringConstraints(strip_whitespace=True)] = "code" + client_id: Annotated[str, StringConstraints(strip_whitespace=True)] + redirect_uri: Annotated[str, StringConstraints(strip_whitespace=True)] + scope: Annotated[str, StringConstraints(strip_whitespace=True)] = "profile" + state: Annotated[str, StringConstraints(strip_whitespace=True)] diff --git a/src/pretix/plugins/socialauth/urls.py b/src/pretix/plugins/socialauth/urls.py index 7f8b593e1b..2c5d590e5f 100644 --- a/src/pretix/plugins/socialauth/urls.py +++ b/src/pretix/plugins/socialauth/urls.py @@ -3,7 +3,7 @@ from . import views urlpatterns = [ - path('oauth_login//', views.oauth_login, name='social.oauth.login'), - path('oauth_return/', views.oauth_return, name='social.oauth.return'), + path('oauth_login//', views.OAuthLoginView.as_view(), name='social.oauth.login'), + path('oauth_return/', views.OAuthReturnView.as_view(), name='social.oauth.return'), path('control/global/social_auth/', views.SocialLoginView.as_view(), name='admin.global.social.auth.settings') ] diff --git a/src/pretix/plugins/socialauth/views.py b/src/pretix/plugins/socialauth/views.py index aa31053f7f..62bdddd258 100644 --- a/src/pretix/plugins/socialauth/views.py +++ b/src/pretix/plugins/socialauth/views.py @@ -1,14 +1,16 @@ import logging from enum import StrEnum -from urllib.parse import urlencode, urljoin, urlparse, urlunparse +from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.models import SocialApp from django.conf import settings from django.contrib import messages +from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect from django.urls import reverse -from django.views.generic import TemplateView +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView, View from pydantic import ValidationError from pretix.base.models import User @@ -18,43 +20,99 @@ from pretix.helpers.urls import build_absolute_uri from .schemas.login_providers import LoginProviders +from .schemas.oauth2_params import OAuth2Params logger = logging.getLogger(__name__) adapter = get_adapter() -def oauth_login(request, provider): - gs = GlobalSettingsObject() - client_id = gs.settings.get('login_providers', as_type=dict).get(provider, {}).get('client_id') - provider = adapter.get_provider(request, provider, client_id=client_id) - - base_url = provider.get_login_url(request) - query_params = { - "next": build_absolute_uri("plugins:socialauth:social.oauth.return") - } - parsed_url = urlparse(base_url) - updated_url = parsed_url._replace(query=urlencode(query_params)) - return redirect(urlunparse(updated_url)) +class OAuthLoginView(View): + def get(self, request: HttpRequest, provider: str) -> HttpResponse: + self.set_oauth2_params(request) + gs = GlobalSettingsObject() + client_id = ( + gs.settings.get("login_providers", as_type=dict) + .get(provider, {}) + .get("client_id") + ) + provider_instance = adapter.get_provider(request, provider, client_id=client_id) + + base_url = provider_instance.get_login_url(request) + query_params = { + "next": build_absolute_uri("plugins:socialauth:social.oauth.return") + } + parsed_url = urlparse(base_url) + updated_url = parsed_url._replace(query=urlencode(query_params)) + return redirect(urlunparse(updated_url)) + + @staticmethod + def set_oauth2_params(request: HttpRequest) -> None: + """ + Handle Login with SSO button from other components + This function will set 'oauth2_params' in session for oauth2_callback + """ + next_url = request.GET.get("next", "") + if not next_url: + return + + parsed = urlparse(next_url) + + # Only allow relative URLs + if parsed.netloc or parsed.scheme: + return + + params = parse_qs(parsed.query) + sanitized_params = { + k: v[0] + for k, v in params.items() + if k in OAuth2Params.model_fields.keys() + } + + try: + oauth2_params = OAuth2Params.model_validate(sanitized_params) + request.session["oauth2_params"] = oauth2_params.model_dump() + except ValidationError as e: + logger.warning("Ignore invalid OAuth2 parameters: %s.", e) + + +class OAuthReturnView(View): + def get(self, request: HttpRequest) -> HttpResponse: + try: + user = self.get_or_create_user(request) + response = process_login_and_set_cookie(request, user, False) + oauth2_params = request.session.pop("oauth2_params", {}) + if oauth2_params: + try: + oauth2_params = OAuth2Params.model_validate(oauth2_params) + query_string = urlencode(oauth2_params.model_dump()) + auth_url = reverse("control:oauth2_provider.authorize") + return redirect(f"{auth_url}?{query_string}") + except ValidationError as e: + logger.warning("Ignore invalid OAuth2 parameters: %s.", e) + + return response + except AttributeError as e: + messages.error( + request, _("Error while authorizing: no email address available.") + ) + logger.error("Error while authorizing: %s", e) + return redirect("control:auth.login") -def oauth_return(request): - try: - user, _ = User.objects.get_or_create( + @staticmethod + def get_or_create_user(request: HttpRequest) -> User: + """ + Get or create a user from social auth information. + """ + return User.objects.get_or_create( email=request.user.email, defaults={ - 'locale': getattr(request, 'LANGUAGE_CODE', settings.LANGUAGE_CODE), - 'timezone': getattr(request, 'timezone', settings.TIME_ZONE), - 'auth_backend': 'native', - 'password': '', + "locale": getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE), + "timezone": getattr(request, "timezone", settings.TIME_ZONE), + "auth_backend": "native", + "password": "", }, - ) - return process_login_and_set_cookie(request, user, False) - except AttributeError: - messages.error( - request, _('Error while authorizing: no email address available.') - ) - logger.error('Error while authorizing: user has no email address.') - return redirect('control:auth.login') + )[0] class SocialLoginView(AdministratorPermissionRequiredMixin, TemplateView):