diff --git a/requirements/dev.txt b/requirements/dev.txt index 025172814..fef4f122f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -50,4 +50,7 @@ pytest-xdist==1.31.0 cssselect==1.1.0 # PostgreSQL database adapter for the Python -psycopg2-binary==2.8.5 \ No newline at end of file +psycopg2-binary==2.8.5 + +# Automated generation of real Swagger/OpenAPI 2.0 schemas from Django REST Framework code. +drf-yasg==1.20.0 diff --git a/src/attendee/api/views.py b/src/attendee/api/views.py index 1bc7a7c1b..9dd91d4aa 100644 --- a/src/attendee/api/views.py +++ b/src/attendee/api/views.py @@ -4,12 +4,12 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from core.authentication import TokenAuthentication +from core.authentication import BearerAuthentication from attendee.models import Attendee class AttendeeAPIView(views.APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] model = Attendee diff --git a/src/core/authentication.py b/src/core/authentication.py index 0ea33615f..5c23fb6ff 100644 --- a/src/core/authentication.py +++ b/src/core/authentication.py @@ -1,7 +1,48 @@ from rest_framework.authentication import TokenAuthentication - +from rest_framework import HTTP_HEADER_ENCODING, exceptions +from django.utils.translation import gettext_lazy as _ from .models import Token -class TokenAuthentication(TokenAuthentication): +class BearerAuthentication(TokenAuthentication): + keyword = 'Bearer' model = Token + + def get_model(self): + if self.model is not None: + return self.model + from rest_framework.authtoken.models import Token + return Token + + def get_authorization_header(self, request): + """ + Return request's 'Authorization:' header, as a bytestring. + Hide some test client ickyness where the header can be unicode. + """ + auth = request.META.get('HTTP_AUTHORIZATION', b'') + if isinstance(auth, str): + # Work around django test client oddness + auth = auth.encode(HTTP_HEADER_ENCODING) + return auth + + def authenticate(self, request): + auth = self.get_authorization_header(request).split() + + if not auth: + return None + + token = auth[0].decode() + + return self.authenticate_credentials(token) + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed(_('Invalid token.')) + + if not token.user.is_active: + raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) + + return (token.user, token) diff --git a/src/core/models.py b/src/core/models.py index c4d290275..e51459ffa 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -271,7 +271,7 @@ class Token(models.Model): key = models.CharField(_("Key"), max_length=40, primary_key=True) user = BigForeignKey( to=settings.AUTH_USER_MODEL, - related_name='auth_token', + related_name='%(app_label)s_auth_token', verbose_name=_('user'), on_delete=models.CASCADE, ) diff --git a/src/events/api/views.py b/src/events/api/views.py index 3494265c0..5211dd811 100644 --- a/src/events/api/views.py +++ b/src/events/api/views.py @@ -11,7 +11,7 @@ from django.http import Http404 from django.utils.timezone import make_naive -from core.authentication import TokenAuthentication +from core.authentication import BearerAuthentication from events.models import ( CustomEvent, Location, ProposedTalkEvent, ProposedTutorialEvent, SponsoredEvent, Time, KeynoteEvent @@ -54,7 +54,7 @@ def get_queryset(self): class SpeechListAPIView(APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] def get(self, request, *args, **kwargs): @@ -81,7 +81,7 @@ def get(self, request, *args, **kwargs): class SpeechListByCategoryAPIView(APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] def get(self, request, *args, **kwargs): @@ -110,7 +110,7 @@ class TutorialDetailAPIView(RetrieveAPIView): class SpeechDetailAPIView(APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] def get(self, request, *args, **kwargs): @@ -263,7 +263,7 @@ def display(self): class ScheduleAPIView(APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] event_querysets = [ @@ -344,7 +344,7 @@ def get(self, request): class KeynoteEventListAPIView(ListAPIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] queryset = KeynoteEvent.objects.all() diff --git a/src/events/tests/api/test_list_speeches.py b/src/events/tests/api/test_list_speeches.py index ae5069403..f0426ef67 100644 --- a/src/events/tests/api/test_list_speeches.py +++ b/src/events/tests/api/test_list_speeches.py @@ -38,7 +38,7 @@ def test_list_speeches_by_category(category, bare_user, drf_api_client): url = reverse("events:speeches-category", kwargs={"category": category}) token = Token.objects.get_or_create(user=bare_user) - drf_api_client.credentials(HTTP_AUTHORIZATION="Token " + str(token[0])) + drf_api_client.credentials(HTTP_AUTHORIZATION=str(token[0])) response = drf_api_client.get(url) for event in response.json(): diff --git a/src/pycontw2016/settings/base.py b/src/pycontw2016/settings/base.py index 29de1d683..f18073081 100644 --- a/src/pycontw2016/settings/base.py +++ b/src/pycontw2016/settings/base.py @@ -103,7 +103,9 @@ 'sorl.thumbnail', 'registry', 'corsheaders', - 'rest_framework' + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', ) LOCAL_APPS = ( @@ -117,6 +119,38 @@ 'attendee' ) +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'core.authentication.BearerAuthentication' + ], + + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ] + +} + +SWAGGER_SETTINGS = { + 'SHOW_REQUEST_HEADERS': True, + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header', + }, + }, + 'USE_SESSION_AUTH': True, + 'JSON_EDITOR': True, + 'SUPPORTED_SUBMIT_METHODS': [ + 'get', + 'post', + 'put', + 'delete', + 'patch' + ], +} + INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS # Enable Postgres-specific things if we are using it. diff --git a/src/pycontw2016/urls.py b/src/pycontw2016/urls.py index e596e215e..baea6a991 100644 --- a/src/pycontw2016/urls.py +++ b/src/pycontw2016/urls.py @@ -4,11 +4,28 @@ from django.conf.urls.static import static from django.contrib import admin from django.views.i18n import set_language +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi from core.views import error_page, flat_page, index from users.views import user_dashboard +schema_view = get_schema_view( + openapi.Info( + title="Snippets API", + default_version='v1', + description="Test description", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@snippets.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + + urlpatterns = i18n_patterns( # Add top-level URL patterns here. @@ -35,7 +52,8 @@ url(r'^api/events/', include('events.api.urls', namespace="events")), url(r'^set-language/$', set_language, name='set_language'), url(r'^admin/', admin.site.urls), - url(r'^api/attendee/', include('attendee.api.urls')) + url(r'^api/attendee/', include('attendee.api.urls')), + url(r'^api/users/', include('users.api.urls')), ] # User-uploaded files like profile pics need to be served in development. @@ -45,3 +63,8 @@ if settings.DEBUG: import debug_toolbar urlpatterns += [url(r'^__debug__/', include(debug_toolbar.urls))] + urlpatterns += [ + url(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), + url(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + ] diff --git a/src/sponsors/api/views.py b/src/sponsors/api/views.py index 8436aea32..4b5a700b6 100644 --- a/src/sponsors/api/views.py +++ b/src/sponsors/api/views.py @@ -4,12 +4,12 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from core.authentication import TokenAuthentication +from core.authentication import BearerAuthentication from sponsors.models import Sponsor, OpenRole class SponsorAPIView(views.APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] def get(self, request): @@ -42,7 +42,7 @@ def get(self, request): class JobAPIView(views.APIView): - authentication_classes = [TokenAuthentication] + authentication_classes = [BearerAuthentication] permission_classes = [IsAuthenticated] def get(self, request): diff --git a/src/users/api/__init__.py b/src/users/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/users/api/serializers.py b/src/users/api/serializers.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/users/api/urls.py b/src/users/api/urls.py new file mode 100644 index 000000000..37d7e1212 --- /dev/null +++ b/src/users/api/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from users.api.views import CustomAuthToken + + +urlpatterns = [ + path("api-token-auth/", CustomAuthToken.as_view()), + +] diff --git a/src/users/api/views.py b/src/users/api/views.py new file mode 100644 index 000000000..d2ca6fc13 --- /dev/null +++ b/src/users/api/views.py @@ -0,0 +1,53 @@ +from rest_framework.response import Response +from rest_framework.authtoken.views import ObtainAuthToken + +from core.models import Token +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework import exceptions +from django.contrib.auth import get_user_model +from users.models import User +from datetime import datetime, timedelta + + +class CustomAuthToken(ObtainAuthToken): + @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['username', 'password'], + order=['username', 'password'], + properties={ + 'username': openapi.Schema(type=openapi.TYPE_STRING), + 'password': openapi.Schema(type=openapi.TYPE_STRING) + }, + ), + operation_description='Get account token' + ) + def post(self, request): + username = request.data['username'] + try: + user = get_user_model().objects.get(email=username) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed(('User matching query does not exist')) + + tokens = Token.objects.filter(user=user) + if len(tokens) == 0: + Token.objects.create(user=user) + + token = Token.objects.get(user=user) + token = str(token) + + token_create_time = Token.objects.get(key=token).created + pre_week_day = datetime.now(token_create_time.tzinfo) + timedelta(days=-7) + if token_create_time < pre_week_day: + Token.objects.get(key=token).delete() + Token.objects.create(user=user) + + serializer = self.serializer_class(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + + return Response({ + 'username': user.email, + 'token': token + })