From de95fb2a91ebfc977cc0f53ba1b4519e55088cfb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 23 Oct 2013 12:01:24 +0200 Subject: [PATCH] Fix #17 - Implemented stateless, oauth2 like authentication. --- greenmine/base/auth/__init__.py | 70 +++++++++++++++++++++++++ greenmine/base/auth/api.py | 35 +++++++++++++ greenmine/base/auth/tests/__init__.py | 0 greenmine/base/auth/tests/tests_auth.py | 43 +++++++++++++++ greenmine/base/middleware.py | 3 +- greenmine/base/users/api.py | 28 ---------- greenmine/base/users/auth.py | 24 --------- greenmine/routers.py | 3 +- greenmine/settings/common.py | 10 +++- 9 files changed, 159 insertions(+), 57 deletions(-) create mode 100644 greenmine/base/auth/__init__.py create mode 100644 greenmine/base/auth/api.py create mode 100644 greenmine/base/auth/tests/__init__.py create mode 100644 greenmine/base/auth/tests/tests_auth.py delete mode 100644 greenmine/base/users/auth.py diff --git a/greenmine/base/auth/__init__.py b/greenmine/base/auth/__init__.py new file mode 100644 index 00000000..193b4d67 --- /dev/null +++ b/greenmine/base/auth/__init__.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +import base64 +import re + +from django.core import signing +from django.db.models import get_model +from rest_framework.authentication import BaseAuthentication + +import greenmine.base.exceptions as exc + + +class Session(BaseAuthentication): + """ + Same as rest_framework.authentication.SessionAuthentication + but without csrf. + """ + + def authenticate(self, request): + """ + Returns a `User` if the request session currently has a logged in user. + Otherwise returns `None`. + """ + + http_request = request._request + user = getattr(http_request, 'user', None) + + if not user or not user.is_active: + return None + + return (user, None) + + +def get_token_for_user(user): + data = {"user_id": user.id} + return signing.dumps(data) + + +def get_user_for_token(token): + data = signing.loads(token) + model_cls = get_model("users", "User") + try: + user = model_cls.objects.get(pk=data["user_id"]) + except model_cls.DoesNotExist: + raise exc.BadRequest("Invalid token") + else: + return user + + +class Token(BaseAuthentication): + """ + Stateless authentication system partially based on oauth. + """ + + auth_rx = re.compile(r"^Bearer (.+)$") + + def authenticate(self, request): + if "HTTP_AUTHORIZATION" not in request.META: + return None + + token_rx_match = self.auth_rx.search(request.META["HTTP_AUTHORIZATION"]) + if not token_rx_match: + return None + + token = token_rx_match.group(1) + user = get_user_for_token(token) + return (user, token) + + def authenticate_header(self, request): + return 'Bearer realm="api"' diff --git a/greenmine/base/auth/api.py b/greenmine/base/auth/api.py new file mode 100644 index 00000000..cbeafd83 --- /dev/null +++ b/greenmine/base/auth/api.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +from django.db.models.loading import get_model +from django.contrib.auth import logout, login, authenticate + +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework import status, viewsets + +from greenmine.base import exceptions as exc +from greenmine.base import auth + +from greenmine.base.users.models import User, Role +from greenmine.base.users.serializers import UserSerializer + + +class AuthViewSet(viewsets.ViewSet): + permission_classes = (AllowAny,) + + def create(self, request, **kwargs): + username = request.DATA.get('username', None) + password = request.DATA.get('password', None) + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exc.BadRequest("Invalid username or password") + + if not user.check_password(password): + raise exc.BadRequest("Invalid username or password") + + serializer = UserSerializer(user) + response_data = serializer.data + response_data["auth_token"] = auth.get_token_for_user(user) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/greenmine/base/auth/tests/__init__.py b/greenmine/base/auth/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greenmine/base/auth/tests/tests_auth.py b/greenmine/base/auth/tests/tests_auth.py new file mode 100644 index 00000000..c39878e8 --- /dev/null +++ b/greenmine/base/auth/tests/tests_auth.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + +from django.core.urlresolvers import reverse +from django.conf.urls import patterns, include, url +from django import test + +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from greenmine import urls +from greenmine.base import auth +from greenmine.base.users.tests import create_user + + +class TestAuthView(APIView): + authentication_classes = (auth.Token,) + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): + return Response("ok") + + +urls.urlpatterns += patterns("", + url(r'^test-api/v1/auth/', TestAuthView.as_view(), name="test-token-auth"), +) + + +class SimpleTokenAuthTests(test.TestCase): + def setUp(self): + self.user1 = create_user(1) + + def test_token_auth_01(self): + response = self.client.get(reverse("test-token-auth")) + self.assertEqual(response.status_code, 401) + + def test_token_auth_02(self): + token = auth.get_token_for_user(self.user1) + response = self.client.get(reverse("test-token-auth"), + HTTP_AUTHORIZATION="Bearer {}".format(token)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'"ok"') + diff --git a/greenmine/base/middleware.py b/greenmine/base/middleware.py index 79ff7364..6422fba1 100644 --- a/greenmine/base/middleware.py +++ b/greenmine/base/middleware.py @@ -24,7 +24,7 @@ COORS_ALLOWED_METHODS = getattr(settings, 'COORS_ALLOWED_METHODS', ['POST', 'GET', 'OPTIONS', 'PUT', 'DELETE', 'PATCH']) COORS_ALLOWED_HEADERS = getattr(settings, 'COORS_ALLOWED_HEADERS', ['Content-Type', 'X-Requested-With', - 'X-Session-Token', 'Accept-Encoding', + 'Authorization', 'Accept-Encoding', 'X-Disable-Pagination']) COORS_ALLOWED_CREDENTIALS = getattr(settings, 'COORS_ALLOWED_CREDENTIALS', True) @@ -43,7 +43,6 @@ class CoorsMiddleware(object): response = http.HttpResponse() self._populate_response(response) return response - return None def process_response(self, request, response): diff --git a/greenmine/base/users/api.py b/greenmine/base/users/api.py index bb787505..dc114c41 100644 --- a/greenmine/base/users/api.py +++ b/greenmine/base/users/api.py @@ -128,31 +128,3 @@ class UsersViewSet(ModelCrudViewSet): request.user.set_password(password) request.user.save(update_fields=["password"]) return Response(status=status.HTTP_204_NO_CONTENT) - - -class AuthViewSet(viewsets.ViewSet): - permission_classes = (AllowAny,) - - def create(self, request, **kwargs): - username = request.DATA.get('username', None) - password = request.DATA.get('password', None) - - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exc.BadRequest("Invalid username or password") - - if not user.check_password(password): - raise exc.BadRequest("Invalid username or password") - - user = authenticate(username=username, password=password) - login(request, user) - - serializer = UserSerializer(user) - response_data = serializer.data - response_data["auth_token"] = request.session.session_key - return Response(response_data) - - def destroy(self, request, pk=None): - logout(request) - return Response({}) diff --git a/greenmine/base/users/auth.py b/greenmine/base/users/auth.py deleted file mode 100644 index cd252ab5..00000000 --- a/greenmine/base/users/auth.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- - -from rest_framework.authentication import BaseAuthentication - - -class SessionAuthentication(BaseAuthentication): - """ - Same as rest_framework.authentication.SessionAuthentication - but without csrf. - """ - - def authenticate(self, request): - """ - Returns a `User` if the request session currently has a logged in user. - Otherwise returns `None`. - """ - - http_request = request._request - user = getattr(http_request, 'user', None) - - if not user or not user.is_active: - return None - - return (user, None) diff --git a/greenmine/routers.py b/greenmine/routers.py index cb46bba6..6ccb79a5 100644 --- a/greenmine/routers.py +++ b/greenmine/routers.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from greenmine.base import routers -from greenmine.base.users.api import AuthViewSet, RolesViewSet, UsersViewSet +from greenmine.base.auth.api import AuthViewSet +from greenmine.base.users.api import RolesViewSet, UsersViewSet from greenmine.base.searches.api import SearchViewSet from greenmine.projects.api import ProjectViewSet, MembershipViewSet from greenmine.projects.milestones.api import MilestoneViewSet diff --git a/greenmine/settings/common.py b/greenmine/settings/common.py index 0fe9d588..628c61a2 100644 --- a/greenmine/settings/common.py +++ b/greenmine/settings/common.py @@ -139,12 +139,17 @@ TEMPLATE_LOADERS = [ ] MIDDLEWARE_CLASSES = [ + # Common middlewares 'django.middleware.common.CommonMiddleware', 'django.middleware.locale.LocaleMiddleware', - 'greenmine.base.middleware.GreenmineSessionMiddleware', 'greenmine.base.middleware.CoorsMiddleware', + + # Only needed by django admin + 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + + # 'greenmine.base.middleware.GreenmineSessionMiddleware', 'reversion.middleware.RevisionMiddleware', ] @@ -270,7 +275,8 @@ MAX_SEARCH_RESULTS = 100 REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'greenmine.base.users.auth.SessionAuthentication', + 'greenmine.base.auth.Token', + 'greenmine.base.auth.Session', ), 'FILTER_BACKEND': 'greenmine.base.filters.FilterBackend', 'PAGINATE_BY': 50,