OAuth: Add profile-only access

This commit is contained in:
Raphael Michel
2020-09-28 16:01:59 +02:00
parent ae0637a3d6
commit 3cbcf663e5
8 changed files with 92 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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
})

View File

@@ -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:

View File

@@ -26,8 +26,10 @@
<li>{{ scope }}</li>
{% endfor %}
</ul>
<p>{% trans "Please select the organizer accounts this application should get access to:" %}</p>
{% bootstrap_field form.organizers layout="inline" %}
{% if form.organizers %}
<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" %}
<p class="text-danger">

View File

@@ -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