From cdb14a1e2c9a9560592dbefdd8d2d9c5a2aff9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Fri, 23 Nov 2018 22:28:04 +0100 Subject: [PATCH] Session API --- src/c3nav/api/api.py | 96 ++++++++++++++++++++++++ src/c3nav/api/migrations/0001_initial.py | 31 ++++++++ src/c3nav/api/migrations/__init__.py | 0 src/c3nav/api/models.py | 56 ++++++++++++++ src/c3nav/api/urls.py | 5 ++ 5 files changed, 188 insertions(+) create mode 100644 src/c3nav/api/api.py create mode 100644 src/c3nav/api/migrations/0001_initial.py create mode 100644 src/c3nav/api/migrations/__init__.py create mode 100644 src/c3nav/api/models.py diff --git a/src/c3nav/api/api.py b/src/c3nav/api/api.py new file mode 100644 index 00000000..f3631043 --- /dev/null +++ b/src/c3nav/api/api.py @@ -0,0 +1,96 @@ +from django.contrib.auth import login, logout +from django.contrib.auth.forms import AuthenticationForm +from django.middleware import csrf +from django.utils.translation import ugettext_lazy as _ +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.exceptions import ParseError, PermissionDenied +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet + +from c3nav.api.models import Token + + +class SessionViewSet(ViewSet): + """ + Session for Login, Logout, etc… + Don't forget to set X-Csrftoken for POST requests! + + /login – POST with fields token or username and password to log in + /get_token – POST with fields username and password to get a login token + /logout - POST to log out + """ + def list(self, request, *args, **kwargs): + return Response({ + 'is_authenticated': request.user.is_authenticated, + 'csrf_token': csrf.get_token(request), + }) + + @action(detail=False, methods=['post']) + def login(self, request, *args, **kwargs): + # django-rest-framework doesn't do this for logged out requests + SessionAuthentication().enforce_csrf(request) + + if request.user.is_authenticated: + return ParseError(_('Log out first.')) + + try: + data = request.json_body + except AttributeError: + data = request.POST + + if 'token' in data: + try: + token = Token.get_by_token(data['token']) + except Token.DoesNotExist: + raise PermissionDenied(_('This token does not exist or is no longer valid.')) + user = token.user + elif 'username' in data: + form = AuthenticationForm(request, data=data) + if not form.is_valid(): + raise ParseError(form.errors) + user = form.user_cache + else: + raise ParseError(_('You need to send a token or username and password.')) + + login(request, user) + + return Response({ + 'detail': _('Login successful.'), + 'csrf_token': csrf.get_token(request), + }) + + @action(detail=False, methods=['post']) + def get_token(self, request, *args, **kwargs): + # django-rest-framework doesn't do this for logged out requests + SessionAuthentication().enforce_csrf(request) + + try: + data = request.json_body + except AttributeError: + data = request.POST + + form = AuthenticationForm(request, data=data) + if not form.is_valid(): + raise ParseError(form.errors) + + token = form.user_cache.login_tokens.create() + + return Response({ + 'token': token.get_token(), + }) + + @action(detail=False, methods=['post']) + def logout(self, request, *args, **kwargs): + # django-rest-framework doesn't do this for logged out requests + SessionAuthentication().enforce_csrf(request) + + if not request.user.is_authenticated: + return ParseError(_('Not logged in.')) + + logout(request) + + return Response({ + 'detail': _('Logout successful.'), + 'csrf_token': csrf.get_token(request), + }) diff --git a/src/c3nav/api/migrations/0001_initial.py b/src/c3nav/api/migrations/0001_initial.py new file mode 100644 index 00000000..dc093996 --- /dev/null +++ b/src/c3nav/api/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1.1 on 2018-11-23 21:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('secret', models.CharField(max_length=64, verbose_name='secret')), + ('session_auth_hash', models.CharField(max_length=128, verbose_name='session auth hash')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'login tokens', + 'verbose_name_plural': 'login tokens', + 'default_related_name': 'login_tokens', + }, + ), + ] diff --git a/src/c3nav/api/migrations/__init__.py b/src/c3nav/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/c3nav/api/models.py b/src/c3nav/api/models.py new file mode 100644 index 00000000..d908a25f --- /dev/null +++ b/src/c3nav/api/models.py @@ -0,0 +1,56 @@ +import string + +from django.conf import settings +from django.db import models +from django.utils.crypto import constant_time_compare, get_random_string +from django.utils.translation import ugettext_lazy as _ + + +class Token(models.Model): + """ + Token for log in via API + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + secret = models.CharField(max_length=64, verbose_name=_('secret')) + session_auth_hash = models.CharField(_('session auth hash'), max_length=128) + + class Meta: + verbose_name = _('login tokens') + verbose_name_plural = _('login tokens') + default_related_name = 'login_tokens' + + def save(self, *args, **kwargs): + if not self.secret: + self.secret = get_random_string(64, string.ascii_letters + string.digits) + if not self.session_auth_hash: + # noinspection PyUnresolvedReferences + self.session_auth_hash = self.user.get_session_auth_hash() + super().save(*args, **kwargs) + + def get_token(self): + return '%d:%s' % (self.pk, self.secret) + + def verify(self): + # noinspection PyUnresolvedReferences + return constant_time_compare( + self.session_auth_hash, + self.user.get_session_auth_hash() + ) + + @classmethod + def get_by_token(cls, token: str): + try: + pk, secret = token.split(':', 1) + except ValueError: + raise cls.DoesNotExist + + if not pk.isdigit() or not secret: + raise cls.DoesNotExist + + obj = cls.objects.select_related('user').get(pk=pk, secret=secret) + + if not obj.verify(): + obj.delete() + raise cls.DoesNotExist + + return obj diff --git a/src/c3nav/api/urls.py b/src/c3nav/api/urls.py index 9ec622f0..481dd772 100644 --- a/src/c3nav/api/urls.py +++ b/src/c3nav/api/urls.py @@ -8,6 +8,7 @@ from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.routers import SimpleRouter +from c3nav.api.api import SessionViewSet from c3nav.editor.api import ChangeSetViewSet, EditorViewSet from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionViewSet, AreaViewSet, BuildingViewSet, ColumnViewSet, CrossDescriptionViewSet, DoorViewSet, HoleViewSet, @@ -49,6 +50,7 @@ router.register(r'routing', RoutingViewSet, base_name='routing') router.register(r'editor', EditorViewSet, base_name='editor') router.register(r'changesets', ChangeSetViewSet) +router.register(r'session', SessionViewSet, base_name='session') class APIRoot(GenericAPIView): @@ -77,6 +79,9 @@ class APIRoot(GenericAPIView): continue if name == 'editor-detail': name = 'editor-api' + elif base == 'session': + if name == 'session-list': + name = 'session-info' if '-' in name: urls.setdefault(base, OrderedDict())[name.split('-', 1)[1]] = url else: