diff --git a/docs/tethys_portal/configuration.rst b/docs/tethys_portal/configuration.rst index 776e36072..faf132e5e 100644 --- a/docs/tethys_portal/configuration.rst +++ b/docs/tethys_portal/configuration.rst @@ -84,6 +84,7 @@ ENABLE_OPEN_SIGNUP anyone can create a Tethys Po REGISTER_CONTROLLER override the default registration page with a custom controller. The value should be the dot-path to the controller function/class (e.g. ``tethysext.my_extension.controllers.custom_registration``) ENABLE_OPEN_PORTAL no login required for Tethys Portal when ``True``. Defaults to ``False``. Controllers in apps need to use the ``controller`` decorator from the Tethys SDK, rather than Django's ``login_required`` decorator. ENABLE_RESTRICTED_APP_ACCESS app access can be restricted based on user object permissions when ``True``. Defaults to ``False``. A list can also be provided to restrict specific applications. If ``ENABLE_OPEN_PORTAL`` is set to ``True`` this setting has no effect. That is, users will have unrestricted access to apps independently of the value of this setting. +ALLOW_JWT_BASIC_AUTHENTICATION allows users to get a JSON Web Token (JWT) using basic authentication when ``True``. Defaults to ``False``. If set to true, users can obtain a JWT by sending a POST request with their username and password to the ``/api/token/`` endpoint. TETHYS_WORKSPACES_ROOT location to where app/user workspaces will be created. Defaults to :file:`/workspaces`. STATIC_ROOT the Django `STATIC_ROOT `_ setting. Defaults to :file:`/static`. MEDIA_URL the Django `MEDIA_URL `_ setting. Defaults to ``'/media/'``. diff --git a/environment.yml b/environment.yml index 047779aa7..5697a38c7 100644 --- a/environment.yml +++ b/environment.yml @@ -25,10 +25,10 @@ dependencies: - daphne - setuptools_scm - pip - - requests>=2.32.4,<3 # required by lots of things + - requests>=2.32.4,<3 # required by lots of things - tornado>=6.5,<7 - cryptography>=44.0.1,<45 - - bcrypt # also required by channels, docker, daphne, condorpy + - bcrypt # also required by channels, docker, daphne, condorpy # Gen CLI commands - pyyaml @@ -39,14 +39,14 @@ dependencies: - django-model-utils - django-guardian -###################################### -# Optional Dependencies -###################################### + ###################################### + # Optional Dependencies + ###################################### # Security Plugins - - django-cors-headers # enable cors? - - django-session-security # session timeouts - - django-axes # tracked failed login attempts + - django-cors-headers # enable cors? + - django-session-security # session timeouts + - django-axes # tracked failed login attempts # Login/Account Plugins - django-gravatar2 @@ -54,7 +54,7 @@ dependencies: - django-mfa2 - django-recaptcha - social-auth-app-django - - hs_restclient # Used with HydroShare Social backend + - hs_restclient # Used with HydroShare Social backend - python-jose # required by django-mfa2 - used for onelogin backend - django-oauth-toolkit @@ -62,12 +62,13 @@ dependencies: - arrow - isodate - # Misc Plugins - - django-termsandconditions # require users to accept terms and conditions - - django-cookie-consent # requires users to accept cookie usage - - django-analytical # track usage analytics - - django-json-widget # enable json widget for app settings - - djangorestframework # enable REST API framework + # Misc Plugins + - django-termsandconditions # require users to accept terms and conditions + - django-cookie-consent # requires users to accept cookie usage + - django-analytical # track usage analytics + - django-json-widget # enable json widget for app settings + - djangorestframework # enable REST API framework + - djangorestframework_simplejwt # JWT authentication for REST API # Map Layout - PyShp @@ -81,9 +82,9 @@ dependencies: # database dependencies - postgresql - - psycopg2 # required by tethys_dataset_services - - sqlalchemy=1.* # TODO: what will it take to support sqlalchemy 2.0? - - geoalchemy2 # requires sqlalchemy + - psycopg2 # required by tethys_dataset_services + - sqlalchemy=1.* # TODO: what will it take to support sqlalchemy 2.0? + - geoalchemy2 # requires sqlalchemy # plotting Gizmo dependencies - plotly @@ -95,11 +96,12 @@ dependencies: - tethys_dask_scheduler>=1.0.2 # external services dependencies - - tethys_dataset_services>=2.0.0 # used with all data services - - owslib # used for creating WPS services - - siphon # used with Threads + - tethys_dataset_services>=2.0.0 # used with all data services + - owslib # used for creating WPS services + - siphon # used with Threads - # Docs + + # Docs - git # tests/style dependencies diff --git a/scripts/generate_portal_config_tables.py b/scripts/generate_portal_config_tables.py index fd419251e..eaa2d7f93 100644 --- a/scripts/generate_portal_config_tables.py +++ b/scripts/generate_portal_config_tables.py @@ -20,6 +20,7 @@ "ENABLE_OPEN_SIGNUP": 'anyone can create a Tethys Portal account using a "Sign Up" link on the home page when ``True``. Defaults to ``False``.', "REGISTER_CONTROLLER": "override the default registration page with a custom controller. The value should be the dot-path to the controller function/class (e.g. ``tethysext.my_extension.controllers.custom_registration``)", "ENABLE_OPEN_PORTAL": "no login required for Tethys Portal when ``True``. Defaults to ``False``. Controllers in apps need to use the ``controller`` decorator from the Tethys SDK, rather than Django's ``login_required`` decorator.", + "ALLOW_JWT_BASIC_AUTHENTICATION": "allows users to get a JSON Web Token (JWT) using basic authentication when ``True``. Defaults to ``False``. If set to true, users can obtain a JWT by sending a POST request with their username and password to the ``/api/token/`` endpoint.", "ENABLE_RESTRICTED_APP_ACCESS": "app access can be restricted based on user object permissions when ``True``. Defaults to ``False``. A list can also be provided to restrict specific applications. If ``ENABLE_OPEN_PORTAL`` is set to ``True`` this setting has no effect. That is, users will have unrestricted access to apps independently of the value of this setting.", "TETHYS_WORKSPACES_ROOT": "location to which app workspaces will be synced when ``tethys manage collectworkspaces`` is executed. Gathering all workspaces to one location is recommended for production deployments to allow for easier updating and backing up of app data. Defaults to :file:`/workspaces`.", "STATIC_ROOT": f"the Django `STATIC_ROOT `_ setting. Defaults to :file:`/static`.", diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_api.py b/tests/unit_tests/test_tethys_portal/test_views/test_api.py index d456e074b..c7b585916 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_api.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_api.py @@ -5,7 +5,9 @@ from django.urls import reverse, clear_url_caches from django.test import override_settings from django.conf import settings +import warnings +from unittest.mock import patch, MagicMock from tethys_apps.base.testing.testing import TethysTestCase @@ -31,67 +33,121 @@ def tearDown(self): pass def test_get_csrf_not_authenticated(self): - """Test get_csrf API endpoint not authenticated.""" - response = self.client.get(reverse("api:get_csrf")) - self.assertEqual(response.status_code, 401) + """Test get_csrf API endpoint not authenticated and check deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_csrf")) + self.assertEqual(response.status_code, 401) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_csrf is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) @override_settings(ENABLE_OPEN_PORTAL=True) def test_get_csrf_not_authenticated_but_open_portal(self): - """Test get_csrf API endpoint not authenticated.""" + """Test get_csrf API endpoint not authenticated and check deprecation warning.""" self.client.force_login(self.user) - response = self.client.get(reverse("api:get_csrf")) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, HttpResponse) - self.assertIn("X-CSRFToken", response.headers) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_csrf")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, HttpResponse) + self.assertIn("X-CSRFToken", response.headers) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_csrf is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) def test_get_csrf_authenticated(self): - """Test get_csrf API endpoint authenticated.""" + """Test get_csrf API endpoint authenticated and check deprecation warning.""" self.client.force_login(self.user) - response = self.client.get(reverse("api:get_csrf")) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, HttpResponse) - self.assertIn("X-CSRFToken", response.headers) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_csrf")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, HttpResponse) + self.assertIn("X-CSRFToken", response.headers) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_csrf is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) def test_get_session_not_authenticated(self): - """Test get_session API endpoint not authenticated.""" - response = self.client.get(reverse("api:get_session")) - self.assertEqual(response.status_code, 401) + """Test get_session API endpoint not authenticated and check deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_session")) + self.assertEqual(response.status_code, 401) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_session is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) @override_settings(ENABLE_OPEN_PORTAL=True) def test_get_session_not_authenticated_but_open_portal(self): - """Test get_session API endpoint not authenticated.""" - response = self.client.get(reverse("api:get_session")) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, JsonResponse) - # self.assertIn('Set-Cookie', response.headers) - json = response.json() - self.assertIn("isAuthenticated", json) - self.assertTrue(json["isAuthenticated"]) + """Test get_session API endpoint not authenticated and check deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_session")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + json = response.json() + self.assertIn("isAuthenticated", json) + self.assertTrue(json["isAuthenticated"]) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_session is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) def test_get_session_authenticated(self): - """Test get_session API endpoint authenticated.""" + """Test get_session API endpoint authenticated and check deprecation warning.""" self.client.force_login(self.user) - response = self.client.get(reverse("api:get_session")) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, JsonResponse) - # self.assertIn('Set-Cookie', response.headers) - json = response.json() - self.assertIn("isAuthenticated", json) - self.assertTrue(json["isAuthenticated"]) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = self.client.get(reverse("api:get_session")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + json = response.json() + self.assertIn("isAuthenticated", json) + self.assertTrue(json["isAuthenticated"]) + self.assertTrue(any(item.category == DeprecationWarning for item in w)) + self.assertTrue( + any( + "get_session is deprecated and will be removed in a future tethys version" + in str(item.message) + for item in w + ) + ) def test_get_whoami_not_authenticated(self): - """Test get_whoami API endpoint not authenticated.""" - response = self.client.get(reverse("api:get_whoami")) - self.assertEqual(response.status_code, 401) - - @override_settings(ENABLE_OPEN_PORTAL=True) - def test_get_whoami_not_authenticated_but_open_portal(self): """Test get_whoami API endpoint not authenticated.""" response = self.client.get(reverse("api:get_whoami")) self.assertEqual(response.status_code, 200) self.assertIsInstance(response, JsonResponse) json = response.json() - self.assertDictEqual({}, json) + self.assertDictEqual( + {"username": "", "isAuthenticated": False, "isStaff": False}, json + ) def test_get_whoami_authenticated(self): """Test get_whoami API endpoint authenticated.""" @@ -270,3 +326,83 @@ def test_get_app_invalid_id(self): json = response.json() self.assertIn("error", json) self.assertEqual('Could not find app "foo-bar".', json["error"]) + + def test_get_jwt_token_GET_not_authenticated(self): + """Test get_jwt_token API endpoint with GET method.""" + response = self.client.get(reverse("api:token_obtain_pair")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertDictEqual({"access": None, "refresh": None}, response.json()) + + def test_get_jwt_token_GET_authenticated(self): + """Test get_jwt_token API endpoint with GET method.""" + + self.client.force_login(self.user) + with patch("tethys_portal.views.api.RefreshToken") as mock_refresh_token: + mock_refresh = MagicMock() + mock_refresh.access_token = "mock_access" + mock_refresh.__str__.return_value = "mock_refresh" + mock_refresh_token.for_user.return_value = mock_refresh + + response = self.client.get(reverse("api:token_obtain_pair")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertDictEqual( + {"access": "mock_access", "refresh": "mock_refresh"}, response.json() + ) + + @override_settings(ALLOW_JWT_BASIC_AUTHENTICATION=False) + def test_get_jwt_token_POST_not_allowed(self): + """Test get_jwt_token API endpoint with POST method when POST is not allowed.""" + response = self.client.post(reverse("api:token_obtain_pair")) + self.assertEqual(response.status_code, 405) + self.assertEqual(response.reason_phrase, "Method Not Allowed") + self.assertIn( + "JWT basic authentication is disabled.", response.content.decode() + ) + + @override_settings(ALLOW_JWT_BASIC_AUTHENTICATION=True) + def test_get_jwt_token_POST_not_authenticated(self): + """Test get_jwt_token API endpoint with POST method.""" + response = self.client.post(reverse("api:token_obtain_pair")) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertDictEqual( + { + "access": None, + "refresh": None, + "error": "Username and password are required for authentication.", + }, + response.json(), + ) + + @override_settings(ALLOW_JWT_BASIC_AUTHENTICATION=True) + def test_get_jwt_token_POST_invalid_user(self): + """Test get_jwt_token API endpoint with POST method and invalid credentials.""" + data = {"username": "invalid_user", "password": "wrong_password"} + response = self.client.post(reverse("api:token_obtain_pair"), data) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertDictEqual( + {"access": None, "refresh": None, "error": "Invalid credentials."}, + response.json(), + ) + + @override_settings(ALLOW_JWT_BASIC_AUTHENTICATION=True) + def test_get_jwt_token_POST_valid_user(self): + """Test get_jwt_token API endpoint with POST method and valid credentials.""" + data = {"username": self.user.username, "password": "password"} + self.user.set_password("password") + self.user.save() + with patch("tethys_portal.views.api.RefreshToken") as mock_refresh_token: + mock_refresh = MagicMock() + mock_refresh.access_token = "mock_access" + mock_refresh.__str__.return_value = "mock_refresh" + mock_refresh_token.for_user.return_value = mock_refresh + + response = self.client.post(reverse("api:token_obtain_pair"), data) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, JsonResponse) + self.assertDictEqual( + {"access": "mock_access", "refresh": "mock_refresh"}, response.json() + ) diff --git a/tethys_cli/scaffold_templates/app_templates/react/reactapp/components/loader/Loader.js b/tethys_cli/scaffold_templates/app_templates/react/reactapp/components/loader/Loader.js index 07fb54b50..7ba9dbd84 100644 --- a/tethys_cli/scaffold_templates/app_templates/react/reactapp/components/loader/Loader.js +++ b/tethys_cli/scaffold_templates/app_templates/react/reactapp/components/loader/Loader.js @@ -1,18 +1,18 @@ -import PropTypes from 'prop-types'; -import { useState, useEffect } from 'react'; +import PropTypes from "prop-types"; +import { useState, useEffect } from "react"; -import tethysAPI from 'services/api/tethys'; -import LoadingAnimation from 'components/loader/LoadingAnimation'; -import { AppContext } from 'components/context'; +import tethysAPI from "services/api/tethys"; +import LoadingAnimation from "components/loader/LoadingAnimation"; +import { AppContext } from "components/context"; const APP_ID = process.env.TETHYS_APP_ID; const LOADER_DELAY = process.env.TETHYS_LOADER_DELAY; -function Loader({children}) { +function Loader({ children }) { const [error, setError] = useState(null); const [isLoaded, setIsLoaded] = useState(false); const [appContext, setAppContext] = useState(null); - + const handleError = (error) => { // Delay setting the error to avoid flashing the loading animation setTimeout(() => { @@ -20,42 +20,34 @@ function Loader({children}) { }, LOADER_DELAY); }; - useEffect(() => { - // Get the session first - tethysAPI.getSession() - .then(() => { - // Then load all other app data - Promise.all([ - tethysAPI.getAppData(APP_ID), - tethysAPI.getUserData(), - tethysAPI.getCSRF(), - ]) - .then(([tethysApp, user, csrf]) => { - // Update app context - setAppContext({tethysApp, user, csrf}); - - // Allow for minimum delay to display loader - setTimeout(() => { - setIsLoaded(true) - }, LOADER_DELAY); - }) - .catch(handleError); - }).catch(handleError); + useEffect(() => { + // load all other app data + Promise.all([ + tethysAPI.getAppData(APP_ID), + tethysAPI.getUserData(), + tethysAPI.getJWTToken(), + ]) + .then(([tethysApp, user, jwt]) => { + // Update app context + setAppContext({ tethysApp, user, jwt }); + + // Allow for minimum delay to display loader + setTimeout(() => { + setIsLoaded(true); + }, LOADER_DELAY); + }) + .catch(handleError); }, []); if (error) { // Throw error so it will be caught by the ErrorBoundary throw error; } else if (!isLoaded) { - return ( - - ); + return ; } else { return ( <> - - {children} - + {children} ); } diff --git a/tethys_cli/scaffold_templates/app_templates/react/reactapp/services/api/tethys.js b/tethys_cli/scaffold_templates/app_templates/react/reactapp/services/api/tethys.js index 0f49c8986..b36712a60 100644 --- a/tethys_cli/scaffold_templates/app_templates/react/reactapp/services/api/tethys.js +++ b/tethys_cli/scaffold_templates/app_templates/react/reactapp/services/api/tethys.js @@ -1,18 +1,37 @@ import apiClient from "services/api/client"; -function getSession() { - return apiClient.get('/api/session/'); +// JWT token storage helpers +const ACCESS_TOKEN_KEY = "jwt_access"; +const REFRESH_TOKEN_KEY = "jwt_refresh"; + +export function setTokens(access, refresh) { + localStorage.setItem(ACCESS_TOKEN_KEY, access); + localStorage.setItem(REFRESH_TOKEN_KEY, refresh); +} +export function getAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} +export function getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY); +} + +async function getJWTToken() { + const response = await apiClient.get("/api/token/", {}); + const access = response.access; + const refresh = response.refresh; + setTokens(access, refresh); + return { access, refresh }; } -function getCSRF() { - return apiClient.get('/api/csrf/') - .then(response => { - return response.headers['x-csrftoken']; - }); +async function refreshJWTToken() { + const response = await apiClient.post("/api/token/refresh/", { + refresh: getRefreshToken(), + }); + return response.data.access; } function getUserData() { - return apiClient.get('/api/whoami/'); + return apiClient.get("/api/whoami/"); } function getAppData(tethys_app_url) { @@ -20,10 +39,10 @@ function getAppData(tethys_app_url) { } const tethysAPI = { - getSession, - getCSRF, + getJWTToken, + refreshJWTToken, getAppData, getUserData, }; -export default tethysAPI; \ No newline at end of file +export default tethysAPI; diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 545446635..f20df7b5c 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -321,6 +321,9 @@ AUTHENTICATION_BACKENDS = tuple( portal_config_settings.pop("AUTHENTICATION_BACKENDS", []) + AUTHENTICATION_BACKENDS ) +ALLOW_JWT_BASIC_AUTHENTICATION = portal_config_settings.pop( + "ALLOW_JWT_BASIC_AUTHENTICATION", False +) RESOURCE_QUOTA_HANDLERS = portal_config_settings.pop( "RESOURCE_QUOTA_HANDLERS_OVERRIDE", @@ -341,7 +344,9 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), } diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index 081683b5d..b48a574a5 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -22,6 +22,10 @@ PasswordResetConfirmView, PasswordResetCompleteView, ) +from rest_framework_simplejwt.views import ( + TokenRefreshView, + TokenVerifyView, +) from tethys_apps.urls import extension_urls @@ -161,6 +165,13 @@ re_path(r"^csrf/$", tethys_portal_api.get_csrf, name="get_csrf"), re_path(r"^session/$", tethys_portal_api.get_session, name="get_session"), re_path(r"^whoami/$", tethys_portal_api.get_whoami, name="get_whoami"), + re_path( + r"^token/$", + tethys_portal_api.get_jwt_token, + name="token_obtain_pair", + ), + re_path(r"^token/refresh/$", TokenRefreshView.as_view(), name="token_refresh"), + re_path(r"^token/verify/$", TokenVerifyView.as_view(), name="token_verify"), re_path(r"^apps/(?P[\w-]+)/$", tethys_portal_api.get_app, name="get_app"), ] diff --git a/tethys_portal/views/api.py b/tethys_portal/views/api.py index d8d1cd14d..4cc1afa5e 100644 --- a/tethys_portal/views/api.py +++ b/tethys_portal/views/api.py @@ -1,14 +1,21 @@ -from django.http import HttpResponse, JsonResponse -from django.middleware.csrf import get_token +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework_simplejwt.tokens import RefreshToken + +from django.http import HttpResponseNotAllowed, HttpResponse, JsonResponse from django.templatetags.static import static from django.shortcuts import reverse -from django.views.decorators.csrf import ensure_csrf_cookie from django.conf import settings +from django.contrib.auth import authenticate +from django.middleware.csrf import get_token +from django.views.decorators.csrf import ensure_csrf_cookie from tethys_apps.exceptions import TethysAppSettingNotAssigned from tethys_portal.optional_dependencies import optional_import from tethys_portal.utilities import json_serializer +import warnings + # Optional dependencies get_gravatar_url = optional_import( "get_gravatar_url", from_module="django_gravatar.helpers" @@ -16,6 +23,11 @@ def get_csrf(request): + warnings.warn( + "get_csrf is deprecated and will be removed in a future tethys version. Use the get_jwt_token GET endpoint instead.", + DeprecationWarning, + stacklevel=2, + ) if not request.user.is_authenticated and not settings.ENABLE_OPEN_PORTAL: return HttpResponse("Unauthorized", status=401) return HttpResponse(headers={"X-CSRFToken": get_token(request)}) @@ -23,78 +35,123 @@ def get_csrf(request): @ensure_csrf_cookie def get_session(request): + warnings.warn( + "get_session is deprecated and will be removed in a future tethys version. Use the get_jwt_token GET endpoint instead.", + DeprecationWarning, + stacklevel=2, + ) if not request.user.is_authenticated and not settings.ENABLE_OPEN_PORTAL: return HttpResponse("Unauthorized", status=401) return JsonResponse({"isAuthenticated": True}) +@api_view(["POST", "GET"]) +@permission_classes([AllowAny]) +def get_jwt_token(request): + if request.method == "POST": + if not getattr(settings, "ALLOW_JWT_BASIC_AUTHENTICATION", False): + return HttpResponseNotAllowed( + ["GET"], "JWT basic authentication is disabled." + ) + username = request.data.get("username") + password = request.data.get("password") + if username and password: + user = authenticate(request, username=username, password=password) + if user is not None: + refresh = RefreshToken.for_user(user) + return JsonResponse( + { + "access": str(refresh.access_token), + "refresh": str(refresh), + } + ) + else: + return JsonResponse( + {"access": None, "refresh": None, "error": "Invalid credentials."}, + ) + + return JsonResponse( + { + "access": None, + "refresh": None, + "error": "Username and password are required for authentication.", + }, + ) + + # Otherwise, use session user + user = request.user + if not user.is_authenticated: + return JsonResponse({"access": None, "refresh": None}) + refresh = RefreshToken.for_user(user) + return JsonResponse( + { + "access": str(refresh.access_token), + "refresh": str(refresh), + } + ) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) def get_whoami(request): - if not request.user.is_authenticated: - if settings.ENABLE_OPEN_PORTAL: - return JsonResponse({}) - - return HttpResponse("Unauthorized", status=401) - + user = request.user response_data = { - "username": request.user.username, - "firstName": request.user.first_name, - "lastName": request.user.last_name, - "email": request.user.email, - "isAuthenticated": request.user.is_authenticated, - "isStaff": request.user.is_staff, + "username": user.username, + "isAuthenticated": user.is_authenticated, + "isStaff": user.is_staff, } - - # Generate gravatar URL if django_gravatar is available - try: - email = request.user.email if request.user.email else "tethys@example.com" - gravatar_url = get_gravatar_url(email, size=80) - response_data["gravatarUrl"] = gravatar_url - except Exception: - # If django_gravatar is not installed or fails, just skip adding gravatar URL - pass - + if not user.is_anonymous: + response_data["firstName"] = user.first_name + response_data["lastName"] = user.last_name + response_data["email"] = user.email + try: + email = user.email if user.email else "tethys@example.com" + gravatar_url = get_gravatar_url(email, size=80) + response_data["gravatarUrl"] = gravatar_url + except Exception: + pass return JsonResponse(response_data) +@api_view(["GET"]) +@permission_classes([AllowAny]) def get_app(request, app): from tethys_apps.models import TethysApp package = app.replace("-", "_") try: - app = TethysApp.objects.get(package=package) + app_obj = TethysApp.objects.get(package=package) except TethysApp.DoesNotExist: return JsonResponse({"error": f'Could not find app "{app}".'}) metadata = { - "title": app.name, - "description": app.description, - "tags": app.tags, - "package": app.package, - "urlNamespace": app.url_namespace, - "color": app.color, - "icon": static(app.icon), + "title": app_obj.name, + "description": app_obj.description, + "tags": app_obj.tags, + "package": app_obj.package, + "urlNamespace": app_obj.url_namespace, + "color": app_obj.color, + "icon": static(app_obj.icon), "exitUrl": ( reverse("app_library") if settings.MULTIPLE_APP_MODE - else reverse(app.index_url) + else reverse(app_obj.index_url) ), - "rootUrl": reverse(app.index_url), - "settingsUrl": f'{reverse("admin:index")}tethys_apps/tethysapp/{app.id}/change/', + "rootUrl": reverse(app_obj.index_url), + "settingsUrl": f'{reverse("admin:index")}tethys_apps/tethysapp/{app_obj.id}/change/', } if request.user.is_authenticated: metadata["customSettings"] = dict() - for s in app.custom_settings: + for s in app_obj.custom_settings: if not s.include_in_api: continue - v = None try: v = s.get_value() except TethysAppSettingNotAssigned: pass - metadata["customSettings"][s.name] = { "type": ( s.type