diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..85dcc16df6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +node_modules diff --git a/.gitignore b/.gitignore index 26f506f728..6ea3337962 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,13 @@ GEMINI.md AGENTS.md .copilot/ .github/copilot-instructions.md +mapstore.war +mapstore**.war +.env + +# Local auth overrides and runtime bootstrap files +/docker/keycloak/realm-mapstore.json +/docker/openldap/ldif/02-users.ldif +# datadir runtime files +datadir/ +datadir/* diff --git a/docker-compose-override.yml b/docker-compose-override.yml index a93f1a6c72..60d04e8a87 100644 --- a/docker-compose-override.yml +++ b/docker-compose-override.yml @@ -2,6 +2,21 @@ version: "3.8" services: + ldap: + build: + context: ./tests/acme-ldap/ + image: mapstore-test/acme-ldap + profiles: + - base + healthcheck: + test: ["CMD-SHELL", "bash -c ' + rm -rf /container/service/slapd/assets/config/bootstrap/ldif/custom/* && + cp -R /ldif-src/. /container/service/slapd/assets/config/bootstrap/ldif/custom/ && + exec /container/tool/run + environment: + LDAP_ORGANISATION: "${LDAP_ORGANISATION:-Acme}" + LDAP_DOMAIN: "${LDAP_DOMAIN:-acme.org}" + LDAP_ADMIN_PASSWORD: "${LDAP_ADMIN_PASSWORD:-changeme-ldap-admin-pw-123}" + LDAP_CONFIG_PASSWORD: "${LDAP_CONFIG_PASSWORD:-changeme-ldap-config-pw-123}" + LDAP_READONLY_USER: "true" + LDAP_READONLY_USER_USERNAME: "${LDAP_READONLY_USER_USERNAME:-svc_mapstore_ldap}" + LDAP_READONLY_USER_PASSWORD: "${LDAP_READONLY_PASSWORD:-changeme-ldap-bind-pw-123}" + LDAP_TLS: "${LDAP_TLS:-false}" + volumes: + - ldap_data:/var/lib/ldap + - ldap_config:/etc/ldap/slapd.d + - ./docker/openldap/ldif:/ldif-src:ro + healthcheck: + test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost -b \"\" -s base \"(objectClass=*)\" namingContexts >/dev/null 2>&1"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + networks: + - mapstore-network + + keycloak: + image: quay.io/keycloak/keycloak:26.0.7 + container_name: keycloak + restart: on-failure + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: "${KEYCLOAK_ADMIN:-admin}" + KC_BOOTSTRAP_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD}" + KC_HTTP_ENABLED: "true" + KC_HTTP_RELATIVE_PATH: "${KEYCLOAK_HTTP_RELATIVE_PATH:-/keycloak}" + KC_HOSTNAME: "${KEYCLOAK_HOSTNAME:-http://localhost/keycloak}" + KC_HOSTNAME_STRICT: "${KEYCLOAK_HOSTNAME_STRICT:-false}" + KC_PROXY_HEADERS: "${KEYCLOAK_PROXY_HEADERS:-xforwarded}" + KC_HEALTH_ENABLED: "true" + KC_HTTP_MANAGEMENT_RELATIVE_PATH: / + command: start-dev --import-realm + volumes: + - ./docker/keycloak/realm-mapstore.json:/opt/keycloak/data/import/realm-mapstore.json:ro + - keycloak_data:/opt/keycloak/data + healthcheck: + test: ["CMD-SHELL", "{ printf 'HEAD /health/ready HTTP/1.0\\r\\n\\r\\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/127.0.0.1/9000"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 30s + networks: + - mapstore-network + + proxy: + image: nginx + container_name: proxy + volumes: + - ./docker/mapstore.auth.conf:/etc/nginx/conf.d/default.conf:rw + ports: + - 80:80 + depends_on: + - mapstore + - keycloak + networks: + - mapstore-network + + mapstore: + build: + context: . + dockerfile: Dockerfile + args: + MAPSTORE_WEBAPP_SRC: "mapstore.war" + depends_on: + ldap: + condition: service_healthy + keycloak: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - "${DATADIR_PATH:-./datadir}:/usr/local/tomcat/datadir:ro" + networks: + - mapstore-network + +volumes: + ldap_data: + ldap_config: + keycloak_data: diff --git a/docker/keycloak/realm-mapstore.sample.json b/docker/keycloak/realm-mapstore.sample.json new file mode 100644 index 0000000000..6ce1d47b69 --- /dev/null +++ b/docker/keycloak/realm-mapstore.sample.json @@ -0,0 +1,102 @@ +{ + "realm": "mapstore", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "sslRequired": "external", + "roles": { + "realm": [ + { + "name": "admin", + "description": "Mapped to MapStore ADMIN (see mapstore-ovr.properties roleMappings)" + }, + { + "name": "user", + "description": "Optional; default MapStore role is USER if unmapped" + } + ] + }, + "groups": [ + { + "name": "kc-admins", + "path": "/kc-admins" + }, + { + "name": "kc-users", + "path": "/kc-users" + } + ], + "users": [ + { + "username": "kcuser", + "enabled": true, + "emailVerified": true, + "firstName": "Keycloak", + "lastName": "User", + "email": "kcuser@acme.org", + "realmRoles": ["user"], + "groups": ["/kc-users"], + "credentials": [ + { + "type": "password", + "value": "changeme-kcuser-pw-123", + "temporary": false + } + ] + }, + { + "username": "kcadmin", + "enabled": true, + "emailVerified": true, + "firstName": "Keycloak", + "lastName": "Admin", + "email": "kcadmin@acme.org", + "realmRoles": ["admin"], + "groups": ["/kc-admins"], + "credentials": [ + { + "type": "password", + "value": "changeme-kcadmin-pw-123", + "temporary": false + } + ] + } + ], + "clients": [ + { + "clientId": "mapstore-server", + "name": "MapStore OpenID (server)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "changeme-mapstore-oidc-client-secret-123", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["http://localhost/mapstore/*"], + "webOrigins": ["http://localhost", "+"], + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "claim.name": "groups", + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ], + "attributes": { + "post.logout.redirect.uris": "http://localhost/mapstore/*" + } + } + ] +} diff --git a/docker/mapstore.auth.conf b/docker/mapstore.auth.conf new file mode 100644 index 0000000000..6791f6042d --- /dev/null +++ b/docker/mapstore.auth.conf @@ -0,0 +1,115 @@ +server { + server_name localhost; + + listen 80; + listen [::]:80; + + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_disable "msie6"; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types + text/plain + text/css + application/json + application/javascript + application/x-javascript + text/xml application/xm + application/xml+rss + text/javascript; + + underscores_in_headers on; + proxy_set_header HOST $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering on; + proxy_buffer_size 1k; + proxy_buffers 24 4k; + proxy_busy_buffers_size 8k; + proxy_max_temp_file_size 2048m; + proxy_temp_file_write_size 32k; + + location / { + deny all; + return 301 /mapstore; + } + + # Keycloak (same host as MapStore — required for OIDC redirects and MapStore backend) + location /keycloak/ { + proxy_pass http://keycloak:8080/keycloak/; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Prefix /keycloak; + + # Keycloak auth responses set large Set-Cookie headers; default 1k buffers cause 502. + proxy_buffer_size 128k; + proxy_buffers 8 256k; + proxy_busy_buffers_size 256k; + proxy_temp_file_write_size 256k; + } + + location /mapstore { + proxy_pass http://mapstore:8080/mapstore; + + # OIDC callback returns 302 + Set-Cookie; large request Cookie headers are common too. + proxy_buffer_size 128k; + proxy_buffers 8 256k; + proxy_busy_buffers_size 256k; + proxy_temp_file_write_size 256k; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '$http_origin'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + if ($request_method = 'DELETE') { + add_header 'Access-Control-Allow-Origin' '$http_origin'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; + } + if ($request_method = 'PUT') { + add_header 'Access-Control-Allow-Origin' '$http_origin'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; + } + if ($request_method = 'POST') { + add_header 'Access-Control-Allow-Origin' '$http_origin'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; + } + if ($request_method = 'GET') { + add_header 'Access-Control-Allow-Origin' '$http_origin'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; + } + } +} diff --git a/docker/openldap/Dockerfile b/docker/openldap/Dockerfile new file mode 100644 index 0000000000..9cbee98558 --- /dev/null +++ b/docker/openldap/Dockerfile @@ -0,0 +1,4 @@ +FROM osixia/openldap:1.5.0 + +# Bootstrap LDIF is baked into the image (runs once when ldap_data volume is empty). +COPY ldif/ /container/service/slapd/assets/config/bootstrap/ldif/custom/ diff --git a/docker/openldap/ldif.sample/02-users.ldif b/docker/openldap/ldif.sample/02-users.ldif new file mode 100644 index 0000000000..76f0c8338b --- /dev/null +++ b/docker/openldap/ldif.sample/02-users.ldif @@ -0,0 +1,21 @@ +dn: uid=ldapadmin,ou=people,dc=acme,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +uid: ldapadmin +cn: ldapadmin +sn: Admin +givenName: LDAP Admin +mail: ldapadmin@ldap.test +userPassword: changeme-ldapadmin-pw-123 + +dn: uid=ldapuser,ou=people,dc=acme,dc=org +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +uid: ldapuser +cn: ldapuser +sn: User +givenName: LDAP User +mail: ldapuser@ldap.test +userPassword: changeme-ldapuser-pw-123 diff --git a/docker/openldap/ldif/01-organizational-units.ldif b/docker/openldap/ldif/01-organizational-units.ldif new file mode 100644 index 0000000000..a9064a1e38 --- /dev/null +++ b/docker/openldap/ldif/01-organizational-units.ldif @@ -0,0 +1,7 @@ +dn: ou=people,dc=acme,dc=org +objectClass: organizationalUnit +ou: people + +dn: ou=groups,dc=acme,dc=org +objectClass: organizationalUnit +ou: groups \ No newline at end of file diff --git a/docker/openldap/ldif/03-groups.ldif b/docker/openldap/ldif/03-groups.ldif new file mode 100644 index 0000000000..49ca1cea38 --- /dev/null +++ b/docker/openldap/ldif/03-groups.ldif @@ -0,0 +1,19 @@ +dn: cn=ROLE_ADMIN,ou=groups,dc=acme,dc=org +objectClass: groupOfNames +cn: ROLE_ADMIN +member: uid=ldapadmin,ou=people,dc=acme,dc=org + +dn: cn=ROLE_USER,ou=groups,dc=acme,dc=org +objectClass: groupOfNames +cn: ROLE_USER +member: uid=ldapuser,ou=people,dc=acme,dc=org + +dn: cn=LDAP_ADMINS,ou=groups,dc=acme,dc=org +objectClass: groupOfNames +cn: LDAP_ADMINS +member: uid=ldapadmin,ou=people,dc=acme,dc=org + +dn: cn=LDAP_USERS,ou=groups,dc=acme,dc=org +objectClass: groupOfNames +cn: LDAP_USERS +member: uid=ldapuser,ou=people,dc=acme,dc=org diff --git a/docker/sample-datadir/configs/localConfig.json.patch b/docker/sample-datadir/configs/localConfig.json.patch new file mode 100644 index 0000000000..b82ce46682 --- /dev/null +++ b/docker/sample-datadir/configs/localConfig.json.patch @@ -0,0 +1,17 @@ +[ + { + "op": "add", + "path": "/authenticationProviders", + "value": [ + { + "type": "openID", + "provider": "keycloak", + "title": "Keycloak" + }, + { + "type": "basic", + "provider": "geostore" + } + ] + } +] diff --git a/docker/sample-datadir/geostore-datasource-ovr.properties b/docker/sample-datadir/geostore-datasource-ovr.properties new file mode 100644 index 0000000000..8ac26db202 --- /dev/null +++ b/docker/sample-datadir/geostore-datasource-ovr.properties @@ -0,0 +1,10 @@ +# Postgres connection for GeoStore (externalized via datadir) +geostoreDataSource.driverClassName=org.postgresql.Driver +geostoreDataSource.url=jdbc:postgresql://postgres:5432/geostore +geostoreDataSource.username=geostore +geostoreDataSource.password=geostore +geostoreVendorAdapter.databasePlatform=org.hibernate.dialect.PostgreSQLDialect +geostoreEntityManagerFactory.jpaPropertyMap[hibernate.hbm2ddl.auto]=update +geostoreEntityManagerFactory.jpaPropertyMap[hibernate.default_schema]=geostore +geostoreVendorAdapter.generateDdl=true +geostoreVendorAdapter.showSql=false diff --git a/docker/sample-datadir/ldap.properties b/docker/sample-datadir/ldap.properties new file mode 100644 index 0000000000..5b4d691d91 --- /dev/null +++ b/docker/sample-datadir/ldap.properties @@ -0,0 +1,28 @@ +## MapStore LDAP — OpenLDAP (docker service: ldap) +## https://docs.mapstore.geosolutionsgroup.com/en/latest/developer-guide/integrations/users/ldap/ +## +## Bind DN and password must match .env / docker-compose. + +ldap.host=ldap +ldap.port=389 +ldap.root=dc=acme,dc=org +ldap.userDn=cn=svc_mapstore_ldap,dc=acme,dc=org +ldap.password=changeme-ldap-bind-pw-123 + +ldap.userBase=ou=people +ldap.groupBase=ou=groups +ldap.roleBase=ou=groups +ldap.userFilter=(uid={0}) +ldap.groupFilter=(member={0}) +ldap.roleFilter=(member={0}) +ldap.memberPattern=^uid=([^,]+).*$ + +ldap.hierachicalGroups=false +ldap.nestedGroupFilter=(member={0}) +ldap.nestedGroupLevels=3 +ldap.searchSubtree=true +ldap.convertToUpperCase=true + +# GeoStore: groups named ROLE_* are application roles +ldap.rolePrefix=ROLE_ +ldap.adminRole=ROLE_ADMIN diff --git a/docker/sample-datadir/mapstore-ovr.properties b/docker/sample-datadir/mapstore-ovr.properties new file mode 100644 index 0000000000..d1a7fd9ccf --- /dev/null +++ b/docker/sample-datadir/mapstore-ovr.properties @@ -0,0 +1,60 @@ +# Keycloak OpenID — GeoStore 2.6+ generic OIDC provider configuration. +# Browser and redirects use http://localhost (nginx on :80). +# Backend-only token, userinfo and key endpoints use the host proxy URL from +# inside Docker, avoiding direct dependency on the Keycloak service network name. + +oidc_providers=keycloak + +keycloakOAuth2Config.enabled=true +keycloakOAuth2Config.clientId=mapstore-server +keycloakOAuth2Config.clientSecret=changeme-mapstore-oidc-client-secret-123 +keycloakOAuth2Config.sendClientSecret=true +keycloakOAuth2Config.authorizationUri=http://localhost/keycloak/realms/mapstore/protocol/openid-connect/auth +keycloakOAuth2Config.accessTokenUri=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/token +keycloakOAuth2Config.checkTokenEndpointUrl=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/userinfo +keycloakOAuth2Config.idTokenUri=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/certs +keycloakOAuth2Config.logoutUri=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/logout +keycloakOAuth2Config.revokeEndpoint=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/revoke +keycloakOAuth2Config.introspectionEndpoint=http://host.docker.internal/keycloak/realms/mapstore/protocol/openid-connect/token/introspect +keycloakOAuth2Config.redirectUri=http://localhost/mapstore/rest/geostore/openid/keycloak/callback +keycloakOAuth2Config.internalRedirectUri=http://localhost/mapstore/ +keycloakOAuth2Config.globalLogoutEnabled=true +keycloakOAuth2Config.postLogoutRedirectUri=http://localhost/mapstore/ +# Set to false only when the WAR is in LDAP-direct/read-only user-store mode. +keycloakOAuth2Config.autoCreateUser=true +keycloakOAuth2Config.scopes=openid,profile,email +keycloakOAuth2Config.rolesClaim=realm_access.roles +keycloakOAuth2Config.groupsClaim=groups +keycloakOAuth2Config.authenticatedDefaultRole=USER +# Keycloak realm role name -> MapStore role (USER or ADMIN) +keycloakOAuth2Config.roleMappings=admin:ADMIN,user:USER +# Keycloak group name from the OIDC "groups" claim -> MapStore user group +keycloakOAuth2Config.groupMappings=kc-admins:KC_ADMINS,kc-users:KC_USERS +keycloakOAuth2Config.dropUnmapped=true + +# Optional Microsoft Entra provider template for the auth test bench. +# Uncomment the provider in oidc_providers and the block below only after +# creating an Entra app registration and adding the Microsoft login entry to +# configs/localConfig.json.patch. Keep real secrets out of committed samples. +# +# oidc_providers=keycloak,microsoft +# microsoftOAuth2Config.enabled=true +# microsoftOAuth2Config.clientId= +# microsoftOAuth2Config.clientSecret= +# microsoftOAuth2Config.sendClientSecret=true +# microsoftOAuth2Config.discoveryUrl=https://login.microsoftonline.com//v2.0/.well-known/openid-configuration +# microsoftOAuth2Config.redirectUri=http://localhost/mapstore/rest/geostore/openid/microsoft/callback +# microsoftOAuth2Config.internalRedirectUri=http://localhost/mapstore/ +# microsoftOAuth2Config.autoCreateUser=true +# microsoftOAuth2Config.scopes=openid,email,profile +# microsoftOAuth2Config.principalKey=email +# Microsoft App Roles are recommended for MapStore ADMIN/USER mapping. +# microsoftOAuth2Config.rolesClaim=roles +# microsoftOAuth2Config.roleMappings=MapStore.Admin:ADMIN,MapStore.User:USER +# microsoftOAuth2Config.authenticatedDefaultRole=USER +# Microsoft Entra groups are recommended for MapStore user-group mapping. +# Prefer stable Entra group Object IDs in groupMappings. If your app registration +# emits group display names instead, use those names in place of the IDs. +# microsoftOAuth2Config.groupsClaim=groups +# microsoftOAuth2Config.groupMappings=:MS_ADMINS,:MS_USERS +# microsoftOAuth2Config.dropUnmapped=true diff --git a/docs/developer-guide/integrations/auth-docker-setup.md b/docs/developer-guide/integrations/auth-docker-setup.md new file mode 100644 index 0000000000..1bf6774121 --- /dev/null +++ b/docs/developer-guide/integrations/auth-docker-setup.md @@ -0,0 +1,318 @@ +# Auth Docker Setup + +This page describes a quick local authentication environment for MapStore developers and environment maintainers. It extends the default Docker setup with Keycloak, OpenLDAP and an authentication-aware Nginx proxy, so you can quickly test MapStore login flows and sample user/group mappings. + +The stack is intended as a starting point. For the full configuration details, see the dedicated guides for [LDAP](users/ldap.md#ldap-integration-with-mapstore), [OpenID Connect](users/openId.md#integration-with-openid-connect), [Keycloak](users/keycloak.md#keycloak-integrations), [MapStore authentication](auth.md#mapstore-authentication---implementation-details) and the available [infrastructure setups](infrastructure-setups.md#possible-setups). + +!!! warning + The provided files contain public sample credentials, sample users, a sample Keycloak realm and sample LDAP entries. They are useful only for local development and testing. They are not secrets, they are not secure defaults, and they must be changed before using this setup outside a disposable local environment. + +## Services + +The auth Docker setup includes: + +- **MapStore**: the application container, configured with a mounted datadir. +- **PostgreSQL/PostGIS**: the GeoStore database from the base `docker-compose.yml`. +- **Nginx**: the reverse proxy exposed on `http://localhost`. +- **Keycloak**: an OpenID Connect identity provider exposed at `http://localhost/keycloak`. +- **OpenLDAP**: a sample LDAP directory used to test LDAP users, groups and roles. + +The proxy exposes the applications on the same host: + +- MapStore: [http://localhost/mapstore](http://localhost/mapstore) +- Keycloak: [http://localhost/keycloak/](http://localhost/keycloak/) +- Keycloak admin console: [http://localhost/keycloak/admin/](http://localhost/keycloak/admin/) + +## Prerequisites + +Before starting the stack, make sure you have: + +- Docker and Docker Compose installed. +- A MapStore WAR available as `mapstore.war` in the repository root, or adjust the `MAPSTORE_WEBAPP_SRC` build argument in `docker/docker-compose.auth.yml`. +- A MapStore WAR built with the LDAP profile if you want to test LDAP username/password login. See [building and deploying](../building-and-deploying.md#building-and-deploying) and the [LDAP integration guide](users/ldap.md#building-mapstore-with-ldap-support). + +For Keycloak OpenID login only, the sample datadir enables the Keycloak provider through `mapstore-ovr.properties`. For LDAP login, the MapStore backend must also include the LDAP security configuration. + +### MapStore WAR source + +The auth compose overlay builds the MapStore image with this default argument: + +```yaml +MAPSTORE_WEBAPP_SRC: "mapstore.war" +``` + +This means Docker expects a `mapstore.war` file in the repository root. If the file is missing, the MapStore image build will fail. You can either copy your built WAR to `./mapstore.war` or edit `docker/docker-compose.auth.yml` to point `MAPSTORE_WEBAPP_SRC` to another local WAR path or a downloadable WAR URL. + +## Prepare Environment Variables + +Copy the sample environment file to the repository root: + +```sh +cp docker/.env.auth.example .env +``` + +Review the values before starting the stack. The most relevant variables are: + +- `KEYCLOAK_ADMIN` and `KEYCLOAK_ADMIN_PASSWORD`: credentials for the Keycloak admin console. +- `KEYCLOAK_HOSTNAME`: public Keycloak URL used in redirects. The local default is `http://localhost/keycloak`. +- `LDAP_ADMIN_PASSWORD`, `LDAP_CONFIG_PASSWORD` and `LDAP_READONLY_PASSWORD`: OpenLDAP administrative and read-only bind credentials. +- `DATADIR_PATH`: optional path to the MapStore datadir mounted in the container. If omitted, `./datadir` is used. + +!!! warning + Do not reuse the sample passwords or client secret in shared, staging or production environments. The placeholder values in this repository are intentionally guessable. Update `.env`, the Keycloak realm, LDIF files and MapStore datadir properties consistently. + +## Prepare The Datadir + +Create a local datadir from the provided sample: + +```sh +mkdir -p datadir +cp -R docker/sample-datadir/. datadir/ +``` + +The sample datadir contains: + +- `mapstore-ovr.properties`: enables Keycloak OpenID login and configures the `mapstore-server` client. +- `ldap.properties`: sample LDAP connection, search filters and role mapping properties. +- `geostore-datasource-ovr.properties`: sample PostgreSQL datasource configuration for GeoStore. +- `configs/localConfig.json.patch`: adds Keycloak as an authentication provider in the login UI. + +The sample follows the production-oriented OpenID mapping model used by GeoStore 2.6+: + +- Identity-provider roles map only to MapStore roles, currently `ADMIN` or `USER`. +- Identity-provider groups map to MapStore user groups, such as `KC_ADMINS`, `KC_USERS`, `MS_ADMINS` or `MS_USERS`. +- `dropUnmapped=true` is used for sample OpenID group mappings, so only groups explicitly listed in `groupMappings` are created and assigned in MapStore. + +The sample `mapstore-ovr.properties` intentionally separates the internal and public Keycloak URLs: + +- Browser-facing authorization and redirect URLs use `http://localhost/...`, because the local Nginx proxy exposes both MapStore and Keycloak on that host. +- Backend-only token, userinfo, certificate, logout, revocation and introspection URLs use `http://host.docker.internal/keycloak/...`, so MapStore calls the same local proxy from inside Docker without depending on the direct Keycloak container network name. + +Do not point backend-only MapStore OpenID endpoints at `http://localhost/keycloak/...` inside this Docker setup. `localhost` is also the MapStore container itself, so server-side callback operations such as token exchange and key retrieval can fail or depend on host-specific Docker networking behavior. For non-Docker deployments, replace `http://host.docker.internal/keycloak` with a Keycloak/proxy URL reachable from the MapStore backend. + +The sample also enables OpenID global logout and redirects back to `http://localhost/mapstore/`, so a MapStore logout clears the Keycloak SSO session instead of silently logging the user in again on the next Keycloak login attempt. + +!!! note + The sample Keycloak realm and LDAP credentials are intended for local testing only. Keep `docker/keycloak/realm-mapstore.sample.json` and the shipped LDIF data as local test data, and replace client secrets and user passwords before using the setup on another machine or in a shared environment. + +!!! note + The sample `mapstore-ovr.properties` uses `keycloakOAuth2Config.autoCreateUser=true` so the sample Keycloak users and mapped groups are created and synchronized in a DB-backed GeoStore setup. Set it to `false` only when the WAR is built in LDAP-direct/read-only user-store mode; in that case, make sure the Keycloak usernames are already available in the external user store. + +When you use a MapStore WAR built in LDAP-direct mode, `ldap.properties` must also define `ldap.memberPattern`. The shipped sample uses `^uid=([^,]+).*$`, which matches the sample OpenLDAP `member` values such as `uid=ldapadmin,ou=people,dc=acme,dc=org`. + +These files are regular MapStore externalized configuration files. See [configuration files](../configuration-files.md#configuration-files) and [externalized configuration](../externalized-configuration.md#externalized-configuration) for more details about datadir-based overrides. + +## Prepare Keycloak + +The auth stack imports a Keycloak realm at first startup. The realm defines the `mapstore` realm, sample users, roles and the confidential OpenID Connect client used by MapStore. + +Copy the sample realm into the filename expected by the compose file: + +```sh +cp docker/keycloak/realm-mapstore.sample.json docker/keycloak/realm-mapstore.json +``` + +The sample realm configures: + +- Realm: `mapstore` +- Client: `mapstore-server` +- Client secret: `changeme-mapstore-oidc-client-secret-123` +- Redirect URI: `http://localhost/mapstore/*` +- Realm roles: `admin`, `user` +- Realm groups: `kc-admins`, `kc-users` +- Client mapper: a Group Membership mapper that emits group names in the OIDC `groups` claim +- Sample users: `kcuser`, `kcadmin` + +The client and secret must match the values in `datadir/mapstore-ovr.properties`. If you change the realm file, update the MapStore datadir configuration accordingly. For details about Keycloak OpenID configuration, see the [Keycloak section of the OpenID Connect guide](users/openId.md#keycloak). + +The sample uses Keycloak realm roles for MapStore role mapping: + +```properties +keycloakOAuth2Config.rolesClaim=realm_access.roles +keycloakOAuth2Config.roleMappings=admin:ADMIN,user:USER +``` + +It uses Keycloak groups for MapStore user-group mapping: + +```properties +keycloakOAuth2Config.groupsClaim=groups +keycloakOAuth2Config.groupMappings=kc-admins:KC_ADMINS,kc-users:KC_USERS +keycloakOAuth2Config.dropUnmapped=true +``` + +This keeps the test bench close to a real customer setup: administrative authority and group membership can be changed independently in the identity provider. + +### Keycloak Test Users + +| Username | Password | Email | Keycloak role | Keycloak group | MapStore role | MapStore group | +| --- | --- | --- | --- | --- | --- | --- | +| `kcuser` | `changeme-kcuser-pw-123` | `kcuser@acme.org` | `user` | `kc-users` | `USER` | `KC_USERS` | +| `kcadmin` | `changeme-kcadmin-pw-123` | `kcadmin@acme.org` | `admin` | `kc-admins` | `ADMIN` | `KC_ADMINS` | + +The Keycloak admin console is available at [http://localhost/keycloak/admin/](http://localhost/keycloak/admin/) with the `KEYCLOAK_ADMIN` and `KEYCLOAK_ADMIN_PASSWORD` values configured in `.env`. The admin user belongs to the Keycloak `master` realm and is used to manage Keycloak, not to log in to MapStore. + +## Optional Microsoft Entra Provider + +The sample `mapstore-ovr.properties` includes a commented Microsoft Entra template. It is intentionally disabled because every Entra tenant requires tenant-specific IDs and a real client secret. To enable it in a local copied datadir: + +1. Change `oidc_providers=keycloak` to `oidc_providers=keycloak,microsoft`. +2. Uncomment the `microsoftOAuth2Config.*` block. +3. Fill in `clientId`, `clientSecret` and the tenant-specific `discoveryUrl`. +4. Add a Microsoft entry to `datadir/configs/localConfig.json.patch` with `"provider": "microsoft"`. +5. Register `http://localhost/mapstore/rest/geostore/openid/microsoft/callback` as a Web redirect URI in the Entra app registration. +6. Add readable user claims such as `email`, `preferred_username` and `unique_name`, then set `microsoftOAuth2Config.principalKey` to a claim that is actually present in the token returned by your tenant. + +The Microsoft login entry should use the same provider name as the backend prefix: + +```json +{ + "type": "openID", + "provider": "microsoft", + "title": "Microsoft" +} +``` + +For a realistic client-style setup, use **Entra App Roles** for MapStore `ADMIN` / `USER` role mapping and **Entra security groups** for MapStore user-group mapping. Avoid using the same App Role claim for both roles and groups unless you are deliberately testing a shortcut. + +The sample uses `microsoftOAuth2Config.principalKey=email` because the local Microsoft Entra test token includes a readable `email` claim while `preferred_username` may be absent from the token/userinfo data used by this GeoStore 2.6 flow. For a client tenant, inspect the actual returned claims and prefer a stable readable value such as `email`, `preferred_username` or `unique_name`. + +### Entra Role Mapping + +In the App Registration, create app roles like these: + +| Display name | Value | Allowed member types | MapStore role | +| --- | --- | --- | --- | +| `MapStore Admin` | `MapStore.Admin` | Users/Groups | `ADMIN` | +| `MapStore User` | `MapStore.User` | Users/Groups | `USER` | + +Assign users or groups to these roles from **Enterprise applications** -> your application -> **Users and groups**. The token should then contain a `roles` claim. Configure MapStore with: + +```properties +microsoftOAuth2Config.rolesClaim=roles +microsoftOAuth2Config.roleMappings=MapStore.Admin:ADMIN,MapStore.User:USER +microsoftOAuth2Config.authenticatedDefaultRole=USER +``` + +`MapStore.User` is optional when `authenticatedDefaultRole=USER` is present, but keeping it in the test bench lets you verify both explicit user and admin mappings. + +### Entra Group Mapping + +Create or choose Entra security groups for MapStore groups, for example: + +| Entra group | MapStore group | +| --- | --- | +| `MS_ADMINS` | `MS_ADMINS` | +| `MS_USERS` | `MS_USERS` | + +For the most stable production-like mapping, use the Entra group **Object ID** values in `groupMappings`: + +```properties +microsoftOAuth2Config.groupsClaim=groups +microsoftOAuth2Config.groupMappings=:MS_ADMINS,:MS_USERS +microsoftOAuth2Config.dropUnmapped=true +``` + +To get the `groups` claim in the token, configure the Entra app registration to emit group claims. A practical setup is: + +1. Open **App registrations** -> your app -> **Token configuration**. +2. Add a **Groups claim**. +3. Prefer groups assigned to the application when available, so the token contains only groups relevant to this app. +4. Assign the `MS_ADMINS` and `MS_USERS` groups to the Enterprise Application. + +By default, Entra emits group Object IDs. That is usually the safest value to map because display names can change. If a client explicitly wants display names, configure the app manifest to emit cloud display names for app-assigned groups and use those display names in `groupMappings` instead: + +```json +{ + "groupMembershipClaims": "ApplicationGroup", + "optionalClaims": { + "idToken": [ + { + "name": "groups", + "additionalProperties": ["cloud_displayname"] + } + ] + } +} +``` + +When group claims are enabled broadly, users in many groups can hit Entra's group overage behavior and the token may omit the normal `groups` list. For this test bench, prefer app-assigned groups and `dropUnmapped=true` so the MapStore side stays deterministic. + +## Prepare OpenLDAP + +OpenLDAP is initialized from the LDIF files in `docker/openldap/ldif/` when the LDAP data volume is empty. The repository ships the complete bootstrap set there, so the directory itself is the source of truth for organizational units, users, groups and roles. + +The LDAP bootstrap files define: + +- Organizational units, such as `ou=people` and `ou=groups`. +- Sample users in `02-users.ldif`. +- LDAP groups and roles, including `ROLE_ADMIN`, `ROLE_USER`, `LDAP_ADMINS` and `LDAP_USERS`. + +MapStore/GeoStore also assigns users to its default `everyone` group; that group is not bootstrapped from the LDAP LDIF files. + +The LDIF files are copied into the OpenLDAP image at build time and imported only when the LDAP volumes are empty. If you edit LDIF files after the first startup, rebuild the LDAP image and remove the LDAP volumes before starting again. + +```sh +docker compose -f docker-compose.yml -f docker/docker-compose.auth.yml down +docker volume rm mapstore2_ldap_data mapstore2_ldap_config +docker compose -f docker-compose.yml -f docker/docker-compose.auth.yml up --build +``` + +!!! note + Docker Compose may use a different volume prefix depending on the project directory or `COMPOSE_PROJECT_NAME`. Run `docker volume ls` if the volume names differ. + +### LDAP Test Users + +| Login | Password | MapStore role | LDAP / MapStore groups | +| --- | --- | --- | --- | +| `ldapadmin` | `changeme-ldapadmin-pw-123` | `ADMIN` | `ROLE_ADMIN`, `LDAP_ADMINS`, `everyone` | +| `ldapuser` | `changeme-ldapuser-pw-123` | `USER` | `ROLE_USER`, `LDAP_USERS`, `everyone` | + +Log in with the LDAP `uid`, for example `ldapadmin`, not the full DN. With `ldap.convertToUpperCase=true`, synchronized user and group names may appear uppercase in MapStore. + +For a full explanation of LDAP synchronized and direct modes, see the [LDAP integration guide](users/ldap.md#ldap-integration-with-mapstore). + +## Start The Stack + +Start MapStore with the auth compose overlay: + +```sh +docker compose -f docker-compose.yml -f docker/docker-compose.auth.yml up -d --build +``` + +Open [http://localhost/mapstore](http://localhost/mapstore) when the services are ready. + +To stop the stack: + +```sh +docker compose -f docker-compose.yml -f docker/docker-compose.auth.yml down +``` + +To remove containers and volumes for a clean re-import: + +```sh +docker compose -f docker-compose.yml -f docker/docker-compose.auth.yml down -v +``` + +## Test Login + +For Keycloak OpenID login: + +1. Open [http://localhost/mapstore](http://localhost/mapstore). +2. Click login and choose **Keycloak**. +3. Log in with `kcuser` / `changeme-kcuser-pw-123` or `kcadmin` / `changeme-kcadmin-pw-123`. +4. Keycloak redirects back to MapStore. + +For LDAP login, use the standard username/password form with one of the LDAP users, such as `ldapadmin` / `changeme-ldapadmin-pw-123`. This requires a MapStore WAR built and configured with LDAP support. See [LDAP integration with MapStore](users/ldap.md#ldap-integration-with-mapstore). + +## Troubleshooting + +- **Keycloak realm not imported**: Keycloak imports realm files only on first startup. Remove the `keycloak_data` volume and restart the stack. +- **LDAP users not found**: confirm `docker/openldap/ldif/02-users.ldif` exists before first startup. If the LDAP volume already exists, remove the LDAP volumes and restart. +- **MapStore waits for LDAP healthcheck**: the LDAP container now uses a root DSE healthcheck, so this usually points to an LDAP startup problem rather than missing sample users. +- **OIDC login returns 500 before reaching Keycloak**: check the MapStore logs for `authorizationUri is null`. In this Docker setup, `keycloakOAuth2Config.authorizationUri` must be configured explicitly and should point to the public browser URL `http://localhost/keycloak/realms/mapstore/protocol/openid-connect/auth`. +- **OIDC callback returns 500 after Keycloak login**: make sure backend-only endpoints such as `accessTokenUri`, `checkTokenEndpointUrl`, `idTokenUri`, `revokeEndpoint` and `introspectionEndpoint` point to `http://host.docker.internal/keycloak/...`, not `http://localhost/keycloak/...`. +- **Keycloak login is automatic after MapStore logout**: this means the Keycloak SSO session is still active. The sample sets `keycloakOAuth2Config.globalLogoutEnabled=true` and `keycloakOAuth2Config.postLogoutRedirectUri=http://localhost/mapstore/` so MapStore logout also asks Keycloak to end the realm session. +- **Redirect or callback errors**: check that the Keycloak redirect URI matches `http://localhost/mapstore/*` and that `keycloakOAuth2Config.redirectUri` in the datadir points to `http://localhost/mapstore/rest/geostore/openid/keycloak/callback`. In LDAP-direct mode, keep `keycloakOAuth2Config.autoCreateUser=false`; otherwise the Keycloak callback can fail while trying to sync users and groups through the LDAP-backed GeoStore DAOs. +- **502 Bad Gateway during login or callback**: the auth proxy config in `docker/mapstore.auth.conf` increases proxy buffers for Keycloak and MapStore callback responses. Restart the proxy if you edit the file. +- **LDAP LDIF edits are ignored**: LDIF bootstrap runs only on an empty LDAP volume. Rebuild the LDAP image and remove LDAP volumes before restarting. +- **LDAP login does not appear to work**: verify the WAR was built with the LDAP profile and that `ldap.properties` is available in the datadir. diff --git a/docs/developer-guide/integrations/infrastructure-setups.md b/docs/developer-guide/integrations/infrastructure-setups.md index 6d7a414aa4..6a6e25baa4 100644 --- a/docs/developer-guide/integrations/infrastructure-setups.md +++ b/docs/developer-guide/integrations/infrastructure-setups.md @@ -12,6 +12,19 @@ flowchart TB GeoServer <--> |authkey| MapStore ``` +## MapStore-Keycloak (OIDC) + MapStore-GeoServer + +```mermaid +flowchart TB + Browser((Browser)) -->| Login | MapStore + MapStore <--> |OIDC login/logout| Keycloak[(Keycloak)] + MapStore -->|"Resources
(e.g. maps)"| DB[(MapStore
Database)] + MapStore -->| Sync Users, Groups, Roles| DB + MapStore -->| OGC requests with authkey| GeoServer + GeoServer -->| Resolve authkey user| MapStore + GeoServer -->| Users, Groups, Roles| DB +``` + ## MapStore-LDAP + MapStore-GeoServer ```mermaid diff --git a/docs/quick-start.md b/docs/quick-start.md index 1455a1179b..52f5bb6ed5 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -84,6 +84,9 @@ The provided `docker-compose.yml` sets up a full production stack with: * **MapStore** — the application container * **Nginx** — reverse proxy handling external traffic on port 80 +!!! note + If you need a local Docker setup with sample authentication services, see the [Auth Docker Setup](developer-guide/integrations/auth-docker-setup.md#auth-docker-setup) guide for the optional Keycloak and OpenLDAP compose overlay. + ### Prerequisites * A server with [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed diff --git a/mkdocs.yml b/mkdocs.yml index fba94cce46..9e00050af9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -181,6 +181,7 @@ nav: - General: - GeoServer: 'developer-guide/integrations/geoserver.md' - Authentication: 'developer-guide/integrations/auth.md' + - Auth Docker Setup: 'developer-guide/integrations/auth-docker-setup.md' - Infrastructure: 'developer-guide/integrations/infrastructure-setups.md' - Projects: - MapStore Projects: 'developer-guide/mapstore-projects.md' diff --git a/tests/acme-ldap/Dockerfile b/tests/acme-ldap/Dockerfile new file mode 100644 index 0000000000..c9a7b3a891 --- /dev/null +++ b/tests/acme-ldap/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:17-jre + +ADD https://geoserver.org/acme-ldap/acme-ldap-1.0.jar /opt/acme-ldap/acme-ldap-1.0.jar + +EXPOSE 10389 + +CMD ["java", "-jar", "/opt/acme-ldap/acme-ldap-1.0.jar"] diff --git a/tests/ldap.properties b/tests/ldap.properties new file mode 100644 index 0000000000..8380c6ac97 --- /dev/null +++ b/tests/ldap.properties @@ -0,0 +1,25 @@ +## Test LDAP configuration for the acme-ldap container. +## This file lets the generic Docker test stack boot LDAP-enabled WARs. + +ldap.host=ldap +ldap.port=10389 +ldap.root=dc=acme,dc=org +ldap.userDn= +ldap.password= + +ldap.userBase=ou=people +ldap.groupBase=ou=groups +ldap.roleBase=ou=groups +ldap.userFilter=(uid={0}) +ldap.groupFilter=(member={0}) +ldap.roleFilter=(member={0}) +ldap.memberPattern=^uid=([^,]+).*$ + +ldap.hierachicalGroups=false +ldap.nestedGroupFilter=(member={0}) +ldap.nestedGroupLevels=3 +ldap.searchSubtree=true +ldap.convertToUpperCase=true + +ldap.rolePrefix=admin +ldap.adminRole=admin