From 803f398dba025de20f3810bd557a39da360cab92 Mon Sep 17 00:00:00 2001 From: mposolda Date: Mon, 17 Feb 2020 22:13:39 +0100 Subject: [PATCH] KEYCLOAK-12876 KEYCLOAK-13148 KEYCLOAK-13149 KEYCLOAK-13151 Re-introduce some changes to preserve UserStorage SPI backwards compatibility. Added test for backwards compatibility of user storage --- .../ldap/mappers/LDAPStorageMapperSpi.java | 2 - ...entialModelBackwardsCompatibilityTest.java | 134 +++++++++ .../keycloak/credential/CredentialModel.java | 216 +++++++++++++++ .../credential/hash/PasswordHashProvider.java | 29 ++ .../keycloak/models/UserCredentialModel.java | 100 ++++++- .../PasswordUserCredentialModel.java | 32 +++ .../BackwardsCompatibilityUserStorage.java | 257 ++++++++++++++++++ ...kwardsCompatibilityUserStorageFactory.java | 47 ++++ .../testsuite/federation/UserMapStorage.java | 8 +- ...eycloak.storage.UserStorageProviderFactory | 1 + ...BackwardsCompatibilityUserStorageTest.java | 123 +++++++++ 11 files changed, 940 insertions(+), 9 deletions(-) create mode 100644 server-spi-private/src/test/java/org/keycloak/models/CredentialModelBackwardsCompatibilityTest.java create mode 100644 server-spi/src/main/java/org/keycloak/models/credential/PasswordUserCredentialModel.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java index 54fe747fd5..321e0cb873 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapperSpi.java @@ -17,8 +17,6 @@ package org.keycloak.storage.ldap.mappers; -import org.keycloak.credential.CredentialProvider; -import org.keycloak.credential.CredentialProviderFactory; import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; import org.keycloak.provider.Spi; diff --git a/server-spi-private/src/test/java/org/keycloak/models/CredentialModelBackwardsCompatibilityTest.java b/server-spi-private/src/test/java/org/keycloak/models/CredentialModelBackwardsCompatibilityTest.java new file mode 100644 index 0000000000..ba86b70d30 --- /dev/null +++ b/server-spi-private/src/test/java/org/keycloak/models/CredentialModelBackwardsCompatibilityTest.java @@ -0,0 +1,134 @@ +/* + * Copyright 2019 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; + +import org.bouncycastle.util.Arrays; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.credential.PasswordCredentialModel; + +/** + * @author Marek Posolda + */ +public class CredentialModelBackwardsCompatibilityTest { + + @Test + public void testCredentialModelLegacyGetterSetters() { + CredentialModel credential = new CredentialModel(); + + // Assert null can be read + Assert.assertNull(credential.getValue()); + Assert.assertNull(credential.getDevice()); + Assert.assertNull(credential.getAlgorithm()); + Assert.assertNull(credential.getSalt()); + Assert.assertEquals(0, credential.getCounter()); + Assert.assertEquals(0, credential.getHashIterations()); + Assert.assertEquals(0, credential.getDigits()); + Assert.assertEquals(0, credential.getPeriod()); + + credential.setValue("foo"); + credential.setDevice("foo-device"); + credential.setAlgorithm("foo-algorithm"); + credential.setSalt(new byte[] { 1, 2, 3}); + credential.setCounter(15); + credential.setHashIterations(20); + credential.setDigits(25); + credential.setPeriod(30); + + Assert.assertEquals("foo", credential.getValue()); + Assert.assertEquals("foo-device", credential.getDevice()); + Assert.assertTrue(Arrays.areEqual(new byte[] { 1, 2, 3 }, credential.getSalt())); + Assert.assertEquals(15, credential.getCounter()); + Assert.assertEquals(20, credential.getHashIterations()); + Assert.assertEquals(25, credential.getDigits()); + Assert.assertEquals(30, credential.getPeriod()); + + // Set null to some values + credential.setValue(null); + credential.setSalt(null); + credential.setAlgorithm(null); + + Assert.assertNull(credential.getValue()); + Assert.assertNull(credential.getAlgorithm()); + Assert.assertNull(credential.getSalt()); + Assert.assertEquals("foo-device", credential.getDevice()); + } + + @Test + public void testCredentialModelConfigMap() { + MultivaluedHashMap map = new MultivaluedHashMap<>(); + map.add("key1", "val11"); + map.add("key1", "val12"); + map.add("key2", "val21"); + + CredentialModel credential = new CredentialModel(); + Assert.assertNull(credential.getConfig()); + credential.setConfig(map); + + MultivaluedHashMap loadedMap = credential.getConfig(); + Assert.assertEquals(map, loadedMap); + } + + @Test + public void testCredentialModelOTP() { + CredentialModel otp = OTPCredentialModel.createTOTP("456123", 6, 30, "someAlg"); + + Assert.assertEquals("456123", otp.getValue()); + Assert.assertEquals(6, otp.getDigits()); + Assert.assertEquals(30, otp.getPeriod()); + Assert.assertEquals("someAlg", otp.getAlgorithm()); + + // Change something and assert it is changed + otp.setValue("789789"); + Assert.assertEquals("789789", otp.getValue()); + + // Test clone + OTPCredentialModel cloned = OTPCredentialModel.createFromCredentialModel(otp); + Assert.assertEquals("789789", cloned.getOTPSecretData().getValue()); + Assert.assertEquals(6, cloned.getOTPCredentialData().getDigits()); + Assert.assertEquals("someAlg", cloned.getOTPCredentialData().getAlgorithm()); + } + + + @Test + public void testCredentialModelPassword() { + byte[] salt = { 1, 2, 3 }; + CredentialModel password = PasswordCredentialModel.createFromValues("foo", salt, 1000, "pass"); + + Assert.assertEquals("pass", password.getValue()); + Assert.assertTrue(Arrays.areEqual(salt, password.getSalt())); + Assert.assertEquals(1000, password.getHashIterations()); + Assert.assertEquals("foo", password.getAlgorithm()); + + // Change something and assert it is changed + password.setValue("789789"); + Assert.assertEquals("789789", password.getValue()); + + // Test clone + PasswordCredentialModel cloned = PasswordCredentialModel.createFromCredentialModel(password); + Assert.assertEquals("789789", cloned.getPasswordSecretData().getValue()); + Assert.assertEquals(1000, cloned.getPasswordCredentialData().getHashIterations()); + Assert.assertEquals(1000, cloned.getPasswordCredentialData().getHashIterations()); + Assert.assertEquals("foo", cloned.getPasswordCredentialData().getAlgorithm()); + + } +} diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialModel.java b/server-spi/src/main/java/org/keycloak/credential/CredentialModel.java index b62125f303..16b0481bd6 100755 --- a/server-spi/src/main/java/org/keycloak/credential/CredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialModel.java @@ -17,8 +17,16 @@ package org.keycloak.credential; +import java.io.IOException; import java.io.Serializable; import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; + +import org.keycloak.common.util.Base64; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.util.JsonSerialization; /** * Used just in cases when we want to "directly" update or retrieve the hash or salt of user credential (For example during export/import) @@ -33,6 +41,9 @@ public class CredentialModel implements Serializable { @Deprecated /** Use PasswordCredentialModel.PASSWORD_HISTORY instead **/ public static final String PASSWORD_HISTORY = "password-history"; + @Deprecated /** Legacy stuff. Not used in Keycloak anymore **/ + public static final String PASSWORD_TOKEN = "password-token"; + @Deprecated /** Use OTPCredentialModel.TYPE instead **/ public static final String OTP = "otp"; @@ -116,4 +127,209 @@ public class CredentialModel implements Serializable { return (-o1Date.compareTo(o2Date)); }; } + + // DEPRECATED - the methods below exists for the backwards compatibility + + /** + * @deprecated Recommended to use PasswordCredentialModel.getSecretData().getValue() or OTPCredentialModel.getSecretData().getValue() + */ + @Deprecated + public String getValue() { + return readString("value", true); + } + + /** + * @deprecated See {@link #getValue()} + */ + @Deprecated + public void setValue(String value) { + writeProperty("value", value, true); + } + + /** + * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDevice() + */ + @Deprecated + public String getDevice() { + return readString("device", false); + } + + /** + * @deprecated See {@link #getDevice()} + */ + @Deprecated + public void setDevice(String device) { + writeProperty("device", device, false); + } + + /** + * @deprecated Recommended to use PasswordCredentialModel.getSecretData().getSalt() + */ + @Deprecated + public byte[] getSalt() { + try { + String saltStr = readString("salt", true); + return saltStr == null ? null : Base64.decode(saltStr); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + + /** + * @deprecated See {@link #getSalt()} + */ + @Deprecated + public void setSalt(byte[] salt) { + String saltStr = salt == null ? null : Base64.encodeBytes(salt); + writeProperty("salt", saltStr, true); + } + + /** + * @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getHashIterations() + */ + @Deprecated + public int getHashIterations() { + return readInt("hashIterations", false); + } + + /** + * @deprecated See {@link #getHashIterations()} + */ + @Deprecated + public void setHashIterations(int iterations) { + writeProperty("hashIterations", iterations, false); + } + + /** + * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getCounter() + */ + @Deprecated + public int getCounter() { + return readInt("counter", false); + } + + /** + * @deprecated See {@link #getCounter()} + */ + @Deprecated + public void setCounter(int counter) { + writeProperty("counter", counter, false); + } + + /** + * @deprecated Recommended to use PasswordCredentialModel.getCredentialData().getAlgorithm() or OTPCredentialModel.getCredentialData().getAlgorithm() + */ + @Deprecated + public String getAlgorithm() { + return readString("algorithm", false); + } + + /** + * @deprecated See {@link #getAlgorithm()} + */ + @Deprecated + public void setAlgorithm(String algorithm) { + writeProperty("algorithm", algorithm, false); + } + + /** + * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getDigits() + */ + @Deprecated + public int getDigits() { + return readInt("digits", false); + } + + /** + * @deprecated See {@link #setDigits(int)} + */ + @Deprecated + public void setDigits(int digits) { + writeProperty("digits", digits, false); + } + + /** + * @deprecated Recommended to use OTPCredentialModel.getCredentialData().getPeriod() + */ + @Deprecated + public int getPeriod() { + return readInt("period", false); + } + + /** + * @deprecated See {@link #setPeriod(int)} + */ + @Deprecated + public void setPeriod(int period) { + writeProperty("period", period, false); + } + + /** + * @deprecated Recommended to use {@link #getCredentialData()} instead and use the subtype of CredentialData specific to your credential + */ + @Deprecated + public MultivaluedHashMap getConfig() { + Map credentialData = readMapFromJson(false); + if (credentialData == null) { + return null; + } + + Object obj = credentialData.get("config"); + return obj == null ? null : new MultivaluedHashMap<>((Map)obj); + } + + /** + * @deprecated Recommended to use {@link #setCredentialData(String)} instead and use the subtype of CredentialData specific to your credential + */ + @Deprecated + public void setConfig(MultivaluedHashMap config) { + writeProperty("config", config, false); + } + + private Map readMapFromJson(boolean secret) { + String jsonStr = secret ? secretData : credentialData; + if (jsonStr == null) { + return new HashMap<>(); + } + + try { + return JsonSerialization.readValue(jsonStr, Map.class); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + + private void writeMapAsJson(Map map, boolean secret) { + try { + String jsonStr = JsonSerialization.writeValueAsString(map); + if (secret) { + this.secretData = jsonStr; + } else { + this.credentialData = jsonStr; + } + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + + private String readString(String key, boolean secret) { + Map credentialDataMap = readMapFromJson(secret); + return (String) credentialDataMap.get(key); + } + + private int readInt(String key, boolean secret) { + Map credentialDataMap = readMapFromJson(secret); + Object obj = credentialDataMap.get(key); + return obj == null ? 0 : (Integer) obj; + } + + private void writeProperty(String key, Object value, boolean secret) { + Map credentialDataMap = readMapFromJson(secret); + if (value == null) { + credentialDataMap.remove(key); + } else { + credentialDataMap.put(key, value); + } + writeMapAsJson(credentialDataMap, secret); + } } diff --git a/server-spi/src/main/java/org/keycloak/credential/hash/PasswordHashProvider.java b/server-spi/src/main/java/org/keycloak/credential/hash/PasswordHashProvider.java index 69db2c63eb..2e44777ce4 100644 --- a/server-spi/src/main/java/org/keycloak/credential/hash/PasswordHashProvider.java +++ b/server-spi/src/main/java/org/keycloak/credential/hash/PasswordHashProvider.java @@ -17,6 +17,7 @@ package org.keycloak.credential.hash; +import org.keycloak.credential.CredentialModel; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.provider.Provider; @@ -35,4 +36,32 @@ public interface PasswordHashProvider extends Provider { } boolean verify(String rawPassword, PasswordCredentialModel credential); + + /** + * @deprecated Exists due the backwards compatibility. It is recommended to use {@link #policyCheck(PasswordPolicy, PasswordCredentialModel)} + */ + @Deprecated + default boolean policyCheck(PasswordPolicy policy, CredentialModel credential) { + return policyCheck(policy, PasswordCredentialModel.createFromCredentialModel(credential)); + } + + /** + * @deprecated Exists due the backwards compatibility. It is recommended to use {@link #encodedCredential(String, int)}} + */ + @Deprecated + default void encode(String rawPassword, int iterations, CredentialModel credential) { + PasswordCredentialModel passwordCred = encodedCredential(rawPassword, iterations); + + credential.setCredentialData(passwordCred.getCredentialData()); + credential.setSecretData(passwordCred.getSecretData()); + } + + /** + * @deprecated Exists due the backwards compatibility. It is recommended to use {@link #verify(String, PasswordCredentialModel)} + */ + @Deprecated + default boolean verify(String rawPassword, CredentialModel credential) { + PasswordCredentialModel password = PasswordCredentialModel.createFromCredentialModel(credential); + return verify(rawPassword, password); + } } diff --git a/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java index 917af7c2a7..4a0346688d 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java @@ -21,7 +21,10 @@ import org.keycloak.credential.CredentialInput; import org.keycloak.credential.CredentialModel; import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.credential.PasswordUserCredentialModel; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; /** @@ -42,15 +45,23 @@ public class UserCredentialModel implements CredentialInput { @Deprecated /** Use OTPCredentialModel.TOTP instead **/ public static final String HOTP = OTPCredentialModel.HOTP; + @Deprecated /** Legacy stuff. Not used in Keycloak anymore **/ + public static final String PASSWORD_TOKEN = CredentialModel.PASSWORD_TOKEN; + public static final String SECRET = CredentialModel.SECRET; public static final String KERBEROS = CredentialModel.KERBEROS; public static final String CLIENT_CERT = CredentialModel.CLIENT_CERT; private final String credentialId; - private final String type; - private final String challengeResponse; + private String type; + private String challengeResponse; + private String device; + private String algorithm; private final boolean adminRequest; + // Additional context informations + protected Map notes = new HashMap<>(); + public UserCredentialModel(String credentialId, String type, String challengeResponse) { this.credentialId = credentialId; this.type = type; @@ -65,12 +76,39 @@ public class UserCredentialModel implements CredentialInput { this.adminRequest = adminRequest; } - public static UserCredentialModel password(String password) { + public static PasswordUserCredentialModel password(String password) { return password(password, false); } - public static UserCredentialModel password(String password, boolean adminRequest) { - return new UserCredentialModel("", PasswordCredentialModel.TYPE, password, adminRequest); + public static PasswordUserCredentialModel password(String password, boolean adminRequest) { + // It uses PasswordUserCredentialModel for backwards compatibility. Some UserStorage providers can check for that type + return new PasswordUserCredentialModel("", PasswordCredentialModel.TYPE, password, adminRequest); + } + + @Deprecated /** passwordToken is legacy stuff. Not used in Keycloak anymore **/ + public static UserCredentialModel passwordToken(String passwordToken) { + return new UserCredentialModel("", PASSWORD_TOKEN, passwordToken); + } + + /** + * @param type must be "totp" or "hotp" + * @param key + * @return + */ + public static UserCredentialModel otp(String type, String key) { + if (type.equals(HOTP)) return hotp(key); + if (type.equals(TOTP)) return totp(key); + throw new RuntimeException("Unknown OTP type"); + } + + + public static UserCredentialModel totp(String key) { + return new UserCredentialModel("", TOTP, key); + } + + + public static UserCredentialModel hotp(String key) { + return new UserCredentialModel("", HOTP, key); } public static UserCredentialModel secret(String password) { @@ -95,6 +133,10 @@ public class UserCredentialModel implements CredentialInput { return type; } + public void setType(String type) { + this.type = type; + } + @Override public String getChallengeResponse() { return challengeResponse; @@ -103,6 +145,54 @@ public class UserCredentialModel implements CredentialInput { public boolean isAdminRequest() { return adminRequest; } + + /** + * This method exists only because of the backwards compatibility + */ + @Deprecated + public static boolean isOtp(String type) { + return TOTP.equals(type) || HOTP.equals(type); + } + + /** + * This method exists only because of the backwards compatibility. It is recommended to use {@link #getChallengeResponse()} instead + */ + public String getValue() { + return getChallengeResponse(); + } + + public void setValue(String value) { + this.challengeResponse = value; + } + + public String getDevice() { + return device; + } + + public void setDevice(String device) { + this.device = device; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public void setNote(String key, String value) { + this.notes.put(key, value); + } + + public void removeNote(String key) { + this.notes.remove(key); + } + + public Object getNote(String key) { + return this.notes.get(key); + } + } diff --git a/server-spi/src/main/java/org/keycloak/models/credential/PasswordUserCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/PasswordUserCredentialModel.java new file mode 100644 index 0000000000..8f03deeb44 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/PasswordUserCredentialModel.java @@ -0,0 +1,32 @@ +/* + * 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. + */ + +package org.keycloak.models.credential; + +import org.keycloak.models.UserCredentialModel; + +/** + * @author Marek Posolda + * + * @deprecated Recommended to use {@link UserCredentialModel} as it contains all the functionality required by this class + */ +public class PasswordUserCredentialModel extends UserCredentialModel { + + public PasswordUserCredentialModel(String credentialId, String type, String challengeResponse, boolean adminRequest) { + super(credentialId, type, challengeResponse, adminRequest); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java new file mode 100644 index 0000000000..647f62b529 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java @@ -0,0 +1,257 @@ +/* + * Copyright 2019 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.testsuite.federation; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialInput; +import org.keycloak.credential.CredentialInputUpdater; +import org.keycloak.credential.CredentialInputValidator; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.cache.UserCache; +import org.keycloak.models.credential.PasswordUserCredentialModel; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage; +import org.keycloak.storage.user.UserLookupProvider; +import org.keycloak.storage.user.UserRegistrationProvider; + +/** + * UserStorage implementation created in Keycloak 4.8.3. It is used for backwards compatibility testing. Future Keycloak versions + * should work fine without a need to change the code of this provider. + * + * TODO: Have some good mechanims to make sure that source code of this provider is really compatible with Keycloak 4.8.3 + * + * @author Marek Posolda + */ +public class BackwardsCompatibilityUserStorage implements UserLookupProvider, UserStorageProvider, UserRegistrationProvider, CredentialInputUpdater, CredentialInputValidator { + + private static final Logger log = Logger.getLogger(BackwardsCompatibilityUserStorage.class); + + protected final Map users; + protected final ComponentModel model; + protected final KeycloakSession session; + + public BackwardsCompatibilityUserStorage(KeycloakSession session, ComponentModel model, Map users) { + this.session = session; + this.model = model; + this.users = users; + } + + + @Override + public UserModel getUserById(String id, RealmModel realm) { + StorageId storageId = new StorageId(id); + final String username = storageId.getExternalId(); + if (!users.containsKey(username)) return null; + + return createUser(realm, username); + } + + private UserModel createUser(RealmModel realm, String username) { + return new AbstractUserAdapterFederatedStorage(session, realm, model) { + @Override + public String getUsername() { + return username; + } + + @Override + public void setUsername(String username1) { + if (!username1.equals(username)) { + throw new RuntimeException("Unsupported to change username"); + } + } + + }; + } + + @Override + public boolean supportsCredentialType(String credentialType) { + return CredentialModel.PASSWORD.equals(credentialType); + } + + @Override + public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) { + if (!(input instanceof UserCredentialModel)) return false; + + if (input.getType().equals(UserCredentialModel.PASSWORD)) { + + // Compatibility with 4.8.3 - Using "legacy" type PasswordUserCredentialModel + if (!(input instanceof PasswordUserCredentialModel)) { + log.warn("Input is not PasswordUserCredentialModel"); + return false; + } + + PasswordUserCredentialModel userCredentialModel = (PasswordUserCredentialModel) input; + + // Those are not supposed to be set when calling this method in Keycloak 4.8.3 for password credential + assertNull(userCredentialModel.getDevice()); + assertNull(userCredentialModel.getAlgorithm()); + + PasswordPolicy policy = session.getContext().getRealm().getPasswordPolicy(); + PasswordHashProvider hashProvider = getHashProvider(policy); + + CredentialModel newPassword = new CredentialModel(); + newPassword.setType(CredentialModel.PASSWORD); + long createdDate = Time.currentTimeMillis(); + newPassword.setCreatedDate(createdDate); + + // Compatibility with 4.8.3 - Using "legacy" signature of the method on hashProvider + hashProvider.encode(userCredentialModel.getValue(), policy.getHashIterations(), newPassword); + + // Test expected values of credentialModel + assertEquals(newPassword.getAlgorithm(), policy.getHashAlgorithm()); + assertNotNull(newPassword.getValue()); + assertNotNull(newPassword.getSalt()); + + users.get(user.getUsername()).hashedPassword = newPassword; + + UserCache userCache = session.userCache(); + if (userCache != null) { + userCache.evict(realm, user); + } + return true; + } else { + return false; + } + } + + protected PasswordHashProvider getHashProvider(PasswordPolicy policy) { + PasswordHashProvider hash = session.getProvider(PasswordHashProvider.class, policy.getHashAlgorithm()); + if (hash == null) { + log.warnv("Realm PasswordPolicy PasswordHashProvider {0} not found", policy.getHashAlgorithm()); + return session.getProvider(PasswordHashProvider.class, PasswordPolicy.HASH_ALGORITHM_DEFAULT); + } + return hash; + } + + @Override + public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) { + + } + + @Override + public Set getDisableableCredentialTypes(RealmModel realm, UserModel user) { + return Collections.EMPTY_SET; + } + + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + return CredentialModel.PASSWORD.equals(credentialType); + } + + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { + if (!(input instanceof PasswordUserCredentialModel)) return false; + if (input.getType().equals(UserCredentialModel.PASSWORD)) { + CredentialModel hashedPassword = users.get(user.getUsername()).hashedPassword; + if (hashedPassword == null) { + log.warnf("Password not set for user %s", user.getUsername()); + return false; + } + + PasswordUserCredentialModel userCredentialModel = (PasswordUserCredentialModel) input; + + // Those are not supposed to be set when calling this method in Keycloak 4.8.3 for password credential + assertNull(userCredentialModel.getDevice()); + assertNull(userCredentialModel.getAlgorithm()); + + PasswordPolicy policy = session.getContext().getRealm().getPasswordPolicy(); + PasswordHashProvider hashProvider = getHashProvider(policy); + + String rawPassword = userCredentialModel.getValue(); + + // Compatibility with 4.8.3 - using "legacy" signature of this method + return hashProvider.verify(rawPassword, hashedPassword); + } else { + return false; + } + } + + @Override + public UserModel getUserByUsername(String username, RealmModel realm) { + if (!users.containsKey(username)) return null; + + return createUser(realm, username); + } + + @Override + public UserModel getUserByEmail(String email, RealmModel realm) { + return null; + } + + @Override + public UserModel addUser(RealmModel realm, String username) { + users.put(username, new MyUser(username)); + return createUser(realm, username); + } + + @Override + public boolean removeUser(RealmModel realm, UserModel user) { + return users.remove(user.getUsername()) != null; + } + + @Override + public void close() { + } + + + class MyUser { + + private String username; + private CredentialModel hashedPassword; + + private MyUser(String username) { + this.username = username; + } + + } + + + private void assertNull(Object obj) { + if (obj != null) { + throw new AssertionError("Object wasn't null"); + } + } + + private void assertNotNull(Object obj) { + if (obj == null) { + throw new AssertionError("Object was null"); + } + } + + private void assertEquals(Object obj1, Object obj2) { + if (!(obj1.equals(obj2))) { + throw new AssertionError("Objects not equals"); + } + } + +} + diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java new file mode 100644 index 0000000000..84095067f2 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright 2019 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.testsuite.federation; + +import java.util.Hashtable; +import java.util.Map; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.storage.UserStorageProviderFactory; + +/** + * @author Marek Posolda + */ +public class BackwardsCompatibilityUserStorageFactory implements UserStorageProviderFactory { + + public static final String PROVIDER_ID = "backwards-compatibility-storage"; + + protected Map userPasswords = new Hashtable<>(); + + @Override + public BackwardsCompatibilityUserStorage create(KeycloakSession session, ComponentModel model) { + return new BackwardsCompatibilityUserStorage(session, model, userPasswords); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java index 52a98c6cf7..f21fe28a7e 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java @@ -27,6 +27,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; +import org.keycloak.models.credential.PasswordUserCredentialModel; import org.keycloak.storage.ReadOnlyException; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.storage.StorageId; @@ -186,12 +187,15 @@ public class UserMapStorage implements UserLookupProvider, UserStorageProvider, @Override public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { - if (!(input instanceof UserCredentialModel)) { + // Test "instanceof PasswordUserCredentialModel" on purpose. We want to test that the backwards compatibility + if (!(input instanceof PasswordUserCredentialModel)) { return false; } if (input.getType().equals(PasswordCredentialModel.TYPE)) { String pw = userPasswords.get(user.getUsername()); - return pw != null && pw.equals(input.getChallengeResponse()); + + // Using "getValue" on purpose here, to test that backwards compatibility works as expected + return pw != null && pw.equals(((UserCredentialModel) input).getValue()); } else { return false; } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory index 9d5abba8bc..fd6a4d0f6b 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -1,3 +1,4 @@ +org.keycloak.testsuite.federation.BackwardsCompatibilityUserStorageFactory org.keycloak.testsuite.federation.DummyUserFederationProviderFactory org.keycloak.testsuite.federation.FailableHardcodedStorageProviderFactory org.keycloak.testsuite.federation.UserMapStorageFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java new file mode 100644 index 0000000000..3f218bf532 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2019 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.testsuite.federation.storage; + +import java.io.IOException; +import java.net.URISyntaxException; + +import javax.ws.rs.core.Response; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.credential.CredentialModel; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.testsuite.AbstractAuthTest; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.federation.BackwardsCompatibilityUserStorageFactory; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.pages.LoginPage; + +import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith; +import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith; + +/** + * Test that userStorage implementation created in previous version is still compatible with latest Keycloak version + * + * @author Marek Posolda + */ +public class BackwardsCompatibilityUserStorageTest extends AbstractAuthTest { + + private String backwardsCompProviderId; + + @Before + public void addProvidersBeforeTest() throws URISyntaxException, IOException { + ComponentRepresentation memProvider = new ComponentRepresentation(); + memProvider.setName("backwards-compatibility"); + memProvider.setProviderId(BackwardsCompatibilityUserStorageFactory.PROVIDER_ID); + memProvider.setProviderType(UserStorageProvider.class.getName()); + memProvider.setConfig(new MultivaluedHashMap<>()); + memProvider.getConfig().putSingle("priority", Integer.toString(0)); + + backwardsCompProviderId = addComponent(memProvider); + + } + + protected String addComponent(ComponentRepresentation component) { + Response resp = testRealmResource().components().add(component); + String id = ApiUtil.getCreatedId(resp); + getCleanup().addComponentId(id); + return id; + } + + + @Page + protected AppPage appPage; + + @Page + protected LoginPage loginPage; + + private void loginSuccessAndLogout(String username, String password) { + testRealmAccountPage.navigateTo(); + loginPage.login(username, password); + assertCurrentUrlStartsWith(testRealmAccountPage); + testRealmAccountPage.logOut(); + } + + public void loginBadPassword(String username) { + testRealmAccountPage.navigateTo(); + testRealmLoginPage.form().login(username, "badpassword"); + assertCurrentUrlDoesntStartWith(testRealmAccountPage); + } + + @Test + public void testLoginSuccess() { + addUserAndResetPassword("tbrady", "goat"); + addUserAndResetPassword("tbrady2", "goat2"); + + loginSuccessAndLogout("tbrady", "goat"); + loginSuccessAndLogout("tbrady2", "goat2"); + loginBadPassword("tbrady"); + } + + private void addUserAndResetPassword(String username, String password) { + // Save user and assert he is saved in the new storage + UserRepresentation user = new UserRepresentation(); + user.setEnabled(true); + user.setUsername(username); + Response response = testRealmResource().users().create(user); + String userId = ApiUtil.getCreatedId(response); + + Assert.assertEquals(backwardsCompProviderId, new StorageId(userId).getProviderId()); + + // Update his password + CredentialRepresentation passwordRep = new CredentialRepresentation(); + passwordRep.setType(CredentialModel.PASSWORD); + passwordRep.setValue(password); + passwordRep.setTemporary(false); + + testRealmResource().users().get(userId).resetPassword(passwordRep); + } +} \ No newline at end of file