diff --git a/.travis.sh b/.travis.sh index e507bb219..11a090a55 100755 --- a/.travis.sh +++ b/.travis.sh @@ -39,11 +39,11 @@ if [ "$1" == "translation-spelling" ]; then potypo fi if [ "$1" == "tests" ]; then - pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt + pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt pytest-xdist cd src python manage.py check make all compress - py.test --reruns 5 tests + py.test --reruns 5 -n 2 tests fi if [ "$1" == "tests-cov" ]; then pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt diff --git a/.travis.yml b/.travis.yml index 9398bcd6f..f68b87204 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ matrix: - python: 3.6 env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg - python: 3.6 - env: JOB=tests-cov + env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg - python: 3.6 env: JOB=style - python: 3.4 diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 03b3e28b3..fbb86e41a 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -6,27 +6,13 @@ with pretix' REST API, such as authentication, pagination and similar definition .. _`rest-auth`: -Obtaining an API token ----------------------- - -To authenticate your API requests, you need to obtain an API token. You can create a -token in the pretix web interface on the level of organizer teams. Create a new team -or choose an existing team that has the level of permissions the token should have and -create a new token using the form below the list of team members: - -.. image:: img/token_form.png - :class: screenshot - -You can enter a description for the token to distinguish from other tokens later on. -Once you click "Add", you will be provided with an API token in the success message. -Copy this token, as you won't be able to retrieve it again. - -.. image:: img/token_success.png - :class: screenshot - Authentication -------------- +If you're building an application for end users, we strongly recommend that you use our +:ref:`OAuth-based authentication progress `. However, for simpler needs, you +can also go with static API tokens that you can create on a per-team basis (see below). + You need to include the API token with every request to pretix' API in the ``Authorization`` header like the following: @@ -44,6 +30,24 @@ like the following: adding OAuth2 support in the future for user-level authentication. If you want to use session authentication, be sure to comply with Django's `CSRF policies`_. +Obtaining an API token +---------------------- + +To authenticate your API requests, you need to obtain an API token. You can create a +token in the pretix web interface on the level of organizer teams. Create a new team +or choose an existing team that has the level of permissions the token should have and +create a new token using the form below the list of team members: + +.. image:: img/token_form.png + :class: screenshot + +You can enter a description for the token to distinguish from other tokens later on. +Once you click "Add", you will be provided with an API token in the success message. +Copy this token, as you won't be able to retrieve it again. + +.. image:: img/token_success.png + :class: screenshot + Permissions ----------- diff --git a/doc/api/index.rst b/doc/api/index.rst index 06fa8711d..0e8ace9c4 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -14,4 +14,5 @@ in functionality over time. :maxdepth: 2 fundamentals + oauth resources/index diff --git a/doc/api/oauth.rst b/doc/api/oauth.rst new file mode 100644 index 000000000..21b972cd4 --- /dev/null +++ b/doc/api/oauth.rst @@ -0,0 +1,171 @@ +.. _`rest-oauth`: + +OAuth support / "Connect with pretix" +===================================== + +In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with +pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool +that allows the user to easily set up a connection between the two systems. + +If you haven't worked with OAuth before, have a look at the `OAuth2 Simplified`_ tutorial. + +Registering an application +-------------------------- + +To use OAuth, you need to register your application with the pretix instance you want to connect to. +In order to do this, log in to your pretix account and go to your user settings. Click on "Authorized applications" +first and then on "Manage your own apps". From there, you can "Create a new application". + +You should fill in a descriptive name of your application that allows users to recognize who you are. You also need to +give a list of fully-qualified URLs that users will be redirected to after a successful authorization. After you pressed +"Save", you will be presented with a client ID and a client secret. Please note them down and treat the client secret +like a password; it should not become available to your users. + +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``) +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:: + + https://pretix.eu/api/v1/oauth/authorize?client_id=lsLi0hNL0vk53mEdYjNJxHUn1PcO1R6wVg81dLNT&response_type=code&scope=read+write&redirect_uri=https://pretalx.com + +To prevent CSRF attacks, you can also optionally pass a ``state`` parameter with a random string. Later, when +redirecting back to your application, we will pass the same ``state`` parameter back to you, so you can compare if they +match. + +After the user granted or denied access, they will be redirected back either to the ``redirect_url`` you passed in the +query or to the first redirect URL configured in your application settings. + +On successful registration, we will append the query parameter ``code`` to the URL containing an authorization code. +For example, we might redirect the user to this URL:: + + https://pretalx.com/?code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&state=e3KCh9mfx07qxU4bRpXk + +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. + +Getting an access token +----------------------- + +Using the ``code`` value you obtained above and your client ID, you can now request an access token that actually gives +access to the API. The ``token`` endpoint expects you to authenticate using `HTTP Basic authentication`_ using your client +ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri`` +parameter that you used for the authorization. + +.. http:get:: /api/v1/oauth/token + + Request a new access token + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/oauth/token HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg== + + grant_type=authorization_code&code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&redirect_uri=https://pretalx.com + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "access_token": "i3ytqTSRWsKp16fqjekHXa4tdM4qNC", + "expires_in": 86400, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": "XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp" + } + + :statuscode 200: no error + :statuscode 401: Authentication failure + + +As you can see, you receive two types of tokens: One "access token", and one "refresh token". The access token is valid +for a day and can be used to actually access the API. The refresh token does not have an expiration date and can be used +to obtain a new access_token after a day, so you should make sure to store the access token safely if you need long-term +access. + +Using the API with an access token +---------------------------------- + +You can supply a valid access token as a ``Bearer``-type token in the ``Authorization`` header to get API access. + +.. sourcecode:: http + :emphasize-lines: 3 + + GET /api/v1/organizers/ HTTP/1.1 + Host: pretix.eu + Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC + +Refreshing an access token +-------------------------- + +You can obtain a new access token using your refresh token any time. This can be done using the same ``token`` endpoint +used to obtain the first access token above, but with a different set of parameters: + +.. sourcecode:: http + + POST /api/v1/oauth/token HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg== + + grant_type=refresh_token&refresh_token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp + +The previous access token will instantly become invalid. + +Revoking a token +---------------- + +If you don't need a token any more or if you believe it may have been compromised, you can use the ``revoke_token`` +endpoint to revoke it. + +.. http:get:: /api/v1/oauth/revoke_token + + Revoke an access or refresh token. If you revoke an access token, you can still create a new one using the refresh token. If you + revoke a refresh token, the connected access token will also be revoked. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/oauth/revoke_token HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg== + + token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + :statuscode 200: no error + :statuscode 401: Authentication failure + +If you want to revoke your client secret, you can generate a new one in the list of your managed applications in the +pretix user interface. + +.. _OAuth2: https://en.wikipedia.org/wiki/OAuth +.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/ +.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication \ No newline at end of file diff --git a/src/pretix/api/__init__.py b/src/pretix/api/__init__.py index e69de29bb..f5e54ddd9 100644 --- a/src/pretix/api/__init__.py +++ b/src/pretix/api/__init__.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class PretixApiConfig(AppConfig): + name = 'pretix.api' + label = 'pretixapi' + + +default_app_config = 'pretix.api.PretixApiConfig' diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index aa9e4f517..0412d2004 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -1,5 +1,6 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission +from pretix.api.models import OAuthAccessToken from pretix.base.models import Event from pretix.base.models.organizer import Organizer, TeamAPIToken from pretix.helpers.security import ( @@ -55,6 +56,15 @@ class EventPermission(BasePermission): if required_permission and required_permission not in request.orgapermset: return False + + if isinstance(request.auth, OAuthAccessToken): + if not request.auth.allow_scopes(['write']) and request.method not in SAFE_METHODS: + return False + if not request.auth.allow_scopes(['read']) and request.method in SAFE_METHODS: + return False + if isinstance(request.auth, OAuthAccessToken) and hasattr(request, 'organizer'): + if not request.auth.organizers.filter(pk=request.organizer.pk).exists(): + return False return True diff --git a/src/pretix/api/migrations/0001_initial.py b/src/pretix/api/migrations/0001_initial.py new file mode 100644 index 000000000..d56fb19ef --- /dev/null +++ b/src/pretix/api/migrations/0001_initial.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-04 11:19 +from __future__ import unicode_literals + +import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OAuthAccessToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OAuthApplication', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('client_type', + models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField( + choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), + ('password', 'Resource owner password-based'), + ('client-credentials', 'Client credentials')], max_length=32)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='Application name')), + ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', + validators=[oauth2_provider.validators.validate_uris], + verbose_name='Redirection URIs')), + ('client_id', + models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, + unique=True, verbose_name='Client ID')), + ('client_secret', + models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_secret, + max_length=255, verbose_name='Client secret')), + ('active', models.BooleanField(default=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + related_name='pretixapi_oauthapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OAuthGrant', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('code', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('redirect_uri', models.CharField(max_length=255)), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthgrant', + to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OAuthRefreshToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), + ('access_token', + models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='pretixapi_oauthrefreshtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='oauthaccesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='oauthaccesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='refreshed_access_token', + to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + migrations.AddField( + model_name='oauthaccesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + related_name='pretixapi_oauthaccesstoken', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='oauthrefreshtoken', + unique_together=set([('token', 'revoked')]), + ), + ] diff --git a/src/pretix/api/migrations/0002_auto_20180604_1120.py b/src/pretix/api/migrations/0002_auto_20180604_1120.py new file mode 100644 index 000000000..b3b35a9fd --- /dev/null +++ b/src/pretix/api/migrations/0002_auto_20180604_1120.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-04 11:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0001_initial'), + ('pretixapi', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='oauthaccesstoken', + name='organizers', + field=models.ManyToManyField(to='pretixbase.Organizer'), + ), + migrations.AddField( + model_name='oauthgrant', + name='organizers', + field=models.ManyToManyField(to='pretixbase.Organizer'), + ), + ] diff --git a/src/pretix/api/migrations/__init__.py b/src/pretix/api/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/api/models.py b/src/pretix/api/models.py new file mode 100644 index 000000000..189d04efb --- /dev/null +++ b/src/pretix/api/models.py @@ -0,0 +1,70 @@ +from datetime import timedelta + +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from oauth2_provider.generators import ( + generate_client_id, generate_client_secret, +) +from oauth2_provider.models import ( + AbstractAccessToken, AbstractApplication, AbstractGrant, + AbstractRefreshToken, +) +from oauth2_provider.validators import validate_uris + + +class OAuthApplication(AbstractApplication): + name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False) + redirect_uris = models.TextField( + blank=False, validators=[validate_uris], + verbose_name=_("Redirection URIs"), + help_text=_("Allowed URIs list, space separated") + ) + client_id = models.CharField( + verbose_name=_("Client ID"), + max_length=100, unique=True, default=generate_client_id, db_index=True + ) + client_secret = models.CharField( + verbose_name=_("Client secret"), + max_length=255, blank=False, default=generate_client_secret, db_index=True + ) + active = models.BooleanField(default=True) + + def get_absolute_url(self): + return reverse("control:user.settings.oauth.app", kwargs={'pk': self.id}) + + def is_usable(self, request): + return self.active and super().is_usable(request) + + +class OAuthGrant(AbstractGrant): + application = models.ForeignKey( + OAuthApplication, on_delete=models.CASCADE + ) + organizers = models.ManyToManyField('pretixbase.Organizer') + + +class OAuthAccessToken(AbstractAccessToken): + source_refresh_token = models.OneToOneField( + # unique=True implied by the OneToOneField + 'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True, + related_name="refreshed_access_token" + ) + application = models.ForeignKey( + OAuthApplication, on_delete=models.CASCADE, blank=True, null=True, + ) + organizers = models.ManyToManyField('pretixbase.Organizer') + + def revoke(self): + self.expires = now() - timedelta(hours=1) + self.save(update_fields=['expires']) + + +class OAuthRefreshToken(AbstractRefreshToken): + application = models.ForeignKey( + OAuthApplication, on_delete=models.CASCADE) + access_token = models.OneToOneField( + OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True, + related_name="refresh_token" + ) diff --git a/src/pretix/api/oauth.py b/src/pretix/api/oauth.py new file mode 100644 index 000000000..88aac5972 --- /dev/null +++ b/src/pretix/api/oauth.py @@ -0,0 +1,45 @@ +from datetime import timedelta + +from django.utils import timezone +from oauth2_provider.exceptions import FatalClientError +from oauth2_provider.oauth2_validators import Grant, OAuth2Validator +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): + raise FatalClientError('No organizers selected.') + + expires = timezone.now() + timedelta( + seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) + g = Grant(application=request.client, user=request.user, code=code["code"], + expires=expires, redirect_uri=request.redirect_uri, + scope=" ".join(request.scopes)) + g.save() + g.organizers.add(*request.organizers.all()) + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + try: + grant = Grant.objects.get(code=code, application=client) + if not grant.is_expired(): + request.scopes = grant.scope.split(" ") + request.user = grant.user + request.organizers = grant.organizers.all() + return True + return False + + except Grant.DoesNotExist: + 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'): + raise FatalClientError('No organizers selected.') + 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) + return access_token diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 539cad2ac..34e8eab62 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -4,7 +4,9 @@ from django.apps import apps from django.conf.urls import include, url from rest_framework import routers -from .views import checkin, event, item, order, organizer, voucher, waitinglist +from .views import ( + checkin, event, item, oauth, order, organizer, voucher, waitinglist, +) router = routers.DefaultRouter() router.register(r'organizers', organizer.OrganizerViewSet) @@ -52,4 +54,7 @@ urlpatterns = [ include(question_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/checkinlists/(?P[^/]+)/', include(checkinlist_router.urls)), + url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"), + url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"), + url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"), ] diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 95ccefd4c..964d56862 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -16,7 +16,6 @@ from pretix.api.serializers.order import OrderPositionSerializer from pretix.api.views import RichOrderingFilter from pretix.api.views.order import OrderPositionFilter from pretix.base.models import Checkin, CheckinList, Order, OrderPosition -from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, perform_checkin, ) @@ -49,7 +48,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.checkinlist.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -63,7 +62,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.checkinlist.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -71,7 +70,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): instance.log_action( 'pretix.event.checkinlist.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 83ffdd3af..cb92fc110 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -12,7 +12,6 @@ from pretix.api.serializers.event import ( from pretix.api.views import ConditionalListView from pretix.base.models import Event, ItemCategory, TaxRule from pretix.base.models.event import SubEvent -from pretix.base.models.organizer import TeamAPIToken from pretix.helpers.dicts import merge_dicts @@ -39,7 +38,7 @@ class EventViewSet(viewsets.ModelViewSet): serializer.instance.log_action( log_action, user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -52,7 +51,7 @@ class EventViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.plugins.' + action, user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data={'plugin': module} ) @@ -61,7 +60,7 @@ class EventViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -70,7 +69,7 @@ class EventViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -115,7 +114,7 @@ class CloneEventViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -151,7 +150,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.taxrule.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -160,7 +159,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.taxrule.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -171,6 +170,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): instance.log_action( 'pretix.event.taxrule.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 3c6a336a2..27585f724 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -18,7 +18,6 @@ from pretix.base.models import ( Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, Quota, ) -from pretix.base.models.organizer import TeamAPIToken from pretix.helpers.dicts import merge_dicts @@ -54,7 +53,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.item.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -69,7 +68,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.item.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -82,7 +81,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): instance.log_action( 'pretix.event.item.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) @@ -114,7 +113,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): item.log_action( 'pretix.event.item.variation.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}, {'value': serializer.instance.value}) ) @@ -124,7 +123,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): serializer.instance.item.log_action( 'pretix.event.item.variation.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}, {'value': serializer.instance.value}) ) @@ -141,7 +140,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): instance.item.log_action( 'pretix.event.item.variation.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data={ 'value': instance.value, 'id': self.kwargs['pk'] @@ -175,7 +174,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): item.log_action( 'pretix.event.item.addons.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) @@ -184,7 +183,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): serializer.instance.base_item.log_action( 'pretix.event.item.addons.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) @@ -193,7 +192,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): instance.base_item.log_action( 'pretix.event.item.addons.removed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data={'category': instance.addon_category.pk} ) @@ -222,7 +221,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.category.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -236,7 +235,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.category.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -247,7 +246,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): instance.log_action( 'pretix.event.category.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) @@ -275,7 +274,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.question.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -289,7 +288,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.question.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -297,7 +296,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): instance.log_action( 'pretix.event.question.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) @@ -327,7 +326,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): q.log_action( 'pretix.event.question.option.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) @@ -336,7 +335,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): serializer.instance.question.log_action( 'pretix.event.question.option.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) @@ -344,7 +343,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): instance.question.log_action( 'pretix.event.question.option.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data={'id': instance.pk} ) super().perform_destroy(instance) @@ -374,14 +373,14 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.quota.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) if serializer.instance.subevent: serializer.instance.subevent.log_action( 'pretix.subevent.quota.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -397,7 +396,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.quota.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) if current_subevent == request_subevent: @@ -405,7 +404,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): current_subevent.log_action( 'pretix.subevent.quota.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) else: @@ -413,14 +412,14 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): request_subevent.log_action( 'pretix.subevent.quota.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) if current_subevent is not None: current_subevent.log_action( 'pretix.subevent.quota.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) serializer.instance.rebuild_cache() @@ -428,13 +427,13 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): instance.log_action( 'pretix.event.quota.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) if instance.subevent: instance.subevent.log_action( 'pretix.subevent.quota.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) diff --git a/src/pretix/api/views/oauth.py b/src/pretix/api/views/oauth.py new file mode 100644 index 000000000..51920495e --- /dev/null +++ b/src/pretix/api/views/oauth.py @@ -0,0 +1,92 @@ +import logging + +from django import forms +from django.conf import settings +from django.utils.translation import ugettext as _ +from oauth2_provider.exceptions import OAuthToolkitError +from oauth2_provider.forms import AllowForm +from oauth2_provider.views import ( + AuthorizationView as BaseAuthorizationView, + RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView, +) + +from pretix.api.models import OAuthApplication +from pretix.base.models import Organizer + +logger = logging.getLogger(__name__) + + +class OAuthAllowForm(AllowForm): + organizers = forms.ModelMultipleChoiceField( + queryset=Organizer.objects.none(), + widget=forms.CheckboxSelectMultiple + ) + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user') + super().__init__(*args, **kwargs) + self.fields['organizers'].queryset = Organizer.objects.filter( + pk__in=user.teams.values_list('organizer', flat=True)) + + +class AuthorizationView(BaseAuthorizationView): + template_name = "pretixcontrol/auth/oauth_authorization.html" + form_class = OAuthAllowForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['settings'] = settings + return ctx + + def create_authorization_response(self, request, scopes, credentials, allow, organizers): + credentials["organizers"] = organizers + return super().create_authorization_response(request, scopes, credentials, allow) + + def form_valid(self, form): + client_id = form.cleaned_data["client_id"] + application = OAuthApplication.objects.get(client_id=client_id) + credentials = { + "client_id": form.cleaned_data.get("client_id"), + "redirect_uri": form.cleaned_data.get("redirect_uri"), + "response_type": form.cleaned_data.get("response_type", None), + "state": form.cleaned_data.get("state", None), + } + scopes = form.cleaned_data.get("scope") + allow = form.cleaned_data.get("allow") + + try: + uri, headers, body, status = self.create_authorization_response( + request=self.request, scopes=scopes, credentials=credentials, allow=allow, + organizers=form.cleaned_data.get("organizers") + ) + except OAuthToolkitError as error: + return self.error_response(error, application) + + self.success_url = uri + logger.debug("Success url for the request: {0}".format(self.success_url)) + + msgs = [ + _('The application "{application_name}" has been authorized to access your account.').format( + application_name=application.name + ) + ] + self.request.user.send_security_notice(msgs) + self.request.user.log_action('pretix.user.oauth.authorized', user=self.request.user, data={ + 'application_id': application.pk, + 'application_name': application.name, + }) + + return self.redirect(self.success_url, application) + + +class TokenView(BaseTokenView): + pass + + +class RevokeTokenView(BaseRevokeTokenView): + pass diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 08c7426df..0f860c914 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -17,12 +17,14 @@ from rest_framework.filters import OrderingFilter from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response +from pretix.api.models import OAuthAccessToken from pretix.api.serializers.order import ( InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer, OrderSerializer, ) -from pretix.base.models import Invoice, Order, OrderPosition, Quota -from pretix.base.models.organizer import TeamAPIToken +from pretix.base.models import ( + Invoice, Order, OrderPosition, Quota, TeamAPIToken, +) from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, regenerate_invoice, @@ -124,7 +126,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): mark_order_paid( order, manual=True, user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=request.auth, ) except Quota.QuotaExceededException as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -151,7 +153,8 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): cancel_order( order, user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None, + oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, send_mail=send_mail ) return self.retrieve(request, [], **kwargs) @@ -172,7 +175,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): order.log_action( 'pretix.event.order.unpaid', user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=request.auth, ) return self.retrieve(request, [], **kwargs) @@ -189,7 +192,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): mark_order_expired( order, user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=request.auth, ) return self.retrieve(request, [], **kwargs) @@ -243,7 +246,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): new_date=new_date, force=force, user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=request.auth, ) return self.retrieve(request, [], **kwargs) except OrderError as e: @@ -263,7 +266,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): order.log_action( 'pretix.event.order.placed', user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=request.auth, ) order_placed.send(self.request.event, order=order) @@ -437,7 +440,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): 'invoice': inv.pk }, user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) return Response(status=204) @@ -460,6 +463,6 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): 'invoice': inv.pk }, user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) return Response(status=204) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index c62a66656..b2ab942a0 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -1,5 +1,6 @@ from rest_framework import viewsets +from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import OrganizerSerializer from pretix.base.models import Organizer @@ -14,6 +15,12 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): if self.request.user.is_authenticated(): if self.request.user.has_active_staff_session(self.request.session.session_key): return Organizer.objects.all() + elif isinstance(self.request.auth, OAuthAccessToken): + return Organizer.objects.filter( + pk__in=self.request.user.teams.values_list('organizer', flat=True) + ).filter( + pk__in=self.request.auth.organizers.values_list('pk', flat=True) + ) else: return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) else: diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index bdcc86c2f..73e7f8cf1 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -9,7 +9,6 @@ from rest_framework.filters import OrderingFilter from pretix.api.serializers.voucher import VoucherSerializer from pretix.base.models import Voucher -from pretix.base.models.organizer import TeamAPIToken class VoucherFilter(FilterSet): @@ -51,7 +50,7 @@ class VoucherViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.voucher.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -69,7 +68,7 @@ class VoucherViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.voucher.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, data=self.request.data ) @@ -80,6 +79,6 @@ class VoucherViewSet(viewsets.ModelViewSet): instance.log_action( 'pretix.voucher.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) diff --git a/src/pretix/api/views/waitinglist.py b/src/pretix/api/views/waitinglist.py index 8ab436bfd..36f6e8bf7 100644 --- a/src/pretix/api/views/waitinglist.py +++ b/src/pretix/api/views/waitinglist.py @@ -7,7 +7,7 @@ from rest_framework.filters import OrderingFilter from rest_framework.response import Response from pretix.api.serializers.waitinglist import WaitingListSerializer -from pretix.base.models import TeamAPIToken, WaitingListEntry +from pretix.base.models import WaitingListEntry from pretix.base.models.waitinglist import WaitingListException @@ -45,7 +45,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.orders.waitinglist.added', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) def perform_update(self, serializer): @@ -55,7 +55,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): serializer.instance.log_action( 'pretix.event.orders.waitinglist.changed', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) def perform_destroy(self, instance): @@ -65,7 +65,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): instance.log_action( 'pretix.event.orders.waitinglist.deleted', user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) super().perform_destroy(instance) @@ -74,7 +74,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): try: self.get_object().send_voucher( user=self.request.user, - api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + auth=self.request.auth, ) except WaitingListException as e: raise ValidationError(str(e)) diff --git a/src/pretix/base/migrations/0093_auto_20180528_1432.py b/src/pretix/base/migrations/0093_auto_20180528_1432.py index 3f0c9c84f..cb09cc9aa 100644 --- a/src/pretix/base/migrations/0093_auto_20180528_1432.py +++ b/src/pretix/base/migrations/0093_auto_20180528_1432.py @@ -21,7 +21,6 @@ def set_pids(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('pretixbase', '0092_auto_20180511_1224'), ] diff --git a/src/pretix/base/migrations/0094_auto_20180604_1119.py b/src/pretix/base/migrations/0094_auto_20180604_1119.py new file mode 100644 index 000000000..f6e0f514d --- /dev/null +++ b/src/pretix/base/migrations/0094_auto_20180604_1119.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-04 11:19 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0093_auto_20180528_1432'), + ('pretixapi', '0001_initial') + ] + + operations = [ + ] diff --git a/src/pretix/base/migrations/0095_auto_20180604_1129.py b/src/pretix/base/migrations/0095_auto_20180604_1129.py new file mode 100644 index 000000000..1dac5255b --- /dev/null +++ b/src/pretix/base/migrations/0095_auto_20180604_1129.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-04 11:29 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0094_auto_20180604_1119'), + ] + + operations = [ + migrations.AddField( + model_name='logentry', + name='oauth_application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + to='pretixapi.OAuthApplication'), + ), + ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 965cdedd2..541e9f129 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -25,13 +25,13 @@ class UserManager(BaseUserManager): model documentation to see what's so special about our user model. """ - def create_user(self, email: str, password: str=None, **kwargs): + def create_user(self, email: str, password: str = None, **kwargs): user = self.model(email=email, **kwargs) user.set_password(password) user.save() return user - def create_superuser(self, email: str, password: str=None): # NOQA + def create_superuser(self, email: str, password: str = None): # NOQA # Not used in the software but required by Django if password is None: raise Exception("You must provide a password") diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index 8b947c928..98abf7092 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -36,7 +36,7 @@ def cached_file_delete(sender, instance, **kwargs): class LoggingMixin: - def log_action(self, action, data=None, user=None, api_token=None, save=True): + def log_action(self, action, data=None, user=None, api_token=None, auth=None, save=True): """ Create a LogEntry object that is related to this object. See the LogEntry documentation for details. @@ -47,6 +47,8 @@ class LoggingMixin: """ from .log import LogEntry from .event import Event + from pretix.api.models import OAuthAccessToken, OAuthApplication + from .organizer import TeamAPIToken from ..notifications import get_all_notification_types from ..services.notifications import notify @@ -57,7 +59,18 @@ class LoggingMixin: event = self.event if user and not user.is_authenticated: user = None - logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, api_token=api_token) + + kwargs = {} + if isinstance(auth, OAuthAccessToken): + kwargs['oauth_application'] = auth.application + elif isinstance(auth, OAuthApplication): + kwargs['oauth_application'] = auth + elif isinstance(auth, TeamAPIToken): + kwargs['api_token'] = auth + elif isinstance(api_token, TeamAPIToken): + kwargs['api_token'] = api_token + + logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs) if data: logentry.data = json.dumps(data, cls=CustomJSONEncoder) if save: @@ -83,4 +96,4 @@ class LoggedModel(models.Model, LoggingMixin): return LogEntry.objects.filter( content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk - ).select_related('user', 'event') + ).select_related('user', 'event', 'oauth_application', 'api_token') diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 2dc38fdec..1f569e2ba 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -41,6 +41,7 @@ class LogEntry(models.Model): datetime = models.DateTimeField(auto_now_add=True, db_index=True) user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT) api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT) + oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT) event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL) action_type = models.CharField(max_length=255) data = models.TextField(default='{}') diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index e5be4e21e..6c812adbd 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -77,7 +77,7 @@ class WaitingListEntry(LoggedModel): WaitingListEntry.clean_itemvar(self.event, self.item, self.variation) WaitingListEntry.clean_subevent(self.event, self.subevent) - def send_voucher(self, quota_cache=None, user=None, api_token=None): + def send_voucher(self, quota_cache=None, user=None, auth=None): availability = ( self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache) if self.variation @@ -114,8 +114,8 @@ class WaitingListEntry(LoggedModel): 'email': self.email, 'waitinglistentry': self.pk, 'subevent': self.subevent.pk if self.subevent else None, - }, user=user, api_token=api_token) - self.log_action('pretix.waitinglist.voucher', user=user, api_token=api_token) + }, user=user, auth=auth) + self.log_action('pretix.waitinglist.voucher', user=user, auth=auth) self.voucher = v self.save() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2b535e66c..423befe56 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -16,6 +16,7 @@ from django.utils.formats import date_format from django.utils.timezone import now from django.utils.translation import ugettext as _ +from pretix.api.models import OAuthApplication from pretix.base.i18n import ( LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language, ) @@ -80,7 +81,7 @@ logger = logging.getLogger(__name__) def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None, force: bool=False, send_mail: bool=True, user: User=None, mail_text='', - count_waitinglist=True, api_token=None) -> Order: + count_waitinglist=True, auth=None) -> Order: """ Marks an order as paid. This sets the payment provider, info and date and returns the order object. @@ -123,7 +124,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date 'date': date or now_dt, 'manual': manual, 'force': force - }, user=user, api_token=api_token) + }, user=user, auth=auth) order_paid.send(order.event, order=order) invoice = None @@ -173,7 +174,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date return order -def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, api_token=None): +def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None): """ Extends the deadline of an order. If the order is already expired, the quota will be checked to see if this is actually still possible. If ``force`` is set to ``True``, the result of this check @@ -187,7 +188,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User order.log_action( 'pretix.event.order.expirychanged', user=user, - api_token=api_token, + auth=auth, data={ 'expires': order.expires, 'state_change': False @@ -203,7 +204,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User order.log_action( 'pretix.event.order.expirychanged', user=user, - api_token=api_token, + auth=auth, data={ 'expires': order.expires, 'state_change': True @@ -237,24 +238,21 @@ def mark_order_refunded(order, user=None, api_token=None): @transaction.atomic -def mark_order_expired(order, user=None, api_token=None): +def mark_order_expired(order, user=None, auth=None): """ Mark this order as expired. This sets the payment status and returns the order object. :param order: The order to change :param user: The user that performed the change - :param api_token: The API token used to performed the change """ if isinstance(order, int): order = Order.objects.get(pk=order) if isinstance(user, int): user = User.objects.get(pk=user) - if isinstance(api_token, int): - api_token = TeamAPIToken.objects.get(pk=api_token) with order.event.lock(): order.status = Order.STATUS_EXPIRED order.save() - order.log_action('pretix.event.order.expired', user=user, api_token=api_token) + order.log_action('pretix.event.order.expired', user=user, auth=auth) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) @@ -263,7 +261,7 @@ def mark_order_expired(order, user=None, api_token=None): @transaction.atomic -def _cancel_order(order, user=None, send_mail: bool=True, api_token=None): +def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None): """ Mark this order as canceled :param order: The order to change @@ -275,13 +273,15 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None): user = User.objects.get(pk=user) if isinstance(api_token, int): api_token = TeamAPIToken.objects.get(pk=api_token) + if isinstance(oauth_application, int): + oauth_application = OAuthApplication.objects.get(pk=oauth_application) with order.event.lock(): if not order.cancel_allowed(): raise OrderError(_('You cannot cancel this order.')) order.status = Order.STATUS_CANCELED order.save() - order.log_action('pretix.event.order.canceled', user=user, api_token=api_token) + order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) @@ -1163,10 +1163,10 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str], @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None): +def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None): try: try: - return _cancel_order(order, user, send_mail, api_token) + return _cancel_order(order, user, send_mail, api_token, oauth_application) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index fd80f2480..3df1653a8 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -197,6 +197,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.user.settings.notifications.enabled': _('Notifications have been enabled.'), 'pretix.user.settings.notifications.disabled': _('Notifications have been disabled.'), 'pretix.user.settings.notifications.changed': _('Your notification settings have been changed.'), + 'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your ' + 'account.'), 'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'), 'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'), 'pretix.voucher.added': _('The voucher has been created.'), diff --git a/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html b/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html new file mode 100644 index 000000000..e592488a0 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/auth/oauth_authorization.html @@ -0,0 +1,51 @@ +{% extends "pretixcontrol/auth/base.html" %} +{% load bootstrap3 %} +{% load staticfiles %} +{% load i18n %} +{% block content %} + {% if not error %} + + {% else %} + + {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/email/security_notice.txt b/src/pretix/control/templates/pretixcontrol/email/security_notice.txt index b0f7639fc..9568dd5d4 100644 --- a/src/pretix/control/templates/pretixcontrol/email/security_notice.txt +++ b/src/pretix/control/templates/pretixcontrol/email/security_notice.txt @@ -1,4 +1,4 @@ -{% load i18n %}{% blocktrans with url=url|safe %}Hello, +{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello, this is to inform you that the account information of your pretix account has been changed. In particular, the following changes have been performed: diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 057d0c20c..59d504464 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -124,6 +124,13 @@ {% endif %} {{ log.user.get_full_name }} + {% if log.oauth_application %} +
+ {{ log.oauth_application.name }} + {% endif %} + {% elif log.api_token %} + + {{ log.api_token.name }} {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/logs.html b/src/pretix/control/templates/pretixcontrol/event/logs.html index b141f18eb..3c8555b66 100644 --- a/src/pretix/control/templates/pretixcontrol/event/logs.html +++ b/src/pretix/control/templates/pretixcontrol/event/logs.html @@ -16,7 +16,8 @@ {% for up in userlist %} {% if up.user__id %} - {% endif %} @@ -42,13 +43,20 @@ {% if log.user %} {% if log.user.is_staff %} + data-toggle="tooltip" + title="{% trans "This change was performed by a pretix administrator." %}"> {% else %} {% endif %} {{ log.user.get_full_name }} + {% if log.oauth_application %} +
+ {{ log.oauth_application.name }} + {% endif %} + {% elif log.api_token %} + + {{ log.api_token.name }} {% endif %}
@@ -61,7 +69,7 @@
- {% empty %} + {% empty %}
{% trans "No results" %}
diff --git a/src/pretix/control/templates/pretixcontrol/includes/logs.html b/src/pretix/control/templates/pretixcontrol/includes/logs.html index 01f131c8f..624efb8ce 100644 --- a/src/pretix/control/templates/pretixcontrol/includes/logs.html +++ b/src/pretix/control/templates/pretixcontrol/includes/logs.html @@ -15,6 +15,13 @@ {% endif %} {{ log.user.get_full_name }} + {% if log.oauth_application %} + + {{ log.oauth_application.name }} + {% endif %} + {% elif log.api_token %} + + {{ log.api_token.name }} {% endif %} {% if log.shredded %} {% trans "Disable application" %} +
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to disable the application {{ application }} permanently?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/app_list.html b/src/pretix/control/templates/pretixcontrol/oauth/app_list.html new file mode 100644 index 000000000..b44320b4e --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/app_list.html @@ -0,0 +1,50 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Your applications" %}{% endblock %} +{% block content %} +

{% trans "Your applications" %}

+ {% if applications %} +
+ + + + + + + + + {% for application in applications %} + + + + + {% endfor %} + +
{% trans "Name" %}
{{ application.name }} + + + +
+
+

+ + + {% trans "Create new application" %} + +

+ {% else %} +
+

+ {% blocktrans trimmed %} + No applications registered yet. + {% endblocktrans %} +

+ + + {% trans "Register a new application" %} + +
+ {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/app_register.html b/src/pretix/control/templates/pretixcontrol/oauth/app_register.html new file mode 100644 index 000000000..527ff417a --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/app_register.html @@ -0,0 +1,16 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Register a new application" %}{% endblock %} +{% block content %} +

{% trans "Register a new application" %}

+
+ {% csrf_token %} + {% bootstrap_form form layout='control' %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html b/src/pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html new file mode 100644 index 000000000..c4385d842 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Generate new application secret" %}{% endblock %} +{% block content %} +

{% trans "Generate new application secret" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to generate a new client secret for the application {{ application }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/app_update.html b/src/pretix/control/templates/pretixcontrol/oauth/app_update.html new file mode 100644 index 000000000..d65f3e4cf --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/app_update.html @@ -0,0 +1,16 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Update an application" %}{% endblock %} +{% block content %} +

{% trans "Update an application" %}

+
+ {% csrf_token %} + {% bootstrap_form form layout='control' %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/auth_revoke.html b/src/pretix/control/templates/pretixcontrol/oauth/auth_revoke.html new file mode 100644 index 000000000..dba1bd4d8 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/auth_revoke.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Revoke access" %}{% endblock %} +{% block content %} +

{% trans "Revoke access" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to revoke access to your account for the application {{ application }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/oauth/authorized.html b/src/pretix/control/templates/pretixcontrol/oauth/authorized.html new file mode 100644 index 000000000..4b13d1673 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/oauth/authorized.html @@ -0,0 +1,65 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Authorized applications" %}{% endblock %} +{% block content %} +

{% trans "Authorized applications" %}

+

+ + {% trans "Manage your own apps" %} + +

+ {% if tokens %} +
+ + + + + + + + + + + {% for token in tokens %} + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Permissions" %}{% trans "Organizers" %}
{{ token.application.name }} +
    + {% for scope in token.scopes_descriptions %} +
  • + {{ scope }} +
  • + {% endfor %} +
+
+ + + {% trans "Revoke access" %} +
+
+ {% else %} +
+

+ {% blocktrans trimmed %} + No applications have access to your pretix account. + {% endblocktrans %} +

+
+ {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/settings.html b/src/pretix/control/templates/pretixcontrol/user/settings.html index cbb155f3f..8daabfb9f 100644 --- a/src/pretix/control/templates/pretixcontrol/user/settings.html +++ b/src/pretix/control/templates/pretixcontrol/user/settings.html @@ -54,7 +54,16 @@
- + + +
+
+
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 3cb8473e3..37f406bb9 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -1,9 +1,9 @@ from django.conf.urls import include, url from pretix.control.views import ( - auth, checkin, dashboards, event, global_settings, item, main, orders, - organizer, pdf, search, shredder, subevents, typeahead, user, users, - vouchers, waitinglist, + auth, checkin, dashboards, event, global_settings, item, main, oauth, + orders, organizer, pdf, search, shredder, subevents, typeahead, user, + users, vouchers, waitinglist, ) urlpatterns = [ @@ -35,6 +35,20 @@ urlpatterns = [ url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'), url(r'^settings/notifications/off/(?P\d+)/(?P[^/]+)/$', user.UserNotificationsDisableView.as_view(), name='user.settings.notifications.off'), + url(r'^settings/oauth/authorized/$', oauth.AuthorizationListView.as_view(), + name='user.settings.oauth.list'), + url(r'^settings/oauth/authorized/(?P\d+)/revoke$', oauth.AuthorizationRevokeView.as_view(), + name='user.settings.oauth.revoke'), + url(r'^settings/oauth/apps/$', oauth.OAuthApplicationListView.as_view(), + name='user.settings.oauth.apps'), + url(r'^settings/oauth/apps/add$', oauth.OAuthApplicationRegistrationView.as_view(), + name='user.settings.oauth.apps.register'), + url(r'^settings/oauth/apps/(?P\d+)/$', oauth.OAuthApplicationUpdateView.as_view(), + name='user.settings.oauth.app'), + url(r'^settings/oauth/apps/(?P\d+)/disable$', oauth.OAuthApplicationDeleteView.as_view(), + name='user.settings.oauth.app.disable'), + url(r'^settings/oauth/apps/(?P\d+)/roll$', oauth.OAuthApplicationRollView.as_view(), + name='user.settings.oauth.app.roll'), url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'), url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'), url(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'), diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 6ec8375ad..f1fa8b324 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -250,7 +250,7 @@ def event_index(request, organizer, event): can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request) - qs = request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime') + qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application').order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order)) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 50321d3b9..df00a1d20 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -347,6 +347,7 @@ class EventSettingsFormView(EventPermissionRequiredMixin, FormView): for k in form.changed_data } ) + self.form_success() messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) else: @@ -824,7 +825,9 @@ class EventLog(EventPermissionRequiredMixin, ListView): paginate_by = 20 def get_queryset(self): - qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime') + qs = self.request.event.logentry_set.all().select_related( + 'user', 'content_type', 'api_token', 'oauth_application' + ).order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders', request=self.request): diff --git a/src/pretix/control/views/oauth.py b/src/pretix/control/views/oauth.py new file mode 100644 index 000000000..02877f1f8 --- /dev/null +++ b/src/pretix/control/views/oauth.py @@ -0,0 +1,140 @@ +import logging + +from django import forms +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import DetailView, ListView +from oauth2_provider.generators import generate_client_secret +from oauth2_provider.models import get_application_model +from oauth2_provider.scopes import get_scopes_backend +from oauth2_provider.views import ( + ApplicationDelete, ApplicationDetail, ApplicationList, + ApplicationRegistration, ApplicationUpdate, +) + +from pretix.api.models import ( + OAuthAccessToken, OAuthApplication, OAuthRefreshToken, +) + +logger = logging.getLogger(__name__) + + +class OAuthApplicationListView(ApplicationList): + template_name = 'pretixcontrol/oauth/app_list.html' + + def get_queryset(self): + return super().get_queryset().filter(active=True) + + +class OAuthApplicationRegistrationView(ApplicationRegistration): + template_name = 'pretixcontrol/oauth/app_register.html' + + def get_form_class(self): + return forms.modelform_factory( + get_application_model(), + fields=( + "name", "redirect_uris" + ) + ) + + def form_valid(self, form): + form.instance.client_type = 'confidential' + form.instance.authorization_grant_type = 'authorization-code' + return super().form_valid(form) + + +class ApplicationUpdateForm(forms.ModelForm): + class Meta: + model = OAuthApplication + fields = ("name", "client_id", "client_secret", "redirect_uris") + + def clean_client_id(self): + return self.instance.client_id + + def clean_client_secret(self): + return self.instance.client_secret + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['client_id'].widget.attrs['readonly'] = True + self.fields['client_secret'].widget.attrs['readonly'] = True + + +class OAuthApplicationUpdateView(ApplicationUpdate): + template_name = 'pretixcontrol/oauth/app_update.html' + + def get_form_class(self): + return ApplicationUpdateForm + + def get_queryset(self): + return super().get_queryset().filter(active=True) + + +class OAuthApplicationRollView(ApplicationDetail): + template_name = 'pretixcontrol/oauth/app_rollkeys.html' + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + messages.success(request, _('A new client secret has been generated and is now effective.')) + self.object.client_secret = generate_client_secret() + self.object.save() + return HttpResponseRedirect(self.object.get_absolute_url()) + + def get_queryset(self): + return super().get_queryset().filter(active=True) + + +class OAuthApplicationDeleteView(ApplicationDelete): + template_name = 'pretixcontrol/oauth/app_delete.html' + success_url = reverse_lazy("control:user.settings.oauth.apps") + + def get_queryset(self): + return super().get_queryset().filter(active=True) + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + self.object.active = False + self.object.save() + return HttpResponseRedirect(self.success_url) + + +class AuthorizationListView(ListView): + template_name = 'pretixcontrol/oauth/authorized.html' + context_object_name = 'tokens' + + def get_queryset(self): + return OAuthAccessToken.objects.filter( + user=self.request.user + ).select_related('application').prefetch_related('organizers') + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + all_scopes = get_scopes_backend().get_all_scopes() + for t in ctx['tokens']: + t.scopes_descriptions = [all_scopes[scope] for scope in t.scopes] + return ctx + + +class AuthorizationRevokeView(DetailView): + template_name = 'pretixcontrol/oauth/auth_revoke.html' + success_url = reverse_lazy("control:user.settings.oauth.list") + + def get_queryset(self): + return OAuthAccessToken.objects.filter(user=self.request.user) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['application'] = self.get_object().application + return ctx + + def post(self, request, *args, **kwargs): + o = self.get_object() + for rt in OAuthRefreshToken.objects.filter(access_token=o): + rt.revoke() + o.delete() + + messages.success(request, _('Access for the selected application has been revoked.')) + return redirect(self.success_url) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index af64e4a80..99b1718b4 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -250,6 +250,7 @@ INSTALLED_APPS = [ 'django_countries', 'hijack', 'compat', + 'oauth2_provider', ] try: @@ -275,6 +276,7 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'pretix.api.auth.token.TeamTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', @@ -590,3 +592,18 @@ AUTH_PASSWORD_VALIDATORS = [ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] +OAUTH2_PROVIDER_APPLICATION_MODEL = 'pretixapi.OAuthApplication' +OAUTH2_PROVIDER_GRANT_MODEL = 'pretixapi.OAuthGrant' +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'pretixapi.OAuthAccessToken' +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'pretixapi.OAuthRefreshToken' +OAUTH2_PROVIDER = { + 'SCOPES': { + 'read': _('Read access'), + 'write': _('Write access'), + }, + 'OAUTH2_VALIDATOR_CLASS': 'pretix.api.oauth.Validator', + 'ALLOWED_REDIRECT_URI_SCHEMES': ['https'] if not DEBUG else ['http', 'https'], + 'ACCESS_TOKEN_EXPIRE_SECONDS': 3600 * 24, + 'ROTATE_REFRESH_TOKEN': False, + +} diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 188909c96..3ebb48b73 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -98,6 +98,7 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d list-style: none; margin-left: 0; padding-left: 0; + margin-bottom: 0; } .question-option-row { diff --git a/src/requirements/production.txt b/src/requirements/production.txt index afa6f459d..07e5f480d 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -33,6 +33,7 @@ raven babel django-i18nfield>=1.2.1 django-hijack==2.1.* +django-oauth-toolkit==1.1.* # Stripe stripe==1.79.* # PayPal diff --git a/src/setup.py b/src/setup.py index ccc992168..c97478f91 100644 --- a/src/setup.py +++ b/src/setup.py @@ -110,7 +110,8 @@ setup( 'pyuca', 'defusedcsv', 'vat_moss==0.11.0', - 'django-hijack==2.1.*' + 'django-hijack==2.1.*', + 'django-oauth-toolkit==1.1.*', ], extras_require={ 'dev': [ diff --git a/src/tests/api/test_oauth.py b/src/tests/api/test_oauth.py new file mode 100644 index 000000000..0dba619c8 --- /dev/null +++ b/src/tests/api/test_oauth.py @@ -0,0 +1,586 @@ +import base64 +import json + +import pytest +from django.utils.http import urlquote +from django.utils.timezone import now + +from pretix.api.models import ( + OAuthAccessToken, OAuthApplication, OAuthGrant, OAuthRefreshToken, +) +from pretix.base.models import Event, Organizer, Team, User + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def event(organizer): + event = Event.objects.create( + organizer=organizer, name='Dummy', slug='dummy', + date_from=now() + ) + return event + + +@pytest.fixture +def admin_team(organizer): + return Team.objects.create(organizer=organizer, can_change_teams=True, name='Admin team', all_events=True, + can_create_events=True) + + +@pytest.fixture +def admin_user(admin_team): + u = User.objects.create_user('dummy@dummy.dummy', 'dummy') + admin_team.members.add(u) + return u + + +@pytest.fixture +def application(): + return OAuthApplication.objects.create( + name="pretalx", + redirect_uris="https://pretalx.com", + client_type='confidential', + authorization_grant_type='authorization-code' + ) + + +@pytest.mark.django_db +def test_authorize_require_login(client, application: OAuthApplication): + resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s' % ( + application.client_id, urlquote('https://example.org') + )) + assert resp.status_code == 302 + assert resp['Location'].startswith('/control/login') + + +@pytest.mark.django_db +def test_authorize_invalid_redirect_uri(client, admin_user, application: OAuthApplication): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s' % ( + application.client_id, urlquote('https://example.org') + )) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_authorize_missing_response_type(client, admin_user, application: OAuthApplication): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s' % ( + application.client_id, urlquote(application.redirect_uris) + )) + assert resp.status_code == 302 + assert resp['Location'] == 'https://pretalx.com?error=invalid_request&error_description=Missing+response_type+parameter.' + + +@pytest.mark.django_db +def test_authorize_require_organizer(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' % ( + application.client_id, urlquote(application.redirect_uris) + )) + assert resp.status_code == 200 + resp = client.post('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code' % ( + application.client_id, urlquote(application.redirect_uris) + ), data={ + 'redirect_uri': application.redirect_uris, + 'scope': 'read write', + 'client_id': application.client_id, + 'response_type': 'code', + 'allow': 'Authorize', + }) + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_authorize_denied(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' % ( + application.client_id, urlquote(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': 'read write', + 'client_id': application.client_id, + 'response_type': 'code', + }) + assert resp.status_code == 302 + assert resp['Location'] == 'https://pretalx.com?error=access_denied' + + +@pytest.mark.django_db +def test_authorize_disallow_response_token(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=token' % ( + application.client_id, urlquote(application.redirect_uris) + )) + assert resp.status_code == 302 + assert resp['Location'] == 'https://pretalx.com?error=unauthorized_client' + + +@pytest.mark.django_db +def test_authorize_read_scope(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' % ( + application.client_id, urlquote(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': 'read', + '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] + grant = OAuthGrant.objects.get(code=code) + assert list(grant.organizers.all()) == [organizer] + assert grant.scope == "read" + + +@pytest.mark.django_db +def test_authorize_state(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&state=asdadf' % ( + application.client_id, urlquote(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': 'read', + 'client_id': application.client_id, + 'response_type': 'code', + 'allow': 'Authorize', + 'state': 'asdadf' + }) + assert resp.status_code == 302 + assert 'state=asdadf' in resp['Location'] + + +@pytest.mark.django_db +def test_authorize_default_scope(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' % ( + application.client_id, urlquote(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': 'read write', + 'client_id': application.client_id, + 'response_type': 'code', + 'allow': 'Authorize', + }) + assert resp.status_code == 302 + + client.logout() + assert resp['Location'].startswith('https://pretalx.com?code=') + code = resp['Location'].split("=")[1] + grant = OAuthGrant.objects.get(code=code) + assert list(grant.organizers.all()) == [organizer] + assert grant.scope == "read write" + + +@pytest.mark.django_db +def test_token_from_code_without_auth(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' % ( + application.client_id, urlquote(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': 'read write', + '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', + }) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_token_from_code(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' % ( + application.client_id, urlquote(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': 'read write', + '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()) + assert data['expires_in'] == 86400 + assert data['token_type'] == "Bearer" + assert data['scope'] == "read write" + access_token = data['access_token'] + grant = OAuthAccessToken.objects.get(token=access_token) + assert list(grant.organizers.all()) == [organizer] + + +@pytest.mark.django_db +def test_use_token_for_access_one_organizer(client, admin_user, organizer, application: OAuthApplication): + o2 = Organizer.objects.create(name='A', slug='a') + t2 = Team.objects.create(organizer=o2, can_change_teams=True, name='Admin team', all_events=True) + t2.members.add(admin_user) + + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code' % ( + application.client_id, urlquote(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': 'read write', + '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 == 200 + data = json.loads(resp.content.decode()) + assert data == {'count': 1, 'next': None, 'previous': None, 'results': [{'name': 'Dummy', 'slug': 'dummy'}]} + resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 200 + resp = client.get('/api/v1/organizers/a/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_use_token_for_access_two_organizers(client, admin_user, organizer, application: OAuthApplication): + o2 = Organizer.objects.create(name='A', slug='a') + t2 = Team.objects.create(organizer=o2, can_change_teams=True, name='Admin team', all_events=True) + t2.members.add(admin_user) + + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code' % ( + application.client_id, urlquote(application.redirect_uris) + )) + assert resp.status_code == 200 + resp = client.post('/api/v1/oauth/authorize', data={ + 'organizers': [str(organizer.pk), str(o2.pk)], + 'redirect_uri': application.redirect_uris, + 'scope': 'read write', + '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 == 200 + data = json.loads(resp.content.decode()) + assert data == {'count': 2, 'next': None, 'previous': None, 'results': [ + {'name': 'A', 'slug': 'a'}, + {'name': 'Dummy', 'slug': 'dummy'}, + ]} + resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 200 + resp = client.get('/api/v1/organizers/a/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_token_refresh(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' % ( + application.client_id, urlquote(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': 'read write', + '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()) + refresh_token = data['refresh_token'] + access_token = data['access_token'] + resp = client.post('/api/v1/oauth/token', data={ + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( + ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) + assert resp.status_code == 200 + assert not OAuthAccessToken.objects.filter(token=access_token).exists() # old token revoked + data = json.loads(resp.content.decode()) + access_token = data['access_token'] + grant = OAuthAccessToken.objects.get(token=access_token) + assert list(grant.organizers.all()) == [organizer] + + +@pytest.mark.django_db +def test_allow_write(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' % ( + application.client_id, urlquote(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': 'read write', + '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.post('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_allow_read_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' % ( + application.client_id, urlquote(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': 'read', + '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.post('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_token_revoke_refresh_token(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' % ( + application.client_id, urlquote(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': 'read write', + '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()) + refresh_token = data['refresh_token'] + access_token = data['access_token'] + resp = client.post('/api/v1/oauth/revoke_token', data={ + 'token': refresh_token, + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( + ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) + assert resp.status_code == 200 + assert not OAuthAccessToken.objects.get(token=access_token).is_valid() + assert not OAuthRefreshToken.objects.filter(token=refresh_token, revoked__isnull=True).exists() + resp = client.post('/api/v1/oauth/token', data={ + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( + ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_token_revoke_access_token(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' % ( + application.client_id, urlquote(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': 'read write', + '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()) + refresh_token = data['refresh_token'] + access_token = data['access_token'] + resp = client.post('/api/v1/oauth/revoke_token', data={ + 'token': access_token, + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( + ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) + assert resp.status_code == 200 + assert not OAuthAccessToken.objects.get(token=access_token).is_valid() # old token revoked + + resp = client.post('/api/v1/oauth/token', data={ + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + }, 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'] + grant = OAuthAccessToken.objects.get(token=access_token) + assert list(grant.organizers.all()) == [organizer] + + +@pytest.mark.django_db +def test_user_revoke(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' % ( + application.client_id, urlquote(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': 'read write', + '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()) + refresh_token = data['refresh_token'] + access_token = data['access_token'] + + at = OAuthAccessToken.objects.get(token=access_token) + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/settings/oauth/authorized/{}/revoke'.format(at.pk), data={ + }) + assert resp.status_code == 302 + client.logout() + assert not OAuthAccessToken.objects.filter(token=access_token).exists() + assert OAuthRefreshToken.objects.get(token=refresh_token).revoked + + resp = client.post('/api/v1/oauth/token', data={ + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + }, HTTP_AUTHORIZATION='Basic ' + base64.b64encode( + ('%s:%s' % (application.client_id, application.client_secret)).encode()).decode()) + assert resp.status_code == 401 diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index f4151c7b0..67f0b11a5 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -73,6 +73,9 @@ def logged_in_client(client, event): ('/control/', 200), ('/control/settings/2fa/', 302), ('/control/settings/history/', 200), + ('/control/settings/oauth/authorized/', 200), + ('/control/settings/oauth/apps/', 200), + ('/control/settings/oauth/apps/add', 200), ('/control/global/settings/', 200), ('/control/global/update/', 200),