forked from CGM_Public/pretix_original
OpenID Connect OP support for customer accounts
This commit is contained in:
committed by
Raphael Michel
parent
7f5518dbf6
commit
a4171ef819
@@ -22,7 +22,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for provider in request.organizer.sso_providers.all %}
|
||||
{% for provider in providers %}
|
||||
{% if provider.is_active %}
|
||||
<a href="{% eventurl request.organizer "presale:organizer.customer.login" provider=provider.pk %}?{{ request.META.QUERY_STRING }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
|
||||
@@ -40,6 +40,7 @@ import pretix.presale.views.checkout
|
||||
import pretix.presale.views.customer
|
||||
import pretix.presale.views.event
|
||||
import pretix.presale.views.locale
|
||||
import pretix.presale.views.oidc_op
|
||||
import pretix.presale.views.order
|
||||
import pretix.presale.views.organizer
|
||||
import pretix.presale.views.robots
|
||||
@@ -72,6 +73,7 @@ frame_wrapped_urls = [
|
||||
re_path(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
|
||||
re_path(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
|
||||
]
|
||||
|
||||
event_patterns = [
|
||||
# Cart/checkout patterns are a bit more complicated, as they should have simple URLs like cart/clear in normal
|
||||
# cases, but need to have versions with unguessable URLs like w/8l4Y83XNonjLxoBb/cart/clear to be used in widget
|
||||
@@ -174,9 +176,11 @@ organizer_patterns = [
|
||||
re_path(r'^events/ical/$',
|
||||
pretix.presale.views.organizer.OrganizerIcalDownload.as_view(),
|
||||
name='organizer.ical'),
|
||||
|
||||
re_path(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
|
||||
name='organizer.widget.productlist'),
|
||||
re_path(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
|
||||
|
||||
re_path(r'^account/login/(?P<provider>[0-9]+)/$', pretix.presale.views.customer.SSOLoginView.as_view(), name='organizer.customer.login'),
|
||||
re_path(r'^account/login/(?P<provider>[0-9]+)/return$', pretix.presale.views.customer.SSOLoginReturnView.as_view(), name='organizer.customer.login.return'),
|
||||
re_path(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
|
||||
@@ -192,6 +196,17 @@ organizer_patterns = [
|
||||
re_path(r'^account/addresses/(?P<id>\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
|
||||
re_path(r'^account/profiles/(?P<id>\d+)/delete$', pretix.presale.views.customer.ProfileDeleteView.as_view(), name='organizer.customer.profile.delete'),
|
||||
re_path(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'),
|
||||
|
||||
re_path(r'^oauth2/v1/authorize$', pretix.presale.views.oidc_op.AuthorizeView.as_view(),
|
||||
name='organizer.oauth2.v1.authorize'),
|
||||
re_path(r'^oauth2/v1/token$', pretix.presale.views.oidc_op.TokenView.as_view(),
|
||||
name='organizer.oauth2.v1.token'),
|
||||
re_path(r'^oauth2/v1/userinfo$', pretix.presale.views.oidc_op.UserInfoView.as_view(),
|
||||
name='organizer.oauth2.v1.userinfo'),
|
||||
re_path(r'^oauth2/v1/keys$', pretix.presale.views.oidc_op.KeysView.as_view(),
|
||||
name='organizer.oauth2.v1.jwks'),
|
||||
re_path(r'^.well-known/openid-configuration$', pretix.presale.views.oidc_op.ConfigurationView.as_view(),
|
||||
name='organizer.oauth2.v1.configuration'),
|
||||
]
|
||||
|
||||
locale_patterns = [
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import re
|
||||
import time
|
||||
import warnings
|
||||
from importlib import import_module
|
||||
from urllib.parse import urljoin
|
||||
@@ -153,9 +154,15 @@ def add_customer_to_request(request):
|
||||
request.customer = SimpleLazyObject(lambda: get_customer(request))
|
||||
|
||||
|
||||
def get_customer_auth_time(request):
|
||||
auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
|
||||
return request.session.get(auth_time_session_key) or 0
|
||||
|
||||
|
||||
def customer_login(request, customer):
|
||||
session_key = f'customer_auth_id:{request.organizer.pk}'
|
||||
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||
auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
|
||||
dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}'
|
||||
session_auth_hash = customer.get_session_auth_hash()
|
||||
|
||||
@@ -172,6 +179,7 @@ def customer_login(request, customer):
|
||||
request.session.pop(dependency_key, None)
|
||||
request.session[session_key] = customer.pk
|
||||
request.session[hash_session_key] = session_auth_hash
|
||||
request.session[auth_time_session_key] = int(time.time())
|
||||
request.customer = customer
|
||||
|
||||
customer.last_login = now()
|
||||
@@ -183,6 +191,7 @@ def customer_login(request, customer):
|
||||
def customer_logout(request):
|
||||
session_key = f'customer_auth_id:{request.organizer.pk}'
|
||||
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||
auth_time_session_key = f'customer_auth_time:{request.organizer.pk}'
|
||||
dependency_key = f'customer_auth_session_dependency:{request.organizer.pk}'
|
||||
|
||||
# Remove dependency on parent session
|
||||
@@ -193,6 +202,7 @@ def customer_logout(request):
|
||||
# Remove user session
|
||||
customer_id = request.session.pop(session_key, None)
|
||||
request.session.pop(hash_session_key, None)
|
||||
request.session.pop(auth_time_session_key, None)
|
||||
|
||||
# Remove carts tied to this user
|
||||
carts = request.session.get('carts', {})
|
||||
|
||||
@@ -113,6 +113,12 @@ class LoginView(RedirectBackMixin, FormView):
|
||||
raise Http404('Feature not enabled')
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
**kwargs,
|
||||
providers=self.request.organizer.sso_providers.all()
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['request'] = self.request
|
||||
|
||||
526
src/pretix/presale/views/oidc_op.py
Normal file
526
src/pretix/presale/views/oidc_op.py
Normal file
@@ -0,0 +1,526 @@
|
||||
#
|
||||
# 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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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.middleware.csrf import _compare_masked_tokens
|
||||
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.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):
|
||||
request_token = CsrfViewMiddleware(lambda: None)._get_token(request)
|
||||
if not request_token or not _compare_masked_tokens(request.POST.get('csrfmiddlewaretoken', ''), request_token):
|
||||
# External request, we prefer GET and will redirect to prevent confusion with our login form
|
||||
return redirect(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,
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user