diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6bda7a1e..67b49468 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -19,6 +19,9 @@ jobs: contents: read security-events: write + # build_plugins: + + release_image: name: "Publish image and scan with trivy" needs: codeql_analyze diff --git a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml index 78732f48..19e875b5 100644 --- a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml +++ b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml @@ -25,14 +25,32 @@ jobs: contents: read security-events: write - build_image_on_push: - name: "Publish image and scan with trivy" + build_dependencies_for_image_on_push: if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + packages: write + security-events: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: ${{ github.ref_name }} + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'temurin' + - name: Build Jars with Maven + run: mvn -f providers/privacyidea/pom.xml clean package + + build_image_on_push_2: permissions: packages: write security-events: write contents: read - uses: dBildungsplattform/dbp-github-workflows/.github/workflows/image-publish-trivy.yaml@7 + uses: dBildungsplattform/dbp-github-workflows/.github/workflows/image-publish-trivy.yaml@DBP-1196-adjust-dev-release-piepline with: image_name: "dbildungs-iam-keycloak" run_trivy_scan: true @@ -42,113 +60,114 @@ jobs: fail_on_vulnerabilites: false report_location: "Dockerfile" target: "deployment" + github_branch: ${{ github.ref_name }} - scan_helm: - if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} - uses: dBildungsplattform/dbp-github-workflows/.github/workflows/check-helm-kics.yaml@5 - permissions: - contents: read + # scan_helm: + # if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} + # uses: dBildungsplattform/dbp-github-workflows/.github/workflows/check-helm-kics.yaml@5 + # permissions: + # contents: read - select_helm_version_generation_and_image_tag_generation: - if: ${{ github.event_name == 'push'}} - runs-on: ubuntu-latest - outputs: - SELECT_HELM_VERION_GENERATION: ${{ steps.select_generation.outputs.SELECT_HELM_VERION_GENERATION }} - SELECT_IMAGE_TAG_GENERATION: ${{ steps.select_generation.outputs.SELECT_IMAGE_TAG_GENERATION }} - steps: - - id: select_generation - shell: bash - run: | - if ${{ github.ref_name == 'main' }}; then - echo "SELECT_HELM_VERION_GENERATION=timestamp" >> "$GITHUB_OUTPUT" - echo "SELECT_IMAGE_TAG_GENERATION=commit_hash" >> "$GITHUB_OUTPUT" - else - echo "SELECT_HELM_VERION_GENERATION=ticket_from_branch_timestamp" >> "$GITHUB_OUTPUT" - echo "SELECT_IMAGE_TAG_GENERATION=ticket_from_branch" >> "$GITHUB_OUTPUT" - fi - - release_helm: - needs: - - select_helm_version_generation_and_image_tag_generation - if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} - uses: dBildungsplattform/dbp-github-workflows/.github/workflows/chart-release.yaml@7 - secrets: inherit - with: - chart_name: dbildungs-iam-keycloak - image_tag_generation: ${{ needs.select_helm_version_generation_and_image_tag_generation.outputs.SELECT_IMAGE_TAG_GENERATION }} - helm_chart_version_generation: ${{ needs.select_helm_version_generation_and_image_tag_generation.outputs.SELECT_HELM_VERION_GENERATION }} + # select_helm_version_generation_and_image_tag_generation: + # if: ${{ github.event_name == 'push'}} + # runs-on: ubuntu-latest + # outputs: + # SELECT_HELM_VERION_GENERATION: ${{ steps.select_generation.outputs.SELECT_HELM_VERION_GENERATION }} + # SELECT_IMAGE_TAG_GENERATION: ${{ steps.select_generation.outputs.SELECT_IMAGE_TAG_GENERATION }} + # steps: + # - id: select_generation + # shell: bash + # run: | + # if ${{ github.ref_name == 'main' }}; then + # echo "SELECT_HELM_VERION_GENERATION=timestamp" >> "$GITHUB_OUTPUT" + # echo "SELECT_IMAGE_TAG_GENERATION=commit_hash" >> "$GITHUB_OUTPUT" + # else + # echo "SELECT_HELM_VERION_GENERATION=ticket_from_branch_timestamp" >> "$GITHUB_OUTPUT" + # echo "SELECT_IMAGE_TAG_GENERATION=ticket_from_branch" >> "$GITHUB_OUTPUT" + # fi - wait_for_helm_chart_to_get_published: - needs: - - release_helm - runs-on: ubuntu-latest - steps: - - shell: bash - run: sleep 1m - - branch_meta: - if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} - uses: dBildungsplattform/spsh-app-deploy/.github/workflows/get-branch-meta.yml@3 - - create_branch_identifier: - if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} - needs: - - branch_meta - uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy-branch-to-namespace.yml@3 - with: - branch: ${{ needs.branch_meta.outputs.branch }} - - deploy: - if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} - needs: - - branch_meta - - create_branch_identifier - - wait_for_helm_chart_to_get_published - - build_image_on_push - uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy.yml@5 - with: - dbildungs_iam_server_branch: ${{ needs.branch_meta.outputs.ticket }} - schulportal_client_branch: ${{ needs.branch_meta.outputs.ticket }} - dbildungs_iam_keycloak_branch: ${{ needs.branch_meta.outputs.ticket }} - dbildungs_iam_ldap_branch: ${{ needs.branch_meta.outputs.ticket }} - namespace: ${{ needs.create_branch_identifier.outputs.namespace_from_branch }} - secrets: inherit - - # On Delete - create_branch_identifier_for_deletion: - if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch' }} - uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy-branch-to-namespace.yml@3 - with: - branch: ${{ github.event.ref }} + # release_helm: + # needs: + # - select_helm_version_generation_and_image_tag_generation + # if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} + # uses: dBildungsplattform/dbp-github-workflows/.github/workflows/chart-release.yaml@7 + # secrets: inherit + # with: + # chart_name: dbildungs-iam-keycloak + # image_tag_generation: ${{ needs.select_helm_version_generation_and_image_tag_generation.outputs.SELECT_IMAGE_TAG_GENERATION }} + # helm_chart_version_generation: ${{ needs.select_helm_version_generation_and_image_tag_generation.outputs.SELECT_HELM_VERION_GENERATION }} - delete_namespace: - if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch'}} - needs: - - create_branch_identifier_for_deletion - uses: dBildungsplattform/spsh-app-deploy/.github/workflows/delete-namespace.yml@5 - with: - namespace: ${{ needs.create_branch_identifier_for_deletion.outputs.namespace_from_branch }} - secrets: - SPSH_DEV_KUBECONFIG: ${{ secrets.SPSH_DEV_KUBECONFIG }} - - delete_successful: - if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch' }} - needs: - - delete_namespace - - create_branch_identifier_for_deletion - runs-on: ubuntu-latest - steps: - - run: echo "Deletion workflow of namespace" ${{ needs.create_branch_identifier_for_deletion.outputs.namespace_from_branch }} "done" + # wait_for_helm_chart_to_get_published: + # needs: + # - release_helm + # runs-on: ubuntu-latest + # steps: + # - shell: bash + # run: sleep 1m - # Scheduled - scheduled_trivy_scan: - name: "Scheduled trivy scan of latest image" - if: ${{ github.event_name == 'schedule' }} - permissions: - packages: read - security-events: write - uses: dBildungsplattform/dbp-github-workflows/.github/workflows/check-trivy.yaml@7 - with: - image_ref: 'ghcr.io/${{ github.repository_owner }}/dbildungs-iam-keycloak:latest' - fail_on_vulnerabilites: false - report_location: "Dockerfile" \ No newline at end of file + # branch_meta: + # if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} + # uses: dBildungsplattform/spsh-app-deploy/.github/workflows/get-branch-meta.yml@3 + + # create_branch_identifier: + # if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} + # needs: + # - branch_meta + # uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy-branch-to-namespace.yml@3 + # with: + # branch: ${{ needs.branch_meta.outputs.branch }} + + # deploy: + # if: ${{ github.event_name == 'push' && !startsWith(github.ref_name,'dependabot/') }} + # needs: + # - branch_meta + # - create_branch_identifier + # - wait_for_helm_chart_to_get_published + # - build_image_on_push + # uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy.yml@5 + # with: + # dbildungs_iam_server_branch: ${{ needs.branch_meta.outputs.ticket }} + # schulportal_client_branch: ${{ needs.branch_meta.outputs.ticket }} + # dbildungs_iam_keycloak_branch: ${{ needs.branch_meta.outputs.ticket }} + # dbildungs_iam_ldap_branch: ${{ needs.branch_meta.outputs.ticket }} + # namespace: ${{ needs.create_branch_identifier.outputs.namespace_from_branch }} + # secrets: inherit + + # # On Delete + # create_branch_identifier_for_deletion: + # if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch' }} + # uses: dBildungsplattform/spsh-app-deploy/.github/workflows/deploy-branch-to-namespace.yml@3 + # with: + # branch: ${{ github.event.ref }} + + # delete_namespace: + # if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch'}} + # needs: + # - create_branch_identifier_for_deletion + # uses: dBildungsplattform/spsh-app-deploy/.github/workflows/delete-namespace.yml@5 + # with: + # namespace: ${{ needs.create_branch_identifier_for_deletion.outputs.namespace_from_branch }} + # secrets: + # SPSH_DEV_KUBECONFIG: ${{ secrets.SPSH_DEV_KUBECONFIG }} + + # delete_successful: + # if: ${{ github.event_name == 'delete' && github.event.ref_type == 'branch' }} + # needs: + # - delete_namespace + # - create_branch_identifier_for_deletion + # runs-on: ubuntu-latest + # steps: + # - run: echo "Deletion workflow of namespace" ${{ needs.create_branch_identifier_for_deletion.outputs.namespace_from_branch }} "done" + + # # Scheduled + # scheduled_trivy_scan: + # name: "Scheduled trivy scan of latest image" + # if: ${{ github.event_name == 'schedule' }} + # permissions: + # packages: read + # security-events: write + # uses: dBildungsplattform/dbp-github-workflows/.github/workflows/check-trivy.yaml@7 + # with: + # image_ref: 'ghcr.io/${{ github.repository_owner }}/dbildungs-iam-keycloak:latest' + # fail_on_vulnerabilites: false + # report_location: "Dockerfile" \ No newline at end of file diff --git a/providers/privacyidea/.gitignore b/providers/privacyidea/.gitignore new file mode 100644 index 00000000..0279f8c0 --- /dev/null +++ b/providers/privacyidea/.gitignore @@ -0,0 +1,7 @@ +/target +.idea/ +*.iml +template.ftl +dependency-reduced-pom.xml +make.bat +deploy.bat diff --git a/providers/privacyidea/.gitmodules b/providers/privacyidea/.gitmodules new file mode 100644 index 00000000..73ab631e --- /dev/null +++ b/providers/privacyidea/.gitmodules @@ -0,0 +1,3 @@ +[submodule "java-client"] + path = java-client + url = https://github.com/privacyidea/java-client diff --git a/providers/privacyidea/.travis.yml b/providers/privacyidea/.travis.yml new file mode 100644 index 00000000..a98b7603 --- /dev/null +++ b/providers/privacyidea/.travis.yml @@ -0,0 +1,2 @@ +language: java + diff --git a/providers/privacyidea/Changelog.md b/providers/privacyidea/Changelog.md new file mode 100644 index 00000000..986d1714 --- /dev/null +++ b/providers/privacyidea/Changelog.md @@ -0,0 +1,76 @@ +# Changelog + +### v1.4.1 - 2024-03-05 + +* Fixed a bug that would cause empty error messages to appear in the log +* The threadpool allows core threads to time out, which will reduce the memory footprint of the provider + +### v1.4.0 - 2023-11-07 + +* Added `sendStaticPass` feature to send a static (or empty) password to trigger challenges +* Added automatic submit after X entered digits option + +### v1.3.0 - 2023-08-11 + +* Added poll in browser setting. This moves the polling for successful push authentication to the browser of the user so that the site does not have to reload. (#133) +* Default OTP text is now customizable. (#137) + +* Added compatibility for keycloak 22 +* Removed listing as theme from keycloak settings + +### v1.2.0 - 2023-01-25 + +* Added implementation of the preferred client mode (#121) +* Added implementation of a new feature: Token enrollment via challenge (#125) + +### v1.1.0 - 2022-07-01 + +* Included groups setting to specify groups of keycloak users for which 2FA should be activated (#54). Check the [configuration documenation](https://github.com/privacyidea/keycloak-provider#configuration). +* It is now possible to configure the names of header that should be forwarded to privacyIDEA (#94) +* If a user has multiple WebAuthn token, all of them can be used to log in (#84) + +* Fixed a bug where the provider would crash if privacyIDEA sent a response with missing fields (#105) + +### v1.0.0 - 2021-11-06 + +* Support for different configurations in different keycloak realms +* U2F + +### v0.6 - 2021-04-03 + +* WebAuthn support +* PIN change via challenge-response + +### v0.5.1 - 2020-11-26 + +* Use java sdk for communication with privacyIDEA +* Added user-agent to http requests + +### v0.5 - 2020-06-10 + +* Fixed a bug where overlapping logins could override the username in the login process + +### v0.4 - 2020-04-24 + +* Changed configuration input type to match new version of keycloak +* Use /validate/polltransaction to check if push was confirmed + +### v0.3 - 2019-10-22 + +* Reset error message when switching between OTP and push +* Catch parsing error for push intervals +* Remove duplicates for token messages + +### v0.2 - 2019-05-22 + +* Add trigger challenge +* Add possibility to exclude keycloak's groups from 2FA +* Add token enrollment, if user does not have a token +* Add push tokens +* Add logging behaviour +* Add transaction id for validate/check + +### v0.1 - 2019-04-11 + +* First version +* Supports basic OTP token \ No newline at end of file diff --git a/providers/privacyidea/LICENSE b/providers/privacyidea/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/providers/privacyidea/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/providers/privacyidea/README.md b/providers/privacyidea/README.md new file mode 100644 index 00000000..36aac67c --- /dev/null +++ b/providers/privacyidea/README.md @@ -0,0 +1,58 @@ +# privacyIDEA provider for Keycloak + +This provider allows you to use privacyIDEA's 2FA with Keycloak. +We added a detailed how-to on our [blog](https://community.privacyidea.org/t/how-to-use-keycloak-with-privacyidea/1132). + +## Download + +* Check our latest [releases](https://github.com/privacyidea/keycloak-provider/releases). +* Download the PrivacyIDEA-Provider.jar for your keycloak version. + +## Installation +**Make sure to pick the correct jar for your keycloak version from the [releases page](https://github.com/privacyidea/keycloak-provider/releases)!** + +#### Keycloak >= 17 +* Keycloak has to be shut down +* Move the jar file into the `providers` directory +* Go to `bin` and run `kc.sh build` (or the batch file on windows) +* Start keycloak again + +#### Keycloak <= 16 +* Move the packed jar file into your deployment directory `standalone/deployment`. +* Optional: Move the template privacyIDEA.ftl to `themes/base/login`. +NOTE: For releases from version 0.6 onward, the template will be deployed automatically, so this step can be skipped. + +Now you can enable the execution for your auth flow. +If you set the execution as 'required', every user needs to login with a second factor. + +## Configuration + +The different configuration parameters that are available on the configuration page of the execution are explained in the following table: + +| Configuration | Explanation | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| privacyIDEA URL | The URL of your privacyIDEA server, which must be reachable from the keycloak server. | +| Realm | This realm will be appended to all requests to privacyIDEA. Leave empty to use the privacyIDEA default realm. | +| Verify SSL | You can choose if Keycloak should verify the SSL certificate from privacyIDEA. Please do not uncheck this in a productive environment! | +| Preferred Login Token Type | Select the token type for which the UI should be first shown. This only matters if such token was triggered before. The UI defaults to OTP mode. This can be overwritten by the policy in privacyIDEA. | +| Enable trigger challenge | Enable if challenges should be triggered beforehand using the provided service account. This is mutually exclusive to sending the password and takes precedence. | +| Service account | The username of the service account to trigger challenges or enroll tokens. Please make sure, that the service account has the correct rights. | +| Service account password | The password of your service account. | +| Service account realm | Specify a separate realm for the service account if needed. If the service account is in the same realm as the users, it is sufficient to specify the realm in the config parameter above. | +| Send password | Enable if the password that was used to authenticate with keycloak in the first step should be sent to privacyIDEA prior to the authentication. Can be used to trigger challenges. Mutually exclusive to trigger challenge. | +| Send static pass | Enable if the configured *static pass* should be sent to privacyIDEA prior to the authentication. Can be used to trigger challenges. If trigger challenge or send password is enabled, this will be ignored. | +| Static pass | The static password for *send static pass*. Can also be empty to send an empty password. | +| Included groups | Keycloak groups that should be included to 2FA. Multiple groups can be specified, separated with ','. NOTE: If both included and excluded are configured, the excluded setting will be ignored! | +| Excluded groups | Keycloak groups that should be excluded from 2FA. Multiple groups can be specified, separated with ','. | +| Forward headers | Set the headers which should be forwarded to privacyIDEA. If the header does not exist or has no value, it will be ignored. The headers names should be separated with ','. | +| OTP length | If you want to turn on the form-auto-submit function after x number of characters are entered into the OTP input field, set the expected OTP length. | +| Enable token enrollment | If the current user does not have a token yet, it can be enrolled. The service account has to be set up. **Starting in privacyIDEA server version 3.8, token enrollment can be done via challenge-response and centrally managed in the server. That is the preferred way of token enrollment while logging in. This feature is therefore deprecated and will be removed in a future version.** | +| Enrollment token type | Select the token type for the token enrollment. | +| Poll in browser | Enable this to do the polling for accepted push requests in the user's browser. When enabled, the login page does not refresh when checking for successful push authentication. CORS settings for privacyidea can be adjusted in `etc/apache2/sites-available/privacyidea.conf`. | +| URL for poll in browser | Optional. If poll in browser should use a deviating URL, set it here. Otherwise, the general URL will be used. | +| Push refresh interval | Interval in seconds to check if the push token is confirmed. This can be a comma separated list where the last value will be repeated. Default: 4,2,2,2,3. | +| Enable logging | Enable this to have the privacyIDEA Keycloak provider write log messages to the keycloak log file. | + +## Manual build with source code +* First, the client submodule has to be build using maven: ``mvn clean install`` in ``java-client``. +* Then build with ``mvn clean install`` in the provider directory and go on with **Installation**. \ No newline at end of file diff --git a/providers/privacyidea/java-client/.github/badges/branches.svg b/providers/privacyidea/java-client/.github/badges/branches.svg new file mode 100644 index 00000000..6bbfb47f --- /dev/null +++ b/providers/privacyidea/java-client/.github/badges/branches.svg @@ -0,0 +1 @@ +branches67.7% \ No newline at end of file diff --git a/providers/privacyidea/java-client/.github/badges/coverage-summary.json b/providers/privacyidea/java-client/.github/badges/coverage-summary.json new file mode 100644 index 00000000..e467b9d3 --- /dev/null +++ b/providers/privacyidea/java-client/.github/badges/coverage-summary.json @@ -0,0 +1 @@ +{"branches": 67.72727272727272, "coverage": 90.37881327522628} \ No newline at end of file diff --git a/providers/privacyidea/java-client/.github/badges/jacoco.svg b/providers/privacyidea/java-client/.github/badges/jacoco.svg new file mode 100644 index 00000000..4b16a3d7 --- /dev/null +++ b/providers/privacyidea/java-client/.github/badges/jacoco.svg @@ -0,0 +1 @@ +coverage90.3% \ No newline at end of file diff --git a/providers/privacyidea/java-client/.github/workflows/build.yml b/providers/privacyidea/java-client/.github/workflows/build.yml new file mode 100644 index 00000000..5c35bcf1 --- /dev/null +++ b/providers/privacyidea/java-client/.github/workflows/build.yml @@ -0,0 +1,70 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time + +name: Java CI with Maven, Run Tests, Coverage Report and Badge + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Java Development Kits + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: microsoft + cache: maven + + - name: Build with Maven + run: mvn -B package --file pom.xml + + - name: Generate JavaCodeCoverage badge + id: jacoco + uses: cicirello/jacoco-badge-generator@v2 + with: + badges-directory: .github/badges + generate-branches-badge: true + generate-summary: true + + - name: Log coverage percentages to workflow output + run: | + echo "coverage = ${{ steps.jacoco.outputs.coverage }}" + echo "branches = ${{ steps.jacoco.outputs.branches }}" + + - name: Upload JaCoCo coverage report as a workflow artifact + uses: actions/upload-artifact@v2 + with: + name: jacoco-report + path: target/site/jacoco/ + + - name: Commit and push the coverage badges and summary file + if: ${{ github.event_name != 'pull_request' }} + run: | + cd .github/badges + if [[ `git status --porcelain *.svg *.json` ]]; then + git config --global user.name 'github-actions' + git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add *.svg *.json + git commit -m "Autogenerated JaCoCo coverage badges" *.svg *.json + git push + fi + + - name: Comment on PR with coverage percentages + if: ${{ github.event_name == 'pull_request' }} + run: | + REPORT=$(<.github/badges/coverage-summary.json) + COVERAGE=$(jq -r '.coverage' <<< "$REPORT")% + BRANCHES=$(jq -r '.branches' <<< "$REPORT")% + NEWLINE=$'\n' + BODY="## JaCoCo Test Coverage Summary Statistics${NEWLINE}* __Coverage:__ ${COVERAGE}${NEWLINE}* __Branches:__ ${BRANCHES}" + gh pr comment ${{github.event.pull_request.number}} -b "${BODY}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/providers/privacyidea/java-client/.gitignore b/providers/privacyidea/java-client/.gitignore new file mode 100644 index 00000000..c6a624c1 --- /dev/null +++ b/providers/privacyidea/java-client/.gitignore @@ -0,0 +1,4 @@ +target/ +.idea/ +dependency-reduced-pom.xml +*.iml diff --git a/providers/privacyidea/java-client/Changelog.md b/providers/privacyidea/java-client/Changelog.md new file mode 100644 index 00000000..e7c849c5 --- /dev/null +++ b/providers/privacyidea/java-client/Changelog.md @@ -0,0 +1,45 @@ +# Changelog + +### v1.2.2 - 5 Mar 2024 + +* Fixed a problem with the threadpool where thread would not time out and accumulate over time +* Added the option to set http timeouts + +### v1.2.1 - 9 Aug 2023 + +* Added Kotlin dependencies for okhttp + +### v1.2.0 - 17 Jan 2023 + +* Added implementation of a new feature: Token enrollment via challenge (#47) +* Added implementation of the preferred client mode (#42, #49) + +### v1.0.2 - 06 May 2022 + +* Added option to pass headers to every privacyIDEA API function + +### v1.0.1 - 25 Mar 2022 + +* Merge sign request for multiple WebAuthn tokens (#31) +* Add authentication status to PIResponse (#32) +* Add error to responses (#27) + +### v1.0.0 - 12 Oct 2021 + +* Add U2F support (#25) + +### v0.3 - 26 Apr 2021 + +* Using async requests (#22) + +### v0.2 - 05 Feb 2021 + +* Add WebAuthn support (#18) +* Add trigger challenge +* Add token enrollment +* Add push token support + +### v0.1 - 18 Sep 2020 + +* First version +* Supports basic OTP token diff --git a/providers/privacyidea/java-client/README.md b/providers/privacyidea/java-client/README.md new file mode 100644 index 00000000..673c21e1 --- /dev/null +++ b/providers/privacyidea/java-client/README.md @@ -0,0 +1,5 @@ +![Coverage](.github/badges/jacoco.svg) +![Branches](.github/badges/branches.svg) + +# Java client for privacyIDEA +Java client to aid develop plugins for the privacyIDEA authentication server. diff --git a/providers/privacyidea/java-client/badges/branches.svg b/providers/privacyidea/java-client/badges/branches.svg new file mode 100644 index 00000000..3935ab96 --- /dev/null +++ b/providers/privacyidea/java-client/badges/branches.svg @@ -0,0 +1 @@ +branches68.3% \ No newline at end of file diff --git a/providers/privacyidea/java-client/badges/coverage-summary.json b/providers/privacyidea/java-client/badges/coverage-summary.json new file mode 100644 index 00000000..70a6cb04 --- /dev/null +++ b/providers/privacyidea/java-client/badges/coverage-summary.json @@ -0,0 +1 @@ +{"branches": 68.39622641509435, "coverage": 90.35936420179682} \ No newline at end of file diff --git a/providers/privacyidea/java-client/badges/jacoco.svg b/providers/privacyidea/java-client/badges/jacoco.svg new file mode 100644 index 00000000..4b16a3d7 --- /dev/null +++ b/providers/privacyidea/java-client/badges/jacoco.svg @@ -0,0 +1 @@ +coverage90.3% \ No newline at end of file diff --git a/providers/privacyidea/java-client/pom.xml b/providers/privacyidea/java-client/pom.xml new file mode 100644 index 00000000..a80f601c --- /dev/null +++ b/providers/privacyidea/java-client/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + org.privacyidea + privacyidea-java-client + 1.2.2 + jar + + UTF-8 + + + privacyidea-java-client + + + org.apache.maven.plugins + maven-surefire-plugin + 2.12.4 + + false + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + + com.google.code.gson + com.squareup.okhttp3 + org.jetbrains.kotlin + com.squareup.okio + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 1.8 + 1.8 + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + + junit + junit + 4.13.2 + test + + + com.squareup.okhttp3 + okhttp + 4.10.0 + + + org.jetbrains.kotlin + kotlin-stdlib + 1.9.0 + + + com.squareup.okio + okio + 3.4.0 + + + com.google.code.gson + gson + 2.10.1 + + + org.mock-server + mockserver-netty-no-dependencies + 5.14.0 + test + + + org.slf4j + slf4j-simple + 2.0.5 + test + + + \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/AsyncRequestCallable.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/AsyncRequestCallable.java new file mode 100644 index 00000000..5519125c --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/AsyncRequestCallable.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +import static org.privacyidea.PIConstants.ENDPOINT_AUTH; + +/** + * Instances of this class are submitted to the thread pool so that requests can be executed in parallel. + */ +public class AsyncRequestCallable implements Callable, Callback +{ + private String path; + private final String method; + private final Map headers; + private final Map params; + private final boolean authTokenRequired; + private final Endpoint endpoint; + private final PrivacyIDEA privacyIDEA; + final String[] callbackResult = {null}; + private CountDownLatch latch; + + public AsyncRequestCallable(PrivacyIDEA privacyIDEA, Endpoint endpoint, String path, Map params, + Map headers, boolean authTokenRequired, String method) + { + this.privacyIDEA = privacyIDEA; + this.endpoint = endpoint; + this.path = path; + this.params = params; + this.headers = headers; + this.authTokenRequired = authTokenRequired; + this.method = method; + } + + @Override + public String call() throws Exception + { + // If an auth token is required for the request, get that first then do the actual request + if (this.authTokenRequired) + { + if (!privacyIDEA.serviceAccountAvailable()) + { + privacyIDEA.error("Service account is required to retrieve auth token!"); + return null; + } + latch = new CountDownLatch(1); + String tmpPath = path; + path = ENDPOINT_AUTH; + endpoint.sendRequestAsync(ENDPOINT_AUTH, privacyIDEA.serviceAccountParam(), Collections.emptyMap(), PIConstants.POST, this); + if (!latch.await(30, TimeUnit.SECONDS)) + { + privacyIDEA.error("Latch timed out..."); + return ""; + } + // Extract the auth token from the response + String response = callbackResult[0]; + String authToken = privacyIDEA.parser.extractAuthToken(response); + if (authToken == null) + { + // The parser already logs the error. + return null; + } + // Add the auth token to the header + headers.put(PIConstants.HEADER_AUTHORIZATION, authToken); + path = tmpPath; + callbackResult[0] = null; + } + + // Do the actual request + latch = new CountDownLatch(1); + endpoint.sendRequestAsync(path, params, headers, method, this); + if (!latch.await(30, TimeUnit.SECONDS)) + { + privacyIDEA.error("Latch timed out..."); + return ""; + } + return callbackResult[0]; + } + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) + { + privacyIDEA.error(e); + latch.countDown(); + } + + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException + { + if (response.body() != null) + { + String s = response.body().string(); + if (!privacyIDEA.logExcludedEndpoints().contains(path) && !ENDPOINT_AUTH.equals(path)) + { + privacyIDEA.log(path + ":\n" + privacyIDEA.parser.formatJson(s)); + } + callbackResult[0] = s; + } + latch.countDown(); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/AuthenticationStatus.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/AuthenticationStatus.java new file mode 100644 index 00000000..5c08168d --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/AuthenticationStatus.java @@ -0,0 +1,20 @@ +/* + * Copyright 2023 NetKnights GmbH - lukas.matusiewicz@netknights.it + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public enum AuthenticationStatus +{ + CHALLENGE, ACCEPT, REJECT, NONE +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/Challenge.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/Challenge.java new file mode 100644 index 00000000..861757a6 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/Challenge.java @@ -0,0 +1,55 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.ArrayList; +import java.util.List; + +public class Challenge +{ + private final List attributes = new ArrayList<>(); + private final String serial; + private final String clientMode; + private final String message; + private final String transactionId; + private final String type; + private final String image; + + public Challenge(String serial, String message, String clientMode, String image, String transactionId, String type) + { + this.serial = serial; + this.message = message; + this.clientMode = clientMode; + this.image = image; + this.transactionId = transactionId; + this.type = type; + } + + public List getAttributes() {return attributes;} + + public String getSerial() {return serial;} + + public String getMessage() {return message;} + + public String getClientMode() {return clientMode;} + + public String getImage() {return image.replaceAll("\"", "");} + + public String getTransactionID() {return transactionId;} + + public String getType() {return type;} +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/Endpoint.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/Endpoint.java new file mode 100644 index 00000000..b4658a59 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/Endpoint.java @@ -0,0 +1,176 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import okhttp3.Callback; +import okhttp3.FormBody; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +import static org.privacyidea.PIConstants.GET; +import static org.privacyidea.PIConstants.HEADER_USER_AGENT; +import static org.privacyidea.PIConstants.POST; +import static org.privacyidea.PIConstants.WEBAUTHN_PARAMETERS; + +/** + * This class handles sending requests to the server. + */ +class Endpoint +{ + private final PrivacyIDEA privacyIDEA; + private final PIConfig piconfig; + private final OkHttpClient client; + + final TrustManager[] trustAllManager = new TrustManager[]{new X509TrustManager() + { + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) + { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) + { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() + { + return new java.security.cert.X509Certificate[]{}; + } + }}; + + Endpoint(PrivacyIDEA privacyIDEA) + { + this.privacyIDEA = privacyIDEA; + this.piconfig = privacyIDEA.configuration(); + + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + builder.connectTimeout(piconfig.httpTimeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(piconfig.httpTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(piconfig.httpTimeoutMs, TimeUnit.MILLISECONDS); + + if (!this.piconfig.doSSLVerify) + { + // Trust all certs and verify every host + try + { + final SSLContext sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, trustAllManager, new java.security.SecureRandom()); + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllManager[0]); + builder.hostnameVerifier((s, sslSession) -> true); + } + catch (KeyManagementException | NoSuchAlgorithmException e) + { + privacyIDEA.error(e); + } + } + this.client = builder.build(); + } + + /** + * Add a request to the okhttp queue. The callback will be invoked upon success or failure. + * + * @param endpoint server endpoint + * @param params request parameters + * @param headers request headers + * @param method http request method + * @param callback okhttp3 callback + */ + void sendRequestAsync(String endpoint, Map params, Map headers, String method, + Callback callback) + { + HttpUrl httpUrl = HttpUrl.parse(piconfig.serverURL + endpoint); + if (httpUrl == null) + { + privacyIDEA.error("Server url could not be parsed: " + (piconfig.serverURL + endpoint)); + // Invoke the callback to terminate the thread that called this function. + callback.onFailure(null, new IOException("Request could not be created because the url could not be parsed")); + return; + } + HttpUrl.Builder urlBuilder = httpUrl.newBuilder(); + privacyIDEA.log(method + " " + endpoint); + params.forEach((k, v) -> + { + if (k.equals("pass") || k.equals("password")) + { + v = "*".repeat(v.length()); + } + + privacyIDEA.log(k + "=" + v); + }); + + if (GET.equals(method)) + { + params.forEach((key, value) -> + { + String encValue = URLEncoder.encode(value, StandardCharsets.UTF_8); + urlBuilder.addQueryParameter(key, encValue); + }); + } + + String url = urlBuilder.build().toString(); + //privacyIDEA.log("URL: " + url); + Request.Builder requestBuilder = new Request.Builder().url(url); + + // Add the headers + requestBuilder.addHeader(HEADER_USER_AGENT, piconfig.userAgent); + if (headers != null && !headers.isEmpty()) + { + headers.forEach(requestBuilder::addHeader); + } + + if (POST.equals(method)) + { + FormBody.Builder formBodyBuilder = new FormBody.Builder(); + params.forEach((key, value) -> + { + if (key != null && value != null) + { + String encValue = value; + // WebAuthn params are excluded from url encoding, + // they are already in the correct encoding for the server + if (!WEBAUTHN_PARAMETERS.contains(key)) + { + encValue = URLEncoder.encode(value, StandardCharsets.UTF_8); + } + formBodyBuilder.add(key, encValue); + } + }); + // This switches okhttp to make a post request + requestBuilder.post(formBodyBuilder.build()); + } + + Request request = requestBuilder.build(); + //privacyIDEA.log("HEADERS:\n" + request.headers().toString()); + client.newCall(request).enqueue(callback); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPILogger.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPILogger.java new file mode 100644 index 00000000..6e56c73b --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPILogger.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +/** + * The java-client will log infos and errors to this interface if it is set. + * Implementations of this interface (the plugins) should map these methods to their corresponding loggers. + */ +public interface IPILogger +{ + void log(String message); + + void error(String message); + + void log(Throwable t); + + void error(Throwable t); +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPIPollTransactionCallback.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPIPollTransactionCallback.java new file mode 100644 index 00000000..775f3f50 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPIPollTransactionCallback.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public interface IPIPollTransactionCallback +{ + /** + * If this method is invoked, the polling the status of the transaction_id passed + * to org.privacyidea.PrivacyIDEA::asyncPollTransaction returned true. + * + * @param response the response of the finalizing call to /validate/check + */ + void transactionFinalized(PIResponse response); +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPISimpleLogger.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPISimpleLogger.java new file mode 100644 index 00000000..09622473 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/IPISimpleLogger.java @@ -0,0 +1,25 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +/** + * The java-client will log infos/errors to this interface if it set and there is no IPILogger set. + */ +public interface IPISimpleLogger +{ + void pilog(String message); +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/JSONParser.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/JSONParser.java new file mode 100644 index 00000000..b91ae2b5 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/JSONParser.java @@ -0,0 +1,590 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.privacyidea.PIConstants.ASSERTIONCLIENTEXTENSIONS; +import static org.privacyidea.PIConstants.ATTRIBUTES; +import static org.privacyidea.PIConstants.AUTHENTICATION; +import static org.privacyidea.PIConstants.AUTHENTICATORDATA; +import static org.privacyidea.PIConstants.CLIENTDATA; +import static org.privacyidea.PIConstants.CLIENT_MODE; +import static org.privacyidea.PIConstants.CODE; +import static org.privacyidea.PIConstants.CREDENTIALID; +import static org.privacyidea.PIConstants.DETAIL; +import static org.privacyidea.PIConstants.ERROR; +import static org.privacyidea.PIConstants.ID; +import static org.privacyidea.PIConstants.IMAGE; +import static org.privacyidea.PIConstants.INFO; +import static org.privacyidea.PIConstants.JSONRPC; +import static org.privacyidea.PIConstants.MAXFAIL; +import static org.privacyidea.PIConstants.MESSAGE; +import static org.privacyidea.PIConstants.MESSAGES; +import static org.privacyidea.PIConstants.MULTI_CHALLENGE; +import static org.privacyidea.PIConstants.OTPLEN; +import static org.privacyidea.PIConstants.PREFERRED_CLIENT_MODE; +import static org.privacyidea.PIConstants.REALMS; +import static org.privacyidea.PIConstants.RESULT; +import static org.privacyidea.PIConstants.SERIAL; +import static org.privacyidea.PIConstants.SIGNATURE; +import static org.privacyidea.PIConstants.SIGNATUREDATA; +import static org.privacyidea.PIConstants.STATUS; +import static org.privacyidea.PIConstants.TOKEN; +import static org.privacyidea.PIConstants.TOKENS; +import static org.privacyidea.PIConstants.TOKEN_TYPE_U2F; +import static org.privacyidea.PIConstants.TOKEN_TYPE_WEBAUTHN; +import static org.privacyidea.PIConstants.TRANSACTION_ID; +import static org.privacyidea.PIConstants.TYPE; +import static org.privacyidea.PIConstants.U2F_SIGN_REQUEST; +import static org.privacyidea.PIConstants.USERHANDLE; +import static org.privacyidea.PIConstants.USERNAME; +import static org.privacyidea.PIConstants.VALUE; +import static org.privacyidea.PIConstants.VERSION_NUMBER; +import static org.privacyidea.PIConstants.WEBAUTHN_SIGN_REQUEST; + +public class JSONParser +{ + private final PrivacyIDEA privacyIDEA; + + public JSONParser(PrivacyIDEA privacyIDEA) + { + this.privacyIDEA = privacyIDEA; + } + + /** + * Format a json string with indentation. + * + * @param json json string + * @return formatted json string + */ + public String formatJson(String json) + { + if (json == null || json.isEmpty()) + { + return ""; + } + + JsonObject obj; + Gson gson = new GsonBuilder().setPrettyPrinting().setLenient().create(); + try + { + obj = JsonParser.parseString(json).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error(e.getMessage()); + return json; + } + + return gson.toJson(obj); + } + + /** + * Extract the auth token from the response of the server. + * + * @param serverResponse response of the server + * @return the auth token or null if error + */ + String extractAuthToken(String serverResponse) + { + if (serverResponse != null && !serverResponse.isEmpty()) + { + JsonElement root = JsonParser.parseString(serverResponse); + if (root != null) + { + try + { + JsonObject obj = root.getAsJsonObject(); + return obj.getAsJsonObject(RESULT).getAsJsonObject(VALUE).getAsJsonPrimitive(TOKEN).getAsString(); + } + catch (Exception e) + { + privacyIDEA.error("Response did not contain an authorization token: " + formatJson(serverResponse)); + } + } + } + else + { + privacyIDEA.error("/auth response was empty or null!"); + } + return null; + } + + /** + * Parse the response of the server into a PIResponse object. + * + * @param serverResponse response of the server + * @return PIResponse or null if input is empty + */ + public PIResponse parsePIResponse(String serverResponse) + { + if (serverResponse == null || serverResponse.isEmpty()) + { + return null; + } + + PIResponse response = new PIResponse(); + response.rawMessage = serverResponse; + + JsonObject obj; + try + { + obj = JsonParser.parseString(serverResponse).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error(e); + return response; + } + + response.id = getInt(obj, ID); + response.piVersion = getString(obj, VERSION_NUMBER); + response.signature = getString(obj, SIGNATURE); + response.jsonRPCVersion = getString(obj, JSONRPC); + + JsonObject result = obj.getAsJsonObject(RESULT); + if (result != null) + { + String r = getString(result, AUTHENTICATION); + for (AuthenticationStatus en : AuthenticationStatus.values()) + { + if (en.toString().equals(r)) + { + response.authentication = en; + } + } + response.status = getBoolean(result, STATUS); + response.value = getBoolean(result, VALUE); + + JsonElement errElem = result.get(ERROR); + if (errElem != null && !errElem.isJsonNull()) + { + JsonObject errObj = result.getAsJsonObject(ERROR); + response.error = new PIError(getInt(errObj, CODE), getString(errObj, MESSAGE)); + return response; + } + } + + JsonElement detailElem = obj.get(DETAIL); + if (detailElem != null && !detailElem.isJsonNull()) + { + JsonObject detail = obj.getAsJsonObject(DETAIL); + + // Translate some preferred client mode names + String modeFromResponse = getString(detail, PREFERRED_CLIENT_MODE); + if ("poll".equals(modeFromResponse)) + { + response.preferredClientMode = "push"; + } + else if ("interactive".equals(modeFromResponse)) + { + response.preferredClientMode = "otp"; + } + else + { + response.preferredClientMode = modeFromResponse; + } + response.message = getString(detail, MESSAGE); + response.image = getString(detail, IMAGE); + response.serial = getString(detail, SERIAL); + response.transactionID = getString(detail, TRANSACTION_ID); + response.type = getString(detail, TYPE); + response.otpLength = getInt(detail, OTPLEN); + + JsonArray arrMessages = detail.getAsJsonArray(MESSAGES); + if (arrMessages != null) + { + arrMessages.forEach(val -> + { + if (val != null) + { + response.messages.add(val.getAsString()); + } + }); + } + + JsonArray arrChallenges = detail.getAsJsonArray(MULTI_CHALLENGE); + if (arrChallenges != null) + { + for (int i = 0; i < arrChallenges.size(); i++) + { + JsonObject challenge = arrChallenges.get(i).getAsJsonObject(); + String serial = getString(challenge, SERIAL); + String message = getString(challenge, MESSAGE); + String clientmode = getString(challenge, CLIENT_MODE); + String image = getString(challenge, IMAGE); + String transactionid = getString(challenge, TRANSACTION_ID); + String type = getString(challenge, TYPE); + + if (TOKEN_TYPE_WEBAUTHN.equals(type)) + { + String webAuthnSignRequest = getItemFromAttributes(WEBAUTHN_SIGN_REQUEST, challenge); + response.multichallenge.add(new WebAuthn(serial, message, clientmode, image, transactionid, webAuthnSignRequest)); + } + else if (TOKEN_TYPE_U2F.equals(type)) + { + String u2fSignRequest = getItemFromAttributes(U2F_SIGN_REQUEST, challenge); + response.multichallenge.add(new U2F(serial, message, clientmode, image, transactionid, u2fSignRequest)); + } + else + { + response.multichallenge.add(new Challenge(serial, message, clientmode, image, transactionid, type)); + } + } + } + } + return response; + } + + static String mergeWebAuthnSignRequest(WebAuthn webAuthn, List arr) throws JsonSyntaxException + { + List extracted = new ArrayList<>(); + for (String signRequest : arr) + { + JsonObject obj = JsonParser.parseString(signRequest).getAsJsonObject(); + extracted.add(obj.getAsJsonArray("allowCredentials")); + } + + JsonObject signRequest = JsonParser.parseString(webAuthn.signRequest()).getAsJsonObject(); + JsonArray allowCredentials = new JsonArray(); + extracted.forEach(allowCredentials::addAll); + + signRequest.add("allowCredentials", allowCredentials); + + return signRequest.toString(); + } + + private String getItemFromAttributes(String item, JsonObject jsonObject) + { + String ret = ""; + JsonElement attributeElement = jsonObject.get(ATTRIBUTES); + if (attributeElement != null && !attributeElement.isJsonNull()) + { + JsonElement requestElement = attributeElement.getAsJsonObject().get(item); + if (requestElement != null && !requestElement.isJsonNull()) + { + ret = requestElement.toString(); + } + } + return ret; + } + + /** + * Parse the response of the /token endpoint into a list of objects. + * + * @param serverResponse response of the server. + * @return list of token info objects or null + */ + List parseTokenInfoList(String serverResponse) + { + if (serverResponse == null || serverResponse.isEmpty()) + { + return null; + } + + List ret = new ArrayList<>(); + JsonObject object; + try + { + object = JsonParser.parseString(serverResponse).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error(e); + return ret; + } + + JsonObject result = object.getAsJsonObject(RESULT); + if (result != null) + { + JsonObject value = result.getAsJsonObject(VALUE); + + if (value != null) + { + JsonArray tokens = value.getAsJsonArray(TOKENS); + if (tokens != null) + { + List infos = new ArrayList<>(); + tokens.forEach(jsonValue -> infos.add(parseSingleTokenInfo(jsonValue.toString()))); + ret = infos; + } + } + } + return ret; + } + + /** + * Parse the info of a single token into an object. + * + * @param json json array element as string + * @return TokenInfo object, might be null object is json is empty + */ + private TokenInfo parseSingleTokenInfo(String json) + { + TokenInfo info = new TokenInfo(); + if (json == null || json.isEmpty()) + { + return info; + } + + info.rawJson = json; + + JsonObject obj; + try + { + obj = JsonParser.parseString(json).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error(e); + return info; + } + + info.active = getBoolean(obj, "active"); + info.count = getInt(obj, "count"); + info.countWindow = getInt(obj, "count_window"); + info.description = getString(obj, "description"); + info.failCount = getInt(obj, "failcount"); + info.id = getInt(obj, ID); + info.locked = getBoolean(obj, "locked"); + info.maxFail = getInt(obj, MAXFAIL); + info.otpLen = getInt(obj, OTPLEN); + info.resolver = getString(obj, "resolver"); + info.revoked = getBoolean(obj, "revoked"); + info.rolloutState = getString(obj, "rollout_state"); + info.serial = getString(obj, SERIAL); + info.image = getString(obj, IMAGE); + info.syncWindow = getInt(obj, "sync_window"); + info.tokenType = getString(obj, "tokentype"); + info.userEditable = getBoolean(obj, "user_editable"); + info.userID = getString(obj, "user_id"); + info.userRealm = getString(obj, "user_realm"); + info.username = getString(obj, USERNAME); + + JsonObject joInfo = obj.getAsJsonObject(INFO); + if (joInfo != null) + { + joInfo.entrySet().forEach(entry -> + { + if (entry.getKey() != null && entry.getValue() != null) + { + info.info.put(entry.getKey(), entry.getValue().getAsString()); + } + }); + } + + JsonArray arrRealms = obj.getAsJsonArray(REALMS); + if (arrRealms != null) + { + arrRealms.forEach(val -> + { + if (val != null) + { + info.realms.add(val.getAsString()); + } + }); + } + return info; + } + + /** + * Parse the response of /token/init into an object. + * + * @param serverResponse response of /token/init + * @return RolloutInfo object, might be null object if response is empty + */ + RolloutInfo parseRolloutInfo(String serverResponse) + { + RolloutInfo rinfo = new RolloutInfo(); + rinfo.raw = serverResponse; + rinfo.googleurl = new RolloutInfo.GoogleURL(); + rinfo.oathurl = new RolloutInfo.OATHURL(); + rinfo.otpkey = new RolloutInfo.OTPKey(); + + if (serverResponse == null || serverResponse.isEmpty()) + { + return rinfo; + } + + JsonObject obj; + try + { + obj = JsonParser.parseString(serverResponse).getAsJsonObject(); + + JsonObject result = obj.getAsJsonObject(RESULT); + JsonElement errElem = result.get(ERROR); + if (errElem != null && !errElem.isJsonNull()) + { + JsonObject errObj = result.getAsJsonObject(ERROR); + rinfo.error = new PIError(getInt(errObj, CODE), getString(errObj, MESSAGE)); + return rinfo; + } + + JsonObject detail = obj.getAsJsonObject("detail"); + if (detail != null) + { + JsonObject google = detail.getAsJsonObject("googleurl"); + if (google != null) + { + rinfo.googleurl.description = getString(google, "description"); + rinfo.googleurl.img = getString(google, "img"); + rinfo.googleurl.value = getString(google, "value"); + } + + JsonObject oath = detail.getAsJsonObject("oath"); + if (oath != null) + { + rinfo.oathurl.description = getString(oath, "description"); + rinfo.oathurl.img = getString(oath, "img"); + rinfo.oathurl.value = getString(oath, "value"); + } + + JsonObject otp = detail.getAsJsonObject("otpkey"); + if (otp != null) + { + rinfo.otpkey.description = getString(otp, "description"); + rinfo.otpkey.img = getString(otp, "img"); + rinfo.otpkey.value = getString(otp, "value"); + rinfo.otpkey.value_b32 = getString(otp, "value_b32"); + } + + rinfo.serial = getString(detail, "serial"); + rinfo.rolloutState = getString(detail, "rollout_state"); + } + } + catch (JsonSyntaxException | ClassCastException e) + { + privacyIDEA.error(e); + return rinfo; + } + + return rinfo; + } + + /** + * Parse the json string that is returned from the browser after signing the WebAuthnSignRequest into a map. + * The map contains the parameters with the corresponding keys ready to be sent to the server. + * + * @param json json string from the browser + * @return map + */ + Map parseWebAuthnSignResponse(String json) + { + Map params = new LinkedHashMap<>(); + JsonObject obj; + try + { + obj = JsonParser.parseString(json).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error("WebAuthn sign response has the wrong format: " + e.getLocalizedMessage()); + return null; + } + + params.put(CREDENTIALID, getString(obj, CREDENTIALID)); + params.put(CLIENTDATA, getString(obj, CLIENTDATA)); + params.put(SIGNATUREDATA, getString(obj, SIGNATUREDATA)); + params.put(AUTHENTICATORDATA, getString(obj, AUTHENTICATORDATA)); + + // The userhandle and assertionclientextension fields are optional + String userhandle = getString(obj, USERHANDLE); + if (!userhandle.isEmpty()) + { + params.put(USERHANDLE, userhandle); + } + String extensions = getString(obj, ASSERTIONCLIENTEXTENSIONS); + if (!extensions.isEmpty()) + { + params.put(ASSERTIONCLIENTEXTENSIONS, extensions); + } + return params; + } + + /** + * Parse the json string that is returned from the browser after signing the U2FSignRequest into a map. + * The map contains the parameters with the corresponding keys ready to be sent to the server. + * + * @param json json string from the browser + * @return map + */ + Map parseU2FSignResponse(String json) + { + Map params = new LinkedHashMap<>(); + JsonObject obj; + try + { + obj = JsonParser.parseString(json).getAsJsonObject(); + } + catch (JsonSyntaxException e) + { + privacyIDEA.error("U2F sign response has the wrong format: " + e.getLocalizedMessage()); + return null; + } + + params.put(CLIENTDATA, getString(obj, "clientData")); + params.put(SIGNATUREDATA, getString(obj, "signatureData")); + + return params; + } + + private boolean getBoolean(JsonObject obj, String name) + { + JsonPrimitive primitive = getPrimitiveOrNull(obj, name); + return primitive != null && primitive.isBoolean() && primitive.getAsBoolean(); + } + + private int getInt(JsonObject obj, String name) + { + JsonPrimitive primitive = getPrimitiveOrNull(obj, name); + return primitive != null && primitive.isNumber() ? primitive.getAsInt() : 0; + } + + private String getString(JsonObject obj, String name) + { + JsonPrimitive primitive = getPrimitiveOrNull(obj, name); + return primitive != null && primitive.isString() ? primitive.getAsString() : ""; + } + + private JsonPrimitive getPrimitiveOrNull(JsonObject obj, String name) + { + JsonPrimitive primitive = null; + try + { + primitive = obj.getAsJsonPrimitive(name); + } + catch (Exception e) + { + // Just catch the exception instead of checking to get some log + privacyIDEA.error("Cannot get " + name + " from JSON"); + privacyIDEA.error(e); + } + return primitive; + } +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConfig.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConfig.java new file mode 100644 index 00000000..0d494e41 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConfig.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +class PIConfig +{ + String serverURL; + String realm = ""; + boolean doSSLVerify = true; + String serviceAccountName = ""; + String serviceAccountPass = ""; + String serviceAccountRealm = ""; + boolean disableLog = false; + String userAgent; + int httpTimeoutMs = 30000; + + public PIConfig(String serverURL, String userAgent) + { + this.serverURL = serverURL; + this.userAgent = userAgent; + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConstants.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConstants.java new file mode 100644 index 00000000..b667fd15 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIConstants.java @@ -0,0 +1,111 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.Arrays; +import java.util.List; + +public class PIConstants +{ + private PIConstants() + { + } + + public static final String GET = "GET"; + public static final String POST = "POST"; + + // ENDPOINTS + public static final String ENDPOINT_AUTH = "/auth"; + public static final String ENDPOINT_TOKEN_INIT = "/token/init"; + public static final String ENDPOINT_TRIGGERCHALLENGE = "/validate/triggerchallenge"; + public static final String ENDPOINT_POLLTRANSACTION = "/validate/polltransaction"; + public static final String ENDPOINT_VALIDATE_CHECK = "/validate/check"; + public static final String ENDPOINT_TOKEN = "/token/"; + + public static final String HEADER_ORIGIN = "Origin"; + public static final String HEADER_AUTHORIZATION = "Authorization"; + public static final String HEADER_USER_AGENT = "User-Agent"; + + // TOKEN TYPES + public static final String TOKEN_TYPE_PUSH = "push"; + public static final String TOKEN_TYPE_OTP = "otp"; + public static final String TOKEN_TYPE_TOTP = "totp"; + public static final String TOKEN_TYPE_HOTP = "hotp"; + public static final String TOKEN_TYPE_WEBAUTHN = "webauthn"; + public static final String TOKEN_TYPE_U2F = "u2f"; + + // JSON KEYS + public static final String USERNAME = "username"; + public static final String USER = "user"; + public static final String PASSWORD = "password"; + public static final String PASS = "pass"; + public static final String SERIAL = "serial"; + public static final String TYPE = "type"; + public static final String TRANSACTION_ID = "transaction_id"; + public static final String REALM = "realm"; + public static final String REALMS = "realms"; + public static final String GENKEY = "genkey"; + public static final String RESULT = "result"; + public static final String VALUE = "value"; + public static final String TOKENS = "tokens"; + public static final String TOKEN = "token"; + public static final String PREFERRED_CLIENT_MODE = "preferred_client_mode"; + public static final String MESSAGE = "message"; + public static final String CLIENT_MODE = "client_mode"; + public static final String IMAGE = "image"; + public static final String MESSAGES = "messages"; + public static final String MULTI_CHALLENGE = "multi_challenge"; + public static final String ATTRIBUTES = "attributes"; + public static final String DETAIL = "detail"; + public static final String OTPLEN = "otplen"; + public static final String CODE = "code"; + public static final String ERROR = "error"; + public static final String STATUS = "status"; + public static final String JSONRPC = "jsonrpc"; + public static final String SIGNATURE = "signature"; + public static final String VERSION_NUMBER = "versionnumber"; + public static final String AUTHENTICATION = "authentication"; + public static final String ID = "id"; + public static final String MAXFAIL = "maxfail"; + public static final String INFO = "info"; + public static final String LOCKED = "locked"; + public static final String FAILCOUNT = "failcount"; + public static final String DESCRIPTION = "description"; + public static final String COUNT = "count"; + public static final String COUNT_WINDOW = "count_window"; + public static final String ACTIVE = "active"; + public static final String RESOLVER = "resolver"; + public static final String REVOKED = "revoked"; + public static final String SYNC_WINDOW = "sync_window"; + + // WebAuthn and U2F params + public static final String WEBAUTHN_SIGN_REQUEST = "webAuthnSignRequest"; + public static final String CREDENTIALID = "credentialid"; + public static final String CLIENTDATA = "clientdata"; + public static final String SIGNATUREDATA = "signaturedata"; + public static final String AUTHENTICATORDATA = "authenticatordata"; + public static final String USERHANDLE = "userhandle"; + public static final String ASSERTIONCLIENTEXTENSIONS = "assertionclientextensions"; + public static final String U2F_SIGN_REQUEST = "u2fSignRequest"; + + + // These will be excluded from url encoding + public static final List WEBAUTHN_PARAMETERS = Arrays.asList(CREDENTIALID, CLIENTDATA, SIGNATUREDATA, AUTHENTICATORDATA, USERHANDLE, + ASSERTIONCLIENTEXTENSIONS); + public static final List U2F_PARAMETERS = Arrays.asList(CLIENTDATA, SIGNATUREDATA); + +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIError.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIError.java new file mode 100644 index 00000000..093f6cf6 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIError.java @@ -0,0 +1,29 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public class PIError +{ + public PIError(int code, String message) + { + this.code = code; + this.message = message; + } + + public int code; + public String message; +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIResponse.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIResponse.java new file mode 100644 index 00000000..c880aad6 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PIResponse.java @@ -0,0 +1,176 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import com.google.gson.JsonSyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static org.privacyidea.PIConstants.TOKEN_TYPE_PUSH; +import static org.privacyidea.PIConstants.TOKEN_TYPE_U2F; +import static org.privacyidea.PIConstants.TOKEN_TYPE_WEBAUTHN; + +/** + * This class parses the JSON response of privacyIDEA into a POJO for easier access. + */ +public class PIResponse +{ + public String message = ""; + public String preferredClientMode = ""; + public List messages = new ArrayList<>(); + public List multichallenge = new ArrayList<>(); + public String transactionID = ""; + public String serial = ""; + public String image = ""; + public int id = 0; + public String jsonRPCVersion = ""; + public boolean status = false; + public boolean value = false; + public AuthenticationStatus authentication = AuthenticationStatus.NONE; + public String piVersion = ""; // e.g. 3.2.1 + public String rawMessage = ""; + public String signature = ""; + public String type = ""; // Type of token that was matching the request + public int otpLength = 0; + + public PIError error = null; + + public boolean pushAvailable() + { + return multichallenge.stream().anyMatch(c -> TOKEN_TYPE_PUSH.equals(c.getType())); + } + + /** + * Get the messages of all triggered push challenges reduced to a string to show on the push UI. + * + * @return messages of all push challenges combined + */ + public String pushMessage() + { + return reduceChallengeMessagesWhere(c -> TOKEN_TYPE_PUSH.equals(c.getType())); + } + + /** + * Get the messages of all token that require an input field (HOTP, TOTP, SMS, Email...) reduced to a single string + * to show with the input field. + * + * @return message string + */ + public String otpMessage() + { + // Any challenge that is not WebAuthn, U2F or Push is considered OTP + return reduceChallengeMessagesWhere(c -> !(TOKEN_TYPE_PUSH.equals(c.getType()))); + } + + private String reduceChallengeMessagesWhere(Predicate predicate) + { + StringBuilder sb = new StringBuilder(); + sb.append(multichallenge.stream().filter(predicate).map(Challenge::getMessage).distinct().reduce("", (a, s) -> a + s + ", ").trim()); + + if (sb.length() > 0) + { + sb.deleteCharAt(sb.length() - 1); + } + + return sb.toString(); + } + + /** + * @return list of token types that were triggered or an empty list + */ + public List triggeredTokenTypes() + { + return multichallenge.stream().map(Challenge::getType).distinct().collect(Collectors.toList()); + } + + /** + * Get all WebAuthn challenges from the multi_challenge. + * + * @return List of WebAuthn objects or empty list + */ + public List webAuthnSignRequests() + { + List ret = new ArrayList<>(); + multichallenge.stream().filter(c -> TOKEN_TYPE_WEBAUTHN.equals(c.getType())).collect(Collectors.toList()).forEach(c -> + { + if (c instanceof WebAuthn) + { + ret.add((WebAuthn) c); + } + }); + return ret; + } + + /** + * Return the SignRequest that contains the merged allowCredentials so that the SignRequest can be used with any device that + * is allowed to answer the SignRequest. + *

