mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
OpenID Connect RP support for customer accounts
This commit is contained in:
committed by
Raphael Michel
parent
e102a590ab
commit
7f5518dbf6
21
src/pretix/base/customersso/__init__.py
Normal file
21
src/pretix/base/customersso/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
207
src/pretix/base/customersso/oidc.py
Normal file
207
src/pretix/base/customersso/oidc.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#
|
||||
# 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 logging
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from requests import RequestException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _urljoin(base, path):
|
||||
if not base.endswith("/"):
|
||||
base += "/"
|
||||
return urljoin(base, path)
|
||||
|
||||
|
||||
def oidc_validate_and_complete_config(config):
|
||||
for k in ("base_url", "client_id", "client_secret", "uid_field", "email_field", "scope"):
|
||||
if not config.get(k):
|
||||
raise ValidationError(_('Configuration option "{name}" is missing.').format(name=k))
|
||||
|
||||
conf_url = _urljoin(config["base_url"], ".well-known/openid-configuration")
|
||||
try:
|
||||
resp = requests.get(conf_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
provider_config = resp.json()
|
||||
except RequestException as e:
|
||||
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
|
||||
url=conf_url,
|
||||
error=str(e)
|
||||
))
|
||||
except ValueError as e:
|
||||
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
|
||||
url=conf_url,
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
if not provider_config.get("authorization_endpoint"):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error="authorization_endpoint not set"
|
||||
))
|
||||
|
||||
if not provider_config.get("userinfo_endpoint"):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error="userinfo_endpoint not set"
|
||||
))
|
||||
|
||||
if not provider_config.get("token_endpoint"):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error="token_endpoint not set"
|
||||
))
|
||||
|
||||
if "code" not in provider_config.get("response_types_supported", []):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error=f"provider supports response types {','.join(provider_config.get('response_types_supported', []))}, but we only support 'code'."
|
||||
))
|
||||
|
||||
if "query" not in provider_config.get("response_modes_supported", ["query", "fragment"]):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error=f"provider supports response modes {','.join(provider_config.get('response_modes_supported', []))}, but we only support 'query'."
|
||||
))
|
||||
|
||||
if "authorization_code" not in provider_config.get("grant_types_supported", ["authorization_code", "implicit"]):
|
||||
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
|
||||
error=f"provider supports grant types {','.join(provider_config.get('grant_types_supported', ''))}, but we only support 'authorization_code'."
|
||||
))
|
||||
|
||||
if "openid" not in config["scope"].split(" "):
|
||||
raise ValidationError(
|
||||
_('You are not requesting "{scope}".').format(
|
||||
scope="openid",
|
||||
))
|
||||
|
||||
for scope in config["scope"].split(" "):
|
||||
if scope not in provider_config.get("scopes_supported", []):
|
||||
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
|
||||
scope=scope,
|
||||
scopes=", ".join(provider_config.get("scopes_supported", []))
|
||||
))
|
||||
|
||||
for k, v in config.items():
|
||||
if k.endswith('_field') and v:
|
||||
if v not in provider_config.get("claims_supported", []): # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
|
||||
field=v,
|
||||
fields=", ".join(provider_config.get("claims_supported", []))
|
||||
))
|
||||
|
||||
config['provider_config'] = provider_config
|
||||
return config
|
||||
|
||||
|
||||
def oidc_authorize_url(provider, state, redirect_uri):
|
||||
endpoint = provider.configuration['provider_config']['authorization_endpoint']
|
||||
params = {
|
||||
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint
|
||||
'response_type': 'code',
|
||||
'client_id': provider.configuration['client_id'],
|
||||
'scope': provider.configuration['scope'],
|
||||
'state': state,
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
return endpoint + '?' + urlencode(params)
|
||||
|
||||
|
||||
def oidc_validate_authorization(provider, code, redirect_uri):
|
||||
endpoint = provider.configuration['provider_config']['token_endpoint']
|
||||
params = {
|
||||
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
endpoint,
|
||||
data=params,
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
auth=(provider.configuration['client_id'], provider.configuration['client_secret']),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve authorization token')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not reach login provider',
|
||||
)
|
||||
)
|
||||
|
||||
if 'access_token' not in data:
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='access token missing',
|
||||
)
|
||||
)
|
||||
|
||||
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
|
||||
try:
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
resp = requests.get(
|
||||
endpoint,
|
||||
headers={
|
||||
'Authorization': f'Bearer {data["access_token"]}'
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
userinfo = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve user info')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not fetch user info',
|
||||
)
|
||||
)
|
||||
|
||||
if 'email_verified' in userinfo and not userinfo['email_verified']:
|
||||
# todo: how universal is this, do we need to make this configurable?
|
||||
raise ValidationError(_('The email address on this account is not yet verified. Please first confirm the '
|
||||
'email address in your customer account.'))
|
||||
|
||||
profile = {}
|
||||
for k, v in provider.configuration.items():
|
||||
if k.endswith('_field'):
|
||||
profile[k[:-6]] = userinfo.get(v)
|
||||
|
||||
if not profile.get('uid'):
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not fetch user id',
|
||||
)
|
||||
)
|
||||
|
||||
if not profile.get('email'):
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not fetch user email',
|
||||
)
|
||||
)
|
||||
|
||||
return profile
|
||||
38
src/pretix/base/migrations/0219_auto_20220706_0913.py
Normal file
38
src/pretix/base/migrations/0219_auto_20220706_0913.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 3.2.12 on 2022-07-06 09:13
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0218_checkinlist_addon_match'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomerSSOProvider',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('button_label', i18nfield.fields.I18nCharField(max_length=200)),
|
||||
('method', models.CharField(max_length=190)),
|
||||
('configuration', models.JSONField()),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers', to='pretixbase.organizer')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='provider',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.customerssoprovider'),
|
||||
),
|
||||
]
|
||||
@@ -30,6 +30,7 @@ from django.db.models import F, Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
@@ -39,12 +40,42 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
class CustomerSSOProvider(LoggedModel):
|
||||
METHOD_OIDC = 'oidc'
|
||||
METHODS = (
|
||||
(METHOD_OIDC, 'OpenID Connect'),
|
||||
)
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
organizer = models.ForeignKey(Organizer, related_name='sso_providers', on_delete=models.CASCADE)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Provider name"),
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
|
||||
button_label = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Login button label"),
|
||||
)
|
||||
method = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_("Single-sign-on method"),
|
||||
null=False, blank=False,
|
||||
choices=METHODS,
|
||||
)
|
||||
configuration = models.JSONField()
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.customers.exists()
|
||||
|
||||
|
||||
class Customer(LoggedModel):
|
||||
"""
|
||||
Represents a registered customer of an organizer.
|
||||
"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
|
||||
identifier = models.CharField(
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
|
||||
@@ -146,6 +146,17 @@ DEFAULTS = {
|
||||
"advanced features like memberships.")
|
||||
)
|
||||
},
|
||||
'customer_accounts_native': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Allow customers to log in with email address and password"),
|
||||
help_text=_("If disabled, you will need to connect one or more single-sign-on providers."),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
||||
)
|
||||
},
|
||||
'customer_accounts_link_by_email': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
|
||||
Reference in New Issue
Block a user