diff --git a/docs/developer-guide/integrations/geoserver.md b/docs/developer-guide/integrations/geoserver.md index 68cf8a9215a..7898d941b6a 100644 --- a/docs/developer-guide/integrations/geoserver.md +++ b/docs/developer-guide/integrations/geoserver.md @@ -158,25 +158,30 @@ The last step is to configure MapStore to use the authkey with the configured in ```javascript //... -"useAuthenticationRules": true, - "authenticationRules": [{ - "urlPattern": ".*geostore.*", - "method": "bearer" - }, { +"requestsConfigurationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { "urlPattern": "\\/geoserver/.*", - "authkeyParamName": "authkey", - "method": "authkey" - }], + "params": { + "authkey": "${securityToken}" + } + } +], //... ``` -- Verify that "useAuthenticationRules" is set to `true` -- `authenticationRules` array should contain 2 rules: - - The first rule should already be present, and defines the authentication method used internally in mapstore +- Note: The new `requestsConfigurationRules` system is always active when rules are present, no flag needed +- `requestsConfigurationRules` array should contain 2 rules: + - The first rule should already be present, and defines the authentication method used internally in mapstore (Bearer token) - The second rule (the one you need to add) should be added and defines how to authenticate to GeoServer: - `urlPattern`: is a regular expression that identifies the request url where to apply the rule - - `method`: set it to `authkey` to use the authentication filter you just created in Geoserver. - - `authkeyParamName`: is the name of the authkey parameter defined in GeoServer (set to `authkey` by default) + - `params`: use query parameters for authkey authentication + - `authkey`: the name of the parameter (must match the one in GeoServer configuration, default is `authkey`) ### Advantages of user integration diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 54f46e236f8..ab72185d31d 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -45,17 +45,30 @@ This is the main structure: // path to the translation files directory (if different from default) "translationsPath", // if true, every ajax and mapping request will be authenticated with the configurations if match a rule (default: true) - "useAuthenticationRules": true - // the authentication rules to match - "authenticationRules": [ - { // every rule has a `urlPattern` regex to match - "urlPattern": ".*geostore.*", - // and a authentication `method` to use (basic, authkey, browserWithCredentials, header) - "method": "basic" - }, { - "urlPattern": "\\/geoserver.*", - "method": "authkey" - }], + // the request configuration rules to match + "requestsConfigurationRules": [ + { // every rule has a `urlPattern` regex to match + "urlPattern": ".*geostore.*", + // headers to add to matching requests + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, { + "urlPattern": "\\/geoserver/.*", + // parameters to add to matching requests + "params": { + "authkey": "${securityToken}" + } + }, { + "urlPattern": ".*azure-blob.*", + // expiration timestamp (optional, Unix timestamp in seconds) + "expires": 1735689600, + // parameters can be used for SAS tokens + "params": { + "sv": "2024-11-04", + "sig": "${sasToken}" + } + }], // flag for postponing mapstore 2 load time after theme "loadAfterTheme": false, // if defined, WMS layer styles localization will be added @@ -150,23 +163,59 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta - `initialState`: is an object that will initialize the state with some default values and this WILL OVERRIDE the initialState imposed by plugins & reducers. - `projectionDefs`: is an array of objects that contain definitions for Coordinate Reference Systems - `gridFiles`: is an object that contains definitions for grid files used in coordinate transformations -- `useAuthenticationRules`: if this flag is set to true, the `authenticationRules` will be used to authenticate every ajax and mapping request. If the flag is set to false, the `authenticationRules` will be ignored. -- `authenticationRules`: is an array of objects that contain rules to match for authentication. Each rule has a `urlPattern` regex to match and a `method` to use (`basic`, `authkey`, `header`, `browserWithCredentials`). If the URL of a request matches the `urlPattern` of a rule, the `method` will be used to authenticate the request. The `method` can be: - - `basic` will use the basic authentication method getting the credentials from the user that logged in (adding the header `Authorization` `Basic ` to the request). ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - `authkey` will use the authkey method getting the credentials from the user that logged in. The token of the current MapStore session will be used as the authkey value, so this works only with the geoserver integration. - - `bearer` will use the header `Authorization` `Bearer ` getting the credentials from the user that logged in. The token of the current MapStore session will be used as the bearer value, so this works only with the geoserver integration. - - `header` will use the header method getting the credentials from the user that logged in. You can add an `headers` object containing the static headers to this rule to specify witch headers to use. e.g. - - `browserWithCredentials` will add the `withCredentials` parameter to ajax requests, so the browser will send the cookies and the authentication headers to the server. This method is useful when you have a proxy that needs to authenticate the user. ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - ```json +- `useAuthenticationRules` (deprecated): if this flag is set to true, legacy `authenticationRules` will be used. The new `requestsConfigurationRules` system does not require this flag and is always active when rules are present. +- `requestsConfigurationRules`: is an array of objects that contain rules to match for request configuration. Each rule has a `urlPattern` regex to match and either `headers`, `params`, or `withCredentials` configuration. If the URL of a request matches the `urlPattern` of a rule, the configuration will be applied to the request. + + **Available variable for template substitution (ES6 template syntax `${variable}`):** + - `${securityToken}` - The current MapStore session token (automatically replaced) + + **Configuration options:** + - `headers` - Object containing HTTP headers to add to matching requests. Example: + + ```json { - "urlPattern": ".*geostore.*", - "method": "header", - "headers": { - "X-Auth-Token": "mytoken" - } + "urlPattern": ".*geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } } ``` + + - `params` - Object containing query parameters to add to matching requests. Example: + + ```json + { + "urlPattern": "\\/geoserver/.*", + "params": { + "authkey": "${securityToken}" + } + } + ``` + + - `withCredentials` - Boolean to enable sending credentials with requests (useful with proxies): + + ```json + { + "urlPattern": ".*internal-api.*", + "withCredentials": true + } + ``` + + - `expires` - Optional Unix timestamp (in seconds) for automatic rule expiration. Example: + + ```json + { + "urlPattern": ".*azure-blob.*", + "expires": 1735689600, + "params": { + "sv": "2024-11-04", + "sig": "token" + } + } + ``` + +!!! note "Backward Compatibility" + The old `useAuthenticationRules` and `authenticationRules` configuration still works and will be automatically converted to the new format. However, the new format is recommended for better flexibility and features like expiration support. ### initialState configuration diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index c0d83b1fdad..93708fa1e1f 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -20,6 +20,65 @@ This is a list of things to check if you want to update from a previous version - Optionally check also accessory files like `.eslinrc`, if you want to keep aligned with lint standards. - Follow the instructions below, in order, from your version to the one you want to update to. +## Migration from 2025.02.02 to 2026.01.00 + +### Replace authenticationRules with requestsConfigurationRules + +As part of improving the authentication rules to make dynamic request configurations, we have deprecated the use of `authenticationRules` in favor of the new request rule configuration `requestsConfigurationRules`. The new system provides a more flexible way to configure request authentication and parameters. + +### Configuration Changes + +#### Old Configuration (authenticationRules) + +```json +{ + "useAuthenticationRules": true, + "authenticationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "method": "bearer" + }, + { + "urlPattern": ".*rest/config.*", + "method": "bearer" + } + ] +} +``` + +#### New Configuration (requestsConfigurationRules) + +```json +{ + "requestsConfigurationRules": [ + { + "urlPattern": ".*rest/geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { + "urlPattern": ".*rest/config.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } + } + ] +} +``` + +**Note**: The `${securityToken}` placeholder is automatically replaced at runtime with the actual security token from the security context + +#### Method Mapping + +| Old Method | New Configuration | +|------------|------------------| +| `bearer` | `headers: { "Authorization": "Bearer ${securityToken}" }` | +| `authkey` | `params: { "authkey": "${securityToken}" }` | +| `basic` | `headers: { "Authorization": "${authHeader}" }` | +| `header` | `headers: { ... }` | +| `browserWithCredentials` | `withCredentials: true` | + ## Migration from 2025.01.01 to 2025.02.00 ### Update authenticationRules in localConfig.json diff --git a/web/client/actions/security.js b/web/client/actions/security.js index 25522cef465..1c696350d70 100644 --- a/web/client/actions/security.js +++ b/web/client/actions/security.js @@ -14,7 +14,6 @@ import AuthenticationAPI from '../api/GeoStoreDAO'; import {setCredentials, getToken, getRefreshToken} from '../utils/SecurityUtils'; import {encodeUTF8} from '../utils/EncodeUtils'; - export const CHECK_LOGGED_USER = 'CHECK_LOGGED_USER'; export const LOGIN_SUBMIT = 'LOGIN_SUBMIT'; export const LOGIN_PROMPT_CLOSED = "LOGIN:LOGIN_PROMPT_CLOSED"; @@ -34,6 +33,11 @@ export const SET_CREDENTIALS = 'SECURITY:SET_CREDENTIALS'; export const CLEAR_SECURITY = 'SECURITY:CLEAR_SECURITY'; export const SET_PROTECTED_SERVICES = 'SECURITY:SET_PROTECTED_SERVICES'; export const REFRESH_SECURITY_LAYERS = 'SECURITY:REFRESH_SECURITY_LAYERS'; + +export const UPDATE_REQUESTS_RULES = 'SECURITY:UPDATE_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES = 'SECURITY:LOAD_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES_ERROR = 'SECURITY:LOAD_REQUESTS_RULES_ERROR'; + export function loginSuccess(userDetails, username, password, authProvider) { return { type: LOGIN_SUCCESS, @@ -229,3 +233,36 @@ export function refreshSecurityLayers() { type: REFRESH_SECURITY_LAYERS }; } + +/** + * Updates the request configuration rules + * @param {Array} rules - Array of request configuration rules + * @param {boolean} enabled - Whether request configuration is enabled + */ +export const updateRequestsRules = (rules) => { + return { + type: UPDATE_REQUESTS_RULES, + rules + }; +}; + +/** + * Starts loading request configuration rules + */ +export const loadRequestsRules = (rules) => { + return { + type: LOAD_REQUESTS_RULES, + rules + }; +}; + +/** + * Error loading request configuration rules + * @param {Error} error - The error that occurred + */ +export const loadRequestsRulesError = (error) => { + return { + type: LOAD_REQUESTS_RULES_ERROR, + error + }; +}; diff --git a/web/client/api/ArcGIS.js b/web/client/api/ArcGIS.js index 5f0a5b042a4..ff3e994863a 100644 --- a/web/client/api/ArcGIS.js +++ b/web/client/api/ArcGIS.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ -import { getAuthorizationBasic } from '../utils/SecurityUtils'; import axios from '../libs/ajax'; import { reprojectBbox } from '../utils/CoordinatesUtils'; import trimEnd from 'lodash/trimEnd'; @@ -89,14 +88,13 @@ export const searchAndPaginate = (records, params) => { }; const getData = (url, params = {}) => { const protectedId = params?.info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); const request = _cache[url] ? () => Promise.resolve(_cache[url]) : () => axios.get(url, { params: { f: 'json' }, - headers + _msAuthSourceId: protectedId }).then(({ data }) => { _cache[url] = data; return data; diff --git a/web/client/api/CSW.js b/web/client/api/CSW.js index 9613fe29cf2..bc4b3859a4d 100644 --- a/web/client/api/CSW.js +++ b/web/client/api/CSW.js @@ -16,7 +16,6 @@ import { extractCrsFromURN, makeBboxFromOWS, makeNumericEPSG, getExtentFromNorma import WMS from "../api/WMS"; import { THREE_D_TILES, getCapabilities } from './ThreeDTiles'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -513,12 +512,11 @@ const Api = { getRecords: function(url, startPosition, maxRecords, text, options) { const body = constructXMLBody(startPosition, maxRecords, text, options); const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); return axios.post(parseUrl(url), body, { headers: { - 'Content-Type': 'application/xml', - ...headers - } + 'Content-Type': 'application/xml' + }, + _msAuthSourceId: protectedId }).then((response) => { const { error, _dcRef, result } = parseCSWResponse(response) || {}; if (result) { diff --git a/web/client/api/TMS.js b/web/client/api/TMS.js index f80c5948038..9d71c4ee9c3 100644 --- a/web/client/api/TMS.js +++ b/web/client/api/TMS.js @@ -7,7 +7,6 @@ */ import xml2js from 'xml2js'; import axios from '../libs/ajax'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; /** * Common requests to TMS services. @@ -21,8 +20,7 @@ import { getAuthorizationBasic } from '../utils/SecurityUtils'; */ export const getTileMap = (url, options) => { const protectedId = options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(response => { return new Promise((resolve) => { xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => resolve(result)); diff --git a/web/client/api/ThreeDTiles.js b/web/client/api/ThreeDTiles.js index a0b5ffc1830..467d5f26169 100644 --- a/web/client/api/ThreeDTiles.js +++ b/web/client/api/ThreeDTiles.js @@ -10,7 +10,6 @@ import axios from '../libs/ajax'; import { convertRadianToDegrees } from '../utils/CoordinatesUtils'; import { METERS_PER_UNIT } from '../utils/MapUtils'; import { logError } from '../utils/DebugUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; // converts the boundingVolume of the root tileset to a valid layer bbox function tilesetToBoundingBox(Cesium, tileset) { @@ -140,8 +139,7 @@ function extractCapabilities(tileset) { */ export const getCapabilities = (url, info) => { const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(({ data }) => { return extractCapabilities(data).then((properties) => ({ tileset: data, ...properties })); }).catch((e) => { diff --git a/web/client/api/WFS.js b/web/client/api/WFS.js index 250d2a1aebc..6370be114cf 100644 --- a/web/client/api/WFS.js +++ b/web/client/api/WFS.js @@ -14,7 +14,6 @@ import {toOGCFilterParts} from '../utils/FilterUtils'; import { getDefaultUrl } from '../utils/URLUtils'; import { castArray } from 'lodash'; import { isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; @@ -140,8 +139,7 @@ export const getCapabilities = function(url, info) { return Promise.resolve(cached.data); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(getCapabilitiesURL(url, {headers})) + return axios.get(getCapabilitiesURL(url), {_msAuthSourceId: protectedId}) .then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false, stripPrefix: true }, (ignore, result) => { diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index b6b9def38a1..faf4998ac89 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -13,7 +13,6 @@ import axios from '../libs/ajax'; import { getConfigProp } from '../utils/ConfigUtils'; import { getWMSBoundingBox } from '../utils/CoordinatesUtils'; import { isValidGetMapFormat, isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; export const WMS_GET_CAPABILITIES_VERSION = '1.3.0'; @@ -160,12 +159,12 @@ export const getDimensions = (layer) => { * - `Capability`: capability object that contains layers and requests formats * - `Service`: service information object */ -export const getCapabilities = (url, headers = {}) => { +export const getCapabilities = (url, {headers, params, _msAuthSourceId} = {}) => { return axios.get(parseUrl(url, { service: "WMS", version: WMS_GET_CAPABILITIES_VERSION, request: "GetCapabilities" - }), {headers}).then((response) => { + }), {headers, params, _msAuthSourceId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -204,8 +203,7 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return getCapabilities(url, headers) + return getCapabilities(url, {_msAuthSourceId: protectedId}) .then((json) => { capabilitiesCache[url] = { timestamp: new Date().getTime(), @@ -215,13 +213,12 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); }; export const describeLayers = (url, layers, security) => { - const headers = getAuthorizationBasic(security?.sourceId); return axios.get(parseUrl(url, { service: "WMS", version: WMS_DESCRIBE_LAYER_VERSION, layers: layers, request: "DescribeLayer" - }), {headers}).then((response) => { + }), {_msAuthSourceId: security?.sourceId}).then((response) => { let descriptions; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { descriptions = result && result.WMS_DescribeLayerResponse && result.WMS_DescribeLayerResponse.LayerDescription; diff --git a/web/client/api/WMTS.js b/web/client/api/WMTS.js index a59c3d74f05..ce05b95ccd1 100644 --- a/web/client/api/WMTS.js +++ b/web/client/api/WMTS.js @@ -24,7 +24,6 @@ import { getDefaultStyleIdentifier, getDefaultFormat } from '../utils/WMTSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -82,8 +81,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -106,8 +104,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; diff --git a/web/client/api/catalog/TMS_1_0_0.js b/web/client/api/catalog/TMS_1_0_0.js index adb57e95ad2..e16fecf0d40 100644 --- a/web/client/api/catalog/TMS_1_0_0.js +++ b/web/client/api/catalog/TMS_1_0_0.js @@ -9,7 +9,7 @@ import ConfigUtils from '../../utils/ConfigUtils'; import xml2js from 'xml2js'; import axios from '../../libs/ajax'; import { get, castArray } from 'lodash'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../../utils/SecurityUtils'; import { guessFormat } from '../../utils/TMSUtils'; const capabilitiesCache = {}; @@ -55,8 +55,7 @@ export const getRecords = (url, startPosition, maxRecords, text, info) => { }); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers} ).then((response) => { + return axios.get(url, {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => { json = { ...result, url }; diff --git a/web/client/components/map/cesium/plugins/ArcGISLayer.js b/web/client/components/map/cesium/plugins/ArcGISLayer.js index ef1d4f8e8c7..ee984b04b40 100644 --- a/web/client/components/map/cesium/plugins/ArcGISLayer.js +++ b/web/client/components/map/cesium/plugins/ArcGISLayer.js @@ -9,8 +9,9 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; import { isImageServerUrl } from '../../../../utils/ArcGISUtils'; -import { getProxiedUrl } from '../../../../utils/ConfigUtils'; - +import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; +import { getProxyUrl } from "../../../../utils/ProxyUtils"; // this override is needed to apply the selected format // and to detect an ImageServer and to apply the correct exportImage path @@ -54,9 +55,7 @@ class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImage constructor(options) { super(options); this._format = options.format; - this._resource = new Cesium.Resource({ - url: options.url - }); + this._resource = options.url; this._resource.appendForwardSlash(); } requestImage = function( @@ -73,8 +72,15 @@ class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImage } const create = (options) => { + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); + const resource = new Cesium.Resource({ + url: options.url, + queryParameters: params, + headers, + proxy: options.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined + }); return new ArcGisMapAndImageServerImageryProvider({ - url: options?.forceProxy ? getProxiedUrl() + encodeURIComponent(options.url) : options.url, + url: resource, ...(options.name !== undefined && { layers: `${options.name}` }), format: options.format, // we need to disable this when using layers ids @@ -85,7 +91,7 @@ const create = (options) => { }; const update = (layer, newOptions, oldOptions) => { - if (newOptions.forceProxy !== oldOptions.forceProxy) { + if (newOptions.forceProxy !== oldOptions.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return create(newOptions); } return null; diff --git a/web/client/components/map/cesium/plugins/ElevationLayer.js b/web/client/components/map/cesium/plugins/ElevationLayer.js index 8a7e8bcf675..2bcf2040dd4 100644 --- a/web/client/components/map/cesium/plugins/ElevationLayer.js +++ b/web/client/components/map/cesium/plugins/ElevationLayer.js @@ -8,7 +8,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; - +import isEqual from 'lodash/isEqual'; import { wmsToCesiumOptions } from '../../../../utils/cesium/WMSUtils'; import { addElevationTile, getElevation, getElevationKey, getTileRelativePixel } from '../../../../utils/ElevationUtils'; @@ -144,10 +144,18 @@ const createWMSElevationLayer = (options, map) => { return layer; }; +const create = (options, map) => { + if (options.provider === 'wms') { + return createWMSElevationLayer(options, map); + } + return null; +}; + Layers.registerType('elevation', { - create: (options, map) => { - if (options.provider === 'wms') { - return createWMSElevationLayer(options, map); + create, + update: (layer, newOptions, oldOptions, map) => { + if (!isEqual(oldOptions.security, newOptions.security)) { + return create(newOptions, map); } return null; } diff --git a/web/client/components/map/cesium/plugins/GraticuleLayer.js b/web/client/components/map/cesium/plugins/GraticuleLayer.js index b38a3025bcc..b9d9070e7a9 100644 --- a/web/client/components/map/cesium/plugins/GraticuleLayer.js +++ b/web/client/components/map/cesium/plugins/GraticuleLayer.js @@ -8,7 +8,6 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; - /** * Created by thomas on 27/01/14. // [source 07APR2015: http://pad.geocento.com/AddOns/Graticule.js] diff --git a/web/client/components/map/cesium/plugins/MarkerLayer.js b/web/client/components/map/cesium/plugins/MarkerLayer.js index 1847d39152d..85a94d851c6 100644 --- a/web/client/components/map/cesium/plugins/MarkerLayer.js +++ b/web/client/components/map/cesium/plugins/MarkerLayer.js @@ -9,7 +9,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; -import { isEqual } from 'lodash'; +import isEqual from 'lodash/isEqual'; /** * @deprecated diff --git a/web/client/components/map/cesium/plugins/ModelLayer.js b/web/client/components/map/cesium/plugins/ModelLayer.js index d9d578b2518..cd0ee004509 100644 --- a/web/client/components/map/cesium/plugins/ModelLayer.js +++ b/web/client/components/map/cesium/plugins/ModelLayer.js @@ -194,7 +194,7 @@ Layers.registerType('model', { if (primitives && !isEqual(newOptions?.features?.[0], oldOptions?.features?.[0])) { updatePrimitivesMatrix(primitives, newOptions?.features?.[0]); } - if (newOptions?.forceProxy !== oldOptions?.forceProxy) { + if (newOptions?.forceProxy !== oldOptions?.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/TerrainLayer.js b/web/client/components/map/cesium/plugins/TerrainLayer.js index b70d9928355..33dd61bfc4e 100644 --- a/web/client/components/map/cesium/plugins/TerrainLayer.js +++ b/web/client/components/map/cesium/plugins/TerrainLayer.js @@ -11,11 +11,16 @@ import * as Cesium from 'cesium'; import GeoServerBILTerrainProvider from '../../../../utils/cesium/GeoServerBILTerrainProvider'; import WMSUtils from '../../../../utils/cesium/WMSUtils'; import { getProxyUrl } from "../../../../utils/ProxyUtils"; +import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function cesiumOptionsMapping(config) { + const { headers, params } = getRequestConfigurationByUrl(config.url, null, config.security?.sourceId); return { url: new Cesium.Resource({ url: config.url, + headers, + queryParameters: params, proxy: config.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined }), options: { @@ -115,7 +120,8 @@ const updateLayer = (layer, newOptions, oldOptions, map) => { || newOptions?.options?.crs !== oldOptions?.options?.crs || newOptions?.version !== oldOptions?.version || newOptions?.name !== oldOptions?.name - || oldOptions.forceProxy !== newOptions.forceProxy) { + || oldOptions.forceProxy !== newOptions.forceProxy + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js index 17d065087d0..17f828ed9e6 100644 --- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js +++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js @@ -17,6 +17,7 @@ import tinycolor from 'tinycolor2'; import googleOnWhiteLogo from '../img/google_on_white_hdpi.png'; import googleOnNonWhiteLogo from '../img/google_on_non_white_hdpi.png'; import { createClippingPolygonsFromGeoJSON, applyClippingPolygons } from '../../../../utils/cesium/PrimitivesUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const cleanStyle = (style, options) => { if (style && options?.pointCloudShading?.attenuation) { @@ -167,11 +168,14 @@ const createLayer = (options, map) => { detached: true, ...layer, add: () => { + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); // delay creation of tileset when frequents recreation are requested timeout = setTimeout(() => { timeout = undefined; resource = new Cesium.Resource({ url: options.url, + queryParameters: params, + headers, proxy: options.forceProxy ? new Cesium.DefaultProxy(getProxyUrl()) : undefined // TODO: axios supports also adding access tokens or credentials (e.g. authkey, Authentication header ...). // if we want to use internal cesium functionality to retrieve data @@ -233,6 +237,7 @@ Layers.registerType('3dtiles', { // recreate the tileset when the imagery has been updated and the layer has enableImageryOverlay set to true || newOptions.enableImageryOverlay && (newOptions.imageryLayersTreeUpdatedCount !== oldOptions.imageryLayersTreeUpdatedCount) || (newOptions.enableImageryOverlay !== oldOptions.enableImageryOverlay) + || !isEqual(oldOptions.security, newOptions.security) ) { return createLayer(newOptions, map); } diff --git a/web/client/components/map/cesium/plugins/TileProviderLayer.js b/web/client/components/map/cesium/plugins/TileProviderLayer.js index c0457b842f1..5e40b6e7f60 100644 --- a/web/client/components/map/cesium/plugins/TileProviderLayer.js +++ b/web/client/components/map/cesium/plugins/TileProviderLayer.js @@ -13,6 +13,7 @@ import ConfigUtils from '../../../../utils/ConfigUtils'; import {creditsToAttribution} from '../../../../utils/LayersUtils'; import {getProxyUrl} from '../../../../utils/ProxyUtils'; import isEqual from 'lodash/isEqual'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function splitUrl(originalUrl) { let url = originalUrl; @@ -81,14 +82,20 @@ const create = (options) => { const cr = opt.credits; const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : opt.attribution; - return new Cesium.UrlTemplateImageryProvider({ + const { headers, params } = getRequestConfigurationByUrl(options.url, null, options.security?.sourceId); + const resource = new Cesium.Resource({ url: template(url, opt), + queryParameters: params, + headers, + proxy: options?.forceProxy ? new TileProviderProxy(proxyUrl) : new NoProxy() + }); + return new Cesium.UrlTemplateImageryProvider({ + url: resource, enablePickFeatures: false, subdomains: opt.subdomains, maximumLevel: opt.maxZoom, minimumLevel: opt.minZoom, - credit, - proxy: options?.forceProxy ? new TileProviderProxy(proxyUrl) : new NoProxy() + credit }); }; diff --git a/web/client/components/map/cesium/plugins/WMTSLayer.js b/web/client/components/map/cesium/plugins/WMTSLayer.js index 01dbcc4060d..c69da8ff7d9 100644 --- a/web/client/components/map/cesium/plugins/WMTSLayer.js +++ b/web/client/components/map/cesium/plugins/WMTSLayer.js @@ -18,7 +18,7 @@ import { isEqual, isObject, isArray, slice, get, head} from 'lodash'; import urlParser from 'url'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function splitUrl(originalUrl) { let url = originalUrl; @@ -118,13 +118,13 @@ function wmtsToCesiumOptions(_options) { const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : ''; let headersOpts; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - headersOpts = { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - } - }; + if (options.security && options.url) { + const urlToCheck = isArray(options.url) ? options.url[0] : options.url; + const requestConfig = getRequestConfigurationByUrl(urlToCheck, null, options.security?.sourceId); + + if (requestConfig.headers) { + headersOpts = { headers: requestConfig.headers }; + } } return Object.assign({ // TODO: multi-domain support, if use {s} switches to RESTFul mode diff --git a/web/client/components/map/leaflet/plugins/ElevationLayer.js b/web/client/components/map/leaflet/plugins/ElevationLayer.js index ff5d6dfd3b2..d6d1772c482 100644 --- a/web/client/components/map/leaflet/plugins/ElevationLayer.js +++ b/web/client/components/map/leaflet/plugins/ElevationLayer.js @@ -73,8 +73,8 @@ L.tileLayer.elevationWMS = function(urls, options, nodata, littleEndian, id) { const createWMSElevationLayer = (options) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = L.tileLayer.elevationWMS( urls, { diff --git a/web/client/components/map/leaflet/plugins/VectorLayer.jsx b/web/client/components/map/leaflet/plugins/VectorLayer.jsx index 0ab86451add..4ecdfc640f5 100644 --- a/web/client/components/map/leaflet/plugins/VectorLayer.jsx +++ b/web/client/components/map/leaflet/plugins/VectorLayer.jsx @@ -84,7 +84,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { if (newOptions.opacity !== oldOptions.opacity) { layer.opacity = newOptions.opacity; } - if (!isEqual(newOptions.style, oldOptions.style)) { + if (!isEqual(newOptions.style, oldOptions.style) || !isEqual(oldOptions.security, newOptions.security)) { return isNewStyle(newOptions) ? createLayer(newOptions) : createLayerLegacy(newOptions); @@ -93,7 +93,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { }; const updateLayer = (layer, newOptions, oldOptions) => { - if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter)) { + if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter) || !isEqual(oldOptions.security, newOptions.security)) { layer.remove(); return createLayer(newOptions); } diff --git a/web/client/components/map/leaflet/plugins/WFSLayer.jsx b/web/client/components/map/leaflet/plugins/WFSLayer.jsx index cb013240610..15d4491e157 100644 --- a/web/client/components/map/leaflet/plugins/WFSLayer.jsx +++ b/web/client/components/map/leaflet/plugins/WFSLayer.jsx @@ -88,7 +88,7 @@ Layers.registerType('wfs', { return layer; }, update: (layer, newOptions, oldOptions) => { - if (needsReload(oldOptions, newOptions)) { + if (needsReload(oldOptions, newOptions) || !isEqual(oldOptions.security, newOptions.security)) { loadFeatures(layer, newOptions); } if (!isEqual(newOptions.style, oldOptions.style) diff --git a/web/client/components/map/leaflet/plugins/WMSLayer.js b/web/client/components/map/leaflet/plugins/WMSLayer.js index 1cb724eccfc..a4b2a39f760 100644 --- a/web/client/components/map/leaflet/plugins/WMSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMSLayer.js @@ -10,7 +10,8 @@ import Layers from '../../../../utils/leaflet/Layers'; import { filterWMSParamOptions, getWMSURLs, wmsToLeafletOptions, removeNulls } from '../../../../utils/leaflet/WMSUtils'; import L from 'leaflet'; -import { isArray } from 'lodash'; +import isEqual from 'lodash/isEqual'; +import isArray from 'lodash/isArray'; import {addAuthenticationToSLD, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import 'leaflet.nontiledlayer'; @@ -56,19 +57,24 @@ Layers.registerType('wms', { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); if (options.singleTile) { return L.nonTiledLayer.wmsCustom(urls[0], queryParameters); } return L.tileLayer.multipleUrlWMS(urls, queryParameters); }, update: function(layer, newOptions, oldOptions) { - if (oldOptions.singleTile !== newOptions.singleTile || oldOptions.tileSize !== newOptions.tileSize || oldOptions.securityToken !== newOptions.securityToken && newOptions.visibility) { + if ( + (oldOptions.singleTile !== newOptions.singleTile + || oldOptions.tileSize !== newOptions.tileSize + || oldOptions.securityToken !== newOptions.securityToken + || !isEqual(oldOptions.security, newOptions.security)) + && newOptions.visibility) { let newLayer; const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); - const queryParameters = wmsToLeafletOptions(newOptions) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, newOptions.securityToken)); + let queryParameters = wmsToLeafletOptions(newOptions) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, newOptions.securityToken, newOptions.security?.sourceId); if (newOptions.singleTile) { // return the nonTiledLayer newLayer = L.nonTiledLayer.wmsCustom(urls[0], queryParameters); diff --git a/web/client/components/map/leaflet/plugins/WMTSLayer.js b/web/client/components/map/leaflet/plugins/WMTSLayer.js index 9d59d7f0a32..f3250cedc3b 100644 --- a/web/client/components/map/leaflet/plugins/WMTSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMTSLayer.js @@ -13,7 +13,8 @@ import {addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import { creditsToAttribution } from '../../../../utils/LayersUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import WMTS from '../../../../utils/leaflet/WMTS'; -import { isArray } from 'lodash'; +import isArray from 'lodash/isArray'; +import isEqual from 'lodash/isEqual'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; L.tileLayer.wmts = function(urls, options, matrixOptions) { @@ -47,8 +48,8 @@ function getWMSURLs(urls) { const createLayer = _options => { const options = WMTSUtils.parseTileMatrixSetOption(_options); const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmtsToLeafletOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmtsToLeafletOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const srs = normalizeSRS(options.srs || 'EPSG:3857', options.allowedSRS); const { tileMatrixSet, matrixIds } = WMTSUtils.getTileMatrix(options, srs); return L.tileLayer.wmts(urls, queryParameters, { @@ -65,7 +66,8 @@ const createLayer = _options => { const updateLayer = (layer, newOptions, oldOptions) => { if (oldOptions.securityToken !== newOptions.securityToken || oldOptions.format !== newOptions.format - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions); } return null; diff --git a/web/client/components/map/openlayers/plugins/ArcGISLayer.js b/web/client/components/map/openlayers/plugins/ArcGISLayer.js index 5bef918b143..0fcb35f2644 100644 --- a/web/client/components/map/openlayers/plugins/ArcGISLayer.js +++ b/web/client/components/map/openlayers/plugins/ArcGISLayer.js @@ -11,15 +11,12 @@ import { registerType } from '../../../../utils/openlayers/Layers'; import TileLayer from 'ol/layer/Tile'; import TileArcGISRest from 'ol/source/TileArcGISRest'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -31,7 +28,7 @@ const tileLoadFunction = options => (image, src) => { registerType('arcgis', { create: (options) => { const sourceOpt = {}; - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } return new TileLayer({ @@ -58,7 +55,8 @@ registerType('arcgis', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } }, diff --git a/web/client/components/map/openlayers/plugins/COGLayer.js b/web/client/components/map/openlayers/plugins/COGLayer.js index 103e622a687..2b12f4e21ed 100644 --- a/web/client/components/map/openlayers/plugins/COGLayer.js +++ b/web/client/components/map/openlayers/plugins/COGLayer.js @@ -13,15 +13,16 @@ import get from 'lodash/get'; import GeoTIFF from 'ol/source/GeoTIFF.js'; import TileLayer from 'ol/layer/WebGLTile.js'; import { isProjectionAvailable } from '../../../../utils/ProjectionUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function create(options) { - let sourceOptions; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - sourceOptions.headers = { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }; + let sourceOptions = {}; + if (options.security && options.sources && options.sources.length > 0) { + const firstSource = options.sources[0]; + const requestConfig = getRequestConfigurationByUrl(firstSource.url, null, options.security?.sourceId); + if (requestConfig.headers) { + sourceOptions.headers = requestConfig.headers; + } } return new TileLayer({ msId: options.id, @@ -47,6 +48,7 @@ Layers.registerType('cog', { || !isEqual(newOptions.style, oldOptions.style) || !isEqual(newOptions.security, oldOptions.security) || !isEqual(newOptions.sources, oldOptions.sources) // min/max source data value can change + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash) ) { return create(newOptions, map); } diff --git a/web/client/components/map/openlayers/plugins/ElevationLayer.js b/web/client/components/map/openlayers/plugins/ElevationLayer.js index bb1f0167333..139dbaa873f 100644 --- a/web/client/components/map/openlayers/plugins/ElevationLayer.js +++ b/web/client/components/map/openlayers/plugins/ElevationLayer.js @@ -57,8 +57,8 @@ function getElevation(pos) { const createWMSElevationLayer = (options, map) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = new TileLayer({ msId: options.id, opacity: options.opacity !== undefined ? options.opacity : 1, diff --git a/web/client/components/map/openlayers/plugins/TMSLayer.js b/web/client/components/map/openlayers/plugins/TMSLayer.js index efe24b5e1ef..5dcfe493b73 100644 --- a/web/client/components/map/openlayers/plugins/TMSLayer.js +++ b/web/client/components/map/openlayers/plugins/TMSLayer.js @@ -12,14 +12,10 @@ import TileGrid from 'ol/tilegrid/TileGrid'; import TileLayer from 'ol/layer/Tile'; import Layers from '../../../../utils/openlayers/Layers'; -import { getCredentials } from '../../../../utils/SecurityUtils'; - +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -36,7 +32,7 @@ function tileXYZToOpenlayersOptions(options = {}) { url: `${options.tileMapUrl}/{z}/{x}/{-y}.${options.extension}`, // TODO use resolutions attributions: options.attribution ? [options.attribution] : [] }; - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } @@ -89,7 +85,8 @@ Layers.registerType('tms', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } } diff --git a/web/client/components/map/openlayers/plugins/TileProviderLayer.js b/web/client/components/map/openlayers/plugins/TileProviderLayer.js index 34d6c4bda64..dc490072aa7 100644 --- a/web/client/components/map/openlayers/plugins/TileProviderLayer.js +++ b/web/client/components/map/openlayers/plugins/TileProviderLayer.js @@ -12,19 +12,16 @@ import { getUrls, template } from '../../../../utils/TileProviderUtils'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; +import { hasRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function lBoundsToOlExtent(bounds, destPrj) { var [ [ miny, minx], [ maxy, maxx ] ] = bounds; return CoordinatesUtils.reprojectBbox([minx, miny, maxx, maxy], 'EPSG:4326', CoordinatesUtils.normalizeSRS(destPrj)); } const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); @@ -41,7 +38,7 @@ function tileXYZToOpenlayersOptions(options) { maxZoom: options.maxZoom ? options.maxZoom : 18, minZoom: options.minZoom ? options.minZoom : 0 // dosen't affect ol layer rendering UNSUPPORTED }); - if (options.security) { + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { sourceOpt.tileLoadFunction = tileLoadFunction(options); } let source = new XYZ(sourceOpt); @@ -70,7 +67,8 @@ Layers.registerType('tileprovider', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { + if (!isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } } diff --git a/web/client/components/map/openlayers/plugins/WFSLayer.js b/web/client/components/map/openlayers/plugins/WFSLayer.js index 41b52eec62d..0465eb8ef01 100644 --- a/web/client/components/map/openlayers/plugins/WFSLayer.js +++ b/web/client/components/map/openlayers/plugins/WFSLayer.js @@ -170,7 +170,10 @@ Layers.registerType('wfs', { f.getGeometry().transform(oldCrs, newCrs); }); } - if (needsReload(oldOptions, options) || !isEqual(oldOptions.security, options.security)) { + if (needsReload(oldOptions, options) + || !isEqual(oldOptions.security, options.security) + || !isEqual(oldOptions.requestRuleRefreshHash, options.requestRuleRefreshHash) + ) { source.setLoader(createLoader(source, options)); source.clear(); source.refresh(); diff --git a/web/client/components/map/openlayers/plugins/WMSLayer.js b/web/client/components/map/openlayers/plugins/WMSLayer.js index 578ec91c66f..d478e48ce76 100644 --- a/web/client/components/map/openlayers/plugins/WMSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMSLayer.js @@ -104,8 +104,8 @@ const createLayer = (options, map, mapId) => { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const headers = getAuthenticationHeaders(urls[0], options.securityToken, options.security); const vectorFormat = isVectorFormat(options.format); @@ -187,6 +187,8 @@ const mustCreateNewLayer = (oldOptions, newOptions) => { || oldOptions.forceProxy !== newOptions.forceProxy || oldOptions.tileGridStrategy !== newOptions.tileGridStrategy || !isEqual(oldOptions.tileGrids, newOptions.tileGrids) + || !isEqual(oldOptions.security, newOptions.security) + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash) ); }; @@ -268,12 +270,6 @@ Layers.registerType('wms', { if (oldOptions.maxResolution !== newOptions.maxResolution) { layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); } - if (!isEqual(oldOptions.security, newOptions.security)) { - const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); - const headers = getAuthenticationHeaders(urls[0], newOptions.securityToken, newOptions.security); - wmsSource.setTileLoadFunction(loadFunction(newOptions, headers)); - wmsSource.refresh(); - } if (needsRefresh) { // forces tile cache drop // this prevents old cached tiles at lower zoom levels to be diff --git a/web/client/components/map/openlayers/plugins/WMTSLayer.js b/web/client/components/map/openlayers/plugins/WMTSLayer.js index 43b6e55c31c..48731414875 100644 --- a/web/client/components/map/openlayers/plugins/WMTSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMTSLayer.js @@ -13,7 +13,7 @@ import head from 'lodash/head'; import last from 'lodash/last'; import axios from '../../../../libs/ajax'; import { proxySource } from '../../../../utils/openlayers/WMSUtils'; -import {getCredentials, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; +import {addAuthenticationParameter, hasRequestConfigurationByUrl} from '../../../../utils/SecurityUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import CoordinatesUtils from '../../../../utils/CoordinatesUtils'; import MapUtils from '../../../../utils/MapUtils'; @@ -46,11 +46,8 @@ function getWMSURLs(urls, requestEncoding) { } const tileLoadFunction = (options) => (image, src) => { - const storedProtectedService = options.security ? getCredentials(options.security?.sourceId) : {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { if (isValidResponse(response)) { @@ -119,8 +116,8 @@ const createLayer = options => { // the extent has effect to the tile ranges // we should skip the extent if the layer does not provide bounding box let extent = layerExtent && getIntersection(layerExtent, projection.getExtent()); - const queryParameters = options.params ? options.params : {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = options.params ? options.params : {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const queryParametersString = urlParser.format({ query: { ...queryParameters } }); // TODO: support tileSizes from matrix @@ -154,8 +151,8 @@ const createLayer = options => { }), wrapX: true }; - if (options.security?.sourceId) { - wmtsOptions.urls = urls.map(url => proxySource(options.forceProxy, url)); + if (hasRequestConfigurationByUrl(options.url, null, options.security?.sourceId)) { + wmtsOptions.urls = wmtsOptions.urls.map(url => proxySource(options.forceProxy, url)); wmtsOptions.tileLoadFunction = tileLoadFunction(options); } @@ -189,7 +186,8 @@ const updateLayer = (layer, newOptions, oldOptions) => { || oldOptions.srs !== newOptions.srs || oldOptions.format !== newOptions.format || oldOptions.style !== newOptions.style - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { return createLayer(newOptions); } if (oldOptions.minResolution !== newOptions.minResolution) { diff --git a/web/client/components/misc/SecureImage.jsx b/web/client/components/misc/SecureImage.jsx index a87ac6c964b..038c1b40c87 100644 --- a/web/client/components/misc/SecureImage.jsx +++ b/web/client/components/misc/SecureImage.jsx @@ -7,9 +7,8 @@ */ import React, { useEffect, useState } from 'react'; -import axios from 'axios'; - -import { getAuthKeyParameter, getAuthenticationMethod, getAuthorizationBasic, getToken } from '../../utils/SecurityUtils'; +import axios from '../../libs/ajax'; +import { getAuthenticationMethod, getAuthKeyParameter, getToken } from '../../utils/SecurityUtils'; import { updateUrlParams } from '../../utils/URLUtils'; @@ -53,10 +52,9 @@ const SecureImage = ({ } } else if (props?.layer?.security?.sourceId) { - const headers = getAuthorizationBasic(props?.layer?.security?.sourceId); axios.get(src, { responseType: 'blob', - headers + _msAuthSourceId: props?.layer?.security?.sourceId }) .then((response) => { const imageUrl = URL.createObjectURL(response.data); diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 556e4dc4b6e..318fc989144 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -30,7 +30,6 @@ "mapboxAccessToken": "__ACCESS_TOKEN_MAPBOX__", "initialMapFilter": "", "ignoreMobileCss": false, - "useAuthenticationRules": true, "loadAfterTheme": true, "defaultMapOptions": { "cesium": { @@ -48,14 +47,18 @@ "localizedLayerStyles": { "name": "mapstore_language" }, - "authenticationRules": [ + "requestsConfigurationRules": [ { "urlPattern": ".*rest/geostore.*", - "method": "bearer" + "headers": { + "Authorization": "Bearer ${securityToken}" + } }, { "urlPattern": ".*rest/config.*", - "method": "bearer" + "headers": { + "Authorization": "Bearer ${securityToken}" + } } ], "monitorState": [ diff --git a/web/client/epics/security.js b/web/client/epics/security.js index 2a473902984..b5942a06d59 100644 --- a/web/client/epics/security.js +++ b/web/client/epics/security.js @@ -8,14 +8,32 @@ import Rx from 'rxjs'; import uniqBy from 'lodash/uniqBy'; import isArray from 'lodash/isArray'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import head from 'lodash/head'; +import castArray from 'lodash/castArray'; +import isEmpty from 'lodash/isEmpty'; +import { v4 as uuidv4 } from 'uuid'; import { DASHBOARD_LOADED } from '../actions/dashboard'; import { SET_CURRENT_STORY } from '../actions/geostory'; import { EDITOR_CHANGE } from '../actions/widgets'; import { UPDATE_ITEM } from '../actions/mediaEditor'; import { currentMediaTypeSelector, selectedItemSelector } from '../selectors/mediaEditor'; import { MAP_CONFIG_LOADED } from '../actions/config'; -import { setShowModalStatus, setProtectedServices } from '../actions/security'; -import { getCredentials } from '../utils/SecurityUtils'; +import { + setShowModalStatus, + setProtectedServices, + loadRequestsRules, + LOAD_REQUESTS_RULES, + UPDATE_REQUESTS_RULES +} from '../actions/security'; +import { + getCredentials, + convertAuthenticationRulesToRequestConfiguration +} from '../utils/SecurityUtils'; +import { LOCAL_CONFIG_LOADED } from '../actions/localConfig'; +import { layersSelector } from '../selectors/layers'; +import { changeLayerProperties } from '../actions/layers'; /** * checks if a content is protected in a map @@ -196,3 +214,72 @@ export const checkProtectedContentGeostoryEpic = (action$) => return Rx.Observable.of(setShowModalStatus(false)); }); +/** + * Epic to handle loading request configuration rules from config + */ +export const loadRequestsRulesFromConfigEpic = (action$) => + action$.ofType(LOCAL_CONFIG_LOADED) + .switchMap((action) => { + const config = action.config; + let rules = config?.requestsConfigurationRules ?? []; + const legacyRules = config?.authenticationRules ?? []; + const useLegacyRules = config?.useAuthenticationRules ?? false; + if (isEmpty(rules) && !isEmpty(legacyRules) && useLegacyRules) { + rules = convertAuthenticationRulesToRequestConfiguration(legacyRules); + } + return Rx.Observable.of(loadRequestsRules(rules)); + }); + +/** + * Helper function to determine which rules have changed + * Returns an array of URL patterns from rules that have changed + */ +const getChangedRuleUrlPatterns = (oldRules, newRules) => { + const makeMap = (rules) => new Map(rules.filter(r => r?.urlPattern).map(r => [r.urlPattern, r])); + const [oldMap, newMap] = [makeMap(oldRules), makeMap(newRules)]; + const changed = new Set([ + ...[...newMap].filter(([p, n]) => !oldMap.has(p) || !isEqual(oldMap.get(p), n)).map(([p]) => p), + ...[...oldMap].filter(([p]) => !newMap.has(p)).map(([p]) => p) + ]); + return [...changed]; +}; + +/** + * Epic to refresh layers when request configuration rules are updated + * This ensures that layers re-fetch tiles with the new authentication parameters + * Only refreshes layers whose URLs match changed rules + */ +export const refreshLayersOnRulesUpdateEpic = (action$, store) => + action$.ofType(LOAD_REQUESTS_RULES, UPDATE_REQUESTS_RULES) + .switchMap((action) => { + const state = store.getState(); + const newRules = get(action, 'rules', []); + const oldRules = state.security?.previousRules || []; + + // Get URL patterns of rules that have changed + const changedPatterns = getChangedRuleUrlPatterns(oldRules, newRules); + + if (isEmpty(changedPatterns)) { + // No rules changed, no need to refresh + return Rx.Observable.empty(); + } + + const layers = layersSelector(state) || []; + + // Find layers that should be refreshed based on matching changed rules + const layersToUpdate = []; + layers.forEach(layer => { + const url = head(castArray(layer.url)); + + // Check if any layer URL matches any changed rule pattern + const shouldRefresh = changedPatterns.some(pattern => url?.match(new RegExp(pattern, "i"))); + if (shouldRefresh) layersToUpdate.push(layer); + }); + + // Dispatch changeLayerProperties for each matching layer + const actions = layersToUpdate.map(layer => { + return changeLayerProperties(layer.id, { requestRuleRefreshHash: uuidv4() }); + }); + + return actions.length > 0 ? Rx.Observable.from(actions) : Rx.Observable.empty(); + }); diff --git a/web/client/epics/wfsquery.js b/web/client/epics/wfsquery.js index 9cb09c5a8da..9e4d90e46a9 100644 --- a/web/client/epics/wfsquery.js +++ b/web/client/epics/wfsquery.js @@ -58,7 +58,6 @@ import {selectedLayerSelector, useLayerFilterSelector} from '../selectors/featur import {layerLoad} from '../actions/layers'; import { mergeFiltersToOGC } from '../utils/FilterUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const extractInfo = (data, fields = []) => { return { @@ -140,8 +139,7 @@ export const featureTypeSelectedEpic = (action$, store) => .mergeAll(); } - const headers = getAuthorizationBasic(selectedLayer?.security?.sourceId); - return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {headers})) + return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {_msAuthSourceId: selectedLayer?.security?.sourceId})) .map((response) => { if (typeof response.data === 'object' && response.data.featureTypes && response.data.featureTypes[0]) { const info = extractInfo(response.data, action.fields); diff --git a/web/client/libs/ajax.js b/web/client/libs/ajax.js index 2e12333416e..cab7403ca3d 100644 --- a/web/client/libs/ajax.js +++ b/web/client/libs/ajax.js @@ -10,10 +10,12 @@ import axios from 'axios'; import combineURLs from 'axios/lib/helpers/combineURLs'; import ConfigUtils from '../utils/ConfigUtils'; import { - isAuthenticationActivated, - getAuthenticationRule, + getAuthenticationMethod, + getAuthorizationBasic, + getRequestConfigurationByUrl, + getRequestConfigurationRule, getToken, - getBasicAuthHeader + isRequestConfigurationActivated } from '../utils/SecurityUtils'; import isObject from 'lodash/isObject'; @@ -21,6 +23,7 @@ import omitBy from 'lodash/omitBy'; import isNil from 'lodash/isNil'; import urlUtil from 'url'; import { getProxyCacheByUrl, setProxyCacheByUrl } from '../utils/ProxyUtils'; +import { isEmpty } from 'lodash'; /** * Internal helper that adds an extra paramater to an axios configuration. @@ -44,59 +47,45 @@ function addHeaderToAxiosConfig(axiosConfig, headerName, headerValue) { * authentication method based on the request URL. */ function addAuthenticationToAxios(axiosConfig) { - if (!axiosConfig || !axiosConfig.url || !isAuthenticationActivated()) { + if (!axiosConfig || !axiosConfig.url) { return axiosConfig; } const axiosUrl = combineURLs(axiosConfig.baseURL || '', axiosConfig.url); - const rule = getAuthenticationRule(axiosUrl); - switch (rule && rule.method) { - case 'browserWithCredentials': - { - axiosConfig.withCredentials = true; - return axiosConfig; - } - case 'authkey': - { - const token = getToken(); - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + // Extract custom sourceId from axios config if provided + const sourceId = axiosConfig._msAuthSourceId; + + const method = getAuthenticationMethod(axiosUrl); + if (method === "bearer" && !getToken()) return axiosConfig; + if (method === "authkey" && !getToken()) return axiosConfig; + if (method === "basic" && sourceId && isEmpty(getAuthorizationBasic(sourceId))) return axiosConfig; + + // If request configuration is not activated but sourceId is provided, still need to handle basic auth + const { headers, params } = getRequestConfigurationByUrl(axiosUrl, undefined, sourceId); + + if (headers) { + Object.entries(headers).forEach(([headerName, headerValue]) => { + addHeaderToAxiosConfig(axiosConfig, headerName, headerValue); + }); } - case 'test': { - const token = rule ? rule.token : ""; - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + if (params) { + Object.entries(params).forEach(([paramName, paramValue]) => { + addParameterToAxiosConfig(axiosConfig, paramName, paramValue); + }); } - case 'basic': - const basicAuthHeader = getBasicAuthHeader(); - if (!basicAuthHeader) { - return axiosConfig; - } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', basicAuthHeader); - return axiosConfig; - case 'bearer': - { - const token = getToken(); - if (!token) { - return axiosConfig; + + // Check for withCredentials + if (isRequestConfigurationActivated()) { + const rule = getRequestConfigurationRule(axiosUrl); + if (rule?.withCredentials) { + axiosConfig.withCredentials = true; } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', "Bearer " + token); - return axiosConfig; - } - case 'header': { - Object.entries(rule.headers).map(([headerName, headerValue]) => addHeaderToAxiosConfig(axiosConfig, headerName, headerValue)); - return axiosConfig; - } - default: - // we cannot handle the required authentication method - return axiosConfig; } + + // Remove the custom prop from config to avoid it being sent as a regular param + delete axiosConfig._msAuthSourceId; + + return axiosConfig; } const checkSameOrigin = (uri) => { diff --git a/web/client/observables/wfs.js b/web/client/observables/wfs.js index 53cae4cb928..f542b67ab63 100644 --- a/web/client/observables/wfs.js +++ b/web/client/observables/wfs.js @@ -19,7 +19,6 @@ import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; import requestBuilder from '../utils/ogc/WFS/RequestBuilder'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const {getFeature, query, sortBy, propertyName} = requestBuilder({ wfsVersion: "1.1.0" }); @@ -170,7 +169,6 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption } const { data, queryString } = getFeatureUtilities(searchUrl, filterObj, options, downloadOption); - const headers = getAuthorizationBasic(options.layer?.security?.sourceId || options.security?.sourceId); return Rx.Observable.defer(() => axios.post(queryString, data, { @@ -178,9 +176,9 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption responseType: 'arraybuffer', headers: { 'Accept': `application/xml`, - 'Content-Type': `application/xml`, - ...headers - } + 'Content-Type': `application/xml` + }, + _msAuthSourceId: options.layer?.security?.sourceId || options.security?.sourceId })); }; @@ -278,14 +276,13 @@ export const getLayerJSONFeature = ({ search = {}, url, name, security } = {}, f }); export const describeFeatureType = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const url = toDescribeURL(layer); return Rx.Observable.defer(() => - axios.get(toDescribeURL(layer), {headers})).let(interceptOGCError); + axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})).let(interceptOGCError); }; export const getLayerWFSCapabilities = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - - return Rx.Observable.defer( () => axios.get(toLayerCapabilitiesURL(layer), {headers})) + const url = toLayerCapabilitiesURL(layer); + return Rx.Observable.defer( () => axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})) .let(interceptOGCError) .switchMap( response => Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, { tagNameProcessors: [stripPrefix], diff --git a/web/client/observables/wms.js b/web/client/observables/wms.js index e71f00bde43..a5c8952593d 100644 --- a/web/client/observables/wms.js +++ b/web/client/observables/wms.js @@ -17,7 +17,7 @@ import axios from '../libs/ajax'; import { determineCrs, fetchProjRemotely, getProjUrl } from '../utils/CoordinatesUtils'; import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../utils/SecurityUtils'; import { getDefaultUrl } from '../utils/URLUtils'; const proj4 = Proj4js; @@ -40,12 +40,10 @@ export const toDescribeLayerURL = ({name, search = {}, url} = {}) => { }); }; export const describeLayer = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer( () => axios.get(toDescribeLayerURL(l), {headers})).let(interceptOGCError); + return Observable.defer( () => axios.get(toDescribeLayerURL(l), {_msAuthSourceId: l?.security?.sourceId})).let(interceptOGCError); }; export const getLayerCapabilities = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), headers)) + return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), {_msAuthSourceId: l?.security?.sourceId})) .let(interceptOGCError) .map(c => WMS.parseLayerCapabilities(c, l)); }; diff --git a/web/client/observables/wps/execute.js b/web/client/observables/wps/execute.js index d75c265485b..678f07824f8 100644 --- a/web/client/observables/wps/execute.js +++ b/web/client/observables/wps/execute.js @@ -13,7 +13,6 @@ import { stripPrefix } from 'xml2js/lib/processors'; import axios from '../../libs/ajax'; import { getWPSURL } from './common'; -import { getAuthorizationBasic } from '../../utils/SecurityUtils'; /** * Contains routines pertaining to Execute WPS operation. @@ -194,13 +193,13 @@ export const makeOutputsExtractor = (...extractors) => * @returns {Observable} observable that emits result from axios.post */ export const executeProcessRequest = (url, payload, requestOptions = {}, layer) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const wpsUrl = getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}); return Observable.defer(() => - axios.post(getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}), payload, { + axios.post(wpsUrl, payload, { headers: { - 'Content-Type': 'application/xml', - ...headers + 'Content-Type': 'application/xml' }, + _msAuthSourceId: layer?.security?.sourceId, ...requestOptions }) ); diff --git a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx index a99f542437b..4fc141d6096 100644 --- a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx +++ b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx @@ -133,7 +133,7 @@ class StyleBasedWMSJsonLegend extends React.Component { const cleanParams = clearNilValuesForParams(layer.params); const scale = this.getScale(props); const projection = normalizeSRS(props.projection || 'EPSG:3857', layer.allowedSRS); - const query = { + let query = { ...getWMSLegendConfig({ layer, format: LEGEND_FORMAT.JSON, @@ -146,8 +146,7 @@ class StyleBasedWMSJsonLegend extends React.Component { ...(cleanParams && cleanParams.SLD_BODY ? { SLD_BODY: cleanParams.SLD_BODY } : {}), ...(scale !== null ? { SCALE: scale } : {}) }; - addAuthenticationParameter(url, query); - + query = addAuthenticationParameter(url, query); return urlUtil.format({ host: urlObj.host, protocol: urlObj.protocol, diff --git a/web/client/reducers/security.js b/web/client/reducers/security.js index 6462daaba66..2329300428f 100644 --- a/web/client/reducers/security.js +++ b/web/client/reducers/security.js @@ -17,14 +17,23 @@ import { SESSION_VALID, CHANGE_PASSWORD, SET_SHOW_MODAL_STATUS, - SET_PROTECTED_SERVICES + SET_PROTECTED_SERVICES, + UPDATE_REQUESTS_RULES, + LOAD_REQUESTS_RULES_ERROR, + LOAD_REQUESTS_RULES } from '../actions/security'; import { RESET_CONTROLS, SET_CONTROL_PROPERTY } from '../actions/controls'; import { USERMANAGER_UPDATE_USER } from '../actions/users'; import {getUserAttributes} from '../utils/SecurityUtils'; import { cloneDeep, head } from 'lodash'; -const initialState = {user: null, errorCause: null}; +const initialState = { + user: null, + rules: [], + loading: false, + error: null, + lastRefresh: null +}; function security(state = initialState, action) { switch (action.type) { case USERMANAGER_UPDATE_USER: @@ -135,6 +144,27 @@ function security(state = initialState, action) { }; } + case UPDATE_REQUESTS_RULES: + return { + ...state, + previousRules: state.rules ?? [], + rules: action.rules ?? [], + error: null + }; + case LOAD_REQUESTS_RULES: + return { + ...state, + loading: false, + previousRules: state.rules ?? [], + rules: action.rules ?? [], + error: null + }; + case LOAD_REQUESTS_RULES_ERROR: + return { + ...state, + loading: false, + error: action.error + }; default: return state; } diff --git a/web/client/selectors/__tests__/catalog-test.js b/web/client/selectors/__tests__/catalog-test.js index 7ed69533e96..380925ea842 100644 --- a/web/client/selectors/__tests__/catalog-test.js +++ b/web/client/selectors/__tests__/catalog-test.js @@ -232,7 +232,14 @@ describe('Test catalog selectors', () => { expect(retVal).toBe("someval"); }); it('test authkeyParamNameSelector with authkey params set', () => { - const authkeyParamNames = authkeyParamNameSelector(state); + const authkeyParamNames = authkeyParamNameSelector({security: {rules: [ + { + urlPattern: ".*geoserver.*", + params: { + "ms2-authkey": "${securityToken}" + } + } + ]}}); expect(authkeyParamNames).toExist(); expect(authkeyParamNames.length).toBe(1); expect(authkeyParamNames[0]).toBe("ms2-authkey"); diff --git a/web/client/selectors/catalog.js b/web/client/selectors/catalog.js index b3656eba9db..4108438970c 100644 --- a/web/client/selectors/catalog.js +++ b/web/client/selectors/catalog.js @@ -48,7 +48,17 @@ export const layerErrorSelector = (state) => get(state, "catalog.layerError"); export const searchTextSelector = (state) => get(state, "catalog.searchOptions.text", ""); export const isActiveSelector = (state) => get(state, "controls.toolbar.active") === "metadataexplorer" || get(state, "controls.metadataexplorer.enabled"); export const authkeyParamNameSelector = (state) => { - return (get(state, "localConfig.authenticationRules") || []).filter(a => a.method === "authkey").map(r => r.authkeyParamName) || []; + const rules = state?.security?.rules || []; + const authKeyParams = rules + .filter(rule => rule.params) + .map(rule => { + const authKeyParam = Object.keys(rule.params).find(key => + rule.params[key] && (`${rule.params[key]}`.includes('${securityToken}')) + ); + return authKeyParam; + }) + .filter(param => param); + return authKeyParams; }; export const pageSizeSelector = (state) => get(state, "catalog.pageSize", 4); export const delayAutoSearchSelector = (state) => get(state, "catalog.delayAutoSearch", 1000); diff --git a/web/client/selectors/security.js b/web/client/selectors/security.js index 30dd31fe0f3..8da873b8638 100644 --- a/web/client/selectors/security.js +++ b/web/client/selectors/security.js @@ -53,7 +53,8 @@ export const securityTokenSelector = state => state.security && state.security.t export const isAdminUserSelector = (state) => userRoleSelector(state) === "ADMIN"; export const isUserSelector = (state) => userRoleSelector(state) === "USER"; export const authProviderSelector = state => state.security && state.security.authProvider; - +export const requestsRulesSelector = state => get(state, 'security.rules', []); +export const requestsRulesEnabledSelector = state => get(state, 'security.rulesEnabled', false); /** * Creates a selector that checks if user is allowed to edit * something based on the user's role and groups diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index 3386738169c..a1252bfc9b9 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -850,11 +850,8 @@ export const setCustomUtils = (type, fun) => { export const getAuthenticationParam = options => { const urls = getURLs(isArray(options.url) ? options.url : [options.url]); - let authenticationParam = {}; - urls.forEach(url => { - addAuthenticationParameter(url, authenticationParam, options.securityToken); - }); - return authenticationParam; + // Use first URL since all URLs in array should have same auth config + return addAuthenticationParameter(urls[0] || '', {}, options.securityToken, options.security?.sourceId); }; /** * Removes google backgrounds and select an alternative one as visible diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index 0d0b7e0dec5..b20385f54c4 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -13,6 +13,9 @@ import head from "lodash/head"; import isNil from "lodash/isNil"; import isArray from "lodash/isArray"; import isEmpty from "lodash/isEmpty"; +import template from "lodash/template"; +import get from "lodash/get"; +import castArray from "lodash/castArray"; import {setStore as stateSetStore, getState} from "./StateUtils"; @@ -112,110 +115,300 @@ export function findUserAttributeValue(attributeName) { } /** - * Returns an array with the configured authentication rules. If no rules - * were configured an empty array is returned. + * Parses request configuration by replacing variables with actual values using lodash template + * @param {Object} config - Configuration object with headers/params + * @param {Object} securityProperties - Security properties to replace variables + * @returns {Object} Parsed configuration with replaced variables */ -export function getAuthenticationRules() { - return ConfigUtils.getConfigProp('authenticationRules') || []; -} +const parseRequestConfiguration = (config = {}, securityProperties) => { + return Object.fromEntries( + Object.entries(config) + .map((entry) => { + const [name, value] = entry; + if (typeof value === 'string' && value.includes('${')) { + try { + // Use lodash template for variable substitution + const compiled = template(value); + let result = compiled(securityProperties); + result = result === "" ? undefined : result; + return [name, result]; + } catch (error) { + console.warn(`Template parsing error for ${name}:`, error); + return entry; // Return original if template fails + } + } + return entry; + }) + .filter(entry => entry) + ); +}; /** - * Checks if authentication is activated or not. + * Legacy compatibility: Converts old authenticationRules to new format + * @param {Array} authRules - Old authentication rules + * @returns {Array} New request configuration rules */ -export function isAuthenticationActivated() { - return ConfigUtils.getConfigProp('useAuthenticationRules') || false; -} +export const convertAuthenticationRulesToRequestConfiguration = (authRules = []) => { + return authRules.map(rule => { + const newRule = { + urlPattern: rule.urlPattern + }; + + switch (rule.method) { + case 'bearer': + newRule.headers = { + 'Authorization': 'Bearer ${securityToken}' + }; + break; + case 'authkey': + newRule.params = { + [rule.authkeyParamName || 'authkey']: '${securityToken}' + }; + break; + case 'basic': + newRule.headers = { + 'Authorization': 'Basic ${securityToken}' + }; + break; + case 'test': + newRule.params = { + [rule.authkeyParamName || 'authkey']: rule.token ?? "" + }; + break; + case 'header': + newRule.headers = rule.headers || {}; + break; + case 'browserWithCredentials': + newRule.withCredentials = true; + break; + default: + // Unknown method, skip this rule + return null; + } + + return newRule; + }).filter(rule => rule !== null); +}; + +/** + * Gets all request configuration rules from Redux state or config + * Automatically converts authenticationRules to new format if requestsConfigurationRules is missing + * @returns {Array} Array of request configuration rules + */ +export const getRequestConfigurationRules = () => { + // First try to get from Redux state (if available) + const stateRules = get(getState(), 'security.rules', []); + if (!isEmpty(stateRules)) { + return stateRules; + } + + // Try to get new format from config + const configRules = ConfigUtils.getConfigProp('requestsConfigurationRules'); + if (!isEmpty(configRules)) { + return configRules; + } + + // If new format is missing, convert old authenticationRules format + const authRules = ConfigUtils.getConfigProp('authenticationRules'); + if (!isEmpty(authRules)) { + return convertAuthenticationRulesToRequestConfiguration(authRules); + } + + // No rules found + return []; +}; + +/** + * Gets the request configuration rule that matches the provided URL + * @param {string|string[]} url - The URL to match against rules + * @returns {Object|null} Matching rule or null + */ +export const getRequestConfigurationRule = (url) => { + const _url = head(castArray(url ?? [])) ?? ""; + const rules = getRequestConfigurationRules(); + return head(rules.filter( + rule => rule && rule.urlPattern && _url.match(new RegExp(rule.urlPattern, "i")) + )); +}; /** - * Returns the authentication method that should be used for the provided URL. - * We go through the authentication rules and find the first one that matches - * the provided URL, if no rule matches the provided URL undefined is returned. + * Gets the authentication method for a given URL based on request configuration rules. + * Infers the method type from the rule structure for backward compatibility. + * @param {string} url - The URL to check. + * @returns {string} The inferred authentication method. */ export function getAuthenticationMethod(url) { - const foundRule = head(getAuthenticationRules().filter( - rule => rule && rule.urlPattern && url.match(new RegExp(rule.urlPattern, "i")))); - return foundRule?.method; + const rule = getRequestConfigurationRule(url); + if (!rule) return null; + + if (rule.params && Object.keys(rule.params).length > 0) { + const hasTokenParam = Object.values(rule.params) + .some(val => typeof val === 'string' && val.includes('${securityToken}')); + if (hasTokenParam) return 'authkey'; + } + + if (rule.headers) { + const authHeader = rule.headers.Authorization || rule.headers.authorization; + if (typeof authHeader === 'string') { + if (authHeader.includes('Bearer')) return 'bearer'; + if (authHeader.includes('Basic')) return 'basic'; + } + } + + return null; } /** - * Returns the authentication rule that should be used for the provided URL. - * We go through the authentication rules and find the first one that matches - * the provided URL, if no rule matches the provided URL undefined is returned. + * Checks if request configuration is activated + * Returns true only when user is authenticated and rules are present + * @returns {boolean} True if request configuration is activated */ -export function getAuthenticationRule(url) { - return head(getAuthenticationRules().filter( - rule => rule && rule.urlPattern && url.match(new RegExp(rule.urlPattern, "i")))); -} +export const isRequestConfigurationActivated = () => { + // Check if redux state exist + const state = getState(); + if (!isEmpty(state?.security?.rules)) { + return true; + } -export function getAuthKeyParameter(url) { - const foundRule = getAuthenticationRule(url); - return foundRule?.authkeyParamName ?? 'authkey'; -} + const newRules = ConfigUtils.getConfigProp('requestsConfigurationRules'); + if (!isEmpty(newRules)) { + return true; + } -export function getAuthenticationHeaders(url, securityToken, security) { - if (!url || !isAuthenticationActivated()) { - return null; + // Legacy support + const useLegacyRules = ConfigUtils.getConfigProp('useAuthenticationRules'); + const oldRules = ConfigUtils.getConfigProp('authenticationRules'); + if (isNil(useLegacyRules)) { + return !isEmpty(oldRules); } - const storedProtectedService = getCredentials(security?.sourceId); - if (security && storedProtectedService) { - return { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` + return useLegacyRules; +}; + +/** + * it creates the headers function for axios config, if it finds a reference in sessionStorage + * @param {string} protectedId the id of the protected service to look for in sessionStorage + * @returns {object} the headers Basic + */ +export const getAuthorizationBasic = (protectedId) => { + let headers = {}; + const storedProtectedService = getCredentials(protectedId); + if (!isEmpty(storedProtectedService)) { + headers = { + Authorization: `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` }; } - switch (getAuthenticationMethod(url)) { - case 'bearer': { - const token = !isNil(securityToken) ? securityToken : getToken(); - if (!token) { - return null; - } - return { - "Authorization": `Bearer ${token}` - }; + return headers; +}; + +/** + * Filter out headers/params that still contain unresolved template variables + * @param {object} obj - The object to filter + * @returns {object} The filtered object + */ +const filterUnresolvedTemplates = (obj) => { + if (typeof obj !== 'object' || !obj) return obj; + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => !String(v).includes('${securityToken}')) + ); +}; + +const basicAuthorizationHeader = (sourceId) => { + return !isNil(sourceId) ? { headers: getAuthorizationBasic(sourceId) } : {}; +}; + +/** + * Gets request configuration (headers and params) for a given URL + * This is the main function that centralizes all request configuration logic + * @param {string} url - The URL to get configuration for + * @param {string} securityToken - Optional security token override + * @param {string} [sourceId] - Optional source ID for sessionStorage-based credentials + * @returns {Object} Object containing headers and/or params + */ +export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { + if (!url || !isRequestConfigurationActivated()) { + return basicAuthorizationHeader(sourceId); } - case 'header': { - const rule = getAuthenticationRule(url); - return rule.headers; + + const rule = getRequestConfigurationRule(url); + if (!rule) return basicAuthorizationHeader(sourceId); + + const token = securityToken ?? getToken(); + const basicAuth = sourceId ? getAuthorizationBasic(sourceId) : null; + const authHeader = basicAuth?.Authorization ?? getBasicAuthHeader(); + + const securityProps = { + ...(!isNil(token) && { securityToken: token }), + ...(!isNil(authHeader) && { authHeader: authHeader }) + }; + + const parsedHeaders = filterUnresolvedTemplates( + parseRequestConfiguration(rule.headers, securityProps) + ); + const params = filterUnresolvedTemplates( + parseRequestConfiguration(rule.params, securityProps) + ); + const headers = !isEmpty(parsedHeaders) + ? parsedHeaders : (!isEmpty(basicAuth) && sourceId ? basicAuth : undefined); + + return { + ...(!isEmpty(headers) && { headers: headers }), + ...(!isEmpty(params) && { params: params }) + }; +}; + +export const hasRequestConfigurationByUrl = (url, securityToken, sourceId) => { + const { headers, params } = getRequestConfigurationByUrl(url, securityToken, sourceId); + return !!(headers || params); +}; + +export function getAuthKeyParameter(url) { + // Use the new request configuration system + const rule = getRequestConfigurationRule(url); + if (rule && rule.params) { + // Find the parameter that contains the securityToken placeholder + const authKeyParam = Object.keys(rule.params).find(key => + rule.params[key] && (rule.params[key].includes('${securityToken}')) + ); + if (authKeyParam) { + return authKeyParam; + } } - default: - // we cannot handle the required authentication method - return null; + return 'authkey'; +} + +export function getAuthenticationHeaders(url, securityToken, security) { + const requestConfig = getRequestConfigurationByUrl(url, securityToken, security?.sourceId); + if (!isEmpty(requestConfig.headers)) { + return requestConfig.headers; } + return null; +} + +export function clearNilValuesForParams(params = {}) { + return Object.keys(params).reduce((pre, cur) => { + const value = params[cur]; + return !isNil(value) ? {...pre, [cur]: value} : pre; + }, {}); } /** * This method will add query parameter based authentications to an object * containing query parameters. */ -export function addAuthenticationParameter(url, parameters, securityToken) { - if (!url || !isAuthenticationActivated()) { - return parameters; - } - switch (getAuthenticationMethod(url)) { - case 'authkey': { - const token = !isNil(securityToken) ? securityToken : getToken(); - if (!token) { - return parameters; - } - const authParam = getAuthKeyParameter(url); - return Object.assign(parameters || {}, {[authParam]: token}); - } - case 'test': { - const rule = getAuthenticationRule(url); - const token = rule ? rule.token : ""; - const authParam = getAuthKeyParameter(url); - return Object.assign(parameters || {}, { [authParam]: token }); - } - default: - // we cannot handle the required authentication method - return parameters; +export function addAuthenticationParameter(url, parameters, securityToken, sourceId) { + let params = {...(parameters ?? {})}; + const requestConfig = getRequestConfigurationByUrl(url, securityToken, sourceId); + if (!isEmpty(requestConfig.params)) { + params = {...params, ...requestConfig.params}; } + return clearNilValuesForParams(params); } /** * This method will add query parameter based authentications to an url. */ export function addAuthenticationToUrl(url) { - if (!url || !isAuthenticationActivated()) { + if (!url || !isRequestConfigurationActivated()) { return url; } const parsedUrl = URL.parse(url, true); @@ -225,12 +418,6 @@ export function addAuthenticationToUrl(url) { return URL.format(parsedUrl); } -export function clearNilValuesForParams(params = {}) { - return Object.keys(params).reduce((pre, cur) => { - return !isNil(params[cur]) ? {...pre, [cur]: params[cur]} : pre; - }, {}); -} - export function addAuthenticationToSLD(layerParams, options) { if (layerParams.SLD) { const parsed = URL.parse(layerParams.SLD, true); @@ -249,22 +436,6 @@ export function cleanAuthParamsFromURL(url) { return ConfigUtils.filterUrlParams(url, [getAuthKeyParameter(url)].filter(p => p)); } -/** - * it creates the headers function for axios config, if it finds a reference in sessionStorage - * @param {string} protectedId the id of the protected service to look for in sessionStorage - * @returns {object} the headers Basic - */ -export const getAuthorizationBasic = (protectedId) => { - let headers = {}; - const storedProtectedService = getCredentials(protectedId); - if (!isEmpty(storedProtectedService)) { - headers = { - Authorization: `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }; - } - return headers; -}; - /** * This utility class will get information about the current logged user directly from the store. */ @@ -281,10 +452,6 @@ const SecurityUtils = { getUserAttributes, findUserAttribute, findUserAttributeValue, - getAuthenticationRules, - isAuthenticationActivated, - getAuthenticationMethod, - getAuthenticationRule, addAuthenticationToUrl, addAuthenticationParameter, clearNilValuesForParams, @@ -292,6 +459,12 @@ const SecurityUtils = { getAuthKeyParameter, cleanAuthParamsFromURL, getAuthenticationHeaders, + getRequestConfigurationByUrl, + getRequestConfigurationRules, + getRequestConfigurationRule, + getAuthenticationMethod, + isRequestConfigurationActivated, + convertAuthenticationRulesToRequestConfiguration, USER_GROUP_ALL }; diff --git a/web/client/utils/__tests__/SecurityUtils-test.js b/web/client/utils/__tests__/SecurityUtils-test.js index 979782070da..c5afc30b587 100644 --- a/web/client/utils/__tests__/SecurityUtils-test.js +++ b/web/client/utils/__tests__/SecurityUtils-test.js @@ -183,29 +183,65 @@ describe('Test security utils methods', () => { expect(attributeValue).toBe("263c6917-543f-43e3-8e1a-6a0d29952f72"); }); - it('test get authentication method for an url', () => { - // mocking the authentication rules - ConfigUtils.setConfigProp('authenticationRules', authenticationRules); - expect(SecurityUtils.getAuthenticationRules().length).toBe(3); - // basic authentication should be found - let authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('basic'); - // authkey authentication should be found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('authkey'); - // not-supported authentication should be found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.not-supported.com/?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('not-supported'); - // no authentication method found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.no-authentication.com/?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toNotExist(); + it('test get request configuration rule for an url', () => { + // Set up request configuration rules (converted from old authenticationRules format) + // Note: unsupported methods are filtered out, so we expect 2 rules (geoserver and some-site) + const requestConfigRules = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authenticationRules); + expect(requestConfigRules.length).toBe(2); + + // Set the rules in config + ConfigUtils.setConfigProp('requestsConfigurationRules', requestConfigRules); + setSecurityInfo(securityInfoToken); + + // Test basic authentication rule should be found and converted + let rule = SecurityUtils.getRequestConfigurationRule('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); + expect(rule).toExist(); + expect(rule.urlPattern).toBe('.*some-site.*'); + + // Test authkey authentication rule should be found + rule = SecurityUtils.getRequestConfigurationRule('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); + expect(rule).toExist(); + + // Test that no rule matches + rule = SecurityUtils.getRequestConfigurationRule('http://www.no-matching.com/?parameter1=value1¶meter2=value2'); + expect(rule).toNotExist(); + + // Test with array URL (single element) - should use first URL + rule = SecurityUtils.getRequestConfigurationRule(['http://www.some-site.com/index?parameter1=value1¶meter2=value2']); + expect(rule).toExist(); + expect(rule.urlPattern).toBe('.*some-site.*'); + + // Test with array URL (multiple elements) - should use first URL only, basepath never changes + rule = SecurityUtils.getRequestConfigurationRule([ + 'http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2', + 'http://www.some-site.com/geoserver/api' + ]); + expect(rule).toExist(); + expect(rule.urlPattern).toBe('.*geoserver.*'); + + // Test that changing the order of URLs in array changes the result (only first URL matters) + rule = SecurityUtils.getRequestConfigurationRule([ + 'http://www.some-site.com/index', + 'http://www.some-site.com/api' + ]); + expect(rule).toExist(); + expect(rule.urlPattern).toBe('.*some-site.*'); + + rule = SecurityUtils.getRequestConfigurationRule([]); + expect(rule).toNotExist(); + + rule = SecurityUtils.getRequestConfigurationRule(null); + expect(rule).toNotExist(); + + rule = SecurityUtils.getRequestConfigurationRule(undefined); + expect(rule).toNotExist(); }); it('test add authkey authentication to url', () => { - // mocking the authentication rules + // Convert authentication rules to new format and set them + const requestConfigRules = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authenticationRules); ConfigUtils.setConfigProp("useAuthenticationRules", true); - ConfigUtils.setConfigProp('authenticationRules', authenticationRules); - expect(SecurityUtils.getAuthenticationRules().length).toBe(3); + ConfigUtils.setConfigProp('requestsConfigurationRules', requestConfigRules); // authkey authentication with no user let urlWithAuthentication = SecurityUtils.addAuthenticationToUrl('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); expect(urlWithAuthentication).toBe('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); @@ -223,6 +259,7 @@ describe('Test security utils methods', () => { expect(urlWithAuthentication).toBe('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); // authkey authentication with a user providing a uuid but authentication deactivated ConfigUtils.setConfigProp("useAuthenticationRules", false); + ConfigUtils.setConfigProp('requestsConfigurationRules', []); setSecurityInfo(securityInfoC); urlWithAuthentication = SecurityUtils.addAuthenticationToUrl('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); expect(urlWithAuthentication).toBe('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); @@ -240,10 +277,17 @@ describe('Test security utils methods', () => { expect(SecurityUtils.getAuthenticationHeaders("http://header-site.com/something", null)).toEqual({'X-Auth-Token': 'goodtoken'}); }); it('test getAuthenticationHeaders using basic auth', () => { + const creds = {username: "testuser", password: "testpass"}; + SecurityUtils.setCredentials("id2", creds); setSecurityInfo(securityInfoToken); - ConfigUtils.setConfigProp("useAuthenticationRules", true); - ConfigUtils.setConfigProp('authenticationRules', headerAuthenticationRules); - expect(SecurityUtils.getAuthenticationHeaders("http://header-site.com/something", null, {sourceId: "id2"})).toEqual({Authorization: "Basic dW5kZWZpbmVkOnVuZGVmaW5lZA=="}); + ConfigUtils.setConfigProp("useAuthenticationRules", false); + // Use a rule that doesn't match, so it falls back to sourceId basic auth + ConfigUtils.setConfigProp('requestsConfigurationRules', []); + + const result = SecurityUtils.getAuthenticationHeaders("http://other-site.com/something", null, {sourceId: "id2"}); + expect(result).toExist(); + expect(result.Authorization).toExist(); + expect(result.Authorization).toInclude('Basic'); }); it('cleanAuthParamsFromURL', () => { // mocking the authentication rules @@ -282,4 +326,448 @@ describe('Test security utils methods', () => { headers = SecurityUtils.getAuthorizationBasic(); expect(headers).toEqual({}); }); + + describe('getRequestConfigurationByUrl', () => { + it('should return empty object when not activated', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api'); + expect(result).toEqual({}); + }); + + it('should return headers configuration with Bearer token', () => { + const rules = [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api'); + expect(result.headers).toExist(); + expect(result.headers.Authorization).toBe('Bearer goodtoken'); + }); + + it('should return params configuration with authkey', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/geoserver/wms'); + expect(result.params).toExist(); + expect(result.params.authkey).toBe('goodtoken'); + }); + + it('should use sourceId for basic auth when provided', () => { + const creds = {username: "testuser", password: "testpass"}; + SecurityUtils.setCredentials("source123", creds); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api', null, "source123"); + expect(result.headers).toExist(); + expect(result.headers.Authorization).toExist(); + }); + }); + + describe('isRequestConfigurationActivated', () => { + it('should return true when user has token and rules exist in state', () => { + const stateWithRules = { + user: securityInfoC.user, + token: 'test-token', + rules: [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ] + }; + setSecurityInfo(stateWithRules); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + + it('should return true when user has token and rules exist in config', () => { + setSecurityInfo(securityInfoToken); + ConfigUtils.setConfigProp('requestsConfigurationRules', [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + + it('should return true for legacy authenticationRules when enabled', () => { + setSecurityInfo(securityInfoToken); + ConfigUtils.setConfigProp('authenticationRules', authenticationRules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + }); + + describe('convertAuthenticationRulesToRequestConfiguration', () => { + it('should convert bearer method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'bearer' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*api.*'); + expect(result[0].headers.Authorization).toBe('Bearer ${securityToken}'); + }); + + it('should convert authkey method', () => { + const authRules = [ + { urlPattern: '.*geoserver.*', method: 'authkey', authkeyParamName: 'token' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*geoserver.*'); + expect(result[0].params.token).toBe('${securityToken}'); + }); + + it('should convert basic method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'basic' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].headers.Authorization).toBe('Basic ${securityToken}'); + }); + + it('should convert header method', () => { + const authRules = [ + { + urlPattern: '.*api.*', + method: 'header', + headers: { 'X-API-Key': 'test123' } + } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].headers['X-API-Key']).toBe('test123'); + }); + + it('should convert browserWithCredentials method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'browserWithCredentials' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].withCredentials).toBe(true); + }); + + it('should filter out unsupported methods', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'bearer' }, + { urlPattern: '.*unsupported.*', method: 'unsupported' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*api.*'); + }); + }); + + describe('getRequestConfigurationRules', () => { + it('should return rules from Redux state first', () => { + const rulesInState = [ + { urlPattern: '.*api.*', headers: { 'Authorization': 'Bearer ${securityToken}' } } + ]; + setSecurityInfo({ user: securityInfoToken.user, token: 'test', rules: rulesInState }); + + const result = SecurityUtils.getRequestConfigurationRules(); + expect(result).toEqual(rulesInState); + }); + + it('should return rules from config when state is empty', () => { + const rulesInConfig = [ + { urlPattern: '.*api.*', headers: { 'Authorization': 'Bearer ${securityToken}' } } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rulesInConfig); + + const result = SecurityUtils.getRequestConfigurationRules(); + expect(result).toEqual(rulesInConfig); + }); + + it('should convert legacy authenticationRules when new format missing', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + ConfigUtils.setConfigProp('authenticationRules', authenticationRules); + + const result = SecurityUtils.getRequestConfigurationRules(); + // Unsupported methods are filtered out, so we expect 2 rules + expect(result.length).toBe(2); + expect(result[0].urlPattern).toBe('.*geoserver.*'); + }); + }); + + describe('getAuthKeyParameter', () => { + it('should return authkey parameter from rule', () => { + const rules = [ + { + urlPattern: '.*api.*', + params: { 'customAuthKey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + + const result = SecurityUtils.getAuthKeyParameter('https://example.com/api'); + expect(result).toBe('customAuthKey'); + }); + + it('should return default authkey when no rule found', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + + const result = SecurityUtils.getAuthKeyParameter('https://example.com/api'); + expect(result).toBe('authkey'); + }); + }); + + describe('addAuthenticationParameter', () => { + it('should add authentication params to existing parameters object', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const existingParams = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://geoserver.example.com/wms', existingParams); + + expect(result).toExist(); + expect(result.param1).toBeTruthy(); + expect(result.authkey).toBeTruthy(); + expect(result.authkey).toBe('goodtoken'); + }); + + it('should not mutate original parameters object', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const originalParams = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://geoserver.example.com/wms', originalParams); + + expect(result).toExist(); + expect(originalParams).toEqual({ param1: 'value1' }); // Original unchanged + expect(result).toNotEqual(originalParams); // New object + }); + + it('should return original params when no auth params available', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + const params = { param1: 'value1' }; + + const result = SecurityUtils.addAuthenticationParameter('https://example.com/api', params); + expect(result).toEqual(params); + }); + + it('should pass sourceId to getRequestConfigurationByUrl', () => { + const creds = { username: "testuser", password: "testpass" }; + SecurityUtils.setCredentials("testSource", creds); + + const params = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://example.com/api', params, null, "testSource"); + + expect(result).toExist(); + expect(result.param1).toBe('value1'); + }); + }); + + describe('addAuthenticationToSLD', () => { + it('should add authentication to SLD URL', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const layerParams = { + SLD: 'http://geoserver.example.com/sld?LAYER=layer1' + }; + const options = { securityToken: 'testtoken' }; + + const result = SecurityUtils.addAuthenticationToSLD(layerParams, options); + expect(result.SLD).toInclude('authkey=testtoken'); + }); + + it('should return original layerParams when no SLD', () => { + const layerParams = { LAYERS: 'layer1' }; + const options = { securityToken: 'testtoken' }; + + const result = SecurityUtils.addAuthenticationToSLD(layerParams, options); + expect(result).toEqual(layerParams); + }); + }); + + describe('getAuthenticationHeaders', () => { + it('should return headers from request config', () => { + const rules = [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null); + expect(result).toExist(); + expect(result.Authorization).toBe('Bearer goodtoken'); + }); + + it('should return null when no headers available', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null); + expect(result).toBe(null); + }); + + it('should use sourceId for basic auth', () => { + const creds = { username: "testuser", password: "testpass" }; + SecurityUtils.setCredentials("testSource", creds); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null, { sourceId: "testSource" }); + expect(result).toExist(); + expect(result.Authorization).toExist(); + }); + }); + + describe('getToken', () => { + it('should return token from security info', () => { + setSecurityInfo(securityInfoToken); + const token = SecurityUtils.getToken(); + expect(token).toBe('goodtoken'); + }); + + it('should return null when no token', () => { + setSecurityInfo(securityInfoA); + const token = SecurityUtils.getToken(); + expect(token).toBe(undefined); + }); + }); + + describe('getBasicAuthHeader', () => { + it('should return basic auth header', () => { + const securityInfoWithAuth = { ...securityInfoToken, authHeader: 'Basic dGVzdDp0ZXN0' }; + setSecurityInfo(securityInfoWithAuth); + const authHeader = SecurityUtils.getBasicAuthHeader(); + expect(authHeader).toBe('Basic dGVzdDp0ZXN0'); + }); + }); + + describe('getRefreshToken', () => { + it('should return refresh token', () => { + const securityInfoWithRefresh = { ...securityInfoToken, refresh_token: 'refresh-token-123' }; + setSecurityInfo(securityInfoWithRefresh); + const refreshToken = SecurityUtils.getRefreshToken(); + expect(refreshToken).toBe('refresh-token-123'); + }); + }); + + describe('getUser', () => { + it('should return user from security info', () => { + setSecurityInfo(securityInfoToken); + const user = SecurityUtils.getUser(); + expect(user).toExist(); + expect(user.name).toBe(securityInfoC.user.name); + }); + + it('should return undefined when no user', () => { + setSecurityInfo({}); + const user = SecurityUtils.getUser(); + expect(user).toBe(undefined); + }); + }); + + describe('getSecurityInfo', () => { + it('should return security info object', () => { + setSecurityInfo(securityInfoToken); + const info = SecurityUtils.getSecurityInfo(); + expect(info).toExist(); + expect(info.user).toExist(); + expect(info.token).toBe('goodtoken'); + }); + + it('should return empty object when no security info', () => { + setSecurityInfo({}); + const info = SecurityUtils.getSecurityInfo(); + expect(info).toEqual({}); + }); + }); + + describe('addAuthenticationToUrl', () => { + it('should add authkey parameter to URL when activated', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1'; + const result = SecurityUtils.addAuthenticationToUrl(url); + expect(result).toInclude('authkey=goodtoken'); + }); + + it('should return original URL when not activated', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules/E', null); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1'; + const result = SecurityUtils.addAuthenticationToUrl(url); + expect(result).toBe(url); + }); + + it('should return original URL when null', () => { + const result = SecurityUtils.addAuthenticationToUrl(null); + expect(result).toBe(null); + }); + }); + + describe('cleanAuthParamsFromURL', () => { + it('should remove authkey parameter from URL', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1&authkey=test123'; + const result = SecurityUtils.cleanAuthParamsFromURL(url); + expect(result).toNotInclude('authkey'); + }); + + it('should handle URLs without auth parameters', () => { + const url = 'http://example.com/api?param1=value1'; + const result = SecurityUtils.cleanAuthParamsFromURL(url); + expect(result).toExist(); + }); + }); }); diff --git a/web/client/utils/cesium/WMSUtils.js b/web/client/utils/cesium/WMSUtils.js index d9171afaa07..fceaf6292f0 100644 --- a/web/client/utils/cesium/WMSUtils.js +++ b/web/client/utils/cesium/WMSUtils.js @@ -7,8 +7,8 @@ */ import * as Cesium from 'cesium'; -import { isArray } from 'lodash'; -import { addAuthenticationToSLD, getAuthenticationHeaders } from "../SecurityUtils"; +import { isArray, castArray } from 'lodash'; +import { addAuthenticationParameter, addAuthenticationToSLD, getAuthenticationHeaders } from "../SecurityUtils"; import { getProxyUrl } from "../ProxyUtils"; import ConfigUtils from "../ConfigUtils"; import { creditsToAttribution, getAuthenticationParam, getURLs, getWMSVendorParams } from "../LayersUtils"; @@ -68,8 +68,8 @@ export const getProxy = (options) => { * @returns {object} converted BIL options */ export const wmsToCesiumOptionsBIL = (layer) => { - let url = layer.url; - const headers = getAuthenticationHeaders(url, layer.securityToken, layer.security); + const url = layer.url; + const headers = getAuthenticationHeaders(castArray(url)[0], layer.securityToken, layer.security); const params = getAuthenticationParam(layer); // specific options for terrain provider now are inside the options parameter // we still use layer object for retrocompatibility @@ -96,13 +96,13 @@ export const wmsToCesiumOptionsBIL = (layer) => { export function wmsToCesiumOptions(options) { var opacity = options.opacity !== undefined ? options.opacity : 1; - const params = optionsToVendorParams(options); + let params = optionsToVendorParams(options); const cr = options.credits; const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : options.attribution; // NOTE: can we use opacity to manage visibility? const urls = getURLs(isArray(options.url) ? options.url : [options.url]); const headers = getAuthenticationHeaders(urls[0], options.securityToken, options.security); - + params = addAuthenticationParameter(urls[0], params, options.securityToken); return { url: new Cesium.Resource({ url: "{s}", diff --git a/web/client/utils/mapinfo/wfs.js b/web/client/utils/mapinfo/wfs.js index e81a9e0d1ee..a864a7084d5 100644 --- a/web/client/utils/mapinfo/wfs.js +++ b/web/client/utils/mapinfo/wfs.js @@ -16,7 +16,7 @@ import { describeFeatureType, getFeature } from '../../api/WFS'; import { extractGeometryAttributeName } from '../WFSLayerUtils'; -import {addAuthenticationToSLD, getAuthorizationBasic} from '../SecurityUtils'; +import {addAuthenticationToSLD} from '../SecurityUtils'; // if the url uses following constant means the whole workflow is managed client side // and prevent request to a service @@ -99,7 +99,6 @@ const getIdentifyGeometry = point => { export default { buildRequest, getIdentifyFlow: (layer = {}, baseURL, defaultParams) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); const { point, features, ...baseParams } = defaultParams || {}; if (features) { if (baseURL && baseURL !== CLIENT_WORKFLOW) { @@ -112,7 +111,7 @@ export default { ...baseParams } }, filterIdsCQL); - return Observable.defer(() => getFeature(baseURL, layer.name, params, {headers})); + return Observable.defer(() => getFeature(baseURL, layer.name, params, {_msAuthSourceId: layer?.security?.sourceId})); } return Observable.of({ data: { @@ -134,8 +133,12 @@ export default { } }, - params: Object.assign({}, layer.baseParams, layer.params, baseParams) + params: { + ...layer.baseParams, + ...layer.params, + ...baseParams + } }); - return getFeature(baseURL, layer.name, params, {headers}); + return getFeature(baseURL, layer.name, params, {_msAuthSourceId: layer?.security?.sourceId}); })); }}; diff --git a/web/client/utils/mapinfo/wms.js b/web/client/utils/mapinfo/wms.js index f488ef62b2f..414791bc3d7 100644 --- a/web/client/utils/mapinfo/wms.js +++ b/web/client/utils/mapinfo/wms.js @@ -16,7 +16,7 @@ import { generateEnvString } from '../LayerLocalizationUtils'; import axios from "../../libs/ajax"; // import {parseString} from "xml2js"; // import {stripPrefix} from "xml2js/lib/processors"; -import {addAuthenticationToSLD, getAuthorizationBasic} from '../SecurityUtils'; +import {addAuthenticationToSLD} from '../SecurityUtils'; import { interceptOGCError } from '../ObservableUtils'; export default { /** @@ -97,8 +97,7 @@ export default { * @param {object} params for the request */ getIdentifyFlow: (layer, basePath, params) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - return Observable.defer(() => axios.get(basePath, { params, headers })) + return Observable.defer(() => axios.get(basePath, { params, _msAuthSourceId: layer?.security?.sourceId })) .let(interceptOGCError); } }; diff --git a/web/client/utils/mapinfo/wmts.js b/web/client/utils/mapinfo/wmts.js index ee002dbe518..1025c2f246d 100644 --- a/web/client/utils/mapinfo/wmts.js +++ b/web/client/utils/mapinfo/wmts.js @@ -21,8 +21,6 @@ import { } from '../WMTSUtils'; import {getLayerUrl} from '../LayersUtils'; import {optionsToVendorParams} from '../VendorParamsUtils'; -import { getAuthorizationBasic } from '../SecurityUtils'; - import {isObject, isNil, get} from 'lodash'; import Rx, {Observable} from "rxjs"; @@ -113,8 +111,7 @@ export default { }; }, getIdentifyFlow: (layer, basePath, params) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - return Observable.defer(() => axios.get(basePath, { params, headers })) + return Observable.defer(() => axios.get(basePath, { params, _msAuthSourceId: layer?.security?.sourceId })) .catch((e) => { if (e.data.indexOf("ExceptionReport") > 0) { return Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, {