From d23fc557ba9226e9f71d041869fa96dc43fab600 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 1 Aug 2024 14:28:59 -0400 Subject: [PATCH 1/5] Make views/api a package Allows creating api submodules without disturbing existing imports. --- arches/app/views/{api.py => api/__pycache__/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename arches/app/views/{api.py => api/__pycache__/__init__.py} (100%) diff --git a/arches/app/views/api.py b/arches/app/views/api/__pycache__/__init__.py similarity index 100% rename from arches/app/views/api.py rename to arches/app/views/api/__pycache__/__init__.py From ed08d3370e835c2809da1541d4aa0cd1e1c27028 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 1 Aug 2024 15:56:56 -0400 Subject: [PATCH 2/5] Avoid omitting __init__.py from coverage --- .coveragerc | 1 - arches/app/views/api/{__pycache__ => }/__init__.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) rename arches/app/views/api/{__pycache__ => }/__init__.py (99%) diff --git a/.coveragerc b/.coveragerc index 026e7a11cbd..287e55bd17f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,7 +3,6 @@ source = arches/ omit = - */__init__.py */celery.py */hosts.py */models/migrations/* diff --git a/arches/app/views/api/__pycache__/__init__.py b/arches/app/views/api/__init__.py similarity index 99% rename from arches/app/views/api/__pycache__/__init__.py rename to arches/app/views/api/__init__.py index e022c6ae12e..57162fe13c0 100644 --- a/arches/app/views/api/__pycache__/__init__.py +++ b/arches/app/views/api/__init__.py @@ -1302,7 +1302,7 @@ def get(self, request): else: response = JSONResponse(export_files) return response - return JSONResponse(status=404) + return JSONErrorResponse(status=404) class SearchComponentData(APIBase): @@ -1311,7 +1311,7 @@ def get(self, request, componentname): search_filter = search_filter_factory.get_filter(componentname) if search_filter: return JSONResponse(search_filter.view_data()) - return JSONResponse(status=404) + return JSONErrorResponse(status=404) @method_decorator(csrf_exempt, name="dispatch") From f8076a3f85303f3bf114fca5cc463a1e6c859afd Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 1 Aug 2024 16:22:31 -0400 Subject: [PATCH 3/5] Add session-based auth API #11261 --- arches/app/templates/arches_urls.htm | 6 ++- arches/app/views/api/auth.py | 67 ++++++++++++++++++++++++++++ arches/settings.py | 2 + arches/urls.py | 3 ++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 arches/app/views/api/auth.py diff --git a/arches/app/templates/arches_urls.htm b/arches/app/templates/arches_urls.htm index c9a4cfa0f48..540e515f48f 100644 --- a/arches/app/templates/arches_urls.htm +++ b/arches/app/templates/arches_urls.htm @@ -104,6 +104,8 @@ api_tiles='(tileid)=>{ return "{% url "api_tiles" "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" %}".replace("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", tileid); }' + api_login="{% url 'api_login' %}" + api_logout="{% url 'api_logout' %}" api_nodegroup='(nodegroupid)=>{ return "{% url "api_nodegroup" "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" %}".replace("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", nodegroupid); }' @@ -130,5 +132,7 @@ get_dsl="{% url 'get_dsl' %}" transform_edtf_for_tile="{% url 'transform_edtf_for_tile' %}" api_get_nodegroup_tree="{% url 'api_get_nodegroup_tree' %}" - + signup="{% url 'signup' %}" + auth="{% url 'auth' %}" +> {% endblock arches_urls %} diff --git a/arches/app/views/api/auth.py b/arches/app/views/api/auth.py new file mode 100644 index 00000000000..a51323bf578 --- /dev/null +++ b/arches/app/views/api/auth.py @@ -0,0 +1,67 @@ +from http import HTTPStatus + +from django.contrib.auth.views import LoginView, LogoutView +from django.contrib.auth import authenticate, login, logout +from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ +from django_ratelimit.decorators import ratelimit + +from arches.app.models.system_settings import settings +from arches.app.utils.betterJSONSerializer import JSONDeserializer, JSONSerializer +from arches.app.utils.response import JSONErrorResponse, JSONResponse +from arches.app.views.api import APIBase + + +class Login(LoginView, APIBase): + """Inherit from LoginView to get @csrf_protect etc. on dispatch().""" + + http_method_names = ["post"] + + @method_decorator( + ratelimit(key="post:username", rate=settings.RATE_LIMIT, block=False) + ) + def post(self, request): + if getattr(request, "limited", False): + return JSONErrorResponse(status=HTTPStatus.TOO_MANY_REQUESTS) + + failure_title = _("Login failed") + failure_msg_invalid = _("Invalid username and/or password.") + failure_msg_inactive = _("This account is no longer active.") + + data = JSONDeserializer().deserialize(request.body) + username = data.get("username") + password = data.get("password") + if not username or not password: + return JSONErrorResponse( + failure_title, failure_msg_invalid, status=HTTPStatus.UNAUTHORIZED + ) + + user = authenticate(username=username, password=password) + if user is None: + return JSONErrorResponse( + failure_title, failure_msg_invalid, status=HTTPStatus.UNAUTHORIZED + ) + if not user.is_active: + return JSONErrorResponse( + failure_title, failure_msg_inactive, status=HTTPStatus.FORBIDDEN + ) + if settings.FORCE_TWO_FACTOR_AUTHENTICATION or ( + settings.ENABLE_TWO_FACTOR_AUTHENTICATION + and user.userprofile.encrypted_mfa_hash + ): + return JSONErrorResponse( + title=_("Two-factor authentication required."), + message=_("Use the provided link to log in."), + status=HTTPStatus.UNAUTHORIZED, + ) + + login(request, user) + + fields = {"first_name", "last_name", "username"} + return JSONResponse(JSONSerializer().serialize(request.user, fields=fields)) + + +class Logout(LogoutView): + def post(self, request): + logout(request) + return JSONResponse(status=HTTPStatus.NO_CONTENT) diff --git a/arches/settings.py b/arches/settings.py index f19fa4bf88b..a3f06000f52 100644 --- a/arches/settings.py +++ b/arches/settings.py @@ -199,6 +199,8 @@ # Unique session cookie ensures that logins are treated separately for each app SESSION_COOKIE_NAME = "arches" +SESSION_COOKIE_SAMESITE = "Strict" +SESSION_COOKIE_SECURE = True # EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' #<-- Only need to uncomment this for testing without an actual email server # EMAIL_USE_TLS = True diff --git a/arches/urls.py b/arches/urls.py index 94ddecd86fb..923b83bc5b7 100644 --- a/arches/urls.py +++ b/arches/urls.py @@ -21,6 +21,7 @@ from django.contrib.auth import views as auth_views from django.urls import include, path, re_path from arches.app.views import concept, main, map, search, graph, api +from arches.app.views.api import auth as api_auth from arches.app.views.admin import ReIndexResources, ClearUserPermissionCache from arches.app.views.etl_manager import ETLManagerView from arches.app.views.file import FileView, TempFileView @@ -569,6 +570,8 @@ api.Resources.as_view(), name="resources", ), + path("api/login", api_auth.Login.as_view(), name="api_login"), + path("api/logout", api_auth.Logout.as_view(), name="api_logout"), re_path( r"^api/tiles/(?P%s|())$" % (uuid_regex), api.Tile.as_view(), From 6c40511c7350890cc2056c55502b1f2547930b18 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 5 Aug 2024 11:16:14 -0400 Subject: [PATCH 4/5] Make tests/views/api a package --- tests/views/api/__init__.py | 0 tests/views/{api_tests.py => api/test_resources.py} | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/views/api/__init__.py rename tests/views/{api_tests.py => api/test_resources.py} (99%) diff --git a/tests/views/api/__init__.py b/tests/views/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/views/api_tests.py b/tests/views/api/test_resources.py similarity index 99% rename from tests/views/api_tests.py rename to tests/views/api/test_resources.py index fba861e930f..740d2ad70ad 100644 --- a/tests/views/api_tests.py +++ b/tests/views/api/test_resources.py @@ -34,10 +34,10 @@ from arches.app.utils.betterJSONSerializer import JSONSerializer, JSONDeserializer # these tests can be run from the command line via -# python manage.py test tests.views.api_tests --settings="tests.test_settings" +# python manage.py test tests.views.api.test_resources --settings="tests.test_settings" -class APITests(ArchesTestCase): +class ResourceAPITests(ArchesTestCase): @classmethod def setUpClass(cls): cls.data_type_graphid = "330802c5-95bd-11e8-b7ac-acde48001122" From 7132231c49d3ff7257450d5b2e4c66ad7f52fcb9 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 5 Aug 2024 11:26:43 -0400 Subject: [PATCH 5/5] Add view tests re #11261 --- arches/app/views/api/auth.py | 2 + releases/8.0.0.md | 1 + tests/views/api/test_auth.py | 110 +++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 tests/views/api/test_auth.py diff --git a/arches/app/views/api/auth.py b/arches/app/views/api/auth.py index a51323bf578..6f95f5b759c 100644 --- a/arches/app/views/api/auth.py +++ b/arches/app/views/api/auth.py @@ -42,6 +42,8 @@ def post(self, request): failure_title, failure_msg_invalid, status=HTTPStatus.UNAUTHORIZED ) if not user.is_active: + # ModelBackend already disallows inactive users, but add some safety + # and disallow this no matter the backend for now. return JSONErrorResponse( failure_title, failure_msg_inactive, status=HTTPStatus.FORBIDDEN ) diff --git a/releases/8.0.0.md b/releases/8.0.0.md index 17844f5d1d8..77b2e0c5faf 100644 --- a/releases/8.0.0.md +++ b/releases/8.0.0.md @@ -6,6 +6,7 @@ Arches 8.0.0 Release Notes ### Additional highlights +- Add session-based REST APIs for login, logout [#11261] ### Dependency changes ``` diff --git a/tests/views/api/test_auth.py b/tests/views/api/test_auth.py new file mode 100644 index 00000000000..dec17f91b48 --- /dev/null +++ b/tests/views/api/test_auth.py @@ -0,0 +1,110 @@ +from http import HTTPStatus + +from django.contrib.auth.models import User +from django.test.utils import override_settings +from django.urls import reverse + +from tests.base_test import ArchesTestCase, sync_overridden_test_settings_to_arches + +# these tests can be run from the command line via +# python manage.py test tests.views.api.test_auth --settings="tests.test_settings" + + +class AuthAPITests(ArchesTestCase): + @classmethod + def setUpTestData(cls): + cls.visitor = User.objects.create( + username="visitor", + first_name="Esperanza", + last_name="Spalding", + ) + cls.visitor.set_password("jazz") + cls.visitor.save() + + def test_login_success(self): + response = self.client.post( + reverse("api_login"), + {"username": "visitor", "password": "jazz"}, + content_type="application/json", + ) + self.assertContains( + response, + '{"first_name": "Esperanza", "last_name": "Spalding", "username": "visitor"}', + status_code=HTTPStatus.OK, + ) + self.assertNotContains(response, "password") + + @override_settings(FORCE_TWO_FACTOR_AUTHENTICATION=True) + def test_login_rejected_two_factor_enabled(self): + with ( + sync_overridden_test_settings_to_arches(), + self.assertLogs("django.request", level="WARNING"), + ): + response = self.client.post( + reverse("api_login"), + {"username": "visitor", "password": "jazz"}, + content_type="application/json", + ) + self.assertContains( + response, + "Two-factor authentication required.", + status_code=HTTPStatus.UNAUTHORIZED, + ) + + def test_login_failure_missing_credentials(self): + with self.assertLogs("django.request", level="WARNING"): + response = self.client.post( + reverse("api_login"), {}, content_type="application/json" + ) + self.assertContains( + response, + "Invalid username and/or password.", + status_code=HTTPStatus.UNAUTHORIZED, + ) + + def test_login_failure_wrong_credentials(self): + with self.assertLogs("django.request", level="WARNING"): + response = self.client.post( + reverse("api_login"), + {"username": "visitor", "password": "garbage"}, + content_type="application/json", + ) + self.assertContains( + response, + "Invalid username and/or password.", + status_code=HTTPStatus.UNAUTHORIZED, + ) + + def test_login_failure_inactive_user(self): + self.visitor.is_active = False + self.visitor.save() + with ( + self.modify_settings( + AUTHENTICATION_BACKENDS={ + "prepend": "django.contrib.auth.backends.AllowAllUsersModelBackend" + } + ), + self.assertLogs("django.request", level="WARNING"), + ): + response = self.client.post( + reverse("api_login"), + {"username": "visitor", "password": "jazz"}, + content_type="application/json", + ) + self.assertContains( + response, + "This account is no longer active.", + status_code=HTTPStatus.FORBIDDEN, + ) + + def test_login_get_not_allowed(self): + with self.assertLogs("django.request", level="WARNING"): + self.client.get( + reverse("api_login"), status_code=HTTPStatus.METHOD_NOT_ALLOWED + ) + + def test_logout(self): + self.client.force_login(self.visitor) + response = self.client.post(reverse("api_logout")) + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.assertEqual(response.wsgi_request.user.username, "")