# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-2021 rami.io GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # import base64 import hashlib import time from binascii import unhexlify from datetime import timedelta from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from Crypto.PublicKey import RSA from django.db import transaction from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import redirect, render from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator from django.utils.timezone import now from django.views import View from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from pretix.base.customersso.oidc import ( _get_or_create_server_keypair, customer_claims, generate_id_token, ) from pretix.base.models.customers import ( CustomerSSOAccessToken, CustomerSSOClient, CustomerSSOGrant, ) from pretix.helpers.http import redirect_to_url from pretix.multidomain.middlewares import CsrfViewMiddleware from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.forms.customer import AuthenticationForm from pretix.presale.utils import customer_login, get_customer_auth_time """ We implement the OpenID Connect spec as per https://openid.net/specs/openid-connect-core-1_0.html Based on the OAuth spec as per https://www.rfc-editor.org/rfc/rfc6749 We implement all three flows (authorization code, implicit, hybrid), as well as some typical standard claims. We currently do not implement the following optional parts of the spec: - 4. Initiating Login from a Third Party - 5.5. Requesting Claims using the "claims" Request Parameter - 5.6.2. Aggregated and Distributed Claims - 6. Passing Request Parameters as JWTs - 8.1. Pairwise Identifier Algorithm - 9. Client Authentication (except for client_secret_basic, client_secret_post) - 10.2. Encryption - 11. Offline Access - 12. Using Refresh Tokens We also implement the Discovery extension (without issuer discovery) as per https://openid.net/specs/openid-connect-discovery-1_0.html The implementation passed the certification tests against the following profiles, but we did not acquire formal certification: - Basic OP - Implicit OP - Hybrid OP - Config OP """ RESPONSE_TYPES_SUPPORTED = ("code", "id_token token", "id_token", "code id_token", "code id_token token", "code token") class AuthorizeView(View): # We need to be exempt from CSRF because the spec mandates that relying parties can send requests as POST. # This is not a risk when we show a login form, because CSRF is pointless for a login form, if the attacker has # the password, they don't need to resort to CSRF. We still to a minimal validation below. # It would be a problem for a consent form, but we currently never show a consent form because it is not required # for our intended use case where all relying parties are at least somewhat trusted. @method_decorator(csrf_exempt) @method_decorator(never_cache) @method_decorator(sensitive_post_parameters()) def dispatch(self, request, *args, **kwargs): if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: raise Http404('Feature not enabled') return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): return self._process_auth_request(request, request.GET) def post(self, request, *args, **kwargs): try: CsrfViewMiddleware(lambda: None)._check_token(request) except: # External request, we prefer GET and will redirect to prevent confusion with our login form return redirect_to_url(request.path + '?' + request.POST.urlencode()) return self._process_auth_request(request, request.GET) def _final_error(self, error, error_description): return HttpResponse( f'Error: {error_description} ({error})', status=400, ) def _construct_redirect_uri(self, redirect_uri, response_mode, params): ru = urlparse(redirect_uri) qs = parse_qs(ru.query) fm = parse_qs(ru.fragment) if response_mode == 'query': qs.update(params) elif response_mode == 'fragment': fm.update(params) query = urlencode(qs, doseq=True) fragment = urlencode(fm, doseq=True) return urlunparse((ru.scheme, ru.netloc, ru.path, ru.params, query, fragment)) def _redirect_error(self, error, error_description, redirect_uri, response_mode, state): qs = {'error': error, 'error_description': error_description} if state: qs['state'] = state return redirect( self._construct_redirect_uri(redirect_uri, response_mode, qs) ) def _require_login(self, request, client, scope, redirect_uri, response_type, response_mode, state, nonce): form = AuthenticationForm(data=request.POST if "login-email" in request.POST else None, request=request, prefix="login") if "login-email" in request.POST and form.is_valid(): customer_login(request, form.get_customer()) return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, form.get_customer()) else: return render(request, 'pretixpresale/organizers/customer_login.html', { 'providers': [], 'form': form, }) def _success(self, client, scope, redirect_uri, response_type, response_mode, state, nonce, customer): response_type = response_type.split(' ') qs = {} id_token_kwargs = {} if 'code' in response_type: grant = client.grants.create( customer=customer, scope=' '.join(scope), redirect_uri=redirect_uri, code=get_random_string(64), expires=now() + timedelta(minutes=10), auth_time=get_customer_auth_time(self.request), nonce=nonce, ) qs['code'] = grant.code id_token_kwargs['with_code'] = grant.code expires = now() + timedelta(hours=24) if 'token' in response_type: token = client.access_tokens.create( customer=customer, token=get_random_string(128), expires=expires, scope=' '.join(scope), ) qs['access_token'] = token.token qs['token_type'] = 'Bearer' qs['expires_in'] = int((token.expires - now()).total_seconds()) id_token_kwargs['with_access_token'] = token.token if 'id_token' in response_type: qs['id_token'] = generate_id_token( customer, client, get_customer_auth_time(self.request), nonce, ' '.join(scope), expires, scope_claims='token' not in response_type and 'code' not in response_type, **id_token_kwargs, ) if state: qs['state'] = state r = redirect(self._construct_redirect_uri(redirect_uri, response_mode, qs)) r['Cache-Control'] = 'no-store' r['Pragma'] = 'no-cache' return r def _process_auth_request(self, request, request_data): response_mode = request_data.get("response_mode") client_id = request_data.get("client_id") state = request_data.get("state") nonce = request_data.get("nonce") max_age = request_data.get("max_age") prompt = request_data.get("prompt") response_type = request_data.get("response_type") scope = request_data.get("scope", "").split(" ") if not client_id: return self._final_error("invalid_request", "client_id missing") try: client = self.request.organizer.sso_clients.get(is_active=True, client_id=client_id) except CustomerSSOClient.DoesNotExist: return self._final_error("unauthorized_client", "invalid client_id") redirect_uri = request_data.get("redirect_uri") if not redirect_uri or not client.allow_redirect_uri(redirect_uri): return self._final_error("invalid_request_uri", "invalid redirect_uri") if response_type not in RESPONSE_TYPES_SUPPORTED: return self._final_error("unsupported_response_type", "response_type unsupported") if response_type != "code" and response_mode == "query": return self._final_error("invalid_request", "response_mode query must not be used with implicit or hybrid flow") elif not response_mode: response_mode = "query" if response_type == "code" else "fragment" elif response_mode not in ("query", "fragment"): return self._final_error("invalid_request", "invalid response_mode") if "request" in request_data: return self._redirect_error("request_not_supported", "request_not_supported", redirect_uri, response_mode, state) if response_type not in ("code", "code token") and not nonce: return self._redirect_error("invalid_request", "nonce is required in implicit or hybrid flow", redirect_uri, response_mode, state) if "openid" not in scope: return self._redirect_error("invalid_scope", "scope 'openid' must be requested", redirect_uri, response_mode, state) if "id_token_hint" in request_data: self._redirect_error("invalid_request", "id_token_hint currently not supported by this server", redirect_uri, response_mode, state) has_valid_session = bool(request.customer) if has_valid_session and max_age: try: has_valid_session = int(time.time() - get_customer_auth_time(request)) < int(max_age) except ValueError: self._redirect_error("invalid_request", "invalid max_age value", redirect_uri, response_mode, state) if not has_valid_session and prompt and prompt == "none": return self._redirect_error("interaction_required", "user is not logged in but no prompt is allowed", redirect_uri, response_mode, state) elif prompt in ("select_account", "login"): has_valid_session = False if has_valid_session: return self._success(client, scope, redirect_uri, response_type, response_mode, state, nonce, request.customer) else: return self._require_login(request, client, scope, redirect_uri, response_type, response_mode, state, nonce) class TokenView(View): @method_decorator(csrf_exempt) @method_decorator(never_cache) @method_decorator(sensitive_post_parameters()) def dispatch(self, request, *args, **kwargs): if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: raise Http404('Feature not enabled') return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): auth_header = request.headers.get('Authorization') if auth_header: encoded_credentials = auth_header.split(' ')[1] # Removes "Basic " to isolate credentials decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8").split(':') client_id = decoded_credentials[0] client_secret = decoded_credentials[1] try: self.client = request.organizer.sso_clients.get(client_id=client_id, is_active=True) except CustomerSSOClient.DoesNotExist: return JsonResponse({ "error": "invalid_client", "error_description": "Unknown or inactive client_id" }, status=401, headers={ 'WWW-Authenticate': 'error="invalid_client"&error_description="Unknown or inactive client_id"' }) if not self.client.check_client_secret(client_secret): return JsonResponse({ "error": "invalid_client", "error_description": "Wrong client_secret" }, status=401, headers={ 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"' }) elif request.POST.get("client_id"): try: self.client = request.organizer.sso_clients.get(client_id=request.POST["client_id"], is_active=True) except CustomerSSOClient.DoesNotExist: return JsonResponse({ "error": "invalid_client", "error_description": "Unknown or inactive client_id" }, status=400) if "client_secret" in request.POST: if not self.client.check_client_secret(request.POST.get("client_secret")): return JsonResponse({ "error": "invalid_client", "error_description": "Wrong client_secret" }, status=401, headers={ 'WWW-Authenticate': 'error="invalid_client"&error_description="Wrong client_secret"' }) elif self.client.client_type != CustomerSSOClient.CLIENT_PUBLIC: return JsonResponse({ "error": "invalid_client", "error_description": "Client is confidential, authentication required" }, status=400) else: return JsonResponse({ "error": "invalid_client", "error_description": "Client is confidential, authentication required" }, status=400) grant_type = request.POST.get("grant_type") if grant_type == "authorization_code": return self._handle_authorization_code(request) else: return JsonResponse({ "error": "unsupported_grant_type" }, status=400) def _handle_authorization_code(self, request): code = request.POST.get("code") redirect_uri = request.POST.get("redirect_uri") if not code: return JsonResponse({ "error": "invalid_grant", }, status=400) try: grant = self.client.grants.get(code=code, expires__gt=now()) except CustomerSSOGrant.DoesNotExist: # The server must return an invalid_grant error as the authorization code has already been used. # The originally issued access token should be revoked (as per RFC6749-4.1.2) CustomerSSOAccessToken.objects.filter( client=self.client, from_code=code ).update(expires=now() - timedelta(seconds=1)) return JsonResponse({ "error": "invalid_grant", "error_description": "Unknown or expired authorization code" }, status=400) if grant.redirect_uri != redirect_uri: return JsonResponse({ "error": "invalid_grant", "error_description": "Mismatch of redirect_uri" }, status=400) with transaction.atomic(): token = self.client.access_tokens.create( customer=grant.customer, token=get_random_string(128), expires=now() + timedelta(hours=24), scope=grant.scope, from_code=code, ) grant.delete() return JsonResponse({ "access_token": token.token, "token_type": "Bearer", "expires_in": int((token.expires - now()).total_seconds()), "id_token": generate_id_token(grant.customer, self.client, grant.auth_time, grant.nonce, grant.scope, token.expires) }, headers={ 'Cache-Control': 'no-store', 'Pragma': 'no-cache', }) class UserInfoView(View): @method_decorator(csrf_exempt) @method_decorator(never_cache) @method_decorator(sensitive_post_parameters()) def dispatch(self, request, *args, **kwargs): if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: raise Http404('Feature not enabled') auth_header = request.headers.get('Authorization') if auth_header: method, token = auth_header.split(' ', 1) if method != 'Bearer': return JsonResponse({ "error": "invalid_request", "error_description": "Unknown authorization method" }, status=400, headers={ 'Access-Control-Allow-Origin': '*', }) elif request.method == "POST" and "access_token" in request.POST: token = request.POST.get("access_token") else: return HttpResponse(status=401, headers={ 'WWW-Authenticate': 'Bearer realm="example"', 'Access-Control-Allow-Origin': '*', }) try: access_token = CustomerSSOAccessToken.objects.get( token=token, expires__gt=now(), client__organizer=self.request.organizer, ) except CustomerSSOAccessToken.DoesNotExist: return JsonResponse({ "error": "invalid_token", "error_description": "Unknown access token" }, status=401, headers={ 'WWW-Authenticate': 'error="invalid_token"&error_description="Unknown access token"', 'Access-Control-Allow-Origin': '*', }) else: self.customer = access_token.customer self.access_token = access_token r = super().dispatch(request, *args, **kwargs) r['Access-Control-Allow-Origin'] = '*' return r def post(self, request, *args, **kwargs): return self._handle(request) def get(self, request, *args, **kwargs): return self._handle(request) def _handle(self, request): return JsonResponse(customer_claims(self.customer, self.access_token.client.evaluated_scope(self.access_token.scope))) class KeysView(View): def dispatch(self, request, *args, **kwargs): if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: raise Http404('Feature not enabled') r = super().dispatch(request, *args, **kwargs) r['Access-Control-Allow-Origin'] = '*' return r def _encode_int(self, i): hexi = hex(i)[2:] return base64.urlsafe_b64encode(unhexlify((len(hexi) % 2) * '0' + hexi)) def get(self, request, *args, **kwargs): privkey, pubkey = _get_or_create_server_keypair(request.organizer) kid = hashlib.sha256(pubkey.encode()).hexdigest()[:16] pubkey = RSA.import_key(pubkey) return JsonResponse({ 'keys': [ { 'kty': 'RSA', 'alg': 'RS256', 'kid': kid, 'use': 'sig', 'e': self._encode_int(pubkey.e).decode().rstrip("="), 'n': self._encode_int(pubkey.n).decode().rstrip("="), } ] }) class ConfigurationView(View): def dispatch(self, request, *args, **kwargs): if not request.organizer.settings.customer_accounts or not request.organizer.settings.customer_accounts_native: raise Http404('Feature not enabled') r = super().dispatch(request, *args, **kwargs) r['Access-Control-Allow-Origin'] = '*' return r def get(self, request, *args, **kwargs): return JsonResponse({ 'issuer': build_absolute_uri(request.organizer, 'presale:organizer.index').rstrip('/'), 'authorization_endpoint': build_absolute_uri( request.organizer, 'presale:organizer.oauth2.v1.authorize' ), 'token_endpoint': build_absolute_uri( request.organizer, 'presale:organizer.oauth2.v1.token' ), 'userinfo_endpoint': build_absolute_uri( request.organizer, 'presale:organizer.oauth2.v1.userinfo' ), 'jwks_uri': build_absolute_uri( request.organizer, 'presale:organizer.oauth2.v1.jwks' ), 'scopes_supported': [k for k, v in CustomerSSOClient.SCOPE_CHOICES], 'response_types_supported': RESPONSE_TYPES_SUPPORTED, 'response_modes_supported': ['query', 'fragment'], 'request_parameter_supported': False, 'grant_types_supported': ['authorization_code', 'implicit'], 'subject_types_supported': ['public'], 'id_token_signing_alg_values_supported': ['RS256'], 'token_endpoint_auth_methods_supported': [ 'client_secret_post', 'client_secret_basic' ], 'claims_supported': [ 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'c_hash', 'at_hash', 'sub', 'locale', 'name', 'given_name', 'family_name', 'middle_name', 'nickname', 'email', 'email_verified', 'phone_number', ], 'request_uri_parameter_supported': False, })