From eebd47c149e029e3ec4d88a1f78cf140282b0ba7 Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Thu, 7 May 2026 12:34:16 +0000 Subject: [PATCH 1/4] [Fixes #14177] Implement storage and handling of authentications for external resources --- .../0099_resourcebase_auth_config.py | 26 ++++ geonode/base/models.py | 9 +- geonode/harvesting/harvesters/wms.py | 28 ++-- geonode/security/admin.py | 98 +++++++++++++ geonode/security/apps.py | 2 + geonode/security/auth_handlers.py | 135 ++++++++++++++++++ geonode/security/auth_registry.py | 50 +++++++ geonode/security/migrations/0001_initial.py | 45 ++++++ geonode/security/migrations/__init__.py | 0 geonode/security/models.py | 25 ++++ geonode/security/tests.py | 116 +++++++++++++++ geonode/services/forms.py | 17 ++- ...058_migrate_service_auth_to_auth_config.py | 43 ++++++ ...ervice_password_remove_service_username.py | 21 +++ geonode/services/models.py | 24 +--- geonode/services/serviceprocessors/wms.py | 25 +++- .../templates/services/service_detail.html | 2 +- geonode/services/tests.py | 25 ++++ geonode/services/views.py | 14 +- geonode/settings.py | 4 + geonode/thumbs/thumbnails.py | 7 +- 21 files changed, 664 insertions(+), 52 deletions(-) create mode 100644 geonode/base/migrations/0099_resourcebase_auth_config.py create mode 100644 geonode/security/admin.py create mode 100644 geonode/security/auth_handlers.py create mode 100644 geonode/security/auth_registry.py create mode 100644 geonode/security/migrations/0001_initial.py create mode 100644 geonode/security/migrations/__init__.py create mode 100644 geonode/services/migrations/0058_migrate_service_auth_to_auth_config.py create mode 100644 geonode/services/migrations/0059_remove_service_password_remove_service_username.py 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..c9ff8183656 --- /dev/null +++ b/geonode/base/migrations/0099_resourcebase_auth_config.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.13 on 2026-05-06 13:29 + +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..56777614a0f --- /dev/null +++ b/geonode/security/admin.py @@ -0,0 +1,98 @@ +######################################################################### +# +# 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) + + class Meta: + model = AuthConfig + fields = "__all__" + + 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: + auth_handler_cls = auth_handler_registry.get_handler_class(current_type) + if auth_handler_cls: + self.initial["payload"] = json.dumps(auth_handler_cls.decrypt_payload(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 + + if self.instance.pk and payload_value == self.instance.payload: + # unchanged existing encrypted payload + 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"): + auth_handler_cls = auth_handler_registry.get_handler_class(instance.type) + instance.payload = auth_handler_cls.encrypt_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..80966998de7 --- /dev/null +++ b/geonode/security/auth_handlers.py @@ -0,0 +1,135 @@ +######################################################################### +# +# 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 base64 +import hashlib +import json +from abc import ABC, abstractmethod + +from cryptography.fernet import Fernet +from django.conf import settings +from django.core.exceptions import ValidationError +from requests.auth import AuthBase, HTTPBasicAuth + +from geonode.security.models import AuthConfig + +SECRET_KEY = settings.SECRET_KEY +ENCRYPTION_KEY = base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY.encode()).digest()) +cipher = Fernet(ENCRYPTION_KEY) + + +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 + + @classmethod + def encrypt_payload(cls, payload): + return cipher.encrypt(json.dumps(payload).encode()).decode() + + @classmethod + def decrypt_payload(cls, payload): + return json.loads(cipher.decrypt(payload.encode()).decode()) + + +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) + + return AuthConfig.objects.create( + type=cls.handled_type, + payload=cls.encrypt_payload(payload), + ) + + def _init_from_config(self): + payload = self.decrypt_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..11a3960cd1b --- /dev/null +++ b/geonode/security/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.13 on 2026-05-06 13:29 + +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(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..e738636a077 100644 --- a/geonode/security/models.py +++ b/geonode/security/models.py @@ -24,6 +24,7 @@ from functools import reduce +from django.db import models from django.db.models import Q from geonode.security.permissions import ( get_default_anonymous_compact_permission, @@ -462,3 +463,27 @@ 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) + + class Meta: + verbose_name = "Authentication Configuration" + verbose_name_plural = "Authentication Configurations" + + def __str__(self): + return f"{self.type}:{self.pk}" + + +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..8cfc3cdd341 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,115 @@ 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("demo-user", "demo-pass") + + self.assertEqual(auth_config.type, "basic") + self.assertNotIn("demo-user", auth_config.payload) + self.assertNotIn("demo-pass", auth_config.payload) + + handler = BasicAuthHandler(auth_config) + self.assertEqual(handler.get_credentials(), ("demo-user", "demo-pass")) + + +class AuthHandlerTests(TestCase): + def setUp(self): + super().setUp() + self.auth_config = AuthConfig.objects.create( + type="basic", + payload=BasicAuthHandler.encrypt_payload({"username": "demo-user", "password": "demo-pass"}), + ) + + 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, "demo-user") + self.assertEqual(auth_handler.password, "demo-pass") + + 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, "demo-user") + self.assertEqual(request_auth.auth.password, "demo-pass") + + 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, "demo-user") + self.assertEqual(request.auth.auth.password, "demo-pass") + + +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", payload="demo") + 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", + payload=BasicAuthHandler.encrypt_payload({"username": "demo-user", "password": "demo-pass"}), + ) + registry = AuthHandlerRegistry() + registry.register(BasicAuthHandler) + auth_handler = registry.build(auth_config) + self.assertIsInstance(auth_handler, BasicAuthHandler) + self.assertEqual(auth_handler.username, "demo-user") + self.assertEqual(auth_handler.password, "demo-pass") diff --git a/geonode/services/forms.py b/geonode/services/forms.py index d958f0f8c87..376e3af0195 100644 --- a/geonode/services/forms.py +++ b/geonode/services/forms.py @@ -24,6 +24,9 @@ from django.utils.translation import gettext_lazy as _ import taggit +from geonode.security.auth_handlers import BasicAuthHandler +from geonode.security.models import AuthConfig + from . import enumerations from .models import Service from .serviceprocessors import get_service_handler @@ -75,13 +78,23 @@ 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} + BasicAuthHandler.validate(payload) + auth_config = AuthConfig( + type=BasicAuthHandler.handled_type, + payload=BasicAuthHandler.encrypt_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..086ab67daad --- /dev/null +++ b/geonode/services/migrations/0058_migrate_service_auth_to_auth_config.py @@ -0,0 +1,43 @@ +import base64 +import hashlib +import json + +from cryptography.fernet import Fernet +from django.conf import settings +from django.db import migrations + + +SECRET_KEY = settings.SECRET_KEY +ENCRYPTION_KEY = base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY.encode()).digest()) +cipher = Fernet(ENCRYPTION_KEY) + + +def migrate_service_auth_to_auth_config(apps, schema_editor): + Service = apps.get_model("services", "Service") + AuthConfig = apps.get_model("security", "AuthConfig") + + 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() + payload = json.dumps({"username": service.username, "password": raw_password}) + encrypted_payload = cipher.encrypt(payload.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..5203d6c94c2 --- /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-07 06:07 + +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..e268bf5099f 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 = BasicAuthHandler(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 %}