+ * Can return an empty string if an error occurred or if no WebAuthn challenges have been triggered. + * + * @return merged SignRequest or empty string. + */ + public String mergedSignRequest() + { + List webAuthnSignRequests = webAuthnSignRequests(); + if (webAuthnSignRequests.isEmpty()) + { + return ""; + } + if (webAuthnSignRequests.size() == 1) + { + return webAuthnSignRequests.get(0).signRequest(); + } + + WebAuthn webAuthn = webAuthnSignRequests.get(0); + List stringSignRequests = webAuthnSignRequests.stream().map(WebAuthn::signRequest).collect(Collectors.toList()); + + try + { + return JSONParser.mergeWebAuthnSignRequest(webAuthn, stringSignRequests); + } + catch (JsonSyntaxException e) + { + return ""; + } + } + + /** + * Get all U2F challenges from the multi_challenge. + * + * @return List of U2F objects or empty list + */ + public List u2fSignRequests() + { + List ret = new ArrayList<>(); + multichallenge.stream().filter(c -> TOKEN_TYPE_U2F.equals(c.getType())).collect(Collectors.toList()).forEach(c -> + { + if (c instanceof U2F) + { + ret.add((U2F) c); + } + }); + return ret; + } + + @Override + public String toString() + { + return rawMessage; + } +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/PrivacyIDEA.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PrivacyIDEA.java new file mode 100644 index 00000000..4081a0ad --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/PrivacyIDEA.java @@ -0,0 +1,694 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import static org.privacyidea.PIConstants.ENDPOINT_AUTH; +import static org.privacyidea.PIConstants.ENDPOINT_POLLTRANSACTION; +import static org.privacyidea.PIConstants.ENDPOINT_TOKEN; +import static org.privacyidea.PIConstants.ENDPOINT_TOKEN_INIT; +import static org.privacyidea.PIConstants.ENDPOINT_TRIGGERCHALLENGE; +import static org.privacyidea.PIConstants.ENDPOINT_VALIDATE_CHECK; +import static org.privacyidea.PIConstants.GENKEY; +import static org.privacyidea.PIConstants.GET; +import static org.privacyidea.PIConstants.HEADER_ORIGIN; +import static org.privacyidea.PIConstants.PASS; +import static org.privacyidea.PIConstants.PASSWORD; +import static org.privacyidea.PIConstants.POST; +import static org.privacyidea.PIConstants.REALM; +import static org.privacyidea.PIConstants.SERIAL; +import static org.privacyidea.PIConstants.TRANSACTION_ID; +import static org.privacyidea.PIConstants.TYPE; +import static org.privacyidea.PIConstants.USER; +import static org.privacyidea.PIConstants.USERNAME; + +/** + * This is the main class. It implements the common endpoints such as /validate/check as methods for easy usage. + * To create an instance of this class, use the nested PrivacyIDEA.Builder class. + */ +public class PrivacyIDEA implements Closeable +{ + private final PIConfig configuration; + private final IPILogger log; + private final IPISimpleLogger simpleLog; + private final Endpoint endpoint; + // Thread pool for connections + private final BlockingQueue queue = new ArrayBlockingQueue<>(1000); + private final ThreadPoolExecutor threadPool = new ThreadPoolExecutor(20, 20, 10, TimeUnit.SECONDS, queue); + final JSONParser parser; + // Responses from these endpoints will not be logged. The list can be overwritten. + private List logExcludedEndpoints = Arrays.asList(PIConstants.ENDPOINT_AUTH, + PIConstants.ENDPOINT_POLLTRANSACTION); //Collections.emptyList(); // + + private PrivacyIDEA(PIConfig configuration, IPILogger logger, IPISimpleLogger simpleLog) + { + this.log = logger; + this.simpleLog = simpleLog; + this.configuration = configuration; + this.endpoint = new Endpoint(this); + this.parser = new JSONParser(this); + this.threadPool.allowCoreThreadTimeOut(true); + } + + /** + * @see PrivacyIDEA#validateCheck(String, String, String, Map) + */ + public PIResponse validateCheck(String username, String pass) + { + return this.validateCheck(username, pass, null, Collections.emptyMap()); + } + + /** + * @see PrivacyIDEA#validateCheck(String, String, String, Map) + */ + public PIResponse validateCheck(String username, String pass, Map headers) + { + return this.validateCheck(username, pass, null, headers); + } + + /** + * @see PrivacyIDEA#validateCheck(String, String, String, Map) + */ + public PIResponse validateCheck(String username, String pass, String transactionId) + { + return this.validateCheck(username, pass, transactionId, Collections.emptyMap()); + } + + /** + * Send a request to validate/check with the given parameters. + * Which parameters to send depends on the use case and how privacyIDEA is configured. + * (E.g. this can also be used to trigger challenges without a service account) + * + * @param username username + * @param pass pass/otp value + * @param transactionId optional, will be appended if set + * @param headers optional headers for the request + * @return PIResponse object containing the response or null if error + */ + public PIResponse validateCheck(String username, String pass, String transactionId, Map headers) + { + return getPIResponse(USER, username, pass, headers, transactionId); + } + + /** + * @see PrivacyIDEA#validateCheckSerial(String, String, String, Map) + */ + public PIResponse validateCheckSerial(String serial, String pass) + { + return this.validateCheckSerial(serial, pass, null, Collections.emptyMap()); + } + + /** + * @see PrivacyIDEA#validateCheckSerial(String, String, String, Map) + */ + public PIResponse validateCheckSerial(String serial, String pass, Map headers) + { + return this.validateCheckSerial(serial, pass, null, headers); + } + + /** + * @see PrivacyIDEA#validateCheckSerial(String, String, String, Map) + */ + public PIResponse validateCheckSerial(String serial, String pass, String transactionId) + { + return this.validateCheckSerial(serial, pass, transactionId, Collections.emptyMap()); + } + + /** + * Send a request to /validate/check with the serial rather than the username to identify exact token. + * + * @param serial serial of the token + * @param pass pass/otp value + * @param transactionId transactionId + * @return PIResponse or null if error + */ + public PIResponse validateCheckSerial(String serial, String pass, String transactionId, Map headers) + { + return getPIResponse(SERIAL, serial, pass, headers, transactionId); + } + + /** + * Used by validateCheck and validateCheckSerial to get the PI Response. + * + * @param type distinguish between user and serial to set forwarded input to the right PI-request param + * @param input forwarded username for classic validateCheck or serial to trigger exact token + * @param pass OTP, PIN+OTP or password to use + * @param headers optional headers for the request + * @param transactionId optional, will be appended if set + * @return PIResponse object containing the response or null if error + */ + private PIResponse getPIResponse(String type, String input, String pass, Map headers, String transactionId) + { + Map params = new LinkedHashMap<>(); + // Add forwarded user or serial to the params + params.put(type, input); + params.put(PASS, (pass != null ? pass : "")); + appendRealm(params); + if (transactionId != null && !transactionId.isEmpty()) + { + params.put(TRANSACTION_ID, transactionId); + } + String response = runRequestAsync(ENDPOINT_VALIDATE_CHECK, params, headers, false, POST); + return this.parser.parsePIResponse(response); + } + + /** + * @see PrivacyIDEA#validateCheckWebAuthn(String, String, String, String, Map) + */ + public PIResponse validateCheckWebAuthn(String user, String transactionId, String signResponse, String origin) + { + return this.validateCheckWebAuthn(user, transactionId, signResponse, origin, Collections.emptyMap()); + } + + /** + * Sends a request to /validate/check with the data required to authenticate a WebAuthn token. + * + * @param user username + * @param transactionId transactionId + * @param webAuthnSignResponse the WebAuthnSignResponse as returned from the browser + * @param origin server name that was used for + * @param headers optional headers for the request + * @return PIResponse or null if error + */ + public PIResponse validateCheckWebAuthn(String user, String transactionId, String webAuthnSignResponse, String origin, Map headers) + { + Map params = new LinkedHashMap<>(); + // Standard validateCheck data + params.put(USER, user); + params.put(TRANSACTION_ID, transactionId); + params.put(PASS, ""); + appendRealm(params); + + // Additional WebAuthn data + Map wanParams = parser.parseWebAuthnSignResponse(webAuthnSignResponse); + params.putAll(wanParams); + + Map hdrs = new LinkedHashMap<>(); + hdrs.put(HEADER_ORIGIN, origin); + hdrs.putAll(headers); + + String response = runRequestAsync(ENDPOINT_VALIDATE_CHECK, params, hdrs, false, POST); + return this.parser.parsePIResponse(response); + } + + /** + * @see PrivacyIDEA#validateCheckU2F(String, String, String, Map) + */ + public PIResponse validateCheckU2F(String user, String transactionId, String signResponse) + { + return this.validateCheckU2F(user, transactionId, signResponse, Collections.emptyMap()); + } + + /** + * Sends a request to /validate/check with the data required to authenticate a U2F token. + * + * @param user username + * @param transactionId transactionId + * @param u2fSignResponse the U2F Sign Response as returned from the browser + * @return PIResponse or null if error + */ + public PIResponse validateCheckU2F(String user, String transactionId, String u2fSignResponse, Map headers) + { + Map params = new LinkedHashMap<>(); + // Standard validateCheck data + params.put(USER, user); + params.put(TRANSACTION_ID, transactionId); + params.put(PASS, ""); + appendRealm(params); + + // Additional U2F data + Map u2fParams = parser.parseU2FSignResponse(u2fSignResponse); + params.putAll(u2fParams); + + String response = runRequestAsync(ENDPOINT_VALIDATE_CHECK, params, headers, false, POST); + return this.parser.parsePIResponse(response); + } + + /** + * @see PrivacyIDEA#triggerChallenges(String, Map) + */ + public PIResponse triggerChallenges(String username) + { + return this.triggerChallenges(username, new LinkedHashMap<>()); + } + + /** + * Trigger all challenges for the given username. This requires a service account to be set. + * + * @param username username to trigger challenges for + * @param headers optional headers for the request + * @return the server response or null if error + */ + public PIResponse triggerChallenges(String username, Map headers) + { + Objects.requireNonNull(username, "Username is required!"); + + if (!serviceAccountAvailable()) + { + log("No service account configured. Cannot trigger challenges"); + return null; + } + Map params = new LinkedHashMap<>(); + params.put(USER, username); + appendRealm(params); + + String response = runRequestAsync(ENDPOINT_TRIGGERCHALLENGE, params, headers, true, POST); + return this.parser.parsePIResponse(response); + } + + /** + * Poll for status of the given transaction ID once. + * + * @param transactionId transaction ID to poll for + * @return the status value, true or false + */ + public boolean pollTransaction(String transactionId) + { + Objects.requireNonNull(transactionId, "TransactionID is required!"); + + String response = runRequestAsync(ENDPOINT_POLLTRANSACTION, Collections.singletonMap(TRANSACTION_ID, transactionId), Collections.emptyMap(), + false, GET); + PIResponse piresponse = this.parser.parsePIResponse(response); + return piresponse.value; + } + + /** + * Get the auth token from the /auth endpoint using the service account. + * + * @return auth token or null. + */ + public String getAuthToken() + { + if (!serviceAccountAvailable()) + { + error("Cannot retrieve auth token without service account!"); + return null; + } + String response = runRequestAsync(ENDPOINT_AUTH, serviceAccountParam(), Collections.emptyMap(), false, POST); + return parser.extractAuthToken(response); + } + + Map serviceAccountParam() + { + Map authTokenParams = new LinkedHashMap<>(); + authTokenParams.put(USERNAME, configuration.serviceAccountName); + authTokenParams.put(PASSWORD, configuration.serviceAccountPass); + + if (configuration.serviceAccountRealm != null && !configuration.serviceAccountRealm.isEmpty()) + { + authTokenParams.put(REALM, configuration.serviceAccountRealm); + } + else if (configuration.realm != null && !configuration.realm.isEmpty()) + { + authTokenParams.put(REALM, configuration.realm); + } + return authTokenParams; + } + + /** + * Retrieve information about the users tokens. This requires a service account to be set. + * + * @param username username to get info for + * @return possibly empty list of TokenInfo or null if failure + */ + public List getTokenInfo(String username) + { + Objects.requireNonNull(username); + if (!serviceAccountAvailable()) + { + error("Cannot retrieve token info without service account!"); + return null; + } + + String response = runRequestAsync(ENDPOINT_TOKEN, Collections.singletonMap(USER, username), new LinkedHashMap<>(), true, GET); + return parser.parseTokenInfoList(response); + } + + /** + * Enroll a new token of the specified type for the specified user. + * This requires a service account to be set. Currently, only HOTP and TOTP type token are supported. + * + * @param username username + * @param typeToEnroll token type to enroll + * @return RolloutInfo which contains all info for the token or null if error + */ + public RolloutInfo tokenRollout(String username, String typeToEnroll) + { + if (!serviceAccountAvailable()) + { + error("Cannot do rollout without service account!"); + return null; + } + + Map params = new LinkedHashMap<>(); + params.put(USER, username); + params.put(TYPE, typeToEnroll); + params.put(GENKEY, "1"); // Let the server generate the secret + + String response = runRequestAsync(ENDPOINT_TOKEN_INIT, params, new LinkedHashMap<>(), true, POST); + + return parser.parseRolloutInfo(response); + } + + private void appendRealm(Map params) + { + if (configuration.realm != null && !configuration.realm.isEmpty()) + { + params.put(REALM, configuration.realm); + } + } + + /** + * Run a request in a thread of the thread pool. Then join that thread to the one that was calling this method. + * If the server takes longer to answer a request, the other requests do not have to wait. + * + * @param path path to the endpoint of the privacyIDEA server + * @param params request parameters + * @param headers request headers + * @param authTokenRequired whether an auth token should be acquired prior to the request + * @param method http request method + * @return response of the server as string or null + */ + private String runRequestAsync(String path, Map params, Map headers, boolean authTokenRequired, String method) + { + Callable callable = new AsyncRequestCallable(this, endpoint, path, params, headers, authTokenRequired, method); + Future future = threadPool.submit(callable); + String response = null; + try + { + response = future.get(); + } + catch (InterruptedException | ExecutionException e) + { + log("runRequestAsync: " + e.getLocalizedMessage()); + } + return response; + } + + /** + * @return list of endpoints for which the response is not printed + */ + public List logExcludedEndpoints() + { + return this.logExcludedEndpoints; + } + + /** + * @param list list of endpoints for which the response should not be printed + */ + public void logExcludedEndpoints(List list) + { + this.logExcludedEndpoints = list; + } + + public boolean serviceAccountAvailable() + { + return configuration.serviceAccountName != null && !configuration.serviceAccountName.isEmpty() && configuration.serviceAccountPass != null && + !configuration.serviceAccountPass.isEmpty(); + } + + PIConfig configuration() + { + return configuration; + } + + /** + * Pass the message to the appropriate logger implementation. + * + * @param message message to log. + */ + void error(String message) + { + if (!configuration.disableLog) + { + if (this.log != null) + { + this.log.error(message); + } + else if (this.simpleLog != null) + { + this.simpleLog.pilog(message); + } + else + { + System.err.println(message); + } + } + } + + /** + * Pass the error to the appropriate logger implementation. + * + * @param e error to log. + */ + void error(Throwable e) + { + if (!configuration.disableLog) + { + if (this.log != null) + { + this.log.error(e); + } + else if (this.simpleLog != null) + { + this.simpleLog.pilog(e.getMessage()); + } + else + { + System.err.println(e.getLocalizedMessage()); + } + } + } + + /** + * Pass the message to the appropriate logger implementation. + * + * @param message message to log. + */ + void log(String message) + { + if (!configuration.disableLog) + { + if (this.log != null) + { + this.log.log(message); + } + else if (this.simpleLog != null) + { + this.simpleLog.pilog(message); + } + else + { + System.out.println(message); + } + } + } + + /** + * Pass the error to the appropriate logger implementation. + * + * @param e error to log. + */ + void log(Throwable e) + { + if (!configuration.disableLog) + { + if (this.log != null) + { + this.log.log(e); + } + else if (this.simpleLog != null) + { + this.simpleLog.pilog(e.getMessage()); + } + else + { + System.out.println(e.getLocalizedMessage()); + } + } + } + + @Override + public void close() throws IOException + { + this.threadPool.shutdown(); + } + + /** + * Get a new Builder to create a PrivacyIDEA instance. + * + * @param serverURL url of the privacyIDEA server. + * @param userAgent userAgent of the plugin using the java-client. + * @return Builder + */ + public static Builder newBuilder(String serverURL, String userAgent) + { + return new Builder(serverURL, userAgent); + } + + public static class Builder + { + private final String serverURL; + private final String userAgent; + private String realm = ""; + private boolean doSSLVerify = true; + private String serviceAccountName = ""; + private String serviceAccountPass = ""; + private String serviceAccountRealm = ""; + private IPILogger logger = null; + private boolean disableLog = false; + private IPISimpleLogger simpleLogBridge = null; + private int httpTimeoutMs = 30000; + + /** + * @param serverURL the server URL is mandatory to communicate with privacyIDEA. + * @param userAgent the user agent that should be used in the http requests. Should refer to the plugin, something like "privacyIDEA-Keycloak" + */ + private Builder(String serverURL, String userAgent) + { + this.userAgent = userAgent; + this.serverURL = serverURL; + } + + /** + * Set a logger, which will receive log and error/throwable messages to be passed to the plugins log/error output. + * This implementation takes precedence over the IPISimpleLogger if both are set. + * + * @param logger ILoggerBridge implementation + * @return Builder + */ + public Builder logger(IPILogger logger) + { + this.logger = logger; + return this; + } + + /** + * Set a simpler logger implementation, which logs all messages as Strings. + * The IPILogger takes precedence over this if both are set. + * + * @param simpleLog IPISimpleLogger implementation + * @return Builder + */ + public Builder simpleLogger(IPISimpleLogger simpleLog) + { + this.simpleLogBridge = simpleLog; + return this; + } + + /** + * Set a realm that is appended to every request + * + * @param realm realm + * @return Builder + */ + public Builder realm(String realm) + { + this.realm = realm; + return this; + } + + /** + * Set whether to verify the peer when connecting. + * It is not recommended to set this to false in productive environments. + * + * @param sslVerify boolean + * @return Builder + */ + public Builder sslVerify(boolean sslVerify) + { + this.doSSLVerify = sslVerify; + return this; + } + + /** + * Set a service account, which can be used to trigger challenges etc. + * + * @param serviceAccountName account name + * @param serviceAccountPass account password + * @return Builder + */ + public Builder serviceAccount(String serviceAccountName, String serviceAccountPass) + { + this.serviceAccountName = serviceAccountName; + this.serviceAccountPass = serviceAccountPass; + return this; + } + + /** + * Set the realm for the service account if the account is found in a separate realm from the realm set in {@link Builder#realm(String)}. + * + * @param serviceAccountRealm realm of the service account + * @return Builder + */ + public Builder serviceRealm(String serviceAccountRealm) + { + this.serviceAccountRealm = serviceAccountRealm; + return this; + } + + /** + * Disable logging completely regardless of any set loggers. + * + * @return Builder + */ + public Builder disableLog() + { + this.disableLog = true; + return this; + } + + /** + * Set the timeout for http requests in milliseconds. + * @param httpTimeoutMs timeout in milliseconds + * @return Builder + */ + public Builder httpTimeoutMs(int httpTimeoutMs) + { + this.httpTimeoutMs = httpTimeoutMs; + return this; + } + + public PrivacyIDEA build() + { + PIConfig configuration = new PIConfig(serverURL, userAgent); + configuration.realm = realm; + configuration.doSSLVerify = doSSLVerify; + configuration.serviceAccountName = serviceAccountName; + configuration.serviceAccountPass = serviceAccountPass; + configuration.serviceAccountRealm = serviceAccountRealm; + configuration.disableLog = disableLog; + configuration.httpTimeoutMs = httpTimeoutMs; + return new PrivacyIDEA(configuration, logger, simpleLogBridge); + } + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/RolloutInfo.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/RolloutInfo.java new file mode 100644 index 00000000..8254ade4 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/RolloutInfo.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +/** + * This class parses the JSON response of privacyIDEA into a POJO for easier access. + */ +public class RolloutInfo +{ + public GoogleURL googleurl = new GoogleURL(); + public OATHURL oathurl = new OATHURL(); + public OTPKey otpkey = new OTPKey(); + public String raw = ""; + public String serial = ""; + public String rolloutState = ""; + + public PIError error = null; + + public static class GoogleURL + { + public String description = "", img = "", value = ""; + } + + public static class OATHURL + { + public String description = "", img = "", value = ""; + } + + public static class OTPKey + { + public String description = "", img = "", value = "", value_b32 = ""; + } +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/TokenInfo.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/TokenInfo.java new file mode 100644 index 00000000..228c2828 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/TokenInfo.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class parses the JSON response of privacyIDEA into a POJO for easier access. + */ +public class TokenInfo +{ + boolean active = false; + int count = 0; + int countWindow = 0; + String description = ""; + int failCount = 0; + int id = 0; + final Map info = new HashMap<>(); + boolean locked = false; + int maxFail = 0; + int otpLen = 0; + final List realms = new ArrayList<>(); + String resolver = ""; + boolean revoked = false; + String rolloutState = ""; + String serial = ""; + String image = ""; + int syncWindow = 0; + String tokenType = ""; + boolean userEditable = false; + String userID = ""; + String userRealm = ""; + String username = ""; + String rawJson = ""; +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/U2F.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/U2F.java new file mode 100644 index 00000000..22473a4c --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/U2F.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 NetKnights GmbH - lukas.matusiewicz@netknights.it + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public class U2F extends Challenge +{ + private final String signRequest; + + public U2F(String serial, String message, String client_mode, String image, String transaction_id, String signRequest) + { + super(serial, message, client_mode, image, transaction_id, PIConstants.TOKEN_TYPE_U2F); + this.signRequest = signRequest; + } + + /** + * Returns the U2FSignRequest in JSON format as a string, ready to use with pi-u2f.js. + * If this returns an empty string, it *might* indicate that the PIN of this token should be changed. + * + * @return sign request or empty string + */ + public String signRequest() + { + return signRequest; + } +} diff --git a/providers/privacyidea/java-client/src/main/java/org/privacyidea/WebAuthn.java b/providers/privacyidea/java-client/src/main/java/org/privacyidea/WebAuthn.java new file mode 100644 index 00000000..c4e7ea13 --- /dev/null +++ b/providers/privacyidea/java-client/src/main/java/org/privacyidea/WebAuthn.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public class WebAuthn extends Challenge +{ + private final String signRequest; + + public WebAuthn(String serial, String message, String client_mode, String image, String transaction_id, String signRequest) + { + super(serial, message, client_mode, image, transaction_id, PIConstants.TOKEN_TYPE_WEBAUTHN); + this.signRequest = signRequest; + } + + /** + * Returns the WebAuthnSignRequest in JSON format as a string, ready to use with pi-webauthn.js. + * If this returns an empty string, it *might* indicate that the PIN of this token should be changed. + * + * @return sign request or empty string + */ + public String signRequest() + { + return signRequest; + } +} diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/PILogImplementation.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/PILogImplementation.java new file mode 100644 index 00000000..9b5035d1 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/PILogImplementation.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 NetKnights GmbH - lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +public class PILogImplementation implements IPILogger +{ + @Override + public void log(String message) + { + System.out.println(message); + } + + @Override + public void error(String message) + { + System.out.println(message); + } + + @Override + public void log(Throwable t) + { + t.printStackTrace(); + } + + @Override + public void error(Throwable t) + { + t.printStackTrace(); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestGetTokenInfo.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestGetTokenInfo.java new file mode 100644 index 00000000..fb002e50 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestGetTokenInfo.java @@ -0,0 +1,134 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestGetTokenInfo +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + private final String username = "Test"; + private final String authToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicmVhbG0iOiIiLCJub25jZSI6IjVjOTc4NWM5OWU"; + private final String serviceAccount = "admin"; + private final String servicePassword = "admin"; + private final String serviceRealm = "realm"; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .serviceAccount(serviceAccount, servicePassword) + .serviceRealm(serviceRealm) + .disableLog() + .httpTimeoutMs(15000) + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + } + + @Test + public void testSuccess() + { + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_AUTH) + .withMethod("POST") + .withBody("username=" + serviceAccount + "&password=" + servicePassword + "&realm=" + serviceRealm)) + .respond(HttpResponse.response() + // This response is simplified because it is very long and contains info that is not (yet) processed anyway + .withBody(Utils.postAuthSuccessResponse())); + + mockServer.when(HttpRequest.request() + .withMethod("GET") + .withQueryStringParameter("user", username) + .withPath(PIConstants.ENDPOINT_TOKEN) + .withHeader("Authorization", authToken)).respond(HttpResponse.response().withBody(Utils.getTokenResponse())); + + List tokenInfoList = privacyIDEA.getTokenInfo(username); + assertNotNull(tokenInfoList); + assertEquals(tokenInfoList.size(), 1); + + TokenInfo tokenInfo = tokenInfoList.get(0); + assertTrue(tokenInfo.active); + assertEquals(2, tokenInfo.count); + assertEquals(10, tokenInfo.countWindow); + assertEquals("", tokenInfo.description); + assertEquals(0, tokenInfo.failCount); + assertEquals(347, tokenInfo.id); + assertFalse(tokenInfo.locked); + assertEquals(10, tokenInfo.maxFail); + assertEquals(6, tokenInfo.otpLen); + assertEquals("deflocal", tokenInfo.resolver); + assertFalse(tokenInfo.revoked); + assertEquals("", tokenInfo.rolloutState); + assertEquals("OATH00123564", tokenInfo.serial); + assertEquals(1000, tokenInfo.syncWindow); + assertEquals("hotp", tokenInfo.tokenType); + assertFalse(tokenInfo.userEditable); + assertEquals("5", tokenInfo.userID); + assertEquals("defrealm", tokenInfo.userRealm); + assertEquals("Test", tokenInfo.username); + + assertEquals(authToken, privacyIDEA.getAuthToken()); + } + + @Test + public void testForNoToken() + { + mockServer.when(HttpRequest.request() + .withMethod("GET") + .withQueryStringParameter("user", "Test") + .withPath(PIConstants.ENDPOINT_TOKEN) + .withHeader("Authorization", authToken)) + .respond(HttpResponse.response().withBody(Utils.getTokenNoTokenResponse())); + + List tokenInfoList = privacyIDEA.getTokenInfo(username); + assertNull(tokenInfoList); + } + + @Test + public void testNoServiceAccount() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test").sslVerify(false).logger(new PILogImplementation()).build(); + + List tokenInfoList = privacyIDEA.getTokenInfo(username); + + assertNull(tokenInfoList); + + assertNull(privacyIDEA.getAuthToken()); + } + + @After + public void teardown() + { + mockServer.stop(); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestPollTransaction.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestPollTransaction.java new file mode 100644 index 00000000..6e85eda8 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestPollTransaction.java @@ -0,0 +1,175 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.matchers.Times; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.model.MediaType; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class TestPollTransaction +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + private final String username = "testuser"; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .simpleLogger(System.out::println) + .build(); + } + + @Test + public void testPushSynchronous() throws InterruptedException + { + // Set the initial "challenges triggered" response, pass is empty here + // How the challenge is triggered depends on the configuration of the privacyIDEA server + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=")) + .respond(HttpResponse.response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody(Utils.pollGetChallenges()) + .withDelay(TimeUnit.MILLISECONDS, 50)); + + PIResponse initialResponse = privacyIDEA.validateCheck(username, null); + + // Check the triggered challenges - the other things are already tested in org.privacyidea.TestOTP + List challenges = initialResponse.multichallenge; + + Challenge hotpChallenge = challenges.stream() + .filter(c -> c.getSerial().equals("OATH00020121")) + .findFirst() + .orElse(null); + assertNotNull(hotpChallenge); + assertEquals("Bitte geben Sie einen OTP-Wert ein: ", hotpChallenge.getMessage()); + assertEquals("02659936574063359702", hotpChallenge.getTransactionID()); + assertEquals("hotp", hotpChallenge.getType()); + assertEquals("", hotpChallenge.getImage()); + assertTrue(hotpChallenge.getAttributes().isEmpty()); + + assertEquals("push", initialResponse.preferredClientMode); + + Challenge pushChallenge = challenges.stream() + .filter(c -> c.getSerial().equals("PIPU0001F75E")) + .findFirst() + .orElse(null); + assertNotNull(pushChallenge); + assertEquals("Please confirm the authentication on your mobile device!", pushChallenge.getMessage()); + assertEquals("02659936574063359702", pushChallenge.getTransactionID()); + assertEquals("push", pushChallenge.getType()); + assertTrue(pushChallenge.getAttributes().isEmpty()); + + String imagePush = ""; + for (Challenge c : challenges) + { + if ("push".equals(c.getType())) + { + if (!c.getImage().isEmpty()) + { + imagePush = c.getImage(); + } + } + } + assertEquals("dataimage", imagePush); + + List triggeredTypes = initialResponse.triggeredTokenTypes(); + assertTrue(triggeredTypes.contains("push")); + assertTrue(triggeredTypes.contains("hotp")); + + assertEquals(2, initialResponse.messages.size()); + + // Set the server up to respond to the polling requests twice with false + setPollTransactionResponse(false, 2); + + // Polling is controlled by the code using the sdk + for (int i = 0; i < 2; i++) + { + assertFalse(privacyIDEA.pollTransaction(initialResponse.transactionID)); + Thread.sleep(500); + } + + // Set the server to respond with true + setPollTransactionResponse(true, 1); + assertTrue(privacyIDEA.pollTransaction(initialResponse.transactionID)); + + // Now the transaction has to be finalized manually + setFinalizationResponse(initialResponse.transactionID); + + PIResponse response = privacyIDEA.validateCheck(username, null, initialResponse.transactionID); + assertTrue(response.value); + + //push side functions + boolean pushAvailable = response.pushAvailable(); + assertFalse(pushAvailable); + String pushMessage = response.pushMessage(); + assertEquals("", pushMessage); + } + + private void setFinalizationResponse(String transactionID) + { + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=&transaction_id=" + transactionID)) + .respond(HttpResponse.response() + .withBody(Utils.foundMatchingChallenge())); + } + + private void setPollTransactionResponse(boolean value, int times) + { + String val = value ? "true" : "false"; + mockServer.when(HttpRequest.request() + .withMethod("GET") + .withPath("/validate/polltransaction") + .withQueryStringParameter("transaction_id", "02659936574063359702"), Times.exactly(times)) + .respond(HttpResponse.response() + .withBody("{\n" + " \"id\": 1,\n" + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + " \"status\": true,\n" + + " \"value\": " + val + "\n" + " },\n" + + " \"time\": 1589446811.1909237,\n" + + " \"version\": \"privacyIDEA 3.2.1\",\n" + + " \"versionnumber\": \"3.2.1\",\n" + + " \"signature\": \"rsa_sha256_pss:\"\n" + "}") + .withDelay(TimeUnit.MILLISECONDS, 50)); + } + + + @After + public void tearDown() + { + mockServer.stop(); + } +} diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestRollout.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestRollout.java new file mode 100644 index 00000000..38c6dbd5 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestRollout.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.Header; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestRollout +{ + private PrivacyIDEA privacyIDEA; + private ClientAndServer mockServer; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .serviceAccount("admin", "admin") + .logger(new PILogImplementation()) + .build(); + } + + @Test + public void testSuccess() + { + String authToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicmVhbG0iOiIiLCJub25jZSI6IjVjOTc4NWM5OWU"; + + String img = "data:image/png;base64,iVBdgfgsdfgRK5CYII="; + + mockServer.when(HttpRequest.request().withPath(PIConstants.ENDPOINT_AUTH).withMethod("POST").withBody("")) + .respond(HttpResponse.response() + // This response is simplified because it is very long and contains info that is not (yet) processed anyway + .withBody(Utils.postAuthSuccessResponse())); + + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_TOKEN_INIT) + .withMethod("POST") + .withHeader(Header.header("Authorization", authToken))) + .respond(HttpResponse.response() + .withBody(Utils.rolloutSuccess())); + + RolloutInfo rolloutInfo = privacyIDEA.tokenRollout("games", "hotp"); + + assertEquals(img, rolloutInfo.googleurl.img); + assertNotNull(rolloutInfo.googleurl.description); + assertNotNull(rolloutInfo.googleurl.value); + + assertNotNull(rolloutInfo.otpkey.description); + assertNotNull(rolloutInfo.otpkey.value); + assertNotNull(rolloutInfo.otpkey.img); + assertNotNull(rolloutInfo.otpkey.value_b32); + + assertNotNull(rolloutInfo.oathurl.value); + assertNotNull(rolloutInfo.oathurl.description); + assertNotNull(rolloutInfo.oathurl.img); + + assertNotNull(rolloutInfo.serial); + assertTrue(rolloutInfo.rolloutState.isEmpty()); + } + + @Test + public void testNoServiceAccount() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + + RolloutInfo rolloutInfo = privacyIDEA.tokenRollout("games", "hotp"); + + assertNull(rolloutInfo); + } + + @Test + public void testRolloutViaValidateCheck() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + + String image = "data:image/png;base64,iVBdgfgsdfgRK5CYII="; + String username = "testuser"; + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=" + username + "&pass=")) + .respond(HttpResponse.response().withBody(Utils.rolloutViaChallenge())); + + PIResponse responseValidateCheck = privacyIDEA.validateCheck(username, ""); + + assertEquals(image, responseValidateCheck.image); + } + + @After + public void teardown() + { + mockServer.stop(); + } +} diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestTriggerChallenge.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestTriggerChallenge.java new file mode 100644 index 00000000..2b6eecb8 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestTriggerChallenge.java @@ -0,0 +1,145 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestTriggerChallenge +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + String serviceAccount = "service"; + String servicePass = "pass"; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .serviceAccount(serviceAccount, servicePass) + .logger(new PILogImplementation()) + .realm("realm") + .build(); + } + + @Test + public void testTriggerChallengeSuccess() + { + mockServer.when(HttpRequest.request().withPath(PIConstants.ENDPOINT_AUTH).withMethod("POST").withBody("")) + .respond(HttpResponse.response() + // This response is simplified because it is very long and contains info that is not (yet) processed anyway + .withBody(Utils.postAuthSuccessResponse())); + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_TRIGGERCHALLENGE) + .withMethod("POST") + .withBody("user=testuser&realm=realm")) + .respond(HttpResponse.response().withBody(Utils.triggerChallengeSuccess())); + + String username = "testuser"; + PIResponse responseTriggerChallenge = privacyIDEA.triggerChallenges(username); + + assertEquals("otp", responseTriggerChallenge.preferredClientMode); + assertEquals(1, responseTriggerChallenge.id); + assertEquals("BittegebenSieeinenOTP-Wertein:", responseTriggerChallenge.message); + assertEquals("2.0", responseTriggerChallenge.jsonRPCVersion); + assertEquals("3.6.3", responseTriggerChallenge.piVersion); + assertEquals("rsa_sha256_pss:4b0f0e12c2...89409a2e65c87d27b", responseTriggerChallenge.signature); + // Trim all whitespaces, newlines + assertEquals(Utils.triggerChallengeSuccess().replaceAll("[\n\r]", ""), responseTriggerChallenge.rawMessage.replaceAll("[\n\r]", "")); + assertEquals(Utils.triggerChallengeSuccess().replaceAll("[\n\r]", ""), responseTriggerChallenge.toString().replaceAll("[\n\r]", "")); + // result + assertTrue(responseTriggerChallenge.status); + assertFalse(responseTriggerChallenge.value); + + List challenges = responseTriggerChallenge.multichallenge; + String imageTOTP = ""; + for (Challenge c : challenges) + { + if ("totp".equals(c.getType())) + { + if (!c.getImage().isEmpty()) + { + imageTOTP = c.getImage(); + } + } + } + assertEquals("dataimage", imageTOTP); + } + + @Test + public void testNoServiceAccount() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + + PIResponse responseTriggerChallenge = privacyIDEA.triggerChallenges("Test"); + + assertNull(responseTriggerChallenge); + } + + @Test + public void testWrongServerURL() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://12ds7:1nvcbn080", "test") + .sslVerify(false) + .serviceAccount(serviceAccount, servicePass) + .logger(new PILogImplementation()) + .realm("realm") + .build(); + + PIResponse responseTriggerChallenge = privacyIDEA.triggerChallenges("Test"); + + assertNull(responseTriggerChallenge); + } + + @Test + public void testNoUsername() + { + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .serviceAccount(serviceAccount, servicePass) + .logger(new PILogImplementation()) + .realm("realm") + .build(); + + PIResponse responseTriggerChallenge = privacyIDEA.triggerChallenges(""); + + assertNull(responseTriggerChallenge); + } + + @After + public void tearDown() + { + mockServer.stop(); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestU2F.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestU2F.java new file mode 100644 index 00000000..b02d2082 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestU2F.java @@ -0,0 +1,168 @@ +/* + * Copyright 2023 NetKnights GmbH - lukas.matusiewicz@netknights.it + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.privacyidea.PIConstants.TOKEN_TYPE_U2F; + +public class TestU2F +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + } + + @After + public void teardown() + { + mockServer.stop(); + } + + @Test + public void testTriggerU2F() + { + String u2fSignRequest = "{" + "\"appId\":\"https://ttype.u2f\"," + + "\"challenge\":\"TZKiB0VFFMFsnlz00lF5iCqtQduDJf56AeJAY_BT4NU\"," + + "\"keyHandle\":\"UUHmZ4BUFCrt7q88MhlQJYu4G5qB9l7ScjRRxA-M35cTH-uHWyMEpxs4WBzbkjlZqzZW1lC-jDdFd2pKDUsNnA\"," + + "\"version\":\"U2F_V2\"" + "}"; + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=Test&pass=test")) + .respond(HttpResponse.response().withBody(Utils.triggerU2FSuccess())); + + PIResponse response = privacyIDEA.validateCheck("Test", "test"); + + assertEquals(1, response.id); + assertEquals("Please confirm with your U2F token (Yubico U2F EE Serial 61730834)", response.message); + assertEquals(0, response.otpLength); + assertEquals("U2F00014651", response.serial); + assertEquals("u2f", response.type); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.6.3", response.piVersion); + assertEquals("rsa_sha256_pss:3e51d814...dccd5694b8c15943e37e1", response.signature); + assertTrue(response.status); + assertFalse(response.value); + + Optional opt = response.multichallenge.stream() + .filter(challenge -> TOKEN_TYPE_U2F.equals(challenge.getType())) + .findFirst(); + if (opt.isPresent()) + { + Challenge a = opt.get(); + if (a instanceof U2F) + { + U2F b = (U2F) a; + String trimmedRequest = u2fSignRequest.replaceAll("\n", "").replaceAll(" ", ""); + assertEquals(trimmedRequest, b.signRequest()); + } + else + { + fail(); + } + } + else + { + fail(); + } + } + + @Test + public void testSuccess() + { + String username = "Test"; + + String u2fSignResponse = "{\"clientData\":\"eyJjaGFsbGVuZ2UiOiJpY2UBc3NlcnRpb24ifQ\"," + "\"errorCode\":0," + + "\"keyHandle\":\"UUHmZ4BUFCrt7q88MhlQkjlZqzZW1lC-jDdFd2pKDUsNnA\"," + + "\"signatureData\":\"AQAAAxAwRQIgZwEObruoCRRo738F9up1tdV2M0H1MdP5pkO5Eg\"}"; + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=Test&transaction_id=16786665691788289392&pass=" + + "&clientdata=eyJjaGFsbGVuZ2UiOiJpY2UBc3NlcnRpb24ifQ" + + "&signaturedata=AQAAAxAwRQIgZwEObruoCRRo738F9up1tdV2M0H1MdP5pkO5Eg")) + .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); + + Map header = new HashMap<>(); + header.put("accept-language", "en"); + PIResponse response = privacyIDEA.validateCheckU2F(username, "16786665691788289392", u2fSignResponse, header); + + assertEquals(1, response.id); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + assertTrue(response.status); + assertTrue(response.value); + } + + @Test + public void testSuccessWithoutHeader() + { + String username = "Test"; + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=Test&transaction_id=16786665691788289392&pass=" + + "&clientdata=eyJjaGFsbGVuZ2UiOiJpY2UBc3NlcnRpb24ifQ" + + "&signaturedata=AQAAAxAwRQIgZwEObruoCRRo738F9up1tdV2M0H1MdP5pkO5Eg")) + .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); + + String u2fSignResponse = "{\"clientData\":\"eyJjaGFsbGVuZ2UiOiJpY2UBc3NlcnRpb24ifQ\"," + "\"errorCode\":0," + + "\"keyHandle\":\"UUHmZ4BUFCrt7q88MhlQkjlZqzZW1lC-jDdFd2pKDUsNnA\"," + + "\"signatureData\":\"AQAAAxAwRQIgZwEObruoCRRo738F9up1tdV2M0H1MdP5pkO5Eg\"}"; + + PIResponse response = privacyIDEA.validateCheckU2F(username, "16786665691788289392", u2fSignResponse); + + assertEquals(1, response.id); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + assertTrue(response.status); + assertTrue(response.value); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheck.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheck.java new file mode 100644 index 00000000..bc58ea5f --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheck.java @@ -0,0 +1,195 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.model.MediaType; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestValidateCheck +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + private final String username = "testuser"; + private final String otp = "123456"; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + } + + @Test + public void testOTPSuccess() + { + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=" + otp)) + .respond(HttpResponse.response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody(Utils.matchingOneToken()) + .withDelay(TimeUnit.MILLISECONDS, 50)); + + PIResponse response = privacyIDEA.validateCheck(username, otp); + + assertEquals(1, response.id); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + // Trim all whitespaces, newlines + assertEquals(Utils.matchingOneToken().replaceAll("[\n\r]", ""), response.rawMessage.replaceAll("[\n\r]", "")); + assertEquals(Utils.matchingOneToken().replaceAll("[\n\r]", ""), response.toString().replaceAll("[\n\r]", "")); + // result + assertTrue(response.status); + assertTrue(response.value); + } + + @Test + public void testOTPAddHeader() + { + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=" + otp)) + .respond(HttpResponse.response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody(Utils.matchingOneToken()) + .withDelay(TimeUnit.MILLISECONDS, 50)); + + Map header = new HashMap<>(); + header.put("accept-language", "en"); + PIResponse response = privacyIDEA.validateCheck(username, otp, header); + + assertEquals(1, response.id); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + // Trim all whitespaces, newlines + assertEquals(Utils.matchingOneToken().replaceAll("[\n\r]", ""), response.rawMessage.replaceAll("[\n\r]", "")); + assertEquals(Utils.matchingOneToken().replaceAll("[\n\r]", ""), response.toString().replaceAll("[\n\r]", "")); + // result + assertTrue(response.status); + assertTrue(response.value); + } + + @Test + public void testLostValues() + { + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=" + otp)) + .respond(HttpResponse.response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody(Utils.lostValues()) + .withDelay(TimeUnit.MILLISECONDS, 50)); + + PIResponse response = privacyIDEA.validateCheck(username, otp); + + assertEquals("", response.piVersion); + assertEquals("", response.message); + assertEquals(0, response.otpLength); + assertEquals(0, response.id); + assertEquals("", response.jsonRPCVersion); + assertEquals("", response.serial); + assertEquals("", response.type); + assertEquals("", response.signature); + } + + @Test + public void testEmptyResponse() + { + mockServer.when(HttpRequest.request() + .withMethod("POST") + .withPath("/validate/check") + .withBody("user=" + username + "&pass=" + otp)) + .respond(HttpResponse.response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody("") + .withDelay(TimeUnit.MILLISECONDS, 50)); + + PIResponse response = privacyIDEA.validateCheck(username, otp); + + // An empty response returns null + assertNull(response); + } + + @Test + public void testNoResponse() + { + // No server setup - server might be offline/unreachable etc + PIResponse response = privacyIDEA.validateCheck(username, otp); + + // No response also returns null - the exception is forwarded to the ILoggerBridge if set + assertNull(response); + } + + @Test + public void testUserNotFound() + { + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=TOTP0001AFB9&pass=12")) + .respond(HttpResponse.response().withStatusCode(400).withBody(Utils.errorUserNotFound())); + + String user = "TOTP0001AFB9"; + String pin = "12"; + + PIResponse response = privacyIDEA.validateCheck(user, pin); + + assertEquals(Utils.errorUserNotFound(), response.toString()); + assertEquals(1, response.id); + assertEquals("2.0", response.jsonRPCVersion); + assertFalse(response.status); + assertNotNull(response.error); + assertEquals("rsa_sha256_pss:1c64db29cad0dc127d6...5ec143ee52a7804ea1dc8e23ab2fc90ac0ac147c0", response.signature); + } + + @After + public void tearDown() + { + mockServer.stop(); + } +} \ No newline at end of file diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheckSerial.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheckSerial.java new file mode 100644 index 00000000..930d6fdd --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestValidateCheckSerial.java @@ -0,0 +1,103 @@ +/* + * Copyright 2023 NetKnights GmbH - lukas.matusiewicz@netknights.it + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class TestValidateCheckSerial +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test") + .sslVerify(false) + .logger(new PILogImplementation()) + .build(); + } + + @Test + public void testNoChallengeResponsePINPlusOTP() + { + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("serial=PISP0001C673&pass=123456")) + .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); + + String serial = "PISP0001C673"; + String pinPlusOTP = "123456"; + + PIResponse response = privacyIDEA.validateCheckSerial(serial, pinPlusOTP); + + assertEquals(Utils.matchingOneToken(), response.toString()); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals(1, response.id); + assertEquals("2.0", response.jsonRPCVersion); + assertTrue(response.status); + assertTrue(response.value); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + } + + @Test + public void testNoChallengeResponseTransactionID() + { + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("serial=PISP0001C673&pass=123456&transaction_id=12093809214")) + .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); + + String serial = "PISP0001C673"; + String pinPlusOTP = "123456"; + String transactionID = "12093809214"; + + PIResponse response = privacyIDEA.validateCheckSerial(serial, pinPlusOTP, transactionID); + + assertEquals(Utils.matchingOneToken(), response.toString()); + assertEquals("matching 1 tokens", response.message); + assertEquals(6, response.otpLength); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals(1, response.id); + assertEquals("2.0", response.jsonRPCVersion); + assertTrue(response.status); + assertTrue(response.value); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + } + + @After + public void teardown() + { + mockServer.stop(); + } +} diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestWebAuthn.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestWebAuthn.java new file mode 100644 index 00000000..38db2995 --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/TestWebAuthn.java @@ -0,0 +1,153 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License here: + * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea; + +import java.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.privacyidea.PIConstants.TOKEN_TYPE_WEBAUTHN; + +public class TestWebAuthn +{ + private ClientAndServer mockServer; + private PrivacyIDEA privacyIDEA; + + @Before + public void setup() + { + mockServer = ClientAndServer.startClientAndServer(1080); + + privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test").sslVerify(false).logger(new PILogImplementation()).build(); + } + + @After + public void teardown() + { + mockServer.stop(); + } + + @Test + public void testSuccess() + { + String webauthnSignResponse = + "{" + "\"credentialid\":\"X9FrwMfmzj...saw21\"," + "\"authenticatordata\":\"xGzvgAAACA\"," + + "\"clientdata\":\"eyJjaGFsbG...dfhs\"," + "\"signaturedata\":\"MEUCIQDNrG...43hc\"," + + "\"assertionclientextensions\":\"alsjdlfkjsadjeiw\"," + "\"userhandle\":\"jalsdkjflsjvccco2\"\n" + "}"; + + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=Test&transaction_id=16786665691788289392&pass=" + + "&credentialid=X9FrwMfmzj...saw21&clientdata=eyJjaGFsbG...dfhs&signaturedata=MEUCIQDNrG...43hc" + + "&authenticatordata=xGzvgAAACA&userhandle=jalsdkjflsjvccco2&assertionclientextensions=alsjdlfkjsadjeiw")) + .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); + + PIResponse response = privacyIDEA.validateCheckWebAuthn("Test", "16786665691788289392", webauthnSignResponse, "test.it"); + + assertNotNull(response); + assertEquals("matching 1 tokens", response.message); + assertEquals("PISP0001C673", response.serial); + assertEquals("totp", response.type); + assertEquals(1, response.id); + assertEquals("2.0", response.jsonRPCVersion); + assertEquals("3.2.1", response.piVersion); + assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); + assertEquals(6, response.otpLength); + assertTrue(response.status); + assertTrue(response.value); + } + + @Test + public void testTriggerWebAuthn() + { + String username = "Test"; + String pass = "Test"; + + mockServer.when( + HttpRequest.request().withPath(PIConstants.ENDPOINT_VALIDATE_CHECK).withMethod("POST").withBody("user=" + username + "&pass=" + pass)) + .respond(HttpResponse.response() + // This response is simplified because it is very long and contains info that is not (yet) processed anyway + .withBody(Utils.triggerWebauthn())); + + PIResponse response = privacyIDEA.validateCheck(username, pass); + + Optional opt = response.multichallenge.stream().filter(challenge -> TOKEN_TYPE_WEBAUTHN.equals(challenge.getType())).findFirst(); + assertTrue(opt.isPresent()); + assertEquals(AuthenticationStatus.CHALLENGE, response.authentication); + assertEquals("webauthn", response.preferredClientMode); + Challenge a = opt.get(); + if (a instanceof WebAuthn) + { + WebAuthn b = (WebAuthn) a; + String trimmedRequest = Utils.webauthnSignRequest().replaceAll("\n", "").replaceAll(" ", ""); + assertEquals(trimmedRequest, b.signRequest()); + assertEquals("static/img/FIDO-U2F-Security-Key-444x444.png", b.getImage()); + assertEquals("webauthn", b.getClientMode()); + } + else + { + fail(); + } + } + + @Test + public void testMergedSignRequestSuccess() + { + JSONParser jsonParser = new JSONParser(privacyIDEA); + PIResponse piResponse1 = jsonParser.parsePIResponse(Utils.multipleWebauthnResponse()); + String trimmedRequest = Utils.expectedMergedResponse().replaceAll("\n", "").replaceAll(" ", ""); + String merged1 = piResponse1.mergedSignRequest(); + + assertEquals(trimmedRequest, merged1); + + // short test otpMessage() + String otpMessage = piResponse1.otpMessage(); + + assertEquals("Please confirm with your WebAuthn token (FT BioPass FIDO2 USB), " + + "Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)", otpMessage); + } + + @Test + public void testMergedSignRequestEmpty() + { + JSONParser jsonParser = new JSONParser(privacyIDEA); + PIResponse piResponse1 = jsonParser.parsePIResponse(Utils.mergedSignRequestEmpty()); + String empty1 = piResponse1.mergedSignRequest(); + + assertEquals("", empty1); + } + + @Test + public void testMergedSignRequestIncompleteSignRequest() + { + JSONParser jsonParser = new JSONParser(privacyIDEA); + PIResponse piResponse1 = jsonParser.parsePIResponse(Utils.mergedSignRequestIncomplete()); + String trimmedRequest = Utils.expectedMergedResponseIncomplete().replaceAll("\n", "").replaceAll(" ", ""); + String merged1 = piResponse1.mergedSignRequest(); + + assertEquals(trimmedRequest, merged1); + } +} diff --git a/providers/privacyidea/java-client/src/test/java/org/privacyidea/Utils.java b/providers/privacyidea/java-client/src/test/java/org/privacyidea/Utils.java new file mode 100644 index 00000000..5d850c9e --- /dev/null +++ b/providers/privacyidea/java-client/src/test/java/org/privacyidea/Utils.java @@ -0,0 +1,354 @@ +package org.privacyidea; + +public class Utils +{ + private final static String authToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicmVhbG0iOiIiLCJub25jZSI6IjVjOTc4NWM5OWU"; + + public static String postAuthSuccessResponse() + { + return "{\n" + " \"id\": 1,\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + + " \"status\": true,\n" + + " \"value\": {\n" + + " \"log_level\": 20,\n" + + " \"menus\": [\n" + + " \"components\",\n" + + " \"machines\"\n" + + " ],\n" + + " \"realm\": \"\",\n" + + " \"rights\": [\n" + + " \"policydelete\",\n" + + " \"resync\"\n" + + " ],\n" + + " \"role\": \"admin\",\n" + + " \"token\": \"" + + authToken + "\",\n" + + " \"username\": \"admin\",\n" + + " \"logout_time\": 120,\n" + + " \"default_tokentype\": \"hotp\",\n" + + " \"user_details\": false,\n" + + " \"subscription_status\": 0\n" + + " }\n" + " },\n" + + " \"time\": 1589446794.8502703,\n" + + " \"version\": \"privacyIDEA 3.2.1\",\n" + + " \"versionnumber\": \"3.2.1\",\n" + + " \"signature\": \"rsa_sha256_pss:\"\n" + + "}"; + } + + public static String getTokenResponse() + { + return "{\"id\":1," + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":{" + + "\"count\":1," + "\"current\":1," + "\"tokens\":[{" + "\"active\":true," + "\"count\":2," + + "\"count_window\":10," + "\"description\":\"\"," + "\"failcount\":0," + "\"id\":347," + + "\"info\":{" + "\"count_auth\":\"1\"," + "\"count_auth_success\":\"1\"," + + "\"hashlib\":\"sha1\"," + "\"last_auth\":\"2022-03-2912:18:59.639421+02:00\"," + + "\"tokenkind\":\"software\"}," + "\"locked\":false," + "\"maxfail\":10," + "\"otplen\":6," + + "\"realms\":[\"defrealm\"]," + "\"resolver\":\"deflocal\"," + "\"revoked\":false," + + "\"rollout_state\":\"\"," + "\"serial\":\"OATH00123564\"," + "\"sync_window\":1000," + + "\"tokentype\":\"hotp\"," + "\"user_editable\":false," + "\"user_id\":\"5\"," + + "\"user_realm\":\"defrealm\"," + "\"username\":\"Test\"}]}}," + "\"time\":1648549489.57896," + + "\"version\":\"privacyIDEA3.6.3\"," + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:58c4eed1...5247c47e3e\"}"; + } + + public static String getTokenNoTokenResponse() + { + return "{\"id\":1," + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":{" + + "\"count\":0," + "\"current\":1," + "\"tokens\":[]}}," + "\"time\":1648548984.9165428," + + "\"version\":\"privacyIDEA3.6.3\"," + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:5295e005a48b0a915a1e37f80\"}"; + } + + public static String pollGetChallenges() + { + return "{\n" + " \"detail\": {\n" + + " \"preferred_client_mode\": \"poll\",\n" + + " \"attributes\": null,\n" + + " \"message\": \"Bitte geben Sie einen OTP-Wert ein: , Please confirm the authentication on your mobile device!\",\n" + + " \"messages\": [\n" + + " \"Bitte geben Sie einen OTP-Wert ein: \",\n" + + " \"Please confirm the authentication on your mobile device!\"\n" + + " ],\n" + " \"multi_challenge\": [\n" + " {\n" + + " \"attributes\": null,\n" + + " \"message\": \"Bitte geben Sie einen OTP-Wert ein: \",\n" + + " \"serial\": \"OATH00020121\",\n" + + " \"transaction_id\": \"02659936574063359702\",\n" + + " \"type\": \"hotp\"\n" + " },\n" + " {\n" + + " \"attributes\": null,\n" + + " \"message\": \"Please confirm the authentication on your mobile device!\",\n" + + " \"serial\": \"PIPU0001F75E\",\n" + + " \"image\": \"dataimage\",\n" + + " \"transaction_id\": \"02659936574063359702\",\n" + + " \"type\": \"push\"\n" + " }\n" + " ],\n" + + " \"serial\": \"PIPU0001F75E\",\n" + + " \"threadid\": 140040525666048,\n" + + " \"transaction_id\": \"02659936574063359702\",\n" + + " \"transaction_ids\": [\n" + " \"02659936574063359702\",\n" + + " \"02659936574063359702\"\n" + " ],\n" + + " \"type\": \"push\"\n" + " },\n" + " \"id\": 1,\n" + + " \"jsonrpc\": \"2.0\",\n" + " \"result\": {\n" + + " \"status\": true,\n" + " \"value\": false\n" + " },\n" + + " \"time\": 1589360175.594304,\n" + + " \"version\": \"privacyIDEA 3.2.1\",\n" + + " \"versionnumber\": \"3.2.1\",\n" + + " \"signature\": \"rsa_sha256_pss:AAAAAAAAAA\"\n" + "}"; + } + + public static String foundMatchingChallenge() + { + return "{\n" + " \"detail\": {\n" + + " \"message\": \"Found matching challenge\",\n" + + " \"serial\": \"PIPU0001F75E\",\n" + + " \"threadid\": 140586038396672\n" + " },\n" + + " \"id\": 1,\n" + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + " \"status\": true,\n" + + " \"value\": true\n" + " },\n" + + " \"time\": 1589446811.2747126,\n" + + " \"version\": \"privacyIDEA 3.2.1\",\n" + + " \"versionnumber\": \"3.2.1\",\n" + + " \"signature\": \"rsa_sha256_pss:\"\n" + "}"; + } + + public static String matchingOneToken() + { + return "{\n" + " \"detail\": {\n" + " \"message\": \"matching 1 tokens\",\n" + " \"otplen\": 6,\n" + + " \"serial\": \"PISP0001C673\",\n" + " \"threadid\": 140536383567616,\n" + + " \"type\": \"totp\"\n" + " },\n" + " \"id\": 1,\n" + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + " \"status\": true,\n" + " \"value\": true\n" + " },\n" + + " \"time\": 1589276995.4397042,\n" + " \"version\": \"privacyIDEA 3.2.1\",\n" + + " \"versionnumber\": \"3.2.1\",\n" + " \"signature\": \"rsa_sha256_pss:AAAAAAAAAAA\"\n" + "}"; + } + + public static String rolloutSuccess() + { + return "{\n" + " \"detail\": {\n" + " \"googleurl\": {\n" + + " \"description\": \"URL for google Authenticator\",\n" + + " \"img\": \"data:image/png;base64,iVBdgfgsdfgRK5CYII=\",\n" + + " \"value\": \"otpauth://hotp/OATH0003A0AA?secret=4DK5JEEQMWY3VES7EWB4M36TAW4YC2YH&counter=1&digits=6&issuer=privacyIDEA\"\n" + + " },\n" + " \"oathurl\": {\n" + + " \"description\": \"URL for OATH token\",\n" + + " \"img\": \"data:image/png;base64,iVBdgfgsdfgRK5CYII=\",\n" + + " \"value\": \"oathtoken:///addToken?name=OATH0003A0AA&lockdown=true&key=e0d5d4909065b1ba925f2583c66fd305b9816b07\"\n" + + " },\n" + " \"otpkey\": {\n" + + " \"description\": \"OTP seed\",\n" + + " \"img\": \"data:image/png;base64,iVBdgfgsdfgRK5CYII=\",\n" + + " \"value\": \"seed://e0d5d4909065b1ba925f2583c66fd305b9816b07\",\n" + + " \"value_b32\": \"4DK5JEEQMWY3VES7EWB4M36TAW4YC2YH\"\n" + + " },\n" + " \"rollout_state\": \"\",\n" + + " \"serial\": \"OATH0003A0AA\",\n" + + " \"threadid\": 140470638720768\n" + " },\n" + + " \"id\": 1,\n" + " \"jsonrpc\": \"2.0\",\n" + + " \"result\": {\n" + " \"status\": true,\n" + + " \"value\": true\n" + " },\n" + + " \"time\": 1592834605.532012,\n" + + " \"version\": \"privacyIDEA 3.3.3\",\n" + + " \"versionnumber\": \"3.3.3\",\n" + + " \"signature\": \"rsa_sha256_pss:\"\n" + "}"; + } + + public static String rolloutViaChallenge() + { + return "{\"detail\":{" + "\"attributes\":null," + "\"message\":\"BittegebenSieeinenOTP-Wertein:\"," + + "\"image\": \"data:image/png;base64,iVBdgfgsdfgRK5CYII=\",\n" + + "\"messages\":[\"BittegebenSieeinenOTP-Wertein:\"]," + "\"multi_challenge\":[{" + + "\"attributes\":null," + "\"message\":\"BittegebenSieeinenOTP-Wertein:\"," + + "\"serial\":\"TOTP00021198\"," + "\"transaction_id\":\"16734787285577957577\"," + + "\"type\":\"totp\"}]," + "\"serial\":\"TOTP00021198\"," + "\"threadid\":140050885818112," + + "\"transaction_id\":\"16734787285577957577\"," + + "\"transaction_ids\":[\"16734787285577957577\"]," + "\"type\":\"totp\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + + "\"time\":1649666174.5351279," + "\"version\":\"privacyIDEA3.6.3\"," + + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:4b0f0e12c2...89409a2e65c87d27b\"}"; + } + + public static String triggerChallengeSuccess() + { + return "{\"detail\":{" + "\"preferred_client_mode\":\"interactive\"," + "\"attributes\":null," + + "\"message\":\"BittegebenSieeinenOTP-Wertein:\"," + + "\"messages\":[\"BittegebenSieeinenOTP-Wertein:\"]," + "\"multi_challenge\":[{" + + "\"attributes\":null," + "\"message\":\"BittegebenSieeinenOTP-Wertein:\"," + + "\"serial\":\"TOTP00021198\"," + "\"transaction_id\":\"16734787285577957577\"," + + "\"image\":\"dataimage\"," + "\"type\":\"totp\"}]," + "\"serial\":\"TOTP00021198\"," + + "\"threadid\":140050885818112," + "\"transaction_id\":\"16734787285577957577\"," + + "\"transaction_ids\":[\"16734787285577957577\"]," + "\"type\":\"totp\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + + "\"time\":1649666174.5351279," + "\"version\":\"privacyIDEA3.6.3\"," + + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:4b0f0e12c2...89409a2e65c87d27b\"}"; + } + + public static String triggerU2FSuccess() + { + return "{" + "\"detail\":{" + "\"attributes\":{" + "\"hideResponseInput\":true," + + "\"img\":\"static/img/FIDO-U2F-Security-Key-444x444.png\"," + "\"u2fSignRequest\":{" + + "\"appId\":\"http//ttype.u2f\"," + + "\"challenge\":\"TZKiB0VFFMF...tQduDJf56AeJAY_BT4NU\"," + + "\"keyHandle\":\"UUHmZ4BUFCrt7q88MhlQ...qzZW1lC-jDdFd2pKDUsNnA\"," + + "\"version\":\"U2F_V2\"}}," + + "\"message\":\"Please confirm with your U2F token (Yubico U2F EE Serial 61730834)\"," + + "\"messages\":[\"Please confirm with your U2F token (Yubico U2F EE Serial 61730834)\"]," + + "\"multi_challenge\":[{" + "\"attributes\":{" + "\"hideResponseInput\":true," + + "\"img\":\"static/img/FIDO-U2F-Security-Key-444x444.png\"," + "\"u2fSignRequest\":{" + + "\"appId\":\"https://ttype.u2f\"," + + "\"challenge\":\"TZKiB0VFFMFsnlz00lF5iCqtQduDJf56AeJAY_BT4NU\"," + + "\"keyHandle\":\"UUHmZ4BUFCrt7q88MhlQJYu4G5qB9l7ScjRRxA-M35cTH-uHWyMEpxs4WBzbkjlZqzZW1lC-jDdFd2pKDUsNnA\"," + + "\"version\":\"U2F_V2\"}}," + + "\"message\":\"Please confirm with your U2F token (Yubico U2F EE Serial 61730834)\"," + + "\"serial\":\"U2F00014651\"," + "\"transaction_id\":\"12399202888279169736\"," + + "\"type\":\"u2f\"}]," + "\"serial\":\"U2F00014651\"," + "\"threadid\":140050978137856," + + "\"transaction_id\":\"12399202888279169736\"," + + "\"transaction_ids\":[\"12399202888279169736\"]," + "\"type\":\"u2f\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + + "\"time\":1649769348.7552881," + "\"version\":\"privacyIDEA 3.6.3\"," + + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:3e51d814...dccd5694b8c15943e37e1\"}"; + } + + public static String lostValues() + { + return "{\n" + " \"detail\": {\n" + " \"threadid\": 140536383567616,\n" + " \"result\": {\n" + + " \"status\": true,\n" + " \"value\": true\n" + " },\n" + " \"time\": 1589276995.4397042,\n" + + " \"version\": \"privacyIDEA None\",\n" + "}"; + } + + public static String errorUserNotFound() + { + return "{" + "\"detail\":null," + "\"id\":1," + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"error\":{" + + "\"code\":904," + "\"message\":\"ERR904: The user can not be found in any resolver in this realm!\"}," + + "\"status\":false}," + "\"time\":1649752303.65651," + "\"version\":\"privacyIDEA 3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:1c64db29cad0dc127d6...5ec143ee52a7804ea1dc8e23ab2fc90ac0ac147c0\"}"; + } + + public static String webauthnSignRequest() + { + return "{\n" + " \"allowCredentials\": [\n" + " {\n" + + " \"id\": \"83De8z_CNqogB6aCyKs6dWIqwpOpzVoNaJ74lgcpuYN7l-95QsD3z-qqPADqsFlPwBXCMqEPssq75kqHCMQHDA\",\n" + + " \"transports\": [\n" + " \"internal\",\n" + " \"nfc\",\n" + + " \"ble\",\n" + " \"usb\"\n" + " ],\n" + + " \"type\": \"public-key\"\n" + " }\n" + " ],\n" + + " \"challenge\": \"dHzSmZnAhxEq0szRWMY4EGg8qgjeBhJDjAPYKWfd2IE\",\n" + + " \"rpId\": \"office.netknights.it\",\n" + " \"timeout\": 60000,\n" + + " \"userVerification\": \"preferred\"\n" + " }\n"; + } + + public static String triggerWebauthn() + { + return "{\n" + " \"detail\": {\n" + " \"preferred_client_mode\": \"webauthn\",\n" + " \"attributes\": {\n" + + " \"hideResponseInput\": true,\n" + " \"img\": \"static/img/FIDO-U2F-Security-Key-444x444.png\",\n" + + " \"webAuthnSignRequest\": {\n" + " \"allowCredentials\": [\n" + " {\n" + + " \"id\": \"83De8z_CNqogB6aCyKs6dWIqwpOpzVoNaJ74lgcpuYN7l-95QsD3z-qqPADqsFlPwBXCMqEPssq75kqHCMQHDA\",\n" + + " \"transports\": [\n" + " \"internal\",\n" + " \"nfc\",\n" + + " \"ble\",\n" + " \"usb\"\n" + " ],\n" + " \"type\": \"public-key\"\n" + + " }\n" + " ],\n" + " \"challenge\": \"dHzSmZnAhxEq0szRWMY4EGg8qgjeBhJDjAPYKWfd2IE\",\n" + + " \"rpId\": \"office.netknights.it\",\n" + " \"timeout\": 60000,\n" + + " \"userVerification\": \"preferred\"\n" + " }\n" + " },\n" + + " \"message\": \"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\",\n" + + " \"messages\": [\n" + " \"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"\n" + + " ],\n" + " \"multi_challenge\": [\n" + " {\n" + " \"attributes\": {\n" + + " \"hideResponseInput\": true,\n" + " \"webAuthnSignRequest\": " + webauthnSignRequest() + " },\n" + + " \"image\": \"static/img/FIDO-U2F-Security-Key-444x444.png\",\n" + + " \"client_mode\": \"webauthn\",\n" + + " \"message\": \"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\",\n" + + " \"serial\": \"WAN00025CE7\",\n" + " \"transaction_id\": \"16786665691788289392\",\n" + + " \"type\": \"webauthn\"\n" + " }\n" + " ],\n" + " \"serial\": \"WAN00025CE7\",\n" + + " \"threadid\": 140040275289856,\n" + " \"transaction_id\": \"16786665691788289392\",\n" + + " \"transaction_ids\": [\n" + " \"16786665691788289392\"\n" + " ],\n" + " \"type\": \"webauthn\"\n" + + " },\n" + " \"id\": 1,\n" + " \"jsonrpc\": \"2.0\",\n" + " \"result\": {\n" + + " \"authentication\": \"CHALLENGE\",\n" + " \"status\": true,\n" + " \"value\": false\n" + " },\n" + + " \"time\": 1611916339.8448942\n" + "}\n" + ""; + } + + public static String multipleWebauthnResponse() + { + return "{" + "\"detail\":{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + + "\"allowCredentials\":[{" + "\"id\":\"kJCeTZ-AtzwuuF-BkzBNO_0...KwYxgitd4uoowT43EGm_x3mNhT1i-w\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}}," + + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB), Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"," + + "\"messages\":[\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\",\"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"]," + + "\"multi_challenge\":[{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}}," + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\"," + + "\"serial\":\"WAN0003ABB5\"," + "\"transaction_id\":\"00699705595414705468\"," + "\"type\":\"webauthn\"}," + "{\"attributes\":{" + + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + "\"allowCredentials\":[{" + + "\"id\":\"kJCeTZ-AtzwuuF-BkzBNO_0...wYxgitd4uoowT43EGm_x3mNhT1i-w\"," + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + + "\"type\":\"public-key\"}]," + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + "\"userVerification\":\"preferred\"}}," + + "\"message\":\"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"," + "\"serial\":\"WAN00042278\"," + + "\"transaction_id\":\"00699705595414705468\"," + "\"type\":\"webauthn\"}]," + "\"serial\":\"WAN00042278\"," + + "\"threadid\":140050952959744," + "\"transaction_id\":\"00699705595414705468\"," + + "\"transaction_ids\":[\"00699705595414705468\",\"00699705595414705468\"]," + "\"type\":\"webauthn\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + "\"time\":1649754970.915023," + + "\"version\":\"privacyIDEA 3.6.3\"," + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:74fac28b3163d4ac3f76...9237bb6c32c0d03de39\"}"; + } + + public static String expectedMergedResponse() + { + return "{" + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}," + "{" + + "\"id\":\"kJCeTZ-AtzwuuF-BkzBNO_0...wYxgitd4uoowT43EGm_x3mNhT1i-w\"," + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + + "\"type\":\"public-key\"}]," + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + "\"userVerification\":\"preferred\"}"; + } + + public static String expectedMergedResponseIncomplete() + { + return "{" + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}"; + } + + public static String mergedSignRequestEmpty() + { + return "{" + "\"detail\":{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{}," + + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB), Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"," + + "\"messages\":[\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\",\"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"]," + + "\"multi_challenge\":[{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}}," + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\"," + + "\"serial\":\"WAN0003ABB5\"," + "\"transaction_id\":\"00699705595414705468\"," + "\"type\":\"webauthn\"}," + "{\"attributes\":{" + + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + "\"allowCredentials\":[{" + + "\"id\":\"kJCeTZ-AtzwuuF-BkzBNO_0...wYxgitd4uoowT43EGm_x3mNhT1i-w\"," + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + + "\"type\":\"public-key\"}]," + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + "\"userVerification\":\"preferred\"}}," + + "\"message\":\"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"," + "\"serial\":\"WAN00042278\"," + + "\"transaction_id\":\"00699705595414705468\"," + "\"type\":\"webauthn\"}]," + "\"serial\":\"WAN00042278\"," + + "\"threadid\":140050952959744," + "\"transaction_id\":\"00699705595414705468\"," + + "\"transaction_ids\":[\"00699705595414705468\",\"00699705595414705468\"]," + "\"type\":\"webauthn\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + "\"time\":1649754970.915023," + + "\"version\":\"privacyIDEA 3.6.3\"," + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:74fac28b3163d4ac3f76...9237bb6c32c0d03de39\"}"; + } + + public static String mergedSignRequestIncomplete() + { + return "{" + "\"detail\":{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}}," + + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB), Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"," + + "\"messages\":[\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\",\"Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)\"]," + + "\"multi_challenge\":[{" + "\"attributes\":{" + "\"hideResponseInput\":true," + "\"img\":\"\"," + "\"webAuthnSignRequest\":{" + + "\"allowCredentials\":[{" + "\"id\":\"EF0bpUwV8YRCzZgZp335GmPbKGU9g1...k2kvqHIPVG3HyBPEEdhLwQFgL2j16K2wEkD2\"," + + "\"transports\":[\"ble\",\"nfc\",\"usb\",\"internal\"]," + "\"type\":\"public-key\"}]," + + "\"challenge\":\"9pxFSjhXo3MwRLCd0HiLaGcjVFLxjXGqlhX52xrIo-k\"," + "\"rpId\":\"office.netknights.it\"," + "\"timeout\":60000," + + "\"userVerification\":\"preferred\"}}," + "\"message\":\"Please confirm with your WebAuthn token (FT BioPass FIDO2 USB)\"," + + "\"serial\":\"WAN0003ABB5\"," + "\"transaction_id\":\"00699705595414705468\"," + "\"type\":\"webauthn\"}]," + + "\"serial\":\"WAN00042278\"," + "\"threadid\":140050952959744," + "\"transaction_id\":\"00699705595414705468\"," + + "\"transaction_ids\":[\"00699705595414705468\",\"00699705595414705468\"]," + "\"type\":\"webauthn\"}," + "\"id\":1," + + "\"jsonrpc\":\"2.0\"," + "\"result\":{" + "\"status\":true," + "\"value\":false}," + "\"time\":1649754970.915023," + + "\"version\":\"privacyIDEA 3.6.3\"," + "\"versionnumber\":\"3.6.3\"," + + "\"signature\":\"rsa_sha256_pss:74fac28b3163d4ac3f76...9237bb6c32c0d03de39\"}"; + } +} diff --git a/providers/privacyidea/pom.xml b/providers/privacyidea/pom.xml new file mode 100644 index 00000000..dceada22 --- /dev/null +++ b/providers/privacyidea/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + org.privacyidea + privacyidea-keycloak-provider + 1.4.1 + jar + + UTF-8 + + + PrivacyIDEA-Provider + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + + com.squareup.okhttp3 + org.jetbrains.kotlin + com.squareup.okio + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + + ${project.artifactId} + ${project.version} + ${project.groupId} + + + + + + org.privacyidea:privacyidea-java-client + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 11 + 11 + + + + org.wildfly.plugins + wildfly-maven-plugin + 2.0.1.Final + + false + + + + + + + org.privacyidea + privacyidea-java-client + 1.2.2 + + + org.keycloak + keycloak-core + 23.0.4 + + + org.keycloak + keycloak-server-spi + 22.0.1 + + + org.keycloak + keycloak-server-spi-private + 22.0.1 + + + org.keycloak + keycloak-services + 24.0.3 + + + org.keycloak + keycloak-common + 22.0.1 + + + org.jboss.logging + jboss-logging + 3.5.0.Final + + + com.squareup.okhttp3 + okhttp + 4.9.1 + + + javax + javaee-api + 8.0.1 + provided + + + jakarta.ws.rs + jakarta.ws.rs-api + 3.1.0 + + + \ No newline at end of file diff --git a/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Configuration.java b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Configuration.java new file mode 100644 index 00000000..fa934fd4 --- /dev/null +++ b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Configuration.java @@ -0,0 +1,259 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea.authenticator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.privacyidea.authenticator.Const.CONFIG_DEFAULT_MESSAGE; +import static org.privacyidea.authenticator.Const.CONFIG_ENABLE_LOG; +import static org.privacyidea.authenticator.Const.CONFIG_ENROLL_TOKEN; +import static org.privacyidea.authenticator.Const.CONFIG_ENROLL_TOKEN_TYPE; +import static org.privacyidea.authenticator.Const.CONFIG_EXCLUDED_GROUPS; +import static org.privacyidea.authenticator.Const.CONFIG_FORWARDED_HEADERS; +import static org.privacyidea.authenticator.Const.CONFIG_INCLUDED_GROUPS; +import static org.privacyidea.authenticator.Const.CONFIG_OTP_LENGTH; +import static org.privacyidea.authenticator.Const.CONFIG_POLL_IN_BROWSER; +import static org.privacyidea.authenticator.Const.CONFIG_POLL_IN_BROWSER_URL; +import static org.privacyidea.authenticator.Const.CONFIG_PREF_TOKEN_TYPE; +import static org.privacyidea.authenticator.Const.CONFIG_PUSH_INTERVAL; +import static org.privacyidea.authenticator.Const.CONFIG_REALM; +import static org.privacyidea.authenticator.Const.CONFIG_SEND_PASSWORD; +import static org.privacyidea.authenticator.Const.CONFIG_TRIGGER_CHALLENGE; +import static org.privacyidea.authenticator.Const.CONFIG_SERVER; +import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_ACCOUNT; +import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_PASS; +import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_REALM; +import static org.privacyidea.authenticator.Const.CONFIG_SEND_STATIC_PASS; +import static org.privacyidea.authenticator.Const.CONFIG_STATIC_PASS; +import static org.privacyidea.authenticator.Const.CONFIG_VERIFY_SSL; +import static org.privacyidea.authenticator.Const.DEFAULT_POLLING_ARRAY; +import static org.privacyidea.authenticator.Const.DEFAULT_POLLING_INTERVAL; +import static org.privacyidea.authenticator.Const.TRUE; + +class Configuration +{ + private final String serverURL; + private final String realm; + private final boolean doSSLVerify; + private final boolean doTriggerChallenge; + private final boolean doSendPassword; + private final boolean doSendStaticPass; + private final String staticPass; + private final String serviceAccountName; + private final String serviceAccountPass; + private final String serviceAccountRealm; + private final List excludedGroups = new ArrayList<>(); + private final List includedGroups = new ArrayList<>(); + private final List forwardedHeaders = new ArrayList<>(); + private final String otpLength; + private final boolean doEnrollToken; + private final boolean doLog; + private final String enrollingTokenType; + private final boolean pollInBrowser; + private final String pollInBrowserUrl; + private final List pollingInterval = new ArrayList<>(); + private final String prefTokenType; + private final int configHash; + private final String defaultOTPMessage; + + Configuration(Map configMap) + { + this.configHash = configMap.hashCode(); + this.serverURL = configMap.get(CONFIG_SERVER); + this.realm = configMap.get(CONFIG_REALM) == null ? "" : configMap.get(CONFIG_REALM); + this.doSSLVerify = configMap.get(CONFIG_VERIFY_SSL) != null && configMap.get(CONFIG_VERIFY_SSL).equals(TRUE); + this.doTriggerChallenge = configMap.get(CONFIG_TRIGGER_CHALLENGE) != null && configMap.get(CONFIG_TRIGGER_CHALLENGE).equals(TRUE); + this.serviceAccountName = configMap.get(CONFIG_SERVICE_ACCOUNT) == null ? "" : configMap.get(CONFIG_SERVICE_ACCOUNT); + this.serviceAccountPass = configMap.get(CONFIG_SERVICE_PASS) == null ? "" : configMap.get(CONFIG_SERVICE_PASS); + this.serviceAccountRealm = configMap.get(CONFIG_SERVICE_REALM) == null ? "" : configMap.get(CONFIG_SERVICE_REALM); + this.staticPass = configMap.get(CONFIG_STATIC_PASS) == null ? "" : configMap.get(CONFIG_STATIC_PASS); + this.defaultOTPMessage = configMap.get(CONFIG_DEFAULT_MESSAGE) == null ? "" : configMap.get(CONFIG_DEFAULT_MESSAGE); + this.otpLength = configMap.get(CONFIG_OTP_LENGTH) == null ? "" : configMap.get(CONFIG_OTP_LENGTH); + this.pollInBrowser = (configMap.get(CONFIG_POLL_IN_BROWSER) != null && configMap.get(CONFIG_POLL_IN_BROWSER).equals(TRUE)); + this.pollInBrowserUrl = configMap.get(CONFIG_POLL_IN_BROWSER_URL) == null ? "" : configMap.get(CONFIG_POLL_IN_BROWSER_URL); + this.doEnrollToken = configMap.get(CONFIG_ENROLL_TOKEN) != null && configMap.get(CONFIG_ENROLL_TOKEN).equals(TRUE); + this.doSendPassword = configMap.get(CONFIG_SEND_PASSWORD) != null && configMap.get(CONFIG_SEND_PASSWORD).equals(TRUE); + this.doSendStaticPass = configMap.get(CONFIG_SEND_STATIC_PASS) != null && configMap.get(CONFIG_SEND_STATIC_PASS).equals(TRUE); + // PI uses all lowercase letters for token types so change it here to match it internally + this.prefTokenType = (configMap.get(CONFIG_PREF_TOKEN_TYPE) == null ? "otp" : configMap.get(CONFIG_PREF_TOKEN_TYPE)).toLowerCase(); + this.enrollingTokenType = (configMap.get(CONFIG_ENROLL_TOKEN_TYPE) == null ? "" : configMap.get(CONFIG_ENROLL_TOKEN_TYPE)).toLowerCase(); + this.doLog = configMap.get(CONFIG_ENABLE_LOG) != null && configMap.get(CONFIG_ENABLE_LOG).equals(TRUE); + + String excludedGroupsStr = configMap.get(CONFIG_EXCLUDED_GROUPS); + if (excludedGroupsStr != null) + { + this.excludedGroups.addAll(Arrays.asList(excludedGroupsStr.split(","))); + } + + String includedGroupsStr = configMap.get(CONFIG_INCLUDED_GROUPS); + if (includedGroupsStr != null) + { + this.includedGroups.addAll(Arrays.asList(includedGroupsStr.split(","))); + } + + String forwardedHeadersStr = configMap.get(CONFIG_FORWARDED_HEADERS); + if (forwardedHeadersStr != null) + { + this.forwardedHeaders.addAll(Arrays.asList(forwardedHeadersStr.split(","))); + } + + // Set intervals to either default or configured values + String s = configMap.get(CONFIG_PUSH_INTERVAL); + if (s != null) + { + List strPollingIntervals = Arrays.asList(s.split(",")); + if (!strPollingIntervals.isEmpty()) + { + this.pollingInterval.clear(); + for (String str : strPollingIntervals) + { + try + { + this.pollingInterval.add(Integer.parseInt(str)); + } + catch (NumberFormatException e) + { + this.pollingInterval.add(DEFAULT_POLLING_INTERVAL); + } + } + } + } + else + { + this.pollingInterval.addAll(DEFAULT_POLLING_ARRAY); + } + } + + int configHash() + { + return configHash; + } + + String serverURL() + { + return serverURL; + } + + String realm() + { + return realm; + } + + boolean sslVerify() + { + return doSSLVerify; + } + + boolean triggerChallenge() + { + return doTriggerChallenge; + } + + boolean sendStaticPass() + { + return doSendStaticPass; + } + + String staticPass() + { + return staticPass; + } + + String serviceAccountName() + { + return serviceAccountName; + } + + String serviceAccountPass() + { + return serviceAccountPass; + } + + String serviceAccountRealm() + { + return serviceAccountRealm; + } + + List excludedGroups() + { + return excludedGroups; + } + + List includedGroups() + { + return includedGroups; + } + + List forwardedHeaders() + { + return forwardedHeaders; + } + + String otpLength() + { + return otpLength; + } + + boolean enrollToken() + { + return doEnrollToken; + } + + String enrollingTokenType() + { + return enrollingTokenType; + } + + boolean pollInBrowser() + { + return pollInBrowser; + } + + String pollInBrowserUrl() + { + return pollInBrowserUrl; + } + + List pollingInterval() + { + return pollingInterval; + } + + boolean doLog() + { + return doLog; + } + + boolean sendPassword() + { + return doSendPassword; + } + + String prefTokenType() + { + return prefTokenType; + } + + String defaultOTPMessage() + { + return defaultOTPMessage; + } +} \ No newline at end of file diff --git a/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Const.java b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Const.java new file mode 100644 index 00000000..70338140 --- /dev/null +++ b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Const.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea.authenticator; + +import java.util.Arrays; +import java.util.List; + +final class Const +{ + private Const() + { + } + + static final String PROVIDER_ID = "privacyidea-authenticator"; + static final String PLUGIN_USER_AGENT = "privacyIDEA-Keycloak"; + + static final String DEFAULT_PUSH_MESSAGE_EN = "Please confirm the authentication on your mobile device!"; + static final String DEFAULT_PUSH_MESSAGE_DE = "Bitte bestätigen Sie die Authentifizierung auf ihrem Smartphone!"; + + static final String DEFAULT_OTP_MESSAGE_EN = "Please enter your One-Time-Password!"; + static final String DEFAULT_OTP_MESSAGE_DE = "Bitte geben Sie ihr Einmalpasswort ein!"; + + static final String TRUE = "true"; + + static final String HEADER_ACCEPT_LANGUAGE = "accept-language"; + // Will be used if single value from config cannot be parsed + static final int DEFAULT_POLLING_INTERVAL = 2; + // Will be used if no intervals are specified + static final List DEFAULT_POLLING_ARRAY = Arrays.asList(4, 2, 2, 2, 3); + + static final String FORM_POLL_INTERVAL = "pollingInterval"; + static final String FORM_TOKEN_ENROLLMENT_QR = "tokenEnrollmentQR"; + static final String FORM_MODE = "mode"; + static final String FORM_IMAGE_PUSH = "pushImage"; + static final String FORM_IMAGE_OTP = "otpImage"; + static final String FORM_IMAGE_WEBAUTHN = "webauthnImage"; + static final String FORM_POLL_IN_BROWSER_FAILED = "pollInBrowserFailed"; + static final String FORM_ERROR_MESSAGE = "errorMsg"; + static final String FORM_TRANSACTION_ID = "transactionID"; + static final String FORM_PI_SERVER_URL = "piServerUrl"; + static final String FORM_AUTO_SUBMIT_OTP_LENGTH = "AutoSubmitOtpLength"; + static final String FORM_PI_POLL_IN_BROWSER_URL = "piPollInBrowserUrl"; + static final String FORM_PUSH_AVAILABLE = "push_available"; + static final String FORM_OTP_AVAILABLE = "otp_available"; + static final String FORM_PUSH_MESSAGE = "pushMessage"; + static final String FORM_OTP_MESSAGE = "otpMessage"; + static final String FORM_FILE_NAME = "privacyIDEA.ftl"; + static final String FORM_MODE_CHANGED = "modeChanged"; + static final String FORM_OTP = "otp"; + static final String FORM_UI_LANGUAGE = "uilanguage"; + static final String FORM_ERROR = "hasError"; + + // Webauthn form fields + static final String FORM_WEBAUTHN_SIGN_REQUEST = "webauthnsignrequest"; + static final String FORM_WEBAUTHN_SIGN_RESPONSE = "webauthnsignresponse"; + static final String FORM_WEBAUTHN_ORIGIN = "origin"; + + // U2F form fields + static final String FORM_U2F_SIGN_REQUEST = "u2fsignrequest"; + static final String FORM_U2F_SIGN_RESPONSE = "u2fsignresponse"; + + static final String AUTH_NOTE_TRANSACTION_ID = "transaction_id"; + static final String AUTH_NOTE_AUTH_COUNTER = "authCounter"; + static final String AUTH_NOTE_ACCEPT_LANGUAGE = "authLanguage"; + + // Changing the config value names will reset the current config + static final String CONFIG_PUSH_INTERVAL = "pipushtokeninterval"; + static final String CONFIG_EXCLUDED_GROUPS = "piexcludegroups"; + static final String CONFIG_INCLUDED_GROUPS = "piincludegroups"; + static final String CONFIG_FORWARDED_HEADERS = "piforwardedheaders"; + static final String CONFIG_ENROLL_TOKEN_TYPE = "pienrolltokentype"; + static final String CONFIG_ENROLL_TOKEN = "pienrolltoken"; + static final String CONFIG_DEFAULT_MESSAGE = "pidefaultmessage"; + static final String CONFIG_POLL_IN_BROWSER = "pipollinbrowser"; + static final String CONFIG_POLL_IN_BROWSER_URL = "pipollinbrowserurl"; + static final String CONFIG_SEND_PASSWORD = "pisendpassword"; + static final String CONFIG_TRIGGER_CHALLENGE = "pidotriggerchallenge"; + static final String CONFIG_SEND_STATIC_PASS = "pisendstaticpass"; + static final String CONFIG_OTP_LENGTH = "piotplength"; + static final String CONFIG_SERVICE_PASS = "piservicepass"; + static final String CONFIG_SERVICE_ACCOUNT = "piserviceaccount"; + static final String CONFIG_SERVICE_REALM = "piservicerealm"; + static final String CONFIG_STATIC_PASS = "pistaticpass"; + static final String CONFIG_VERIFY_SSL = "piverifyssl"; + static final String CONFIG_REALM = "pirealm"; + static final String CONFIG_SERVER = "piserver"; + static final String CONFIG_ENABLE_LOG = "pidolog"; + static final String CONFIG_PREF_TOKEN_TYPE = "preftokentype"; +} \ No newline at end of file diff --git a/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Pair.java b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Pair.java new file mode 100644 index 00000000..46b1a300 --- /dev/null +++ b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/Pair.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + *

+ * Based on original code: + *

+ * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea.authenticator; + +import org.privacyidea.PrivacyIDEA; + +public class Pair +{ + private final PrivacyIDEA privacyIDEA; + private final Configuration configuration; + + public Pair(PrivacyIDEA privacyIDEA, Configuration configuration) + { + this.privacyIDEA = privacyIDEA; + this.configuration = configuration; + } + + public PrivacyIDEA privacyIDEA() + { + return privacyIDEA; + } + + public Configuration configuration() + { + return configuration; + } +} \ No newline at end of file diff --git a/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java new file mode 100644 index 00000000..e7c2e386 --- /dev/null +++ b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java @@ -0,0 +1,661 @@ +/* + * Copyright 2023 NetKnights GmbH - micha.preusser@netknights.it + * nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Based on original code: + *

+ * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea.authenticator; + +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.AuthenticationFlowException; +import org.keycloak.common.Version; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.privacyidea.Challenge; +import org.privacyidea.IPILogger; +import org.privacyidea.PIResponse; +import org.privacyidea.PrivacyIDEA; +import org.privacyidea.RolloutInfo; +import org.privacyidea.TokenInfo; +import org.privacyidea.U2F; + +import static org.privacyidea.PIConstants.PASSWORD; +import static org.privacyidea.PIConstants.TOKEN_TYPE_PUSH; +import static org.privacyidea.PIConstants.TOKEN_TYPE_U2F; +import static org.privacyidea.PIConstants.TOKEN_TYPE_WEBAUTHN; +import static org.privacyidea.authenticator.Const.AUTH_NOTE_AUTH_COUNTER; +import static org.privacyidea.authenticator.Const.AUTH_NOTE_TRANSACTION_ID; +import static org.privacyidea.authenticator.Const.DEFAULT_OTP_MESSAGE_DE; +import static org.privacyidea.authenticator.Const.DEFAULT_OTP_MESSAGE_EN; +import static org.privacyidea.authenticator.Const.DEFAULT_PUSH_MESSAGE_DE; +import static org.privacyidea.authenticator.Const.DEFAULT_PUSH_MESSAGE_EN; +import static org.privacyidea.authenticator.Const.FORM_ERROR; +import static org.privacyidea.authenticator.Const.FORM_ERROR_MESSAGE; +import static org.privacyidea.authenticator.Const.FORM_FILE_NAME; +import static org.privacyidea.authenticator.Const.FORM_IMAGE_OTP; +import static org.privacyidea.authenticator.Const.FORM_IMAGE_PUSH; +import static org.privacyidea.authenticator.Const.FORM_IMAGE_WEBAUTHN; +import static org.privacyidea.authenticator.Const.FORM_MODE; +import static org.privacyidea.authenticator.Const.FORM_MODE_CHANGED; +import static org.privacyidea.authenticator.Const.FORM_OTP; +import static org.privacyidea.authenticator.Const.FORM_OTP_AVAILABLE; +import static org.privacyidea.authenticator.Const.FORM_AUTO_SUBMIT_OTP_LENGTH; +import static org.privacyidea.authenticator.Const.FORM_OTP_MESSAGE; +import static org.privacyidea.authenticator.Const.FORM_PI_POLL_IN_BROWSER_URL; +import static org.privacyidea.authenticator.Const.FORM_POLL_INTERVAL; +import static org.privacyidea.authenticator.Const.FORM_POLL_IN_BROWSER_FAILED; +import static org.privacyidea.authenticator.Const.FORM_PUSH_AVAILABLE; +import static org.privacyidea.authenticator.Const.FORM_PUSH_MESSAGE; +import static org.privacyidea.authenticator.Const.FORM_TOKEN_ENROLLMENT_QR; +import static org.privacyidea.authenticator.Const.FORM_TRANSACTION_ID; +import static org.privacyidea.authenticator.Const.FORM_U2F_SIGN_REQUEST; +import static org.privacyidea.authenticator.Const.FORM_U2F_SIGN_RESPONSE; +import static org.privacyidea.authenticator.Const.FORM_UI_LANGUAGE; +import static org.privacyidea.authenticator.Const.FORM_WEBAUTHN_ORIGIN; +import static org.privacyidea.authenticator.Const.FORM_WEBAUTHN_SIGN_REQUEST; +import static org.privacyidea.authenticator.Const.FORM_WEBAUTHN_SIGN_RESPONSE; +import static org.privacyidea.authenticator.Const.HEADER_ACCEPT_LANGUAGE; +import static org.privacyidea.authenticator.Const.PLUGIN_USER_AGENT; +import static org.privacyidea.authenticator.Const.TRUE; + +public class PrivacyIDEAAuthenticator implements org.keycloak.authentication.Authenticator, IPILogger +{ + private final Logger logger = Logger.getLogger(PrivacyIDEAAuthenticator.class); + + private final ConcurrentHashMap piInstanceMap = new ConcurrentHashMap<>(); + private boolean logEnabled = false; + + /** + * Create new instances of PrivacyIDEA and the Configuration, if it does not exist yet. + * Also adds them to the instance map. + * + * @param context for authentication flow + */ + private Pair loadConfiguration(final AuthenticationFlowContext context) + { + // Get the configuration and privacyIDEA instance for the current realm + // If none is found then create a new one + final int incomingHash = context.getAuthenticatorConfig().getConfig().hashCode(); + final String kcRealm = context.getRealm().getName(); + final Pair currentPair = piInstanceMap.get(kcRealm); + + if (currentPair == null || incomingHash != currentPair.configuration().configHash()) + { + final Map configMap = context.getAuthenticatorConfig().getConfig(); + Configuration config = new Configuration(configMap); + String kcVersion = Version.VERSION; + String providerVersion = PrivacyIDEAAuthenticator.class.getPackage().getImplementationVersion(); + String fullUserAgent = PLUGIN_USER_AGENT + "/" + providerVersion + " Keycloak/" + kcVersion; + PrivacyIDEA privacyIDEA = PrivacyIDEA.newBuilder(config.serverURL(), fullUserAgent) + .sslVerify(config.sslVerify()) + .logger(this) + .realm(config.realm()) + .serviceAccount(config.serviceAccountName(), config.serviceAccountPass()) + .serviceRealm(config.serviceAccountRealm()) + .build(); + + // Close the old privacyIDEA instance to shut down the thread pool before replacing it in the map + if (currentPair != null) + { + try + { + currentPair.privacyIDEA().close(); + } + catch (IOException e) + { + error("Failed to close privacyIDEA instance!"); + } + } + Pair pair = new Pair(privacyIDEA, config); + piInstanceMap.put(kcRealm, pair); + } + + return piInstanceMap.get(kcRealm); + } + + /** + * This function will be called when the authentication flow triggers the privacyIDEA execution. + * i.e. after the username + password have been submitted. + * + * @param context AuthenticationFlowContext + */ + @Override + public void authenticate(AuthenticationFlowContext context) + { + final Pair currentPair = loadConfiguration(context); + + PrivacyIDEA privacyIDEA = currentPair.privacyIDEA(); + Configuration config = currentPair.configuration(); + logEnabled = config.doLog(); + // Get the things that were submitted in the first username+password form + UserModel user = context.getUser(); + String currentUser = user.getUsername(); + + // Check if the current user is member of an included or excluded group + if (!config.includedGroups().isEmpty()) + { + if (user.getGroupsStream().map(GroupModel::getName).noneMatch(config.includedGroups()::contains)) + { + context.success(); + return; + } + } + else if (!config.excludedGroups().isEmpty()) + { + if (user.getGroupsStream().map(GroupModel::getName).anyMatch(config.excludedGroups()::contains)) + { + context.success(); + return; + } + } + + String currentPassword = null; + + // In some cases, there will be no FormParameters so check if it is possible to even get the password + if (config.sendPassword() && context.getHttpRequest() != null && context.getHttpRequest().getDecodedFormParameters() != null && + context.getHttpRequest().getDecodedFormParameters().get(PASSWORD) != null) + { + currentPassword = context.getHttpRequest().getDecodedFormParameters().get(PASSWORD).get(0); + } + + Map headers = getHeadersToForward(context, config); + + // Set UI language + String uiLanguage = "en"; + if (headers.get(HEADER_ACCEPT_LANGUAGE) != null && headers.get(HEADER_ACCEPT_LANGUAGE).startsWith("de")) + { + uiLanguage = "de"; + } + + // Prepare for possibly triggering challenges + PIResponse triggerResponse = null; + String pushMessage = uiLanguage.equals("en") ? DEFAULT_PUSH_MESSAGE_EN : DEFAULT_PUSH_MESSAGE_DE; + String otpMessage = uiLanguage.equals("en") ? DEFAULT_OTP_MESSAGE_EN : DEFAULT_OTP_MESSAGE_DE; + if (!config.defaultOTPMessage().isEmpty()) + { + otpMessage = config.defaultOTPMessage(); + } + // Set the default values, always assume OTP is available + String tokenEnrollmentQR = ""; + context.form() + .setAttribute(FORM_MODE, "otp") + .setAttribute(FORM_WEBAUTHN_SIGN_REQUEST, "") + .setAttribute(FORM_U2F_SIGN_REQUEST, "") + .setAttribute(FORM_PUSH_MESSAGE, pushMessage) + .setAttribute(FORM_OTP_AVAILABLE, true) + .setAttribute(FORM_OTP_MESSAGE, otpMessage) + .setAttribute(FORM_PUSH_AVAILABLE, false) + .setAttribute(FORM_IMAGE_PUSH, "") + .setAttribute(FORM_IMAGE_OTP, "") + .setAttribute(FORM_IMAGE_WEBAUTHN, "") + .setAttribute(FORM_AUTO_SUBMIT_OTP_LENGTH, config.otpLength()) + .setAttribute(FORM_POLL_IN_BROWSER_FAILED, false) + .setAttribute(FORM_POLL_INTERVAL, config.pollingInterval().get(0)); + + // Trigger challenges if configured. Service account has precedence over send password + if (config.triggerChallenge()) + { + triggerResponse = privacyIDEA.triggerChallenges(currentUser, headers); + } + else if (config.sendPassword()) + { + if (currentPassword != null) + { + triggerResponse = privacyIDEA.validateCheck(currentUser, currentPassword, null, headers); + } + else + { + log("Cannot send password because it is null!"); + } + } + else if (config.sendStaticPass()) + { + triggerResponse = privacyIDEA.validateCheck(currentUser, config.staticPass(), null, headers); + } + + // Evaluate for possibly triggered token + if (triggerResponse != null) + { + if (triggerResponse.value) + { + context.success(); + return; + } + + if (triggerResponse.error != null) + { + context.form().setError(triggerResponse.error.message); + context.form().setAttribute(FORM_ERROR, true); + } + + if (!triggerResponse.multichallenge.isEmpty()) + { + extractChallengeDataToForm(triggerResponse, context, config); + } + + // Enroll token if enabled and user does not have one. If something was triggered before, don't even try. + if (config.enrollToken() && (triggerResponse.transactionID == null || triggerResponse.transactionID.isEmpty())) + { + List tokenInfos = privacyIDEA.getTokenInfo(currentUser); + + if (tokenInfos == null || tokenInfos.isEmpty()) + { + RolloutInfo rolloutInfo = privacyIDEA.tokenRollout(currentUser, config.enrollingTokenType()); + + if (rolloutInfo != null) + { + if (rolloutInfo.error == null) + { + tokenEnrollmentQR = rolloutInfo.googleurl.img; + } + else + { + context.form().setError(rolloutInfo.error.message); + context.form().setAttribute(FORM_ERROR, true); + } + } + else + { + context.form().setError("Configuration error, please check the log file."); + } + } + } + } + // Prepare the form and auth notes to pass infos to the UI and the next step + context.getAuthenticationSession().setAuthNote(AUTH_NOTE_AUTH_COUNTER, "0"); + + Response responseForm = context.form() + .setAttribute(FORM_TOKEN_ENROLLMENT_QR, tokenEnrollmentQR) + .setAttribute(FORM_UI_LANGUAGE, uiLanguage) + .createForm(FORM_FILE_NAME); + + context.challenge(responseForm); + } + + /** + * This function will be called when the privacyIDEA form is submitted. + * + * @param context AuthenticationFlowContext + */ + @Override + public void action(AuthenticationFlowContext context) + { + loadConfiguration(context); + String kcRealm = context.getRealm().getName(); + + PrivacyIDEA privacyIDEA; + Configuration config; + if (piInstanceMap.containsKey(kcRealm)) + { + Pair pair = piInstanceMap.get(kcRealm); + privacyIDEA = pair.privacyIDEA(); + config = pair.configuration(); + } + else + { + throw new AuthenticationFlowException("No privacyIDEA configuration found for kc-realm " + kcRealm, + AuthenticationFlowError.IDENTITY_PROVIDER_NOT_FOUND); + } + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + if (formData.containsKey("cancel")) + { + context.cancelLogin(); + return; + } + LoginFormsProvider form = context.form(); + //logger.info("formData:"); + //formData.forEach((k, v) -> logger.info("key=" + k + ", value=" + v)); + + // Get data from the privacyIDEA form + String tokenEnrollmentQR = formData.getFirst(FORM_TOKEN_ENROLLMENT_QR); + String currentMode = formData.getFirst(FORM_MODE); + boolean pushAvailable = TRUE.equals(formData.getFirst(FORM_PUSH_AVAILABLE)); + boolean otpAvailable = TRUE.equals(formData.getFirst(FORM_OTP_AVAILABLE)); + boolean pollInBrowserFailed = TRUE.equals(formData.getFirst(FORM_POLL_IN_BROWSER_FAILED)); + String pushMessage = formData.getFirst(FORM_PUSH_MESSAGE); + String otpMessage = formData.getFirst(FORM_OTP_MESSAGE); + String imagePush = formData.getFirst(FORM_IMAGE_PUSH); + String imageOTP = formData.getFirst(FORM_IMAGE_OTP); + String imageWebauthn = formData.getFirst(FORM_IMAGE_WEBAUTHN); + String otpLength = formData.getFirst(FORM_AUTO_SUBMIT_OTP_LENGTH); + String tokenTypeChanged = formData.getFirst(FORM_MODE_CHANGED); + String uiLanguage = formData.getFirst(FORM_UI_LANGUAGE); + String transactionID = context.getAuthenticationSession().getAuthNote(AUTH_NOTE_TRANSACTION_ID); + String currentUserName = context.getUser().getUsername(); + String webAuthnSignRequest = formData.getFirst(FORM_WEBAUTHN_SIGN_REQUEST); + String webAuthnSignResponse = formData.getFirst(FORM_WEBAUTHN_SIGN_RESPONSE); + // The origin is set by the form every time, no need to put it in the form again + String origin = formData.getFirst(FORM_WEBAUTHN_ORIGIN); + + String u2fSignRequest = formData.getFirst(FORM_U2F_SIGN_REQUEST); + String u2fSignResponse = formData.getFirst(FORM_U2F_SIGN_RESPONSE); + + // Prepare the failure message, the message from privacyIDEA will be appended if possible + String authenticationFailureMessage = "Authentication failed."; + + // Set the "old" values again + form.setAttribute(FORM_TOKEN_ENROLLMENT_QR, tokenEnrollmentQR) + .setAttribute(FORM_MODE, currentMode) + .setAttribute(FORM_PUSH_AVAILABLE, pushAvailable) + .setAttribute(FORM_OTP_AVAILABLE, otpAvailable) + .setAttribute(FORM_WEBAUTHN_SIGN_REQUEST, webAuthnSignRequest) + .setAttribute(FORM_IMAGE_PUSH, imagePush) + .setAttribute(FORM_IMAGE_OTP, imageOTP) + .setAttribute(FORM_IMAGE_WEBAUTHN, imageWebauthn) + .setAttribute(FORM_U2F_SIGN_REQUEST, u2fSignRequest) + .setAttribute(FORM_UI_LANGUAGE, uiLanguage) + .setAttribute(FORM_AUTO_SUBMIT_OTP_LENGTH, otpLength) + .setAttribute(FORM_POLL_IN_BROWSER_FAILED, pollInBrowserFailed) + .setAttribute(FORM_PUSH_MESSAGE, (pushMessage == null ? DEFAULT_PUSH_MESSAGE_EN : pushMessage)) + .setAttribute(FORM_OTP_MESSAGE, (otpMessage == null ? DEFAULT_OTP_MESSAGE_EN : otpMessage)); + + // Log the error encountered in the browser + String error = formData.getFirst(FORM_ERROR_MESSAGE); + if (error != null && !error.isEmpty()) + { + logger.error(error); + } + + Map headers = getHeadersToForward(context, config); + // Do not show the error message if something was triggered + boolean didTrigger = false; + PIResponse response = null; + + // Send a request to privacyIDEA depending on the mode + if (TOKEN_TYPE_PUSH.equals(currentMode)) + { + // In push mode, poll for the transaction id to see if the challenge has been answered + if (privacyIDEA.pollTransaction(transactionID)) + { + // If the challenge has been answered, finalize with a call to validate check + response = privacyIDEA.validateCheck(currentUserName, "", transactionID, headers); + } + } + else if (webAuthnSignResponse != null && !webAuthnSignResponse.isEmpty()) + { + if (origin == null || origin.isEmpty()) + { + logger.error("Origin is missing for WebAuthn authentication!"); + } + else + { + response = privacyIDEA.validateCheckWebAuthn(currentUserName, transactionID, webAuthnSignResponse, origin, headers); + } + } + else if (u2fSignResponse != null && !u2fSignResponse.isEmpty()) + { + response = privacyIDEA.validateCheckU2F(currentUserName, transactionID, u2fSignResponse, headers); + } + else if (!TRUE.equals(tokenTypeChanged)) + { + String otp = formData.getFirst(FORM_OTP); + // If the transaction id is not present, it will be not be added in validateCheck, so no need to check here + response = privacyIDEA.validateCheck(currentUserName, otp, transactionID, headers); + } + + // Evaluate the response + if (response != null) + { + // On success, finish the execution + if (response.value) + { + context.success(); + return; + } + + if (response.error != null) + { + form.setError(response.error.message); + form.setAttribute(FORM_ERROR, true); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createForm(FORM_FILE_NAME)); + return; + } + + // If the authentication was not successful (yet), either the provided data was wrong + // or another challenge was triggered + if (!response.multichallenge.isEmpty()) + { + extractChallengeDataToForm(response, context, config); + didTrigger = true; + } + else + { + // The authentication failed without triggering anything so the things that have been sent before were wrong + authenticationFailureMessage += "\n" + response.message; + } + } + + // The authCounter is also used to determine the polling interval for push + // If the authCounter is bigger than the size of the polling interval list, repeat the last value in the list + int authCounter = Integer.parseInt(context.getAuthenticationSession().getAuthNote(AUTH_NOTE_AUTH_COUNTER)) + 1; + authCounter = (authCounter >= config.pollingInterval().size() ? config.pollingInterval().size() - 1 : authCounter); + context.getAuthenticationSession().setAuthNote(AUTH_NOTE_AUTH_COUNTER, Integer.toString(authCounter)); + + // The message variables could be overwritten if a challenge was triggered. Therefore, add them here at the end + form.setAttribute(FORM_POLL_INTERVAL, config.pollingInterval().get(authCounter)); + + // Do not display the error if the token type was switched or if another challenge was triggered + if (!(TRUE.equals(tokenTypeChanged)) && !didTrigger) + { + form.setError(TOKEN_TYPE_PUSH.equals(currentMode) ? "Authentication not verified yet." : authenticationFailureMessage); + } + + Response responseForm = form.createForm(FORM_FILE_NAME); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseForm); + } + + private void extractChallengeDataToForm(PIResponse response, AuthenticationFlowContext context, Configuration config) + { + if (context == null || config == null) + { + error("extractChallengeDataToForm missing parameter!"); + return; + } + + // Variables to configure the UI + String webAuthnSignRequest = ""; + String u2fSignRequest = ""; + String mode = "otp"; + String newOtpMessage = response.otpMessage(); + if (response.transactionID != null && !response.transactionID.isEmpty()) + { + context.getAuthenticationSession().setAuthNote(AUTH_NOTE_TRANSACTION_ID, response.transactionID); + } + + // Check for the images + List multiChallenge = response.multichallenge; + for (Challenge c : multiChallenge) + { + if ("poll".equals(c.getClientMode())) + { + context.form().setAttribute(FORM_IMAGE_PUSH, c.getImage()); + } + else if ("interactive".equals(c.getClientMode())) + { + context.form().setAttribute(FORM_IMAGE_OTP, c.getImage()); + } + if ("webauthn".equals(c.getClientMode())) + { + context.form().setAttribute(FORM_IMAGE_WEBAUTHN, c.getImage()); + } + } + + // Check for poll in browser + if (config.pollInBrowser()) + { + context.form().setAttribute(FORM_TRANSACTION_ID, response.transactionID); + newOtpMessage = response.otpMessage() + "\n" + response.pushMessage(); + context.form() + .setAttribute(FORM_PI_POLL_IN_BROWSER_URL, + config.pollInBrowserUrl().isEmpty() ? config.serverURL() : config.pollInBrowserUrl()); + } + + // Check for Push + if (response.pushAvailable()) + { + context.form().setAttribute(FORM_PUSH_AVAILABLE, true); + context.form().setAttribute(FORM_PUSH_MESSAGE, response.pushMessage()); + } + + // Check for WebAuthn and U2F + if (response.triggeredTokenTypes().contains(TOKEN_TYPE_WEBAUTHN)) + { + webAuthnSignRequest = response.mergedSignRequest(); + } + + if (response.triggeredTokenTypes().contains(TOKEN_TYPE_U2F)) + { + List signRequests = response.u2fSignRequests(); + if (!signRequests.isEmpty()) + { + u2fSignRequest = signRequests.get(0).signRequest(); + } + } + + // Check if response from server contains preferred client mode + if (response.preferredClientMode != null && !response.preferredClientMode.isEmpty()) + { + mode = response.preferredClientMode; + } + else + { + // Alternatively check if any triggered token matches the local preferred token type + if (response.triggeredTokenTypes().contains(config.prefTokenType())) + { + mode = config.prefTokenType(); + } + } + // Using poll in browser does not require push mode + if (mode.equals("push") && config.pollInBrowser()) + { + mode = "otp"; + } + + context.form() + .setAttribute(FORM_MODE, mode) + .setAttribute(FORM_WEBAUTHN_SIGN_REQUEST, webAuthnSignRequest) + .setAttribute(FORM_U2F_SIGN_REQUEST, u2fSignRequest) + .setAttribute(FORM_OTP_MESSAGE, newOtpMessage); + } + + /** + * Extract the headers that should be forwarded to privacyIDEA from the original request to keycloak. The header names + * can be defined in the configuration of this provider. The accept-language header is included by default. + * + * @param context AuthenticationFlowContext + * @param config Configuration + * @return Map of headers + */ + private Map getHeadersToForward(AuthenticationFlowContext context, Configuration config) + { + Map headersToForward = new LinkedHashMap<>(); + // Take all headers from config plus accept-language + config.forwardedHeaders().add(HEADER_ACCEPT_LANGUAGE); + + for (String header : config.forwardedHeaders().stream().distinct().collect(Collectors.toList())) + { + List headerValues = context.getSession().getContext().getRequestHeaders().getRequestHeaders().get(header); + + if (headerValues != null && !headerValues.isEmpty()) + { + String temp = String.join(",", headerValues); + headersToForward.put(header, temp); + } + else + { + log("No values for header " + header + " found."); + } + } + return headersToForward; + } + + @Override + public boolean requiresUser() + { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) + { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) + { + } + + @Override + public void close() + { + } + + // IPILogger implementation + @Override + public void log(String message) + { + if (logEnabled) + { + logger.info("PrivacyIDEA Client: " + message); + } + } + + @Override + public void error(String message) + { + if (logEnabled) + { + logger.error("PrivacyIDEA Client: " + message); + } + } + + @Override + public void log(Throwable t) + { + if (logEnabled) + { + logger.info("PrivacyIDEA Client: ", t); + } + } + + @Override + public void error(Throwable t) + { + if (logEnabled) + { + logger.error("PrivacyIDEA Client: ", t); + } + } +} \ No newline at end of file diff --git a/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java new file mode 100644 index 00000000..441d55b7 --- /dev/null +++ b/providers/privacyidea/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java @@ -0,0 +1,298 @@ +/* + * Copyright 2023 NetKnights GmbH - micha.preusser@netknights.it + * nils.behlen@netknights.it + * lukas.matusiewicz@netknights.it + * - Modified + *

+ * Based on original code: + *

+ * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.privacyidea.authenticator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.keycloak.Config; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class PrivacyIDEAAuthenticatorFactory implements org.keycloak.authentication.AuthenticatorFactory, org.keycloak.authentication.ConfigurableAuthenticatorFactory +{ + private static final PrivacyIDEAAuthenticator SINGLETON = new PrivacyIDEAAuthenticator(); + private static final List configProperties = new ArrayList<>(); + + @Override + public String getId() + { + return Const.PROVIDER_ID; + } + + @Override + public org.keycloak.authentication.Authenticator create(KeycloakSession session) + { + return SINGLETON; + } + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED}; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() + { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() + { + return false; + } + + @Override + public boolean isConfigurable() + { + return true; + } + + @Override + public List getConfigProperties() + { + return configProperties; + } + + static + { + ProviderConfigProperty piServerUrl = new ProviderConfigProperty(); + piServerUrl.setType(ProviderConfigProperty.STRING_TYPE); + piServerUrl.setName(Const.CONFIG_SERVER); + piServerUrl.setLabel("privacyIDEA URL"); + piServerUrl.setHelpText("The URL of the privacyIDEA server (complete with scheme, host and port like \"https://:port\")"); + configProperties.add(piServerUrl); + + ProviderConfigProperty piRealm = new ProviderConfigProperty(); + piRealm.setType(ProviderConfigProperty.STRING_TYPE); + piRealm.setName(Const.CONFIG_REALM); + piRealm.setLabel("Realm"); + piRealm.setHelpText( + "Select the realm where your users are stored. Leave empty to use the default realm which is configured in the privacyIDEA server."); + configProperties.add(piRealm); + + ProviderConfigProperty piVerifySSL = new ProviderConfigProperty(); + piVerifySSL.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piVerifySSL.setName(Const.CONFIG_VERIFY_SSL); + piVerifySSL.setLabel("Verify SSL"); + piVerifySSL.setHelpText( + "Do not set this to false in a productive environment. Disables the verification of the privacyIDEA server's certificate and hostname."); + configProperties.add(piVerifySSL); + + List prefToken = Arrays.asList("OTP", "PUSH", "WebAuthn", "U2F"); + ProviderConfigProperty piPrefToken = new ProviderConfigProperty(); + piPrefToken.setType(ProviderConfigProperty.LIST_TYPE); + piPrefToken.setName(Const.CONFIG_PREF_TOKEN_TYPE); + piPrefToken.setLabel("Preferred Login Token Type"); + piPrefToken.setHelpText("Select the token type for which the login interface should be shown first. " + + "If other token types are available for login, it will be possible to change the interface when logging in. " + + "If the selected token type is not available, because no token of such type was triggered, the interface will default to OTP."); + piPrefToken.setOptions(prefToken); + piPrefToken.setDefaultValue(prefToken.get(0)); + configProperties.add(piPrefToken); + + ProviderConfigProperty piDoTriggerChallenge = new ProviderConfigProperty(); + piDoTriggerChallenge.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piDoTriggerChallenge.setName(Const.CONFIG_TRIGGER_CHALLENGE); + piDoTriggerChallenge.setLabel("Enable trigger challenge"); + piDoTriggerChallenge.setHelpText( + "Choose if you want to trigger challenge-response token using the provided service account before the second step of authentication. " + + "This setting is mutually exclusive with sending any password and will take precedence over both."); + configProperties.add(piDoTriggerChallenge); + + ProviderConfigProperty piServiceAccount = new ProviderConfigProperty(); + piServiceAccount.setType(ProviderConfigProperty.STRING_TYPE); + piServiceAccount.setName(Const.CONFIG_SERVICE_ACCOUNT); + piServiceAccount.setLabel("Service account"); + piServiceAccount.setHelpText("Username of the service account. Needed for trigger challenge and token enrollment."); + configProperties.add(piServiceAccount); + + ProviderConfigProperty piServicePass = new ProviderConfigProperty(); + piServicePass.setType(ProviderConfigProperty.PASSWORD); + piServicePass.setName(Const.CONFIG_SERVICE_PASS); + piServicePass.setLabel("Service account password"); + piServicePass.setHelpText("Password of the service account. Needed for trigger challenge and token enrollment."); + configProperties.add(piServicePass); + + ProviderConfigProperty piServiceRealm = new ProviderConfigProperty(); + piServiceRealm.setType(ProviderConfigProperty.STRING_TYPE); + piServiceRealm.setName(Const.CONFIG_SERVICE_REALM); + piServiceRealm.setLabel("Service account realm"); + piServiceRealm.setHelpText("Realm of the service account, if it is in a separate realm from the other accounts. " + + "Leave empty to use the general realm specified or the default realm if no realm is configured at all."); + configProperties.add(piServiceRealm); + + ProviderConfigProperty piDoSendPassword = new ProviderConfigProperty(); + piDoSendPassword.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piDoSendPassword.setName(Const.CONFIG_SEND_PASSWORD); + piDoSendPassword.setLabel("Send password"); + piDoSendPassword.setHelpText( + "Choose if you want to send the password from the first login step to privacyIDEA. This can be used to trigger challenge-response token. " + + "This setting is mutually exclusive with trigger challenge and sending a static pass."); + configProperties.add(piDoSendPassword); + + ProviderConfigProperty piSendStaticPass = new ProviderConfigProperty(); + piSendStaticPass.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piSendStaticPass.setName(Const.CONFIG_SEND_STATIC_PASS); + piSendStaticPass.setLabel("Send static password"); + piSendStaticPass.setHelpText("Enable to send the specified static password to privacyIDEA. Mutually exclusive with sending the password and trigger challenge."); + configProperties.add(piSendStaticPass); + + ProviderConfigProperty piStaticPass = new ProviderConfigProperty(); + piStaticPass.setType(ProviderConfigProperty.PASSWORD); + piStaticPass.setName(Const.CONFIG_STATIC_PASS); + piStaticPass.setLabel("Static pass"); + piStaticPass.setHelpText("Set the static password which should be sent to privacyIDEA if \"send static password\" is enabled. " + + "Can be empty to send an empty password."); + configProperties.add(piStaticPass); + + ProviderConfigProperty piIncludeGroups = new ProviderConfigProperty(); + piIncludeGroups.setType(ProviderConfigProperty.STRING_TYPE); + piIncludeGroups.setName(Const.CONFIG_INCLUDED_GROUPS); + piIncludeGroups.setLabel("Included groups"); + piIncludeGroups.setHelpText( + "Set groups for which the privacyIDEA workflow will be activated. The names should be separated with ',' (E.g. group1,group2)"); + configProperties.add(piIncludeGroups); + + ProviderConfigProperty piExcludeGroups = new ProviderConfigProperty(); + piExcludeGroups.setType(ProviderConfigProperty.STRING_TYPE); + piExcludeGroups.setName(Const.CONFIG_EXCLUDED_GROUPS); + piExcludeGroups.setLabel("Excluded groups"); + piExcludeGroups.setHelpText( + "Set groups for which the privacyIDEA workflow will be skipped. The names should be separated with ',' (E.g. group1,group2). " + + "If chosen group is already set in 'Included groups', excluding for this group will be ignored."); + configProperties.add(piExcludeGroups); + + ProviderConfigProperty piDefaultOTPText = new ProviderConfigProperty(); + piDefaultOTPText.setType(ProviderConfigProperty.STRING_TYPE); + piDefaultOTPText.setName(Const.CONFIG_DEFAULT_MESSAGE); + piDefaultOTPText.setLabel("Default OTP Text"); + piDefaultOTPText.setHelpText( + "Set the default OTP text that will be shown if no challenge or error messages are present."); + configProperties.add(piDefaultOTPText); + + ProviderConfigProperty piOtpLength = new ProviderConfigProperty(); + piOtpLength.setType(ProviderConfigProperty.STRING_TYPE); + piOtpLength.setName(Const.CONFIG_OTP_LENGTH); + piOtpLength.setLabel("Auto-Submit OTP Length"); + piOtpLength.setHelpText("Automatically submit the login form after X digits were entered. Leave empty to disable. NOTE: Only digits can be entered!"); + configProperties.add(piOtpLength); + + ProviderConfigProperty piForwardedHeaders = new ProviderConfigProperty(); + piForwardedHeaders.setType(ProviderConfigProperty.STRING_TYPE); + piForwardedHeaders.setName(Const.CONFIG_FORWARDED_HEADERS); + piForwardedHeaders.setLabel("Headers to forward"); + piForwardedHeaders.setHelpText( + "Set the headers which should be forwarded to privacyIDEA. If the header does not exist or has no value, it will be ignored. " + + "The headers should be separated with ','."); + configProperties.add(piForwardedHeaders); + + ProviderConfigProperty piEnrollToken = new ProviderConfigProperty(); + piEnrollToken.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piEnrollToken.setName(Const.CONFIG_ENROLL_TOKEN); + piEnrollToken.setLabel("Enable token enrollment"); + piEnrollToken.setHelpText( + "If enabled, the user gets a token enrolled automatically for them, if they do not have one yet. This requires a service account."); + piEnrollToken.setDefaultValue("false"); + configProperties.add(piEnrollToken); + + List tokenTypes = Arrays.asList("HOTP", "TOTP"); + ProviderConfigProperty piTokenType = new ProviderConfigProperty(); + piTokenType.setType(ProviderConfigProperty.LIST_TYPE); + piTokenType.setName(Const.CONFIG_ENROLL_TOKEN_TYPE); + piTokenType.setLabel("Enrollment token type"); + piTokenType.setHelpText("Select the token type that users can enroll, if they do not have a token yet. Service account is needed."); + piTokenType.setOptions(tokenTypes); + piTokenType.setDefaultValue(tokenTypes.get(0)); + configProperties.add(piTokenType); + + ProviderConfigProperty piPollInBrowser = new ProviderConfigProperty(); + piPollInBrowser.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piPollInBrowser.setName(Const.CONFIG_POLL_IN_BROWSER); + piPollInBrowser.setLabel("Poll in browser"); + piPollInBrowser.setDefaultValue(false); + piPollInBrowser.setHelpText( + "Enable this to do the polling for accepted push requests in the user's browser. "+ + "When enabled, the login page does not refresh when checking for successful push authentication. " + + "NOTE: privacyIDEA has to be reachable from the user's browser and a valid SSL certificate has to be in place."); + configProperties.add(piPollInBrowser); + + ProviderConfigProperty piPollInBrowserUrl = new ProviderConfigProperty(); + piPollInBrowserUrl.setType(ProviderConfigProperty.STRING_TYPE); + piPollInBrowserUrl.setName(Const.CONFIG_POLL_IN_BROWSER_URL); + piPollInBrowserUrl.setLabel("Url for poll in browser"); + piPollInBrowserUrl.setHelpText("Optional. If poll in browser should use a deviating URL, set it here. Otherwise, the general URL will be used."); + configProperties.add(piPollInBrowserUrl); + + ProviderConfigProperty piPushTokenInterval = new ProviderConfigProperty(); + piPushTokenInterval.setType(ProviderConfigProperty.STRING_TYPE); + piPushTokenInterval.setName(Const.CONFIG_PUSH_INTERVAL); + piPushTokenInterval.setLabel("Push refresh interval"); + piPushTokenInterval.setHelpText( + "Set the refresh interval for push tokens in seconds. Use a comma separated list. The last entry will be repeated."); + configProperties.add(piPushTokenInterval); + + ProviderConfigProperty piDoLog = new ProviderConfigProperty(); + piDoLog.setType(ProviderConfigProperty.BOOLEAN_TYPE); + piDoLog.setName(Const.CONFIG_ENABLE_LOG); + piDoLog.setLabel("Enable logging"); + piDoLog.setHelpText("If enabled, log messages will be written to the keycloak server logfile."); + piDoLog.setDefaultValue("false"); + configProperties.add(piDoLog); + } + + @Override + public String getHelpText() + { + return "Authenticate the second factor against privacyIDEA."; + } + + @Override + public String getDisplayType() + { + return "privacyIDEA"; + } + + @Override + public String getReferenceCategory() + { + return "privacyIDEA"; + } + + @Override + public void init(Config.Scope config) + { + } + + @Override + public void postInit(KeycloakSessionFactory factory) + { + } + + @Override + public void close() + { + } +} \ No newline at end of file diff --git a/providers/privacyidea/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/providers/privacyidea/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 00000000..f68ea91f --- /dev/null +++ b/providers/privacyidea/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1,18 @@ +# +# Copyright 2016 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.privacyidea.authenticator.PrivacyIDEAAuthenticatorFactory \ No newline at end of file diff --git a/providers/privacyidea/src/main/resources/theme-resources/resources/pi-pollTransaction.worker.js b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-pollTransaction.worker.js new file mode 100644 index 00000000..9c4e8b93 --- /dev/null +++ b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-pollTransaction.worker.js @@ -0,0 +1,55 @@ +let url; +let params; + +self.addEventListener('message', function (e) +{ + let data = e.data; + + switch (data.cmd) + { + case 'url': + url = data.msg + "/validate/polltransaction"; + break; + case 'transactionID': + params = "transaction_id=" + data.msg; + break; + case 'start': + if (url.length > 0 && params.length > 0) + { + setInterval(function () + { + fetch(url + "?" + params, {method: 'GET'}) + .then(r => + { + if (r.ok) + { + r.text().then(result => + { + const resultJson = JSON.parse(result); + if (resultJson['result']['authentication'] === "ACCEPT") + { + self.postMessage({ + 'message': 'Polling in browser: Push message confirmed!', + 'status': 'success' + }); + self.close(); + } + }); + } + else + { + self.postMessage({'message': r.statusText, 'status': 'error'}); + self.close(); + } + }) + .catch(e => + { + self.postMessage({'message': e, 'status': 'error'}); + self.close(); + } + ); + }, 300); + } + break; + } +}); diff --git a/providers/privacyidea/src/main/resources/theme-resources/resources/pi-u2f.js b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-u2f.js new file mode 100644 index 00000000..48ff810e --- /dev/null +++ b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-u2f.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function (callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function () { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function () { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function () { + return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function (callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function () { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function (callback) { + setTimeout(function () { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function (callback) { + setTimeout(function () { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function (port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function (appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function (appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function (message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function (message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } + }; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function () { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function (message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function () { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function (callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); + }; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function () { +}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function (message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function () { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function (eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function (callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function (message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function () { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function (callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function (port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function (message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function (callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function (port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({'js_api_version': apiVersion}); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; \ No newline at end of file diff --git a/providers/privacyidea/src/main/resources/theme-resources/resources/pi-webauthn.js b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-webauthn.js new file mode 100644 index 00000000..cba4be7b --- /dev/null +++ b/providers/privacyidea/src/main/resources/theme-resources/resources/pi-webauthn.js @@ -0,0 +1,514 @@ +/* + * 2020-02-11 Jean-Pierre Höhmann + * + * License: AGPLv3 + * Contact: https://www.privacyidea.org + * + * Copyright (C) 2020 NetKnights GmbH + * + * This code is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This code is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU AFFERO GENERAL PUBLIC LICENSE for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with this program. If not, see . + */ + +/** + * Namespace for the WebAuthn api. + */ +var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null; + +/** + * WebAuthn wrapper functions for privacyIDEA. + */ +(function (credentials) { + 'use strict'; + + // Do not proceed if webAuthn is unsupported in this client. + if (!this) { + console.log("unsupported!"); + return; + } + + /** + * Create a signature from a WebAuthnSignRequest. + * + * This will accept a WebAuthnSignRequest, as returned by a call to the + * /validate/check, or the /validate/triggerchallenge endpoint. It will + * format the data as necessary and pass it to navigator.credentials.get(), + * the result of the call will be processed and a WebAuthnSignResponse will + * be returned. The fields from the WebAuthnSignResponse need to be passed + * back to the /validate/check endpoint, along with the `user`, and + * `transaction_id` associated with the signing request. + * + * @param {WebAuthnSignRequest} webAuthnSignRequest - The WebAuthnSignRequest as provided by privacyIDEA. + * + * @returns {Promise} - Data for /validate/check minus `user`, `pass`, and `transaction_id`. + * + * @typedef WebAuthnSignRequest + * @type {object} + * @property {string} challenge - The challenge from privacyIDEA. + * @property {{id: string, type: PublicKeyCredentialType, transports: AuthenticatorTransport[]}[]} allowCredentials - Creds to try. + * @property {string} rpId - The relying party id the credential was created with. + * @property {UserVerificationRequirement} userVerification - Option to discourage, or require user verification. + * @property {number} [timeout=60000] - Timeout in milliseconds. + * + * @typedef WebAuthnSignResponse + * @type {object} + * @property {string} credentialid - The id of the credential being used. + * @property {string} clientdata - The clientDataJSON, encoded in base64. + * @property {string} signaturedata - The signature, encoded in base64. + * @property {string} authenticatordata - The authenticatorData, encoded in base64. + * @property {string} [userhandle] - The userHandle as reported by the authenticator. + * @property {string} [assertionclientextensions] - The assertionClientExtensions, encoded in JSON. + */ + this.sign = function (webAuthnSignRequest) { + var publicKeyCredentialRequestOptions = { + challenge: webAuthnBase64DecToArr(webAuthnSignRequest.challenge), + allowCredentials: webAuthnSignRequest.allowCredentials.map(function (x) { + return { + id: webAuthnBase64DecToArr(x.id), + type: x.type, + transports: x.transports + } + }), + rpId: webAuthnSignRequest.rpId, + userVerification: webAuthnSignRequest.userVerification, + timeout: webAuthnSignRequest.timeout || 60000 + }; + return navigator + .credentials + .get({publicKey: publicKeyCredentialRequestOptions}) + .then(function (assertion) { + if (!assertion) { + console.log("WebAuthnSign: assertion failed!"); + return Promise.reject(); + } + + var webAuthnSignResponse = { + credentialid: assertion.id, + clientdata: webAuthnBase64EncArr(assertion.response.clientDataJSON), + signaturedata: webAuthnBase64EncArr(assertion.response.signature), + authenticatordata: webAuthnBase64EncArr(assertion.response.authenticatorData) + }; + + if (assertion.response.userHandle) { + webAuthnSignResponse.userhandle = utf8ArrToStr( + assertion.response.userHandle); + } + if (assertion.response.assertionClientExtensions) { + webAuthnSignResponse.assertionclientextensions = webAuthnBase64EncArr( + strToUtf8Arr(JSON.stringify(assertion.response.assertionClientExtensions))) + } + + return Promise.resolve(webAuthnSignResponse); + }); + }; + + /** + * Create a new credential from a WebAuthnRegisterRequest. + * + * This will accept a WebAuthnRegisterRequest, as returned by a call to + * the /token/init endpoint. It will format the data as necessary and pass + * it to navigator.credentials.create(), the result of the call will be + * processed and a WebAuthnRegisterResponse, ready to be passed on to + * privacyIDEA, will be returned. + * + * @param {WebAuthnRegisterRequest} webAuthnRegisterRequest - The request as provided by privacyIDEA. + * + * @returns {Promise} - Information to pass to /token/init. + * + * @typedef WebAuthnRegisterRequest + * @type {object} + * @property {string} transaction_id - The transaction id from privacyIDEA. + * @property {string} message - Unused. + * @property {string} serialNumber - The serial number of the new token being enrolled. + * @property {string} name - The login name of the user the token is being enrolled for. + * @property {string} displayName - The full name of the user the token is being enrolled for. + * @property {string} nonce - The challenge to use when creating the token. + * @property {PublicKeyCredentialRpEntity} relyingParty - The relyingParty to use for the credential. + * @property {PublicKeyCredentialParameters} preferredAlgorithm - The preferred algorithm for credential creation. + * @property {PublicKeyCredentialParameters} [alternativeAlgorithm] - An alternative algorithm. + * @property {AuthenticatorSelectionCriteria} [authenticatorSelection] - Selection criteria for authenticators. + * @property {number} [timeout=60000] - Timeout in milliseconds. + * @property {AttestationConveyancePreference} [attestation="direct"] - Option to discourage or require attestation. + * @property {sequence} [authenticatorSelectionList] - A whitelist of authenticators to allow. + * @property {string} [description] - A description for the token being created. + * + * @typedef WebAuthnRegisterResponse + * @type {object} + * @property {'webauthn'} type - The token type of the token being enrolled. + * @property {string} transaction_id - The transaction_id that was passed in. + * @property {string} clientdata - The clientDataJSON, encoded in base64. + * @property {string} regdata - The attestationObject, encoded in base64. + * @property {string} registrationclientextensions - The registrationClientExtensions, encoded in JSON. + * @property {string} [description] - The description + */ + this.register = function (webAuthnRegisterRequest) { + var publicKeyCredentialCreationOptions = { + challenge: webAuthnBase64DecToArr(webAuthnRegisterRequest.nonce), + rp: webAuthnRegisterRequest.relyingParty, + user: { + id: strToUtf8Arr(webAuthnRegisterRequest.serialNumber), + name: webAuthnRegisterRequest.name, + displayName: webAuthnRegisterRequest.displayName + }, + pubKeyCredParams: [webAuthnRegisterRequest.preferredAlgorithm], + timeout: webAuthnRegisterRequest.timeout || 60000, + attestation: webAuthnRegisterRequest.attestation || "direct", + extensions: {} + }; + + if (webAuthnRegisterRequest.alternativeAlgorithm) { + publicKeyCredentialCreationOptions.pubKeyCredParams.push( + webAuthnRegisterRequest.alternativeAlgorithm); + } + if (webAuthnRegisterRequest.authenticatorSelection) { + publicKeyCredentialCreationOptions.authenticatorSelection + = webAuthnRegisterRequest.authenticatorSelection; + } + if (webAuthnRegisterRequest.authenticatorSelectionList) { + publicKeyCredentialCreationOptions.extensions.authnSel + = webAuthnRegisterRequest.authenticatorSelectionList + } + + return navigator + .credentials + .create({publicKey: publicKeyCredentialCreationOptions}) + .then(function (credential) { + if (!credential) { + return Promise.reject(); + } + + var webAuthnRegisterResponse = { + type: 'webauthn', + transaction_id: webAuthnRegisterRequest.transaction_id, + clientdata: webAuthnBase64EncArr(credential.response.clientDataJSON), + regdata: webAuthnBase64EncArr(credential.response.attestationObject), + }; + + var clientExtensionResults = credential.getClientExtensionResults(); + if (clientExtensionResults && Object.keys(clientExtensionResults).length) { + webAuthnRegisterResponse.registrationclientextensions = webAuthnBase64EncArr( + strToUtf8Arr(JSON.stringify(clientExtensionResults))); + } + + return Promise.resolve(webAuthnRegisterResponse); + }); + }; + + /** + * Convert a UTF-8 encoded base64 character to a base64 digit. + * + * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) + * + * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + * + * Author: madmurphy + * + * @param {number} nChr - A UTF-8 encoded base64 character. + * + * @returns {number} - The base64 digit. + */ + var b64ToUint6 = function (nChr) { + return nChr > 64 && nChr < 91 + ? nChr - 65 + : nChr > 96 && nChr < 123 + ? nChr - 71 + : nChr > 47 && nChr < 58 + ? nChr + 4 + : nChr === 43 + ? 62 + : nChr === 47 + ? 63 + : 0; + }; + + /** + * Convert a base64 digit, to a UTF-8 encoded base64 character. + * + * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) + * + * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + * + * Author: madmurphy + * + * @param {number} nUint6 - A base64 digit. + * + * @returns {number} - The UTF-8 encoded base64 character. + */ + var uint6ToB64 = function (nUint6) { + return nUint6 < 26 ? + nUint6 + 65 + : nUint6 < 52 ? + nUint6 + 71 + : nUint6 < 62 ? + nUint6 - 4 + : nUint6 === 62 ? + 43 + : nUint6 === 63 ? + 47 + : + 65; + }; + + /** + * Decode base64 into UTF-8. + * + * This will take a base64 encoded string and decode it to UTF-8, + * optionally NUL-padding it to make its length a multiple of a given + * block size. + * + * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) + * + * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + * + * Author: madmurphy + * + * @param {string} sBase64 - Base64 to decode. + * @param {number} [nBlockSize=1] - The block-size for the output. + * + * @returns {Uint8Array} - The decoded string. + */ + var base64DecToArr = function (sBase64, nBlockSize) { + var sB64Enc = sBase64.replace(/[^A-Za-z0-9+\/]/g, ""); + var nInLen = sB64Enc.length; + var nOutLen = nBlockSize ? + Math.ceil((nInLen * 3 + 1 >>> 2) / nBlockSize) * nBlockSize + : + nInLen * 3 + 1 >>> 2; + var aBytes = new Uint8Array(nOutLen); + + for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) { + nMod4 = nInIdx & 3; + nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4; + if (nMod4 === 3 || nInLen - nInIdx === 1) { + for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) { + aBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255; + } + nUint24 = 0; + } + } + + return aBytes; + }; + + /** + * Encode a binary into base64. + * + * This will take a binary ArrrayBufferLike and encode it into base64. + * + * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) + * + * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + * + * Author: madmurphy + * + * @param {ArrayBufferLike} bytes - Bytes to encode. + * + * @returns {string} - The encoded base64. + */ + var base64EncArr = function (bytes) { + var aBytes = new Uint8Array(bytes) + var eqLen = (3 - (aBytes.length % 3)) % 3; + var sB64Enc = ""; + + for (var nMod3, nLen = aBytes.length, nUint24 = 0, nIdx = 0; nIdx < nLen; nIdx++) { + nMod3 = nIdx % 3; + + // Split the output in lines 76-characters long + if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) { + sB64Enc += "\r\n"; + } + + nUint24 |= aBytes[nIdx] << (16 >>> nMod3 & 24); + if (nMod3 === 2 || aBytes.length - nIdx === 1) { + sB64Enc += String.fromCharCode( + uint6ToB64(nUint24 >>> 18 & 63), + uint6ToB64(nUint24 >>> 12 & 63), + uint6ToB64(nUint24 >>> 6 & 63), + uint6ToB64(nUint24 & 63)); + nUint24 = 0; + } + } + + return eqLen === 0 ? + sB64Enc + : + sB64Enc.substring(0, sB64Enc.length - eqLen) + (eqLen === 1 ? "=" : "=="); + }; + + /** + * Perform web-safe base64 decoding. + * + * This will perform web-safe base64 decoding as specified by WebAuthn. + * + * @param {string} sBase64 - Base64 to decode. + * + * @returns {Uint8Array} - The decoded binary. + */ + var webAuthnBase64DecToArr = function (sBase64) { + return base64DecToArr( + sBase64 + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd((sBase64.length | 3) + 1, '=')) + }; + + /** + * Perform web-safe base64 encoding. + * + * This will perform web-safe base64 encoding as specified by WebAuthn. + * + * @param {ArrayBufferLike} bytes - Bytes to encode. + * + * @returns {string} - The encoded base64. + */ + var webAuthnBase64EncArr = function (bytes) { + return base64EncArr(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + }; + + /** + * Decode a UTF-8-string. + * + * This will accept a UTF-8 string and decode it into the native string + * representation of the JavaScript engine (read: UTF-16). This function + * currently implements no sanity checks whatsoever. If the input is not + * valid UTF-8, the result of this function is not well-defined! + * + * @param {Uint8Array} aBytes - A UTF-8 encoded string. + * + * @returns {string} The decoded string. + */ + var utf8ArrToStr = function (aBytes) { + var sView = ""; + + for (var nPart, nLen = aBytes.length, nIdx = 0; nIdx < nLen; nIdx++) { + nPart = aBytes[nIdx]; + sView += String.fromCharCode( + nPart > 251 && nPart < 254 && nIdx + 5 < nLen ? + (nPart - 252) * 1073741824 /* << 30 */ + + (aBytes[++nIdx] - 128 << 24) + + (aBytes[++nIdx] - 128 << 18) + + (aBytes[++nIdx] - 128 << 12) + + (aBytes[++nIdx] - 128 << 6) + + aBytes[++nIdx] - 128 + : nPart > 247 && nPart < 252 && nIdx + 4 < nLen ? + (nPart - 248 << 24) + + (aBytes[++nIdx] - 128 << 18) + + (aBytes[++nIdx] - 128 << 12) + + (aBytes[++nIdx] - 128 << 6) + + aBytes[++nIdx] - 128 + : nPart > 239 && nPart < 248 && nIdx + 3 < nLen ? + (nPart - 240 << 18) + + (aBytes[++nIdx] - 128 << 12) + + (aBytes[++nIdx] - 128 << 6) + + aBytes[++nIdx] - 128 + : nPart > 223 && nPart < 240 && nIdx + 2 < nLen ? + (nPart - 224 << 12) + + (aBytes[++nIdx] - 128 << 6) + + aBytes[++nIdx] - 128 + : nPart > 191 && nPart < 224 && nIdx + 1 < nLen ? + (nPart - 192 << 6) + + aBytes[++nIdx] - 128 + : + nPart + ); + } + + return sView; + }; + + /** + * Encode a string to UTF-8. + * + * This will accept a string in the native representation of the JavaScript + * engine (read: UTF-16), and encode it as UTF-8. + * + * @param {string} sDOMStr - A string to encode. + * + * @returns {Uint8Array} - The encoded string. + */ + var strToUtf8Arr = function (sDOMStr) { + var aBytes; + var nChr; + var nStrLen = sDOMStr.length; + var nArrLen = 0; + + /* + * Determine the byte-length of the string when encoded as UTF-8. + */ + + for (var nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { + nChr = sDOMStr.charCodeAt(nMapIdx); + nArrLen += nChr < 0x80 ? + 1 + : nChr < 0x800 ? + 2 + : nChr < 0x10000 ? + 3 + : nChr < 0x200000 ? + 4 + : nChr < 0x4000000 ? + 5 + : + 6; + } + + aBytes = new Uint8Array(nArrLen); + + /* + * Perform the encoding. + */ + + for (var nIdx = 0, nChrIdx = 0; nIdx < nArrLen; nChrIdx++) { + nChr = sDOMStr.charCodeAt(nChrIdx); + if (nChr < 128) { + /* one byte */ + aBytes[nIdx++] = nChr; + } else if (nChr < 0x800) { + /* two bytes */ + aBytes[nIdx++] = 192 + (nChr >>> 6); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x10000) { + /* three bytes */ + aBytes[nIdx++] = 224 + (nChr >>> 12); + aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x200000) { + /* four bytes */ + aBytes[nIdx++] = 240 + (nChr >>> 18); + aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x4000000) { + /* five bytes */ + aBytes[nIdx++] = 248 + (nChr >>> 24); + aBytes[nIdx++] = 128 + (nChr >>> 18 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } else /* if (nChr <= 0x7fffffff) */ { + /* six bytes */ + aBytes[nIdx++] = 252 + (nChr >>> 30); + aBytes[nIdx++] = 128 + (nChr >>> 24 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 18 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); + aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } + } + + return aBytes; + }; +}).bind(pi_webauthn)(navigator.credentials); diff --git a/providers/privacyidea/src/main/resources/theme-resources/templates/privacyIDEA.ftl b/providers/privacyidea/src/main/resources/theme-resources/templates/privacyIDEA.ftl new file mode 100644 index 00000000..7ac10242 --- /dev/null +++ b/providers/privacyidea/src/main/resources/theme-resources/templates/privacyIDEA.ftl @@ -0,0 +1,456 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "title"> + ${msg("loginTitle",realm.name)} + <#elseif section = "header"> + ${msg("loginTitleHtml",realm.name)} + <#elseif section = "form"> +

+
+
+ <#if (!hasError)!true> + <#if mode = "push"> + <#if (pushImage!"") != ""> +
+ chal_img +
+ +

${pushMessage}

+ <#elseif mode = "webauthn"> + <#if (webauthnImage!"") != ""> +
+ chal_img +
+ + <#elseif mode = "otp"> + <#if (otpImage!"") != ""> +
+ chal_img +
+ +

${otpMessage}

+ <#else> +

${otpMessage}

+ + + <#-- Show QR code for new token, if one has been enrolled --> + <#if (tokenEnrollmentQR!"") != ""> +
+ qr_code +
+ Please scan the QR-Code with an authenticator app like "privacyIDEA Authenticator" or "Google Authenticator" + + +
+ + + + + + + + +
+ + + + + +
+
+ +
+
+ <#-- These inputs will be returned to privacyIDEAAuthenticator --> + + + + + + + + + + + + + + + + + + + + + + + + + Abbrechen + + + <#-- ALTERNATE LOGIN OPTIONS class="${properties.kcFormButtonsClass!}" --> +
+

Alternate Login Options

+ +
+ + + + <#if transactionID?? && !(transactionID = "") && !(piPollInBrowserUrl = "") && (pollInBrowserFailed = false)> + + + + <#if mode = "push"> + <#-- Polling for push by reloading every X seconds --> + + <#if otp_available> + + + <#else> + <#--If token type is not push, an input field and login button is needed--> + + <#if push_available> + + + + + <#-- WEBAUTHN --> + <#if !(webauthnsignrequest = "")> + + + + + + + <#-- U2F --> + <#if !(u2fsignrequest = "")> + + + + + + + + + <#if (!push_available || !(piPollInBrowserUrl! == "") && pollInBrowserFailed == false) && (u2fsignrequest == "") && (webauthnsignrequest == "")> + + + + <#if hasError!false> + + +
+
+
+
+
+ + \ No newline at end of file diff --git a/providers/privacyidea/src/main/resources/theme/privacyidea/login/theme.properties b/providers/privacyidea/src/main/resources/theme/privacyidea/login/theme.properties new file mode 100644 index 00000000..98e5b3a1 --- /dev/null +++ b/providers/privacyidea/src/main/resources/theme/privacyidea/login/theme.properties @@ -0,0 +1,3 @@ +parent=base +import=common/keycloak +scripts=js/pi-webauthn.js, js/pi-u2f.js, js/pi-pollTransaction.worker.js \ No newline at end of file diff --git a/providers/readme.md b/providers/readme.md new file mode 100644 index 00000000..f163f76c --- /dev/null +++ b/providers/readme.md @@ -0,0 +1,3 @@ +# Keycloak Providers + +This directory contains sources for our customised Keycloak providers, e. g. privacyIDEA. The JARs will be built by a GitHub action and moved to /src/providers in order to embed them in the Keycloak container. \ No newline at end of file