Skip to content
Merged
1 change: 1 addition & 0 deletions docs/tethys_portal/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:`<TETHYS_HOME>/workspaces`.
STATIC_ROOT the Django `STATIC_ROOT <https://docs.djangoproject.com/en/4.2/ref/settings/#static-root>`_ setting. Defaults to :file:`<TETHYS_HOME>/static`.
MEDIA_URL the Django `MEDIA_URL <https://docs.djangoproject.com/en/4.2/ref/settings/#media-url>`_ setting. Defaults to ``'/media/'``.
Expand Down
46 changes: 24 additions & 22 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,35 +39,36 @@ 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
- django-simple-captcha
- 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

# datetime dependencies for "humanize" template filter (used with MFA2)
- 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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/generate_portal_config_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:`<TETHYS_HOME>/workspaces`.",
"STATIC_ROOT": f"the Django `STATIC_ROOT <https://docs.djangoproject.com/en/{DJANGO_VERSION}/ref/settings/#static-root>`_ setting. Defaults to :file:`<TETHYS_HOME>/static`.",
Expand Down
214 changes: 175 additions & 39 deletions tests/unit_tests/test_tethys_portal/test_views/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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."""
Expand Down Expand Up @@ -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()
)
Loading