├── .gitignore ├── .gitmodules ├── .travis.yml ├── Changelog.md ├── LICENSE ├── README.md ├── pom.xml └── src └── main ├── java └── org │ └── privacyidea │ └── authenticator │ ├── AuthenticationForm.java │ ├── AuthenticationFormResult.java │ ├── Configuration.java │ ├── Const.java │ ├── Mode.java │ ├── Pair.java │ ├── PrivacyIDEAAuthenticator.java │ ├── PrivacyIDEAAuthenticatorFactory.java │ └── Util.java └── resources ├── META-INF └── services │ └── org.keycloak.authentication.AuthenticatorFactory └── theme-resources ├── messages ├── messages_de.properties ├── messages_en.properties ├── messages_es.properties ├── messages_fr.properties ├── messages_nl.properties └── messages_pl.properties ├── resources ├── css │ └── pi-form.css └── js │ ├── pi-form.js │ ├── pi-pollTransaction.worker.js │ └── pi-webauthn.js └── templates └── privacyIDEA.ftl /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea/ 3 | *.iml 4 | template.ftl 5 | dependency-reduced-pom.xml 6 | make.bat 7 | deploy.bat 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "java-client"] 2 | path = java-client 3 | url = https://github.com/privacyidea/java-client 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | 3 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v1.5.1 - 2025-05-15 4 | 5 | * Added a setting to disable the "login with passkey" button. 6 | * Fixed a bug that would cause the authentication to successfully end preemptively when using the triggerchallenge setting 7 | with some versions of the privacyIDEA server. 8 | * Fixed a bug that would cause triggerchallenge to not work when disable password check was enabled. 9 | * Fixed a bug that would cause the OTP button to be shown when an OTP input was already visible. 10 | * Fixed a bug that would cause challenges to be lost after an OTP had been entered wrong. 11 | 12 | ### v1.5.0 - 2025-04-09 13 | 14 | * Added support for passkey token, including enroll_via_multichallenge. 15 | * Added the capability to request and check username and password, to be able to use passkey in the first step. This means 16 | it is no longer necessary to have username and or password requested before using this plugin in the authentication, but still possible. 17 | * Removed poll interval setting. 18 | * Removed default OTP text setting, texts can be edited in the theme-resources/messages directory. 19 | * Added a configuration to allow setting custom headers. 20 | * Added a configuration to set custom http timeouts. 21 | * Removed the deprecated token enrollment function from this plugin in favor of enroll_via_multichallenge in the privacyIDEA server. 22 | 23 | ### v1.4.1 - 2024-03-05 24 | 25 | * Fixed a bug that would cause empty error messages to appear in the log 26 | * The thread pool allows core threads to time out, which will reduce the memory footprint of the provider 27 | 28 | ### v1.4.0 - 2023-11-07 29 | 30 | * Added `sendStaticPass` feature to send a static (or empty) password to trigger challenges 31 | * Added automatic submit after X entered digits option 32 | 33 | ### v1.3.0 - 2023-08-11 34 | 35 | * 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) 36 | * Default OTP text is now customizable. (#137) 37 | 38 | * Added compatibility for keycloak 22 39 | * Removed listing as theme from keycloak settings 40 | 41 | ### v1.2.0 - 2023-01-25 42 | 43 | * Added implementation of the preferred client mode (#121) 44 | * Added implementation of a new feature: Token enrollment via challenge (#125) 45 | 46 | ### v1.1.0 - 2022-07-01 47 | 48 | * Included groups setting to specify groups of keycloak users for which 2FA should be activated (#54). Check the [configuration documentation](https://github.com/privacyidea/keycloak-provider#configuration). 49 | * It is now possible to configure the names of header that should be forwarded to privacyIDEA (#94) 50 | * If a user has multiple WebAuthn token, all of them can be used to log in (#84) 51 | 52 | * Fixed a bug where the provider would crash if privacyIDEA sent a response with missing fields (#105) 53 | 54 | ### v1.0.0 - 2021-11-06 55 | 56 | * Support for different configurations in different keycloak realms 57 | * U2F 58 | 59 | ### v0.6 - 2021-04-03 60 | 61 | * WebAuthn support 62 | * PIN change via challenge-response 63 | 64 | ### v0.5.1 - 2020-11-26 65 | 66 | * Use java sdk for communication with privacyIDEA 67 | * Added user-agent to http requests 68 | 69 | ### v0.5 - 2020-06-10 70 | 71 | * Fixed a bug where overlapping logins could override the username in the login process 72 | 73 | ### v0.4 - 2020-04-24 74 | 75 | * Changed configuration input type to match new version of keycloak 76 | * Use /validate/polltransaction to check if push was confirmed 77 | 78 | ### v0.3 - 2019-10-22 79 | 80 | * Reset error message when switching between OTP and push 81 | * Catch parsing error for push intervals 82 | * Remove duplicates for token messages 83 | 84 | ### v0.2 - 2019-05-22 85 | 86 | * Add trigger challenge 87 | * Add possibility to exclude keycloak's groups from 2FA 88 | * Add token enrollment, if user does not have a token 89 | * Add push tokens 90 | * Add logging behaviour 91 | * Add transaction id for validate/check 92 | 93 | ### v0.1 - 2019-04-11 94 | 95 | * First version 96 | * Supports basic OTP token -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PrivacyIDEA Provider for Keycloak 2 | 3 | This provider allows you to use privacyIDEA's MFA with Keycloak. 4 | We added a detailed how-to on our [blog](https://community.privacyidea.org/t/how-to-use-keycloak-with-privacyidea/1132). 5 | With version 1.5.0 of this provider, the username and password step of keycloak is not required any more, this provider will handle it. 6 | If you still want to use it, this provider will use the username and password provided. 7 | Another option that is now possible is to have the check for the second factor first with this provider and then add a step with the keycloak password form after this. 8 | ## Download 9 | 10 | * Check our latest [releases](https://github.com/privacyidea/keycloak-provider/releases). 11 | 12 | ## Installation 13 | 14 | **Make sure to pick the correct jar for your keycloak version from 15 | the [releases page](https://github.com/privacyidea/keycloak-provider/releases) if there are multiple options!** 16 | 17 | * Keycloak has to be shut down. 18 | * Move the jar file into the `providers` directory. 19 | * Go to `bin` and run `kc.sh build` (or the batch file on windows). Or just start keycloak, depending on the version. 20 | * Start keycloak again. 21 | 22 | Now you can enable the execution for your auth flow. 23 | If you set the execution as 'required', every user needs to log in with a second factor. 24 | 25 | ## Configuration 26 | 27 | The different configuration parameters that are available on the configuration page of the execution are explained in 28 | the following table: 29 | 30 | | Configuration | Explanation | 31 | |--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 32 | | PrivacyIDEA URL | The URL of your privacyIDEA server, which must be reachable from the keycloak server. | 33 | | Realm | This realm will be appended to all requests to privacyIDEA. Leave empty to use the privacyIDEA default realm. | 34 | | Verify SSL | You can choose if Keycloak should verify the SSL certificate from privacyIDEA. Please do not uncheck this in a productive environment! | 35 | | 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. | 36 | | 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. | 37 | | Service Account Password | The password of your service account. | 38 | | 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. | 39 | | 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. | 40 | | 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. | 41 | | Disable Password Check | Since v1.5.0, this provider can verify the user password. This can be disabled, so that you either have a passwordless login, or you can add the keycloak password step after this provider. | 42 | | Disable Passkey Login | Disable the "Sign in with Passkey" button, effectively disabling passkey authentication. | 43 | | Static Pass | The static password for *send static pass*. Can also be empty to send an empty password. | 44 | | 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! | 45 | | Excluded Groups | Keycloak groups that should be excluded from 2FA. Multiple groups can be specified, separated with ','. | 46 | | Forward Client IP | Enable this to add the parameter `clientip` to every request, if the ip of the client is available. | 47 | | HTTP Timeout (ms) | Set a custom value for the HTTP timeout in milliseconds. | 48 | | 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 ','. | 49 | | Custom Headers | Set the custom headers which will be sent with every request. Each entry needs to have the format key=value. Entries that do not have this format will be ignored. Do not use well known headers like 'Authorization' and do not use '##'. | 50 | | Auto-SubmitOTP 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. | 51 | | 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`. | 52 | | 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. | 53 | | Enable Logging | Enable this to have the privacyIDEA Keycloak provider write log messages to the keycloak log file. | 54 | 55 | ### Changing texts 56 | 57 | If you want to change any of the default texts for any localization, you can directly edit the corresponding file in the 58 | `resources\theme-resources\messsages` directory. 59 | 60 | ## Manual build with source code 61 | 62 | * First, the client submodule has to be build using maven: ``mvn clean install`` in ``java-client``. 63 | * Then build with ``mvn clean install`` in the provider directory and go on with **Installation**. 64 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 24 | 26 | 4.0.0 27 | org.privacyidea 28 | privacyidea-keycloak-provider 29 | 1.5.1 30 | jar 31 | 32 | UTF-8 33 | 34 | 35 | PrivacyIDEA-Keycloak-Provider-${project.version} 36 | 37 | 38 | org.apache.maven.plugins 39 | maven-jar-plugin 40 | 3.2.0 41 | 42 | 43 | 44 | 45 | com.squareup.okhttp3 46 | org.jetbrains.kotlin 47 | com.squareup.okio 48 | 49 | 50 | 51 | 52 | 53 | org.apache.maven.plugins 54 | maven-shade-plugin 55 | 3.2.4 56 | 57 | 58 | package 59 | 60 | shade 61 | 62 | 63 | 64 | 65 | 66 | ${project.artifactId} 67 | ${project.version} 68 | ${project.groupId} 69 | 70 | 71 | 72 | 73 | 74 | org.privacyidea:privacyidea-java-client 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | org.apache.maven.plugins 83 | maven-compiler-plugin 84 | 3.8.1 85 | 86 | 14 87 | 14 88 | 89 | 90 | 91 | 92 | 93 | 94 | org.privacyidea 95 | privacyidea-java-client 96 | 1.4.0 97 | 98 | 99 | org.keycloak 100 | keycloak-core 101 | 26.2.4 102 | 103 | 104 | org.keycloak 105 | keycloak-server-spi 106 | 26.2.4 107 | 108 | 109 | org.keycloak 110 | keycloak-server-spi-private 111 | 26.2.4 112 | 113 | 114 | org.keycloak 115 | keycloak-services 116 | 26.2.4 117 | 118 | 119 | org.keycloak 120 | keycloak-common 121 | 26.2.4 122 | 123 | 124 | org.jboss.logging 125 | jboss-logging 126 | 3.5.0.Final 127 | 128 | 129 | com.squareup.okhttp3 130 | okhttp 131 | 4.12.0 132 | 133 | 134 | javax 135 | javaee-api 136 | 8.0.1 137 | provided 138 | 139 | 140 | jakarta.ws.rs 141 | jakarta.ws.rs-api 142 | 3.1.0 143 | 144 | 145 | -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/AuthenticationForm.java: -------------------------------------------------------------------------------- 1 | package org.privacyidea.authenticator; 2 | 3 | import com.google.gson.Gson; 4 | import org.keycloak.utils.StringUtil; 5 | 6 | /** 7 | * This class holds the data that gets passed from java to freemarker/js. 8 | * For the way back from freemarker/js to java, AuthenticationFormResult is used. 9 | * Because freemarker can only read the values, this class contains only data that needs to be read. 10 | */ 11 | public class AuthenticationForm 12 | { 13 | private Mode mode = Mode.OTP; 14 | private boolean otpAvailable = true; 15 | private String otpMessage = null; 16 | private boolean pushAvailable = false; 17 | private String pushMessage = null; 18 | private String webAuthnSignRequest = null; 19 | private String autoSubmitLength = null; 20 | private String transactionId = null; 21 | private String pollInBrowserURL = null; 22 | private int pollInterval = 2; 23 | private String errorMessage = null; 24 | private String pushImage = null; 25 | private String otpImage = null; 26 | private String webAuthnImage = null; 27 | private String enrollmentLink = null; 28 | private boolean challengesTriggered = false; 29 | // passkeyChallenge is separate from fido2SignRequest, because we need to remember if we are using passkey (=> we get the username 30 | // from privacyIDEA) or the regular webauthn that has been triggered for the user explicitly. 31 | private String passkeyRegistration = null; 32 | private String passkeyChallenge = null; 33 | private boolean disablePasskeyLogin = false; 34 | private boolean isEnrollViaMultichallenge = false; 35 | 36 | public AuthenticationForm(Configuration configuration) 37 | { 38 | this.disablePasskeyLogin = configuration.isDisablePasskeyLogin(); 39 | if (configuration.pollInBrowser()) 40 | { 41 | this.pollInBrowserURL = configuration.pollInBrowserUrl(); 42 | } 43 | this.autoSubmitLength = configuration.otpLength(); 44 | } 45 | 46 | public boolean isFirstStep() 47 | { 48 | return Mode.USERNAME.equals(mode) || Mode.USERNAMEPASSWORD.equals(mode); 49 | } 50 | 51 | public boolean isPollInBrowserAvailable() 52 | { 53 | return StringUtil.isNotBlank(pollInBrowserURL) && StringUtil.isNotBlank(transactionId); 54 | } 55 | 56 | public String getPushImage() 57 | { 58 | return pushImage; 59 | } 60 | 61 | public void setPushImage(String pushImage) 62 | { 63 | this.pushImage = pushImage; 64 | } 65 | 66 | public String getOtpImage() 67 | { 68 | return otpImage; 69 | } 70 | 71 | public void setOtpImage(String otpImage) 72 | { 73 | this.otpImage = otpImage; 74 | } 75 | 76 | public String getWebAuthnImage() 77 | { 78 | return webAuthnImage; 79 | } 80 | 81 | public void setWebAuthnImage(String webAuthnImage) 82 | { 83 | this.webAuthnImage = webAuthnImage; 84 | } 85 | 86 | public String getErrorMessage() 87 | { 88 | return errorMessage; 89 | } 90 | 91 | public void setErrorMessage(String errorMessage) 92 | { 93 | this.errorMessage = errorMessage; 94 | } 95 | 96 | public String getImage() 97 | { 98 | return image; 99 | } 100 | 101 | public void setImage(String image) 102 | { 103 | this.image = image; 104 | } 105 | 106 | private String image = null; 107 | 108 | public boolean isOtpAvailable() 109 | { 110 | return otpAvailable; 111 | } 112 | 113 | public void setOtpAvailable(boolean otpAvailable) 114 | { 115 | this.otpAvailable = otpAvailable; 116 | } 117 | 118 | public String getOtpMessage() 119 | { 120 | return otpMessage; 121 | } 122 | 123 | public void setOtpMessage(String otpMessage) 124 | { 125 | this.otpMessage = otpMessage; 126 | } 127 | 128 | public boolean isPushAvailable() 129 | { 130 | return pushAvailable; 131 | } 132 | 133 | public void setPushAvailable(boolean pushAvailable) 134 | { 135 | this.pushAvailable = pushAvailable; 136 | } 137 | 138 | public String getPushMessage() 139 | { 140 | return pushMessage; 141 | } 142 | 143 | public void setPushMessage(String pushMessage) 144 | { 145 | this.pushMessage = pushMessage; 146 | } 147 | 148 | public String getWebAuthnSignRequest() 149 | { 150 | return webAuthnSignRequest; 151 | } 152 | 153 | public void setWebAuthnSignRequest(String webAuthnSignRequest) 154 | { 155 | this.webAuthnSignRequest = webAuthnSignRequest; 156 | } 157 | 158 | public String getAutoSubmitLength() 159 | { 160 | return autoSubmitLength; 161 | } 162 | 163 | public void setAutoSubmitLength(String autoSubmitLength) 164 | { 165 | this.autoSubmitLength = autoSubmitLength; 166 | } 167 | 168 | public String getTransactionId() 169 | { 170 | return transactionId; 171 | } 172 | 173 | public void setTransactionId(String transactionId) 174 | { 175 | this.transactionId = transactionId; 176 | } 177 | 178 | public String getPollInBrowserURL() 179 | { 180 | return pollInBrowserURL; 181 | } 182 | 183 | public void setPollInBrowserURL(String pollInBrowserURL) 184 | { 185 | this.pollInBrowserURL = pollInBrowserURL; 186 | } 187 | 188 | public int getPollInterval() 189 | { 190 | return pollInterval; 191 | } 192 | 193 | public void setPollInterval(int pollInterval) 194 | { 195 | this.pollInterval = pollInterval; 196 | } 197 | 198 | public Mode getMode() 199 | { 200 | if (mode == null) 201 | { 202 | return Mode.OTP; 203 | } 204 | return mode; 205 | } 206 | 207 | public void setMode(Mode mode) 208 | { 209 | this.mode = mode; 210 | } 211 | 212 | public String toString() 213 | { 214 | return new Gson().toJson(this); 215 | } 216 | 217 | public static AuthenticationForm fromJson(String json) 218 | { 219 | return new Gson().fromJson(json, AuthenticationForm.class); 220 | } 221 | 222 | public boolean isChallengesTriggered() 223 | { 224 | return challengesTriggered; 225 | } 226 | 227 | public void setChallengesTriggered(boolean challengesTriggered) 228 | { 229 | this.challengesTriggered = challengesTriggered; 230 | } 231 | 232 | public String getPasskeyRegistration() 233 | { 234 | return passkeyRegistration; 235 | } 236 | 237 | public void setPasskeyRegistration(String passkeyRegistration) 238 | { 239 | this.passkeyRegistration = passkeyRegistration; 240 | } 241 | 242 | public String getPasskeyChallenge() 243 | { 244 | return passkeyChallenge; 245 | } 246 | 247 | public void setPasskeyChallenge(String passkeyChallenge) 248 | { 249 | this.passkeyChallenge = passkeyChallenge; 250 | } 251 | 252 | public String getEnrollmentLink() 253 | { 254 | return enrollmentLink; 255 | } 256 | 257 | public void setEnrollmentLink(String enrollmentLink) 258 | { 259 | this.enrollmentLink = enrollmentLink; 260 | } 261 | 262 | public boolean isDisablePasskeyLogin() 263 | { 264 | return disablePasskeyLogin; 265 | } 266 | 267 | public void setDisablePasskeyLogin(boolean disablePasskeyLogin) 268 | { 269 | this.disablePasskeyLogin = disablePasskeyLogin; 270 | } 271 | 272 | public boolean isEnrollViaMultichallenge() 273 | { 274 | return isEnrollViaMultichallenge; 275 | } 276 | 277 | public void setEnrollViaMultichallenge(boolean enrollViaMultichallenge) 278 | { 279 | isEnrollViaMultichallenge = enrollViaMultichallenge; 280 | } 281 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/AuthenticationFormResult.java: -------------------------------------------------------------------------------- 1 | package org.privacyidea.authenticator; 2 | 3 | import com.google.gson.Gson; 4 | 5 | /** 6 | * This class holds the data that gets passed from js (/freemarker) back to java. It is assembled in js, serialized to json 7 | * and passed to java. 8 | */ 9 | public class AuthenticationFormResult 10 | { 11 | public boolean authenticationResetRequested = false; 12 | public boolean passkeyLoginRequested = false; 13 | public boolean passkeyLoginCancelled = false; 14 | public boolean modeChanged = false; 15 | public Mode newMode = null; 16 | // The SignResponse is differentiated for passkey and webauthn because passkey is expected to return the username, so the order of 17 | // their use is inverted. 18 | public String webAuthnSignResponse = null; 19 | public String passkeySignResponse = null; 20 | public String origin = null; 21 | public String passkeyRegistrationResponse = null; 22 | 23 | public String toString() { 24 | return new Gson().toJson(this); 25 | } 26 | 27 | public static AuthenticationFormResult fromJson(String json) { 28 | return new Gson().fromJson(json, AuthenticationFormResult.class); 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/Configuration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it 3 | * lukas.matusiewicz@netknights.it 4 | * - Modified 5 | *

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

