From e982579711da3c7e7015b3c9d7dc0fb37e03c1bf Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Tue, 1 Oct 2019 14:41:36 +0200 Subject: [PATCH 01/18] Add public graphql endpoint --- server/api/public_schema.py | 25 ++++++++++++++ server/api/schema.py | 8 +++-- server/api/urls.py | 8 +++++ server/users/schema.py | 46 +++---------------------- server/users/schema_public.py | 63 +++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 44 deletions(-) create mode 100644 server/api/public_schema.py create mode 100644 server/users/schema_public.py diff --git a/server/api/public_schema.py b/server/api/public_schema.py new file mode 100644 index 00000000..f0c2abae --- /dev/null +++ b/server/api/public_schema.py @@ -0,0 +1,25 @@ +import graphene +from django.conf import settings +from graphene import relay +from graphene_django.debug import DjangoDebug + + +from users.schema_public import UsersQuery +from users.mutations import ProfileMutations + + +class Query(UsersQuery, graphene.ObjectType): + node = relay.Node.Field() + + if settings.DEBUG: + debug = graphene.Field(DjangoDebug, name='__debug') + + +# class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations, +# ProfileMutations, SurveysMutations, graphene.ObjectType): + + if settings.DEBUG: + debug = graphene.Field(DjangoDebug, name='__debug') + + +schema = graphene.Schema(query=Query) diff --git a/server/api/schema.py b/server/api/schema.py index 68436053..24953121 100644 --- a/server/api/schema.py +++ b/server/api/schema.py @@ -19,12 +19,14 @@ from surveys.schema import SurveysQuery from surveys.mutations import SurveysMutations from rooms.mutations import RoomMutations from rooms.schema import RoomsQuery, ModuleRoomsQuery -from users.schema import UsersQuery +from users.schema_public import UsersQuery +from users.schema import AllUsersQuery from users.mutations import ProfileMutations -class Query(UsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, StudentSubmissionQuery, - BasicKnowledgeQuery, PortfolioQuery, MyActivityQuery, SurveysQuery, graphene.ObjectType): +class Query(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, + StudentSubmissionQuery, BasicKnowledgeQuery, PortfolioQuery, MyActivityQuery, SurveysQuery, + graphene.ObjectType): node = relay.Node.Field() if settings.DEBUG: diff --git a/server/api/urls.py b/server/api/urls.py index 92f2eef5..b3002aea 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -1,13 +1,21 @@ from django.conf import settings from django.conf.urls import url from django.views.decorators.csrf import csrf_exempt +from graphene_django.views import GraphQLView + +from api.public_schema import schema from core.views import PrivateGraphQLView app_name = 'api' urlpatterns = [ + url(r'^graphql-public', csrf_exempt(GraphQLView.as_view(schema=schema))), url(r'^graphql', csrf_exempt(PrivateGraphQLView.as_view())), ] if settings.DEBUG: + urlpatterns += [url(r'^graphiql-public', csrf_exempt(GraphQLView.as_view(schema=schema, graphiql=True, + pretty=True)))] urlpatterns += [url(r'^graphiql', csrf_exempt(PrivateGraphQLView.as_view(graphiql=True, pretty=True)))] + + diff --git a/server/users/schema.py b/server/users/schema.py index 40aa2146..7b84653f 100644 --- a/server/users/schema.py +++ b/server/users/schema.py @@ -1,54 +1,18 @@ import graphene -from graphene import relay -from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField -from users.models import SchoolClass, User +from users.models import User +from users.schema_public import UserNode -class SchoolClassNode(DjangoObjectType): - pk = graphene.Int() - - class Meta: - model = SchoolClass - filter_fields = ['name'] - interfaces = (relay.Node,) - - def resolve_pk(self, *args, **kwargs): - return self.id - - -class UserNode(DjangoObjectType): - pk = graphene.Int() - permissions = graphene.List(graphene.String) - selected_class = graphene.Field(SchoolClassNode) - - class Meta: - model = User - filter_fields = ['username', 'email'] - only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module', 'avatar_url', - 'selected_class'] - interfaces = (relay.Node,) - - def resolve_pk(self, info, **kwargs): - return self.id - - def resolve_permissions(self, info): - return self.get_all_permissions() - - def resolve_selected_class(self, info): - return self.selected_class() - - -class UsersQuery(object): +class AllUsersQuery(object): me = graphene.Field(UserNode) all_users = DjangoFilterConnectionField(UserNode) - def resolve_me(self, info, **kwargs): - return info.context.user - def resolve_all_users(self, info, **kwargs): if not info.context.user.is_superuser: return User.objects.none() else: return User.objects.all() + + diff --git a/server/users/schema_public.py b/server/users/schema_public.py new file mode 100644 index 00000000..dfa23853 --- /dev/null +++ b/server/users/schema_public.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-01 +# @author: chrigu +import graphene +from graphene import relay +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from users.models import User, SchoolClass + + +class SchoolClassNode(DjangoObjectType): + pk = graphene.Int() + + class Meta: + model = SchoolClass + filter_fields = ['name'] + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + +class UserNode(DjangoObjectType): + pk = graphene.Int() + permissions = graphene.List(graphene.String) + selected_class = graphene.Field(SchoolClassNode) + + class Meta: + model = User + filter_fields = ['username', 'email'] + only_fields = ['username', 'email', 'first_name', 'last_name', 'school_classes', 'last_module', 'avatar_url', + 'selected_class'] + interfaces = (relay.Node,) + + def resolve_pk(self, info, **kwargs): + return self.id + + def resolve_permissions(self, info): + return self.get_all_permissions() + + def resolve_selected_class(self, info): + return self.selected_class() + + +class UsersQuery(object): + me = graphene.Field(UserNode) + all_users = DjangoFilterConnectionField(UserNode) + + def resolve_me(self, info, **kwargs): + return info.context.user + + def resolve_all_users(self, info, **kwargs): + if not info.context.user.is_superuser: + return User.objects.none() + else: + return User.objects.all() From 7d6a03743c3dab4595e92ed379bbc767aa71497e Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Tue, 1 Oct 2019 15:07:36 +0200 Subject: [PATCH 02/18] Add login endpoint --- .../{public_schema.py => schema_public.py} | 7 ++- server/api/urls.py | 4 +- server/users/mutations_public.py | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) rename server/api/{public_schema.py => schema_public.py} (59%) create mode 100644 server/users/mutations_public.py diff --git a/server/api/public_schema.py b/server/api/schema_public.py similarity index 59% rename from server/api/public_schema.py rename to server/api/schema_public.py index f0c2abae..69c972aa 100644 --- a/server/api/public_schema.py +++ b/server/api/schema_public.py @@ -5,7 +5,7 @@ from graphene_django.debug import DjangoDebug from users.schema_public import UsersQuery -from users.mutations import ProfileMutations +from users.mutations_public import UserMutations class Query(UsersQuery, graphene.ObjectType): @@ -15,11 +15,10 @@ class Query(UsersQuery, graphene.ObjectType): debug = graphene.Field(DjangoDebug, name='__debug') -# class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations, -# ProfileMutations, SurveysMutations, graphene.ObjectType): +class Mutation(UserMutations, graphene.ObjectType): if settings.DEBUG: debug = graphene.Field(DjangoDebug, name='__debug') -schema = graphene.Schema(query=Query) +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/server/api/urls.py b/server/api/urls.py index b3002aea..69beea15 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -3,7 +3,7 @@ from django.conf.urls import url from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView -from api.public_schema import schema +from api.schema_public import schema from core.views import PrivateGraphQLView @@ -15,7 +15,7 @@ urlpatterns = [ if settings.DEBUG: urlpatterns += [url(r'^graphiql-public', csrf_exempt(GraphQLView.as_view(schema=schema, graphiql=True, - pretty=True)))] + pretty=True)))] urlpatterns += [url(r'^graphiql', csrf_exempt(PrivateGraphQLView.as_view(graphiql=True, pretty=True)))] diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py new file mode 100644 index 00000000..0c186b4f --- /dev/null +++ b/server/users/mutations_public.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-01 +# @author: chrigu +import graphene +from django.contrib.auth import authenticate, login +from graphene import relay + + +class FieldError(graphene.ObjectType): + code = graphene.String() + + +class UpdateError(graphene.ObjectType): + field = graphene.String() + errors = graphene.List(FieldError) + + +class Login(relay.ClientIDMutation): + class Input: + username_input = graphene.String() + password_input = graphene.String() + + success = graphene.Boolean() + errors = graphene.List(UpdateError) # todo: change for consistency + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + + user = authenticate(username=kwargs.get('username_input'), password=kwargs.get('password_input')) + if user is not None: + login(info.context, user) + return cls(success=True, errors=[]) + else: + return cls(success=False, errors=['invalid_credentials']) + + +class UserMutations: + login = Login.Field() + + From 46518a22f838aa8d00172fc07cf34432d36dc89e Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 10:30:04 +0200 Subject: [PATCH 03/18] Add password reset mutations --- server/users/mutations_public.py | 98 +++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py index 0c186b4f..dd140a4f 100644 --- a/server/users/mutations_public.py +++ b/server/users/mutations_public.py @@ -9,14 +9,19 @@ # @author: chrigu import graphene from django.contrib.auth import authenticate, login +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.views import PasswordResetView, PasswordResetConfirmView, INTERNAL_RESET_URL_TOKEN from graphene import relay +from core import settings +from users.models import User + class FieldError(graphene.ObjectType): code = graphene.String() -class UpdateError(graphene.ObjectType): +class MutationError(graphene.ObjectType): field = graphene.String() errors = graphene.List(FieldError) @@ -27,7 +32,7 @@ class Login(relay.ClientIDMutation): password_input = graphene.String() success = graphene.Boolean() - errors = graphene.List(UpdateError) # todo: change for consistency + errors = graphene.List(MutationError) # todo: change for consistency @classmethod def mutate_and_get_payload(cls, root, info, **kwargs): @@ -40,7 +45,96 @@ class Login(relay.ClientIDMutation): return cls(success=False, errors=['invalid_credentials']) +class PasswordReset(relay.ClientIDMutation): + class Input: + email_input = graphene.String() + + success = graphene.Boolean() + errors = graphene.List(MutationError) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + email = kwargs.get('email_input') + try: + user = User.objects.get(email=email) # todo: make lowercase + except User.DoesNotExist: + return cls(success=False, errors=['invalid_email']) + + try: + password_reset_view = PasswordResetView(request=info.context) + form = password_reset_view.form_class({'email': user.email}) + form.is_valid() + password_reset_view.form_valid(form) + except Exception: + return cls(success=False, errors=['email_error']) + + return cls(success=True, errors=[]) + + +class PasswordConfirm: + @classmethod + def verify_token_and_uidb64(cls, token, uidb64, request): + password_reset_confirm_view = PasswordResetConfirmView(request=request) + return password_reset_confirm_view.dispatch(uidb64=uidb64, token=token, request=request) + + +class PasswordResetVerify(relay.ClientIDMutation, PasswordConfirm): + class Input: + token_input = graphene.String() + uidb64_input = graphene.String() + + success = graphene.Boolean() + errors = graphene.List(MutationError) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + uidb64 = kwargs.get('uidb64_input') + token = kwargs.get('token_input') + + http_response = cls.verify_token_and_uidb64(token, uidb64, info.context) + + # PasswordResetConfirmView returns a webpage if either token or uidb64 are invalid (HTTP 200) + # if token and uidb64 are correct a redirect is returned (HTTP302) + + if http_response.status_code == 302: + return cls(success=True, errors=[]) + else: + return cls(success=False, errors=['invalid_challenge']) + + +class PasswordResetSetPassword(relay.ClientIDMutation, PasswordConfirm): + class Input: + new_password_input = graphene.String() + confirm_new_password_input = graphene.String() + uidb64_input = graphene.String() + + success = graphene.Boolean() + errors = graphene.List(MutationError) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + uidb64 = kwargs.get('uidb64_input') + new_password = kwargs.get('new_password_input') + confirm_new_password_input = kwargs.get('confirm_new_password_input') + + # fake ordinary POST for view + info.context.POST = { + 'new_password1': new_password, + 'new_password2': confirm_new_password_input + } + + http_response = cls.verify_token_and_uidb64(INTERNAL_RESET_URL_TOKEN, uidb64, info.context) + + if http_response.status_code == 302: + return cls(success=True, errors=[]) + else: + return cls(success=False, errors=['invalid_passwords']) # todo: check error from form + + class UserMutations: login = Login.Field() + password_reset = PasswordReset.Field() + password_reset_verify = PasswordResetVerify.Field() + password_reset_set_password = PasswordResetSetPassword.Field() From 7e927539777bf75a2db169399583622fa75bdc6c Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 13:06:16 +0200 Subject: [PATCH 04/18] Add password reset tests --- server/users/mutations_public.py | 32 ++++- server/users/tests/test_password_reset.py | 136 ++++++++++++++++++++++ 2 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 server/users/tests/test_password_reset.py diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py index dd140a4f..2e43b0b5 100644 --- a/server/users/mutations_public.py +++ b/server/users/mutations_public.py @@ -7,6 +7,8 @@ # # Created on 2019-10-01 # @author: chrigu +import re + import graphene from django.contrib.auth import authenticate, login from django.contrib.auth.forms import PasswordResetForm @@ -60,10 +62,14 @@ class PasswordReset(relay.ClientIDMutation): except User.DoesNotExist: return cls(success=False, errors=['invalid_email']) + password_reset_view = PasswordResetView() + password_reset_view.request = info.context + form = password_reset_view.form_class({'email': user.email}) + + if not form.is_valid(): + return cls(success=False, errors=form.errors) + try: - password_reset_view = PasswordResetView(request=info.context) - form = password_reset_view.form_class({'email': user.email}) - form.is_valid() password_reset_view.form_valid(form) except Exception: return cls(success=False, errors=['email_error']) @@ -119,9 +125,12 @@ class PasswordResetSetPassword(relay.ClientIDMutation, PasswordConfirm): # fake ordinary POST for view info.context.POST = { - 'new_password1': new_password, - 'new_password2': confirm_new_password_input - } + 'new_password1': new_password, + 'new_password2': confirm_new_password_input + } + + if not cls.verify_strong_password(new_password): + return cls(success=False, errors=['invalid_passwords']) http_response = cls.verify_token_and_uidb64(INTERNAL_RESET_URL_TOKEN, uidb64, info.context) @@ -130,6 +139,17 @@ class PasswordResetSetPassword(relay.ClientIDMutation, PasswordConfirm): else: return cls(success=False, errors=['invalid_passwords']) # todo: check error from form + @classmethod + def verify_strong_password(cls, password): + if len(password) == 0: + return password + + has_number = re.search('\d', password) + has_upper = re.search('[A-Z]', password) + has_lower = re.search('[a-z]', password) + has_special = re.search('[!@#$%^&*(),.?":{}|<>\+]', password) + + return has_number and has_upper and has_lower and has_special class UserMutations: login = Login.Field() diff --git a/server/users/tests/test_password_reset.py b/server/users/tests/test_password_reset.py new file mode 100644 index 00000000..f9227177 --- /dev/null +++ b/server/users/tests/test_password_reset.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-02 +# @author: chrigu +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.contrib.sessions.middleware import SessionMiddleware +from django.core import mail +from django.test import TestCase, RequestFactory +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from graphene.test import Client + +from api.schema_public import schema +from core.factories import UserFactory + + +class PasswordResetTests(TestCase): + def setUp(self): + self.user = UserFactory(username='aschi') + + request = RequestFactory().post('/') + + # adding session + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + self.client = Client(schema=schema, context_value=request) + + def make_reset_mutation(self, email): + mutation = ''' + mutation PasswordReset($input: PasswordResetInput!){ + passwordReset(input: $input) { + success + errors { + field + } + } + } + ''' + + return self.client.execute(mutation, variables={ + 'input': { + 'emailInput': email + } + }) + + def make_set_verify_mutation(self, uidb64, token): + mutation = ''' + mutation PasswordResetVerify($input: PasswordResetVerifyInput!){ + passwordResetVerify(input: $input) { + success + errors { + field + } + } + } + ''' + + return self.client.execute(mutation, variables={ + 'input': { + 'uidb64Input': uidb64, + 'tokenInput': token + } + }) + + def make_set_password_mutation(self, uidb64, new_password, new_password_confirm): + mutation = ''' + mutation PasswordResetSetPassword($input: PasswordResetSetPasswordInput!){ + passwordResetSetPassword(input: $input) { + success + errors { + field + } + } + } + ''' + + return self.client.execute(mutation, variables={ + 'input': { + 'uidb64Input': uidb64, + 'newPasswordInput': new_password, + 'confirmNewPasswordInput': new_password_confirm, + } + }) + + def test_user_can_initiate_password(self): + result = self.make_reset_mutation(self.user.email) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject.startswith('Passwort auf'), True) + self.assertEqual(result.get('data').get('passwordReset').get('success'), True) + + def test_user_can_verify_and_set_password(self): + token_generator = PasswordResetTokenGenerator() + token = token_generator.make_token(self.user) + uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() + + result = self.make_set_verify_mutation(uidb64, token) + self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + + new_password = 'Abcd1234!' + + set_result = self.make_set_password_mutation(uidb64, new_password, new_password) + print(set_result) + self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), True) + + def test_user_cannot_use_unsafe_password(self): + token_generator = PasswordResetTokenGenerator() + token = token_generator.make_token(self.user) + uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() + + result = self.make_set_verify_mutation(uidb64, token) + self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + + new_password = 'test' + + set_result = self.make_set_password_mutation(uidb64, new_password, new_password) + self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), False) + + def test_new_passwords_must_match(self): + token_generator = PasswordResetTokenGenerator() + token = token_generator.make_token(self.user) + uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() + + result = self.make_set_verify_mutation(uidb64, token) + self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + + new_password = 'Abcd1234!' + new_password_confirm = 'Abcd1234!1' + + set_result = self.make_set_password_mutation(uidb64, new_password, new_password_confirm) + self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), False) From fb225b926d134c5a211062d8b08c3d83603deda3 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 13:29:06 +0200 Subject: [PATCH 05/18] Add login tests --- server/users/mutations_public.py | 2 +- server/users/tests/test_login.py | 64 +++++++++++++++++++++++ server/users/tests/test_password_reset.py | 17 +++--- 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 server/users/tests/test_login.py diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py index 2e43b0b5..d02b6bc8 100644 --- a/server/users/mutations_public.py +++ b/server/users/mutations_public.py @@ -10,7 +10,7 @@ import re import graphene -from django.contrib.auth import authenticate, login +from django.contrib.auth import authenticate, login, logout from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.views import PasswordResetView, PasswordResetConfirmView, INTERNAL_RESET_URL_TOKEN from graphene import relay diff --git a/server/users/tests/test_login.py b/server/users/tests/test_login.py new file mode 100644 index 00000000..c24dbe4a --- /dev/null +++ b/server/users/tests/test_login.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-02 +# @author: chrigu +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import TestCase, RequestFactory +from graphene.test import Client + +from api.schema_public import schema +from core.factories import UserFactory + + +class PasswordResetTests(TestCase): + def setUp(self): + self.user = UserFactory(username='aschi@iterativ.ch', email='aschi@iterativ.ch') + + request = RequestFactory().post('/') + + # adding session + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + self.client = Client(schema=schema, context_value=request) + + def make_login_mutation(self, username, password): + mutation = ''' + mutation Login($input: LoginInput!){ + login(input: $input) { + success + errors { + field + } + } + } + ''' + + return self.client.execute(mutation, variables={ + 'input': { + 'usernameInput': username, + 'passwordInput': password + } + }) + + def test_user_can_login(self): + password = 'test123' + self.user.set_password(password) + self.user.save() + + result = self.make_login_mutation(self.user.email, password) + self.assertTrue(result.get('data').get('login').get('success')) + self.assertTrue(self.user.is_authenticated) + + def test_user_cannot_login_with_invalid_password(self): + password = 'test123' + self.user.set_password(password) + self.user.save() + + result = self.make_login_mutation(self.user.email, 'test1234') + self.assertFalse(result.get('data').get('login').get('success')) diff --git a/server/users/tests/test_password_reset.py b/server/users/tests/test_password_reset.py index f9227177..4bd9f1ee 100644 --- a/server/users/tests/test_password_reset.py +++ b/server/users/tests/test_password_reset.py @@ -91,8 +91,8 @@ class PasswordResetTests(TestCase): def test_user_can_initiate_password(self): result = self.make_reset_mutation(self.user.email) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].subject.startswith('Passwort auf'), True) - self.assertEqual(result.get('data').get('passwordReset').get('success'), True) + self.assertTrue(mail.outbox[0].subject.startswith('Passwort auf')) + self.assertTrue(result.get('data').get('passwordReset').get('success')) def test_user_can_verify_and_set_password(self): token_generator = PasswordResetTokenGenerator() @@ -100,13 +100,12 @@ class PasswordResetTests(TestCase): uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() result = self.make_set_verify_mutation(uidb64, token) - self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + self.assertTrue(result.get('data').get('passwordResetVerify').get('success')) new_password = 'Abcd1234!' set_result = self.make_set_password_mutation(uidb64, new_password, new_password) - print(set_result) - self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), True) + self.assertTrue(set_result.get('data').get('passwordResetSetPassword').get('success')) def test_user_cannot_use_unsafe_password(self): token_generator = PasswordResetTokenGenerator() @@ -114,12 +113,12 @@ class PasswordResetTests(TestCase): uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() result = self.make_set_verify_mutation(uidb64, token) - self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + self.assertTrue(result.get('data').get('passwordResetVerify').get('success')) new_password = 'test' set_result = self.make_set_password_mutation(uidb64, new_password, new_password) - self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), False) + self.assertFalse(set_result.get('data').get('passwordResetSetPassword').get('success'),) def test_new_passwords_must_match(self): token_generator = PasswordResetTokenGenerator() @@ -127,10 +126,10 @@ class PasswordResetTests(TestCase): uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)).decode() result = self.make_set_verify_mutation(uidb64, token) - self.assertEqual(result.get('data').get('passwordResetVerify').get('success'), True) + self.assertTrue(result.get('data').get('passwordResetVerify').get('success')) new_password = 'Abcd1234!' new_password_confirm = 'Abcd1234!1' set_result = self.make_set_password_mutation(uidb64, new_password, new_password_confirm) - self.assertEqual(set_result.get('data').get('passwordResetSetPassword').get('success'), False) + self.assertFalse(set_result.get('data').get('passwordResetSetPassword').get('success')) From 062269f030fcae132fdee7b566a1c26c90a0d0e7 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 16:11:15 +0200 Subject: [PATCH 06/18] Add loggedIn guard, add basic login component --- client/src/App.vue | 2 + client/src/graphql/client.js | 132 ++++++++--------- client/src/graphql/gql/mutations/login.gql | 8 ++ client/src/layouts/PublicLayout.vue | 23 +++ client/src/main.js | 32 ++++- client/src/pages/login.vue | 158 +++++++++++++++++++++ client/src/router/index.js | 15 +- server/core/middleware.py | 20 +++ server/core/settings.py | 1 + server/core/views.py | 2 - server/users/mutations_public.py | 1 - 11 files changed, 323 insertions(+), 71 deletions(-) create mode 100644 client/src/graphql/gql/mutations/login.gql create mode 100644 client/src/layouts/PublicLayout.vue create mode 100644 client/src/pages/login.vue diff --git a/client/src/App.vue b/client/src/App.vue index 1b99180e..479743b2 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -11,6 +11,7 @@ import SimpleLayout from '@/layouts/SimpleLayout'; import BlankLayout from '@/layouts/BlankLayout'; import FullScreenLayout from '@/layouts/FullScreenLayout'; + import PublicLayout from '@/layouts/PublicLayout'; import Modal from '@/components/Modal'; import MobileNavigation from '@/components/MobileNavigation'; import NewContentBlockWizard from '@/components/content-block-form/NewContentBlockWizard'; @@ -36,6 +37,7 @@ SimpleLayout, BlankLayout, FullScreenLayout, + PublicLayout, Modal, MobileNavigation, NewContentBlockWizard, diff --git a/client/src/graphql/client.js b/client/src/graphql/client.js index 84841780..6d0822b0 100644 --- a/client/src/graphql/client.js +++ b/client/src/graphql/client.js @@ -4,71 +4,73 @@ import {ApolloClient} from 'apollo-client/index' import {ApolloLink} from 'apollo-link' import fetch from 'unfetch' -const httpLink = new HttpLink({ - // uri: process.env.NODE_ENV !== 'production' ? 'http://localhost:8000/api/graphql/' : '/api/graphql/', - uri: '/api/graphql/', - credentials: 'include', - fetch: fetch, - headers: { - 'X-CSRFToken': document.cookie.replace(/(?:(?:^|.*;\s*)csrftoken\s*=\s*([^;]*).*$)|^.*$/, '$1') - } -}); - -const consoleLink = new ApolloLink((operation, forward) => { - // console.log(`starting request for ${operation.operationName}`); - - return forward(operation).map((data) => { - // console.log(`ending request for ${operation.operationName}`); - - return data - }) -}); - -// from https://github.com/apollographql/apollo-client/issues/1564#issuecomment-357492659 -const omitTypename = (key, value) => { - return key === '__typename' ? undefined : value -}; - -const createOmitTypenameLink = new ApolloLink((operation, forward) => { - if (operation.variables) { - operation.variables = JSON.parse(JSON.stringify(operation.variables), omitTypename) - } - - return forward(operation) -}); - -const composedLink = ApolloLink.from([createOmitTypenameLink, consoleLink, httpLink]); - -const cache = new InMemoryCache({ - cacheRedirects: { - Query: { - contentBlock: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ContentBlockNode', id: args.id}), - chapter: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ChapterNode', id: args.id}), - assignment: (_, args, {getCacheKey}) => getCacheKey({__typename: 'AssignmentNode', id: args.id}), - objective: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveNode', id: args.id}), - objectiveGroup: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveGroupNode', id: args.id}), - module: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ModuleNode', id: args.id}), - projectEntry: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ProjectEntryNode', id: args.id}), +export default function (uri) { + const httpLink = new HttpLink({ + // uri: process.env.NODE_ENV !== 'production' ? 'http://localhost:8000/api/graphql/' : '/api/graphql/', + uri, + credentials: 'include', + fetch: fetch, + headers: { + 'X-CSRFToken': document.cookie.replace(/(?:(?:^|.*;\s*)csrftoken\s*=\s*([^;]*).*$)|^.*$/, '$1') } - } -}); + }); -// TODO: Monkey-patching in a fix for an open issue suggesting that -// `readQuery` should return null or undefined if the query is not yet in the -// cache: https://github.com/apollographql/apollo-feature-requests/issues/1 -cache.originalReadQuery = cache.readQuery; -cache.readQuery = (...args) => { - try { - return cache.originalReadQuery(...args); - } catch (err) { - return undefined; - } -}; + const consoleLink = new ApolloLink((operation, forward) => { + // console.log(`starting request for ${operation.operationName}`); -// Create the apollo client -export default new ApolloClient({ - link: composedLink, - // link: httpLink, - cache: cache, - connectToDevTools: true -}) + return forward(operation).map((data) => { + // console.log(`ending request for ${operation.operationName}`); + + return data + }) + }); + + // from https://github.com/apollographql/apollo-client/issues/1564#issuecomment-357492659 + const omitTypename = (key, value) => { + return key === '__typename' ? undefined : value + }; + + const createOmitTypenameLink = new ApolloLink((operation, forward) => { + if (operation.variables) { + operation.variables = JSON.parse(JSON.stringify(operation.variables), omitTypename) + } + + return forward(operation) + }); + + const composedLink = ApolloLink.from([createOmitTypenameLink, consoleLink, httpLink]); + + const cache = new InMemoryCache({ + cacheRedirects: { + Query: { + contentBlock: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ContentBlockNode', id: args.id}), + chapter: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ChapterNode', id: args.id}), + assignment: (_, args, {getCacheKey}) => getCacheKey({__typename: 'AssignmentNode', id: args.id}), + objective: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveNode', id: args.id}), + objectiveGroup: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ObjectiveGroupNode', id: args.id}), + module: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ModuleNode', id: args.id}), + projectEntry: (_, args, {getCacheKey}) => getCacheKey({__typename: 'ProjectEntryNode', id: args.id}), + } + } + }); + + // TODO: Monkey-patching in a fix for an open issue suggesting that + // `readQuery` should return null or undefined if the query is not yet in the + // cache: https://github.com/apollographql/apollo-feature-requests/issues/1 + cache.originalReadQuery = cache.readQuery; + cache.readQuery = (...args) => { + try { + return cache.originalReadQuery(...args); + } catch (err) { + return undefined; + } + }; + + // Create the apollo client + return new ApolloClient({ + link: composedLink, + // link: httpLink, + cache: cache, + connectToDevTools: true + }) +} diff --git a/client/src/graphql/gql/mutations/login.gql b/client/src/graphql/gql/mutations/login.gql new file mode 100644 index 00000000..ff107cce --- /dev/null +++ b/client/src/graphql/gql/mutations/login.gql @@ -0,0 +1,8 @@ +mutation Login($input: LoginInput!) { + login(input: $input) { + success + errors { + field + } + } +} diff --git a/client/src/layouts/PublicLayout.vue b/client/src/layouts/PublicLayout.vue new file mode 100644 index 00000000..82ee90a3 --- /dev/null +++ b/client/src/layouts/PublicLayout.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/client/src/main.js b/client/src/main.js index 4f314738..45559d88 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -3,7 +3,7 @@ import Vue from 'vue' import axios from 'axios' import VueAxios from 'vue-axios' import VueVimeoPlayer from 'vue-vimeo-player' -import apolloClient from './graphql/client' +import apolloClientFactory from './graphql/client' import VueApollo from 'vue-apollo' import App from './App' import router from './router' @@ -63,8 +63,14 @@ if (process.env.GOOGLE_ANALYTICS_ID) { Vue.directive('click-outside', clickOutside); Vue.directive('auto-grow', autoGrow); +const publicApolloClient = apolloClientFactory('/api/graphql-public/'); +const privateApolloClient = apolloClientFactory('/api/graphql/'); + const apolloProvider = new VueApollo({ - defaultClient: apolloClient + clients: { + publicClient: publicApolloClient + }, + defaultClient: privateApolloClient }); Validator.extend('required', required); @@ -98,6 +104,28 @@ Vue.use(VeeValidate, { Vue.filter('date', dateFilter); +/* logged in guard */ + +const publicPages = ['login'] + +function getCookieValue(a) { + var b = document.cookie.match('(^|[^;]+)\\s*' + a + '\\s*=\\s*([^;]+)'); + return b ? b.pop() : ''; +} + +function redirectIfLoginRequird(nameOfPage) { + return publicPages.indexOf(nameOfPage) === -1 && getCookieValue('loginStatus') !== 'True'; +} + +router.beforeEach((to, from, next) => { + if (redirectIfLoginRequird(to.name)) { + next('/login'); + } else { + next(); + } + // todo handle public pages for user +}); + /* eslint-disable no-new */ new Vue({ el: '#app', diff --git a/client/src/pages/login.vue b/client/src/pages/login.vue new file mode 100644 index 00000000..bc33bce7 --- /dev/null +++ b/client/src/pages/login.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/client/src/router/index.js b/client/src/router/index.js index fac2196a..3a9b7533 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -27,11 +27,23 @@ import newProject from '@/pages/newProject' import surveyPage from '@/pages/survey' import styleGuidePage from '@/pages/styleguide' import moduleRoom from '@/pages/moduleRoom' +import login from '@/pages/login' import store from '@/store/index'; const routes = [ - {path: '/', component: start, meta: {layout: 'blank'}}, + { + path: '/', + name: 'home', + component: start, + meta: {layout: 'blank'} + }, + { + path: '/login', + name: 'login', + component: login, + meta: {layout: 'public'} + }, { path: '/module/:slug', component: moduleBase, @@ -118,6 +130,7 @@ const router = new Router({ return {x: 0, y: 0} } }); + router.afterEach((to, from) => { store.dispatch('showMobileNavigation', false); }); diff --git a/server/core/middleware.py b/server/core/middleware.py index e6508116..c0b362c5 100644 --- a/server/core/middleware.py +++ b/server/core/middleware.py @@ -75,3 +75,23 @@ class CommonRedirectMiddleware(MiddlewareMixin): # or dummy image: return 'http://via.placeholder.com/{}'.format(m.group('dimensions')) if '.png' in path or '.jpg' in path or '.svg' in path or 'not-found' in path: return 'https://picsum.photos/400/400' + + +# https://stackoverflow.com/questions/4898408/how-to-set-a-login-cookie-in-django +class UserLoggedInCookieMiddleWare(MiddlewareMixin): + """ + Middleware to set user cookie + If user is authenticated and there is no cookie, set the cookie, + If the user is not authenticated and the cookie remains, delete it + """ + + cookie_name = 'loginStatus' + + def process_response(self, request, response): + #if user and no cookie, set cookie + if request.user.is_authenticated and not request.COOKIES.get(self.cookie_name): + response.set_cookie(self.cookie_name, 'true') + elif not request.user.is_authenticated and request.COOKIES.get(self.cookie_name): + #else if if no user and cookie remove user cookie, logout + response.delete_cookie(self.cookie_name) + return response diff --git a/server/core/settings.py b/server/core/settings.py index 8437b685..54738b93 100644 --- a/server/core/settings.py +++ b/server/core/settings.py @@ -116,6 +116,7 @@ MIDDLEWARE += [ 'core.middleware.ThreadLocalMiddleware', 'core.middleware.CommonRedirectMiddleware', + 'core.middleware.UserLoggedInCookieMiddleWare', ] ROOT_URLCONF = 'core.urls' diff --git a/server/core/views.py b/server/core/views.py index 3a103a86..1cece2c1 100644 --- a/server/core/views.py +++ b/server/core/views.py @@ -1,6 +1,5 @@ import requests from django.conf import settings -from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import PasswordResetView, PasswordResetDoneView, PasswordResetConfirmView, \ PasswordResetCompleteView @@ -16,7 +15,6 @@ class PrivateGraphQLView(LoginRequiredMixin, GraphQLView): pass -@login_required @ensure_csrf_cookie def home(request): if settings.DEBUG: diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py index d02b6bc8..3ace25c6 100644 --- a/server/users/mutations_public.py +++ b/server/users/mutations_public.py @@ -15,7 +15,6 @@ from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.views import PasswordResetView, PasswordResetConfirmView, INTERNAL_RESET_URL_TOKEN from graphene import relay -from core import settings from users.models import User From 9783bd802a872bb421931926984197478e275d87 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 16:28:29 +0200 Subject: [PATCH 07/18] Redirect user to visited page after login --- client/src/main.js | 5 +++-- client/src/pages/login.vue | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/main.js b/client/src/main.js index 45559d88..cdb59a5d 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -114,12 +114,13 @@ function getCookieValue(a) { } function redirectIfLoginRequird(nameOfPage) { - return publicPages.indexOf(nameOfPage) === -1 && getCookieValue('loginStatus') !== 'True'; + return publicPages.indexOf(nameOfPage) === -1 && getCookieValue('loginStatus') !== 'true'; } router.beforeEach((to, from, next) => { if (redirectIfLoginRequird(to.name)) { - next('/login'); + const redirectUrl = `/login?redirect=${to.path}`; + next(redirectUrl); } else { next(); } diff --git a/client/src/pages/login.vue b/client/src/pages/login.vue index bc33bce7..ec35d3ac 100644 --- a/client/src/pages/login.vue +++ b/client/src/pages/login.vue @@ -90,12 +90,14 @@ export default { } ) { try { - console.log('success', success) if (success) { + const redirectUrl = that.$route.query.redirect ? that.$route.query.redirect : '/' + that.$router.push(redirectUrl); } else { that.loginError = 'Die E-Mail oder das Passwort ist falsch. Bitte versuchen Sie nochmals.'; } } catch (e) { + console.warn(e); that.loginError = 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie nochmals.'; } } From 57224d228a1b1ee2781cbf61b7bd49f429ddf244 Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Wed, 2 Oct 2019 16:46:10 +0200 Subject: [PATCH 08/18] Style default layout, style login --- .../components/profile/PasswordChangeForm.vue | 31 ---------------- client/src/layouts/PublicLayout.vue | 7 +++- client/src/pages/login.vue | 35 ++----------------- client/src/styles/_password_forms.scss | 30 ++++++++++++++++ client/src/styles/main.scss | 1 + 5 files changed, 40 insertions(+), 64 deletions(-) create mode 100644 client/src/styles/_password_forms.scss diff --git a/client/src/components/profile/PasswordChangeForm.vue b/client/src/components/profile/PasswordChangeForm.vue index 98749d10..f462abee 100644 --- a/client/src/components/profile/PasswordChangeForm.vue +++ b/client/src/components/profile/PasswordChangeForm.vue @@ -82,35 +82,4 @@ } } - .sbform-input { - - margin-bottom: 20px; - font-family: $sans-serif-font-family; - - &__label { - margin-bottom: 10px; - display: inline-block; - } - - &__input { - width: 100%; - - &--error { - border-color: $color-error; - } - } - - &__error { - margin-top: 10px; - color: $color-error; - display: inline-block; - } - - &__hint { - margin-top: $small-spacing; - font-family: $sans-serif-font-family; - color: $color-silver-dark; - } - } - diff --git a/client/src/layouts/PublicLayout.vue b/client/src/layouts/PublicLayout.vue index 82ee90a3..9ce691b3 100644 --- a/client/src/layouts/PublicLayout.vue +++ b/client/src/layouts/PublicLayout.vue @@ -1,5 +1,5 @@ From 80b1d38b934311ec85fb6c68c6bd0e6d5d50aa5a Mon Sep 17 00:00:00 2001 From: Christian Cueni Date: Mon, 7 Oct 2019 16:29:15 +0200 Subject: [PATCH 11/18] Style public pages, add links to login page --- client/src/layouts/PublicLayout.vue | 7 ++++- client/src/pages/login.vue | 42 +++++++++++++++++++++++++++-- client/src/styles/_buttons.scss | 3 +++ server/core/static/styles/main.scss | 11 ++++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/client/src/layouts/PublicLayout.vue b/client/src/layouts/PublicLayout.vue index 9ce691b3..a4e7b9a2 100644 --- a/client/src/layouts/PublicLayout.vue +++ b/client/src/layouts/PublicLayout.vue @@ -1,6 +1,6 @@