├── .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 | *
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 | *
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 | *
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 | *
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 | *