diff --git a/README.md b/README.md index f97c8d8..44d48b3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ In this blog entry, you can find an example with Keycloak, privacyIDEA and Drupa ## Installation * Move the packed jar file into your deployment directory `standalone/deployment`. -* Copy the template privacyIDEA.ftl to `themes/base/login`. +* Move the template privacyIDEA.ftl to `themes/base/login`. Now you can enable the execution for your auth flow. If you set the execution as 'required', every user needs to login with a second factor. @@ -36,12 +36,7 @@ You can find different preferences in your configuration, which are explained be ## Manual build with source code -You can also build the provider yourself. -***Notice:** This is not a stable release. Do not use it in a productive environment.* +* If the wildfly server is running, the authenticator can directly be deployed with +``mvn clean install wildfly:deploy`` and only the template has to be copied. -* We used the [demo server](https://www.keycloak.org/archive/downloads-4.3.0.html) to build our plugin. -* Clone this repo to `keycloak-demo-4.3.0.Final/examples/providers` -* Build this provider with `mvn clean install wildfly:deploy` -* Pack the content of `target/classes` to privacyidea.jar - -Go on with **Installation**. +* Otherwise build with ``mvn clean install`` and go on with **Installation** diff --git a/pom.xml b/pom.xml index 7284eb6..85ddbe5 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,10 @@ 4.0.0 + + UTF-8 + + org.keycloak @@ -86,6 +90,7 @@ org.apache.maven.plugins maven-compiler-plugin + 3.8.1 1.8 1.8 diff --git a/privacyIDEA.ftl b/privacyIDEA.ftl index 44c21d8..785096e 100644 --- a/privacyIDEA.ftl +++ b/privacyIDEA.ftl @@ -10,24 +10,23 @@
<#if tokenType = "push"> ${pushMessage} - <#else> + <#else> ${otpMessage} <#-- Show QR code for new token, if one has been enrolled --> <#if tokenEnrollmentQR != ""> -
+
-
+
Please scan the QR-Code with an authenticator app like "privacyIDEA Authenticator" or "Google Authenticator" - +
-
-
+
<#--These inputs will be returned to privacyIDEAAuthenticator--> @@ -42,43 +41,44 @@
<#if tokenType = "push"> - <#--The form will be reloaded if push token is enabled to check if it is confirmed. - The interval can be set in the configuration--> - - <#if otpToken> - <#--The token type can be changed if we can use push or otp--> - - - <#else> - <#--If token type is not push, an input field and login button is needed--> - - - <#if pushToken> - <#--The token type can be changed if we can use push or otp--> - - + <#--The form will be reloaded if push token is enabled to check if it is confirmed. + The interval can be set in the configuration--> + + <#if otpToken> + <#--The token type can be changed if we can use push or otp--> + + + <#else> + <#--If token type is not push, an input field and login button is needed--> + + + <#if pushToken> + <#--The token type can be changed if we can use push or otp--> + + <#--If we change the token type, this information must be transmitted to privacyIDEAAuthenticator--> - +
- \ No newline at end of file + diff --git a/src/main/java/org/privacyidea/authenticator/Configuration.java b/src/main/java/org/privacyidea/authenticator/Configuration.java new file mode 100644 index 0000000..b48ec4c --- /dev/null +++ b/src/main/java/org/privacyidea/authenticator/Configuration.java @@ -0,0 +1,95 @@ +package org.privacyidea.authenticator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.privacyidea.authenticator.Const.*; + +class Configuration { + + private String _serverURL; + private String _realm; + private boolean _doSSLVerify; + private boolean _doTriggerChallenge; + private String _serviceAccountName; + private String _serviceAccountPass; + private List _excludedGroups = new ArrayList<>(); + private boolean _doEnrollToken; + private String _enrollingTokenType; + private List _pushtokenPollingInterval = new ArrayList<>(); + + Configuration(Map configMap) { + _serverURL = configMap.get(CONFIG_SERVER); + _realm = configMap.get(CONFIG_REALM) == null ? "" : configMap.get(CONFIG_REALM); + _doSSLVerify = configMap.get(CONFIG_VERIFYSSL) != null && configMap.get(CONFIG_VERIFYSSL).equals(TRUE); + _doTriggerChallenge = configMap.get(CONFIG_DOTRIGGERCHALLENGE) != null && configMap.get(CONFIG_DOTRIGGERCHALLENGE).equals(TRUE); + _serviceAccountName = configMap.get(CONFIG_SERVICEACCOUNT) == null ? "" : configMap.get(CONFIG_SERVICEACCOUNT); + _serviceAccountPass = (configMap.get(CONFIG_SERVICEPASS) == null) ? "" : configMap.get(CONFIG_SERVICEPASS); + _doEnrollToken = configMap.get(CONFIG_ENROLLTOKEN) != null && configMap.get(CONFIG_ENROLLTOKEN).equals(TRUE); + _enrollingTokenType = configMap.get(CONFIG_ENROLLTOKENTYPE) == null ? "" : configMap.get(CONFIG_ENROLLTOKENTYPE); + + String excludedGroupsStr = configMap.get(CONFIG_EXCLUDEGROUPS); + if (excludedGroupsStr != null) { + _excludedGroups.addAll(Arrays.asList(excludedGroupsStr.split(","))); + } + + // Set default, overwrite if configured + _pushtokenPollingInterval.addAll(DEFAULT_POLLING_ARRAY); + String s = configMap.get(CONFIG_PUSHTOKENINTERVAL); + if (s != null) { + List strPollingIntervals = Arrays.asList(s.split(",")); + if (!strPollingIntervals.isEmpty()) { + _pushtokenPollingInterval.clear(); + for (String str : strPollingIntervals) { + try { + _pushtokenPollingInterval.add(Integer.parseInt(str)); + } catch (NumberFormatException e) { + _pushtokenPollingInterval.add(DEFAULT_POLLING_INTERVAL); + } + } + } + } + } + + String getServerURL() { + return _serverURL; + } + + String getRealm() { + return _realm; + } + + boolean doSSLVerify() { + return _doSSLVerify; + } + + boolean doTriggerChallenge() { + return _doTriggerChallenge; + } + + String getServiceAccountName() { + return _serviceAccountName; + } + + String getServiceAccountPass() { + return _serviceAccountPass; + } + + List getExcludedGroups() { + return _excludedGroups; + } + + boolean doEnrollToken() { + return _doEnrollToken; + } + + String getEnrollingTokenType() { + return _enrollingTokenType; + } + + List getPushtokenPollingInterval() { + return _pushtokenPollingInterval; + } +} diff --git a/src/main/java/org/privacyidea/authenticator/Const.java b/src/main/java/org/privacyidea/authenticator/Const.java new file mode 100644 index 0000000..dc95515 --- /dev/null +++ b/src/main/java/org/privacyidea/authenticator/Const.java @@ -0,0 +1,81 @@ +package org.privacyidea.authenticator; + +import java.util.Arrays; +import java.util.List; + +final class Const { + private Const() { + } + + static final String PROVIDER_ID = "privacyidea-authenticator"; + + static final String GET = "GET"; + static final String POST = "POST"; + static final String TRUE = "true"; + + static final String ENDPOINT_AUTH = "/auth"; + static final String ENDPOINT_TOKEN_INIT = "/token/init"; + static final String ENDPOINT_TRIGGERCHALLENGE = "/validate/triggerchallenge"; + static final String ENDPOINT_TOKEN_CHALLENGES = "/token/challenges"; + static final String ENDPOINT_VALIDATE_CHECK = "/validate/check"; + static final String ENDPOINT_TOKEN = "/token"; + + static final String DEFAULT_PUSH_MESSAGE = "Please confirm the authentication on your mobile device"; + static final String DEFAULT_OTP_MESSAGE = "Please enter the OTP"; + + static final int DEFAULT_POLLING_INTERVAL = 2; // Will be used if single value from config cannot be parsed + static final List DEFAULT_POLLING_ARRAY = Arrays.asList(5, 1, 1, 1, 2, 3); // Will be used if no intervals are specified + + static final String FORM_PUSHTOKEN_INTERVAL = "pushTokenInterval"; + static final String FORM_TOKEN_ENROLLMENT_QR = "tokenEnrollmentQR"; + static final String FORM_TOKENTYPE = "tokenType"; + static final String FORM_PUSHTOKEN = "pushToken"; + static final String FORM_OTPTOKEN = "otpToken"; + static final String FORM_PUSH_MESSAGE = "pushMessage"; + static final String FORM_OTP_MESSAGE = "otpMessage"; + static final String FORM_FILE_NAME = "privacyIDEA.ftl"; + static final String FORM_TOKENTYPE_CHANGED = "tokenTypeChanged"; + static final String FORM_PI_OTP = "pi_otp"; + + static final String PARAM_KEY_USERNAME = "username"; + static final String PARAM_KEY_USER = "user"; + static final String PARAM_KEY_PASSWORD = "password"; + static final String PARAM_KEY_PASS = "pass"; + static final String PARAM_KEY_TYPE = "type"; + static final String PARAM_KEY_GENKEY = "genkey"; + static final String PARAM_KEY_TRANSACTION_ID = "transaction_id"; + static final String PARAM_KEY_REALM = "realm"; + + static final String TOKEN_TYPE_PUSH = "push"; + static final String TOKEN_TYPE_OTP = "otp"; // Classic OTPs like HOTP/TOTP + + static final String AUTH_NOTE_TRANSACTION_ID = "pi.transaction_id"; + static final String AUTH_NOTE_AUTH_COUNTER = "authCounter"; + + static final String JSON_KEY_DETAIL = "detail"; + static final String JSON_KEY_RESULT = "result"; + static final String JSON_KEY_VALUE = "value"; + static final String JSON_KEY_MESSAGE = "message"; + static final String JSON_KEY_MULTI_CHALLENGE = "multi_challenge"; + static final String JSON_KEY_TYPE = "type"; + static final String JSON_KEY_TOKEN = "token"; + static final String JSON_KEY_GOOGLEURL = "googleurl"; + static final String JSON_KEY_IMG = "img"; + static final String JSON_KEY_CHALLENGES = "challenges"; + static final String JSON_KEY_OTP_VALID = "otp_valid"; + static final String JSON_KEY_TRANSACTION_ID = "transaction_id"; + static final String JSON_KEY_MESSAGES = "messages"; + static final String JSON_KEY_TRANSACTION_IDS = "transaction_ids"; + static final String JSON_KEY_TOKENS = "tokens"; + + static final String CONFIG_PUSHTOKENINTERVAL = "pipushtokeninterval"; + static final String CONFIG_EXCLUDEGROUPS = "piexcludegroups"; + static final String CONFIG_ENROLLTOKENTYPE = "pienrolltokentype"; + static final String CONFIG_ENROLLTOKEN = "pienrolltoken"; + static final String CONFIG_SERVICEPASS = "piservicepass"; + static final String CONFIG_SERVICEACCOUNT = "piserviceaccount"; + static final String CONFIG_DOTRIGGERCHALLENGE = "pidotriggerchallenge"; + static final String CONFIG_VERIFYSSL = "piverifyssl"; + static final String CONFIG_REALM = "pirealm"; + static final String CONFIG_SERVER = "piserver"; +} diff --git a/src/main/java/org/privacyidea/authenticator/Endpoint.java b/src/main/java/org/privacyidea/authenticator/Endpoint.java new file mode 100644 index 0000000..7a889ad --- /dev/null +++ b/src/main/java/org/privacyidea/authenticator/Endpoint.java @@ -0,0 +1,173 @@ +package org.privacyidea.authenticator; + +import org.jboss.logging.Logger; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.net.ssl.*; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.privacyidea.authenticator.Const.*; + +class Endpoint { + + private Logger _log = Logger.getLogger(getClass().getName()); + private String _authToken; + private Configuration _config; + private List excludedEndpointPrints = Arrays.asList(ENDPOINT_TOKEN_CHALLENGES); + + Endpoint(Configuration config) { + this._config = config; + } + + /** + * Make a http(s) call to the specified path, the URL is taken from the config. + * If SSL Verification is turned off in the config, the endpoints certificate will not be verified. + * + * @param path Path to the API endpoint + * @param params All necessary parameters for request + * @param authTokenRequired whether the authorization header should be set + * @param method "POST" or "GET" + * @return JsonObject body which contains the whole response + */ + JsonObject sendRequest(String path, Map params, boolean authTokenRequired, String method) { + //_log.info("Sending to endpoint=" + path + " with params=" + params.toString() + " and method=" + method); + StringBuilder paramsSB = new StringBuilder(); + params.forEach((key, value) -> { + try { + paramsSB.append(key).append("=").append(URLEncoder.encode(value, StandardCharsets.UTF_8.toString())).append("&"); + } catch (Exception e) { + _log.error(e); + } + }); + paramsSB.deleteCharAt(paramsSB.length() - 1); + try { + URL serverURL; + if (method.equals(GET)) { + serverURL = new URL(_config.getServerURL() + path + "?" + paramsSB.toString()); + } else { + serverURL = new URL(_config.getServerURL() + path); + } + + HttpURLConnection con; + if (serverURL.getProtocol().equals("https")) { + con = (HttpsURLConnection) (serverURL.openConnection()); + } else { + con = (HttpURLConnection) (serverURL.openConnection()); + } + + if (!_config.doSSLVerify() && con instanceof HttpsURLConnection) { + con = turnOffSSLVerification((HttpsURLConnection) con); + } + + con.setDoOutput(true); + con.setRequestMethod(method); + + if (_authToken == null && authTokenRequired) { + getAuthorizationToken(); + } + + if (_authToken != null && authTokenRequired) { + con.setRequestProperty("Authorization", _authToken); + } else if (authTokenRequired) { + throw new IllegalStateException("No authorization token found but is needed!"); + } + con.connect(); + + if (method.equals(POST)) { + byte[] outputBytes = (paramsSB.toString()).getBytes(StandardCharsets.UTF_8); + OutputStream os = con.getOutputStream(); + os.write(outputBytes); + os.close(); + } + + String response; + try (InputStream is = con.getInputStream()) { + BufferedReader br = new BufferedReader(new InputStreamReader(is)); + response = br.lines().reduce("", (a, s) -> a += s); + } + + /*if (!excludedEndpointPrints.contains(path)) { + _log.info(path + " RESPONSE: " + Utilities.prettyPrintJson(response)); + }*/ + + JsonReader jsonReader = Json.createReader(new StringReader(response)); + JsonObject body = jsonReader.readObject(); + jsonReader.close(); + + return body; + } catch (Exception e) { + _log.error(e); + } + return null; + } + + /** + * This function will be called on every http request if doSSLVerify is set to false + */ + private HttpsURLConnection turnOffSSLVerification(HttpsURLConnection con) { + final TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[]{}; + } + } + }; + SSLContext sslContext = null; + try { + sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + e.printStackTrace(); + } + + if (sslContext == null) { + return con; + } + + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + con.setSSLSocketFactory(sslSocketFactory); + con.setHostnameVerifier((hostname, session) -> true); + + return con; + } + + private void getAuthorizationToken() { + if (_authToken != null) { + //_log.info("Auth token already set."); + return; + } + //_log.info("Getting auth token from PI"); + Map params = new HashMap<>(); + params.put(PARAM_KEY_USERNAME, _config.getServiceAccountName()); + params.put(PARAM_KEY_PASSWORD, _config.getServiceAccountPass()); + JsonObject body = sendRequest(ENDPOINT_AUTH, params, false, POST); + JsonObject result = body.getJsonObject(JSON_KEY_RESULT); + JsonObject value = result.getJsonObject(JSON_KEY_VALUE); + _authToken = value.getString(JSON_KEY_TOKEN); + if (_authToken == null) { + _log.error("Failed to get authorization token."); + _log.error("Unable to read response from privacyIDEA."); + } + } +} diff --git a/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java b/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java new file mode 100644 index 0000000..9a3fb95 --- /dev/null +++ b/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticator.java @@ -0,0 +1,287 @@ +package org.privacyidea.authenticator; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import javax.json.JsonArray; +import javax.json.JsonObject; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import java.util.*; + +import static org.privacyidea.authenticator.Const.*; + +/** + * Copyright 2019 NetKnights GmbH - micha.preusser@netknights.it + * nils.behlen@netknights.it + * - Modified + *

+ * Based on original code: + *

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

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

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

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +public class PrivacyIDEAAuthenticator implements org.keycloak.authentication.Authenticator { + + private static Logger _log = Logger.getLogger(PrivacyIDEAAuthenticator.class); + + private String _transactionID; + private String _currentUserName; + private Configuration _config; + private Endpoint _endpoint; + + /** + * This function will be called when the authentication flow triggers the privacyIDEA execution. + * i.e. after the username + password have been submitted. + * + * @param context AuthenticationFlowContext + */ + @Override + public void authenticate(AuthenticationFlowContext context) { + _config = new Configuration(context.getAuthenticatorConfig().getConfig()); + _endpoint = new Endpoint(_config); + + UserModel user = context.getUser(); + _currentUserName = user.getUsername(); + + Set groupModelSet = user.getGroups(); + GroupModel[] groupModels = groupModelSet.toArray(new GroupModel[0]); + + // Check if privacyIDEA is enabled for the current user + for (GroupModel groupModel : groupModels) { + for (String excludedGroup : _config.getExcludedGroups()) { + if (groupModel.getName().equals(excludedGroup)) { + context.success(); + return; + } + } + } + + // Trigger challenge for current user + int tokenCounter = 0; + String tokenType = TOKEN_TYPE_OTP; + // Check which kinds of tokens the user has to adapt the options of the form + boolean userHasPushToken = false; + boolean userHasOTPToken = false; + // Collect the messages for the tokens to display + List pushMessages = new ArrayList<>(); + List otpMessages = new ArrayList<>(); + if (_config.doTriggerChallenge()) { + Map params = new HashMap<>(); + params.put(PARAM_KEY_USER, _currentUserName); + JsonObject body = _endpoint.sendRequest(ENDPOINT_TRIGGERCHALLENGE, params, true, POST); + try { + JsonObject detail = body.getJsonObject(JSON_KEY_DETAIL); + JsonObject result = body.getJsonObject(JSON_KEY_RESULT); + tokenCounter = result.getInt(JSON_KEY_VALUE); + if (tokenCounter > 0) { + _transactionID = detail.getString(JSON_KEY_TRANSACTION_ID); + JsonArray multi_challenge = detail.getJsonArray(JSON_KEY_MULTI_CHALLENGE); + for (int i = 0; i < multi_challenge.size(); i++) { + JsonObject challenge = multi_challenge.getJsonObject(i); + String msg = challenge.getString(JSON_KEY_MESSAGE); + if (challenge.getString(JSON_KEY_TYPE).equals(TOKEN_TYPE_PUSH)) { + userHasPushToken = true; + if (!pushMessages.contains(msg)) { + pushMessages.add(msg); + } + } else { + userHasOTPToken = true; + if (!otpMessages.contains(msg)) { + otpMessages.add(msg); + } + } + } + if (userHasPushToken) { + tokenType = TOKEN_TYPE_PUSH; + } + } + } catch (Exception e) { + _log.error(e); + _log.error("Trigger challenge was not successful."); + } + } + + // Enroll token if enabled and user does not have one (counted from trigger challenge) + String tokenEnrollmentQR = ""; + if (_config.doEnrollToken() && tokenCounter == 0) { + // Check if user has a token + Map params = new HashMap<>(); + params.put(PARAM_KEY_USER, _currentUserName); + /*JsonObject response = _endpoint.sendRequest(ENDPOINT_TOKEN, params, true, GET); + + JsonObject result = response.getJsonObject(JSON_KEY_RESULT); + JsonObject value = result.getJsonObject(JSON_KEY_VALUE); + JsonArray tokens = value.getJsonArray(JSON_KEY_TOKENS); + log.info("tokens size:" + tokens.size()); */ + //if (tokens.size() < 1) { + params.put(PARAM_KEY_TYPE, _config.getEnrollingTokenType()); + params.put(PARAM_KEY_GENKEY, "1"); + JsonObject response = _endpoint.sendRequest(ENDPOINT_TOKEN_INIT, params, true, POST); + try { + JsonObject detail = response.getJsonObject(JSON_KEY_DETAIL); + JsonObject googleurl = detail.getJsonObject(JSON_KEY_GOOGLEURL); + tokenEnrollmentQR = googleurl.getString(JSON_KEY_IMG); + } catch (Exception e) { + _log.error("Token enrollment failed"); + } + //} + } + context.getAuthenticationSession().setAuthNote(AUTH_NOTE_AUTH_COUNTER, "0"); + + // Create login form + String pushMessage = Utilities.buildPromptMessage(pushMessages, DEFAULT_PUSH_MESSAGE); + String otpMessage = Utilities.buildPromptMessage(otpMessages, DEFAULT_OTP_MESSAGE); + + Response challenge = context.form() + .setAttribute(FORM_PUSHTOKEN_INTERVAL, _config.getPushtokenPollingInterval().get(0)) + .setAttribute(FORM_TOKEN_ENROLLMENT_QR, tokenEnrollmentQR) + .setAttribute(FORM_TOKENTYPE, tokenType) + .setAttribute(FORM_PUSHTOKEN, userHasPushToken) + .setAttribute(FORM_OTPTOKEN, userHasOTPToken) + .setAttribute(FORM_PUSH_MESSAGE, pushMessage) + .setAttribute(FORM_OTP_MESSAGE, otpMessage) + .createForm(FORM_FILE_NAME); + context.challenge(challenge); + } + + /** + * This function will be called if the user submitted the OTP form + * + * @param context AuthenticationFlowContext + */ + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + if (formData.containsKey("cancel")) { + context.cancelLogin(); + return; + } + /*log.info("formData:"); + formData.forEach((k, v) -> log.info("key=" + k + ", value=" + v)); */ + + // Get data from form + String tokenEnrollmentQR = formData.getFirst(FORM_TOKEN_ENROLLMENT_QR); + String tokenType = formData.getFirst(FORM_TOKENTYPE); + boolean pushToken = formData.getFirst(FORM_PUSHTOKEN).equals(TRUE); + boolean otpToken = formData.getFirst(FORM_OTPTOKEN).equals(TRUE); + String pushMessage = formData.getFirst(FORM_PUSH_MESSAGE); + String otpMessage = formData.getFirst(FORM_OTP_MESSAGE); + String tokenTypeChanged = formData.getFirst(FORM_TOKENTYPE_CHANGED); + + if (!validateResponse(context)) { + int authCounter = Integer.parseInt(context.getAuthenticationSession().getAuthNote(AUTH_NOTE_AUTH_COUNTER)) + 1; + authCounter = (authCounter >= _config.getPushtokenPollingInterval().size() ? _config.getPushtokenPollingInterval().size() - 1 : authCounter); + context.getAuthenticationSession().setAuthNote(AUTH_NOTE_AUTH_COUNTER, Integer.toString(authCounter)); + + LoginFormsProvider form = context.form() + .setAttribute(FORM_PUSHTOKEN_INTERVAL, _config.getPushtokenPollingInterval().get(authCounter)) + .setAttribute(FORM_TOKEN_ENROLLMENT_QR, tokenEnrollmentQR) + .setAttribute(FORM_TOKENTYPE, tokenType) + .setAttribute(FORM_PUSHTOKEN, pushToken) + .setAttribute(FORM_OTPTOKEN, otpToken) + .setAttribute(FORM_PUSH_MESSAGE, pushMessage == null ? DEFAULT_PUSH_MESSAGE : pushMessage) + .setAttribute(FORM_OTP_MESSAGE, otpMessage == null ? DEFAULT_OTP_MESSAGE : otpMessage); + + // Dont display the error if the token type was switched + if (!tokenTypeChanged.equals(TRUE)) { + form.setError(tokenType.equals(TOKEN_TYPE_PUSH) ? "Authentication not verified yet." : "Authentication failed."); + //log.info("Authentication failed for user " + context.getUser().getUsername()); + } + Response challenge = form.createForm(FORM_FILE_NAME); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); + return; + } + context.success(); + } + + /** + * Check if authentication is successful + * + * @param context AuthenticationFlowContext + * @return true if authentication was successful, else false + */ + private boolean validateResponse(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + if (formData.getFirst(FORM_TOKENTYPE_CHANGED).equals(TRUE)) { + return false; + } + + // Get data from form + String tokenEnrollmentQR = formData.getFirst(FORM_TOKEN_ENROLLMENT_QR); + String tokenType = formData.getFirst(FORM_TOKENTYPE); + + if (tokenType.equals(TOKEN_TYPE_PUSH)) { + Map params = new HashMap<>(); + params.put(PARAM_KEY_TRANSACTION_ID, _transactionID); + JsonObject body = _endpoint.sendRequest(ENDPOINT_TOKEN_CHALLENGES, params, true, GET); + try { + JsonObject result = body.getJsonObject(JSON_KEY_RESULT); + JsonObject value = result.getJsonObject(JSON_KEY_VALUE); + JsonArray challenges = value.getJsonArray(JSON_KEY_CHALLENGES); + for (int i = 0; i < challenges.size(); i++) { + JsonObject challenge = challenges.getJsonObject(i); + if (challenge.getBoolean(JSON_KEY_OTP_VALID)) { + return true; + } + } + } catch (Exception e) { + _log.error("Push token verification failed."); + } + return false; + } + + String otp = formData.getFirst(FORM_PI_OTP); + Map params = new HashMap<>(); + params.put(PARAM_KEY_USER, _currentUserName); + params.put(PARAM_KEY_PASS, otp); + params.put(PARAM_KEY_REALM, _config.getRealm()); + if (_config.doTriggerChallenge() && tokenEnrollmentQR.isEmpty()) { + params.put(PARAM_KEY_TRANSACTION_ID, _transactionID); + } + JsonObject body = _endpoint.sendRequest(ENDPOINT_VALIDATE_CHECK, params, false, POST); + try { + JsonObject result = body.getJsonObject(JSON_KEY_RESULT); + return result.getBoolean(JSON_KEY_VALUE); + } catch (Exception e) { + _log.error("Verification was not successful: Invalid response from privacyIDEA"); + } + return false; + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticatorFactory.java b/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java similarity index 74% rename from src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticatorFactory.java rename to src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java index 12c4808..7afec4f 100644 --- a/src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticatorFactory.java +++ b/src/main/java/org/privacyidea/authenticator/PrivacyIDEAAuthenticatorFactory.java @@ -1,9 +1,6 @@ package org.privacyidea.authenticator; -import org.jboss.logging.Logger; import org.keycloak.Config; -import org.keycloak.authentication.Authenticator; -import org.keycloak.authentication.AuthenticatorFactory; import org.keycloak.authentication.ConfigurableAuthenticatorFactory; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; @@ -13,8 +10,11 @@ import java.util.ArrayList; import java.util.List; +import static org.privacyidea.authenticator.Const.*; + /** - * Copyright 2019 NetKnights GmbH - micha.preusser@neknights.it + * Copyright 2019 NetKnights GmbH - micha.preusser@netknights.it + * nils.behlen@netknights.it * - Modified *

* Based on original code: @@ -34,23 +34,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -public class privacyIDEAAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { - - //private static Logger log = Logger.getLogger(privacyIDEAAuthenticatorFactory.class); +public class PrivacyIDEAAuthenticatorFactory implements org.keycloak.authentication.AuthenticatorFactory, ConfigurableAuthenticatorFactory { - private static final String PROVIDER_ID = "privacyidea-authenticator"; - private static final privacyIDEAAuthenticator SINGLETON = new privacyIDEAAuthenticator(); + private static final PrivacyIDEAAuthenticator SINGLETON = new PrivacyIDEAAuthenticator(); private static final List configProperties = new ArrayList<>(); @Override public String getId() { - //log.debug("AUTHENTICATOR FACTORY: GET ID"); return PROVIDER_ID; } @Override - public Authenticator create(KeycloakSession session) { - //log.debug("AUTHENTICATOR FACTORY: CREATE"); + public org.keycloak.authentication.Authenticator create(KeycloakSession session) { return SINGLETON; } @@ -61,105 +56,99 @@ public Authenticator create(KeycloakSession session) { @Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { - //log.debug("AUTHENTICATOR FACTORY: getRequirement Choices"); return REQUIREMENT_CHOICES; } @Override public boolean isUserSetupAllowed() { - // log.debug("AUTHENTICATOR FACTORY: isUserSetupAllowed"); return false; } @Override public boolean isConfigurable() { - //log.debug("AUTHENTICATOR FACTORY: isConfigurable"); return true; } @Override public List getConfigProperties() { - //log.debug("AUTHENTICATOR FACTORY: getConfigProperies"); return configProperties; } static { - //log.debug("AUTHENTICATOR FACTORY: STATIC INIT"); - ProviderConfigProperty piServerUrl = new ProviderConfigProperty(); piServerUrl.setType(ProviderConfigProperty.STRING_TYPE); - piServerUrl.setName("piserver"); + piServerUrl.setName(CONFIG_SERVER); piServerUrl.setLabel("URL"); - piServerUrl.setHelpText("The URL to the privacyIDEA server"); + piServerUrl.setHelpText("The URL of the privacyIDEA server"); configProperties.add(piServerUrl); ProviderConfigProperty piRealm = new ProviderConfigProperty(); piRealm.setType(ProviderConfigProperty.STRING_TYPE); - piRealm.setName("pirealm"); + piRealm.setName(CONFIG_REALM); piRealm.setLabel("Realm"); piRealm.setHelpText("Select the realm where your users are stored. Leave empty for default."); configProperties.add(piRealm); ProviderConfigProperty piVerifySSL = new ProviderConfigProperty(); piVerifySSL.setType(ProviderConfigProperty.BOOLEAN_TYPE); - piVerifySSL.setName("piverifyssl"); + piVerifySSL.setName(CONFIG_VERIFYSSL); piVerifySSL.setLabel("Verify SSL"); piVerifySSL.setHelpText("Do not uncheck this in productive environment"); configProperties.add(piVerifySSL); ProviderConfigProperty piDoTriggerChallenge = new ProviderConfigProperty(); piDoTriggerChallenge.setType(ProviderConfigProperty.BOOLEAN_TYPE); - piDoTriggerChallenge.setName("pidotriggerchallenge"); + piDoTriggerChallenge.setName(CONFIG_DOTRIGGERCHALLENGE); piDoTriggerChallenge.setLabel("Enable trigger challenge"); piDoTriggerChallenge.setHelpText("Choose if you want to do trigger challenge"); configProperties.add(piDoTriggerChallenge); ProviderConfigProperty piServiceAccount = new ProviderConfigProperty(); piServiceAccount.setType(ProviderConfigProperty.STRING_TYPE); - piServiceAccount.setName("piserviceaccount"); + piServiceAccount.setName(CONFIG_SERVICEACCOUNT); piServiceAccount.setLabel("Service account"); piServiceAccount.setHelpText("Username of the service account. Needed for trigger challenge, token enrollment and push tokens."); configProperties.add(piServiceAccount); ProviderConfigProperty piServicePass = new ProviderConfigProperty(); piServicePass.setType(ProviderConfigProperty.PASSWORD); - piServicePass.setName("piservicepass"); + piServicePass.setName(CONFIG_SERVICEPASS); piServicePass.setLabel("Service account password"); piServicePass.setHelpText("Password of the service account. Needed for trigger challenge, token enrollment and push tokens"); configProperties.add(piServicePass); ProviderConfigProperty piExcludeGroups = new ProviderConfigProperty(); piExcludeGroups.setType(ProviderConfigProperty.STRING_TYPE); - piExcludeGroups.setName("piexcludegroups"); + piExcludeGroups.setName(CONFIG_EXCLUDEGROUPS); piExcludeGroups.setLabel("Exclude groups"); piExcludeGroups.setHelpText("You can select groups, which will not do 2FA. Enter the group names and separate them with comma e.g. 'group1,group2'"); configProperties.add(piExcludeGroups); ProviderConfigProperty piEnrollToken = new ProviderConfigProperty(); piEnrollToken.setType(ProviderConfigProperty.BOOLEAN_TYPE); - piEnrollToken.setName("pienrolltoken"); + piEnrollToken.setName(CONFIG_ENROLLTOKEN); piEnrollToken.setLabel("Enable token enrollment"); - piEnrollToken.setHelpText("If enabled, the users can enroll a token themselves, if they do not have one yet. Service account is needed"); + piEnrollToken.setHelpText("If enabled, the users can enroll a token themselves, if they do not have one yet. Trigger Challenge has to be enabled and a service account is needed"); piEnrollToken.setDefaultValue("false"); configProperties.add(piEnrollToken); List tokenTypes = new ArrayList<>(); - tokenTypes.add("hotp"); - tokenTypes.add("totp"); + tokenTypes.add("HOTP"); + tokenTypes.add("TOTP"); ProviderConfigProperty piTokenType = new ProviderConfigProperty(); piTokenType.setType(ProviderConfigProperty.LIST_TYPE); - piTokenType.setName("pienrolltokentype"); + piTokenType.setName(CONFIG_ENROLLTOKENTYPE); piTokenType.setLabel("Token type"); - piTokenType.setHelpText("Select the token type, the users can enroll, if they do not have a token yet. Service account is needed"); + piTokenType.setHelpText("Select the token type that users can enroll, if they do not have a token yet. Service account is needed"); piTokenType.setOptions(tokenTypes); - piTokenType.setDefaultValue("hotp"); + piTokenType.setDefaultValue("HOTP"); configProperties.add(piTokenType); ProviderConfigProperty piPushTokenInterval = new ProviderConfigProperty(); piPushTokenInterval.setType(ProviderConfigProperty.STRING_TYPE); - piPushTokenInterval.setName("pipushtokeninterval"); + piPushTokenInterval.setName(CONFIG_PUSHTOKENINTERVAL); piPushTokenInterval.setLabel("Refresh interval for push tokens"); - piPushTokenInterval.setHelpText("Set refresh interval for push tokens in seconds. You can use a comma separated list. The last entry will be repeated."); + piPushTokenInterval.setHelpText("Set the refresh interval for push tokens in seconds. Use a comma separated list. The last entry will be repeated."); configProperties.add(piPushTokenInterval); } diff --git a/src/main/java/org/privacyidea/authenticator/Utilities.java b/src/main/java/org/privacyidea/authenticator/Utilities.java new file mode 100644 index 0000000..6f543d4 --- /dev/null +++ b/src/main/java/org/privacyidea/authenticator/Utilities.java @@ -0,0 +1,49 @@ +package org.privacyidea.authenticator; + +import javax.json.*; +import javax.json.stream.JsonGenerator; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class Utilities { + + private Configuration _config; + + public Utilities(Configuration config) { + this._config = config; + } + + static String prettyPrintJson(String json) { + StringWriter sw = new StringWriter(); + try { + JsonReader jr = Json.createReader(new StringReader(json)); + JsonObject jobj = jr.readObject(); + + Map properties = new HashMap<>(1); + properties.put(JsonGenerator.PRETTY_PRINTING, true); + + JsonWriterFactory writerFactory = Json.createWriterFactory(properties); + JsonWriter jsonWriter = writerFactory.createWriter(sw); + + jsonWriter.writeObject(jobj); + jsonWriter.close(); + } catch (Exception e) { + e.printStackTrace(); + } + return sw.toString(); + } + + static String buildPromptMessage(List messages, String defaultMessage) { + String res = defaultMessage; + if (messages.size() > 1) { + res = messages.remove(0); + res += messages.stream().reduce("", (a, s) -> a += " or " + s); + } else if (messages.size() == 1) { + res = messages.get(0); + } + return res; + } +} diff --git a/src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticator.java b/src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticator.java deleted file mode 100644 index 1bdb94d..0000000 --- a/src/main/java/org/privacyidea/authenticator/privacyIDEAAuthenticator.java +++ /dev/null @@ -1,460 +0,0 @@ -package org.privacyidea.authenticator; - -import org.jboss.logging.Logger; -import org.keycloak.authentication.AuthenticationFlowContext; -import org.keycloak.authentication.AuthenticationFlowError; -import org.keycloak.authentication.Authenticator; -import org.keycloak.forms.login.LoginFormsProvider; -import org.keycloak.models.*; - -import javax.json.Json; -import javax.json.JsonArray; -import javax.json.JsonObject; -import javax.json.JsonReader; -import javax.net.ssl.*; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.util.*; - -/** - * Copyright 2019 NetKnights GmbH - micha.preusser@neknights.it - * - Modified - *

- * Based on original code: - *

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

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

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

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -public class privacyIDEAAuthenticator implements Authenticator { - public static final String CREDENTIAL_TYPE = "pi_otp"; - - private static Logger log = Logger.getLogger(privacyIDEAAuthenticator.class); - - private String _serverURL; - private String _realm; - private boolean _doSSLVerify; - private boolean _doTriggerChallenge; - private String _serviceAccountName; - private String _serviceAccountPass; - private List _excludedGroups = new ArrayList<>(); - private boolean _doEnrollToken; - private String _enrollingTokenType; - private List _pushtokenPollingInterval = new ArrayList<>(); - private String _serviceAccountAuthToken; - - /** - * This function will be called when the authentication flow triggers the privacyIDEA execution. - * - * @param context AuthenticationFlowContext - */ - @Override - public void authenticate(AuthenticationFlowContext context) { - //log.debug("authenticate"); - String username; - String tokenEnrollmentQR = ""; - String tokenType = "otp"; - boolean pushToken = false; - boolean otpToken = false; - StringBuilder pushMessageSB = null; - StringBuilder otpMessageSB = null; - - UserModel user = context.getUser(); - username = user.getUsername(); - - Set groupModelSet = user.getGroups(); - GroupModel[] groupModels = groupModelSet.toArray(new GroupModel[0]); - - AuthenticatorConfigModel acm = context.getAuthenticatorConfig(); - - loadConfiguration(acm.getConfig()); - - // Check if privacyIDEA is enabled for the current user - for (GroupModel groupModel : groupModels) { - for (String excludedGroup : _excludedGroups) { - if (groupModel.getName().equals(excludedGroup)) { - context.success(); - return; - } - } - } - - int tokenCounter = 0; - - // Trigger challenge for current user - if (_doTriggerChallenge) { - Map params = new HashMap<>(); - params.put("username", _serviceAccountName); - params.put("password", _serviceAccountPass); - JsonObject body = send("/auth", params, null, "POST"); - try { - JsonObject result = body.getJsonObject("result"); - JsonObject value = result.getJsonObject("value"); - _serviceAccountAuthToken = value.getString("token"); - } catch (Exception e) { - log.error(e); - log.error("Failed to get authorization token."); - log.error("Unable to read response from privacyIDEA."); - } - - params.put("user", username); - body = send("/validate/triggerchallenge", params, _serviceAccountAuthToken, "POST"); - - try { - JsonObject detail = body.getJsonObject("detail"); - JsonObject result = body.getJsonObject("result"); - tokenCounter = result.getInt("value"); - if (tokenCounter > 0) { - context.getAuthenticationSession().setAuthNote("pi.transaction_id", detail.getString("transaction_id")); - JsonArray multi_challenge = detail.getJsonArray("multi_challenge"); - for (int i = 0; i < multi_challenge.size(); i++) { - JsonObject challenge = multi_challenge.getJsonObject(i); - if (challenge.getString("type").equals("push")) { - pushToken = true; - if (pushMessageSB == null) { // First time - pushMessageSB = new StringBuilder().append(challenge.getString("message")); - } else { // >1 times - pushMessageSB.append(", ").append(challenge.getString("message")); - } - } else { - otpToken = true; - if (otpMessageSB == null) { // First time - otpMessageSB = new StringBuilder().append(challenge.getString("message")); - } else { // >1 times - otpMessageSB.append(", ").append(challenge.getString("message")); - } - } - } - if (pushToken) { - tokenType = "push"; - } - } - } catch (Exception e) { - log.error(e); - log.error("Trigger challenge was not successful."); - } - - // Enroll token if enabled and user does not have one - if (_doEnrollToken && tokenCounter == 0) { - params.put("user", username); - params.put("type", _enrollingTokenType); - params.put("genkey", "1"); - body = send("/token/init", params, _serviceAccountAuthToken, "POST"); - try { - JsonObject detail = body.getJsonObject("detail"); - JsonObject googleurl = detail.getJsonObject("googleurl"); - tokenEnrollmentQR = googleurl.getString("img"); - } catch (Exception e) { - log.error("Token enrollment failed"); - } - } - } - - context.getAuthenticationSession().setAuthNote("authCounter", "0"); - - // Create login form - Response challenge = context.form() - .setAttribute("pushTokenInterval", _pushtokenPollingInterval.get(0)) - .setAttribute("tokenEnrollmentQR", tokenEnrollmentQR) - .setAttribute("tokenType", tokenType) - .setAttribute("pushToken", pushToken) - .setAttribute("otpToken", otpToken) - .setAttribute("pushMessage", pushMessageSB == null ? "" : pushMessageSB.toString()) - .setAttribute("otpMessage", otpMessageSB == null ? "Please enter OTP" : otpMessageSB.toString()) - .createForm("privacyIDEA.ftl"); - context.challenge(challenge); - } - - private void loadConfiguration(Map configMap) { - _serverURL = configMap.get("piserver"); - _realm = configMap.get("pirealm") == null ? "" : configMap.get("pirealm"); - _doSSLVerify = configMap.get("piverifyssl") != null && configMap.get("piverifyssl").equals("true"); - _doTriggerChallenge = configMap.get("pidotriggerchallenge") != null && configMap.get("pidotriggerchallenge").equals("true"); - _serviceAccountName = configMap.get("piserviceaccount") == null ? "" : configMap.get("piserviceaccount"); - _serviceAccountPass = configMap.get("piservicepass") == null ? "" : configMap.get("piservicepass"); - _doEnrollToken = configMap.get("pienrolltoken") != null && configMap.get("pienrolltoken").equals("true"); - _enrollingTokenType = configMap.get("pienrolltokentype") == null ? "" : configMap.get("pienrolltokentype"); - - String excludedGroupsStr = configMap.get("piexcludegroups"); - if (excludedGroupsStr != null) { - _excludedGroups.addAll(Arrays.asList(excludedGroupsStr.split(","))); - } - - // Set default, overwrite if configured - _pushtokenPollingInterval.addAll(Arrays.asList(5, 1, 1, 1, 2, 3)); - String s = configMap.get("pipushtokeninterval"); - if (s != null) { - List strPollingIntervals = Arrays.asList(s.split(",")); - if (!strPollingIntervals.isEmpty()) { - _pushtokenPollingInterval.clear(); - for (String str : strPollingIntervals) { - try { - _pushtokenPollingInterval.add(Integer.parseInt(str)); - } catch (NumberFormatException e) { - _pushtokenPollingInterval.add(3); // TODO - } - } - } - } - } - - /** - * This function will be called if the user submitted the form - * - * @param context AuthenticationFlowContext - */ - @Override - public void action(AuthenticationFlowContext context) { - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - if (formData.containsKey("cancel")) { - context.cancelLogin(); - return; - } - - // Get data from form - String tokenEnrollmentQR = formData.getFirst("tokenEnrollmentQR"); - String tokenType = formData.getFirst("tokenType"); - boolean pushToken = formData.getFirst("pushToken").equals("true"); - boolean otpToken = formData.getFirst("otpToken").equals("true"); - String transaction_id = context.getAuthenticationSession().getAuthNote("pi.transaction_id"); - String pushMessage = formData.getFirst("pushMessage"); - String otpMessage = formData.getFirst("otpMessage"); - String tokenTypeChanged = formData.getFirst("tokenTypeChanged"); - - if (!validateAnswer(context)) { - int authCounter = Integer.parseInt(context.getAuthenticationSession().getAuthNote("authCounter")) + 1; - authCounter = (authCounter >= _pushtokenPollingInterval.size() ? _pushtokenPollingInterval.size() - 1 : authCounter); - context.getAuthenticationSession().setAuthNote("authCounter", Integer.toString(authCounter)); - - LoginFormsProvider form = context.form() - .setAttribute("pushTokenInterval", _pushtokenPollingInterval.get(authCounter)) - .setAttribute("tokenEnrollmentQR", tokenEnrollmentQR) - .setAttribute("tokenType", tokenType) - .setAttribute("pushToken", pushToken) - .setAttribute("otpToken", otpToken) - .setAttribute("pushMessage", pushMessage == null ? "" : pushMessage) - .setAttribute("otpMessage", otpMessage == null ? "" : otpMessage); - - if (!tokenType.equals("push") || !tokenTypeChanged.equals("true")) { - form.setError("Authentication failed."); - log.debug("Authentication failed for user " + context.getUser().getUsername()); - } - Response challenge = form.createForm("privacyIDEA.ftl"); - context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); - return; - } - context.success(); - } - - /** - * Check if authentication is successful - * - * @param context AuthenticationFlowContext - * @return true if authentication was successful, else false - */ - private boolean validateAnswer(AuthenticationFlowContext context) { - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - - UserModel user = context.getUser(); - String username = user.getUsername(); - - // Get data from form - String tokenEnrollmentQR = formData.getFirst("tokenEnrollmentQR"); - String tokenType = formData.getFirst("tokenType"); - boolean pushToken = formData.getFirst("pushToken").equals("true"); - boolean otpToken = formData.getFirst("otpToken").equals("true"); - String transaction_id = context.getAuthenticationSession().getAuthNote("pi.transaction_id"); - String pushMessage = formData.getFirst("pushMessage"); - String otpMessage = formData.getFirst("otpMessage"); - - if (formData.getFirst("tokenTypeChanged").equals("true")) { - return false; - } - - if (tokenType.equals("push")) { - Map params = new HashMap<>(); - params.put("transaction_id", transaction_id); - JsonObject body = send("/token/challenges/", params, _serviceAccountAuthToken, "GET"); - try { - JsonObject result = body.getJsonObject("result"); - JsonObject value = result.getJsonObject("value"); - JsonArray challenges = value.getJsonArray("challenges"); - for (int i = 0; i < challenges.size(); i++) { - JsonObject challenge = challenges.getJsonObject(i); - if (challenge.getBoolean("otp_valid")) { - return true; - } - } - } catch (Exception e) { - log.error("Push token verification failed."); - } - return false; - } - - String otp = formData.getFirst("pi_otp"); - Map params = new HashMap<>(); - params.put("user", username); - params.put("pass", otp); - params.put("realm", _realm); - if (_doTriggerChallenge && tokenEnrollmentQR.equals("")) { - params.put("transaction_id", transaction_id); - } - JsonObject body = send("/validate/check", params, null, "POST"); - try { - JsonObject result = body.getJsonObject("result"); - return result.getBoolean("value"); - } catch (Exception e) { - log.error("Verification was not successful: Invalid response from privacyIDEA"); - } - return false; - } - - /** - * Make a http(s) call to the specified path, the URL is taken from the config. - * If SSL Verification is turned off in the config, the endpoints certificate will not be verified. - * - * @param path Path to the API endpoint - * @param params All necessary parameters for request - * @param authToken The auth token for the service account (null, if not necessary) - * @param method "POST" or "GET" - * @return JsonObject body which contains the whole response - */ - private JsonObject send(String path, Map params, String authToken, String method) { - StringBuilder paramsSB = new StringBuilder(); - params.forEach((key, value) -> { - try { - paramsSB.append(key).append("=").append(URLEncoder.encode(value, StandardCharsets.UTF_8.toString())).append("&"); - } catch (Exception e) { - log.error(e); - } - }); - paramsSB.deleteCharAt(paramsSB.length() - 1); - try { - URL piserverurl; - if (method.equals("GET")) { - piserverurl = new URL(_serverURL + path + "?" + paramsSB.toString()); - } else { - piserverurl = new URL(_serverURL + path); - } - - HttpURLConnection con; - if (piserverurl.getProtocol().equals("https")) { - con = (HttpsURLConnection) (piserverurl.openConnection()); - } else { - con = (HttpURLConnection) (piserverurl.openConnection()); - } - - if (!_doSSLVerify && con instanceof HttpsURLConnection) { - con = turnOffSSLVerification((HttpsURLConnection) con); - } - - con.setDoOutput(true); - con.setRequestMethod(method); - if (authToken != null) { - con.setRequestProperty("Authorization", authToken); - } - con.connect(); - - if (method.equals("POST")) { - byte[] outputBytes = (paramsSB.toString()).getBytes(StandardCharsets.UTF_8); - OutputStream os = con.getOutputStream(); - os.write(outputBytes); - os.close(); - } - - String response; - try (InputStream is = con.getInputStream()) { - BufferedReader br = new BufferedReader(new InputStreamReader(is)); - response = br.lines().reduce("", (a, s) -> a += s); - } - log.info("RESPONSE: " + response); - JsonReader jsonReader = Json.createReader(new StringReader(response)); - JsonObject body = jsonReader.readObject(); - jsonReader.close(); - - return body; - } catch (Exception e) { - log.error(e); - } - return null; - } - - /** - * This function will be called on every http request if doSSLVerify is set to false - */ - private HttpsURLConnection turnOffSSLVerification(HttpsURLConnection con) { - final TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - @Override - public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) { - } - - @Override - public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) { - } - - @Override - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[]{}; - } - } - }; - SSLContext sslContext = null; - try { - sslContext = SSLContext.getInstance("SSL"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - } catch (NoSuchAlgorithmException | KeyManagementException e) { - e.printStackTrace(); - } - - if (sslContext == null) { - return con; - } - - final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); - con.setSSLSocketFactory(sslSocketFactory); - con.setHostnameVerifier((hostname, session) -> true); - - return con; - } - - @Override - public boolean requiresUser() { - log.debug("requiresUser"); - return true; - } - - @Override - public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - log.debug("configuredFor"); - return true; - } - - @Override - public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { - log.debug("setRequiredActions"); - } - - @Override - public void close() { - log.debug("close"); - } -} diff --git a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 2db4c2c..f68ea91 100644 --- a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -15,4 +15,4 @@ # limitations under the License. # -org.privacyidea.authenticator.privacyIDEAAuthenticatorFactory \ No newline at end of file +org.privacyidea.authenticator.PrivacyIDEAAuthenticatorFactory \ No newline at end of file