diff --git a/.env.template b/.env.template new file mode 100644 index 000000000..1af83a01f --- /dev/null +++ b/.env.template @@ -0,0 +1,12 @@ + +PUBLIC_APP_HOST=workday.flock.local:8081 +PUBLIC_APP_URL=http://$PUBLIC_APP_HOST + +PUBLIC_ACCOUNTS_HOST=accounts.flock.local:8081 +PUBLIC_ACCOUNTS_URL=http://$PUBLIC_ACCOUNTS_HOST +ACCOUNTS_SELFSERVICE_URL=$PUBLIC_ACCOUNTS_URL/ui/ + +PUBLIC_KRATOS_URL=http://$PUBLIC_ACCOUNTS_HOST/api + +INTERNAL_KRATOS_URL=http://kratos:4433 +INTERNAL_ADMIN_KRATOS_URL=http://kratos:4434 diff --git a/.gitignore b/.gitignore index 46418dc65..11d084932 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ secrets.yml node_modules coverage .env +/docker/kratos/identities/existing_identities.json diff --git a/docker-compose.yml b/docker-compose.yml index 1a262d6fa..7e3512299 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,25 @@ version: "3.9" services: - workday: - image: gcr.io/flock-eco/flock-eco-workday-develop:snapshot - environment: - - GOOGLE_APPLICATION_CREDENTIALS=/tmp/keys/application_default_credentials.json - volumes: - - type: bind - source: $HOME/.config/gcloud/application_default_credentials.json - target: /tmp/keys/application_default_credentials.json - read_only: true - ports: - - "8080:8080" - networks: - - intranet +# workday: +# image: gcr.io/flock-eco/flock-eco-workday-develop:snapshot +# environment: +# - GOOGLE_APPLICATION_CREDENTIALS=/tmp/keys/application_default_credentials.json +# volumes: +# - type: bind +# source: $HOME/.config/gcloud/application_default_credentials.json +# target: /tmp/keys/application_default_credentials.json +# read_only: true +# ports: +# - "8080:8080" +# networks: +# - intranet oathkeeper: image: oryd/oathkeeper:v0.40.2 depends_on: - kratos - kratos-selfservice-ui-node + - kratos-setup-users # - workday ports: - "8081:4455" @@ -26,9 +27,10 @@ services: command: serve proxy -c "/etc/config/oathkeeper/config.yaml" environment: - LOG_LEVEL=debug - - ERRORS_HANDLERS_REDIRECT_CONFIG_TO=$SELFSERVICE_URL/login - - SERVE_PROXY_CORS_ALLOWED_ORIGINS_0=$PUBLIC_INGRESS_URL - - SERVE_PROXY_CORS_ALLOWED_ORIGINS_1=$SELFSERVICE_URL + - ERRORS_HANDLERS_REDIRECT_CONFIG_TO=$ACCOUNTS_SELFSERVICE_URL/login + - MUTATORS_ID_TOKEN_CONFIG_ISSUER_URL=$PUBLIC_ACCOUNTS_URL + - SERVE_PROXY_CORS_ALLOWED_ORIGINS_0=$PUBLIC_APP_URL + - SERVE_PROXY_CORS_ALLOWED_ORIGINS_1=$ACCOUNTS_SELFSERVICE_URL - SERVE_PROXY_CORS_ALLOWED_ORIGINS_2=$PUBLIC_KRATOS_URL restart: on-failure networks: @@ -63,19 +65,19 @@ services: - DSN=postgres://kratos:secret@postgres-kratos:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4 # - SERVE_PUBLIC_BASE_URL=$PUBLIC_KRATOS_URL - - SERVE_ADMIN_BASE_URL=$ADMIN_KRATOS_URL_INTERNAL - - SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=$PUBLIC_INGRESS_URL - - SELFSERVICE_ALLOWED_RETURN_URLS_0=$PUBLIC_INGRESS_URL - - SELFSERVICE_ALLOWED_RETURN_URLS_1=$SELFSERVICE_URL + - SERVE_ADMIN_BASE_URL=$INTERNAL_ADMIN_KRATOS_URL + - SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=$PUBLIC_APP_URL + - SELFSERVICE_ALLOWED_RETURN_URLS_0=$PUBLIC_APP_URL + - SELFSERVICE_ALLOWED_RETURN_URLS_1=$ACCOUNTS_SELFSERVICE_URL # - BASE_PATH=/kratos - - SELFSERVICE_FLOWS_ERROR_UI_URL=$SELFSERVICE_URL/error - - SELFSERVICE_FLOWS_SETTINGS_UI_URL=$SELFSERVICE_URL/settings - - SELFSERVICE_FLOWS_RECOVERY_UI_URL=$SELFSERVICE_URL/recovery - - SELFSERVICE_FLOWS_VERIFICATION_UI_URL=$SELFSERVICE_URL/verification - - SELFSERVICE_FLOWS_LOGOUT_UI_URL=$SELFSERVICE_URL/logout - - SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=$PUBLIC_INGRESS_URL - - SELFSERVICE_FLOWS_LOGIN_UI_URL=$SELFSERVICE_URL/login - - SELFSERVICE_FLOWS_REGISTRATION_UI_URL=$SELFSERVICE_URL/registration + - SELFSERVICE_FLOWS_ERROR_UI_URL=$ACCOUNTS_SELFSERVICE_URL/error + - SELFSERVICE_FLOWS_SETTINGS_UI_URL=$ACCOUNTS_SELFSERVICE_URL/settings + - SELFSERVICE_FLOWS_RECOVERY_UI_URL=$ACCOUNTS_SELFSERVICE_URL/recovery + - SELFSERVICE_FLOWS_VERIFICATION_UI_URL=$ACCOUNTS_SELFSERVICE_URL/verification + - SELFSERVICE_FLOWS_LOGOUT_UI_URL=$ACCOUNTS_SELFSERVICE_URL/logout + - SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=$PUBLIC_APP_URL + - SELFSERVICE_FLOWS_LOGIN_UI_URL=$ACCOUNTS_SELFSERVICE_URL/login + - SELFSERVICE_FLOWS_REGISTRATION_UI_URL=$ACCOUNTS_SELFSERVICE_URL/registration ports: - '4433:4433' - '4434:4434' @@ -92,7 +94,7 @@ services: kratos-selfservice-ui-node: image: oryd/kratos-selfservice-ui-node:latest environment: - - KRATOS_PUBLIC_URL=$PUBLIC_KRATOS_URL_INTERNAL + - KRATOS_PUBLIC_URL=$INTERNAL_KRATOS_URL - KRATOS_BROWSER_URL=$PUBLIC_KRATOS_URL - BASE_PATH=/ui networks: @@ -135,7 +137,7 @@ services: command: migrate sql -e --yes -c /etc/config/kratos/kratos.yml kratos-setup-users: - image: curlimages/curl + image: atlassian/default-image:4 # TODO let's go leaner depends_on: - kratos networks: @@ -144,7 +146,7 @@ services: - type: bind source: ./docker/kratos/identities target: /tmp - command: sh /tmp/import_identities.sh + command: /tmp/import_identities.sh keto-migrate: image: oryd/keto:v0.11.1-alpha.0 diff --git a/docker/keto/namespaces/permissions.ts b/docker/keto/namespaces/permissions.ts index 8f1457dbb..5e875a45f 100644 --- a/docker/keto/namespaces/permissions.ts +++ b/docker/keto/namespaces/permissions.ts @@ -1,4 +1,4 @@ -import {Namespace, SubjectSet, Context} from "@ory/keto-namespace-types" +import {Namespace, Context} from "@ory/keto-namespace-types" class User implements Namespace { diff --git a/docker/kratos/identities/import_identities.sh b/docker/kratos/identities/import_identities.sh index b8da9937f..9ac79a03d 100644 --- a/docker/kratos/identities/import_identities.sh +++ b/docker/kratos/identities/import_identities.sh @@ -1,10 +1,14 @@ -#!/bin/bash +#!/bin/sh + sleep 3 while read line; do curl --request POST -vvv -sL \ + --fail \ --header "Content-Type: application/json" \ --data-binary "@$line" http://kratos:4434/admin/identities -done < <(find . -type f -name "*.json") - - +done < <(find /tmp -type f -name "*.json") +echo $PWD +curl -vvv \ + --header "Accept: application/json" \ + http://kratos:4434/admin/identities | jq '[.[] | {"id": .id, "email": .traits.email} ] ' > /tmp/existing_identities.json diff --git a/docker/oathkeeper/accounts-management-rules.yaml b/docker/oathkeeper/accounts-management-rules.yaml index 5e5924373..b63ded6c4 100644 --- a/docker/oathkeeper/accounts-management-rules.yaml +++ b/docker/oathkeeper/accounts-management-rules.yaml @@ -1,3 +1,20 @@ +- id: oathkeeper-jwks + version: v0.40.2 + upstream: + url: http://oathkeeper:4456 + match: + url: http://accounts.flock.local:8081/.well-known/jwks.json + methods: + - GET + authenticators: + - handler: noop + authorizer: + handler: allow + mutators: + - handler: noop + errors: + - handler: redirect + - id: kratos-public-api version: v0.40.2 upstream: diff --git a/docker/oathkeeper/config.yaml b/docker/oathkeeper/config.yaml index 66983d7d2..c1d0c6b34 100644 --- a/docker/oathkeeper/config.yaml +++ b/docker/oathkeeper/config.yaml @@ -57,6 +57,14 @@ errors: verbose: true mutators: + id_token: + # Set enabled to true if the authenticator should be enabled and false to disable the authenticator. Defaults to false. + enabled: true + config: + issuer_url: --set-me-through-env-variable-- + jwks_url: file:///etc/config/oathkeeper/jwks.json + ttl: 60s + claims: '{"aud": ["https://my-backend-service/some/endpoint"],"email": "{{ print .Extra.identity.traits.email }}"}' header: enabled: true config: diff --git a/docker/oathkeeper/jwks.json b/docker/oathkeeper/jwks.json new file mode 100644 index 000000000..949be6087 --- /dev/null +++ b/docker/oathkeeper/jwks.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "use": "sig", + "kty": "RSA", + "kid": "75ab2a62-5eeb-4362-8a23-6aa8e0622f4d", + "alg": "RS256", + "n": "u6cx6cF08Jqavfl5Mrh8_KBDXkBu-g6aY1SZsxy31ac_9iZgI3YgEkqkFqO4AvDG8fIydU8BINbbxtvDe6bgTXdagKAshsgqTHLRIrv0gPX7ZEeAU-dahP1dsWtmmgmNCzduPn8N8LBkQuj9DifGKihGr8E7LjcuGx6rvaxpsrl-bQZ5TxKZRq3CkB-vXJM8nXn48hlWwRZ8AcAUcazJFbao5xD0ejzgL1WDndXPix2u_Twl6zDZ8BlMmUKmPQUr9voWLA0CZbmpF3Temp6ENW20ngucrv1MsWE65MQnFqnt5bnlL30OJWdV3u280Dqchhr1b5CtNOdB0lzzGdmVuw", + "e": "AQAB", + "d": "qPy1_DXVI192_qGMvVpaY_3G7TfFOLax3cWv7BZujp7hAMAqT4Tu-Mny3thsu2ktH3Dmc7HD6FjU7k21ysLKt7hF7hNQRcg4H0Q-hbMRSpk53GJvdB-h6qVoHIAFk64KMBGo2-r7GMcgYwkB6h9zWz-N-HRg4QucH5wNOO_s9556GHe5tRVuz4YsaF0XdLXS5bPLgkAtVZhE_4KCaAaZJL4aV35zBc2HMKZ059L3H2RnPQtloEDUDbeDPPZKwUBCBhGNQvz6GkaYn-sdgIQnHv374RfXDHga2_qDKBbNIKno9F59tt5ytijrtqnJGovAMnmT4bAn7ExpUw0tMfC7gQ", + "p": "wVwk9tRW5w4PTyRuo5eYvmgRmfDUFWYreQCweHkKNT92EPUlJQevKcfvKc8DM2CH3T6o3KVvuNVq4JUsPAFh7MWW_V_H_HDZQ6Q0iAyb6mTrSdAjHrnTY9MHc-aDSKfuneXY_e3-ag-0gmMBng7Bu0PxomWZZHhWZ7aclAEitBs", + "q": "-HHEhBkjuycQg6ZdbOP4SFHQ_X6CQNXrHP8RWnYpnwFCV1vnccUxOHsaXZkJSVU1ibLD9eU_iTtR3F4zPFEEmIM5JVuBvLJcHqqBbEDYPabPoefAz3yqzUEw-yWG2O9JaD7u4zl-zr9R6ieMYV0IyGuGvRkPk6MnhJSsr6RcfuE", + "dp": "NT7pkurwL1pIzvNqYDQ7xJsl1a1iya3D5ONStSonrHgYTB0lqGfQTYIYEvxFll1LrJo3p-MKu-xRLR0G_FMpIylaJdW9XX-55I2QRbKrMMWvoTjmouxrEL8o-zqMBnLwG50SvwJNn-lJPOqEpIDNyoKwRJcTwX391TuNLJiPdOc", + "dq": "F_8VFhU_iEIbrDuTZoMWZhXQ88sWVaDT1rckO_KRzpPWjo2H60s6l9P8FfJEHVsnkqc7mjKMUnrySzCmDjtdEEYMbPlLrKglkaiyW3xf5oSIJYE29FN8Q9r6GifjwGxMUM9TT4ssHlgIV29-daEAyirolrcm9AGcPYgGrehYLsE", + "qi": "fjD-SuFpHe3xgk-1SDtjV19QtbFmchjoGuWA5A9SPiA-L3471uv9mI5zil-GS54URtuVBd6QZ404zBR7cuc9VnUHE5k_avKfQh0O4uIkOF7o7TMPf8xc3d4Uee2uKvJsASQw9rNcwtpqKSismxTNE86SoTa8zEYjYDdgYr81zTE" + } + ] +} diff --git a/docker/oathkeeper/rules.yaml b/docker/oathkeeper/rules.yaml index d5c7f84c9..0c89de1c9 100644 --- a/docker/oathkeeper/rules.yaml +++ b/docker/oathkeeper/rules.yaml @@ -17,106 +17,12 @@ handler: allow mutators: - handler: header - errors: - - handler: redirect - -- id: echo-public - version: v0.40.2 - match: - url: http://localhost:8085/<.*> - methods: - - GET - authenticators: - - handler: anonymous - authorizer: - handler: allow - mutators: - - handler: header - errors: - - handler: redirect -- id: echo-public - version: v0.40.2 - match: - url: https://<.*>.gitpod.io/echo/public/<.*> - methods: - - GET - authenticators: - - handler: anonymous - authorizer: - handler: allow - mutators: - - handler: header - errors: - - handler: redirect -- id: echo-protected - version: v0.40.2 - match: - url: https://<.*>.gitpod.io/echo/protected/<.*> - methods: - - GET - authenticators: - - handler: cookie_session - authorizer: - handler: allow - mutators: - - handler: header - errors: - - handler: redirect -- id: echo-elevated-authentication - version: v0.40.2 - match: - url: https://<.*>.gitpod.io/echo/elevated/<.*> - methods: - - GET - authenticators: - - handler: cookie_session + - handler: id_token config: - check_session_url: http://kratos-whoami-aal-aware:8080/sessions/whoami?aal=aal2 - authorizer: - handler: remote_json - config: - remote: http://keto:4466/relation-tuples/check - payload: '{ "namespace": "access", "object": "{{printIndex .MatchContext.RegexpCaptureGroups - 1}}", "relation": "read", "subject_id": "{{print .Subject}}" }' - mutators: - - handler: header - errors: - - handler: redirect - config: - to: https://8085-flockcommun-identityacc-995ctfvg8og.ws-eu96b.gitpod.io/kratos/self-service/login/browser?refresh=true&aal=aal2 -- id: echo-keto-get-vehicles - version: v0.40.2 - match: - url: https://<.*>.gitpod.io/echo/vehicles/<.*> - methods: - - GET - authenticators: - - handler: cookie_session - authorizer: - handler: remote_json - config: - remote: http://keto:4466/relation-tuples/check - payload: '{ "namespace": "access", "object": "{{printIndex .MatchContext.RegexpCaptureGroups - 1}}", "relation": "read", "subject_id": "{{print .Subject}}" }' - mutators: - - handler: header - errors: - - handler: redirect -- id: echo-keto-get-finances - version: v0.40.2 - match: - url: https://<.*>.gitpod.io/echo/finances/<.*> - methods: - - GET - authenticators: - - handler: cookie_session - authorizer: - handler: remote_json - config: - remote: http://keto:4466/relation-tuples/check - payload: '{ "namespace": "access", "object": "{{printIndex .MatchContext.RegexpCaptureGroups - 1}}", "relation": "read", "subject_id": "{{print .Subject}}" }' - mutators: - - handler: header + claims: | + { + "aud": ["http://workday.flock.local:8081"], + "email": "{{ print .Extra.identity.traits.email }}" + } errors: - handler: redirect diff --git a/docs/Workday App permissions model.drawio.png b/docs/Workday App permissions model.drawio.png new file mode 100644 index 000000000..a7bad596f Binary files /dev/null and b/docs/Workday App permissions model.drawio.png differ diff --git a/docs/identity-access-management.md b/docs/identity-access-management.md new file mode 100644 index 000000000..460988e29 --- /dev/null +++ b/docs/identity-access-management.md @@ -0,0 +1,117 @@ +# Authentication and Authorization + +The intended target state for Workday is to extract all forms of Authentication and Authorization from the spring boot +and or react application, but rather make use of an identity aware proxy (Ory's Oathkeeper) to make such decisions. + +This means that all incoming requests to the Spring Boot application are expected to be authenticated, and authorised, +such that the application can focus on the functional logic solely. + +## Roadmap + +(🟡 being worked on, 🟢 done ) + +1. 🟡 Setup authentication model in `flock-iam` project (external to Workday) +2. 🟡 Introduce Oathkeeper proxy for Workday, to route all traffic through it before reaching the app itself +3. 🟡 Authentication via Ory's Kratos +4. 'Simpler' `GET` and `PUT` endpoints authorization +5. `POST` / create endpoint +6. List endpoints +7. Removal of authorities from workday app itself + +## Understanding the Ory stack + +(probably best to move me to flock-iam related repo?) + +There are multiple components to the Ory stack + +- Kratos, ... +- Oathkeeper, ... +- Keto, ... + +Every http request to the Workday app is called via the Oathkeeper proxy (running on port `8081`) + +![Oathkeeper decision engine](./oathkeeper-decision-engine.png) + +Oathkeeper performs various checks: + +1. Is there a matching access rule? If no, handle error +2. Is the user's request authenticated through a `cookie_sesion`? If no, handle error +3. Is the user's request authorized? If no, handle error +4. Mutate the request (e.g. to amend user information to the request) + +## Workday's permission model + +[Ory's keto](https://www.ory.sh/docs/keto/) make use of a permission model, to determine whether a given subject (a +User) can access an object (e.g. a Workday, a Holiday, etc.). A permission model consists of two parts: a relation +model, and the derived permissions from said these relations. + +Below you'll find the initial _relations model_ for Workday. +![x](Workday%20App%20permissions%20model.drawio.png) + +Which can be read as + +- A _Person_ is the **owner** of a _Workday_ +- A _User_ is the **owner** of a _Person_ +- A _Flock_ is the **organisational unit** of a _Person_ +- A _Flock_ is the **organisational unit** of a _Flock_ +- A _User_ is the **manager** of a _Flock_ +- etc. + +> ℹ️ +> This model allows manager of (a) Flock. to have a relation with a specific Workday coupled to a person. +> +> E.g. _Workday-X_ is **owned** by _Person-Y_, which is part of **organisational unit** _Flock-Z_, which in turn is \* +> \*managed\*\* by _User-manager-A_ + +Besides the relations model, Workday also need permissions assigned. Ory's keto, provided an intuitive concept for +permissions in the following form: + +> _subject_ can **permission** _object_? yes/no + +These are derived from the relations, and are essentially a graph traversal. + +In the permission model the following rules apply (amongst others): + +- An owner of a Person can view and edit all owned Workdays by that Person. +- A manager of a Person can view and edit all owned Workdays by that Person. +- A manager of a Flock is the manager of all Persons that have said Flock as their organizational unit. +- A manager of a Flock is the manager of all Flocks that have said Flock as their organizational unit. + +for the implementation details see [permissions.ts](../docker/keto/namespaces/permissions.ts) + +## Developing Workday with Ory + +> ℹ️ Prerequisites: +> +> 1. In your `etc/hosts`, the domains `accounts.flock.local` and `workday.flock.local` should both resolve to 127.0.0.1 +> +> ``` +> 127.0.0.1 accounts.flock.local +> 127.0.0.1 workday.flock.local +> ``` +> +> 2. Run `docker compose up -d` to start all Ory related containers for identity and access management. +> 3. Also, make sure that you're The workday application tries to connect to is expected to run on port `8080`, with + + profile [`🔗develop`](src/main/resources/application-develop.properties) + and [`🔗develop-kratos`](src/main/resources/application-develop-kratos.properties) active (in that order!) + +By default, the `kratos-setup-users` container will try to create identities (= users) found in +the [docker/kratos/identities](../docker/kratos/identities) folder. Workday can use the resulting kratosIds to bind them +to the mocked users created on startup. + +> ⚠️ `kratosId`s +> +> Note that the `kratosId`s defined in [🔗`Users.kt`](src/develop/kotlin/community/flock/eco/workday/mocks/Users.kt) will +> have different ids from the generated ones. These are substituted with the actual Kratos identities when Workday +> creates +> the users. + +### Generate a jwks.json + +(flock-iam material) +To generate a `jwks.json` file: + +```bash +docker run oryd/oathkeeper:v0.40.3 credentials generate --alg RS256 > jwks.json +``` diff --git a/docs/oathkeeper-decision-engine.png b/docs/oathkeeper-decision-engine.png new file mode 100644 index 000000000..8462e52dd Binary files /dev/null and b/docs/oathkeeper-decision-engine.png differ diff --git a/package-lock.json b/package-lock.json index 769b82538..465785c8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "dependencies": { "@babel/runtime": "^7.7.7", "@date-io/dayjs": "^1.3.13", - "@flock-community/flock-eco-core": "^2.7.8", - "@flock-community/flock-eco-feature-user": "^2.7.8", + "@flock-community/flock-eco-core": "^2.7.9", + "@flock-community/flock-eco-feature-user": "^2.7.9", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", @@ -1893,19 +1893,19 @@ } }, "node_modules/@flock-community/flock-eco-core": { - "version": "2.7.8", - "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-core/-/@flock-community/flock-eco-core-2.7.8.tgz", - "integrity": "sha512-K/VS2vwG7kQnFd9O+1hk/a50tqil+0C2Qu4LqEVhyEB4OFAs1ugQnhM8L/Fl1/XdjM1QPw6gSyeDWRbFodjebg==", + "version": "2.7.9", + "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-core/-/@flock-community/flock-eco-core-2.7.9.tgz", + "integrity": "sha512-5XNqKPE5zNOwCSlWoKAN9AiIwF1OfjDVnG3uQXCEY/rWRLPOpWCI0nFvvMmCGKHna71cr+Hy25j8gZ5308tgNQ==", "dependencies": { "typescript": "4.5.5" } }, "node_modules/@flock-community/flock-eco-feature-user": { - "version": "2.7.8", - "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-feature-user/-/@flock-community/flock-eco-feature-user-2.7.8.tgz", - "integrity": "sha512-LpeA7aqNle7YT+eVCKK2ZWF3pu6G2DmPbJxMN4TyOCmQ4A0yidKx2iCjHQ1LNTO0WP55GZ5/U0Ny7ik6DTsbjg==", + "version": "2.7.9", + "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-feature-user/-/@flock-community/flock-eco-feature-user-2.7.9.tgz", + "integrity": "sha512-vbHb2AXag50oc3Ra7n2wi1ygN8nkpRe4bpmNBn+a2kxyarrtlltcE4yZjfAWAAuHRnf9+VU22eT/uwMyeKfOZA==", "dependencies": { - "@flock-community/flock-eco-core": "^2.7.8" + "@flock-community/flock-eco-core": "^2.7.9" } }, "node_modules/@flock-community/flock-eco-webpack": { @@ -32131,19 +32131,19 @@ } }, "@flock-community/flock-eco-core": { - "version": "2.7.8", - "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-core/-/@flock-community/flock-eco-core-2.7.8.tgz", - "integrity": "sha512-K/VS2vwG7kQnFd9O+1hk/a50tqil+0C2Qu4LqEVhyEB4OFAs1ugQnhM8L/Fl1/XdjM1QPw6gSyeDWRbFodjebg==", + "version": "2.7.9", + "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-core/-/@flock-community/flock-eco-core-2.7.9.tgz", + "integrity": "sha512-5XNqKPE5zNOwCSlWoKAN9AiIwF1OfjDVnG3uQXCEY/rWRLPOpWCI0nFvvMmCGKHna71cr+Hy25j8gZ5308tgNQ==", "requires": { "typescript": "4.5.5" } }, "@flock-community/flock-eco-feature-user": { - "version": "2.7.8", - "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-feature-user/-/@flock-community/flock-eco-feature-user-2.7.8.tgz", - "integrity": "sha512-LpeA7aqNle7YT+eVCKK2ZWF3pu6G2DmPbJxMN4TyOCmQ4A0yidKx2iCjHQ1LNTO0WP55GZ5/U0Ny7ik6DTsbjg==", + "version": "2.7.9", + "resolved": "https://flock.jfrog.io/artifactory/api/npm/flock-npm/@flock-community/flock-eco-feature-user/-/@flock-community/flock-eco-feature-user-2.7.9.tgz", + "integrity": "sha512-vbHb2AXag50oc3Ra7n2wi1ygN8nkpRe4bpmNBn+a2kxyarrtlltcE4yZjfAWAAuHRnf9+VU22eT/uwMyeKfOZA==", "requires": { - "@flock-community/flock-eco-core": "^2.7.8" + "@flock-community/flock-eco-core": "^2.7.9" } }, "@flock-community/flock-eco-webpack": { diff --git a/package.json b/package.json index 410ed0ce4..b2e3d5855 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "dependencies": { "@babel/runtime": "^7.7.7", "@date-io/dayjs": "^1.3.13", - "@flock-community/flock-eco-core": "^2.7.8", - "@flock-community/flock-eco-feature-user": "^2.7.8", + "@flock-community/flock-eco-core": "^2.7.9", + "@flock-community/flock-eco-feature-user": "^2.7.9", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", diff --git a/pom.xml b/pom.xml index 3a6048567..fa7e9fc30 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ flock-eco-application-parent com.github.flock-community.flock-eco - 2.7.8 + 2.7.9 flock.community.eco.workday @@ -45,6 +45,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.security + spring-security-oauth2-resource-server + org.springframework.cloud spring-cloud-gcp-starter-sql-postgresql diff --git a/readme.md b/readme.md index 98ce4ed8d..fef700fca 100644 --- a/readme.md +++ b/readme.md @@ -1,22 +1,44 @@ # Flock Workday -## Run +about + +## Get started + +Run the Workday app, with the backend and frontend bundled together: ```bash -./mvnw clean compile spring-boot:run -Dspring-boot.run.profiles=develop -Pdevelop +./mvnw clean compile spring-boot:run \ + -Dspring-boot.run.profiles=develop \ + -P develop \ + -P frontend ``` -Mark `src/develop/kotlin` as source directory. - -Add `develop` to the springboot `application` run configuration active profiles. +Run a full build of backend and frontend, and build a Docker 🐳 image ```bash -./mvnw clean install -npm install +./mvnw clean install jib:dockerBuild\ + -P develop \ + -P frontend \ + -Djib.container.environment=SPRING_PROFILES_ACTIVE=develop \ + --file pom.xml ``` -Run `application` and `npm start`. Make sure you're using the correct node version. If you have nvm installed, -run `nvm use` to set node to the version defined in .nvmrc +### Backend tips + +- ℹ️ Mark `src/develop/kotlin` as source directory, if not done automatically by your IDE. +- ℹ️ Add `develop` to the springboot `application` run configuration active profiles. ( + Find [`🔗Application.kt`](src/main/kotlin/Application.kt), run the app one time, and adjust the config) +- ℹ️ The `com.github.eirslett` dependency is used to package the frontend together with the backend, but this only + happens if the `frontend` profile is active (`-P` flag). + - Example: `./mvnw clean install -P frontend`) + +### Frontend tips + +- ℹ️ Workday makes use of [nvm](https://github.com/nvm-sh/nvm) and an [`🔗.nvmrc`](./.nvmrc) to define the Node version. + By running `nvm use`, you activate the configured Node version. +- ℹ️ To run the frontend independently, run `npm install` and then `npm start`. + - A webpack devServer is used to proxy api calls towards the backend, which is expected to run on port 8080. See + the [`🔗webpack.config.js`](./webpack.config.js) for more details. ## Users @@ -30,12 +52,31 @@ run `nvm use` to set node to the version defined in .nvmrc ## Linting -Use `ktlint` to lint kotlin files or `eslint` for javascript files +Use `ktlint` to lint kotlin files + +```bash +# check code style (it's also bound to "mvn verify") +./mvnw antrun:run@ktlint +``` + +```bash +# fix code style deviations (runs built-in formatter) +./mvnw antrun:run@ktlint-format +``` + +Use `prettier` for javascript/typescript files + +```bash +# fix code styles for js files with eslint +npm run lint format +``` + +[Install the prettier plugin in your IDE](https://prettier.io/docs/en/editors.html) to get the most out of prettier. ## Database - Generate diff file with liquibase - ``` + ```bash ./mvnw clean compile liquibase:update liquibase:diff ``` - Rename `db.changelog-diff.yaml` to `db.changelog-#.yaml` @@ -49,23 +90,38 @@ Use `ktlint` to lint kotlin files or `eslint` for javascript files relativeToChangelogFile: true ``` -```bash -# check code style (it's also bound to "mvn verify") -$ ./mvnw antrun:run@ktlint - src/main/kotlin/Main.kt:10:10: Unused import +## Integration with Ory (authentication and authorization) -# fix code style deviations (runs built-in formatter) -$ ./mvnw antrun:run@ktlint-format +See also [Identity access management](./docs/identity-access-management.md) for the prerequisites and more detailed +information -# fix code styles for js files with eslint -$ npm run lint +It is possible to run workday locally, integrating with the Ory stack (Kratos, Oathkeeper, Keto) for identity access and +permission management using the docker compose file. + +```bash +# Yolo, TL;DR +# Start everything authentication/authorization related +docker compose up -d + +# Alternatively, start the spring app through IDE, with profile develop-kratos active +./mvnw clean compile spring-boot:run \ + -Dspring-boot.run.profiles=develop,develop-kratos \ + -P develop \ + -P frontend + +# Go +open http://workday.flock.local:8081 ``` -## Generate secrets +### Development info regarding the Ory stack + +See [Authentication and Authorization](./docs/authentication-and-authorization.md) + +## Generate secrets (deprecated?) Generate secrets to deploy via travis-ci -``` +```bash tar cvf secrets.tar ./service-account.json src/main/resources/application-cloud.properties travis encrypt-file secrets.tar --add ``` diff --git a/src/develop/kotlin/mocks/LoadAssignmentData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadAssignmentData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadAssignmentData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadAssignmentData.kt diff --git a/src/develop/kotlin/mocks/LoadClientData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadClientData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadClientData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadClientData.kt diff --git a/src/develop/kotlin/mocks/LoadContractData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadContractData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadContractData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadContractData.kt diff --git a/src/develop/kotlin/mocks/LoadData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadData.kt diff --git a/src/develop/kotlin/mocks/LoadEventData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadEventData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadEventData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadEventData.kt diff --git a/src/develop/kotlin/mocks/LoadEventRatingData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadEventRatingData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadEventRatingData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadEventRatingData.kt diff --git a/src/develop/kotlin/mocks/LoadExpensesData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadExpensesData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadExpensesData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadExpensesData.kt diff --git a/src/develop/kotlin/mocks/LoadHolidayData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadHolidayData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadHolidayData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadHolidayData.kt diff --git a/src/develop/kotlin/mocks/LoadPersonData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadPersonData.kt similarity index 98% rename from src/develop/kotlin/mocks/LoadPersonData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadPersonData.kt index f0bff2d73..327191f90 100644 --- a/src/develop/kotlin/mocks/LoadPersonData.kt +++ b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadPersonData.kt @@ -3,7 +3,6 @@ package community.flock.eco.workday.mocks import community.flock.eco.feature.user.model.User import community.flock.eco.workday.model.Person import community.flock.eco.workday.repository.PersonRepository -import mocks.users import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component import java.time.Instant @@ -73,7 +72,7 @@ class LoadPersonData( init { val userMap = userData.data.associateBy { it.name } - users.forEach { + mockUsers.forEach { createPerson( firstname = it.firstName, lastname = it.lastName, diff --git a/src/develop/kotlin/mocks/LoadProjectData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadProjectData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadProjectData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadProjectData.kt diff --git a/src/develop/kotlin/mocks/LoadSickdaysData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadSickdaysData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadSickdaysData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadSickdaysData.kt diff --git a/src/develop/kotlin/community/flock/eco/workday/mocks/LoadUserData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadUserData.kt new file mode 100644 index 000000000..144820c6f --- /dev/null +++ b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadUserData.kt @@ -0,0 +1,116 @@ +package community.flock.eco.workday.mocks + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import community.flock.eco.core.authorities.Authority +import community.flock.eco.feature.user.forms.UserAccountOauthForm +import community.flock.eco.feature.user.forms.UserAccountPasswordForm +import community.flock.eco.feature.user.model.User +import community.flock.eco.feature.user.model.UserAccountOauthProvider +import community.flock.eco.feature.user.services.UserAccountService +import community.flock.eco.feature.user.services.UserAuthorityService +import community.flock.eco.workday.authorities.ExpenseAuthority +import community.flock.eco.workday.authorities.HolidayAuthority +import community.flock.eco.workday.authorities.SickdayAuthority +import community.flock.eco.workday.authorities.WorkDayAuthority +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import java.io.File +import java.io.IOException +import java.util.* + + +@Component +@ConditionalOnProperty(prefix = "flock.eco.workday", name = ["develop"]) +class LoadUserData( + @Value("\${flock.eco.workday.login}") + private val loginType: String, + private val userAccountService: UserAccountService, + userAuthorityService: UserAuthorityService, + private val objectMapper: ObjectMapper +) { + + val data: MutableSet = mutableSetOf() + + val workerRoles = setOf( + HolidayAuthority.READ, + HolidayAuthority.WRITE, + SickdayAuthority.READ, + SickdayAuthority.WRITE, + WorkDayAuthority.READ, + WorkDayAuthority.WRITE, + WorkDayAuthority.TOTAL_HOURS, + ExpenseAuthority.READ, + ExpenseAuthority.WRITE + ) + private val allAuthorities = userAuthorityService.allAuthorities() + + private val workerAuthorities = userAuthorityService.allAuthorities().filter { workerRoles.contains(it) } + + init { + if (loginType == "KRATOS") { + mockUsers.forEach { createUserAccountOAuth(it, getKratosIdentities()) } + } else { + mockUsers.forEach { createUserAccountPassword(it) } + } + + } + + private fun getKratosIdentities() = try { + objectMapper.readValue>(File(KRATOS_IDENTITIES_FILE_LOCATION)) + } catch (e: IOException) { + LOG.warn("Could not find existing Kratos identities. Will create users without Kratos link") + throw IllegalStateException( + "Kratos identities could not be found and linked to users known in Workday. " + + "Be sure to run `docker compose up -d` to generate the kratos identity file" + ) + } + + private final fun createUserAccountPassword(mockUser: MockUser) = UserAccountPasswordForm( + name = mockUser.firstName, + email = "${mockUser.firstName.lowercase()}@sesam.straat", + password = mockUser.firstName.lowercase(), + authorities = mockUser.authorities.map { it.toName() }.toSet() + ) + .save() + + + private final fun createUserAccountOAuth(mockUser: MockUser, kratosIdentities: List): User { + val email = "${mockUser.firstName.lowercase()}@sesam.straat" + return UserAccountOauthForm( + name = mockUser.firstName, + email = email, + provider = UserAccountOauthProvider.KRATOS, + reference = kratosIdentities.find { it.email == email }?.id ?: mockUser.kratosId, + authorities = mockUser.authorities.map { it.toName() }.toSet() + ) + .save() + } + + val MockUser.authorities: List + get() { + return when (role) { + Role.ADMIN -> allAuthorities + Role.USER -> workerAuthorities + } + } + + + private fun UserAccountPasswordForm.save(): User = + userAccountService.createUserAccountPassword(this) + .user + .also { data.add(it) } + + private fun UserAccountOauthForm.save(): User = + userAccountService.createUserAccountOauth(this) + .user + .also { data.add(it) } + + companion object { + private const val KRATOS_IDENTITIES_FILE_LOCATION = "./docker/kratos/identities/existing_identities.json" + private val LOG: Logger = LoggerFactory.getLogger(LoadUserData::class.java) + } +} diff --git a/src/develop/kotlin/mocks/LoadWorkDayData.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/LoadWorkDayData.kt similarity index 100% rename from src/develop/kotlin/mocks/LoadWorkDayData.kt rename to src/develop/kotlin/community/flock/eco/workday/mocks/LoadWorkDayData.kt diff --git a/src/develop/kotlin/community/flock/eco/workday/mocks/Users.kt b/src/develop/kotlin/community/flock/eco/workday/mocks/Users.kt new file mode 100644 index 000000000..70e532bcf --- /dev/null +++ b/src/develop/kotlin/community/flock/eco/workday/mocks/Users.kt @@ -0,0 +1,44 @@ +package community.flock.eco.workday.mocks + +import java.time.LocalDate + +enum class Role { + USER, ADMIN +} + +data class MockUser( + val firstName: String, + val lastName: String, + val kratosId: String, + val role: Role = Role.USER, + val active: Boolean = true, + val birthdate: LocalDate? = null, + val joinDate: LocalDate? = null +) + +data class KratosIdentity(val id: String, val email: String) + +val mockUsers = listOf( + MockUser( + "Tommy", + "Dog", + kratosId = "00000000-0000-0000-0000-000000000001", + birthdate = LocalDate.of(1980, 5, 8), + joinDate = LocalDate.of(2000, 5, 8) + ), + MockUser( + "Ieniemienie", + "Mouse", + kratosId = "00000000-0000-0000-0000-000000000002", + birthdate = LocalDate.now() + ), + MockUser( + "Pino", + "Woodpecker", + kratosId = "00000000-0000-0000-0000-000000000003", + joinDate = LocalDate.of(1983, 6, 7) + ), + MockUser("Bert", "Muppets", kratosId = "00000000-0000-0000-0000-000000000004", role = Role.ADMIN), + MockUser("Ernie", "Muppets", kratosId = "00000000-0000-0000-0000-000000000005"), + MockUser("Aart", "Staartjes", kratosId = "00000000-0000-0000-0000-000000000006", active = false) +) diff --git a/src/develop/kotlin/mocks/LoadUserData.kt b/src/develop/kotlin/mocks/LoadUserData.kt deleted file mode 100644 index aecea41fc..000000000 --- a/src/develop/kotlin/mocks/LoadUserData.kt +++ /dev/null @@ -1,64 +0,0 @@ -package community.flock.eco.workday.mocks - -import community.flock.eco.core.authorities.Authority -import community.flock.eco.feature.user.forms.UserAccountPasswordForm -import community.flock.eco.feature.user.model.User -import community.flock.eco.feature.user.services.UserAccountService -import community.flock.eco.feature.user.services.UserAuthorityService -import community.flock.eco.workday.authorities.ExpenseAuthority -import community.flock.eco.workday.authorities.HolidayAuthority -import community.flock.eco.workday.authorities.SickdayAuthority -import community.flock.eco.workday.authorities.WorkDayAuthority -import mocks.Role -import mocks.users -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.stereotype.Component - -@Component -@ConditionalOnProperty(prefix = "flock.eco.workday", name = ["develop"]) -class LoadUserData( - private val userAccountService: UserAccountService, - userAuthorityService: UserAuthorityService -) { - val data: MutableSet = mutableSetOf() - - val workerRoles = setOf( - HolidayAuthority.READ, - HolidayAuthority.WRITE, - SickdayAuthority.READ, - SickdayAuthority.WRITE, - WorkDayAuthority.READ, - WorkDayAuthority.WRITE, - WorkDayAuthority.TOTAL_HOURS, - ExpenseAuthority.READ, - ExpenseAuthority.WRITE - ) - - private val allAuthorities = userAuthorityService.allAuthorities() - private val workerAuthorities = userAuthorityService.allAuthorities().filter { workerRoles.contains(it) } - - init { - users.forEach { create(it) } - } - - private final fun create(user: mocks.User) = UserAccountPasswordForm( - name = user.firstName, - email = "${user.firstName.toLowerCase()}@sesam.straat", - password = user.firstName.toLowerCase(), - authorities = user.authorities.map { it.toName() }.toSet() - ) - .save() - - val mocks.User.authorities: List - get() { - return when (role) { - Role.ADMIN -> allAuthorities - Role.USER -> workerAuthorities - } - } - - private fun UserAccountPasswordForm.save(): User = - userAccountService.createUserAccountPassword(this) - .user - .also { data.add(it) } -} diff --git a/src/develop/kotlin/mocks/Users.kt b/src/develop/kotlin/mocks/Users.kt deleted file mode 100644 index bba080647..000000000 --- a/src/develop/kotlin/mocks/Users.kt +++ /dev/null @@ -1,38 +0,0 @@ -package mocks - -import java.time.LocalDate - -enum class Role { - USER, ADMIN -} - -data class User( - val firstName: String, - val lastName: String, - val role: Role = Role.USER, - val active: Boolean = true, - val birthdate: LocalDate? = null, - val joinDate: LocalDate? = null -) - -val users = listOf( - User( - "Tommy", - "Dog", - birthdate = LocalDate.of(1980, 5, 8), - joinDate = LocalDate.of(2000, 5, 8) - ), - User( - "Ieniemienie", - "Mouse", - birthdate = LocalDate.now() - ), - User( - "Pino", - "Woodpecker", - joinDate = LocalDate.of(1983, 6, 7) - ), - User("Bert", "Muppets", role = Role.ADMIN), - User("Ernie", "Muppets"), - User("Aart", "Staartjes", active = false) -) diff --git a/src/main/kotlin/authentication/KratosIdentity.kt b/src/main/kotlin/authentication/KratosIdentity.kt index c52b72ce7..030671590 100644 --- a/src/main/kotlin/authentication/KratosIdentity.kt +++ b/src/main/kotlin/authentication/KratosIdentity.kt @@ -1,13 +1,5 @@ package community.flock.eco.workday.authentication -data class KratosIdentity( - val userId: UserId, - val emailAddress: EmailAddress -) { - @JvmInline - value class UserId(val value: String) - - @JvmInline - value class EmailAddress(val value: String) -} +@JvmInline +value class KratosUserId(val value: String) diff --git a/src/main/kotlin/authentication/OathkeeperProxy.kt b/src/main/kotlin/authentication/OathkeeperProxy.kt index e58f60dff..aa370ecca 100644 --- a/src/main/kotlin/authentication/OathkeeperProxy.kt +++ b/src/main/kotlin/authentication/OathkeeperProxy.kt @@ -2,18 +2,19 @@ package community.flock.eco.workday.authentication import community.flock.eco.feature.user.services.UserAccountService import community.flock.eco.feature.user.services.UserSecurityService -import community.flock.eco.workday.authentication.KratosIdentity.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.AuthenticationProvider import org.springframework.security.core.Authentication import org.springframework.security.core.AuthenticationException import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.core.oidc.OidcIdToken import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.stereotype.Component import java.io.IOException +import java.time.Instant import javax.servlet.FilterChain import javax.servlet.ServletException import javax.servlet.http.HttpServletRequest @@ -21,29 +22,34 @@ import javax.servlet.http.HttpServletResponse @Component class OathkeeperProxyAuthenticationProvider( + private val userAccountService: UserAccountService ) : AuthenticationProvider { - @Autowired - lateinit var userAccountService: UserAccountService - @Throws(AuthenticationException::class) override fun authenticate(authentication: Authentication): Authentication { - val kratosIdentity = authentication.credentials as KratosIdentity? - - val account = - kratosIdentity?.let { - userAccountService.findUserAccountPasswordByUserEmail(it.emailAddress.value) - } + val kratosIdentity = authentication.credentials as KratosUserId? + val account = kratosIdentity?.let { userAccountService.findUserAccountOauthByReference(it.value) } return if (account != null) { - val userSecurityPassword = UserSecurityService.UserSecurityPassword(account) + val userSecurityOauth2 = UserSecurityService.UserSecurityOauth2( + account, OidcIdToken( + "token-value", + Instant.EPOCH, Instant.MAX, + mapOf( + Pair("sub", account.reference), + Pair("name", account.user.name ?: "anon"), + Pair("email", account.user.email), + Pair("kratos_user_id", kratosIdentity.value) + ) + ) + ) PreAuthenticatedAuthenticationToken( - userSecurityPassword.username, - userSecurityPassword, - userSecurityPassword.authorities + userSecurityOauth2.account.user.code, + userSecurityOauth2, + userSecurityOauth2.authorities ) } else { PreAuthenticatedAuthenticationToken( - kratosIdentity?.emailAddress ?: "anonymous", + kratosIdentity?.value ?: "anonymous", null ) } @@ -67,19 +73,11 @@ class OathkeeperProxyAuthenticationProcessingFilter( @Throws(AuthenticationException::class, IOException::class, ServletException::class) override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { // Extract from request - val email = request.getHeader("X-Email") - val userId = request.getHeader("X-User"); - - if ((email == null || userId == null)) { - throw InvalidOathkeeperProxyHeadersException("Invalid authentication headers (X-User: $userId, X-Email: $email") - } - val auth = KratosIdentity( - UserId(userId), - EmailAddress(email) - ) + val userId = request.getHeader("X-User") + ?: throw InvalidOathkeeperProxyHeadersException("Invalid authentication headers (X-User)") // Create a token object ot pass to Authentication Provider - val token = PreAuthenticatedAuthenticationToken(email, auth) + val token = PreAuthenticatedAuthenticationToken(userId, KratosUserId(userId)) return authenticationManager.authenticate(token) } diff --git a/src/main/kotlin/config/WebSecurityConfig.kt b/src/main/kotlin/config/WebSecurityConfig.kt index 800d74e68..38c5a69ef 100644 --- a/src/main/kotlin/config/WebSecurityConfig.kt +++ b/src/main/kotlin/config/WebSecurityConfig.kt @@ -10,6 +10,8 @@ import community.flock.eco.workday.authorities.HolidayAuthority import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter +import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -18,17 +20,50 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.oauth2.core.oidc.OidcIdToken +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.web.authentication.AnonymousAuthenticationFilter +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken import org.springframework.security.web.util.matcher.AntPathRequestMatcher import org.springframework.security.web.util.matcher.NegatedRequestMatcher import org.springframework.stereotype.Component - +import java.util.* + +internal class CustomAuthenticationConverter( + private val userAccountService: UserAccountService, +) : Converter { + override fun convert(jwt: Jwt): AbstractAuthenticationToken { + + val account = userAccountService.findUserAccountOauthByReference(jwt.subject) + return if (account != null) { + val userSecurityOauth2 = UserSecurityService.UserSecurityOauth2( + account, OidcIdToken( + jwt.tokenValue, + jwt.issuedAt, jwt.expiresAt, + jwt.claims + ) + ) + PreAuthenticatedAuthenticationToken( + userSecurityOauth2.account.user.code, + userSecurityOauth2, + userSecurityOauth2.authorities + ) + } else { + PreAuthenticatedAuthenticationToken( + jwt.subject ?: "anonymous", + null + ) + } + } +} @Component class MyUserSecurityService( private val userSecurityService: UserSecurityService, - private val oathkeeperProxyAuthenticationProvider:OathkeeperProxyAuthenticationProvider + private val userAccountService: UserAccountService, + private val oathkeeperProxyAuthenticationProvider: OathkeeperProxyAuthenticationProvider ) { + fun googleLogin(http: HttpSecurity): OAuth2LoginConfigurer.UserInfoEndpointConfig { return userSecurityService.googleLogin(http) } @@ -37,12 +72,29 @@ class MyUserSecurityService( return userSecurityService.databaseLogin(http) } - fun kratosLogin(http: HttpSecurity, authenticationManager: AuthenticationManager): Unit { + fun kratosLoginJWT(http: HttpSecurity, authenticationManager: AuthenticationManager): Unit { http - .httpBasic().disable() //No Http Basic Login - .formLogin().disable() //No Form Login - .logout() - .addLogoutHandler(RedirectLogoutHandler()) //No Logout + .httpBasic().disable() //No Http Basic Login + .formLogin().disable() //No Form Login + .logout().addLogoutHandler(RedirectLogoutHandler()) //No Logout + + // No Session pls + http + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .oauth2ResourceServer { + it.jwt { jwt -> + jwt.jwtAuthenticationConverter(CustomAuthenticationConverter(userAccountService)) + } + } + } + + fun kratosLoginHeader(http: HttpSecurity, authenticationManager: AuthenticationManager): Unit { + http + .httpBasic().disable() //No Http Basic Login + .formLogin().disable() //No Form Login + .logout().addLogoutHandler(RedirectLogoutHandler()) //No Logout + // No Session pls http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -54,7 +106,6 @@ class MyUserSecurityService( authenticationManager ), AnonymousAuthenticationFilter::class.java ) - } fun testLogin(http: HttpSecurity): FormLoginConfigurer { @@ -75,11 +126,6 @@ class WebSecurityConfig : WebSecurityConfigurerAdapter() { @Autowired lateinit var userSecurityService: MyUserSecurityService - @Autowired - lateinit var userAccountService: UserAccountService - - @Autowired - lateinit var oathkeeperProxyAuthenticationProvider: OathkeeperProxyAuthenticationProvider @Value("\${flock.eco.workday.login:TEST}") lateinit var loginType: String @@ -95,11 +141,6 @@ class WebSecurityConfig : WebSecurityConfigurerAdapter() { userAuthorityService.addAuthority(HolidayAuthority::class.java) http - .httpBasic().disable() //No Http Basic Login - .formLogin().disable() //No Form Login - .logout() - .addLogoutHandler(RedirectLogoutHandler()) //No Logout - .and() .headers() .frameOptions() .sameOrigin() @@ -119,19 +160,22 @@ class WebSecurityConfig : WebSecurityConfigurerAdapter() { .antMatchers("/h2/**").permitAll() .antMatchers("/api/events/**").permitAll() .antMatchers(*SWAGGER_WHITELIST).permitAll() + .anyRequest().authenticated() http .cors() - http - // No Session pls - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .authenticationProvider(oathkeeperProxyAuthenticationProvider) - .addFilterBefore( - OathkeeperProxyAuthenticationProcessingFilter( - NegatedRequestMatcher(AntPathRequestMatcher("/error")), - authenticationManager() - ), AnonymousAuthenticationFilter::class.java - ) + + when (loginType.uppercase(Locale.getDefault())) { + "GOOGLE" -> + userSecurityService.googleLogin(http) + .and() + .defaultSuccessUrl("/", true) + + "DATABASE" -> userSecurityService.databaseLogin(http).loginPage("/").loginProcessingUrl("/login") + "KRATOS" -> userSecurityService.kratosLoginJWT(http, authenticationManager()) + else -> userSecurityService.testLogin(http).loginPage("/").loginProcessingUrl("/login") + } + + } } diff --git a/src/main/resources/application-develop-kratos.properties b/src/main/resources/application-develop-kratos.properties new file mode 100644 index 000000000..eb0c57f69 --- /dev/null +++ b/src/main/resources/application-develop-kratos.properties @@ -0,0 +1,47 @@ +# trim me down to only the kratos related properties. I should only be used in combination with the `develop` profile / properties +flock.eco.cloud.stub.enabled=true + +flock.eco.workday.develop=true +flock.eco.workday.login=KRATOS +flock.eco.workday.bucket.documents=flock-eco-workday-documents + + +spring.security.oauth2.resourceserver.jwt.issuer-uri= http://accounts.flock.local:8081 +spring.security.oauth2.resourceserver.jwt.jws-algorithm=RS256 +spring.security.oauth2.resourceserver.jwt.jwk-set-uri= http://accounts.flock.local:8081/.well-known/jwks.json + +spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.platform=h2 + +spring.h2.console.enabled=true +spring.h2.console.path=/h2 +spring.h2.console.settings.web-allow-others=true + +spring.jpa.database=h2 +spring.cloud.gcp.sql.enabled=false + +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security.web.authentication=TRACE + +server.port=${PORT:8080} +server.forward-headers-strategy=NATIVE +server.compression.enabled=true +server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css,image/jpeg + +flock.eco.feature.exactonline.clientId=745c6eae-3f5c-4ccd-ae78-4217ff32d621 +flock.eco.feature.exactonline.clientSecret=eEVRgN8GU8wJ +flock.eco.feature.exactonline.redirectUri=http://localhost:3000/api/exactonline/redirect +flock.eco.feature.exactonline.requestUri=https://start.exactonline.nl + +workday.average_sick_days = 5 +workday.average_training_days = 5 +workday.average_public_days = 9 +workday.average_holi_days = 25 + +spring.cloud.gcp.storage.enabled = false +#spring.cloud.gcp.credentials.location=file:token.json +google.drive.sheets.workday.templateId=16dSJHdp-LphqhpodNPrpDCOJLSJmDEe-NNGiZR0lbqM diff --git a/src/main/resources/application-develop.properties b/src/main/resources/application-develop.properties index 7114c84db..45cea5d73 100644 --- a/src/main/resources/application-develop.properties +++ b/src/main/resources/application-develop.properties @@ -19,7 +19,6 @@ spring.cloud.gcp.sql.enabled=false logging.level.org.hibernate.SQL=DEBUG logging.level.org.springframework.security=DEBUG -logging.level.org.springframework.security.web.authentication=TRACE server.port=${PORT:8080} server.forward-headers-strategy=NATIVE