10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | *

12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | package org.privacyidea.authenticator; 19 | 20 | import java.util.ArrayList; 21 | import java.util.Arrays; 22 | import java.util.HashMap; 23 | import java.util.List; 24 | import java.util.Map; 25 | 26 | import static org.privacyidea.authenticator.Const.CONFIG_CUSTOM_HEADERS; 27 | import static org.privacyidea.authenticator.Const.CONFIG_DISABLE_PASSWORD_CHECK; 28 | import static org.privacyidea.authenticator.Const.CONFIG_ENABLE_LOG; 29 | import static org.privacyidea.authenticator.Const.CONFIG_EXCLUDED_GROUPS; 30 | import static org.privacyidea.authenticator.Const.CONFIG_FORWARDED_HEADERS; 31 | import static org.privacyidea.authenticator.Const.CONFIG_FORWARD_CLIENT_IP; 32 | import static org.privacyidea.authenticator.Const.CONFIG_INCLUDED_GROUPS; 33 | import static org.privacyidea.authenticator.Const.CONFIG_OTP_LENGTH; 34 | import static org.privacyidea.authenticator.Const.CONFIG_POLL_IN_BROWSER; 35 | import static org.privacyidea.authenticator.Const.CONFIG_POLL_IN_BROWSER_URL; 36 | import static org.privacyidea.authenticator.Const.CONFIG_REALM; 37 | import static org.privacyidea.authenticator.Const.CONFIG_SEND_PASSWORD; 38 | import static org.privacyidea.authenticator.Const.CONFIG_SEND_STATIC_PASS; 39 | import static org.privacyidea.authenticator.Const.CONFIG_SERVER; 40 | import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_ACCOUNT; 41 | import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_PASS; 42 | import static org.privacyidea.authenticator.Const.CONFIG_SERVICE_REALM; 43 | import static org.privacyidea.authenticator.Const.CONFIG_STATIC_PASS; 44 | import static org.privacyidea.authenticator.Const.CONFIG_TRIGGER_CHALLENGE; 45 | import static org.privacyidea.authenticator.Const.CONFIG_VERIFY_SSL; 46 | import static org.privacyidea.authenticator.Const.POLLING_INTERVALS; 47 | import static org.privacyidea.authenticator.Const.TRUE; 48 | 49 | 50 | public class Configuration 51 | { 52 | private final String serverURL; 53 | private final String realm; 54 | private final boolean doSSLVerify; 55 | private final boolean doTriggerChallenge; 56 | private final boolean doSendPassword; 57 | private final boolean doSendStaticPass; 58 | private final String staticPass; 59 | private final String serviceAccountName; 60 | private final String serviceAccountPass; 61 | private final String serviceAccountRealm; 62 | private final List excludedGroups = new ArrayList<>(); 63 | private final List includedGroups = new ArrayList<>(); 64 | private final List forwardedHeaders = new ArrayList<>(); 65 | private final boolean forwardClientIP; 66 | private final String otpLength; 67 | private final boolean doLog; 68 | private final boolean pollInBrowser; 69 | private final String pollInBrowserUrl; 70 | private final List pollingInterval = new ArrayList<>(); 71 | private final int configHash; 72 | private final Map customHeaders = new HashMap<>(); 73 | private final int httpTimeoutMs; 74 | private final boolean disablePasswordCheck; 75 | private final boolean disablePasskeyLogin; 76 | 77 | public Configuration(Map configMap) 78 | { 79 | this.configHash = configMap.hashCode(); 80 | this.serverURL = configMap.get(CONFIG_SERVER); 81 | this.realm = configMap.get(CONFIG_REALM) == null ? "" : configMap.get(CONFIG_REALM); 82 | this.doSSLVerify = configMap.get(CONFIG_VERIFY_SSL) != null && configMap.get(CONFIG_VERIFY_SSL).equals(TRUE); 83 | this.doTriggerChallenge = configMap.get(CONFIG_TRIGGER_CHALLENGE) != null && configMap.get(CONFIG_TRIGGER_CHALLENGE).equals(TRUE); 84 | this.serviceAccountName = configMap.get(CONFIG_SERVICE_ACCOUNT) == null ? "" : configMap.get(CONFIG_SERVICE_ACCOUNT); 85 | this.serviceAccountPass = configMap.get(CONFIG_SERVICE_PASS) == null ? "" : configMap.get(CONFIG_SERVICE_PASS); 86 | this.serviceAccountRealm = configMap.get(CONFIG_SERVICE_REALM) == null ? "" : configMap.get(CONFIG_SERVICE_REALM); 87 | this.staticPass = configMap.get(CONFIG_STATIC_PASS) == null ? "" : configMap.get(CONFIG_STATIC_PASS); 88 | this.forwardClientIP = configMap.get(CONFIG_FORWARD_CLIENT_IP) != null && configMap.get(CONFIG_FORWARD_CLIENT_IP).equals(TRUE); 89 | this.otpLength = configMap.get(CONFIG_OTP_LENGTH) == null ? "" : configMap.get(CONFIG_OTP_LENGTH); 90 | this.pollInBrowser = (configMap.get(CONFIG_POLL_IN_BROWSER) != null && configMap.get(CONFIG_POLL_IN_BROWSER).equals(TRUE)); 91 | this.pollInBrowserUrl = configMap.get(CONFIG_POLL_IN_BROWSER_URL) == null ? "" : configMap.get(CONFIG_POLL_IN_BROWSER_URL); 92 | this.doSendPassword = configMap.get(CONFIG_SEND_PASSWORD) != null && configMap.get(CONFIG_SEND_PASSWORD).equals(TRUE); 93 | this.doSendStaticPass = configMap.get(CONFIG_SEND_STATIC_PASS) != null && configMap.get(CONFIG_SEND_STATIC_PASS).equals(TRUE); 94 | this.doLog = configMap.get(CONFIG_ENABLE_LOG) != null && configMap.get(CONFIG_ENABLE_LOG).equals(TRUE); 95 | this.disablePasswordCheck = 96 | configMap.get(CONFIG_DISABLE_PASSWORD_CHECK) != null && configMap.get(CONFIG_DISABLE_PASSWORD_CHECK).equals(TRUE); 97 | this.disablePasskeyLogin = 98 | configMap.get("pidisablepasskeylogin") != null && configMap.get("pidisablepasskeylogin").equals(TRUE); 99 | String excludedGroupsStr = configMap.get(CONFIG_EXCLUDED_GROUPS); 100 | if (excludedGroupsStr != null) 101 | { 102 | this.excludedGroups.addAll(Arrays.asList(excludedGroupsStr.split(","))); 103 | } 104 | 105 | String includedGroupsStr = configMap.get(CONFIG_INCLUDED_GROUPS); 106 | if (includedGroupsStr != null) 107 | { 108 | this.includedGroups.addAll(Arrays.asList(includedGroupsStr.split(","))); 109 | } 110 | 111 | String forwardedHeadersStr = configMap.get(CONFIG_FORWARDED_HEADERS); 112 | if (forwardedHeadersStr != null) 113 | { 114 | this.forwardedHeaders.addAll(Arrays.asList(forwardedHeadersStr.split(","))); 115 | } 116 | 117 | this.pollingInterval.addAll(POLLING_INTERVALS); 118 | 119 | String entry = configMap.get(CONFIG_CUSTOM_HEADERS); 120 | if (entry != null && !entry.isEmpty()) 121 | { 122 | String[] entries = entry.split("##"); 123 | for (String e : entries) 124 | { 125 | if (e == null || e.isEmpty()) 126 | { 127 | continue; 128 | } 129 | String[] keyValue = e.split("="); 130 | if (keyValue.length == 2) 131 | { 132 | customHeaders.put(keyValue[0], keyValue[1]); 133 | } 134 | } 135 | } 136 | 137 | this.httpTimeoutMs = Integer.parseInt(configMap.getOrDefault("httpTimeoutMs", "10000")); 138 | } 139 | 140 | int configHash() 141 | { 142 | return configHash; 143 | } 144 | 145 | String serverURL() 146 | { 147 | return serverURL; 148 | } 149 | 150 | String realm() 151 | { 152 | return realm; 153 | } 154 | 155 | boolean sslVerify() 156 | { 157 | return doSSLVerify; 158 | } 159 | 160 | boolean triggerChallenge() 161 | { 162 | return doTriggerChallenge; 163 | } 164 | 165 | boolean sendStaticPass() 166 | { 167 | return doSendStaticPass; 168 | } 169 | 170 | String staticPass() 171 | { 172 | return staticPass; 173 | } 174 | 175 | String serviceAccountName() 176 | { 177 | return serviceAccountName; 178 | } 179 | 180 | String serviceAccountPass() 181 | { 182 | return serviceAccountPass; 183 | } 184 | 185 | String serviceAccountRealm() 186 | { 187 | return serviceAccountRealm; 188 | } 189 | 190 | List excludedGroups() 191 | { 192 | return excludedGroups; 193 | } 194 | 195 | List includedGroups() 196 | { 197 | return includedGroups; 198 | } 199 | 200 | List forwardedHeaders() 201 | { 202 | return forwardedHeaders; 203 | } 204 | 205 | boolean forwardClientIP() 206 | { 207 | return forwardClientIP; 208 | } 209 | 210 | String otpLength() 211 | { 212 | return otpLength; 213 | } 214 | 215 | boolean pollInBrowser() 216 | { 217 | return pollInBrowser; 218 | } 219 | 220 | String pollInBrowserUrl() 221 | { 222 | return pollInBrowserUrl; 223 | } 224 | 225 | List pollingInterval() 226 | { 227 | return pollingInterval; 228 | } 229 | 230 | boolean doLog() 231 | { 232 | return doLog; 233 | } 234 | 235 | boolean sendPassword() 236 | { 237 | return doSendPassword; 238 | } 239 | 240 | Map customHeaders() 241 | { 242 | return customHeaders; 243 | } 244 | 245 | public int httpTimeoutMs() 246 | { 247 | return httpTimeoutMs; 248 | } 249 | 250 | public boolean isDisablePasswordCheck() 251 | { 252 | return disablePasswordCheck; 253 | } 254 | 255 | public boolean isDisablePasskeyLogin() 256 | { 257 | return disablePasskeyLogin; 258 | } 259 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/Const.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it 3 | * lukas.matusiewicz@netknights.it 4 | * - Modified 5 | *

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

10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | *

12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | package org.privacyidea.authenticator; 19 | 20 | import java.util.Arrays; 21 | import java.util.List; 22 | 23 | final class Const 24 | { 25 | private Const() 26 | { 27 | } 28 | 29 | static final String PROVIDER_ID = "privacyidea-authenticator"; 30 | static final String PLUGIN_USER_AGENT = "privacyIDEA-Keycloak"; 31 | 32 | static final String TRUE = "true"; 33 | 34 | static final String HEADER_ACCEPT_LANGUAGE = "accept-language"; 35 | // Will be used if no intervals are specified 36 | static final List POLLING_INTERVALS = Arrays.asList(4, 3, 2); 37 | 38 | static final String FORM_FILE_NAME = "privacyIDEA.ftl"; 39 | static final String FORM_OTP = "otp"; 40 | 41 | static final String NOTE_OTP_TRANSACTION_ID = "pi_otp_transaction_id"; 42 | static final String NOTE_WEBAUTHN_TRANSACTION_ID = "pi_webauthn_transaction_id"; 43 | static final String NOTE_PUSH_TRANSACTION_ID = "pi_push_transaction_id"; 44 | static final String NOTE_PASSKEY_TRANSACTION_ID = "pi_passkey_transaction_id"; 45 | static final String NOTE_COUNTER = "authCounter"; 46 | static final String NOTE_PASSKEY_REGISTRATION_SERIAL = "passkey_registration_serial"; 47 | static final String NOTE_PREVIOUS_RESPONSE = "pi_previous_response"; 48 | 49 | // Changing the config value names will reset the current config 50 | static final String CONFIG_PUSH_INTERVAL = "pipushtokeninterval"; 51 | static final String CONFIG_EXCLUDED_GROUPS = "piexcludegroups"; 52 | static final String CONFIG_INCLUDED_GROUPS = "piincludegroups"; 53 | static final String CONFIG_FORWARDED_HEADERS = "piforwardedheaders"; 54 | static final String CONFIG_FORWARD_CLIENT_IP = "piforwardclientip"; 55 | static final String CONFIG_POLL_IN_BROWSER = "pipollinbrowser"; 56 | static final String CONFIG_POLL_IN_BROWSER_URL = "pipollinbrowserurl"; 57 | static final String CONFIG_SEND_PASSWORD = "pisendpassword"; 58 | static final String CONFIG_TRIGGER_CHALLENGE = "pidotriggerchallenge"; 59 | static final String CONFIG_SEND_STATIC_PASS = "pisendstaticpass"; 60 | static final String CONFIG_OTP_LENGTH = "piotplength"; 61 | static final String CONFIG_SERVICE_PASS = "piservicepass"; 62 | static final String CONFIG_SERVICE_ACCOUNT = "piserviceaccount"; 63 | static final String CONFIG_SERVICE_REALM = "piservicerealm"; 64 | static final String CONFIG_STATIC_PASS = "pistaticpass"; 65 | static final String CONFIG_VERIFY_SSL = "piverifyssl"; 66 | static final String CONFIG_REALM = "pirealm"; 67 | static final String CONFIG_SERVER = "piserver"; 68 | static final String CONFIG_ENABLE_LOG = "pidolog"; 69 | static final String CONFIG_CUSTOM_HEADERS = "picustomheaders"; 70 | static final String CONFIG_HTTP_TIMEOUT_MS = "pihttptimeoutms"; 71 | static final String CONFIG_DISABLE_PASSWORD_CHECK = "pidisablepasswordcheck"; 72 | static final String CONFIG_DISABLE_PASSKEY_LOGIN = "pidisablepasskeylogin"; 73 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/Mode.java: -------------------------------------------------------------------------------- 1 | package org.privacyidea.authenticator; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | public enum Mode 6 | { 7 | @SerializedName("username") 8 | USERNAME("username"), 9 | @SerializedName("password") 10 | PASSWORD("password"), 11 | @SerializedName("usernamepassword") 12 | USERNAMEPASSWORD("usernamepassword"), 13 | @SerializedName("otp") 14 | OTP("otp"), 15 | @SerializedName("passkey") 16 | PASSKEY("passkey"), 17 | @SerializedName("webauthn") 18 | WEBAUTHN("webauthn"), 19 | @SerializedName("push") 20 | PUSH("push"); 21 | 22 | private final String mode; 23 | 24 | Mode(String mode) 25 | { 26 | this.mode = mode; 27 | } 28 | 29 | @Override 30 | public String toString() 31 | { 32 | return mode.toLowerCase(); 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/Pair.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it 3 | * lukas.matusiewicz@netknights.it 4 | *

5 | * Based on original code: 6 | *

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

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

14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | *

16 | * Unless required by applicable law or agreed to in writing, software 17 | * distributed under the License is distributed on an "AS IS" BASIS, 18 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | * See the License for the specific language governing permissions and 20 | * limitations under the License. 21 | */ 22 | package org.privacyidea.authenticator; 23 | 24 | import org.privacyidea.PrivacyIDEA; 25 | 26 | public class Pair 27 | { 28 | private final PrivacyIDEA privacyIDEA; 29 | private final Configuration configuration; 30 | 31 | public Pair(PrivacyIDEA privacyIDEA, Configuration configuration) 32 | { 33 | this.privacyIDEA = privacyIDEA; 34 | this.configuration = configuration; 35 | } 36 | 37 | public PrivacyIDEA privacyIDEA() 38 | { 39 | return privacyIDEA; 40 | } 41 | 42 | public Configuration configuration() 43 | { 44 | return configuration; 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 NetKnights GmbH - micha.preusser@netknights.it 3 | * nils.behlen@netknights.it 4 | * lukas.matusiewicz@netknights.it 5 | * - Modified 6 | *

7 | * Based on original code: 8 | *

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

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

16 | * http://www.apache.org/licenses/LICENSE-2.0 17 | *

18 | * Unless required by applicable law or agreed to in writing, software 19 | * distributed under the License is distributed on an "AS IS" BASIS, 20 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | * See the License for the specific language governing permissions and 22 | * limitations under the License. 23 | */ 24 | package org.privacyidea.authenticator; 25 | 26 | import jakarta.ws.rs.core.MultivaluedMap; 27 | import jakarta.ws.rs.core.Response; 28 | import java.io.IOException; 29 | import java.util.Map; 30 | import java.util.concurrent.ConcurrentHashMap; 31 | import org.jboss.logging.Logger; 32 | import org.keycloak.authentication.AuthenticationFlowContext; 33 | import org.keycloak.authentication.AuthenticationFlowError; 34 | import org.keycloak.authentication.AuthenticationFlowException; 35 | import org.keycloak.common.Version; 36 | import org.keycloak.forms.login.LoginFormsProvider; 37 | import org.keycloak.models.KeycloakSession; 38 | import org.keycloak.models.RealmModel; 39 | import org.keycloak.models.UserCredentialModel; 40 | import org.keycloak.models.UserModel; 41 | import org.keycloak.utils.StringUtil; 42 | import org.privacyidea.AuthenticationStatus; 43 | import org.privacyidea.ChallengeStatus; 44 | import org.privacyidea.IPILogger; 45 | import org.privacyidea.PIResponse; 46 | import org.privacyidea.PrivacyIDEA; 47 | 48 | import static org.privacyidea.PIConstants.AUTH_FORM; 49 | import static org.privacyidea.PIConstants.AUTH_FORM_RESULT; 50 | import static org.privacyidea.PIConstants.PASSWORD; 51 | import static org.privacyidea.PIConstants.USERNAME; 52 | import static org.privacyidea.authenticator.Const.FORM_FILE_NAME; 53 | import static org.privacyidea.authenticator.Const.FORM_OTP; 54 | import static org.privacyidea.authenticator.Const.NOTE_COUNTER; 55 | import static org.privacyidea.authenticator.Const.NOTE_OTP_TRANSACTION_ID; 56 | import static org.privacyidea.authenticator.Const.NOTE_PASSKEY_REGISTRATION_SERIAL; 57 | import static org.privacyidea.authenticator.Const.NOTE_PASSKEY_TRANSACTION_ID; 58 | import static org.privacyidea.authenticator.Const.NOTE_PUSH_TRANSACTION_ID; 59 | import static org.privacyidea.authenticator.Const.NOTE_WEBAUTHN_TRANSACTION_ID; 60 | import static org.privacyidea.authenticator.Const.PLUGIN_USER_AGENT; 61 | 62 | public class PrivacyIDEAAuthenticator implements org.keycloak.authentication.Authenticator, IPILogger 63 | { 64 | private final Logger logger = Logger.getLogger(PrivacyIDEAAuthenticator.class); 65 | private final Util util; 66 | private final ConcurrentHashMap piInstanceMap = new ConcurrentHashMap<>(); 67 | private boolean logEnabled = false; 68 | 69 | public PrivacyIDEAAuthenticator() 70 | { 71 | log("PrivacyIDEA Authenticator initialized."); 72 | this.util = new Util(this); 73 | } 74 | 75 | /** 76 | * Create new instances of PrivacyIDEA and the Configuration, if it does not exist yet. 77 | * Also adds them to the instance map. 78 | * 79 | * @param context for authentication flow 80 | */ 81 | private Pair loadConfiguration(final AuthenticationFlowContext context) 82 | { 83 | // Get the configuration and privacyIDEA instance for the current realm 84 | // If none is found or the configuration has changed, create a new one 85 | final String kcRealm = context.getRealm().getName(); 86 | final Pair currentPair = piInstanceMap.get(kcRealm); 87 | final int incomingHash = context.getAuthenticatorConfig().getConfig().hashCode(); 88 | if (currentPair == null || incomingHash != currentPair.configuration().configHash()) 89 | { 90 | log("Creating new privacyIDEA instance for realm " + kcRealm); 91 | final Map configMap = context.getAuthenticatorConfig().getConfig(); 92 | Configuration config = new Configuration(configMap); 93 | String kcVersion = Version.VERSION; 94 | String providerVersion = PrivacyIDEAAuthenticator.class.getPackage().getImplementationVersion(); 95 | String fullUserAgent = PLUGIN_USER_AGENT + "/" + providerVersion + " Keycloak/" + kcVersion; 96 | PrivacyIDEA privacyIDEA = PrivacyIDEA.newBuilder(config.serverURL(), fullUserAgent) 97 | .verifySSL(config.sslVerify()) 98 | .logger(this) 99 | .realm(config.realm()) 100 | .serviceAccount(config.serviceAccountName(), config.serviceAccountPass()) 101 | .serviceRealm(config.serviceAccountRealm()) 102 | .httpTimeoutMs(config.httpTimeoutMs()) 103 | .build(); 104 | 105 | // Close the old privacyIDEA instance to shut down the thread pool before replacing it in the map 106 | if (currentPair != null) 107 | { 108 | try 109 | { 110 | currentPair.privacyIDEA().close(); 111 | } 112 | catch (IOException e) 113 | { 114 | error("Failed to close privacyIDEA instance!"); 115 | } 116 | } 117 | Pair pair = new Pair(privacyIDEA, config); 118 | piInstanceMap.put(kcRealm, pair); 119 | } 120 | 121 | return piInstanceMap.get(kcRealm); 122 | } 123 | 124 | /** 125 | * This function is called when the authentication flow triggers the privacyIDEA execution. 126 | * 127 | * @param context AuthenticationFlowContext 128 | */ 129 | @Override 130 | public void authenticate(AuthenticationFlowContext context) 131 | { 132 | final Pair currentPair = loadConfiguration(context); 133 | PrivacyIDEA privacyIDEA = currentPair.privacyIDEA(); 134 | Configuration config = currentPair.configuration(); 135 | logEnabled = config.doLog(); 136 | AuthenticationForm piForm = new AuthenticationForm(config); 137 | piForm.setPollInterval(config.pollingInterval().get(0)); 138 | 139 | // Check if a user is already present. 140 | // If no user is present, request it. Optionally request the password if not disabled. 141 | UserModel user = context.getUser(); 142 | if (user == null) 143 | { 144 | context.clearUser(); 145 | piForm.setMode(config.isDisablePasswordCheck() ? Mode.USERNAME : Mode.USERNAMEPASSWORD); 146 | } 147 | else 148 | { 149 | // Check if the current user is member of an included or excluded group 150 | boolean noMFAbyGroup = util.checkMFAExcludedByGroup(config, user); 151 | if (noMFAbyGroup) 152 | { 153 | context.success(); 154 | return; 155 | } 156 | } 157 | String currentPassword = null; 158 | // In some cases, there will be no FormParameters so check if it is even possible to get the password 159 | if (config.sendPassword() && context.getHttpRequest() != null && context.getHttpRequest().getDecodedFormParameters() != null && 160 | context.getHttpRequest().getDecodedFormParameters().get(PASSWORD) != null) 161 | { 162 | currentPassword = context.getHttpRequest().getDecodedFormParameters().get(PASSWORD).get(0); 163 | } 164 | 165 | Map headers = util.getHeaders(context, config); 166 | 167 | // Trigger challenges if configured. If not, the function does nothing 168 | if (user != null) 169 | { 170 | PIResponse response = util.tryTriggerFirstStep(user.getUsername(), privacyIDEA, config, currentPassword, 171 | util.getAdditionalParamsFromContext(context, config), headers); 172 | if (response != null) 173 | { 174 | if (response.authenticationSuccessful()) 175 | { 176 | context.success(); 177 | return; 178 | } 179 | piForm = util.evaluateResponse(response, context, piForm, config); 180 | } 181 | } 182 | 183 | // Prepare the form and auth notes to pass infos to the UI and the next step 184 | context.getAuthenticationSession().setAuthNote(NOTE_COUNTER, "0"); 185 | context.form().setAttribute(AUTH_FORM, piForm); 186 | Response responseForm = context.form().createForm(FORM_FILE_NAME); 187 | 188 | context.challenge(responseForm); 189 | } 190 | 191 | /** 192 | * This function is called when the privacyIDEA form is submitted. 193 | * 194 | * @param context AuthenticationFlowContext 195 | */ 196 | @Override 197 | public void action(AuthenticationFlowContext context) 198 | { 199 | //log("action() called."); 200 | // Get the configuration and privacyIDEA instance for the current realm 201 | loadConfiguration(context); 202 | String kcRealm = context.getRealm().getName(); 203 | PrivacyIDEA privacyIDEA; 204 | Configuration config; 205 | if (piInstanceMap.containsKey(kcRealm)) 206 | { 207 | Pair pair = piInstanceMap.get(kcRealm); 208 | privacyIDEA = pair.privacyIDEA(); 209 | config = pair.configuration(); 210 | } 211 | else 212 | { 213 | throw new AuthenticationFlowException("No privacyIDEA configuration found for kc-realm " + kcRealm, 214 | AuthenticationFlowError.IDENTITY_PROVIDER_NOT_FOUND); 215 | } 216 | 217 | // Check for cancel 218 | MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); 219 | if (formData.containsKey("cancel")) 220 | { 221 | context.resetFlow(); 222 | return; 223 | } 224 | // Get the data from the forms and session 225 | LoginFormsProvider kcForm = context.form(); 226 | //log("formData:"); 227 | //formData.forEach((k, v) -> log("key=" + k + ", value=" + v)); 228 | // AuthenticationFormResult 229 | if (!formData.containsKey(AUTH_FORM_RESULT)) 230 | { 231 | logger.error("No authenticationFormResult found in form data!"); 232 | return; 233 | } 234 | String t = formData.getFirst(AUTH_FORM_RESULT); 235 | AuthenticationFormResult piFormResult = AuthenticationFormResult.fromJson(t); 236 | if (piFormResult == null) 237 | { 238 | logger.error("AuthenticationFormResult could not be parsed: " + t); 239 | return; 240 | } 241 | // AuthenticationForm 242 | if (!formData.containsKey(AUTH_FORM)) 243 | { 244 | logger.error("No authenticationForm found in form data!"); 245 | return; 246 | } 247 | t = formData.getFirst(AUTH_FORM); 248 | AuthenticationForm piForm = AuthenticationForm.fromJson(t); 249 | if (piForm == null) 250 | { 251 | logger.error("AuthenticationForm could not be parsed: " + t); 252 | return; 253 | } 254 | //logger.error("PiForm: " + piForm); 255 | //logger.error("PiFormResult: " + piFormResult); 256 | // Reset requested: reset the flow 257 | if (piFormResult.authenticationResetRequested) 258 | { 259 | context.resetFlow(); 260 | return; 261 | } 262 | piForm.setAutoSubmitLength(config.otpLength()); 263 | String otpTransactionId = context.getAuthenticationSession().getAuthNote(NOTE_OTP_TRANSACTION_ID); 264 | String pushTransactionId = context.getAuthenticationSession().getAuthNote(NOTE_PUSH_TRANSACTION_ID); 265 | String webAuthnTransactionId = context.getAuthenticationSession().getAuthNote(NOTE_WEBAUTHN_TRANSACTION_ID); 266 | Map headers = util.getHeaders(context, config); 267 | kcForm.setAttribute(AUTH_FORM, piForm); 268 | 269 | boolean didTrigger = false; 270 | PIResponse response = null; 271 | 272 | // Passkey: Will return the username and end the authentication on success. This is different from the WebAuthn authentication 273 | // Which is attempted later. 274 | if (StringUtil.isNotBlank(piFormResult.passkeySignResponse)) 275 | { 276 | if (StringUtil.isBlank(piFormResult.origin)) 277 | { 278 | logger.error("Origin is missing for WebAuthn authentication!"); 279 | } 280 | else 281 | { 282 | String passkeyTransactionID = context.getAuthenticationSession().getAuthNote(NOTE_PASSKEY_TRANSACTION_ID); 283 | response = privacyIDEA.validateCheckPasskey(passkeyTransactionID, piFormResult.passkeySignResponse, piFormResult.origin, 284 | headers); 285 | if (response != null) 286 | { 287 | if (response.authenticationSuccessful()) 288 | { 289 | if (StringUtil.isNotBlank(response.username)) 290 | { 291 | context.clearUser(); 292 | UserModel userModel = context.getSession().users().getUserByUsername(context.getRealm(), response.username); 293 | if (userModel == null) 294 | { 295 | error("User " + response.username + " not found in realm " + context.getRealm().getName()); 296 | kcForm.setError("User not found!"); 297 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 298 | context.challenge(responseForm); 299 | return; 300 | } 301 | context.setUser(userModel); 302 | } 303 | if (context.getUser() == null) 304 | { 305 | error("No user set after passkey authentication!"); 306 | context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR); 307 | return; 308 | } 309 | context.success(); 310 | } 311 | else 312 | { 313 | piForm.setErrorMessage("passkey_authentication_failed"); 314 | kcForm.setAttribute(AUTH_FORM, piForm); 315 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 316 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseForm); 317 | } 318 | return; 319 | } 320 | } 321 | } 322 | // Passkey login requested: Get a challenge and return 323 | if (piFormResult.passkeyLoginRequested) 324 | { 325 | PIResponse res = privacyIDEA.validateInitialize("passkey"); 326 | if (res != null && StringUtil.isNotBlank(res.passkeyChallenge)) 327 | { 328 | piForm.setPasskeyChallenge(res.passkeyChallenge); 329 | piForm.setMode(Mode.PASSKEY); 330 | kcForm.setAttribute(AUTH_FORM, piForm); 331 | context.getAuthenticationSession().setAuthNote(NOTE_PASSKEY_TRANSACTION_ID, res.transactionID); 332 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 333 | context.challenge(responseForm); 334 | return; 335 | } 336 | } 337 | // Passkey login cancelled: Remove the challenge and transaction ID 338 | if (piFormResult.passkeyLoginCancelled) 339 | { 340 | piForm.setPasskeyChallenge(""); 341 | context.getAuthenticationSession().removeAuthNote(NOTE_PASSKEY_TRANSACTION_ID); 342 | } 343 | // Passkey registration: enroll_via_multichallenge, this is after successful authentication 344 | if (StringUtil.isNotBlank(piFormResult.passkeyRegistrationResponse)) 345 | { 346 | String serial = context.getAuthenticationSession().getAuthNote(NOTE_PASSKEY_REGISTRATION_SERIAL); 347 | String transactionId = context.getAuthenticationSession().getAuthNote(NOTE_PASSKEY_TRANSACTION_ID); 348 | PIResponse res = privacyIDEA.validateCheckCompletePasskeyRegistration(transactionId, serial, context.getUser().getUsername(), 349 | piFormResult.passkeyRegistrationResponse, 350 | piFormResult.origin, headers); 351 | if (res != null && res.value) 352 | { 353 | context.success(); 354 | return; 355 | } 356 | else if (res != null && res.error != null) 357 | { 358 | kcForm.setError(res.error.message); 359 | kcForm.setAttribute(AUTH_FORM, piForm); 360 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 361 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseForm); 362 | return; 363 | } 364 | } 365 | 366 | // Set the user and verify the password, check if MFA is required for the user 367 | boolean userRequested = piForm.getMode() == Mode.USERNAMEPASSWORD || piForm.getMode() == Mode.USERNAME; 368 | if (userRequested) 369 | { 370 | String username = formData.getFirst(USERNAME); 371 | 372 | if (StringUtil.isBlank(username)) 373 | { 374 | logger.error("Username was requested but has not been provided!"); 375 | kcForm.setError("Username is required!"); 376 | kcForm.setAttribute(AUTH_FORM, piForm); 377 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 378 | context.challenge(responseForm); 379 | return; 380 | } 381 | UserModel userModel = context.getSession().users().getUserByUsername(context.getRealm(), username); 382 | if (userModel == null) 383 | { 384 | logger.error("User " + username + " not found in realm " + context.getRealm().getName()); 385 | kcForm.setError("Invalid Credentials!"); 386 | kcForm.setAttribute(AUTH_FORM, piForm); 387 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 388 | context.challenge(responseForm); 389 | return; 390 | } 391 | if (!config.isDisablePasswordCheck()) 392 | { 393 | String password = formData.getFirst(PASSWORD); 394 | boolean passwordCorrect = userModel.credentialManager().isValid(UserCredentialModel.password(password)); 395 | if (!passwordCorrect) 396 | { 397 | logger.debug("User " + username + " tried to authenticate with a wrong password."); 398 | kcForm.setError("Invalid Credentials!"); 399 | kcForm.setAttribute(AUTH_FORM, piForm); 400 | Response responseForm = kcForm.createForm(FORM_FILE_NAME); 401 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseForm); 402 | return; 403 | } 404 | } 405 | context.clearUser(); 406 | context.setUser(userModel); 407 | 408 | // Now that we have a user, we can check if MFA is required for the user's groups 409 | boolean mfaExcludedByGroup = util.checkMFAExcludedByGroup(config, userModel); 410 | if (mfaExcludedByGroup) 411 | { 412 | context.success(); 413 | return; 414 | } 415 | } 416 | 417 | // OTP / Push / WebAuthn: User has to be present by now 418 | if (context.getUser() == null) 419 | { 420 | logger.error("User is not available in the context!"); 421 | context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR); 422 | return; 423 | } 424 | String currentUsername = context.getUser().getUsername(); 425 | Mode currentMode = piFormResult.modeChanged ? piFormResult.newMode : piForm.getMode(); 426 | piForm.setMode(currentMode); 427 | kcForm.setAttribute(AUTH_FORM, piForm); 428 | 429 | // Send a request to privacyIDEA depending on the mode. Evaluation of the response is done afterward independently of the mode. 430 | if (Mode.PUSH.equals(currentMode)) 431 | { 432 | // In push mode, poll for the transaction id to see if the challenge has been answered 433 | ChallengeStatus pollTransactionStatus = privacyIDEA.pollTransaction(pushTransactionId); 434 | if (pollTransactionStatus == ChallengeStatus.accept) 435 | { 436 | // If the challenge has been answered, finalize with a call to validate check 437 | response = privacyIDEA.validateCheck(currentUsername, "", pushTransactionId, 438 | util.getAdditionalParamsFromContext(context, config), headers); 439 | } 440 | else if (pollTransactionStatus == ChallengeStatus.declined) 441 | { 442 | // If challenge has been declined, show the error message 443 | log("Push Authentication declined by the user."); 444 | context.cancelLogin(); 445 | } 446 | else if (pollTransactionStatus != ChallengeStatus.pending) 447 | { 448 | // If poll transaction failed, show the error message and fallback to otp mode. 449 | kcForm.setError("Push authentication failed. Please use a different token or restart the login."); 450 | piForm.setMode(Mode.OTP); 451 | } 452 | } 453 | else if (StringUtil.isNotBlank(piFormResult.webAuthnSignResponse)) 454 | { 455 | if (StringUtil.isBlank(piFormResult.origin)) 456 | { 457 | logger.error("Origin is missing for WebAuthn authentication!"); 458 | } 459 | else 460 | { 461 | response = privacyIDEA.validateCheckWebAuthn(currentUsername, webAuthnTransactionId, piFormResult.webAuthnSignResponse, 462 | piFormResult.origin, util.getAdditionalParamsFromContext(context, config), 463 | headers); 464 | } 465 | } 466 | else if (Mode.USERNAMEPASSWORD.equals(currentMode) || Mode.USERNAME.equals(currentMode)) 467 | { 468 | String password = formData.getFirst(PASSWORD); 469 | response = util.tryTriggerFirstStep(currentUsername, privacyIDEA, config, password, 470 | util.getAdditionalParamsFromContext(context, config), headers); 471 | } 472 | else if (!piFormResult.modeChanged) 473 | { 474 | // /validate/check with the OTP input 475 | response = privacyIDEA.validateCheck(currentUsername, formData.getFirst(FORM_OTP), otpTransactionId, 476 | util.getAdditionalParamsFromContext(context, config), headers); 477 | } 478 | 479 | // Evaluate the response: Check for success, error or new challenges 480 | if (response != null) 481 | { 482 | if (response.authenticationSuccessful()) 483 | { 484 | context.success(); 485 | return; 486 | } 487 | if (response.error != null) 488 | { 489 | kcForm.setError(response.error.message); 490 | context.failureChallenge(AuthenticationFlowError.INVALID_USER, kcForm.createForm(FORM_FILE_NAME)); 491 | return; 492 | } 493 | piForm = util.evaluateResponse(response, context, piForm, config); 494 | didTrigger = piForm.isChallengesTriggered(); 495 | } 496 | 497 | // The authCounter is also used to determine the polling interval for push 498 | // If the authCounter is bigger than the size of the polling interval list, repeat the last value in the list 499 | int authCounter = Integer.parseInt(context.getAuthenticationSession().getAuthNote(NOTE_COUNTER)) + 1; 500 | authCounter = (authCounter >= config.pollingInterval().size() ? config.pollingInterval().size() - 1 : authCounter); 501 | context.getAuthenticationSession().setAuthNote(NOTE_COUNTER, Integer.toString(authCounter)); 502 | piForm.setPollInterval(config.pollingInterval().get(authCounter)); 503 | 504 | // Prepare form for the next step, depending on what to do next 505 | kcForm.setAttribute(AUTH_FORM, piForm); 506 | String authenticationFailureMessage = "Authentication failed."; 507 | if ((piFormResult.modeChanged && !didTrigger) || 508 | Mode.PUSH.equals(currentMode) && (response != null && StringUtil.isBlank(response.passkeyRegistration))) 509 | { 510 | if (Mode.PUSH.equals(currentMode)) 511 | { 512 | piForm.setErrorMessage("push_auth_not_verified"); 513 | } 514 | context.challenge(kcForm.createForm(FORM_FILE_NAME)); 515 | } 516 | else if (currentMode == Mode.USERNAMEPASSWORD || currentMode == Mode.USERNAME) 517 | { 518 | // Continue with 2nd step (second factor) 519 | // If there is no next Mode set yet, because no challenges were triggered, or it was not attempted, just continue with OTP 520 | final Mode nextMode = piForm.getMode(); 521 | if (nextMode == Mode.USERNAMEPASSWORD || nextMode == Mode.USERNAME) 522 | { 523 | piForm.setMode(Mode.OTP); 524 | } 525 | kcForm.setAttribute(AUTH_FORM, piForm); 526 | context.challenge(kcForm.createForm(FORM_FILE_NAME)); 527 | } 528 | else if (response != null && StringUtil.isNotBlank(response.passkeyRegistration)) 529 | { 530 | kcForm.setError(response.message); 531 | context.challenge(kcForm.createForm(FORM_FILE_NAME)); 532 | } 533 | else 534 | { 535 | // Fail 536 | if (currentMode.equals(Mode.PUSH)) 537 | { 538 | piForm.setErrorMessage("push_auth_not_verified"); 539 | context.challenge(kcForm.createForm(FORM_FILE_NAME)); 540 | } 541 | else if (!didTrigger) 542 | { 543 | kcForm.setError(authenticationFailureMessage); 544 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, kcForm.createForm(FORM_FILE_NAME)); 545 | } 546 | // Check failed auth vs real error 547 | else if (response.error != null) 548 | { 549 | piForm.setErrorMessage(response.error.message); 550 | kcForm.setError(response.error.message); 551 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, kcForm.createForm(FORM_FILE_NAME)); 552 | } 553 | else if (response.authentication.equals(AuthenticationStatus.REJECT)) 554 | { 555 | kcForm.setError(response.message); 556 | context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, kcForm.createForm(FORM_FILE_NAME)); 557 | } 558 | else 559 | { 560 | context.challenge(kcForm.createForm(FORM_FILE_NAME)); 561 | } 562 | } 563 | } 564 | 565 | @Override 566 | public boolean requiresUser() 567 | { 568 | return false; 569 | } 570 | 571 | @Override 572 | public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) 573 | { 574 | //log("Configured for realm " + realm.getName()); 575 | return true; 576 | } 577 | 578 | @Override 579 | public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) 580 | { 581 | //log("Setting required actions for realm " + realm.getName() + " and user " + user.getUsername()); 582 | } 583 | 584 | @Override 585 | public void close() 586 | { 587 | //log("Closing PrivacyIDEA Authenticator."); 588 | } 589 | 590 | // IPILogger implementation 591 | @Override 592 | public void log(String message) 593 | { 594 | if (logEnabled) 595 | { 596 | logger.info(message); 597 | } 598 | } 599 | 600 | @Override 601 | public void error(String message) 602 | { 603 | if (logEnabled) 604 | { 605 | logger.error(message); 606 | } 607 | } 608 | 609 | @Override 610 | public void log(Throwable t) 611 | { 612 | if (logEnabled) 613 | { 614 | logger.info(t); 615 | } 616 | } 617 | 618 | @Override 619 | public void error(Throwable t) 620 | { 621 | if (logEnabled) 622 | { 623 | logger.error(t); 624 | } 625 | } 626 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 NetKnights GmbH - micha.preusser@netknights.it 3 | * nils.behlen@netknights.it 4 | * lukas.matusiewicz@netknights.it 5 | * - Modified 6 | *

7 | * Based on original code: 8 | *

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

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

16 | * http://www.apache.org/licenses/LICENSE-2.0 17 | *

18 | * Unless required by applicable law or agreed to in writing, software 19 | * distributed under the License is distributed on an "AS IS" BASIS, 20 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | * See the License for the specific language governing permissions and 22 | * limitations under the License. 23 | */ 24 | package org.privacyidea.authenticator; 25 | 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | import org.keycloak.Config; 29 | import org.keycloak.models.AuthenticationExecutionModel; 30 | import org.keycloak.models.KeycloakSession; 31 | import org.keycloak.models.KeycloakSessionFactory; 32 | import org.keycloak.provider.ProviderConfigProperty; 33 | 34 | public class PrivacyIDEAAuthenticatorFactory implements org.keycloak.authentication.AuthenticatorFactory, org.keycloak.authentication.ConfigurableAuthenticatorFactory 35 | { 36 | private static final PrivacyIDEAAuthenticator SINGLETON = new PrivacyIDEAAuthenticator(); 37 | private static final List configProperties = new ArrayList<>(); 38 | 39 | @Override 40 | public String getId() 41 | { 42 | return Const.PROVIDER_ID; 43 | } 44 | 45 | @Override 46 | public org.keycloak.authentication.Authenticator create(KeycloakSession session) 47 | { 48 | return SINGLETON; 49 | } 50 | 51 | private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { 52 | AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED}; 53 | 54 | @Override 55 | public AuthenticationExecutionModel.Requirement[] getRequirementChoices() 56 | { 57 | return REQUIREMENT_CHOICES; 58 | } 59 | 60 | @Override 61 | public boolean isUserSetupAllowed() 62 | { 63 | return false; 64 | } 65 | 66 | @Override 67 | public boolean isConfigurable() 68 | { 69 | return true; 70 | } 71 | 72 | @Override 73 | public List getConfigProperties() 74 | { 75 | return configProperties; 76 | } 77 | 78 | static 79 | { 80 | ProviderConfigProperty serverURL = new ProviderConfigProperty(); 81 | serverURL.setType(ProviderConfigProperty.STRING_TYPE); 82 | serverURL.setName(Const.CONFIG_SERVER); 83 | serverURL.setLabel("PrivacyIDEA URL"); 84 | serverURL.setRequired(true); 85 | serverURL.setHelpText("The URL of the privacyIDEA server. Example: https://privacyidea.company.com"); 86 | configProperties.add(serverURL); 87 | 88 | ProviderConfigProperty realm = new ProviderConfigProperty(); 89 | realm.setType(ProviderConfigProperty.STRING_TYPE); 90 | realm.setName(Const.CONFIG_REALM); 91 | realm.setLabel("Realm"); 92 | realm.setHelpText("Select the realm where your users are stored. Leave empty to use the default realm " + 93 | "which is configured in the privacyIDEA server."); 94 | configProperties.add(realm); 95 | 96 | ProviderConfigProperty verifySSL = new ProviderConfigProperty(); 97 | verifySSL.setType(ProviderConfigProperty.BOOLEAN_TYPE); 98 | verifySSL.setName(Const.CONFIG_VERIFY_SSL); 99 | verifySSL.setLabel("Verify SSL"); 100 | verifySSL.setHelpText("Do not set this to false in a productive environment. " + 101 | "Disables the verification of the privacyIDEA server's certificate and hostname."); 102 | configProperties.add(verifySSL); 103 | 104 | ProviderConfigProperty triggerChallenge = new ProviderConfigProperty(); 105 | triggerChallenge.setType(ProviderConfigProperty.BOOLEAN_TYPE); 106 | triggerChallenge.setName(Const.CONFIG_TRIGGER_CHALLENGE); 107 | triggerChallenge.setLabel("Enable Trigger Challenge"); 108 | triggerChallenge.setHelpText("Choose if you want to trigger challenge-response token " + 109 | "using the provided service account before the second step of authentication. " + 110 | "This setting is mutually exclusive with sending any password " + 111 | "and will take precedence over both."); 112 | configProperties.add(triggerChallenge); 113 | 114 | ProviderConfigProperty serviceAccountName = new ProviderConfigProperty(); 115 | serviceAccountName.setType(ProviderConfigProperty.STRING_TYPE); 116 | serviceAccountName.setName(Const.CONFIG_SERVICE_ACCOUNT); 117 | serviceAccountName.setLabel("Service Account"); 118 | serviceAccountName.setHelpText("Username of the service account. Needed for trigger challenge and token enrollment."); 119 | configProperties.add(serviceAccountName); 120 | 121 | ProviderConfigProperty serviceAccountPass = new ProviderConfigProperty(); 122 | serviceAccountPass.setType(ProviderConfigProperty.PASSWORD); 123 | serviceAccountPass.setName(Const.CONFIG_SERVICE_PASS); 124 | serviceAccountPass.setLabel("Service Account Password"); 125 | serviceAccountPass.setHelpText("Password of the service account. Needed for trigger challenge and token enrollment."); 126 | configProperties.add(serviceAccountPass); 127 | 128 | ProviderConfigProperty serviceAccountRealm = new ProviderConfigProperty(); 129 | serviceAccountRealm.setType(ProviderConfigProperty.STRING_TYPE); 130 | serviceAccountRealm.setName(Const.CONFIG_SERVICE_REALM); 131 | serviceAccountRealm.setLabel("Service Account Realm"); 132 | serviceAccountRealm.setHelpText("Realm of the service account, if it is in a separate realm from the other accounts. " + 133 | "Leave empty to use the general realm specified or the default realm " + 134 | "if no realm is configured at all."); 135 | configProperties.add(serviceAccountRealm); 136 | 137 | ProviderConfigProperty sendPassword = new ProviderConfigProperty(); 138 | sendPassword.setType(ProviderConfigProperty.BOOLEAN_TYPE); 139 | sendPassword.setName(Const.CONFIG_SEND_PASSWORD); 140 | sendPassword.setLabel("Send Password"); 141 | sendPassword.setHelpText("Choose if you want to send the password from the first login step to privacyIDEA. " + 142 | "This can be used to trigger challenge-response token. " + 143 | "This setting is mutually exclusive with trigger challenge and sending a static pass."); 144 | configProperties.add(sendPassword); 145 | 146 | ProviderConfigProperty sendStaticPass = new ProviderConfigProperty(); 147 | sendStaticPass.setType(ProviderConfigProperty.BOOLEAN_TYPE); 148 | sendStaticPass.setName(Const.CONFIG_SEND_STATIC_PASS); 149 | sendStaticPass.setLabel("Send Static Password"); 150 | sendStaticPass.setHelpText("Enable to send the specified static password to privacyIDEA. " + 151 | "Mutually exclusive with sending the password and trigger challenge."); 152 | configProperties.add(sendStaticPass); 153 | 154 | ProviderConfigProperty staticPass = new ProviderConfigProperty(); 155 | staticPass.setType(ProviderConfigProperty.PASSWORD); 156 | staticPass.setName(Const.CONFIG_STATIC_PASS); 157 | staticPass.setLabel("Static Password"); 158 | staticPass.setHelpText("Set the static password which should be sent to privacyIDEA if \"send static password\" is enabled. " + 159 | "Can be empty to send an empty password."); 160 | configProperties.add(staticPass); 161 | 162 | ProviderConfigProperty disablePasswordCheck = new ProviderConfigProperty(); 163 | disablePasswordCheck.setType(ProviderConfigProperty.BOOLEAN_TYPE); 164 | disablePasswordCheck.setDefaultValue(false); 165 | disablePasswordCheck.setName(Const.CONFIG_DISABLE_PASSWORD_CHECK); 166 | disablePasswordCheck.setLabel("Disable Password Check"); 167 | disablePasswordCheck.setHelpText("Whether the user is required to enter the password. Can be disabled to add the keycloak password " + 168 | "step after the privacyIDEA step or require no password at all."); 169 | configProperties.add(disablePasswordCheck); 170 | 171 | ProviderConfigProperty disablePasskeyLogin = new ProviderConfigProperty(); 172 | disablePasskeyLogin.setType(ProviderConfigProperty.BOOLEAN_TYPE); 173 | disablePasskeyLogin.setDefaultValue(false); 174 | disablePasskeyLogin.setName(Const.CONFIG_DISABLE_PASSKEY_LOGIN); 175 | disablePasskeyLogin.setLabel("Disable Passkey Login"); 176 | disablePasskeyLogin.setHelpText("Disable the passkey login button, removing the option to log in with passkeys."); 177 | configProperties.add(disablePasskeyLogin); 178 | 179 | ProviderConfigProperty includedGroups = new ProviderConfigProperty(); 180 | includedGroups.setType(ProviderConfigProperty.STRING_TYPE); 181 | includedGroups.setName(Const.CONFIG_INCLUDED_GROUPS); 182 | includedGroups.setLabel("Included groups"); 183 | includedGroups.setHelpText("Set groups for which the privacyIDEA workflow will be activated. " + 184 | "The names should be separated with ',' (E.g. group1,group2)"); 185 | configProperties.add(includedGroups); 186 | 187 | ProviderConfigProperty excludedGroups = new ProviderConfigProperty(); 188 | excludedGroups.setType(ProviderConfigProperty.STRING_TYPE); 189 | excludedGroups.setName(Const.CONFIG_EXCLUDED_GROUPS); 190 | excludedGroups.setLabel("Excluded groups"); 191 | excludedGroups.setHelpText("Set groups for which the privacyIDEA workflow will be skipped. " + 192 | "The names should be separated with ',' (E.g. group1,group2). " + 193 | "If chosen group is already set in 'Included groups', " + "excluding for this group will be ignored."); 194 | configProperties.add(excludedGroups); 195 | 196 | ProviderConfigProperty autoSubmitLength = new ProviderConfigProperty(); 197 | autoSubmitLength.setType(ProviderConfigProperty.STRING_TYPE); 198 | autoSubmitLength.setName(Const.CONFIG_OTP_LENGTH); 199 | autoSubmitLength.setLabel("Auto-Submit OTP Length"); 200 | autoSubmitLength.setHelpText("Automatically submit the login form after X digits were entered. " + 201 | "Leave empty to disable. NOTE: Only digits can be entered!"); 202 | configProperties.add(autoSubmitLength); 203 | 204 | ProviderConfigProperty forwardClientIP = new ProviderConfigProperty(); 205 | forwardClientIP.setType(ProviderConfigProperty.BOOLEAN_TYPE); 206 | forwardClientIP.setName(Const.CONFIG_FORWARD_CLIENT_IP); 207 | forwardClientIP.setLabel("Forward Client IP"); 208 | forwardClientIP.setHelpText( 209 | "Enable this to forward the client IP to privacyIDEA. This can be used in privacyIDEA server if configured."); 210 | configProperties.add(forwardClientIP); 211 | 212 | ProviderConfigProperty httpTimeoutMs = new ProviderConfigProperty(); 213 | httpTimeoutMs.setType(ProviderConfigProperty.STRING_TYPE); 214 | httpTimeoutMs.setName(Const.CONFIG_HTTP_TIMEOUT_MS); 215 | httpTimeoutMs.setLabel("HTTP Timeout (ms)"); 216 | httpTimeoutMs.setHelpText("Set the HTTP timeout to a custom value. Timeunit is milliseconds. " + 217 | "Leave empty to use the default value of 10 seconds."); 218 | configProperties.add(httpTimeoutMs); 219 | 220 | ProviderConfigProperty forwardHeaders = new ProviderConfigProperty(); 221 | forwardHeaders.setType(ProviderConfigProperty.STRING_TYPE); 222 | forwardHeaders.setName(Const.CONFIG_FORWARDED_HEADERS); 223 | forwardHeaders.setLabel("Headers to Forward"); 224 | forwardHeaders.setHelpText("Set the headers which should be forwarded to privacyIDEA. " + 225 | "If the header does not exist or has no value, it will be ignored. " + 226 | "The headers should be separated with ','."); 227 | configProperties.add(forwardHeaders); 228 | 229 | ProviderConfigProperty customHeaders = new ProviderConfigProperty(); 230 | customHeaders.setType(ProviderConfigProperty.MULTIVALUED_STRING_TYPE); 231 | customHeaders.setName(Const.CONFIG_CUSTOM_HEADERS); 232 | customHeaders.setLabel("Custom Headers"); 233 | customHeaders.setHelpText("Set custom headers to send with each request. Each entry needs to have the format key=value. " + 234 | "Entries that do not have this format will be ignored. Do not use well known headers like 'Authorization' " + 235 | "and do not use '##'."); 236 | configProperties.add(customHeaders); 237 | 238 | ProviderConfigProperty pollInBrowser = new ProviderConfigProperty(); 239 | pollInBrowser.setType(ProviderConfigProperty.BOOLEAN_TYPE); 240 | pollInBrowser.setName(Const.CONFIG_POLL_IN_BROWSER); 241 | pollInBrowser.setLabel("Poll in Browser"); 242 | pollInBrowser.setDefaultValue(false); 243 | pollInBrowser.setHelpText("Enable this to do the polling for accepted push requests in the user's browser. " + 244 | "When enabled, the login page does not refresh when checking for successful push authentication. " + 245 | "NOTE: privacyIDEA has to be reachable from the user's browser and a valid SSL certificate has to be in place."); 246 | configProperties.add(pollInBrowser); 247 | 248 | ProviderConfigProperty pollInBrowserURL = new ProviderConfigProperty(); 249 | pollInBrowserURL.setType(ProviderConfigProperty.STRING_TYPE); 250 | pollInBrowserURL.setName(Const.CONFIG_POLL_IN_BROWSER_URL); 251 | pollInBrowserURL.setLabel("URL for Poll in Browser"); 252 | pollInBrowserURL.setHelpText( 253 | "Optional. If poll in browser should use a deviating URL, set it here. " + "Otherwise, the general URL will be used."); 254 | configProperties.add(pollInBrowserURL); 255 | 256 | ProviderConfigProperty debugLog = new ProviderConfigProperty(); 257 | debugLog.setType(ProviderConfigProperty.BOOLEAN_TYPE); 258 | debugLog.setName(Const.CONFIG_ENABLE_LOG); 259 | debugLog.setLabel("Enable Logging"); 260 | debugLog.setHelpText("If enabled, log messages will be written to the keycloak server logfile."); 261 | debugLog.setDefaultValue(false); 262 | configProperties.add(debugLog); 263 | } 264 | 265 | @Override 266 | public String getHelpText() 267 | { 268 | return "Authenticate the second factor against privacyIDEA."; 269 | } 270 | 271 | @Override 272 | public String getDisplayType() 273 | { 274 | return "privacyIDEA"; 275 | } 276 | 277 | @Override 278 | public String getReferenceCategory() 279 | { 280 | return "privacyIDEA"; 281 | } 282 | 283 | @Override 284 | public void init(Config.Scope config) 285 | { 286 | } 287 | 288 | @Override 289 | public void postInit(KeycloakSessionFactory factory) 290 | { 291 | } 292 | 293 | @Override 294 | public void close() 295 | { 296 | } 297 | } -------------------------------------------------------------------------------- /src/main/java/org/privacyidea/authenticator/Util.java: -------------------------------------------------------------------------------- 1 | package org.privacyidea.authenticator; 2 | 3 | import java.util.LinkedHashMap; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.stream.Collectors; 7 | import org.keycloak.authentication.AuthenticationFlowContext; 8 | import org.keycloak.models.GroupModel; 9 | import org.keycloak.models.UserModel; 10 | import org.keycloak.utils.StringUtil; 11 | import org.privacyidea.Challenge; 12 | import org.privacyidea.IPILogger; 13 | import org.privacyidea.PIResponse; 14 | import org.privacyidea.PrivacyIDEA; 15 | 16 | import static org.privacyidea.PIConstants.TOKEN_TYPE_WEBAUTHN; 17 | import static org.privacyidea.authenticator.Const.HEADER_ACCEPT_LANGUAGE; 18 | import static org.privacyidea.authenticator.Const.NOTE_OTP_TRANSACTION_ID; 19 | import static org.privacyidea.authenticator.Const.NOTE_PASSKEY_REGISTRATION_SERIAL; 20 | import static org.privacyidea.authenticator.Const.NOTE_PASSKEY_TRANSACTION_ID; 21 | import static org.privacyidea.authenticator.Const.NOTE_PREVIOUS_RESPONSE; 22 | import static org.privacyidea.authenticator.Const.NOTE_PUSH_TRANSACTION_ID; 23 | import static org.privacyidea.authenticator.Const.NOTE_WEBAUTHN_TRANSACTION_ID; 24 | 25 | public class Util 26 | { 27 | private final IPILogger logger; 28 | 29 | public Util(IPILogger logger) 30 | { 31 | this.logger = logger; 32 | } 33 | 34 | /** 35 | * Extract the headers that should be forwarded to privacyIDEA from the original request to keycloak. The header names 36 | * can be defined in the configuration of this provider. The accept-language header is included by default. 37 | * Also add the custom headers from the configuration if any are defined. 38 | * 39 | * @param context AuthenticationFlowContext 40 | * @param config Configuration 41 | * @return Map of headers 42 | */ 43 | Map getHeaders(AuthenticationFlowContext context, Configuration config) 44 | { 45 | Map headers = new LinkedHashMap<>(); 46 | // Take all headers from config plus accept-language 47 | config.forwardedHeaders().add(HEADER_ACCEPT_LANGUAGE); 48 | 49 | for (String header : config.forwardedHeaders().stream().distinct().collect(Collectors.toList())) 50 | { 51 | List headerValues = context.getSession().getContext().getRequestHeaders().getRequestHeaders().get(header); 52 | 53 | if (headerValues != null && !headerValues.isEmpty()) 54 | { 55 | String temp = String.join(",", headerValues); 56 | headers.put(header, temp); 57 | } 58 | else 59 | { 60 | logger.log("No values for header " + header + " found."); 61 | } 62 | } 63 | headers.putAll(config.customHeaders()); 64 | return headers; 65 | } 66 | 67 | /** 68 | * Check if the user is member of an included or excluded group. Included groups have precedence over excluded groups. 69 | * If user is null, return false (=MFA required). 70 | * 71 | * @param config Configuration 72 | * @param user UserModel 73 | * @return true if no MFA is required, false if MFA is required 74 | */ 75 | boolean checkMFAExcludedByGroup(Configuration config, UserModel user) 76 | { 77 | if (user == null || config == null) 78 | { 79 | return false; 80 | } 81 | if (!config.includedGroups().isEmpty()) 82 | { 83 | return user.getGroupsStream().map(GroupModel::getName).noneMatch(config.includedGroups()::contains); 84 | } 85 | else if (!config.excludedGroups().isEmpty()) 86 | { 87 | return user.getGroupsStream().map(GroupModel::getName).anyMatch(config.excludedGroups()::contains); 88 | } 89 | return false; 90 | } 91 | 92 | private AuthenticationForm challengesToForm(AuthenticationForm authForm, PIResponse response, Configuration config, 93 | AuthenticationFlowContext context) 94 | { 95 | if (response == null || response.multiChallenge == null || response.multiChallenge.isEmpty()) 96 | { 97 | return authForm; 98 | } 99 | 100 | authForm.setChallengesTriggered(true); 101 | Mode mode = Mode.OTP; 102 | String newOtpMessage = response.otpMessage(); 103 | // Images per challenge 104 | for (Challenge c : response.multiChallenge) 105 | { 106 | if ("poll".equals(c.getClientMode())) 107 | { 108 | String image = c.getImage(); 109 | if (StringUtil.isNotBlank(image)) 110 | { 111 | // TODO assume that if we have an image for a push token, it has to be enroll_via_multichallenge 112 | authForm.setPushImage(c.getImage()); 113 | authForm.setEnrollViaMultichallenge(true); 114 | mode = Mode.PUSH; 115 | authForm.setOtpAvailable(false); 116 | } 117 | } 118 | else if ("interactive".equals(c.getClientMode())) 119 | { 120 | authForm.setOtpImage(c.getImage()); 121 | } 122 | else if ("webauthn".equals(c.getClientMode())) 123 | { 124 | authForm.setWebAuthnImage(c.getImage()); 125 | } 126 | } 127 | // Poll in browser 128 | if (config.pollInBrowser() && response.pushAvailable()) 129 | { 130 | authForm.setTransactionId(response.pushTransactionId()); 131 | newOtpMessage = response.otpMessage() + ". " + response.pushMessage(); 132 | String url = config.pollInBrowserUrl().isEmpty() ? config.serverURL() : config.pollInBrowserUrl(); 133 | authForm.setPollInBrowserURL(url); 134 | } 135 | // Push 136 | if (response.pushAvailable()) 137 | { 138 | authForm.setPushAvailable(true); 139 | authForm.setPushMessage(response.pushMessage()); 140 | } 141 | // WebAuthn 142 | if (response.triggeredTokenTypes().contains(TOKEN_TYPE_WEBAUTHN)) 143 | { 144 | authForm.setWebAuthnSignRequest(response.mergedSignRequest()); 145 | } 146 | // Passkey Registration 147 | if (StringUtil.isNotBlank(response.passkeyRegistration)) 148 | { 149 | authForm.setPasskeyRegistration(response.passkeyRegistration); 150 | context.getAuthenticationSession().setAuthNote(NOTE_PASSKEY_REGISTRATION_SERIAL, response.serial); 151 | context.getAuthenticationSession().setAuthNote(NOTE_PASSKEY_TRANSACTION_ID, response.transactionID); 152 | } 153 | // Preferred client mode 154 | if (StringUtil.isNotBlank(response.preferredClientMode)) 155 | { 156 | try 157 | { 158 | mode = Mode.valueOf(response.preferredClientMode.toUpperCase()); 159 | } 160 | catch (IllegalArgumentException e) 161 | { 162 | logger.error("Preferred client mode " + response.preferredClientMode + " is not valid, defaulting to OTP."); 163 | } 164 | } 165 | // Using poll in browser does not require push mode 166 | if (mode.equals(Mode.PUSH) && config.pollInBrowser() && !authForm.isEnrollViaMultichallenge()) 167 | { 168 | mode = Mode.OTP; 169 | } 170 | 171 | // Set the transactionIds for the different modes 172 | if (StringUtil.isNotBlank(response.otpTransactionId())) 173 | { 174 | context.getAuthenticationSession().setAuthNote(NOTE_OTP_TRANSACTION_ID, response.otpTransactionId()); 175 | } 176 | if (StringUtil.isNotBlank(response.pushTransactionId())) 177 | { 178 | context.getAuthenticationSession().setAuthNote(NOTE_PUSH_TRANSACTION_ID, response.pushTransactionId()); 179 | } 180 | if (StringUtil.isNotBlank(response.webAuthnTransactionId)) 181 | { 182 | context.getAuthenticationSession().setAuthNote(NOTE_WEBAUTHN_TRANSACTION_ID, response.webAuthnTransactionId); 183 | } 184 | authForm.setMode(mode); 185 | authForm.setOtpMessage(newOtpMessage); 186 | return authForm; 187 | } 188 | 189 | /** 190 | * Evaluate the response from privacyIDEA and set the form values accordingly. If there is a response, a new AuthenticationForm is created 191 | * and returned. Some values of the old form can be retained if they are not set to new values by the last response. 192 | * If there is no response, the old form is returned. 193 | * 194 | * @param response PIResponse 195 | * @param context AuthenticationFlowContext 196 | * @param authForm AuthenticationForm from the previous step 197 | * @param config Configuration 198 | * @return AuthenticationForm with new values or the old one if no response 199 | */ 200 | AuthenticationForm evaluateResponse(PIResponse response, AuthenticationFlowContext context, AuthenticationForm authForm, 201 | Configuration config) 202 | { 203 | if (response != null) 204 | { 205 | Mode previousMode = authForm.getMode(); 206 | authForm = new AuthenticationForm(config); 207 | authForm.setMode(previousMode); 208 | authForm.setEnrollmentLink(response.enrollmentLink); 209 | if (response.error != null) 210 | { 211 | authForm.setErrorMessage(response.error.message); 212 | } 213 | // New challenges, set the current response as previous response 214 | // Responses like "wrong otp" or "user not found" do not contain information that we need to remember. 215 | if (!response.multiChallenge.isEmpty()) 216 | { 217 | authForm = challengesToForm(authForm, response, config, context); 218 | String p = response.toJSON(); 219 | context.getAuthenticationSession().setAuthNote(NOTE_PREVIOUS_RESPONSE, p); 220 | } 221 | else if (!response.authenticationSuccessful()) 222 | { 223 | // If the response is not successful, set the error message and restore the previous response to the authform 224 | String previousResponseString = context.getAuthenticationSession().getAuthNote(NOTE_PREVIOUS_RESPONSE); 225 | PIResponse previousResponse = PIResponse.fromJSON(previousResponseString); 226 | authForm = challengesToForm(authForm, previousResponse, config, context); 227 | authForm.setErrorMessage(response.message); 228 | } 229 | } 230 | return authForm; 231 | } 232 | 233 | Map getAdditionalParamsFromContext(AuthenticationFlowContext context, Configuration config) 234 | { 235 | Map additionalParams = new LinkedHashMap<>(); 236 | if (config.forwardClientIP()) 237 | { 238 | String clientIP = context.getConnection().getRemoteAddr(); 239 | if (StringUtil.isBlank(clientIP)) 240 | { 241 | logger.error("ClientIP is empty, cannot forward it to privacyIDEA."); 242 | } 243 | else 244 | { 245 | additionalParams.put("clientip", clientIP); 246 | } 247 | } 248 | return additionalParams; 249 | } 250 | 251 | PIResponse tryTriggerFirstStep(String username, PrivacyIDEA privacyIDEA, Configuration config, String currentPassword, 252 | Map additionalParams, Map headers) 253 | { 254 | // Try to trigger challenges if configured. Using a service account has precedence over sending the (static) password 255 | PIResponse triggerResponse = null; 256 | if (username != null) 257 | { 258 | if (config.triggerChallenge()) 259 | { 260 | triggerResponse = privacyIDEA.triggerChallenges(username, additionalParams, headers); 261 | } 262 | else if (config.sendPassword()) 263 | { 264 | if (currentPassword != null) 265 | { 266 | triggerResponse = privacyIDEA.validateCheck(username, currentPassword, null, additionalParams, headers); 267 | } 268 | else 269 | { 270 | logger.error("Cannot send password because it is null!"); 271 | } 272 | } 273 | else if (config.sendStaticPass()) 274 | { 275 | triggerResponse = privacyIDEA.validateCheck(username, config.staticPass(), null, additionalParams, headers); 276 | } 277 | } 278 | else 279 | { 280 | logger.error("Username is null, cannot trigger challenges."); 281 | } 282 | return triggerResponse; 283 | } 284 | 285 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2016 Red Hat, Inc. and/or its affiliates 3 | # and other contributors as indicated by the @author tags. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | org.privacyidea.authenticator.PrivacyIDEAAuthenticatorFactory -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_de.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacyidea/keycloak-provider/b8368223c4fa3c2eed759aa409bd8514dfea5dcb/src/main/resources/theme-resources/messages/messages_de.properties -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_en.properties: -------------------------------------------------------------------------------- 1 | privacyidea.alternateLoginOptions = Other Login Options 2 | privacyidea.signIn = Sign in 3 | privacyidea.otpButton = One-Time-Password 4 | privacyidea.pushButton = Push 5 | privacyidea.webauthnButton = WebAuthn 6 | privacyidea.usernamePrompt = Please enter your username 7 | privacyidea.passwordPrompt = Please enter your password 8 | privacyidea.usernamepasswordPrompt = Please enter your username and password 9 | privacyidea.passkeyInitiateButton = Sign in with Passkey 10 | privacyidea.otpPrompt = Please enter your One-Time-Password: 11 | privacyidea.passkeyRegisterRetryButton = Retry Passkey Registration 12 | privacyidea.passkeyRetryButton = Retry Passkey Login 13 | privacyidea.resetLogin = Reset Login 14 | privacyidea.pushNotYetVerified = Push authentication not accepted yet! 15 | privacyidea.enrollmentLinkText = Alternatively, you can enroll the new token by clicking here. 16 | privacyidea.passkeyAuthenticationFailed = Passkey authentication failed! -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_es.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacyidea/keycloak-provider/b8368223c4fa3c2eed759aa409bd8514dfea5dcb/src/main/resources/theme-resources/messages/messages_es.properties -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_fr.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privacyidea/keycloak-provider/b8368223c4fa3c2eed759aa409bd8514dfea5dcb/src/main/resources/theme-resources/messages/messages_fr.properties -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_nl.properties: -------------------------------------------------------------------------------- 1 | privacyidea.alternateLoginOptions = Andere aanmeldopties 2 | privacyidea.signIn = Aanmelden 3 | privacyidea.otpButton = Eenmalig wachtwoord 4 | privacyidea.pushButton = Push 5 | privacyidea.webauthnButton = WebAuthn 6 | privacyidea.usernamePrompt = Voer uw gebruikersnaam in 7 | privacyidea.passwordPrompt = Voer uw wachtwoord in 8 | privacyidea.usernamepasswordPrompt = Voer uw gebruikersnaam en wachtwoord in 9 | privacyidea.passkeyInitiateButton = Aanmelden met Passkey 10 | privacyidea.otpPrompt = Voer uw eenmalige wachtwoord in: 11 | privacyidea.passkeyRegisterRetryButton = Herhaal registratie met passkey 12 | privacyidea.passkeyRetryButton = Herhaal Login met passkey 13 | privacyidea.resetLogin = Reset Login 14 | privacyidea.pushNotYetVerified = Push authenticatie nog niet geaccepteerd! 15 | privacyidea.enrollmentLinkText = Je kunt het nieuwe token ook registreren door hier te klikken. 16 | privacyidea.passkeyAuthenticationFailed = Passkey authenticatie mislukt! -------------------------------------------------------------------------------- /src/main/resources/theme-resources/messages/messages_pl.properties: -------------------------------------------------------------------------------- 1 | privacyidea.alternateLoginOptions = Alternatywne opcje logowania 2 | privacyidea.signIn = Zaloguj 3 | privacyidea.otpButton = Jednorazowe Haslo 4 | privacyidea.pushButton = Push 5 | privacyidea.webauthnButton = WebAuthn 6 | privacyidea.usernamePrompt = Nazwa uzytkownika 7 | privacyidea.passwordPrompt = Haslo 8 | privacyidea.usernamepasswordPrompt = Wprowadz nazwe uzytkownika i haslo 9 | privacyidea.passkeyInitiateButton = Zaloguj sie przy uzyciu Passkey 10 | privacyidea.otpPrompt = Wprowadz jednorazowe haslo 11 | privacyidea.passkeyRegisterRetryButton = Powtórz rejestracje Passkey 12 | privacyidea.passkeyRetryButton = Powtórz autentykacje Passkey 13 | privacyidea.resetLogin = Reset Login 14 | privacyidea.pushNotYetVerified = Autentykacja Push czeka na zweryfikowanie... 15 | privacyidea.enrollmentLinkText = Mozesz równiez zarejestrowac nowy token, klikajac tutaj. 16 | privacyidea.passkeyAuthenticationFailed = Autoryzacja Passkey nie powiodla sie! 17 | -------------------------------------------------------------------------------- /src/main/resources/theme-resources/resources/css/pi-form.css: -------------------------------------------------------------------------------- 1 | /* styles.css */ 2 | .center-text 3 | { 4 | text-align: center; 5 | } 6 | 7 | .bold-text 8 | { 9 | font-weight: bold; 10 | } 11 | 12 | .padding-top-20 13 | { 14 | padding-top: 20px; 15 | } 16 | 17 | #otp 18 | { 19 | margin-top: 20px; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/resources/theme-resources/resources/js/pi-form.js: -------------------------------------------------------------------------------- 1 | function bytesToBase64(bytes) { 2 | const binString = Array.from(bytes, (byte) => 3 | String.fromCodePoint(byte),).join(""); 4 | return btoa(binString); 5 | } 6 | 7 | function base64URLToBytes(base64URLString) { 8 | const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/'); 9 | const padLength = (4 - (base64.length % 4)) % 4; 10 | const padded = base64.padEnd(base64.length + padLength, '='); 11 | const binary = atob(padded); 12 | const buffer = new ArrayBuffer(binary.length); 13 | const bytes = new Uint8Array(buffer); 14 | for (let i = 0; i < binary.length; i++) { 15 | bytes[i] = binary.charCodeAt(i); 16 | } 17 | return buffer; 18 | } 19 | 20 | function webAuthnAuthentication(signRequest, mode) { 21 | if (mode === "push") { 22 | changeMode("webauthn"); 23 | return; 24 | } 25 | if (!signRequest) { 26 | console.log("WebAuthn Authentication: Challenge data is empty!") 27 | return ""; 28 | } 29 | let signRequestObject = JSON.parse(signRequest.replace(/(")/g, "\"")); 30 | try { 31 | const webAuthnSignResponse = window.pi_webauthn.sign(signRequestObject); 32 | webAuthnSignResponse.then((webauthnResponse) => { 33 | formResult.webAuthnSignResponse = JSON.stringify(webauthnResponse); 34 | submitForm(); 35 | }); 36 | } catch (err) { 37 | console.log(err); 38 | } 39 | } 40 | 41 | function passkeyAuthentication(passkeyChallenge, mode) { 42 | if (mode === "push") { 43 | changeMode("passkey"); 44 | return; 45 | } 46 | if (!passkeyChallenge) { 47 | console.log("Passkey Authentication: Challenge data is empty!") 48 | return ""; 49 | } 50 | formResult.passkeyLoginCancelled = false; 51 | let challengeObject = JSON.parse(passkeyChallenge.replace(/(")/g, "\"")); 52 | let userVerification = "preferred"; 53 | if (["required", "preferred", "discouraged"].includes(challengeObject.user_verification)) { 54 | userVerification = challengeObject.user_verification; 55 | } 56 | navigator.credentials.get({ 57 | publicKey: { 58 | challenge: Uint8Array.from(challengeObject.challenge, c => c.charCodeAt(0)), 59 | rpId: challengeObject.rpId, 60 | userVerification: userVerification, 61 | }, 62 | }).then(credential => { 63 | let params = { 64 | transaction_id: challengeObject.transaction_id, 65 | credential_id: credential.id, 66 | authenticatorData: bytesToBase64( 67 | new Uint8Array(credential.response.authenticatorData)), 68 | clientDataJSON: bytesToBase64(new Uint8Array(credential.response.clientDataJSON)), 69 | signature: bytesToBase64(new Uint8Array(credential.response.signature)), 70 | userHandle: bytesToBase64(new Uint8Array(credential.response.userHandle)), 71 | }; 72 | formResult.passkeySignResponse = JSON.stringify(params); 73 | submitForm(); 74 | }, function (error) { 75 | console.log("Passkey authentication error: " + error); 76 | formResult.passkeyLoginCancelled = true; 77 | }); 78 | } 79 | 80 | // Use the passkey_registration from the response as input to this function 81 | function registerPasskey(registrationData) { 82 | let data = JSON.parse(registrationData.replace(/(")/g, "\"")); 83 | let excludedCredentials = []; 84 | if (data.excludeCredentials) { 85 | for (const cred of data.excludeCredentials) { 86 | excludedCredentials.push({ 87 | id: base64URLToBytes(cred.id), 88 | type: cred.type, 89 | }); 90 | } 91 | } 92 | 93 | return navigator.credentials.create({ 94 | publicKey: { 95 | rp: data.rp, 96 | user: { 97 | id: base64URLToBytes(data.user.id), 98 | name: data.user.name, 99 | displayName: data.user.displayName 100 | }, 101 | challenge: Uint8Array.from(data.challenge, c => c.charCodeAt(0)), 102 | pubKeyCredParams: data.pubKeyCredParams, 103 | excludeCredentials: excludedCredentials, 104 | authenticatorSelection: data.authenticatorSelection, 105 | timeout: data.timeout, 106 | extensions: { 107 | credProps: true, 108 | }, 109 | attestation: data.attestation 110 | } 111 | }).then(function (publicKeyCred) { 112 | let params = { 113 | credential_id: publicKeyCred.id, 114 | rawId: bytesToBase64(new Uint8Array(publicKeyCred.rawId)), 115 | authenticatorAttachment: publicKeyCred.authenticatorAttachment, 116 | attestationObject: bytesToBase64( 117 | new Uint8Array(publicKeyCred.response.attestationObject)), 118 | clientDataJSON: bytesToBase64(new Uint8Array(publicKeyCred.response.clientDataJSON)), 119 | } 120 | if (publicKeyCred.response.attestationObject) { 121 | params.attestationObject = bytesToBase64( 122 | new Uint8Array(publicKeyCred.response.attestationObject)); 123 | } 124 | const extResults = publicKeyCred.getClientExtensionResults(); 125 | if (extResults.credProps) { 126 | params.credProps = extResults.credProps; 127 | } 128 | formResult.passkeyRegistrationResponse = JSON.stringify(params); 129 | submitForm(); 130 | }, function (error) { 131 | console.log("Error while registering passkey:"); 132 | console.log(error); 133 | return null; 134 | }); 135 | } 136 | 137 | function requestPasskeyLogin() { 138 | formResult.passkeyLoginRequested = true; 139 | submitForm(); 140 | } 141 | 142 | function authenticationReset() { 143 | formResult.authenticationResetRequested = true; 144 | submitForm(); 145 | } 146 | 147 | function setPushReload(intervalSeconds) { 148 | if (!intervalSeconds) { 149 | console.log("Interval seconds is empty, using default of 2s."); 150 | intervalSeconds = 2; 151 | } 152 | window.setTimeout(() => { 153 | submitForm(); 154 | }, parseInt(intervalSeconds) * 1000); 155 | } 156 | 157 | function setAutoSubmit(inputLength) { 158 | let otpField = document.querySelector("#otp") 159 | if (otpField) { 160 | otpField.addEventListener("keyup", function () { 161 | // catch parse int error? 162 | if (otpField.value.length === parseInt(inputLength)) { 163 | submitForm(); 164 | } 165 | }); 166 | } 167 | } 168 | 169 | function changeMode(newMode) { 170 | //console.log("changeMode to " + newMode); 171 | formResult.modeChanged = true; 172 | formResult.newMode = newMode; 173 | submitForm(); 174 | } 175 | 176 | function submitForm() { 177 | if (!window.location.origin) { 178 | window.location.origin = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' 179 | + window.location.port : ''); 180 | } 181 | formResult.origin = window.location.origin; 182 | //console.log("Submit, formResult:"); 183 | //console.log(formResult); 184 | document.querySelector("#authenticationFormResult").value = JSON.stringify(formResult); 185 | document.forms["kc-otp-login-form"].requestSubmit(); 186 | } 187 | 188 | function startPollingInBrowser(url, transactionId, resourcesPath) { 189 | let pushButton = document.querySelector("#pushButton"); 190 | if (pushButton) { 191 | pushButton.style.display = "none"; 192 | } 193 | let worker; 194 | if (typeof (Worker) !== "undefined") { 195 | if (typeof (worker) == "undefined") { 196 | worker = new Worker(resourcesPath + "/js/pi-pollTransaction.worker.js"); 197 | let form = document.querySelector("#kc-login") 198 | if (form) { 199 | form.addEventListener('click', function (e) { 200 | if (worker) { 201 | worker.terminate(); 202 | worker = undefined; 203 | } 204 | }); 205 | } 206 | worker.postMessage({'cmd': 'url', 'msg': url}); 207 | worker.postMessage({'cmd': 'transactionID', 'msg': transactionId}); 208 | worker.postMessage({'cmd': 'start'}); 209 | worker.addEventListener('message', function (e) { 210 | let data = e.data; 211 | switch (data.status) { 212 | case 'success': 213 | submitForm(); 214 | break; 215 | case 'cancel': 216 | formResult.pollInBrowserCancelled = true; 217 | worker = undefined; 218 | submitForm(); 219 | break; 220 | case 'error': 221 | console.log("Poll in Browser error: " + data.message); 222 | formResult.pollInBrowserCancelled = true; 223 | worker = undefined; 224 | if (pushButton) { 225 | pushButton.style.display = "initial"; 226 | } 227 | } 228 | }); 229 | } 230 | } else { 231 | console.log("Poll in Browser error: The browser doesn't support WebWorker."); 232 | worker.terminate(); 233 | formResult.pollInBrowserCancelled = true; 234 | formResult.pollInBrowserError = "The browser doesn't support WebWorker."; 235 | if (pushButton) { 236 | pushButton.style.display = "initial"; 237 | } 238 | } 239 | } 240 | 241 | function setLoginOptionsVisibility() { 242 | let ids = ["passkeyInitiateButton", "otpButton", "pushButton", "webAuthnButton"] 243 | let shouldShow = false; 244 | for (let id of ids) { 245 | let element = document.querySelector("#" + id); 246 | if (element && window.getComputedStyle(element).display !== "none" && window.getComputedStyle(element).display !== "hidden") { 247 | shouldShow = true; 248 | break; 249 | } 250 | } 251 | if (!shouldShow) { 252 | let element = document.querySelector("#alternateToken"); 253 | if (element) { 254 | element.style.display = "none"; 255 | } 256 | } 257 | 258 | // If the otp input field is visible, hide the otp button 259 | let otpInput = document.querySelector("#otp"); 260 | if (otpInput && otpInput.style.display !== "none" && otpInput.style.display !== "hidden") { 261 | let element = document.querySelector("#otpButton"); 262 | if (element) { 263 | element.style.display = "none"; 264 | } 265 | } 266 | } -------------------------------------------------------------------------------- /src/main/resources/theme-resources/resources/js/pi-pollTransaction.worker.js: -------------------------------------------------------------------------------- 1 | let url; 2 | let params; 3 | 4 | self.addEventListener('message', function (e) { 5 | let data = e.data; 6 | 7 | switch (data.cmd) { 8 | case 'url': 9 | url = data.msg + "/validate/polltransaction"; 10 | break; 11 | case 'transactionID': 12 | params = "transaction_id=" + data.msg; 13 | break; 14 | case 'start': 15 | if (url.length > 0 && params.length > 0) { 16 | setInterval(function () { 17 | fetch(url + "?" + params, {method: 'GET'}) 18 | .then(r => { 19 | if (r.ok) { 20 | r.text().then(result => { 21 | const resultJson = JSON.parse(result); 22 | if (resultJson['detail']['challenge_status'] === "accept") { 23 | self.postMessage({ 24 | 'message': 'Polling in browser: Push message confirmed!', 25 | 'status': 'success' 26 | }); 27 | self.close(); 28 | } else if (resultJson['detail']['challenge_status'] === "declined") { 29 | self.postMessage({ 30 | 'message': 'Polling in browser: Authentication declined!', 31 | 'status': 'cancel' 32 | }); 33 | self.close(); 34 | } 35 | }); 36 | } else { 37 | console.log(r); 38 | self.postMessage({'message': r.statusText, 'status': 'error'}); 39 | self.close(); 40 | } 41 | }) 42 | .catch(e => { 43 | console.log(e); 44 | self.postMessage({'message': e, 'status': 'error'}); 45 | self.close(); 46 | }); 47 | }, 300); 48 | } 49 | break; 50 | } 51 | }); -------------------------------------------------------------------------------- /src/main/resources/theme-resources/resources/js/pi-webauthn.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 2020-02-11 Jean-Pierre Höhmann 3 | * 4 | * License: AGPLv3 5 | * Contact: https://www.privacyidea.org 6 | * 7 | * Copyright (C) 2020 NetKnights GmbH 8 | * 9 | * This code is free software; you can redistribute it and/or 10 | * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE 11 | * License as published by the Free Software Foundation; either 12 | * version 3 of the License, or any later version. 13 | * 14 | * This code is distributed in the hope that it will be useful, 15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | * GNU AFFERO GENERAL PUBLIC LICENSE for more details. 18 | * 19 | * You should have received a copy of the GNU Affero General Public 20 | * License along with this program. If not, see . 21 | */ 22 | 23 | /** 24 | * Namespace for the WebAuthn api. 25 | */ 26 | var pi_webauthn = navigator.credentials ? window.pi_webauthn || {} : null; 27 | 28 | /** 29 | * WebAuthn wrapper functions for privacyIDEA. 30 | */ 31 | (function (credentials) { 32 | 'use strict'; 33 | 34 | // Do not proceed if webAuthn is unsupported in this client. 35 | if (!this) { 36 | console.log("unsupported!"); 37 | return; 38 | } 39 | 40 | /** 41 | * Create a signature from a WebAuthnSignRequest. 42 | * 43 | * This will accept a WebAuthnSignRequest, as returned by a call to the 44 | * /validate/check, or the /validate/triggerchallenge endpoint. It will 45 | * format the data as necessary and pass it to navigator.credentials.get(), 46 | * the result of the call will be processed and a WebAuthnSignResponse will 47 | * be returned. The fields from the WebAuthnSignResponse need to be passed 48 | * back to the /validate/check endpoint, along with the `user`, and 49 | * `transaction_id` associated with the signing request. 50 | * 51 | * @param {WebAuthnSignRequest} webAuthnSignRequest - The WebAuthnSignRequest as provided by privacyIDEA. 52 | * 53 | * @returns {Promise} - Data for /validate/check minus `user`, `pass`, and `transaction_id`. 54 | * 55 | * @typedef WebAuthnSignRequest 56 | * @type {object} 57 | * @property {string} challenge - The challenge from privacyIDEA. 58 | * @property {{id: string, type: PublicKeyCredentialType, transports: AuthenticatorTransport[]}[]} allowCredentials - Creds to try. 59 | * @property {string} rpId - The relying party id the credential was created with. 60 | * @property {UserVerificationRequirement} userVerification - Option to discourage, or require user verification. 61 | * @property {number} [timeout=60000] - Timeout in milliseconds. 62 | * 63 | * @typedef WebAuthnSignResponse 64 | * @type {object} 65 | * @property {string} credentialid - The id of the credential being used. 66 | * @property {string} clientdata - The clientDataJSON, encoded in base64. 67 | * @property {string} signaturedata - The signature, encoded in base64. 68 | * @property {string} authenticatordata - The authenticatorData, encoded in base64. 69 | * @property {string} [userhandle] - The userHandle as reported by the authenticator. 70 | * @property {string} [assertionclientextensions] - The assertionClientExtensions, encoded in JSON. 71 | */ 72 | this.sign = function (webAuthnSignRequest) { 73 | var publicKeyCredentialRequestOptions = { 74 | challenge: webAuthnBase64DecToArr(webAuthnSignRequest.challenge), 75 | allowCredentials: webAuthnSignRequest.allowCredentials.map(function (x) { 76 | return { 77 | id: webAuthnBase64DecToArr(x.id), 78 | type: x.type, 79 | transports: x.transports 80 | } 81 | }), 82 | rpId: webAuthnSignRequest.rpId, 83 | userVerification: webAuthnSignRequest.userVerification, 84 | timeout: webAuthnSignRequest.timeout || 60000 85 | }; 86 | return navigator 87 | .credentials 88 | .get({publicKey: publicKeyCredentialRequestOptions}) 89 | .then(function (assertion) { 90 | if (!assertion) { 91 | console.log("WebAuthnSign: assertion failed!"); 92 | return Promise.reject(); 93 | } 94 | 95 | var webAuthnSignResponse = { 96 | credentialid: assertion.id, 97 | clientdata: webAuthnBase64EncArr(assertion.response.clientDataJSON), 98 | signaturedata: webAuthnBase64EncArr(assertion.response.signature), 99 | authenticatordata: webAuthnBase64EncArr(assertion.response.authenticatorData) 100 | }; 101 | 102 | if (assertion.response.userHandle) { 103 | webAuthnSignResponse.userhandle = utf8ArrToStr( 104 | assertion.response.userHandle); 105 | } 106 | if (assertion.response.assertionClientExtensions) { 107 | webAuthnSignResponse.assertionclientextensions = webAuthnBase64EncArr( 108 | strToUtf8Arr(JSON.stringify(assertion.response.assertionClientExtensions))) 109 | } 110 | 111 | return Promise.resolve(webAuthnSignResponse); 112 | }); 113 | }; 114 | 115 | /** 116 | * Create a new credential from a WebAuthnRegisterRequest. 117 | * 118 | * This will accept a WebAuthnRegisterRequest, as returned by a call to 119 | * the /token/init endpoint. It will format the data as necessary and pass 120 | * it to navigator.credentials.create(), the result of the call will be 121 | * processed and a WebAuthnRegisterResponse, ready to be passed on to 122 | * privacyIDEA, will be returned. 123 | * 124 | * @param {WebAuthnRegisterRequest} webAuthnRegisterRequest - The request as provided by privacyIDEA. 125 | * 126 | * @returns {Promise} - Information to pass to /token/init. 127 | * 128 | * @typedef WebAuthnRegisterRequest 129 | * @type {object} 130 | * @property {string} transaction_id - The transaction id from privacyIDEA. 131 | * @property {string} message - Unused. 132 | * @property {string} serialNumber - The serial number of the new token being enrolled. 133 | * @property {string} name - The login name of the user the token is being enrolled for. 134 | * @property {string} displayName - The full name of the user the token is being enrolled for. 135 | * @property {string} nonce - The challenge to use when creating the token. 136 | * @property {PublicKeyCredentialRpEntity} relyingParty - The relyingParty to use for the credential. 137 | * @property {PublicKeyCredentialParameters} preferredAlgorithm - The preferred algorithm for credential creation. 138 | * @property {PublicKeyCredentialParameters} [alternativeAlgorithm] - An alternative algorithm. 139 | * @property {AuthenticatorSelectionCriteria} [authenticatorSelection] - Selection criteria for authenticators. 140 | * @property {number} [timeout=60000] - Timeout in milliseconds. 141 | * @property {AttestationConveyancePreference} [attestation="direct"] - Option to discourage or require attestation. 142 | * @property {sequence} [authenticatorSelectionList] - A whitelist of authenticators to allow. 143 | * @property {string} [description] - A description for the token being created. 144 | * 145 | * @typedef WebAuthnRegisterResponse 146 | * @type {object} 147 | * @property {'webauthn'} type - The token type of the token being enrolled. 148 | * @property {string} transaction_id - The transaction_id that was passed in. 149 | * @property {string} clientdata - The clientDataJSON, encoded in base64. 150 | * @property {string} regdata - The attestationObject, encoded in base64. 151 | * @property {string} registrationclientextensions - The registrationClientExtensions, encoded in JSON. 152 | * @property {string} [description] - The description 153 | */ 154 | this.register = function (webAuthnRegisterRequest) { 155 | var publicKeyCredentialCreationOptions = { 156 | challenge: webAuthnBase64DecToArr(webAuthnRegisterRequest.nonce), 157 | rp: webAuthnRegisterRequest.relyingParty, 158 | user: { 159 | id: strToUtf8Arr(webAuthnRegisterRequest.serialNumber), 160 | name: webAuthnRegisterRequest.name, 161 | displayName: webAuthnRegisterRequest.displayName 162 | }, 163 | pubKeyCredParams: [webAuthnRegisterRequest.preferredAlgorithm], 164 | timeout: webAuthnRegisterRequest.timeout || 60000, 165 | attestation: webAuthnRegisterRequest.attestation || "direct", 166 | extensions: {} 167 | }; 168 | 169 | if (webAuthnRegisterRequest.alternativeAlgorithm) { 170 | publicKeyCredentialCreationOptions.pubKeyCredParams.push( 171 | webAuthnRegisterRequest.alternativeAlgorithm); 172 | } 173 | if (webAuthnRegisterRequest.authenticatorSelection) { 174 | publicKeyCredentialCreationOptions.authenticatorSelection 175 | = webAuthnRegisterRequest.authenticatorSelection; 176 | } 177 | if (webAuthnRegisterRequest.authenticatorSelectionList) { 178 | publicKeyCredentialCreationOptions.extensions.authnSel 179 | = webAuthnRegisterRequest.authenticatorSelectionList 180 | } 181 | 182 | return navigator 183 | .credentials 184 | .create({publicKey: publicKeyCredentialCreationOptions}) 185 | .then(function (credential) { 186 | if (!credential) { 187 | return Promise.reject(); 188 | } 189 | 190 | var webAuthnRegisterResponse = { 191 | type: 'webauthn', 192 | transaction_id: webAuthnRegisterRequest.transaction_id, 193 | clientdata: webAuthnBase64EncArr(credential.response.clientDataJSON), 194 | regdata: webAuthnBase64EncArr(credential.response.attestationObject), 195 | }; 196 | 197 | var clientExtensionResults = credential.getClientExtensionResults(); 198 | if (clientExtensionResults && Object.keys(clientExtensionResults).length) { 199 | webAuthnRegisterResponse.registrationclientextensions = webAuthnBase64EncArr( 200 | strToUtf8Arr(JSON.stringify(clientExtensionResults))); 201 | } 202 | 203 | return Promise.resolve(webAuthnRegisterResponse); 204 | }); 205 | }; 206 | 207 | /** 208 | * Convert a UTF-8 encoded base64 character to a base64 digit. 209 | * 210 | * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) 211 | * 212 | * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding 213 | * 214 | * Author: madmurphy 215 | * 216 | * @param {number} nChr - A UTF-8 encoded base64 character. 217 | * 218 | * @returns {number} - The base64 digit. 219 | */ 220 | var b64ToUint6 = function (nChr) { 221 | return nChr > 64 && nChr < 91 222 | ? nChr - 65 223 | : nChr > 96 && nChr < 123 224 | ? nChr - 71 225 | : nChr > 47 && nChr < 58 226 | ? nChr + 4 227 | : nChr === 43 228 | ? 62 229 | : nChr === 47 230 | ? 63 231 | : 0; 232 | }; 233 | 234 | /** 235 | * Convert a base64 digit, to a UTF-8 encoded base64 character. 236 | * 237 | * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) 238 | * 239 | * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding 240 | * 241 | * Author: madmurphy 242 | * 243 | * @param {number} nUint6 - A base64 digit. 244 | * 245 | * @returns {number} - The UTF-8 encoded base64 character. 246 | */ 247 | var uint6ToB64 = function (nUint6) { 248 | return nUint6 < 26 ? 249 | nUint6 + 65 250 | : nUint6 < 52 ? 251 | nUint6 + 71 252 | : nUint6 < 62 ? 253 | nUint6 - 4 254 | : nUint6 === 62 ? 255 | 43 256 | : nUint6 === 63 ? 257 | 47 258 | : 259 | 65; 260 | }; 261 | 262 | /** 263 | * Decode base64 into UTF-8. 264 | * 265 | * This will take a base64 encoded string and decode it to UTF-8, 266 | * optionally NUL-padding it to make its length a multiple of a given 267 | * block size. 268 | * 269 | * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) 270 | * 271 | * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding 272 | * 273 | * Author: madmurphy 274 | * 275 | * @param {string} sBase64 - Base64 to decode. 276 | * @param {number} [nBlockSize=1] - The block-size for the output. 277 | * 278 | * @returns {Uint8Array} - The decoded string. 279 | */ 280 | var base64DecToArr = function (sBase64, nBlockSize) { 281 | var sB64Enc = sBase64.replace(/[^A-Za-z0-9+\/]/g, ""); 282 | var nInLen = sB64Enc.length; 283 | var nOutLen = nBlockSize ? 284 | Math.ceil((nInLen * 3 + 1 >>> 2) / nBlockSize) * nBlockSize 285 | : 286 | nInLen * 3 + 1 >>> 2; 287 | var aBytes = new Uint8Array(nOutLen); 288 | 289 | for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) { 290 | nMod4 = nInIdx & 3; 291 | nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4; 292 | if (nMod4 === 3 || nInLen - nInIdx === 1) { 293 | for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) { 294 | aBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255; 295 | } 296 | nUint24 = 0; 297 | } 298 | } 299 | 300 | return aBytes; 301 | }; 302 | 303 | /** 304 | * Encode a binary into base64. 305 | * 306 | * This will take a binary ArrrayBufferLike and encode it into base64. 307 | * 308 | * Adapted from Base64 / binary data / UTF-8 strings utilities (#2) 309 | * 310 | * Source: https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding 311 | * 312 | * Author: madmurphy 313 | * 314 | * @param {ArrayBufferLike} bytes - Bytes to encode. 315 | * 316 | * @returns {string} - The encoded base64. 317 | */ 318 | var base64EncArr = function (bytes) { 319 | var aBytes = new Uint8Array(bytes) 320 | var eqLen = (3 - (aBytes.length % 3)) % 3; 321 | var sB64Enc = ""; 322 | 323 | for (var nMod3, nLen = aBytes.length, nUint24 = 0, nIdx = 0; nIdx < nLen; nIdx++) { 324 | nMod3 = nIdx % 3; 325 | 326 | // Split the output in lines 76-characters long 327 | if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) { 328 | sB64Enc += "\r\n"; 329 | } 330 | 331 | nUint24 |= aBytes[nIdx] << (16 >>> nMod3 & 24); 332 | if (nMod3 === 2 || aBytes.length - nIdx === 1) { 333 | sB64Enc += String.fromCharCode( 334 | uint6ToB64(nUint24 >>> 18 & 63), 335 | uint6ToB64(nUint24 >>> 12 & 63), 336 | uint6ToB64(nUint24 >>> 6 & 63), 337 | uint6ToB64(nUint24 & 63)); 338 | nUint24 = 0; 339 | } 340 | } 341 | 342 | return eqLen === 0 ? 343 | sB64Enc 344 | : 345 | sB64Enc.substring(0, sB64Enc.length - eqLen) + (eqLen === 1 ? "=" : "=="); 346 | }; 347 | 348 | /** 349 | * Perform web-safe base64 decoding. 350 | * 351 | * This will perform web-safe base64 decoding as specified by WebAuthn. 352 | * 353 | * @param {string} sBase64 - Base64 to decode. 354 | * 355 | * @returns {Uint8Array} - The decoded binary. 356 | */ 357 | var webAuthnBase64DecToArr = function (sBase64) { 358 | return base64DecToArr( 359 | sBase64 360 | .replace(/-/g, '+') 361 | .replace(/_/g, '/') 362 | .padEnd((sBase64.length | 3) + 1, '=')) 363 | }; 364 | 365 | /** 366 | * Perform web-safe base64 encoding. 367 | * 368 | * This will perform web-safe base64 encoding as specified by WebAuthn. 369 | * 370 | * @param {ArrayBufferLike} bytes - Bytes to encode. 371 | * 372 | * @returns {string} - The encoded base64. 373 | */ 374 | var webAuthnBase64EncArr = function (bytes) { 375 | return base64EncArr(bytes) 376 | .replace(/\+/g, '-') 377 | .replace(/\//g, '_') 378 | .replace(/=/g, ''); 379 | }; 380 | 381 | /** 382 | * Decode a UTF-8-string. 383 | * 384 | * This will accept a UTF-8 string and decode it into the native string 385 | * representation of the JavaScript engine (read: UTF-16). This function 386 | * currently implements no sanity checks whatsoever. If the input is not 387 | * valid UTF-8, the result of this function is not well-defined! 388 | * 389 | * @param {Uint8Array} aBytes - A UTF-8 encoded string. 390 | * 391 | * @returns {string} The decoded string. 392 | */ 393 | var utf8ArrToStr = function (aBytes) { 394 | var sView = ""; 395 | 396 | for (var nPart, nLen = aBytes.length, nIdx = 0; nIdx < nLen; nIdx++) { 397 | nPart = aBytes[nIdx]; 398 | sView += String.fromCharCode( 399 | nPart > 251 && nPart < 254 && nIdx + 5 < nLen ? 400 | (nPart - 252) * 1073741824 /* << 30 */ 401 | + (aBytes[++nIdx] - 128 << 24) 402 | + (aBytes[++nIdx] - 128 << 18) 403 | + (aBytes[++nIdx] - 128 << 12) 404 | + (aBytes[++nIdx] - 128 << 6) 405 | + aBytes[++nIdx] - 128 406 | : nPart > 247 && nPart < 252 && nIdx + 4 < nLen ? 407 | (nPart - 248 << 24) 408 | + (aBytes[++nIdx] - 128 << 18) 409 | + (aBytes[++nIdx] - 128 << 12) 410 | + (aBytes[++nIdx] - 128 << 6) 411 | + aBytes[++nIdx] - 128 412 | : nPart > 239 && nPart < 248 && nIdx + 3 < nLen ? 413 | (nPart - 240 << 18) 414 | + (aBytes[++nIdx] - 128 << 12) 415 | + (aBytes[++nIdx] - 128 << 6) 416 | + aBytes[++nIdx] - 128 417 | : nPart > 223 && nPart < 240 && nIdx + 2 < nLen ? 418 | (nPart - 224 << 12) 419 | + (aBytes[++nIdx] - 128 << 6) 420 | + aBytes[++nIdx] - 128 421 | : nPart > 191 && nPart < 224 && nIdx + 1 < nLen ? 422 | (nPart - 192 << 6) 423 | + aBytes[++nIdx] - 128 424 | : 425 | nPart 426 | ); 427 | } 428 | 429 | return sView; 430 | }; 431 | 432 | /** 433 | * Encode a string to UTF-8. 434 | * 435 | * This will accept a string in the native representation of the JavaScript 436 | * engine (read: UTF-16), and encode it as UTF-8. 437 | * 438 | * @param {string} sDOMStr - A string to encode. 439 | * 440 | * @returns {Uint8Array} - The encoded string. 441 | */ 442 | var strToUtf8Arr = function (sDOMStr) { 443 | var aBytes; 444 | var nChr; 445 | var nStrLen = sDOMStr.length; 446 | var nArrLen = 0; 447 | 448 | /* 449 | * Determine the byte-length of the string when encoded as UTF-8. 450 | */ 451 | 452 | for (var nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { 453 | nChr = sDOMStr.charCodeAt(nMapIdx); 454 | nArrLen += nChr < 0x80 ? 455 | 1 456 | : nChr < 0x800 ? 457 | 2 458 | : nChr < 0x10000 ? 459 | 3 460 | : nChr < 0x200000 ? 461 | 4 462 | : nChr < 0x4000000 ? 463 | 5 464 | : 465 | 6; 466 | } 467 | 468 | aBytes = new Uint8Array(nArrLen); 469 | 470 | /* 471 | * Perform the encoding. 472 | */ 473 | 474 | for (var nIdx = 0, nChrIdx = 0; nIdx < nArrLen; nChrIdx++) { 475 | nChr = sDOMStr.charCodeAt(nChrIdx); 476 | if (nChr < 128) { 477 | /* one byte */ 478 | aBytes[nIdx++] = nChr; 479 | } else if (nChr < 0x800) { 480 | /* two bytes */ 481 | aBytes[nIdx++] = 192 + (nChr >>> 6); 482 | aBytes[nIdx++] = 128 + (nChr & 63); 483 | } else if (nChr < 0x10000) { 484 | /* three bytes */ 485 | aBytes[nIdx++] = 224 + (nChr >>> 12); 486 | aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); 487 | aBytes[nIdx++] = 128 + (nChr & 63); 488 | } else if (nChr < 0x200000) { 489 | /* four bytes */ 490 | aBytes[nIdx++] = 240 + (nChr >>> 18); 491 | aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); 492 | aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); 493 | aBytes[nIdx++] = 128 + (nChr & 63); 494 | } else if (nChr < 0x4000000) { 495 | /* five bytes */ 496 | aBytes[nIdx++] = 248 + (nChr >>> 24); 497 | aBytes[nIdx++] = 128 + (nChr >>> 18 & 63); 498 | aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); 499 | aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); 500 | aBytes[nIdx++] = 128 + (nChr & 63); 501 | } else /* if (nChr <= 0x7fffffff) */ { 502 | /* six bytes */ 503 | aBytes[nIdx++] = 252 + (nChr >>> 30); 504 | aBytes[nIdx++] = 128 + (nChr >>> 24 & 63); 505 | aBytes[nIdx++] = 128 + (nChr >>> 18 & 63); 506 | aBytes[nIdx++] = 128 + (nChr >>> 12 & 63); 507 | aBytes[nIdx++] = 128 + (nChr >>> 6 & 63); 508 | aBytes[nIdx++] = 128 + (nChr & 63); 509 | } 510 | } 511 | 512 | return aBytes; 513 | }; 514 | }).bind(pi_webauthn)(navigator.credentials); -------------------------------------------------------------------------------- /src/main/resources/theme-resources/templates/privacyIDEA.ftl: -------------------------------------------------------------------------------- 1 | <#import "template.ftl" as layout> 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | <@layout.registrationLayout; section> 16 | <#if section = "title"> 17 | ${msg("loginTitle",realm.name)} 18 | <#elseif section = "header"> 19 | ${msg("loginTitleHtml",realm.name)} 20 | <#elseif section = "form"> 21 |

24 |
25 |
26 | 27 | 28 | <#if !authenticationForm.errorMessage?has_content || authenticationForm.errorMessage == "push_auth_not_verified"> 29 | <#if authenticationForm.mode = "push" && !(authenticationForm.passkeyRegistration?has_content)> 30 | <#if authenticationForm.pushImage?has_content> 31 |
32 | challenge_img 33 |
34 | 35 |

${authenticationForm.pushMessage}

36 | <#elseif authenticationForm.mode = "webauthn" && !(authenticationForm.passkeyRegistration?has_content)> 37 | <#if authenticationForm.webAuthnImage?has_content> 38 |
39 | challenge_img 40 |
41 | 42 | <#elseif authenticationForm.mode = "otp" && !(authenticationForm.passkeyRegistration?has_content)> 43 | <#if authenticationForm.otpImage?has_content> 44 |
45 | challenge_img 46 |
47 | 48 | <#elseif authenticationForm.mode = "usernamepassword" && !(authenticationForm.passkeyRegistration?has_content)> 49 |

${msg('privacyidea.usernamepasswordPrompt')}

50 | <#elseif authenticationForm.mode = "username" && !(authenticationForm.passkeyRegistration?has_content)> 51 |

${msg('privacyidea.usernamePrompt')}

52 | <#elseif authenticationForm.mode = "password" && !(authenticationForm.passkeyRegistration?has_content)> 53 |

${msg('privacyidea.passwordPrompt')}

54 | 55 | 56 | <#if authenticationForm.enrollmentLink?has_content> 57 | ${msg('privacyidea.enrollmentLinkText')} 59 | 60 | <#else> 61 | 62 |
63 |
64 | 75 |
76 |
77 | 78 | 79 | <#if ["usernamepassword", "username"]?seq_contains(authenticationForm.mode) 80 | && !(authenticationForm.passkeyRegistration?has_content)> 81 |
82 |
83 | 84 |
85 |
86 | 88 |
89 |
90 | 91 | 92 | <#if ["usernamepassword", "password"]?seq_contains(authenticationForm.mode) 93 | && !(authenticationForm.passkeyRegistration?has_content)> 94 |
95 |
96 | 97 |
98 |
99 | 101 |
102 |
103 | 104 | 105 | <#if !(["usernamepassword", "username", "push", "passkey"]?seq_contains(authenticationForm.mode)) 106 | && !(authenticationForm.passkeyRegistration?has_content)> 107 |
108 |
109 | 116 |
117 |
118 | 120 |
121 |
122 | 123 | 124 | <#if authenticationForm.passkeyRegistration?has_content> 125 | 128 | 131 | 132 |
133 |
134 | 135 | 136 | <#if !(["passkey", "push"]?seq_contains(authenticationForm.mode)) && !(authenticationForm.passkeyRegistration?has_content)> 137 |
138 | 140 |
141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | <#if !authenticationForm.disablePasskeyLogin> 150 | 151 | <#if !authenticationForm.passkeyRegistration?has_content && authenticationForm.firstStep> 152 |
153 | 156 |
157 | 158 | 159 | <#if authenticationForm.passkeyChallenge?has_content && (!authenticationForm.errorMessage?has_content 160 | || authenticationForm.errorMessage == "passkey_authentication_failed")> 161 | 162 | 165 | 168 | 169 | 170 | <#if authenticationForm.passkeyChallenge?has_content && !authenticationForm.errorMessage?has_content> 171 | 174 | 175 | 176 | 177 | 178 | <#if authenticationForm.autoSubmitLength?has_content> 179 | 182 | 183 | 184 | <#if authenticationForm.mode = "push"> 185 | 188 | 189 | <#if authenticationForm.pollInBrowserAvailable> 190 | 193 | 194 | 195 | <#if authenticationForm.mode = "webauthn" && authenticationForm.webAuthnSignRequest?has_content> 196 | 199 | 200 | 201 | 202 | <#if !authenticationForm.firstStep && !authenticationForm.passkeyChallenge?has_content 203 | && !authenticationForm.passkeyRegistration?has_content> 204 |
205 |

${msg('privacyidea.alternateLoginOptions')}

206 | 207 | <#if !authenticationForm.disablePasskeyLogin && !authenticationForm.passkeyRegistration?has_content 208 | && !authenticationForm.firstStep> 209 |
210 | 214 |
215 | 216 | 217 | <#if authenticationForm.otpAvailable && authenticationForm.mode != "otp"> 218 | 221 | 222 | 223 | <#if authenticationForm.pushAvailable && authenticationForm.mode != "push"> 224 | 227 | 228 | 229 | <#if authenticationForm.webAuthnSignRequest?has_content> 230 | 234 | 235 |
236 | 237 | 242 |
243 | 244 | --------------------------------------------------------------------------------