diff --git a/requirements.txt b/requirements.txt index cb089eea7d140094d61e6c9a7f63484a036cadea..8ad28d986aed3517475c9fe3e63130ff614de6cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,21 @@ +aniso8601==7.0.0 asgiref==3.2.10 coverage==5.2.1 Django==3.0.7 django-cors-headers==3.4.0 -django-graphql-jwt==0.3.1 +django-filter==2.3.0 +django-graphql-auth==0.3.11 +django-graphql-jwt==0.3.0 +graphene==2.1.8 graphene-django==2.10.1 +graphql-core==2.3.2 +graphql-relay==2.0.1 gunicorn==20.0.4 +promise==2.3 psycopg2-binary==2.8.5 +PyJWT==1.7.1 pytz==2020.1 +Rx==1.6.1 +singledispatch==3.4.0.3 +six==1.15.0 sqlparse==0.3.1 diff --git a/sizakat/account/email.py b/sizakat/account/email.py deleted file mode 100644 index d4a8152950708ed5ee69f04ed66024b626606714..0000000000000000000000000000000000000000 --- a/sizakat/account/email.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.core.mail import send_mail -from django.conf import settings - - -def send_reset_password_token(receiver_email, url, user_id, token): - subject = 'Reset password akun sizakat' - reset_password_url = '{}?userId={}&token={}'.format( - url, - user_id, - token - ) - message = '{}\n{}'.format( - 'Silahkan buka link berikut untuk mengganti password:', - reset_password_url - ) - email_from = settings.EMAIL_HOST_USER - recipient_list = [receiver_email] - send_mail(subject, message, email_from, recipient_list) diff --git a/sizakat/account/mutations.py b/sizakat/account/mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..6659c67173c8f9899b810fa45571531d73edaafe --- /dev/null +++ b/sizakat/account/mutations.py @@ -0,0 +1,23 @@ +import graphene + +from graphql_auth import mutations +from django.db.models import signals +from graphql_auth.models import UserStatus + + +def verify_user_status(sender, instance, created, **kwargs): + if created: + instance.verified = True + instance.save(update_fields=["verified"]) + + +signals.post_save.connect(receiver=verify_user_status, sender=UserStatus) + + +class AccountMutation(graphene.ObjectType): + register = mutations.Register.Field() + token_auth = mutations.ObtainJSONWebToken.Field() + verify_token = mutations.VerifyToken.Field() + refresh_token = mutations.RefreshToken.Field() + send_password_reset_email = mutations.SendPasswordResetEmail.Field() + password_reset = mutations.PasswordReset.Field() diff --git a/sizakat/account/query.py b/sizakat/account/query.py new file mode 100644 index 0000000000000000000000000000000000000000..ef7b2ff356279cab3f721cdd40c3b23432a150ad --- /dev/null +++ b/sizakat/account/query.py @@ -0,0 +1,7 @@ +import graphene + +from graphql_auth.schema import UserQuery, MeQuery + + +class AccountQuery(UserQuery, MeQuery, graphene.ObjectType): + pass diff --git a/sizakat/account/templates/login.html b/sizakat/account/templates/login.html deleted file mode 100644 index 0b7344c1166c323c4216565f034ed40afb963534..0000000000000000000000000000000000000000 --- a/sizakat/account/templates/login.html +++ /dev/null @@ -1,29 +0,0 @@ -{% if form.errors %} -<p>Username atau password yang Anda masukkan salah. Harap periksa ulang dan coba lagi.</p> -{% endif %} - -{% if next %} -{% if user.is_authenticated %} -<p>Akun Anda tidak memiliki akses ke halaman ini. Silakan - gunakan akun yang memiliki akses.</p> -{% else %} -<p>Silakan login untuk melanjutkan.</p> -{% endif %} -{% endif %} - -<form method="post" action="{% url 'login' %}"> - {% csrf_token %} - <table> - <tr> - <td>{{ form.username.label_tag }}</td> - <td>{{ form.username }}</td> - </tr> - <tr> - <td>{{ form.password.label_tag }}</td> - <td>{{ form.password }}</td> - </tr> - </table> - - <input type="submit" value="login"> - <input type="hidden" name="next" value="/graphql"> -</form> \ No newline at end of file diff --git a/sizakat/account/tests.py b/sizakat/account/tests.py deleted file mode 100644 index d0ec120d57afde2ba8e564f0fb7bdec76f052444..0000000000000000000000000000000000000000 --- a/sizakat/account/tests.py +++ /dev/null @@ -1,100 +0,0 @@ -import json - -from django.test import Client, TestCase -from django.contrib.auth import get_user_model -from django.core import mail - -User = get_user_model() - - -class AccountTestCase(TestCase): - def setUp(self): - user = User.objects.create(username='testuser', email='test@mail.com') - user.set_password('12345') - user.save() - - def test_user_can_login_via_post_login(self): - c = Client() - response = c.post( - '/login/', - json.dumps({'username': 'testuser', 'password': '12345'}), - 'text/json') - logged_in = json.loads(response.content).get('loggedIn', None) - - self.assertEqual(response.status_code, 200) - self.assertTrue(logged_in) - - def test_user_get_fail_message_when_login_failed(self): - c = Client() - response = c.post( - '/login/', - json.dumps({'username': 'thisuser', 'password': 'willfailed'}), - 'text/json') - logged_in = json.loads(response.content).get('loggedIn', None) - self.assertFalse(logged_in) - - def test_user_can_logout_via_post_logout(self): - c = Client() - log_in = c.post( - '/login/', - json.dumps({'username': 'testuser', 'password': '12345'}), - 'text/json') - response = c.post('/logout/') - logged_out = json.loads(response.content).get('loggedOut', None) - self.assertTrue(logged_out) - - def test_user_can_reset_password_and_change_with_valid_token(self): - c = Client() - response = c.post( - '/reset-password/', - json.dumps({ - 'email': 'test@mail.com', - 'changePasswordUrl': 'localhost/reset-password'}), - 'text/json' - ) - success = json.loads(response.content).get('success', None) - self.assertTrue(success) - - # Test that one message has been sent. - self.assertEqual(len(mail.outbox), 1) - - # Verify that the subject of the first message is correct. - self.assertEqual(mail.outbox[0].subject, 'Reset password akun sizakat') - - email_msg = str(mail.outbox[0].message()) - user_id = int(email_msg.split('userId=')[1].split('&')[0]) - token = email_msg.split('token=')[1].split('&')[0] - - new_pass = '54321' - change_reset_password = c.post( - '/change-reset-password/', - json.dumps({'userId': user_id, 'token': token, - 'newPassword': new_pass}), - 'text/json' - ) - self.assertTrue(json.loads( - change_reset_password.content).get('success')) - - login_response = c.post( - '/login/', - json.dumps({'username': 'testuser', 'password': new_pass}), - 'text/json') - self.assertTrue(json.loads(login_response.content).get('loggedIn')) - - def test_session_is_valid_after_logged_in(self): - c = Client() - response = c.post( - '/login/', - json.dumps({'username': 'testuser', 'password': '12345'}), - 'text/json') - logged_in = json.loads(response.content).get('loggedIn', None) - - self.assertTrue(logged_in) - - session = json.loads(response.content).get('session', None) - auth_headers = { - 'HTTP_AUTHORIZATION': session, - } - verify_response = c.get('/verify-session/', **auth_headers) - self.assertTrue(json.loads( - verify_response.content).get('active', None)) diff --git a/sizakat/account/urls.py b/sizakat/account/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..948b5b8123c3718a8a6a250e72601a345409f610 --- /dev/null +++ b/sizakat/account/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import reset_password + +urlpatterns = [ + path('reset-password/', reset_password), +] diff --git a/sizakat/account/views.py b/sizakat/account/views.py index 01e0f31994f3be25391b753ff328621bf8e8a312..205fe14fb97f8639da92e1a885f698dba676e0e4 100644 --- a/sizakat/account/views.py +++ b/sizakat/account/views.py @@ -1,84 +1,8 @@ -import json +from django.shortcuts import redirect +from django.conf import settings -from django.http import JsonResponse -from django.contrib.auth import authenticate, login, get_user_model, logout -from django.contrib.auth.tokens import default_token_generator as token_generator -from django.contrib.sessions.models import Session -from django.utils import timezone -from .models import User -from .email import send_reset_password_token -import logging -UserModel = get_user_model() - -def login_session(request): - if request.method == 'POST': - request_body = json.loads(request.body.decode('utf-8')) - username = request_body.get('username', None) - password = request_body.get('password', None) - user = authenticate(request, username=username, password=password) - if user: - login(request, user) - session = request.session.session_key - return JsonResponse({'loggedIn': True, 'session': session}) - if request.session.session_key == None: - return JsonResponse({'loggedIn': False}, status=404) - else: - return JsonResponse({'loggedIn': True}) - - -def logout_session(request): - logout(request) - return JsonResponse({'loggedOut': True}) - - - def reset_password(request): - if request.method == 'POST': - request_body = json.loads(request.body.decode('utf-8')) - email = request_body.get('email', None) - change_password_url = request_body.get('changePasswordUrl', None) - try: - user = UserModel.objects.get(email=email) - token = token_generator.make_token(user) - send_reset_password_token( - email, change_password_url, user.pk, token) - return JsonResponse({'success': True}) - - except UserModel.DoesNotExist: - return JsonResponse({'success': False}, status=404) - - return JsonResponse({'success': False}, status=405) - - -def change_reset_password(request): - if request.method == 'POST': - request_body = json.loads(request.body.decode('utf-8')) - user_id = request_body.get('userId', None) - token = request_body.get('token', None) - try: - user = UserModel.objects.get(pk=user_id) - if token_generator.check_token(user, token): - new_pass = request_body.get('newPassword') - user.set_password(new_pass) - user.save() - return JsonResponse({'success': True}) - else: - return JsonResponse({'success': False}, status=401) - - except UserModel.DoesNotExist: - return JsonResponse({'success': False}, status=404) - - return JsonResponse({'success': False}, status=405) - - -def verify_session(request): - session_key = request.headers.get('Authorization', None) - session = Session.objects.filter(session_key=session_key) - if session.exists(): - expire_time = session.get().expire_date - if timezone.now() < expire_time: - return JsonResponse({'active': True}) - else: - session.get().delete() - return JsonResponse({'active': False}) + token = request.GET.get('token', '') + reset_password_url = settings.RESET_PASSWORD_URL + return redirect(f'{reset_password_url}?token={token}') diff --git a/sizakat/schema.py b/sizakat/schema.py index fabda0e87beb71398c11d415f0a1b7e9d3ed27f8..94d057397b839d6f83e07f8060f7fe3f48df5ec4 100644 --- a/sizakat/schema.py +++ b/sizakat/schema.py @@ -7,6 +7,8 @@ from .mustahik.mutations import ( DataSourceWargaMutation, DataSourceInstitusiMutation, DataSourcePekerjaMutation, DeleteDataSource ) +from .account.query import AccountQuery +from .account.mutations import AccountMutation from .mustahik.query import MustahikQuery from .transaction.query import TransactionQuery from .transaction.mutations import ( @@ -18,14 +20,19 @@ ABOUT = ('Si Zakat merupakan sistem informasi untuk membantu masjid dalam ' 'yang dipimpin oleh Prof. Dr. Wisnu Jatmiko.') -class Query(MustahikQuery, TransactionQuery, graphene.ObjectType): +class Query( + AccountQuery, + MustahikQuery, + TransactionQuery, + graphene.ObjectType +): about = graphene.String() def resolve_about(self, info): return ABOUT -class Mutation(graphene.ObjectType): +class Mutation(AccountMutation, graphene.ObjectType): mustahik_mutation = MustahikMutation.Field() delete_mustahik = DeleteMustahik.Field() data_source_mutation = DataSourceMutation.Field() diff --git a/sizakat/settings.py b/sizakat/settings.py index 0b094c72c65b10e14f2e1da9aa13a37cda67a1bc..913af7ce65462cfa44b65959236732c490109c66 100644 --- a/sizakat/settings.py +++ b/sizakat/settings.py @@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os +from datetime import timedelta + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -23,7 +25,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = os.environ.get('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.environ.get("DEBUG", False) +DEBUG = os.environ.get('DEBUG', False) ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS').split() @@ -37,7 +39,9 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_filters', 'graphene_django', + 'graphql_auth', 'corsheaders', 'sizakat.mustahik', 'sizakat.transaction', @@ -48,6 +52,9 @@ AUTH_USER_MODEL = 'account.User' GRAPHENE = { 'SCHEMA': 'sizakat.schema.schema', + 'MIDDLEWARE': [ + 'graphql_jwt.middleware.JSONWebTokenMiddleware', + ], } MIDDLEWARE = [ @@ -61,6 +68,22 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +AUTHENTICATION_BACKENDS = [ + 'graphql_auth.backends.GraphQLAuthBackend', + 'django.contrib.auth.backends.ModelBackend', +] + +GRAPHQL_JWT = { + 'JWT_VERIFY_EXPIRATION': True, + 'JWT_EXPIRATION_DELTA': timedelta(hours=1), +} + +GRAPHQL_AUTH = { + 'LOGIN_ALLOWED_FIELDS': ['email'], + 'SEND_ACTIVATION_EMAIL': False, + 'PASSWORD_RESET_PATH_ON_EMAIL': 'reset-password', +} + SESSION_EXPIRE_AT_BROWSER_CLOSE = False SESSION_COOKIE_AGE = 60 * 60 @@ -69,10 +92,14 @@ CORS_ORIGIN_WHITELIST = os.environ.get( ROOT_URLCONF = 'sizakat.urls' +RESET_PASSWORD_URL = os.environ.get( + 'RESET_PASSWORD_URL', 'http://localhost:3000/reset-password/' +) + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -155,5 +182,5 @@ EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = 'smtp.gmail.com' EMAIL_USE_TLS = True EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get('GMAIL_USER').split() -EMAIL_HOST_PASSWORD = os.environ.get('GMAIL_PASSWORD').split() +EMAIL_HOST_USER = os.environ.get('GMAIL_USER', 'email@mail.com') +EMAIL_HOST_PASSWORD = os.environ.get('GMAIL_PASSWORD', 'emailpass') diff --git a/sizakat/urls.py b/sizakat/urls.py index 90e84676f1ef8f424f04f5dd544f02ac6f210407..5272be9e7ce20957848ffdbc9eba75cef8ea5b02 100644 --- a/sizakat/urls.py +++ b/sizakat/urls.py @@ -16,20 +16,14 @@ Including another URLconf from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import path +from django.urls import include, path from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView urlpatterns = [ path('admin/', admin.site.urls), path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True))), - path('accounts/login/', - auth_views.LoginView.as_view(template_name='login.html'), name='login'), - path('login/', csrf_exempt(login_session)), - path('logout/', csrf_exempt(logout_session)), - path('reset-password/', csrf_exempt(reset_password)), - path('change-reset-password/', csrf_exempt(change_reset_password)), - path('verify-session/', verify_session), + path('account/', include('sizakat.account.urls')), ] if settings.DEBUG: diff --git a/templates/email/password_reset_email.html b/templates/email/password_reset_email.html new file mode 100644 index 0000000000000000000000000000000000000000..eebb4ebbf3acadfb83efcbe67f7848de137d3aca --- /dev/null +++ b/templates/email/password_reset_email.html @@ -0,0 +1,7 @@ +<h3>SIZAKAT</h3> + +<p>Hello {{ user.username }}!</p> + +<p>Reset your password on the link:</p> + +<p>{{ protocol }}://{{ domain }}/account/{{ path }}/?token={{ token }}</p> \ No newline at end of file diff --git a/templates/email/password_reset_subject.txt b/templates/email/password_reset_subject.txt new file mode 100644 index 0000000000000000000000000000000000000000..63c6b40b443efd86f2f92066abebca3b0519a03d --- /dev/null +++ b/templates/email/password_reset_subject.txt @@ -0,0 +1 @@ +Reset your password on Sizakat \ No newline at end of file