diff --git a/geonode/base/migrations/0099_resourcebase_auth_config.py b/geonode/base/migrations/0099_resourcebase_auth_config.py
new file mode 100644
index 00000000000..9190ccb0bcb
--- /dev/null
+++ b/geonode/base/migrations/0099_resourcebase_auth_config.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.13 on 2026-05-08 05:18
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("base", "0098_alter_license_identifier"),
+ ("security", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="resourcebase",
+ name="auth_config",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="authconfigresources",
+ to="security.authconfig",
+ ),
+ ),
+ ]
diff --git a/geonode/base/models.py b/geonode/base/models.py
index ba7024be492..a04830adc2b 100644
--- a/geonode/base/models.py
+++ b/geonode/base/models.py
@@ -77,7 +77,7 @@
from geonode.thumbs.utils import thumb_size, remove_thumbs, get_unique_upload_path, ThumbnailAlgorithms
from geonode.groups.models import GroupProfile
from geonode.security.utils import get_visible_resources, get_geoapp_subtypes
-from geonode.security.models import PermissionLevelMixin
+from geonode.security.models import PermissionLevelMixin, AuthConfig
from geonode.security.permissions import VIEW_PERMISSIONS, OWNER_PERMISSIONS
from geonode.notifications_helper import send_notification, get_notification_recipients
@@ -884,6 +884,13 @@ class ResourceBase(PolymorphicModel, PermissionLevelMixin, ItemBase):
)
blob = JSONField(null=True, default=dict, blank=True)
+ auth_config = models.ForeignKey(
+ AuthConfig,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="authconfigresources",
+ )
subtype = models.CharField(max_length=128, null=True, blank=True)
diff --git a/geonode/harvesting/harvesters/wms.py b/geonode/harvesting/harvesters/wms.py
index 2295c92b797..4a3c5af7a77 100644
--- a/geonode/harvesting/harvesters/wms.py
+++ b/geonode/harvesting/harvesters/wms.py
@@ -22,6 +22,7 @@
from datetime import datetime
from functools import lru_cache
from urllib.parse import unquote, urlparse, urlencode, parse_qsl, ParseResult
+from owslib.util import Authentication
import requests
from lxml import etree
@@ -32,7 +33,6 @@
from django.conf import settings
from django.contrib.gis import geos
from django.template.defaultfilters import slugify
-from requests.auth import HTTPBasicAuth
from geonode.layers.models import Dataset
from geonode.base.models import Link, ResourceBase
from geonode.layers.enumerations import GXP_PTYPES
@@ -53,7 +53,15 @@
@lru_cache()
def WebMapService(
- url, version="1.3.0", xml=None, username=None, password=None, parse_remote_metadata=False, timeout=30, headers=None
+ url,
+ version="1.3.0",
+ xml=None,
+ username=None,
+ password=None,
+ parse_remote_metadata=False,
+ timeout=30,
+ headers=None,
+ auth=None,
):
"""
API for Web Map Service (WMS) methods and metadata.
@@ -72,6 +80,7 @@ def WebMapService(
clean_url = clean_ows_url(url)
base_ows_url = clean_url
+ auth = Authentication(auth_delegate=auth)
if version in ["1.1.1"]:
return (
@@ -81,6 +90,7 @@ def WebMapService(
version=version,
xml=xml,
parse_remote_metadata=parse_remote_metadata,
+ auth=auth,
username=username,
password=password,
timeout=timeout,
@@ -95,6 +105,7 @@ def WebMapService(
version=version,
xml=xml,
parse_remote_metadata=parse_remote_metadata,
+ auth=auth,
username=username,
password=password,
timeout=timeout,
@@ -203,15 +214,12 @@ def wms_call(self, kind="GetCapabilities", override_version=None, additional_par
# getting the service
from geonode.services.models import Service
- # check if the connected service has username and password
- has_basic_auth = Service.objects.filter(
- harvester__pk=self.harvester_id, username__isnull=False, password__isnull=False
- )
basic_auth = None
- if has_basic_auth.exists():
- # if the username and password are set, we can prepare the basic auth for the request
- service = has_basic_auth.first()
- basic_auth = HTTPBasicAuth(service.username, service.get_password())
+ service = Service.objects.filter(harvester__pk=self.harvester_id).select_related("auth_config").first()
+ if service and service.needs_authentication:
+ from geonode.security.auth_registry import auth_handler_registry
+
+ basic_auth = auth_handler_registry.build(service.auth_config).get_request_auth()
response = self.http_session.get(
self.get_ogc_wms_url(wms_url, version=_version), params=params, auth=basic_auth
diff --git a/geonode/security/admin.py b/geonode/security/admin.py
new file mode 100644
index 00000000000..c4ab95cabda
--- /dev/null
+++ b/geonode/security/admin.py
@@ -0,0 +1,92 @@
+#########################################################################
+#
+# Copyright (C) 2026 OSGeo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+#########################################################################
+import json
+
+from django import forms
+from django.contrib import admin
+
+from geonode.security.auth_registry import auth_handler_registry
+from geonode.security.models import AuthConfig, URLPatternAuthConfig
+
+
+def _get_auth_type_choices():
+ if not auth_handler_registry.registry:
+ auth_handler_registry.init_registry()
+ return [(handled_type, handled_type) for handled_type in sorted(auth_handler_registry.registry.keys())]
+
+
+class AuthConfigAdminForm(forms.ModelForm):
+ type = forms.ChoiceField(choices=(), required=True)
+ payload = forms.CharField(widget=forms.Textarea, required=True)
+
+ class Meta:
+ model = AuthConfig
+ fields = ("type",)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ choices = _get_auth_type_choices()
+ current_type = getattr(self.instance, "type", None)
+ if current_type and current_type not in {value for value, _ in choices}:
+ choices = choices + [(current_type, current_type)]
+ self.fields["type"].choices = choices
+ if self.instance.pk and self.instance._payload:
+ self.initial["payload"] = json.dumps(self.instance.payload)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ auth_type = cleaned_data.get("type")
+ payload_value = cleaned_data.get("payload")
+ if not auth_type or not payload_value:
+ return cleaned_data
+
+ auth_handler_cls = auth_handler_registry.get_handler_class(auth_type)
+ if auth_handler_cls is None:
+ raise forms.ValidationError(f"Unsupported auth config type '{auth_type}'")
+
+ try:
+ payload_dict = json.loads(payload_value)
+ except json.JSONDecodeError:
+ raise forms.ValidationError("Payload must be valid JSON.")
+
+ auth_handler_cls.validate(payload_dict, instance=self.instance)
+ self.cleaned_payload = payload_dict
+ return cleaned_data
+
+ def save(self, commit=True):
+ instance = super().save(commit=False)
+ if hasattr(self, "cleaned_payload"):
+ instance.payload = self.cleaned_payload
+ if commit:
+ instance.save()
+ return instance
+
+
+@admin.register(AuthConfig)
+class AuthConfigAdmin(admin.ModelAdmin):
+ form = AuthConfigAdminForm
+ list_display = ("id", "type")
+ search_fields = ("type",)
+
+
+@admin.register(URLPatternAuthConfig)
+class URLPatternAuthConfigAdmin(admin.ModelAdmin):
+ list_display = ("id", "auth_config", "pattern")
+ search_fields = ("pattern", "auth_config__type")
+ raw_id_fields = ("auth_config",)
diff --git a/geonode/security/apps.py b/geonode/security/apps.py
index a5646d72bd0..9e15288b997 100644
--- a/geonode/security/apps.py
+++ b/geonode/security/apps.py
@@ -25,6 +25,8 @@ class GeoNodeSecurityAppConfig(AppConfig):
def ready(self):
super().ready()
+ from geonode.security.auth_registry import auth_handler_registry
from geonode.security.registry import permissions_registry
+ auth_handler_registry.init_registry()
permissions_registry.init_registry()
diff --git a/geonode/security/auth_handlers.py b/geonode/security/auth_handlers.py
new file mode 100644
index 00000000000..cd4b5382c40
--- /dev/null
+++ b/geonode/security/auth_handlers.py
@@ -0,0 +1,117 @@
+#########################################################################
+#
+# Copyright (C) 2026 OSGeo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+#########################################################################
+from abc import ABC, abstractmethod
+
+from django.core.exceptions import ValidationError
+from requests.auth import AuthBase, HTTPBasicAuth
+
+from geonode.security.models import AuthConfig
+
+
+class AuthHandler(ABC):
+ handled_type = None
+ config: AuthConfig
+
+ def __init__(self, config: AuthConfig):
+ self.config = config
+ self._init_from_config()
+
+ @abstractmethod
+ def _init_from_config(self):
+ pass
+
+ def get_request_auth(self) -> AuthBase:
+ raise NotImplementedError
+
+ def auth_request(self, request, **kwargs):
+ raise NotImplementedError
+
+ def get_credentials(self):
+ raise NotImplementedError
+
+ @classmethod
+ def validate(cls, payload, instance=None):
+ raise NotImplementedError
+
+ @classmethod
+ def create_auth_config(cls, **kwargs):
+ raise NotImplementedError
+
+
+class HashableAuthBase(AuthBase):
+ """
+ Wrapper around any AuthBase object to make it hashable.
+ Required because lru_cache needs all arguments to be hashable.
+ """
+
+ def __init__(self, auth: AuthBase):
+ self.auth = auth
+
+ def __hash__(self):
+ return hash((self.auth.__class__, tuple(sorted(self.auth.__dict__.items()))))
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, HashableAuthBase)
+ and self.auth.__class__ is other.auth.__class__
+ and self.auth.__dict__ == other.auth.__dict__
+ )
+
+ def __call__(self, r):
+ return self.auth(r)
+
+
+class BasicAuthHandler(AuthHandler):
+ handled_type = "basic"
+ username: str
+ password: str
+
+ @classmethod
+ def validate(cls, payload, instance=None):
+ if not payload.get("username"):
+ raise ValidationError("Username is required for basic authentication.")
+ if not payload.get("password") and not getattr(instance, "pk", None):
+ raise ValidationError("Password is required for basic authentication.")
+ return payload
+
+ @classmethod
+ def create_auth_config(cls, username, password):
+ if username is None and password is None:
+ return None
+ payload = {"username": username, "password": password}
+ cls.validate(payload)
+ auth_config = AuthConfig(type=cls.handled_type)
+ auth_config.payload = payload
+ auth_config.save()
+ return auth_config
+
+ def _init_from_config(self):
+ payload = self.config.payload
+ self.username = payload.get("username")
+ self.password = payload.get("password")
+
+ def get_request_auth(self) -> AuthBase:
+ return HashableAuthBase(HTTPBasicAuth(self.username, self.password))
+
+ def auth_request(self, request, **kwargs):
+ request.auth = self.get_request_auth()
+ return request
+
+ def get_credentials(self):
+ return (self.username, self.password)
diff --git a/geonode/security/auth_registry.py b/geonode/security/auth_registry.py
new file mode 100644
index 00000000000..a16f72e6aed
--- /dev/null
+++ b/geonode/security/auth_registry.py
@@ -0,0 +1,50 @@
+#########################################################################
+#
+# Copyright (C) 2026 OSGeo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+#########################################################################
+from django.conf import settings
+from django.utils.module_loading import import_string
+
+
+class AuthHandlerRegistry:
+ def __init__(self):
+ self.registry = {}
+
+ def register(self, auth_handler_cls):
+ handled_type = auth_handler_cls.handled_type
+ if handled_type is None:
+ raise ValueError("Auth handler class must define handled_type")
+ self.registry[handled_type] = auth_handler_cls
+
+ def init_registry(self):
+ for auth_handler_path in getattr(settings, "AUTH_HANDLERS", []):
+ auth_handler_cls = import_string(auth_handler_path)
+ self.register(auth_handler_cls)
+
+ def get_handler_class(self, handled_type):
+ if not self.registry:
+ self.init_registry()
+ return self.registry.get(handled_type)
+
+ def build(self, config):
+ auth_handler_cls = self.get_handler_class(config.type)
+ if auth_handler_cls is None:
+ raise ValueError(f"Unsupported auth config type '{config.type}'")
+ return auth_handler_cls(config)
+
+
+auth_handler_registry = AuthHandlerRegistry()
diff --git a/geonode/security/migrations/0001_initial.py b/geonode/security/migrations/0001_initial.py
new file mode 100644
index 00000000000..077215b7e1d
--- /dev/null
+++ b/geonode/security/migrations/0001_initial.py
@@ -0,0 +1,45 @@
+# Generated by Django 5.2.13 on 2026-05-08 05:18
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="AuthConfig",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("type", models.CharField(max_length=128)),
+ ("_payload", models.CharField(db_column="payload", max_length=4096)),
+ ],
+ options={
+ "verbose_name": "Authentication Configuration",
+ "verbose_name_plural": "Authentication Configurations",
+ },
+ ),
+ migrations.CreateModel(
+ name="URLPatternAuthConfig",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("pattern", models.CharField(max_length=2048)),
+ (
+ "auth_config",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="url_patterns",
+ to="security.authconfig",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "URL Pattern Authentication Configuration",
+ "verbose_name_plural": "URL Pattern Authentication Configurations",
+ },
+ ),
+ ]
diff --git a/geonode/security/migrations/__init__.py b/geonode/security/migrations/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/geonode/security/models.py b/geonode/security/models.py
index 99b4771861b..2e3b025d7a8 100644
--- a/geonode/security/models.py
+++ b/geonode/security/models.py
@@ -18,12 +18,18 @@
#########################################################################
import copy
+import json
import logging
import operator
import traceback
+import base64
+import hashlib
from functools import reduce
+from cryptography.fernet import Fernet
+from django.conf import settings
+from django.db import models
from django.db.models import Q
from geonode.security.permissions import (
get_default_anonymous_compact_permission,
@@ -57,6 +63,10 @@
logger = logging.getLogger(__name__)
+SECRET_KEY = settings.SECRET_KEY
+ENCRYPTION_KEY = base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY.encode()).digest())
+cipher = Fernet(ENCRYPTION_KEY)
+
class PermissionLevelError(Exception):
pass
@@ -462,3 +472,37 @@ def user_can(self, user, permission):
Checks if a has a given permission to the resource.
"""
return permissions_registry.user_has_perm(user, self, permission)
+
+
+class AuthConfig(models.Model):
+ type = models.CharField(max_length=128)
+ _payload = models.CharField(max_length=4096, db_column="payload")
+
+ class Meta:
+ verbose_name = "Authentication Configuration"
+ verbose_name_plural = "Authentication Configurations"
+
+ def __str__(self):
+ return f"{self.type}:{self.pk}"
+
+ @property
+ def payload(self):
+ if not self._payload:
+ return {}
+ return json.loads(cipher.decrypt(self._payload.encode()).decode())
+
+ @payload.setter
+ def payload(self, value):
+ self._payload = cipher.encrypt(json.dumps(value).encode()).decode()
+
+
+class URLPatternAuthConfig(models.Model):
+ auth_config = models.ForeignKey(AuthConfig, on_delete=models.CASCADE, related_name="url_patterns")
+ pattern = models.CharField(max_length=2048)
+
+ class Meta:
+ verbose_name = "URL Pattern Authentication Configuration"
+ verbose_name_plural = "URL Pattern Authentication Configurations"
+
+ def __str__(self):
+ return self.pattern
diff --git a/geonode/security/tests.py b/geonode/security/tests.py
index b19720cceb0..812daebff4a 100644
--- a/geonode/security/tests.py
+++ b/geonode/security/tests.py
@@ -28,6 +28,7 @@
import mock
from uuid import uuid4
+from requests import Request
from requests.auth import HTTPBasicAuth
from tastypie.test import ResourceTestCaseMixin
from avatar.templatetags.avatar_tags import avatar_url
@@ -99,6 +100,9 @@
from django.core.cache import cache
from geonode.base.models import ResourceBase
from geonode.people.models import Profile
+from geonode.security.auth_handlers import AuthHandler, BasicAuthHandler, HashableAuthBase
+from geonode.security.auth_registry import AuthHandlerRegistry, auth_handler_registry
+from geonode.security.models import AuthConfig, URLPatternAuthConfig
logger = logging.getLogger(__name__)
@@ -3931,3 +3935,116 @@ def test_handler_skips_when_not_created(self):
updated = handler.fixup_perms(resource, perms_payload, created=False)
self.assertDictEqual(perms_payload, updated)
+
+
+class AuthConfigTests(TestCase):
+ def test_auth_config_string_representation(self):
+ auth_config = AuthConfig.objects.create(type="basic", _payload="encrypted-payload")
+ self.assertEqual(str(auth_config), f"basic:{auth_config.pk}")
+
+ def test_url_pattern_auth_config_string_representation(self):
+ auth_config = AuthConfig.objects.create(type="basic", _payload="encrypted-payload")
+ url_pattern_auth_config = URLPatternAuthConfig.objects.create(
+ auth_config=auth_config,
+ pattern="https://example.com/*",
+ )
+ self.assertEqual(str(url_pattern_auth_config), "https://example.com/*")
+
+ def test_basic_auth_payload_round_trip(self):
+ auth_config = BasicAuthHandler.create_auth_config("test_user", "test_password")
+
+ self.assertEqual(auth_config.type, "basic")
+ self.assertNotIn("test_user", auth_config._payload)
+ self.assertNotIn("test_password", auth_config._payload)
+ self.assertEqual(auth_config.payload, {"username": "test_user", "password": "test_password"})
+
+ handler = BasicAuthHandler(auth_config)
+ self.assertEqual(handler.get_credentials(), ("test_user", "test_password"))
+
+
+class AuthHandlerTests(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.auth_config = AuthConfig.objects.create(type="basic")
+ self.auth_config.payload = {"username": "test_user", "password": "test_password"}
+ self.auth_config.save()
+
+ def test_build_returns_basic_auth_handler(self):
+ auth_handler = auth_handler_registry.build(self.auth_config)
+ self.assertIsInstance(auth_handler, BasicAuthHandler)
+ self.assertEqual(auth_handler.username, "test_user")
+ self.assertEqual(auth_handler.password, "test_password")
+
+ def test_build_raises_for_unsupported_type(self):
+ unsupported_auth_config = AuthConfig.objects.create(type="unsupported", _payload="opaque")
+ with self.assertRaises(ValueError):
+ auth_handler_registry.build(unsupported_auth_config)
+
+ def test_basic_auth_handler_get_request_auth(self):
+ auth_handler = auth_handler_registry.build(self.auth_config)
+ request_auth = auth_handler.get_request_auth()
+ self.assertIsInstance(request_auth, HashableAuthBase)
+ self.assertEqual(request_auth.auth.username, "test_user")
+ self.assertEqual(request_auth.auth.password, "test_password")
+
+ def test_basic_auth_handler_auth_request_sets_request_auth(self):
+ auth_handler = auth_handler_registry.build(self.auth_config)
+ request = Request("GET", "https://example.com/data")
+ request = auth_handler.auth_request(request)
+ self.assertIsInstance(request.auth, HashableAuthBase)
+ self.assertEqual(request.auth.auth.username, "test_user")
+ self.assertEqual(request.auth.auth.password, "test_password")
+
+
+class AuthHandlerRegistryTests(TestCase):
+ class SampleAuthHandler(AuthHandler):
+ handled_type = "sample"
+
+ def _init_from_config(self):
+ self.payload = self.config.payload
+
+ def get_request_auth(self):
+ return None
+
+ def auth_request(self, request, **kwargs):
+ return request
+
+ def get_credentials(self):
+ return self.payload
+
+ def test_register_adds_handler_class(self):
+ registry = AuthHandlerRegistry()
+ registry.register(BasicAuthHandler)
+ self.assertEqual(registry.registry["basic"], BasicAuthHandler)
+
+ def test_register_and_build_sample_handler(self):
+ auth_config = AuthConfig.objects.create(type="sample")
+ auth_config.payload = {"value": "demo"}
+ auth_config.save()
+ registry = AuthHandlerRegistry()
+ registry.register(self.SampleAuthHandler)
+ auth_handler = registry.build(auth_config)
+ self.assertIsInstance(auth_handler, self.SampleAuthHandler)
+ self.assertEqual(auth_handler.config, auth_config)
+
+ def test_register_raises_when_handled_type_is_missing(self):
+ class MissingHandledTypeAuthHandler(AuthHandler):
+ handled_type = None
+
+ def _init_from_config(self):
+ pass
+
+ registry = AuthHandlerRegistry()
+ with self.assertRaises(ValueError):
+ registry.register(MissingHandledTypeAuthHandler)
+
+ def test_build_returns_handler_instance(self):
+ auth_config = AuthConfig.objects.create(type="basic")
+ auth_config.payload = {"username": "test_user", "password": "test_password"}
+ auth_config.save()
+ registry = AuthHandlerRegistry()
+ registry.register(BasicAuthHandler)
+ auth_handler = registry.build(auth_config)
+ self.assertIsInstance(auth_handler, BasicAuthHandler)
+ self.assertEqual(auth_handler.username, "test_user")
+ self.assertEqual(auth_handler.password, "test_password")
diff --git a/geonode/services/forms.py b/geonode/services/forms.py
index 9013f078ac6..4f1333a76ab 100644
--- a/geonode/services/forms.py
+++ b/geonode/services/forms.py
@@ -24,6 +24,10 @@
from django.utils.translation import gettext_lazy as _
import taggit
+from geonode.security.auth_handlers import BasicAuthHandler
+from geonode.security.auth_registry import auth_handler_registry
+from geonode.security.models import AuthConfig
+
from . import enumerations
from .models import Service
from .serviceprocessors import get_service_handler
@@ -80,13 +84,22 @@ def clean(self):
super().clean()
url = self.cleaned_data.get("url")
service_type = self.cleaned_data.get("type")
+ username = self.cleaned_data.get("username", None)
+ password = self.cleaned_data.get("password", None)
if url is not None and service_type is not None:
try:
+ auth_config = None
+ if username is not None or password is not None:
+ payload = {"username": username, "password": password}
+ auth_config = AuthConfig(type=BasicAuthHandler.handled_type)
+ auth_handler_cls = auth_handler_registry.get_handler_class(auth_config.type)
+ auth_handler_cls.validate(payload)
+ auth_config.payload = payload
+
service_handler = get_service_handler(
base_url=url,
service_type=service_type,
- username=self.cleaned_data.get("username", None),
- password=self.cleaned_data.get("password", None),
+ auth_config=auth_config,
)
except Exception as e:
logger.error(f"CreateServiceForm cleaning error: {e}")
diff --git a/geonode/services/migrations/0058_migrate_service_auth_to_auth_config.py b/geonode/services/migrations/0058_migrate_service_auth_to_auth_config.py
new file mode 100644
index 00000000000..0c56dac8ab5
--- /dev/null
+++ b/geonode/services/migrations/0058_migrate_service_auth_to_auth_config.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.2.13 on 2026-05-08 05:19
+
+import base64
+import hashlib
+import json
+
+from cryptography.fernet import Fernet
+from django.conf import settings
+from django.db import migrations
+
+
+def migrate_service_auth_to_auth_config(apps, schema_editor):
+ Service = apps.get_model("services", "Service")
+ AuthConfig = apps.get_model("security", "AuthConfig")
+
+ encryption_key = base64.urlsafe_b64encode(hashlib.sha256(settings.SECRET_KEY.encode()).digest())
+ cipher = Fernet(encryption_key)
+
+ legacy_services = Service.objects.filter(
+ username__isnull=False,
+ password__isnull=False,
+ )
+
+ for service in legacy_services.iterator():
+ raw_password = cipher.decrypt(service.password.encode()).decode()
+ encrypted_payload = cipher.encrypt(
+ json.dumps({"username": service.username, "password": raw_password}).encode()
+ ).decode()
+
+ auth_config = AuthConfig.objects.create(type="basic", _payload=encrypted_payload)
+ Service.objects.filter(pk=service.pk).update(auth_config=auth_config)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("base", "0099_resourcebase_auth_config"),
+ ("security", "0001_initial"),
+ ("services", "0057_remove_modeltranslation"),
+ ]
+
+ operations = [migrations.RunPython(migrate_service_auth_to_auth_config, migrations.RunPython.noop)]
diff --git a/geonode/services/migrations/0059_remove_service_password_remove_service_username.py b/geonode/services/migrations/0059_remove_service_password_remove_service_username.py
new file mode 100644
index 00000000000..da0e0bbc417
--- /dev/null
+++ b/geonode/services/migrations/0059_remove_service_password_remove_service_username.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.2.13 on 2026-05-08 05:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("services", "0058_migrate_service_auth_to_auth_config"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="service",
+ name="password",
+ ),
+ migrations.RemoveField(
+ model_name="service",
+ name="username",
+ ),
+ ]
diff --git a/geonode/services/models.py b/geonode/services/models.py
index 69a089e1149..f50ff51593f 100644
--- a/geonode/services/models.py
+++ b/geonode/services/models.py
@@ -17,8 +17,6 @@
#
#########################################################################
import logging
-import base64
-import hashlib
from urllib.parse import urlparse, ParseResult
@@ -32,16 +30,10 @@
from geonode.layers.enumerations import GXP_PTYPES
from geonode.people.enumerations import ROLE_VALUES
from geonode.services.serviceprocessors import get_available_service_types
-from cryptography.fernet import Fernet
from . import enumerations
service_type_as_tuple = [(k, v["label"]) for k, v in get_available_service_types().items()]
-SECRET_KEY = settings.SECRET_KEY # Ensure it's unique per project
-ENCRYPTION_KEY = base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY.encode()).digest())
-
-cipher = Fernet(ENCRYPTION_KEY)
-
logger = logging.getLogger("geonode.services")
@@ -68,8 +60,6 @@ class Service(ResourceBase):
description = models.CharField(max_length=255, null=True, blank=True)
extra_queryparams = models.TextField(null=True, blank=True)
operations = models.JSONField(default=dict, null=True, blank=True)
- username = models.CharField(max_length=150, null=True, blank=True, default=None)
- password = models.CharField(_("password"), max_length=250, null=True, blank=True, default=None)
# Foreign Keys
@@ -79,12 +69,6 @@ class Service(ResourceBase):
# Supported Capabilities
- def save(self, notify=False, *args, **kwargs):
- if kwargs.get("force_insert", False) and self.needs_authentication:
- # if is the first creation, we must encrypt the password
- self.password = self.set_password(self.password)
- return super().save(notify, *args, **kwargs)
-
def __str__(self):
return str(self.name)
@@ -96,7 +80,7 @@ def probe(self):
@property
def needs_authentication(self):
- return self.password is not None and self.username is not None
+ return self.auth_config is not None
def _get_service_url(self):
parsed_url = urlparse(self.base_url)
@@ -128,12 +112,6 @@ def service_type(self):
def get_absolute_url(self):
return "/services/%i" % self.id
- def set_password(self, password):
- return cipher.encrypt(password.encode()).decode()
-
- def get_password(self):
- return cipher.decrypt(self.password.encode()).decode()
-
class Meta:
# custom permissions,
# change and delete are standard in django-guardian
diff --git a/geonode/services/serviceprocessors/wms.py b/geonode/services/serviceprocessors/wms.py
index 2564ab2b430..dd29d34c88d 100644
--- a/geonode/services/serviceprocessors/wms.py
+++ b/geonode/services/serviceprocessors/wms.py
@@ -40,6 +40,8 @@
from geonode.base.bbox_utils import BBOXHelper
from geonode.harvesting.models import Harvester
from geonode.harvesting.harvesters.wms import OgcWmsHarvester, WebMapService
+from geonode.security.auth_handlers import BasicAuthHandler
+from geonode.security.auth_registry import auth_handler_registry
from .. import enumerations
from ..enumerations import CASCADED
@@ -95,11 +97,14 @@ def get_cleaned_url_params(url):
@property
def parsed_service(self):
cleaned_url, service, version, request = WmsServiceHandler.get_cleaned_url_params(self.url)
+ auth = self.kwargs.get("auth")
+ auth_config = self.kwargs.get("auth_config")
+ if auth is None and auth_config is not None:
+ auth = auth_handler_registry.build(auth_config).get_request_auth()
_url, _parsed_service = WebMapService(
cleaned_url.geturl(),
version=version,
- username=self.kwargs.get("username"),
- password=self.kwargs.get("password"),
+ auth=auth,
)
return _parsed_service
@@ -132,6 +137,13 @@ def create_geonode_service(self, owner, parent=None):
service = None
try:
cleaned_url, service_name, version, request = WmsServiceHandler.get_cleaned_url_params(self.url)
+ auth_config = self.kwargs.get("auth_config")
+ if auth_config is None:
+ auth_config = BasicAuthHandler.create_auth_config(
+ self.kwargs.get("username", None), self.kwargs.get("password", None)
+ )
+ elif auth_config.pk is None:
+ auth_config.save()
with transaction.atomic():
service = models.Service.objects.create(
uuid=str(uuid4()),
@@ -150,8 +162,7 @@ def create_geonode_service(self, owner, parent=None):
abstract=str(self.parsed_service.identification.abstract).encode("utf-8", "ignore").decode("utf-8")
or _("Not provided"),
operations=OgcWmsHarvester.get_wms_operations(self.parsed_service.url, version=version),
- username=self.kwargs.get("username", None),
- password=self.kwargs.get("password", None),
+ auth_config=auth_config,
)
service_harvester = Harvester.objects.create(
name=self.name,
@@ -288,12 +299,14 @@ def __init__(self, url, geonode_service_id=None, *args, **kwargs):
@property
def parsed_service(self):
cleaned_url, service, version, request = WmsServiceHandler.get_cleaned_url_params(self.ows_endpoint())
+ auth = None
+ if service.needs_authentication:
+ auth = auth_handler_registry.build(service.auth_config).get_request_auth()
_parsed_service = get_service_handler(
cleaned_url.geturl(),
service.type,
service.id,
- username=service.username if service.needs_authentication else None,
- password=service.get_password() if service.needs_authentication else None,
+ auth=auth,
)
return _parsed_service
diff --git a/geonode/services/templates/services/service_detail.html b/geonode/services/templates/services/service_detail.html
index bd48271354c..630209113ac 100644
--- a/geonode/services/templates/services/service_detail.html
+++ b/geonode/services/templates/services/service_detail.html
@@ -11,7 +11,7 @@
{{service.title|default:service.name}}
{% trans "Keywords" %}: {{ service.keywords.all|join:", " }}
{% trans "Contact" %}: {{ service.owner }}
{% if service.type == 'WMS' and service.needs_authentication %}
- SERVICE NOTES: The service is accessed by Basic auth via the user {{service.username}}
+ SERVICE NOTES: The service is accessed using configured authentication.
{% endif %}
{% autoescape off %}
diff --git a/geonode/services/tests.py b/geonode/services/tests.py
index 73cf9ac7709..5250a899a9a 100644
--- a/geonode/services/tests.py
+++ b/geonode/services/tests.py
@@ -38,6 +38,9 @@
from geonode.harvesting.models import Harvester
from geonode.layers.models import Dataset
+from geonode.security.auth_handlers import BasicAuthHandler
+from geonode.security.models import AuthConfig
+from geonode.security.auth_registry import auth_handler_registry
from geonode.tests.base import GeoNodeBaseTestSupport
from geonode.resource.registry import resource_manager_registry
from geonode.base import enumerations as base_enumerations
@@ -595,6 +598,26 @@ def test_create_geonode_service(self, mock_wms_parsed_service, mock_wms):
# mata_data_only is set to Try
self.assertTrue(result.metadata_only)
+ @mock.patch("geonode.harvesting.harvesters.wms.WebMapService")
+ @mock.patch.object(wms.WmsServiceHandler, "parsed_service")
+ def test_create_geonode_service_with_basic_auth_config(self, mock_wms_parsed_service, mock_wms):
+ mock_wms.return_value = (self.phony_url, self.parsed_wms)
+ mock_wms_parsed_service.return_value = self.parsed_wms
+ mock_wms_parsed_service.provider.url = self.phony_url
+ mock_wms_parsed_service.identification.title = self.phony_title
+ mock_wms_parsed_service.identification.version = self.phony_version
+
+ auth_config = AuthConfig(type=BasicAuthHandler.handled_type)
+ auth_config.payload = {"username": "test_user", "password": "test_password"}
+ handler = wms.WmsServiceHandler(self.phony_url, auth_config=auth_config)
+ result = handler.create_geonode_service(self.test_user)
+
+ self.assertIsNotNone(result.auth_config)
+ self.assertEqual(result.auth_config.type, "basic")
+
+ auth_handler = auth_handler_registry.build(result.auth_config)
+ self.assertEqual(auth_handler.get_credentials(), ("test_user", "test_password"))
+
@mock.patch("geonode.harvesting.harvesters.wms.WebMapService")
@mock.patch("geonode.services.serviceprocessors.wms.WmsServiceHandler.parsed_service", autospec=True)
def test_geonode_service_uses_given_getmap_params(self, mock_wms_parsed_service, mock_wms):
diff --git a/geonode/services/views.py b/geonode/services/views.py
index 75d5f73ff44..93ac7d0fe2a 100644
--- a/geonode/services/views.py
+++ b/geonode/services/views.py
@@ -108,13 +108,13 @@ def _get_service_handler(request, service):
multiple Capabilities requests (this is a time saver on servers that
feature many layers.
"""
- service_handler = get_service_handler(
- service.service_url,
- service.type,
- service.id,
- username=service.username if service.needs_authentication else None,
- password=service.get_password() if service.needs_authentication else None,
- )
+ auth = None
+ if service.needs_authentication:
+ from geonode.security.auth_registry import auth_handler_registry
+
+ auth = auth_handler_registry.build(service.auth_config).get_request_auth()
+
+ service_handler = get_service_handler(service.service_url, service.type, service.id, auth=auth)
if not service_handler.geonode_service_id:
service_handler.geonode_service_id = service.id
# commented out due to jsonserializer error, will be replaced with cache
diff --git a/geonode/settings.py b/geonode/settings.py
index 6e4ddc83a69..beeff06d462 100644
--- a/geonode/settings.py
+++ b/geonode/settings.py
@@ -1829,6 +1829,10 @@ def get_geonode_catalogue_service():
# START SECURITY SETTINGS
# ########################################################################### #
+AUTH_HANDLERS = [
+ "geonode.security.auth_handlers.BasicAuthHandler",
+]
+
ENABLE_APIKEY_LOGIN = ast.literal_eval(os.getenv("ENABLE_APIKEY_LOGIN", "False"))
# ######################################################## #
diff --git a/geonode/thumbs/tests/test_unit.py b/geonode/thumbs/tests/test_unit.py
index f7c3300f578..5c743c75503 100644
--- a/geonode/thumbs/tests/test_unit.py
+++ b/geonode/thumbs/tests/test_unit.py
@@ -201,7 +201,7 @@ def test_datasets_locations_dataset(self):
locations, bbox = thumbnails._datasets_locations(dataset)
self.assertFalse(bbox, "Expected BBOX not to be calculated")
- self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], {}]])
+ self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], None]])
def test_datasets_locations_dataset_default_bbox(self):
expected_bbox = [-8238681.374829309, -8220320.783295829, 4969844.0930337105, 4984363.884452854, "EPSG:3857"]
@@ -211,7 +211,7 @@ def test_datasets_locations_dataset_default_bbox(self):
self.assertEqual(bbox[-1].upper(), "EPSG:3857", "Expected calculated BBOX CRS to be EPSG:3857")
self.assertEqual(bbox, expected_bbox, "Expected calculated BBOX to match pre-converted one.")
- self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], {}]])
+ self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], None]])
def test_datasets_locations_dataset_bbox(self):
dataset = Dataset.objects.get(title="theaters_nyc")
@@ -222,7 +222,7 @@ def test_datasets_locations_dataset_bbox(self):
self.assertEqual(
bbox[-1].lower(), dataset.bbox[-1].lower(), "Expected calculated BBOX's CRS to match dataset's"
)
- self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], {}]])
+ self.assertEqual(locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], [], None]])
def test_datasets_locations_simple_map(self):
dataset = Dataset.objects.get(title="theaters_nyc")
@@ -243,7 +243,7 @@ def test_datasets_locations_simple_map(self):
settings.OGC_SERVER["default"]["LOCATION"],
[dataset.alternate, "geonode:Meteorite_Landings_from_NASA_Open_Data_Portal1"],
["theaters_nyc", "test_style"],
- {},
+ None,
]
],
)
@@ -259,7 +259,7 @@ def test_datasets_locations_simple_map_default_bbox(self):
self.assertEqual(bbox[-1].upper(), "EPSG:3857", "Expected calculated BBOX CRS to be EPSG:3857")
self.assertEqual(bbox, expected_bbox, "Expected calculated BBOX to match pre-converted one.")
self.assertEqual(
- locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], ["theaters_nyc"], {}]]
+ locations, [[settings.OGC_SERVER["default"]["LOCATION"], [dataset.alternate], ["theaters_nyc"], None]]
)
def test_datasets_locations_composition_map_default_bbox(self):
@@ -272,7 +272,7 @@ def test_datasets_locations_composition_map_default_bbox(self):
"rt_geologia.dbg_risorse_minerarie",
],
[],
- {},
+ None,
]
]
diff --git a/geonode/thumbs/thumbnails.py b/geonode/thumbs/thumbnails.py
index fd1888dbb59..2fdbc941be5 100644
--- a/geonode/thumbs/thumbnails.py
+++ b/geonode/thumbs/thumbnails.py
@@ -156,7 +156,7 @@ def create_thumbnail(
# --- fetch WMS datasets ---
partial_thumbs = []
- for ogc_server, datasets, _styles, auth_info in locations:
+ for ogc_server, datasets, _styles, auth in locations:
if isinstance(instance, Map):
styles = []
if len(datasets) == len(_styles):
@@ -173,7 +173,7 @@ def create_thumbnail(
width=width,
height=height,
instance=instance,
- auth_info=auth_info,
+ auth=auth,
)
)
except Exception as e:
@@ -262,15 +262,14 @@ def _generate_thumbnail_name(instance: Union[Dataset, Map, Document, GeoApp, Res
return file_name
-def _get_auth_info(dataset: "Dataset") -> dict:
- """Gets authentication info for a dataset if it's a remote service requiring auth."""
- auth_info = {}
+def _get_auth(dataset: "Dataset"):
+ """Gets an auth object for a dataset if it's a remote service requiring auth."""
+ auth = None
if dataset.remote_service and dataset.remote_service.needs_authentication:
- auth_info = {
- "username": dataset.remote_service.username,
- "password": dataset.remote_service.get_password(),
- }
- return auth_info
+ from geonode.security.auth_registry import auth_handler_registry
+
+ auth = auth_handler_registry.build(dataset.remote_service.auth_config).get_request_auth()
+ return auth
def _datasets_locations(
@@ -296,8 +295,8 @@ def _datasets_locations(
bbox = []
if isinstance(instance, Dataset):
# Check if dataset has remote service with authentication
- auth_info = _get_auth_info(instance)
- locations.append([instance.ows_url or ogc_server_settings.LOCATION, [instance.alternate], [], auth_info])
+ auth = _get_auth(instance)
+ locations.append([instance.ows_url or ogc_server_settings.LOCATION, [instance.alternate], [], auth])
if compute_bbox:
if instance.ll_bbox_polygon:
bbox = bbox_utils.clean_bbox(instance.ll_bbox, target_crs)
@@ -336,7 +335,7 @@ def _datasets_locations(
if dataset.subtype in ["tileStore", "remote"]:
# Check if remote service requires authentication
- auth_info = _get_auth_info(dataset)
+ auth = _get_auth(dataset)
# limit number of locations, ensuring dataset order
if len(locations) and locations[-1][0] == dataset.remote_service.service_url:
# if previous dataset's location is the same as the current one - append current dataset there
@@ -350,7 +349,7 @@ def _datasets_locations(
dataset.remote_service.service_url,
[dataset.alternate],
[map_dataset_style] if map_dataset_style else [],
- auth_info,
+ auth,
]
)
else:
@@ -367,7 +366,7 @@ def _datasets_locations(
settings.OGC_SERVER["default"]["LOCATION"],
[dataset.alternate],
[map_dataset_style] if map_dataset_style else [],
- {},
+ None,
]
)
diff --git a/geonode/thumbs/utils.py b/geonode/thumbs/utils.py
index 8525902a434..7f67543f220 100644
--- a/geonode/thumbs/utils.py
+++ b/geonode/thumbs/utils.py
@@ -145,7 +145,7 @@ def get_map(
max_retries: int = 3,
retry_delay: int = 1,
instance=None,
- auth_info: dict = None,
+ auth=None,
):
"""
Function fetching an image from OGC server.
@@ -165,7 +165,7 @@ def get_map(
:param height: height of the returned image
:param max_retries: maximum number of retries before skipping retrieval
:param retry_delay: number of seconds waited between retries
- :param auth_info: optional dict with 'username' and 'password' for remote service authentication
+ :param auth: optional auth object for remote service authentication
:returns: retrieved image
"""
from geonode.geoserver.helpers import ogc_server_settings
@@ -203,14 +203,6 @@ def get_map(
else:
headers["Authorization"] = f"Bearer {additional_kwargs['access_token']}"
- # Check if auth_info is provided (for remote services with authentication)
- if auth_info and auth_info.get("username") and auth_info.get("password"):
- # Use provided authentication credentials for remote service
- encoded_credentials = base64.b64encode(
- f"{auth_info['username']}:{auth_info['password']}".encode("UTF-8")
- ).decode("ascii")
- headers["Authorization"] = f"Basic {encoded_credentials}"
-
image = None
for retry in range(max_retries):
try:
@@ -219,6 +211,7 @@ def get_map(
f"{thumbnail_url}{wms_endpoint}",
version=wms_version,
headers=headers,
+ auth=auth,
layers=layers,
styles=styles,
srs=bbox[-1] if bbox else None,
@@ -318,6 +311,7 @@ def getmap(
base_url,
version="1.3.0",
headers={},
+ auth=None,
layers=None,
styles=None,
srs=None,
@@ -394,7 +388,7 @@ def getmap(
"""
from owslib.etree import etree
from owslib.namespaces import Namespaces
- from owslib.util import openURL, ServiceException, nspath
+ from owslib.util import Authentication, openURL, ServiceException, nspath
n = Namespaces()
@@ -417,7 +411,8 @@ def getmap(
data = urlencode(request)
- u = openURL(base_url, data, method, timeout=timeout, auth=None, headers=headers)
+ auth = Authentication(auth_delegate=auth)
+ u = openURL(base_url, data, method, timeout=timeout, auth=auth, headers=headers)
# need to handle casing in the header keys
headers = {}