Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add session-based auth REST API endpoints for login, logout #11261 #11284

Merged
merged 5 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ source =
arches/

omit =
*/__init__.py
*/celery.py
*/hosts.py
*/models/migrations/*
Expand Down
6 changes: 5 additions & 1 deletion arches/app/templates/arches_urls.htm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}'
Expand All @@ -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' %}"
</div>
signup="{% url 'signup' %}"
auth="{% url 'auth' %}"
></div>
{% endblock arches_urls %}
4 changes: 2 additions & 2 deletions arches/app/views/api.py → arches/app/views/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions arches/app/views/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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:
# 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
)
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)
2 changes: 2 additions & 0 deletions arches/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions arches/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<tileid>%s|())$" % (uuid_regex),
api.Tile.as_view(),
Expand Down
1 change: 1 addition & 0 deletions releases/8.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Arches 8.0.0 Release Notes

### Additional highlights

- Add session-based REST APIs for login, logout [#11261]

### Dependency changes
```
Expand Down
Empty file added tests/views/api/__init__.py
Empty file.
110 changes: 110 additions & 0 deletions tests/views/api/test_auth.py
Original file line number Diff line number Diff line change
@@ -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, "")
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down