Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for migrating totp #197

Merged
merged 6 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ The GET request will have to return user data as a JSON response in the form:
],
"requiredActions": [
"requiredActions"
],
"totps": [
{
"name": "string",
"secret": "string"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the properties are missing here.

}
]
}
```
Expand Down Expand Up @@ -143,6 +149,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"
}
]
}
```
Expand Down Expand Up @@ -296,3 +312,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 looks 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 the utf8 bytes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using backticks for the properties would increase readability. E.g.,:

"name should be the name of the totp device (...)".

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar nitpick: the utf8 bytesUTF-8 bytes (removed the, uppercased and kebab-cased UTF-8).
Ditto about the utf8 bytes below.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I understand it correctly, but wouldn't UTF-8 plaintext be easier to understand than UTF-8 bytes?

For the utf8 bytes just set the encoding attribute to null.
Possible Algorithms are: HmacSHA1, HmacSHA256, HmacSHA512
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class LegacyUser {
private List<String> roles;
private List<String> groups;
private List<String> requiredActions;
private List<LegacyTotp> totps;

public String getId() {
return id;
Expand Down Expand Up @@ -109,6 +110,14 @@ public void setRequiredActions(List<String> requiredActions) {
this.requiredActions = requiredActions;
}

public List<LegacyTotp> getTotps() {
return this.totps;
}

public void setTotps(List<LegacyTotp> totps) {
this.totps = totps;
}

@Override
public boolean equals(Object o) {
if (this == o) {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +94,14 @@ 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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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());
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double newline


@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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CredentialModel> storedCredentials = new HashSet<>();

@Override
public boolean isValid(List<CredentialInput> 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<CredentialModel> getStoredCredentialsStream() {
throw new RuntimeException("Not implemented");
}

@Override
public Stream<CredentialModel> 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<String> 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<String> getConfiguredUserStorageCredentialTypesStream() {
throw new RuntimeException("Not implemented");
}

@Override
public CredentialModel createCredentialThroughProvider(CredentialModel model) {
throw new RuntimeException("Not implemented");
}
}
Loading
Loading