diff --git a/src/main/java/at/schuerz.keycloak/authenticator/RegistrationMosparo.java b/src/main/java/at/schuerz.keycloak/authenticator/RegistrationMosparo.java index b770e7d..36fc139 100644 --- a/src/main/java/at/schuerz.keycloak/authenticator/RegistrationMosparo.java +++ b/src/main/java/at/schuerz.keycloak/authenticator/RegistrationMosparo.java @@ -17,13 +17,21 @@ package at.schuerz.keycloak.authenticator; +import com.fasterxml.jackson.databind.JsonNode; import jakarta.ws.rs.core.MultivaluedHashMap; -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; +import org.apache.commons.codec.binary.Hex; +import org.apache.http.HeaderElement; import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; import org.apache.http.message.BasicNameValuePair; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.TrustStrategy; +import org.apache.http.util.EntityUtils; import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.authentication.FormAction; @@ -41,26 +49,21 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; -import org.keycloak.provider.ConfiguredProvider; import org.keycloak.provider.ProviderConfigProperty; -import org.keycloak.services.ServicesLogger; -import org.keycloak.services.messages.Messages; -import org.keycloak.services.validation.Validation; import org.keycloak.util.JsonSerialization; -import java.io.InputStream; +import java.io.IOException; import jakarta.ws.rs.core.MultivaluedMap; +import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.util.EntityUtils; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.*; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; /** * @author Bill Burke @@ -82,6 +85,8 @@ public class RegistrationMosparo implements FormAction, FormActionFactory { public static final String PROVIDER_ID = "registration-mosparo-action"; + private Mac mHmacSha256; + @Override public String getDisplayType() { return "mosparo"; @@ -136,17 +141,20 @@ public class RegistrationMosparo implements FormAction, FormActionFactory { boolean success = false; context.getEvent().detail(Details.REGISTER_METHOD, "form"); - success = verifyFormData(context, formData); + try { + success = verifyFormData(context, formData); + } catch (NoSuchAlgorithmException | IOException | InvalidKeyException e) { + throw new RuntimeException(e); + } if (success) { context.success(); } else { - errors.add(new FormMessage(null, "Mosparo verification failed.")); + errors.add(new FormMessage(null, "mosparo verification failed.")); formData.remove(MOSPARO_RESPONSE); context.error(Errors.INVALID_REGISTRATION); context.validationError(formData, errors); context.excludeOtherErrors(); - return; } } @@ -154,7 +162,13 @@ public class RegistrationMosparo implements FormAction, FormActionFactory { return config.getConfig().get(MOSPARO_HOST); } - protected boolean validateMosparo(ValidationContext context, MultivaluedMap formData) { + protected boolean verifyFormData(ValidationContext context, MultivaluedMap formData) throws NoSuchAlgorithmException, IOException, InvalidKeyException { + boolean success = false; + + AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig(); + String publicKey = captchaConfig.getConfig().get(MOSPARO_PUBLIC_KEY); + String privateKey = captchaConfig.getConfig().get(MOSPARO_PRIVATE_KEY); + // 1. Remove the ignored fields from the form data formData.remove("password"); formData.remove("password-confirm"); @@ -164,58 +178,127 @@ public class RegistrationMosparo implements FormAction, FormActionFactory { String mosparoValidationToken = formData.getFirst("_mosparo_validationToken"); // 3. Prepare the form data - MultivaluedMap preparedFormData = new MultivaluedHashMap<>(); + Map preparedFormData = new HashMap(); for (Map.Entry> entry : formData.entrySet()) { if (entry.getKey().startsWith("_mosparo_")) { continue; } String value = entry.getValue().getFirst(); - preparedFormData.add(entry.getKey(), value.replace("\r\n", "\n")); + preparedFormData.put(entry.getKey(), value.replace("\r\n", "\n")); } // 4. Generate the hashes MessageDigest digest = MessageDigest.getInstance("SHA-256"); - MultivaluedMap hashedFormData = new MultivaluedHashMap<>(); - for (Map.Entry> entry : preparedFormData.entrySet()) { - String value = entry.getValue().getFirst(); + Map hashedFormData = new HashMap(); + + // Since the data must be sorted by keys, we sort the keys and then generate the + // SHA256 hash for all the values + List keylist = new ArrayList<>(preparedFormData.keySet()); + Collections.sort(keylist); + for (String key : keylist) { + String value = preparedFormData.get(key); byte[] hashedValue = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - hashedFormData.add(entry.getKey(), convertBytesToHex(hashedValue)); + hashedFormData.put(key, convertBytesToHex(hashedValue)); } // 5. Generate the form data signature + String jsonHashedFormData = JsonSerialization.writeValueAsString(hashedFormData); + String formDataSignature = calculateHmacSignature(jsonHashedFormData, privateKey); + // 6. Generate the validation signature + String validationSignature = calculateHmacSignature(mosparoValidationToken, privateKey); + // 7. Prepare the verification signature + String combinedSignatures = validationSignature + formDataSignature; + String verificationSignature = calculateHmacSignature(combinedSignatures, privateKey); + // 8. Collect the request data + String apiEndpoint = "/api/v1/verification/verify"; + Map requestData = new HashMap(); + requestData.put("submitToken", mosparoSubmitToken); + requestData.put("validationSignature", validationSignature); + requestData.put("formSignature", formDataSignature); + requestData.put("formData", hashedFormData); + // 9. Generate the request signature + String jsonRequestData = JsonSerialization.writeValueAsString(requestData); + String combinedApiEndpointJsonRequestData = apiEndpoint + jsonRequestData; + String requestSignature = calculateHmacSignature(combinedApiEndpointJsonRequestData, privateKey); + // 10. Send the API request - // 11. Check the response + CloseableHttpClient httpClient = context.getSession().getProvider(HttpClientProvider.class).getHttpClient(); + if (captchaConfig.getConfig().get(MOSPARO_VERIFY_SSL) == null) { + try { + httpClient = HttpClients + .custom() + .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .setSSLContext(new SSLContextBuilder().loadTrustMaterial(null, new TrustStrategy() { + public boolean isTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { + return true; + } + }).build()).build(); + } catch (KeyManagementException e) { + logger.error("KeyManagementException for HttpClient without SSL verification"); + } catch (NoSuchAlgorithmException e) { + logger.error("NoSuchAlgorithmException for HttpClient without SSL verification"); + } catch (KeyStoreException e) { + logger.error("KeyStoreException for HttpClient without SSL verification"); + } + } - boolean success = false; - /*CloseableHttpClient httpClient = context.getSession().getProvider(HttpClientProvider.class).getHttpClient(); - HttpPost post = new HttpPost(getMosparoHostname(context.getAuthenticatorConfig()) + "/api/v1/verification/verify"); + HttpPost post = new HttpPost(getMosparoHostname(context.getAuthenticatorConfig()) + apiEndpoint); + + boolean valid = false; + String mosparoVerificationSignature = null; + JsonNode verifiedFields = null; + JsonNode issues = null; - List formparams = new LinkedList<>(); - formparams.add(new BasicNameValuePair("pubkey", pubkey)); - formparams.add(new BasicNameValuePair("response", captcha)); - formparams.add(new BasicNameValuePair("remoteip", context.getConnection().getRemoteAddr())); try { - UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8"); + UrlEncodedFormEntity form = new UrlEncodedFormEntity(convertToNameValuePar(requestData), "UTF-8"); post.setEntity(form); + + String authHeader = publicKey + ":" + requestSignature; + String authHeaderEncoded = Base64.getEncoder().encodeToString(authHeader.getBytes(StandardCharsets.UTF_8)); + post.setHeader("Authorization", "Basic " + authHeaderEncoded); + try (CloseableHttpResponse response = httpClient.execute(post)) { InputStream content = response.getEntity().getContent(); + try { - Map json = JsonSerialization.readValue(content, Map.class); - Object val = json.get("success"); - success = Boolean.TRUE.equals(val); + Map responseData = JsonSerialization.readValue(content, Map.class); + valid = Boolean.TRUE.equals(responseData.get("valid")); + mosparoVerificationSignature = JsonSerialization.mapper.convertValue(responseData.get("verificationSignature"), String.class); + verifiedFields = JsonSerialization.mapper.convertValue(responseData.get("verifiedFields"), JsonNode.class); + issues = JsonSerialization.mapper.convertValue(responseData.get("issues"), JsonNode.class); } finally { EntityUtils.consumeQuietly(response.getEntity()); } } } catch (Exception e) { - ServicesLogger.LOGGER.recaptchaFailed(e); - }*/ - return success; + logger.error(e.getMessage()); + return false; + } + + // 11. Check the response + if (valid && verificationSignature.equals(mosparoVerificationSignature) && verifiedFields != null) { + Set verifiedFieldKeys = new HashSet<>(); + for (Iterator it = verifiedFields.fieldNames(); it.hasNext(); ) { + verifiedFieldKeys.add(it.next()); + } + + Set diffHashedFormData = new HashSet<>(hashedFormData.keySet()); + diffHashedFormData.removeAll(verifiedFieldKeys); + verifiedFieldKeys.removeAll(hashedFormData.keySet()); + + if (!diffHashedFormData.isEmpty() || !verifiedFieldKeys.isEmpty()) { + return false; + } + + return true; + } + + return false; } private static String convertBytesToHex(byte[] hash) { @@ -233,6 +316,35 @@ public class RegistrationMosparo implements FormAction, FormActionFactory { return hexString.toString(); } + private String calculateHmacSignature(String data, String privateKey) throws NoSuchAlgorithmException, InvalidKeyException { + if (mHmacSha256 == null) { + SecretKeySpec key = new SecretKeySpec(privateKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mHmacSha256 = Mac.getInstance("HmacSHA256"); + mHmacSha256.init(key); + } + + return Hex.encodeHexString(mHmacSha256.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } + + private List convertToNameValuePar(Map requestData) { + List data = new LinkedList<>(); + for (String key : requestData.keySet()) { + Object value = requestData.get(key); + + if (Objects.equals(key, "formData") && value instanceof HashMap) { + Map list = (HashMap) value; + for (String subKey : list.keySet()) { + String subValue = list.get(subKey); + data.add(new BasicNameValuePair(key + "[" + subKey + "]", subValue)); + } + } else { + data.add(new BasicNameValuePair(key, (String) value)); + } + } + + return data; + } + @Override public void success(FormContext context) {