Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions geonode/base/migrations/0099_resourcebase_auth_config.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
9 changes: 8 additions & 1 deletion geonode/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
28 changes: 18 additions & 10 deletions geonode/harvesting/harvesters/wms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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 (
Expand All @@ -81,6 +90,7 @@ def WebMapService(
version=version,
xml=xml,
parse_remote_metadata=parse_remote_metadata,
auth=auth,
username=username,
password=password,
timeout=timeout,
Expand All @@ -95,6 +105,7 @@ def WebMapService(
version=version,
xml=xml,
parse_remote_metadata=parse_remote_metadata,
auth=auth,
username=username,
password=password,
timeout=timeout,
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions geonode/security/admin.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
#
#########################################################################
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",)
2 changes: 2 additions & 0 deletions geonode/security/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
117 changes: 117 additions & 0 deletions geonode/security/auth_handlers.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
#
#########################################################################
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)
50 changes: 50 additions & 0 deletions geonode/security/auth_registry.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
#
#########################################################################
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()
Loading
Loading