diff --git a/.aliases b/.aliases
index 2d6b0146..96a0b970 100644
--- a/.aliases
+++ b/.aliases
@@ -74,11 +74,15 @@ function importLegacyData() {
alias gw-importHostingAssets='importLegacyData importHostingAssets'
function gradlewBootRun() {
- local port=${1:-8080}
- shift
+ local serverPort=${1:-8080}; shift
+ local managementPort=${2:-$((serverPort + 1))}; shift
local additional_args="$@"
- echo gw bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${port} ${additional_args}"
- ./gradlew bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${port} ${additional_args}"
+ unset HSADMINNG_JWT_ISSUER
+ unset HSADMINNG_JWT_JWKS_URL
+ unset HSADMINNG_JWT_TOKEN_URL
+ set -x
+ ./gradlew bootRun --args="--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=${serverPort} --management.server.port=${managementPort} ${additional_args}"
+ set +x
}
alias gw-bootRun=gradlewBootRun
@@ -97,7 +101,7 @@ alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql
alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l'
-alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResources'
+alias gw-spotless='./gradlew compile spotlessApply -x pitest -x test -x :processResources'
alias gw-check='. .aliases; . .tc-environment; gw test check -x pitest'
# HOWTO: run all 'normal' tests (by default without scenario+import-tests): `gw-test`
@@ -143,7 +147,7 @@ function _gwTest() {
alias gw-test=_gwTest
alias howto=bin/howto
-alias cas-curl=bin/cas-curl
+alias jwt-curl=bin/jwt-curl
# etc/docker-compose.yml limits CPUs+MEM and includes a PostgreSQL config for analysing slow queries
alias gw-importHostingAssets-in-docker-compose='
diff --git a/.tc-environment b/.tc-environment
index 194e6d52..665a2123 100644
--- a/.tc-environment
+++ b/.tc-environment
@@ -3,6 +3,7 @@ source .unset-environment
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net
-export HSADMINNG_CAS_SERVER=
+export HSADMINNG_OFFICE_DATA_SQL_FILE
+export HSADMINNG_JWT_TOKEN_URL=http://localhost:8080/fake-jwt/token
export LANG=en_US.UTF-8
diff --git a/.unset-environment b/.unset-environment
index 69a50ee3..4fc29b63 100644
--- a/.unset-environment
+++ b/.unset-environment
@@ -5,5 +5,10 @@ unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME
unset HSADMINNG_SUPERUSER
unset HSADMINNG_MIGRATION_DATA_PATH
unset HSADMINNG_OFFICE_DATA_SQL_FILE
-unset HSADMINNG_CAS_SERVER=
+
+unset HSADMINNG_JWT_ISSUER
+unset HSADMINNG_JWT_JWKS_URL
+unset HSADMINNG_JWT_USERNAME
+unset HSADMINNG_JWT_PASSWORD
+unset HSADMINNG_JWT_TOKEN_URL
diff --git a/README.md b/README.md
index 0e553c08..a31e3a46 100644
--- a/README.md
+++ b/README.md
@@ -87,13 +87,10 @@ If you have at least Docker and the Java JDK installed in appropriate versions a
# if the container has been built already and you want to keep the data, run this:
pg-sql-start
-Next, compile and run the application on `localhost:8080` and the management server on `localhost:8081`:
+Next, compile and run the application with in dev-mode with all modules, test-data and fake-JWT-authentication::
- # this disables CAS-authentication, for using the REST-API with CAS-authentication, see `bin/cas-curl`.
- export HSADMINNG_CAS_SERVER=
-
- # this runs the application with test-data and all modules:
- gw bootRun --args='--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data'
+ # on `localhost:8080` and the management server on `localhost:8081`:
+ gw-bootRun
# there is also an alias which takes an optional port as an argument:
gw-bootRun 8888
@@ -101,53 +98,75 @@ Next, compile and run the application on `localhost:8080` and the management ser
The meaning of these profiles is:
- **dev**: the PostgreSQL users are created via Liquibase
-- **fakeCasAuthenticator**: The username is simply taken from whatever is after "Bearer " in the "Authorization" header.
+- **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server
- **complete**: all modules are started
- **test-data**: some test data inserted
-Now we can access the REST API, e.g. using curl:
+Now we can access the REST API, e.g. using curl. But you need to use JWT authentication.
+To make this a bit easier to handle, we use `bin/jwt-curl` (or `jwt-curl` alias).
- # the following command should reply with "pong":
- curl -f -s http://localhost:8080/api/ping
+Make sure you replace `8080` with the port you used to run the application.`
+
+ # the following command does not need authentication and should reply with "pinged ...".
+ curl http://localhost:8080/api/ping
+
+ # but when you try endpoints which need authentication, you will get a 401 error:
+ curl http://localhost:8080/api/pong
+
+ # For the follinging commands we need to be authenticated by a valid JWT token.
+ # To make JWT handling a bit easier, there is a wrapper scropt `jwt-curl`.
+ # Make sure the following variable is set to the fake JWT issuer:
+ export HSADMINNG_JWT_TOKEN_URL=http://localhost:8080/fake-jwt/token
+
+ # optionally, you can set the username and password to in env-vars as well:
+ export HSADMINNG_JWT_USERNAME=superuser-alex@hostsharing.net
+ export HSADMINNG_JWT_PASSWORD=whatever-as-its-not-checked-by-fake-jwt-auth
+
+ # also optionally, you can login explicitly:
+ jwt-curl login
+
+ # now, the following command should reply with "ponged ... superuser-alex@hostsharing.net":
+ jwt-curl GET http://localhost:8080/api/pong
# the following command should return a JSON array with just all customers:
- curl -f -s\
- -H 'Authorization: Bearer superuser-alex@hostsharing.net' \
- http://localhost:8080/api/test/customers \
+ jwt-curl GET http://localhost:8080/api/test/customers \
| jq # just if `jq` is installed, to prettyprint the output
# the following command should return a JSON array with just all packages visible for the admin of the customer yyy:
- curl -f -s\
- -H 'Authorization: Bearer superuser-alex@hostsharing.net' -H 'assumed-roles: rbactest.customer#yyy:ADMIN' \
- http://localhost:8080/api/test/packages \
+ jwt-curl ASSUME 'rbactest.customer#yyy:ADMIN'
+ jwt-curl GET http://localhost:8080/api/test/packages \
| jq
+ jwt-curl UNASSUME
# add a new customer
- curl -f -s\
- -H 'Authorization: Bearer superuser-alex@hostsharing.net' -H "Content-Type: application/json" \
+ jwt-curl POST \
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
- -X POST http://localhost:8080/api/test/customers \
+ http://localhost:8080/api/test/customers \
| jq
If you wonder who 'superuser-alex@hostsharing.net' and 'superuser-fran@hostsharing.net' are and where the data comes from:
-Mike and Sven are just example global admin accounts as part of the example data which is automatically inserted in Testcontainers and Development environments.
-Also try for example 'admin@xxx.example.com' or 'unknown@example.org'.
+Alex and Fran are just example global admin accounts as part of the example data which is automatically inserted in Testcontainers and Development environments.
+Also, for example, try 'admin@xxx.example.com' or 'unknown@example.org'.
If you want a formatted JSON output, you can pipe the result to `jq` or similar.
-And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html).
-For a locally running app without CAS-authentication (export HSADMINNG_CAS_SERVER=''),
-authorize using the name of the subject (e.g. "superuser-alex@hostsharing.net" in case of test-data).
-Otherwise, use a valid CAS-ticket.
+And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html.
-If you want to run the application with real CAS-Authentication:
+If you want to run the application with real (OAuth2) JWT-authentication:
- # set the CAS-SERVER-Root, also see `bin/cas-curl`.
- export HSADMINNG_CAS_SERVER=https://login.hostsharing.net # or whatever your CAS-Server-URL you want to use
+ # set the JWT-issuer URI, e.g.
+ export HSADMINNG_JWT_ISSUER=https://login.hshsngdev.hs-example.de/realms/HSAdminDEV
- # run the application against the real CAS authenticator
- gw bootRun --args='--spring.profiles.active=dev,realCasAuthenticator,complete,test-data'
+ # and the JWT JWKS callback URI:
+ export HSADMINNG_JWT_JWKS_URL=https://login.hshsngdev.hs-example.de/realms/HSAdminDEV/.well-known/openid-configuration
+ # as well as the JWT token endpoint URI:
+ export HSADMINNG_JWT_TOKEN_URL=https://login.hshsngdev.hs-example.de/realms/HSAdminDEV/protocol/openid-connect/token
+
+ # run the application against the specified JWT authenticator, do NOT add the 'fake-jwt' profile:
+ gw bootRun --args='--spring.profiles.active=dev,complete,test-data'
+
+Also run `bin/jwt-curl` (or the alias `jwt-curl`) without any parameters to see the available commands.
### PostgreSQL Server
@@ -156,7 +175,7 @@ You might amend the port and user settings in `src/main/resources/application.ym
But the easiest way to run PostgreSQL is via Docker.
-Initially, pull an image compatible to current PostgreSQL version of Hostsharing:
+Initially, pull an image compatible to the current PostgreSQL version of Hostsharing:
docker pull postgres:15.5-bookworm
@@ -674,7 +693,7 @@ howto
Add `--args='--spring.profiles.active=...` with the wanted profile selector:
```sh
-gw bootRun --args='--spring.profiles.active=fakeCasAuthenticator,external-db,only-prod-schema,without-test-data'
+gw bootRun --args='--spring.profiles.active=external-db,only-prod-schema,without-test-data'
```
These profiles mean:
@@ -712,7 +731,7 @@ If it's selected, just hit the *bug*-symbol next to it.
If you frequently need to run with a fresh database and a clean build, you can use this:
```sh
-export HSADMINNG_CAS_SERVER=
+# replace `gw bootRun` by the proper command as described above
gw clean && pg-sql-reset && sleep 5 && gw bootRun' 2>&1 | tee log
```
@@ -851,9 +870,16 @@ This port can be changed in
Or on the command line, add ` --server.port=...` to the `--args` parameter of the `bootRun` task, e.g.:
```sh
-gw bootRun --args='--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=8888'
+gw bootRun --args='--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=8888'
```
+or, for local development, simply:
+
+```sh
+gw-bootRun 8888
+```
+
+
### How to Use a Persistent Database for Integration Tests?
Usually, the `DataJpaTest` integration tests run against a database in a temporary docker container.
@@ -888,7 +914,7 @@ Therefore, during initial development, it's good approach just to amend the exis
```shell
pg-sql-reset
-gw bootRun
+gw bootRun # with the proper command line arguments
```
**⚠**
diff --git a/bin/cas-curl b/bin/cas-curl
deleted file mode 100755
index af9cf541..00000000
--- a/bin/cas-curl
+++ /dev/null
@@ -1,261 +0,0 @@
-#!/bin/bash
-
-if [ "$2" == "--show-password" ]; then
- HSADMINNG_CAS_SHOW_PASSWORD=yes
- shift
-else
- HSADMINNG_CAS_SHOW_PASSWORD=
-fi
-
-if [ "$1" == "--trace" ]; then
- function trace() {
- echo "$*" >&2
- }
- function doCurl() {
- set -x
- if [ -z "$HSADMINNG_CAS_ASSUME" ]; then
- curl --fail-with-body \
- --header "Authorization: $HSADMINNG_CAS_TICKET" \
- "$@"
- else
- curl --fail-with-body \
- --header "Authorization: $HSADMINNG_CAS_TICKET" \
- --header "assumed-roles: $HSADMINNG_CAS_ASSUME" \
- "$@"
- fi
- set +x
- }
- shift
-else
- function trace() {
- : # noop
- }
- function doCurl() {
- curl --fail-with-body --header "Authorization: $HSADMINNG_CAS_TICKET" "$@"
- }
-fi
-
-export HSADMINNG_CAS_ASSUME_HEADER
-if [ -f ~/.cas-curl-assume ]; then
- HSADMINNG_CAS_ASSUME="$(cat ~/.cas-curl-assume)"
-else
- HSADMINNG_CAS_ASSUME=
-fi
-
-if [ -z "$HSADMINNG_CAS_LOGIN" ] || [ -z "$HSADMINNG_CAS_VALIDATE" ] || \
- [ -z "$HSADMINNG_CAS_SERVICE_ID" ]; then
- cat >&2 <>
- export HSADMINNG_CAS_PASSWORD=<>
- export HSADMINNG_CAS_SERVICE_ID=https://hsadminng.hostsharing.net:443/
-EOF
- exit 1
-fi
-
-function casCurlDocumentation() {
- cat <> [parameters]
-
- commands:
-EOF
- # filters out help texts (containing double-# and following lines with leading single-#) from the commands itself
- # (the '' makes sure that this line is not found, just the lines with actual help texts)
- sed -n '/#''#/ {x; p; x; s/#''#//; p; :a; n; /^[[:space:]]*#/!b; s/^[[:space:]]*#//; p; ba}' <$0
-}
-
-function casLogin() {
- # ticket granting ticket exists and not expired?
- if find ~/.cas-login-tgt -type f -size +0c -mmin -60 2>/dev/null | grep -q .; then
- return
- fi
-
- if [ -z "$HSADMINNG_CAS_USERNAME" ]; then
- read -e -p "Username: " HSADMINNG_CAS_USERNAME
- fi
-
- if [ -z "$HSADMINNG_CAS_PASSWORD" ]; then
- read -s -e -p "Password: " HSADMINNG_CAS_PASSWORD
- fi
-
- if [ "$HSADMINNG_CAS_SHOW_PASSWORD" == "--show-password" ]; then
- HSADMINNG_CAS_PASSWORD_DISPLAY=$HSADMINNG_CAS_PASSWORD
- else
- HSADMINNG_CAS_PASSWORD_DISPLAY="<>"
- fi
-
- # Do NOT use doCurl here! We do neither want to print the password nor pass a CAS service ticket.
- trace "+ curl --fail-with-body -s -i -X POST \
- -H 'Content-Type: application/x-www-form-urlencoded' \
- -d \"username=$HSADMINNG_CAS_USERNAME&password=$HSADMINNG_CAS_PASSWORD_DISPLAY\" \
- $HSADMINNG_CAS_LOGIN -o ~/.cas-login-tgt.response -D -"
- HSADMINNG_CAS_TGT=`curl --fail-with-body -s -i -X POST \
- -H 'Content-Type: application/x-www-form-urlencoded' \
- -d "username=$HSADMINNG_CAS_USERNAME&password=$HSADMINNG_CAS_PASSWORD" \
- $HSADMINNG_CAS_LOGIN -o ~/.cas-login-tgt.response -D - \
- | grep -i "^Location: " | sed -e 's/^Location: //' -e 's/\\r//'`
- if [ -z "$HSADMINNG_CAS_TGT" ]; then
- echo "ERROR: could not get ticket granting ticket" >&2
- cat ~/.cas-login-tgt.response >&2
- exit 1
- fi
- echo "$HSADMINNG_CAS_TGT" >~/.cas-login-tgt
- trace "$HSADMINNG_CAS_TGT"
-}
-
-function casLogout() {
- rm -f ~/.cas-login-tgt
-}
-
-function casTicket() {
- HSADMINNG_CAS_TGT=$(<~/.cas-login-tgt)
- if [[ -z "$HSADMINNG_CAS_TGT" ]]; then
- echo "ERROR: cannot get CAS ticket granting ticket for $HSADMINNG_CAS_USERNAME" >&2
- exit 1
- fi
- trace "CAS-TGT: $HSADMINNG_CAS_TGT"
-
- trace "fetching CAS service ticket"
- trace "curl -s -d \"service=$HSADMINNG_CAS_SERVICE_ID\" $HSADMINNG_CAS_TGT"
- HSADMINNG_CAS_TICKET=$(curl -s -d "service=$HSADMINNG_CAS_SERVICE_ID" $HSADMINNG_CAS_TGT)
- if [[ -z "$HSADMINNG_CAS_TICKET" ]]; then
- echo "ERROR: cannot get CAS service ticket" >&2
- exit 1
- fi
-
- echo $HSADMINNG_CAS_TICKET
-}
-
-function casTgt() {
- HSADMINNG_CAS_TGT=$(<~/.cas-login-tgt)
- if [[ -z "$HSADMINNG_CAS_TGT" ]]; then
- echo "ERROR: cannot get CAS ticket granting ticket for $HSADMINNG_CAS_USERNAME" >&2
- exit 1
- fi
- echo "CAS-TGT: $HSADMINNG_CAS_TGT"
-}
-
-function casValidate() {
- HSADMINNG_CAS_TICKET=`casTicket`
-
- trace "validating CAS-TICKET: $HSADMINNG_CAS_TICKET"
- # Do NOT use doCurl here! We do not pass a CAS service ticket.
- trace curl -i -s $HSADMINNG_CAS_VALIDATE?ticket=${HSADMINNG_CAS_TICKET}\&service=${HSADMINNG_CAS_SERVICE_ID}
- HSADMINNG_CAS_USER=`curl -i -s $HSADMINNG_CAS_VALIDATE?ticket=${HSADMINNG_CAS_TICKET}\&service=${HSADMINNG_CAS_SERVICE_ID} | grep -oPm1 "(?<=)[^<]+"`
- if [ -z "$HSADMINNG_CAS_USER" ]; then
- echo "validation failed" >&2
- exit 1
- fi
- echo "CAS-User: $HSADMINNG_CAS_USER"
-}
-
-case "${1,,}" in
-
- # -- generic commands --------------------------------------------------------------------------
-
- ""|"-h"|"--help"|"help") ## prints documentation about commands and options
- casCurlDocumentation
- exit
- ;;
-
- "env") ## prints all related HSADMINNG_CAS_... environment variables; use '--show-password' to show the password as well
- # example: cas-curl env --show-password
- echo "HSADMINNG_CAS_LOGIN: $HSADMINNG_CAS_LOGIN"
- echo "HSADMINNG_CAS_VALIDATE: $HSADMINNG_CAS_VALIDATE"
- echo "HSADMINNG_CAS_USERNAME: $HSADMINNG_CAS_USERNAME"
- if [ "$2" == "--show-password" ]; then
- echo "HSADMINNG_CAS_PASSWORD: $HSADMINNG_CAS_PASSWORD"
- elif [ -z "$HSADMINNG_CAS_PASSWORD" ]; then
- echo "HSADMINNG_CAS_PASSWORD: <>"
- else
- echo "HSADMINNG_CAS_PASSWORD: <>"
- fi
- echo "HSADMINNG_CAS_SERVICE_ID: $HSADMINNG_CAS_SERVICE_ID"
- ;;
-
- # --- authentication-related commands ------------------------------------------------------------
-
- "login") ## reads username+password and fetches ticket granting ticket (bypasses HSADMINNG_CAS_USERNAME+HSADMINNG_CAS_PASSWORD)
- # example: cas-curl login
- casLogout
- export HSADMINNG_CAS_USERNAME=
- export HSADMINNG_CAS_PASSWORD=
- casLogin
- ;;
- "assume") ## assumes the given comma-separated roles
- # example using object-id-name: cas-curl assume 'hs_office.relation#ExampleMandant-with-PARTNER-ExamplePartner:AGENT'
- # example using object-uuid: cas-curl assume 'hs_office.relation#1d3bc468-c5c8-11ef-9d0d-4751ecfda2b7:AGENT'
- shift
- if [ -z "$1" ]; then
- echo "ERROR: requires comma-separated list of roles to assume" >&2
- exit 1
- fi
- echo "$1" >~/.cas-curl-assume
- ;;
- "unassume") ## do not assume any particular role anymore, use the plain user as RBAC subject
- rm ~/.cas-curl-assume
- ;;
- "tgt") ## prints the current ticket granting ticket
- casTgt
- ;;
- "validate") ## validates current ticket granting ticket and prints currently logged in user
- casValidate
- ;;
- "logout") ## logout, deletes ticket granting ticket
- casLogout
- ;;
-
- # --- HTTP-commands ----------------------------------------------------------------------
-
- "get") ## HTTP GET, add URL as parameter
- # example: cas-curl GET http://localhost:8080/api/hs/office/partners/P-10003 | jq
- # hint: '| jq' is just for human-readable formatted JSON output
- shift
- casLogin
- HSADMINNG_CAS_TICKET=`casTicket`
- doCurl "$*"
- ;;
- "post") ## HTTP POST, add curl options to specify the request body and the URL as last parameter
- # example: cas-curl POST \
- # -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
- # http://localhost:8080/api/test/customers | jq
- # hint: '| jq' is just for human-readable formatted JSON output
- shift
- casLogin
- HSADMINNG_CAS_TICKET=`casTicket`
- doCurl --header "Content-Type: application/json" -X POST "$@"
- ;;
- "patch") ## HTTP PATCH, add curl options to specify the request body and the URL as last parameterparameter
- # example: cas-curl PATCH \
- # -d '{ "reference":80002 }' \
- # http://localhost:8080/api/test/customers/ae90ac2a-4728-4ca9-802e-a0d0108b2324 | jq
- # hint: '| jq' is just for human-readable formatted JSON output
- shift
- casLogin
- HSADMINNG_CAS_TICKET=`casTicket`
- doCurl --header "Content-Type: application/json" -X POST "$*"
- ;;
- "delete") ## HTTP DELETE, add curl options to specify the request body and the URL as last parameter
- # example: cas-curl DELETE http://localhost:8080/api/hs/office/persons/ae90ac2a-4728-4ca9-802e-a0d0108b2324
- shift
- casLogin
- HSADMINNG_CAS_TICKET=`casTicket`
- curl -X POST "$@"
- ;;
- *)
- cat >&2 <&2
+ }
+ function doCurl() {
+ set -x
+ if [ -z "$HSADMINNG_JWT_ASSUME" ]; then
+ curl --fail-with-body \
+ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
+ "$@"
+ else
+ curl --fail-with-body \
+ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
+ --header "assumed-roles: $HSADMINNG_JWT_ASSUME" \
+ "$@"
+ fi
+ set +x
+ echo
+ }
+ shift
+else
+ function trace() {
+ : # noop
+ }
+ function doCurl() {
+ if [ -z "$HSADMINNG_JWT_ASSUME" ]; then
+ curl --fail-with-body --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" "$@"
+ else
+ curl --fail-with-body \
+ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
+ --header "assumed-roles: $HSADMINNG_JWT_ASSUME" \
+ "$@"
+ echo
+ fi
+ }
+fi
+
+export HSADMINNG_JWT_ASSUME_HEADER
+if [ -f ~/.cas-curl-assume ]; then
+ HSADMINNG_JWT_ASSUME="$(cat ~/.cas-curl-assume)"
+else
+ HSADMINNG_JWT_ASSUME=
+fi
+
+if [ -z "$HSADMINNG_JWT_TOKEN_URL" ]; then
+ cat >&2 <>
+ export HSADMINNG_JWT_PASSWORD=<>
+EOF
+ exit 1
+fi
+
+function jwtCurlDocumentation() {
+ cat <> [parameters]
+
+ commands:
+EOF
+ # filters out help texts (containing double-# and following lines with leading single-#) from the commands itself
+ # (the '' makes sure that this line is not found, just the lines with actual help texts)
+ sed -n '/#''#/ {x; p; x; s/#''#//; p; :a; n; /^[[:space:]]*#/!b; s/^[[:space:]]*#//; p; ba}' <$0
+}
+
+function jwtLogin() {
+ # JWT token exists and not expired?
+ if [ -f ~/.jwt-token ]; then
+ # Check if token is still valid (simple expiry check - you might want to add JWT parsing for more precise validation)
+ if find ~/.jwt-token -type f -size +0c -mmin -55 2>/dev/null | grep -q .; then
+ HSADMINNG_JWT_TOKEN=$(<~/.jwt-token)
+ return
+ fi
+ fi
+
+ if [ -z "$HSADMINNG_JWT_USERNAME" ]; then
+ read -e -p "Username: " HSADMINNG_JWT_USERNAME
+ fi
+
+ if [ -z "$HSADMINNG_JWT_PASSWORD" ]; then
+ read -s -e -p "Password: " HSADMINNG_JWT_PASSWORD
+ fi
+
+ if [ "$HSADMINNG_JWT_SHOW_PASSWORD" == "yes" ]; then
+ HSADMINNG_JWT_PASSWORD_DISPLAY=$HSADMINNG_JWT_PASSWORD
+ else
+ HSADMINNG_JWT_PASSWORD_DISPLAY="<>"
+ fi
+
+ # OAuth2 Resource Owner Password Credentials Grant (public client)
+ trace "+ curl --fail-with-body --show-error -X POST \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ -d \"grant_type=password&username=$HSADMINNG_JWT_USERNAME&password=$HSADMINNG_JWT_PASSWORD_DISPLAY\" \
+ $HSADMINNG_JWT_TOKEN_URL -o ~/.jwt-token.response"
+
+ JWT_RESPONSE=$(curl --fail-with-body --show-error -X POST \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ -d "grant_type=password&username=$HSADMINNG_JWT_USERNAME&password=$HSADMINNG_JWT_PASSWORD" \
+ $HSADMINNG_JWT_TOKEN_URL 2>&1 | tee ~/.jwt-token.response)
+
+ # Extract access token from JSON response
+ HSADMINNG_JWT_TOKEN=$(echo "$JWT_RESPONSE" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
+
+ if [ -z "$HSADMINNG_JWT_TOKEN" ]; then
+ echo "ERROR: could not get JWT access token: $JWT_RESPONSE" >&2
+ echo >&2
+ exit 1
+ fi
+
+ echo "$HSADMINNG_JWT_TOKEN" > ~/.jwt-token
+ trace "JWT Token acquired (length: ${#HSADMINNG_JWT_TOKEN})"
+}
+
+function jwtLogout() {
+ rm -f ~/.jwt-token ~/.jwt-token.response
+}
+
+function jwtValidate() {
+ if [ ! -f ~/.jwt-token ]; then
+ echo "ERROR: no JWT token found, please login first" >&2
+ exit 1
+ fi
+
+ HSADMINNG_JWT_TOKEN=$(<~/.jwt-token)
+
+ # Simple JWT validation - decode payload (base64 decode the middle part)
+ JWT_PAYLOAD=$(echo "$HSADMINNG_JWT_TOKEN" | cut -d'.' -f2)
+ # Add padding if needed for base64 decoding
+ case $((${#JWT_PAYLOAD} % 4)) in
+ 2) JWT_PAYLOAD="${JWT_PAYLOAD}==" ;;
+ 3) JWT_PAYLOAD="${JWT_PAYLOAD}=" ;;
+ esac
+
+ if command -v base64 >/dev/null 2>&1; then
+ JWT_DECODED=$(echo "$JWT_PAYLOAD" | base64 -d 2>/dev/null)
+ if [ $? -eq 0 ]; then
+ OAUTH_USER=$(echo "$JWT_DECODED" | grep -o '"sub":"[^"]*' | cut -d'"' -f4)
+ if [ -z "$OAUTH_USER" ]; then
+ OAUTH_USER=$(echo "$JWT_DECODED" | grep -o '"preferred_username":"[^"]*' | cut -d'"' -f4)
+ fi
+ if [ -z "$OAUTH_USER" ]; then
+ OAUTH_USER=$(echo "$JWT_DECODED" | grep -o '"username":"[^"]*' | cut -d'"' -f4)
+ fi
+
+ if [ -n "$OAUTH_USER" ]; then
+ echo "OAuth User: $OAUTH_USER"
+ # Check expiry
+ EXP=$(echo "$JWT_DECODED" | grep -o '"exp":[0-9]*' | cut -d':' -f2)
+ if [ -n "$EXP" ]; then
+ CURRENT_TIME=$(date +%s)
+ if [ "$EXP" -lt "$CURRENT_TIME" ]; then
+ echo "WARNING: JWT token has expired" >&2
+ else
+ echo "Token expires: $(date -d "@$EXP" 2>/dev/null || date -r "$EXP" 2>/dev/null || echo "unknown")"
+ fi
+ fi
+ else
+ echo "Could not extract user from JWT token" >&2
+ fi
+ else
+ echo "Could not decode JWT token" >&2
+ fi
+ else
+ echo "JWT token present but cannot validate (base64 command not available)" >&2
+ fi
+}
+
+function jwtToken() {
+ if [ ! -f ~/.jwt-token ]; then
+ echo "ERROR: no JWT token found, please login first" >&2
+ exit 1
+ fi
+ HSADMINNG_JWT_TOKEN=$(<~/.jwt-token)
+ echo "$HSADMINNG_JWT_TOKEN"
+}
+
+case "${1,,}" in
+
+ # -- generic commands --------------------------------------------------------------------------
+
+ ""|"-h"|"--help"|"help") ## prints documentation about commands and options
+ jwtCurlDocumentation
+ exit
+ ;;
+
+ "env") ## prints all related HSADMINNG_JWT_... environment variables; use '--show-password' to show the password as well
+ # example: jwt-curl -show-password env
+ echo "export HSADMINNG_JWT_TOKEN_URL=$HSADMINNG_JWT_TOKEN_URL"
+ echo "export HSADMINNG_JWT_USERNAME=$HSADMINNG_JWT_USERNAME"
+ if [ "$HSADMINNG_JWT_SHOW_PASSWORD" == "yes" ]; then
+ echo "export HSADMINNG_JWT_PASSWORD=$HSADMINNG_JWT_PASSWORD"
+ elif [ -z "$HSADMINNG_JWT_PASSWORD" ]; then
+ echo "export HSADMINNG_JWT_PASSWORD=#<>"
+ else
+ echo "export HSADMINNG_JWT_PASSWORD=#<>"
+ fi
+ ;;
+
+ # --- authentication-related commands ------------------------------------------------------------
+
+ "login") ## reads username+password and fetches JWT access token (bypasses HSADMINNG_JWT_USERNAME+HSADMINNG_JWT_PASSWORD)
+ # example: jwt-curl login
+ jwtLogout
+ export HSADMINNG_JWT_USERNAME=
+ export HSADMINNG_JWT_PASSWORD=
+ jwtLogin
+ ;;
+ "assume") ## assumes the given comma-separated roles
+ # example using object-id-name: jwt-curl assume 'hs_office.relation#ExampleMandant-with-PARTNER-ExamplePartner:AGENT'
+ # example using object-uuid: jwt-curl assume 'hs_office.relation#1d3bc468-c5c8-11ef-9d0d-4751ecfda2b7:AGENT'
+ shift
+ if [ -z "$1" ]; then
+ echo "ERROR: requires comma-separated list of roles to assume" >&2
+ exit 1
+ fi
+ echo "$1" >~/.cas-curl-assume
+ ;;
+ "unassume") ## do not assume any particular role anymore, use the plain user as RBAC subject
+ rm -f ~/.cas-curl-assume
+ ;;
+ "token") ## prints the current JWT access token
+ jwtToken
+ ;;
+ "validate") ## validates current JWT token and prints currently logged in user
+ jwtValidate
+ ;;
+ "logout") ## logout, deletes JWT token
+ jwtLogout
+ ;;
+
+ # --- HTTP-commands ----------------------------------------------------------------------
+
+ "get") ## HTTP GET, add URL as parameter
+ # example: jwt-curl GET http://localhost:8080/api/hs/office/partners/P-10003 | jq
+ # hint: '| jq' is just for human-readable formatted JSON output
+ shift
+ jwtLogin
+ doCurl "$*"
+ ;;
+ "post") ## HTTP POST, add curl options to specify the request body and the URL as last parameter
+ # example: jwt-curl POST \
+ # -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
+ # http://localhost:8080/api/test/customers | jq
+ # hint: '| jq' is just for human-readable formatted JSON output
+ shift
+ jwtLogin
+ doCurl --header "Content-Type: application/json" -X POST "$@"
+ ;;
+ "patch") ## HTTP PATCH, add curl options to specify the request body and the URL as last parameterparameter
+ # example: jwt-curl PATCH \
+ # -d '{ "reference":80002 }' \
+ # http://localhost:8080/api/test/customers/ae90ac2a-4728-4ca9-802e-a0d0108b2324 | jq
+ # hint: '| jq' is just for human-readable formatted JSON output
+ shift
+ jwtLogin
+ doCurl --header "Content-Type: application/json" -X POST "$*"
+ ;;
+
+ # --- unknown command --------------------------------------------------------------------
+ *)
+ cat >&2 < {
tasks.named("jacocoTestReport") {
dependsOn(tasks.named("test")) // Depends on the main test task
+ dependsOn(tasks.named("compileJava")) // Add explicit dependency on compileJava
+ dependsOn(tasks.named("openApiGenerate")) // Add explicit dependency on openApiGenerate
+
reports {
xml.required.set(true) // Common requirement for CI/CD
csv.required.set(false)
diff --git a/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java
new file mode 100644
index 00000000..fe9fb407
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java
@@ -0,0 +1,78 @@
+package net.hostsharing.hsadminng.config;
+
+import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
+import io.swagger.v3.oas.annotations.security.SecurityScheme;
+import lombok.SneakyThrows;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import static net.hostsharing.hsadminng.config.JwtFakeBearer.RSA_KEY;
+
+@Configuration
+@EnableWebSecurity
+// securitySchemes should work in OpenAPI yaml, but the Spring templates seem not to support it
+@SecurityScheme(
+ name = "bearerAuth",
+ type = SecuritySchemeType.HTTP,
+ scheme = "bearer",
+ bearerFormat = "JWT"
+)
+public abstract class BaseWebSecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
+ return http
+ .authorizeHttpRequests(authorize -> authorize
+ .requestMatchers(
+ // only list endpoints implemented in libraries here
+ "/swagger-ui/**",
+ "/v3/api-docs/**",
+ "/actuator/**",
+ "/fake-jwt/**"
+ // otherwise use @PreAuthorize annotation at the controller class / endpoint method level
+ ).permitAll()
+ .requestMatchers("/api/**").permitAll() // controlled at method level
+ .anyRequest().denyAll()
+ )
+ .oauth2ResourceServer(oauth ->
+ oauth.jwt(Customizer.withDefaults()))
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(AbstractHttpConfigurer::disable)
+ .exceptionHandling(exception -> exception
+ .authenticationEntryPoint((request, response, authException) ->
+ // For unknown reason Spring security returns 403 FORBIDDEN for a BadCredentialsException.
+ // But it should return 401 UNAUTHORIZED.
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
+ )
+ )
+ .build();
+ }
+
+ @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:http://localhost:${server.port}/fake-jwt/.well-known/jwks.json}")
+ private String jwkSetUri;
+
+ @Bean
+ @Profile("!fake-jwt")
+ public JwtDecoder jwtDecoder() {
+ return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
+ }
+
+ @Bean
+ @Profile("fake-jwt")
+ @SneakyThrows
+ public JwtDecoder fakeJwtDecoder() {
+ // For fake-jwt profile, use the same RSA key as JwtFakeBearer
+ return NimbusJwtDecoder.withPublicKey(RSA_KEY.toRSAPublicKey()).build();
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java
deleted file mode 100644
index 4a40afe4..00000000
--- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package net.hostsharing.hsadminng.config;
-
-import lombok.AllArgsConstructor;
-import lombok.SneakyThrows;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.web.filter.OncePerRequestFilter;
-
-import jakarta.servlet.FilterChain;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-// Do NOT use @Component (or similar) here, this would register the filter directly.
-// But we need to register it in the SecurityFilterChain created by WebSecurityConfig.
-// The bean gets created in net.hostsharing.hsadminng.config.WebSecurityConfig.authenticationFilter.
-@AllArgsConstructor
-public class CasAuthenticationFilter extends OncePerRequestFilter {
-
- private CasAuthenticator authenticator;
-
- @Override
- @SneakyThrows
- protected void doFilterInternal(
- HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
-
- if (request.getHeader("authorization") != null) {
- final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request);
- final var currentSubject = authenticator.authenticate(request);
- final var authentication = new UsernamePasswordAuthenticationToken(currentSubject, null, null);
- SecurityContextHolder.getContext().setAuthentication(authentication);
- filterChain.doFilter(authenticatedRequest, response);
- } else {
- filterChain.doFilter(request, response);
- }
- }
-}
diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java
deleted file mode 100644
index b063a61e..00000000
--- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package net.hostsharing.hsadminng.config;
-
-import jakarta.servlet.http.HttpServletRequest;
-
-public interface CasAuthenticator {
-
- String authenticate(final HttpServletRequest httpRequest);
-}
diff --git a/src/main/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java
deleted file mode 100644
index 510496b9..00000000
--- a/src/main/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package net.hostsharing.hsadminng.config;
-
-import lombok.SneakyThrows;
-
-import jakarta.servlet.http.HttpServletRequest;
-
-public class FakeCasAuthenticator implements CasAuthenticator {
-
- @Override
- @SneakyThrows
- public String authenticate(final HttpServletRequest httpRequest) {
- return httpRequest.getHeader("Authorization").replaceAll("^Bearer ", "");
- }
-}
diff --git a/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java b/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java
new file mode 100644
index 00000000..e58776ad
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/config/FakeJwtController.java
@@ -0,0 +1,40 @@
+package net.hostsharing.hsadminng.config;
+
+import io.micrometer.core.annotation.Timed;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+
+@RestController
+@Profile("fake-jwt")
+@NoSecurityRequirement
+@Slf4j
+public class FakeJwtController {
+
+ @PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ @Timed("app.config.jwt.token")
+ public ResponseEntity