Polishing recovery codes

closes #39213

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-04-25 16:01:10 +02:00 committed by GitHub
parent f05f602912
commit 237d0553ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 86 additions and 8 deletions

View File

@ -4,6 +4,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.keycloak.common.util.Base64;
import org.keycloak.credential.CredentialMetadata;
import org.keycloak.credential.CredentialModel;
import org.keycloak.models.PasswordPolicy;
@ -64,7 +65,7 @@ public class RecoveryAuthnCodesCredentialModel extends CredentialModel {
try {
List<RecoveryAuthnCodeRepresentation> recoveryCodes = IntStream.range(0, originalGeneratedCodes.size())
.mapToObj(i -> new RecoveryAuthnCodeRepresentation(i + 1,
RecoveryAuthnCodesUtils.hashRawCode(originalGeneratedCodes.get(i))))
Base64.encodeBytes(RecoveryAuthnCodesUtils.hashRawCode(originalGeneratedCodes.get(i)))))
.collect(Collectors.toList());
secretData = new RecoveryAuthnCodesSecretData(recoveryCodes);
credentialData = new RecoveryAuthnCodesCredentialData(RecoveryAuthnCodesUtils.NUM_HASH_ITERATIONS,

View File

@ -1,7 +1,11 @@
package org.keycloak.models.utils;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.Optional;
import java.util.function.Supplier;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.credential.CredentialModel;
@ -19,6 +23,8 @@ import java.util.stream.Stream;
public class RecoveryAuthnCodesUtils {
private static final Logger logger = Logger.getLogger(RecoveryAuthnCodesUtils.class);
private static final int QUANTITY_OF_CODES_TO_GENERATE = 12;
private static final int CODE_LENGTH = 12;
public static final char[] UPPERNUM = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789".toCharArray();
@ -28,18 +34,22 @@ public class RecoveryAuthnCodesUtils {
public static final String RECOVERY_AUTHN_CODES_INPUT_DEFAULT_ERROR_MESSAGE = "recovery-codes-error-invalid";
public static final String FIELD_RECOVERY_CODE_IN_BROWSER_FLOW = "recoveryCodeInput";
public static String hashRawCode(String rawGeneratedCode) {
public static byte[] hashRawCode(String rawGeneratedCode) {
Objects.requireNonNull(rawGeneratedCode, "rawGeneratedCode cannot be null");
byte[] rawCodeHashedAsBytes = HashUtils.hash(JavaAlgorithm.getJavaAlgorithmForHash(NOM_ALGORITHM_TO_HASH),
return HashUtils.hash(JavaAlgorithm.getJavaAlgorithmForHash(NOM_ALGORITHM_TO_HASH),
rawGeneratedCode.getBytes(StandardCharsets.UTF_8));
return Base64.encodeBytes(rawCodeHashedAsBytes);
}
public static boolean verifyRecoveryCodeInput(String rawInputRecoveryCode, String hashedSavedRecoveryCode) {
String hashedInputBackupCode = hashRawCode(rawInputRecoveryCode);
return (hashedInputBackupCode.equals(hashedSavedRecoveryCode));
byte[] hashedInputBackupCode = hashRawCode(rawInputRecoveryCode);
try {
byte[] savedCode = Base64.decode(hashedSavedRecoveryCode);
return MessageDigest.isEqual(hashedInputBackupCode, savedCode);
} catch (IOException ioe) {
logger.warnf("Error when decoding saved recovery code", ioe);
return false;
}
}
public static List<String> generateRawCodes() {

View File

@ -0,0 +1,64 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.models.credential;
import org.jboss.logging.Logger;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
import org.keycloak.common.util.Time;
import org.keycloak.models.utils.RecoveryAuthnCodesUtils;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class RecoveryCodesUnitTest {
private static final Logger logger = Logger.getLogger(RecoveryCodesUnitTest.class);
@Test
public void testBasicVerification() {
Assert.assertTrue(RecoveryAuthnCodesUtils.verifyRecoveryCodeInput("L9RRAUWYKARB", "OnOEi8vsNqnI2s6t2IxU2+A+KrzWAVpR9AHExeQgDzTuoiU1qPzFsOpFIy8wb6EtPEGHKj0ehgHyTbuBTyChhg=="));
Assert.assertFalse(RecoveryAuthnCodesUtils.verifyRecoveryCodeInput("L9RRAUWYKARC", "OnOEi8vsNqnI2s6t2IxU2+A+KrzWAVpR9AHExeQgDzTuoiU1qPzFsOpFIy8wb6EtPEGHKj0ehgHyTbuBTyChhg=="));
}
@Ignore
@Test
public void testPerf() {
testPerf("Successful code verifications", () ->
Assert.assertTrue(RecoveryAuthnCodesUtils.verifyRecoveryCodeInput("L9RRAUWYKARB", "OnOEi8vsNqnI2s6t2IxU2+A+KrzWAVpR9AHExeQgDzTuoiU1qPzFsOpFIy8wb6EtPEGHKj0ehgHyTbuBTyChhg=="))
);
testPerf("Failed code verifications 1", () ->
Assert.assertFalse(RecoveryAuthnCodesUtils.verifyRecoveryCodeInput("L9RRAUWYKARC", "OnOEi8vsNqnI2s6t2IxU2+A+KrzWAVpR9AHExeQgDzTuoiU1qPzFsOpFIy8wb6EtPEGHKj0ehgHyTbuBTyChhg=="))
);
testPerf("Failed code verifications 2", () ->
Assert.assertFalse(RecoveryAuthnCodesUtils.verifyRecoveryCodeInput("A8CWGYUIUILP", "OnOEi8vsNqnI2s6t2IxU2+A+KrzWAVpR9AHExeQgDzTuoiU1qPzFsOpFIy8wb6EtPEGHKj0ehgHyTbuBTyChhg=="))
);
}
private void testPerf(String prefix, Runnable task) {
long start = Time.currentTimeMillis();
int count = 10000000;
for (int i = 0 ; i < count ; i++) {
task.run();
}
logger.infof("Task '%s' took %d ms", prefix, Time.currentTimeMillis() - start);
}
}

View File

@ -4,6 +4,7 @@ import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.credential.CredentialModel;
import org.keycloak.events.Details;
@ -152,7 +153,9 @@ public class RecoveryAuthnCodesFormAuthenticator implements Authenticator {
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();
authenticationSession.addRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name());
if (!authenticationSession.getRequiredActions().contains(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name())) {
authenticationSession.addRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name());
}
}
@Override