mirror of
https://github.com/pretix/pretix.git
synced 2026-08-11 10:57:00 +00:00
OAuth: Add profile-only access
This commit is contained in:
+9
-10
@@ -25,7 +25,7 @@ Obtaining an authorization grant
|
|||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
|
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
|
||||||
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, or ``read write``)
|
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, ``read write`` or ``profile``)
|
||||||
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
|
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
|
||||||
``response_type`` parameter with a value of ``code``. Example::
|
``response_type`` parameter with a value of ``code``. Example::
|
||||||
|
|
||||||
@@ -47,11 +47,9 @@ You will need this ``code`` parameter to perform the next step.
|
|||||||
|
|
||||||
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
|
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
|
||||||
|
|
||||||
.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to
|
.. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the
|
||||||
re-authenticate the user later, the user might be instantly redirected back to you if authorization is already
|
``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter
|
||||||
given and would therefore be unable to review their organizer restriction settings. You can append the
|
to skip user interaction on subsequen calls.
|
||||||
``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the
|
|
||||||
authorization.
|
|
||||||
|
|
||||||
Getting an access token
|
Getting an access token
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -193,10 +191,11 @@ If you need the user's meta data, you can fetch it here:
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
email: "admin@localhost",
|
"email": "admin@localhost",
|
||||||
fullname: "John Doe",
|
"fullname": "John Doe",
|
||||||
locale: "de",
|
"locale": "de",
|
||||||
timezone: "Europe/Berlin"
|
"is_staff": false,
|
||||||
|
"timezone": "Europe/Berlin"
|
||||||
}
|
}
|
||||||
|
|
||||||
:statuscode 200: no error
|
:statuscode 200: no error
|
||||||
|
|||||||
@@ -84,3 +84,15 @@ class EventCRUDPermission(EventPermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ProfilePermission(BasePermission):
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(request.auth, OAuthAccessToken):
|
||||||
|
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|||||||
+11
-8
@@ -9,7 +9,7 @@ from oauth2_provider.settings import oauth2_settings
|
|||||||
class Validator(OAuth2Validator):
|
class Validator(OAuth2Validator):
|
||||||
|
|
||||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||||
if not getattr(request, 'organizers', None):
|
if not getattr(request, 'organizers', None) and request.scopes != ['profile']:
|
||||||
raise FatalClientError('No organizers selected.')
|
raise FatalClientError('No organizers selected.')
|
||||||
|
|
||||||
expires = timezone.now() + timedelta(
|
expires = timezone.now() + timedelta(
|
||||||
@@ -18,7 +18,8 @@ class Validator(OAuth2Validator):
|
|||||||
expires=expires, redirect_uri=request.redirect_uri,
|
expires=expires, redirect_uri=request.redirect_uri,
|
||||||
scope=" ".join(request.scopes))
|
scope=" ".join(request.scopes))
|
||||||
g.save()
|
g.save()
|
||||||
g.organizers.add(*request.organizers.all())
|
if request.scopes != ['profile']:
|
||||||
|
g.organizers.add(*request.organizers.all())
|
||||||
|
|
||||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@@ -34,12 +35,14 @@ class Validator(OAuth2Validator):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _create_access_token(self, expires, request, token, source_refresh_token=None):
|
def _create_access_token(self, expires, request, token, source_refresh_token=None):
|
||||||
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token'):
|
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token', None) and token["scope"] != 'profile':
|
||||||
raise FatalClientError('No organizers selected.')
|
raise FatalClientError('No organizers selected.')
|
||||||
if hasattr(request, 'organizers'):
|
if token['scope'] != 'profile':
|
||||||
orgs = list(request.organizers.all())
|
if hasattr(request, 'organizers'):
|
||||||
else:
|
orgs = list(request.organizers.all())
|
||||||
orgs = list(source_refresh_token.access_token.organizers.all())
|
else:
|
||||||
|
orgs = list(source_refresh_token.access_token.organizers.all())
|
||||||
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
|
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
|
||||||
access_token.organizers.add(*orgs)
|
if token['scope'] != 'profile':
|
||||||
|
access_token.organizers.add(*orgs)
|
||||||
return access_token
|
return access_token
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import logging
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from oauth2_provider.exceptions import OAuthToolkitError
|
from oauth2_provider.exceptions import OAuthToolkitError, FatalClientError
|
||||||
from oauth2_provider.forms import AllowForm
|
from oauth2_provider.forms import AllowForm
|
||||||
|
from oauth2_provider.settings import oauth2_settings
|
||||||
from oauth2_provider.views import (
|
from oauth2_provider.views import (
|
||||||
AuthorizationView as BaseAuthorizationView,
|
AuthorizationView as BaseAuthorizationView,
|
||||||
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
|
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
|
||||||
@@ -24,9 +25,12 @@ class OAuthAllowForm(AllowForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop('user')
|
user = kwargs.pop('user')
|
||||||
|
scope = kwargs.pop('scope')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['organizers'].queryset = Organizer.objects.filter(
|
self.fields['organizers'].queryset = Organizer.objects.filter(
|
||||||
pk__in=user.teams.values_list('organizer', flat=True))
|
pk__in=user.teams.values_list('organizer', flat=True))
|
||||||
|
if scope == 'profile':
|
||||||
|
del self.fields['organizers']
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationView(BaseAuthorizationView):
|
class AuthorizationView(BaseAuthorizationView):
|
||||||
@@ -36,6 +40,7 @@ class AuthorizationView(BaseAuthorizationView):
|
|||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs['user'] = self.request.user
|
kwargs['user'] = self.request.user
|
||||||
|
kwargs['scope'] = self.request.GET.get('scope')
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -43,8 +48,14 @@ class AuthorizationView(BaseAuthorizationView):
|
|||||||
ctx['settings'] = settings
|
ctx['settings'] = settings
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def create_authorization_response(self, request, scopes, credentials, allow, organizers):
|
def validate_authorization_request(self, request):
|
||||||
credentials["organizers"] = organizers
|
require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT)
|
||||||
|
if require_approval != 'force' and request.GET.get('scope') != 'profile':
|
||||||
|
raise FatalClientError('Combnination of require_approval and scope values not allowed.')
|
||||||
|
return super().validate_authorization_request(request)
|
||||||
|
|
||||||
|
def create_authorization_response(self, request, scopes, credentials, allow, organizers=None):
|
||||||
|
credentials["organizers"] = organizers or []
|
||||||
return super().create_authorization_response(request, scopes, credentials, allow)
|
return super().create_authorization_response(request, scopes, credentials, allow)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ from rest_framework.authentication import SessionAuthentication
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from pretix.api.auth.permission import ProfilePermission
|
||||||
|
|
||||||
|
|
||||||
class MeView(APIView):
|
class MeView(APIView):
|
||||||
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
||||||
|
permission_classes = (ProfilePermission,)
|
||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
return Response({
|
return Response({
|
||||||
'email': request.user.email,
|
'email': request.user.email,
|
||||||
'fullname': request.user.fullname,
|
'fullname': request.user.fullname,
|
||||||
'locale': request.user.locale,
|
'locale': request.user.locale,
|
||||||
|
'is_staff': request.user.is_staff,
|
||||||
'timezone': request.user.timezone
|
'timezone': request.user.timezone
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ class BaseAuthBackend:
|
|||||||
|
|
||||||
class NativeAuthBackend(BaseAuthBackend):
|
class NativeAuthBackend(BaseAuthBackend):
|
||||||
identifier = 'native'
|
identifier = 'native'
|
||||||
verbose_name = _('pretix User')
|
|
||||||
|
@property
|
||||||
|
def verbose_name(self):
|
||||||
|
return _('{system} User').format(system=settings.PRETIX_INSTANCE_NAME)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def login_form_fields(self) -> dict:
|
def login_form_fields(self) -> dict:
|
||||||
|
|||||||
@@ -26,8 +26,10 @@
|
|||||||
<li>{{ scope }}</li>
|
<li>{{ scope }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p>{% trans "Please select the organizer accounts this application should get access to:" %}</p>
|
{% if form.organizers %}
|
||||||
{% bootstrap_field form.organizers layout="inline" %}
|
<p>{% trans "Please select the organizer accounts this application should get access to:" %}</p>
|
||||||
|
{% bootstrap_field form.organizers layout="inline" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% bootstrap_form_errors form layout="control" %}
|
{% bootstrap_form_errors form layout="control" %}
|
||||||
<p class="text-danger">
|
<p class="text-danger">
|
||||||
|
|||||||
@@ -574,3 +574,37 @@ def test_user_revoke(client, admin_user, organizer, application: OAuthApplicatio
|
|||||||
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(
|
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(
|
||||||
('%s:%s' % (application.client_id, application.client_secret)).encode()).decode())
|
('%s:%s' % (application.client_id, application.client_secret)).encode()).decode())
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_allow_profile_only(client, admin_user, organizer, application: OAuthApplication):
|
||||||
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&scope=profile' % (
|
||||||
|
application.client_id, quote(application.redirect_uris)
|
||||||
|
))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = client.post('/api/v1/oauth/authorize', data={
|
||||||
|
'organizers': [str(organizer.pk)],
|
||||||
|
'redirect_uri': application.redirect_uris,
|
||||||
|
'scope': 'profile',
|
||||||
|
'client_id': application.client_id,
|
||||||
|
'response_type': 'code',
|
||||||
|
'allow': 'Authorize',
|
||||||
|
})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp['Location'].startswith('https://pretalx.com?code=')
|
||||||
|
code = resp['Location'].split("=")[1]
|
||||||
|
client.logout()
|
||||||
|
resp = client.post('/api/v1/oauth/token', data={
|
||||||
|
'code': code,
|
||||||
|
'redirect_uri': application.redirect_uris,
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(
|
||||||
|
('%s:%s' % (application.client_id, application.client_secret)).encode()).decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.content.decode())
|
||||||
|
access_token = data['access_token']
|
||||||
|
resp = client.get('/api/v1/organizers/', HTTP_AUTHORIZATION='Bearer %s' % access_token)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = client.get('/api/v1/me', HTTP_AUTHORIZATION='Bearer %s' % access_token)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user