diff --git a/README.md b/README.md index c3fc2a0b..98b6d03e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ The GET request will have to return user data as a JSON response in the form: ], "requiredActions": [ "requiredActions" + ], + "totps": [ + { + "name": "string", + "secret": "string", + "digits": "int", + "period": "int", + "algorithm": "string", + "encoding": "string" + } ] } ``` @@ -143,6 +153,16 @@ response might look like this: "UPDATE_PASSWORD", "UPDATE_PROFILE", "update_user_locale" + ], + "totps": [ + { + "name": "Totp Device 1", + "secret": "secret", + "digits": 6, + "period": 30, + "algorithm": "HmacSHA1", + "encoding": "BASE32" + } ] } ``` @@ -296,3 +316,19 @@ automatically map legacy groups to Keycloak groups, by specifying the mapping in This switch can be toggled to decide whether groups which are not defined in the legacy group conversion map should be migrated anyway or simply ignored. + +## Totp +This module supports the migration of totp devices. The totp configuration block could look like this: +```json +{ + "name": "Totp Device 1", + "secret": "secret", + "digits": 6, + "period": 30, + "algorithm": "HmacSHA1", + "encoding": "BASE32" +} +``` +`name` should be the name of the totp device, while `secret` is the secret, that could be encoded in "BASE32" or as UTF-8 plaintext. +For the utf8 bytes just set the `encoding` attribute to null. +Possible `algorithm`s are: HmacSHA1, HmacSHA256, HmacSHA512 \ No newline at end of file diff --git a/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotp.java b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotp.java new file mode 100644 index 00000000..9fbadc32 --- /dev/null +++ b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotp.java @@ -0,0 +1,84 @@ +package com.danielfrak.code.keycloak.providers.rest.remote; + +import java.util.Objects; + +public class LegacyTotp { + + private String secret; + private String name; + private int digits; + private int period; + private String algorithm; + private String encoding; + + public String getSecret() { + return this.secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getDigits() { + return this.digits; + } + + public void setDigits(int digits) { + this.digits = digits; + } + + public int getPeriod() { + return this.period; + } + + public void setPeriod(int period) { + this.period = period; + } + + public String getAlgorithm() { + return this.algorithm; + } + + public void setAlgorithm(String algorith) { + this.algorithm = algorith; + } + + public String getEncoding() { + return this.encoding; + } + + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LegacyTotp legacyTotp = (LegacyTotp) o; + + return Objects.equals(secret, legacyTotp.secret) && + Objects.equals(name, legacyTotp.name) && + Objects.equals(algorithm, legacyTotp.algorithm) && + Objects.equals(encoding, legacyTotp.encoding) && + digits == legacyTotp.digits && + period == legacyTotp.period; + } + + @Override + public int hashCode() { + return Objects.hash(name, secret, digits, period, algorithm, encoding); + } +} diff --git a/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUser.java b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUser.java index 3d4f9f8a..9c893a8d 100644 --- a/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUser.java +++ b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUser.java @@ -20,6 +20,7 @@ public class LegacyUser { private List roles; private List groups; private List requiredActions; + private List totps; public String getId() { return id; @@ -109,6 +110,14 @@ public void setRequiredActions(List requiredActions) { this.requiredActions = requiredActions; } + public List getTotps() { + return this.totps; + } + + public void setTotps(List totps) { + this.totps = totps; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -129,12 +138,13 @@ public boolean equals(Object o) { Objects.equals(attributes, legacyUser.attributes) && Objects.equals(roles, legacyUser.roles) && Objects.equals(groups, legacyUser.groups) && - Objects.equals(requiredActions, legacyUser.requiredActions); + Objects.equals(requiredActions, legacyUser.requiredActions) && + Objects.equals(totps, legacyUser.totps); } @Override public int hashCode() { return Objects.hash(id, username, email, firstName, lastName, isEnabled, isEmailVerified, attributes, - roles, groups, requiredActions); + roles, groups, requiredActions, totps); } } diff --git a/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactory.java b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactory.java index 14f61475..62e4a5a8 100644 --- a/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactory.java +++ b/src/main/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactory.java @@ -4,6 +4,7 @@ import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.*; +import org.keycloak.models.credential.OTPCredentialModel; import java.util.HashMap; import java.util.List; @@ -93,6 +94,19 @@ public UserModel create(LegacyUser legacyUser, RealmModel realm) { .forEach(userModel::addRequiredAction); } + if (legacyUser.getTotps() != null) { + legacyUser.getTotps().forEach(totp -> { + var otpModel = OTPCredentialModel.createTOTP( + totp.getSecret(), + totp.getDigits(), + totp.getPeriod(), + totp.getAlgorithm(), + totp.getEncoding()); + otpModel.setUserLabel(totp.getName()); + userModel.credentialManager().createStoredCredential(otpModel); + }); + } + return userModel; } diff --git a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotpTest.java b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotpTest.java new file mode 100644 index 00000000..a7bf2ec0 --- /dev/null +++ b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyTotpTest.java @@ -0,0 +1,63 @@ +package com.danielfrak.code.keycloak.providers.rest.remote; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LegacyTotpTest { + + @Test + void shouldGetAndSetName() { + var totp = new LegacyTotp(); + var expectedValue = "value1"; + totp.setName(expectedValue); + assertEquals(expectedValue, totp.getName()); + } + + @Test + void shouldGetAndSetSecret() { + var totp = new LegacyTotp(); + var expectedValue = "value1"; + totp.setSecret(expectedValue); + assertEquals(expectedValue, totp.getSecret()); + } + + @Test + void shouldGetAndSetDigits() { + var totp = new LegacyTotp(); + var expectedValue = 6; + totp.setDigits(expectedValue); + assertEquals(expectedValue, totp.getDigits()); + } + + @Test + void shouldGetAndSetPeriod() { + var totp = new LegacyTotp(); + var expectedValue = 30; + totp.setPeriod(expectedValue); + assertEquals(expectedValue, totp.getPeriod()); + } + + @Test + void shouldGetAndSetAlgorithm() { + var totp = new LegacyTotp(); + var expectedValue = "value1"; + totp.setAlgorithm(expectedValue); + assertEquals(expectedValue, totp.getAlgorithm()); + } + + @Test + void shouldGetAndSetEncpding() { + var totp = new LegacyTotp(); + var expectedValue = "value1"; + totp.setEncoding(expectedValue); + assertEquals(expectedValue, totp.getEncoding()); + } + + @Test + void testEquals() { + EqualsVerifier.simple().forClass(LegacyTotp.class) + .verify(); + } +} \ No newline at end of file diff --git a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUserTest.java b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUserTest.java index 6af86a0d..5f043e80 100644 --- a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUserTest.java +++ b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/LegacyUserTest.java @@ -89,6 +89,17 @@ void shouldGetAndSetRequiredActions() { assertEquals(expectedValue, user.getRequiredActions()); } + @Test + void shouldGetAndSetTotps() { + var user = new LegacyUser(); + var legacyTotp = new LegacyTotp(); + legacyTotp.setName("value1"); + legacyTotp.setSecret("value2"); + var expectedValue = singletonList(legacyTotp); + user.setTotps(expectedValue); + assertEquals(expectedValue, user.getTotps()); + } + @Test void testEquals() { EqualsVerifier.simple().forClass(LegacyUser.class) diff --git a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestCredentialManager.java b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestCredentialManager.java new file mode 100644 index 00000000..11df4b00 --- /dev/null +++ b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestCredentialManager.java @@ -0,0 +1,102 @@ +package com.danielfrak.code.keycloak.providers.rest.remote; + +import org.keycloak.credential.CredentialInput; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.SubjectCredentialManager; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +public class TestCredentialManager implements SubjectCredentialManager { + + private final Set storedCredentials = new HashSet<>(); + + @Override + public boolean isValid(List inputs) { + throw new RuntimeException("Not implemented"); + } + + @Override + public boolean updateCredential(CredentialInput input) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void updateStoredCredential(CredentialModel cred) { + throw new RuntimeException("Not implemented"); + } + + @Override + public CredentialModel createStoredCredential(CredentialModel cred) { + this.storedCredentials.add(cred); + return cred; + } + + @Override + public boolean removeStoredCredentialById(String id) { + throw new RuntimeException("Not implemented"); + } + + @Override + public CredentialModel getStoredCredentialById(String id) { + throw new RuntimeException("Not implemented"); + } + + @Override + public Stream getStoredCredentialsStream() { + throw new RuntimeException("Not implemented"); + } + + @Override + public Stream getStoredCredentialsByTypeStream(String type) { + return this.storedCredentials.stream().filter(credentialModel -> Objects.equals(credentialModel.getType(), type)); + } + + @Override + public CredentialModel getStoredCredentialByNameAndType(String name, String type) { + throw new RuntimeException("Not implemented"); + } + + @Override + public boolean moveStoredCredentialTo(String id, String newPreviousCredentialId) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void updateCredentialLabel(String credentialId, String credentialLabel) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void disableCredentialType(String credentialType) { + throw new RuntimeException("Not implemented"); + } + + @Override + public Stream getDisableableCredentialTypesStream() { + throw new RuntimeException("Not implemented"); + } + + @Override + public boolean isConfiguredFor(String type) { + throw new RuntimeException("Not implemented"); + } + + @Override + public boolean isConfiguredLocally(String type) { + throw new RuntimeException("Not implemented"); + } + + @Override + public Stream getConfiguredUserStorageCredentialTypesStream() { + throw new RuntimeException("Not implemented"); + } + + @Override + public CredentialModel createCredentialThroughProvider(CredentialModel model) { + throw new RuntimeException("Not implemented"); + } +} \ No newline at end of file diff --git a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestUserModel.java b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestUserModel.java index 56447be1..76bbc235 100644 --- a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestUserModel.java +++ b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/TestUserModel.java @@ -18,11 +18,12 @@ public class TestUserModel implements UserModel { private String lastName; private boolean isEnabled; private boolean isEmailVerified; + private String federationLink; private final Map> attributes = new HashMap<>(); private final Set roles = new HashSet<>(); private final Set groups = new HashSet<>(); private final Set requiredActions = new HashSet<>(); - private String federationLink; + private final TestCredentialManager testCredentialManager = new TestCredentialManager(); public TestUserModel(String username) { this.username = username; @@ -179,7 +180,7 @@ public void setServiceAccountClientLink(String clientInternalId) { @Override public SubjectCredentialManager credentialManager() { - throw new RuntimeException("Not implemented"); + return this.testCredentialManager; } @Override @@ -242,4 +243,4 @@ public Stream getRoleMappingsStream() { public void deleteRoleMapping(RoleModel role) { throw new RuntimeException("Not implemented"); } -} +} \ No newline at end of file diff --git a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactoryTest.java b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactoryTest.java index 968270dd..2f436908 100644 --- a/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactoryTest.java +++ b/src/test/java/com/danielfrak/code/keycloak/providers/rest/remote/UserModelFactoryTest.java @@ -6,6 +6,7 @@ import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.component.ComponentModel; import org.keycloak.models.*; +import org.keycloak.models.credential.OTPCredentialModel; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -470,6 +471,37 @@ void migratesRequiredActions() { .collect(Collectors.toSet())); } + @Test + void migratesTotp() { + final UserProvider userProvider = mock(UserProvider.class); + final RealmModel realm = mock(RealmModel.class); + final String username = "user"; + + when(session.users()) + .thenReturn(userProvider); + when(userProvider.addUser(realm, username)) + .thenReturn(new TestUserModel(username)); + + LegacyUser legacyUser = createLegacyUser(username); + var legacyTotp1 = new LegacyTotp(); + legacyTotp1.setName("device 1"); + legacyTotp1.setSecret("SECRET"); + + var legacyTotp2 = new LegacyTotp(); + legacyTotp2.setName("device 2"); + legacyTotp2.setSecret("SECRET2"); + + legacyUser.setTotps(List.of(legacyTotp1, legacyTotp2)); + + var result = userModelFactory.create(legacyUser, realm); + + var resultSet = result.credentialManager().getStoredCredentialsByTypeStream(OTPCredentialModel.TYPE).collect(Collectors.toSet()); + + assertEquals(2, resultSet.size()); + assertEquals("{\"value\":\"SECRET\"}", resultSet.stream().filter(item -> item.getUserLabel().equals("device 1")).findFirst().get().getSecretData()); + assertEquals("{\"value\":\"SECRET2\"}", resultSet.stream().filter(item -> item.getUserLabel().equals("device 2")).findFirst().get().getSecretData()); + } + @Test void isDuplicateUserIdReturnsFalseWhenNotMigratingUserId() { LegacyUser legacyUser = createLegacyUser("user");