diff --git a/doc/api/oauth.rst b/doc/api/oauth.rst index 55762c13b..e5d57ec94 100644 --- a/doc/api/oauth.rst +++ b/doc/api/oauth.rst @@ -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 -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 ``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. -.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to - re-authenticate the user later, the user might be instantly redirected back to you if authorization is already - given and would therefore be unable to review their organizer restriction settings. You can append the - ``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the - authorization. +.. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the + ``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter + to skip user interaction on subsequen calls. 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 { - email: "admin@localhost", - fullname: "John Doe", - locale: "de", - timezone: "Europe/Berlin" + "email": "admin@localhost", + "fullname": "John Doe", + "locale": "de", + "is_staff": false, + "timezone": "Europe/Berlin" } :statuscode 200: no error diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index 3fbe851b4..b7a4d651e 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -84,3 +84,15 @@ class EventCRUDPermission(EventPermission): return False 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 diff --git a/src/pretix/api/oauth.py b/src/pretix/api/oauth.py index 88aac5972..2f6f5c437 100644 --- a/src/pretix/api/oauth.py +++ b/src/pretix/api/oauth.py @@ -9,7 +9,7 @@ from oauth2_provider.settings import oauth2_settings class Validator(OAuth2Validator): 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.') expires = timezone.now() + timedelta( @@ -18,7 +18,8 @@ class Validator(OAuth2Validator): expires=expires, redirect_uri=request.redirect_uri, scope=" ".join(request.scopes)) 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): try: @@ -34,12 +35,14 @@ class Validator(OAuth2Validator): return False 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.') - if hasattr(request, 'organizers'): - orgs = list(request.organizers.all()) - else: - orgs = list(source_refresh_token.access_token.organizers.all()) + if token['scope'] != 'profile': + if hasattr(request, 'organizers'): + orgs = list(request.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.organizers.add(*orgs) + if token['scope'] != 'profile': + access_token.organizers.add(*orgs) return access_token diff --git a/src/pretix/api/views/oauth.py b/src/pretix/api/views/oauth.py index 812377010..1e7a93597 100644 --- a/src/pretix/api/views/oauth.py +++ b/src/pretix/api/views/oauth.py @@ -3,8 +3,9 @@ import logging from django import forms from django.conf import settings 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.settings import oauth2_settings from oauth2_provider.views import ( AuthorizationView as BaseAuthorizationView, RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView, @@ -24,9 +25,12 @@ class OAuthAllowForm(AllowForm): def __init__(self, *args, **kwargs): user = kwargs.pop('user') + scope = kwargs.pop('scope') super().__init__(*args, **kwargs) self.fields['organizers'].queryset = Organizer.objects.filter( pk__in=user.teams.values_list('organizer', flat=True)) + if scope == 'profile': + del self.fields['organizers'] class AuthorizationView(BaseAuthorizationView): @@ -36,6 +40,7 @@ class AuthorizationView(BaseAuthorizationView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['user'] = self.request.user + kwargs['scope'] = self.request.GET.get('scope') return kwargs def get_context_data(self, **kwargs): @@ -43,8 +48,14 @@ class AuthorizationView(BaseAuthorizationView): ctx['settings'] = settings return ctx - def create_authorization_response(self, request, scopes, credentials, allow, organizers): - credentials["organizers"] = organizers + def validate_authorization_request(self, request): + 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) def form_valid(self, form): diff --git a/src/pretix/api/views/user.py b/src/pretix/api/views/user.py index b0e276e53..a3448b3b4 100644 --- a/src/pretix/api/views/user.py +++ b/src/pretix/api/views/user.py @@ -3,14 +3,18 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework.views import APIView +from pretix.api.auth.permission import ProfilePermission + class MeView(APIView): authentication_classes = (SessionAuthentication, OAuth2Authentication) + permission_classes = (ProfilePermission,) def get(self, request, format=None): return Response({ 'email': request.user.email, 'fullname': request.user.fullname, 'locale': request.user.locale, + 'is_staff': request.user.is_staff, 'timezone': request.user.timezone }) diff --git a/src/pretix/base/auth.py b/src/pretix/base/auth.py index fa1cca25d..e0e05d8ea 100644 --- a/src/pretix/base/auth.py +++ b/src/pretix/base/auth.py @@ -98,7 +98,10 @@ class BaseAuthBackend: class NativeAuthBackend(BaseAuthBackend): identifier = 'native' - verbose_name = _('pretix User') + + @property + def verbose_name(self): + return _('{system} User').format(system=settings.PRETIX_INSTANCE_NAME) @property def login_form_fields(self) -> dict: diff --git a/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html b/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html index 3dc000903..a6085b08b 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html +++ b/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html @@ -26,8 +26,10 @@
{% trans "Please select the organizer accounts this application should get access to:" %}
- {% bootstrap_field form.organizers layout="inline" %} + {% if form.organizers %} +{% trans "Please select the organizer accounts this application should get access to:" %}
+ {% bootstrap_field form.organizers layout="inline" %} + {% endif %} {% bootstrap_form_errors form layout="control" %}diff --git a/src/tests/api/test_oauth.py b/src/tests/api/test_oauth.py index da1b92dd7..28078a503 100644 --- a/src/tests/api/test_oauth.py +++ b/src/tests/api/test_oauth.py @@ -574,3 +574,37 @@ def test_user_revoke(client, admin_user, organizer, application: OAuthApplicatio }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) 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