Skip to content

Commit

Permalink
Add Keycloak credential dumper and importer
Browse files Browse the repository at this point in the history
  • Loading branch information
ato committed Feb 10, 2025
1 parent fe13d69 commit e9b1997
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 25 deletions.
30 changes: 5 additions & 25 deletions ui/src/pandas/agency/KeycloakAdminClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@

import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
* Client for the Keycloak Admin REST API.
Expand Down Expand Up @@ -58,26 +57,6 @@ private String obtainAccessToken() {
return authorizedClient.getAccessToken().getTokenValue();
}

private record KeycloakUser(
String id,
String username,
String firstName,
String lastName,
String email,
Map<String, String> attributes
) {
public static KeycloakUser from(User user) {
Map<String, String> attributes = Map.of("agencyId", Long.toString(user.getAgency().getId()),
"accessLevel", user.getRole().getType());
return new KeycloakUser(null,
user.getUserid(),
user.getNameGiven(),
user.getNameFamily(),
user.getEmail(),
attributes);
}
}

private KeycloakUser getUserByUsername(String username) {
// The username= query param seems to be broken in our current version of Keycloak (or with federated accounts)
// so we have to do a general search= instead and then filter the results for username matches.
Expand All @@ -101,15 +80,16 @@ public void resetPasswordForUsername(String username, String newPassword) {
private void resetPasswordForUserId(String userId, String password) {
restClient.put().uri("/users/{user-id}/reset-password", userId)
.contentType(MediaType.APPLICATION_JSON)
.body(new Credential("password", password, false))
.body(new KeycloakCredential("password", password, false))
.retrieve()
.toBodilessEntity();
}

private record Credential(String type, String value, Boolean temporary) {
public void saveUser(User user) {
saveUser(user, null);
}

public void saveUser(User user) {
public void saveUser(User user, List<KeycloakCredential> credentials) {
if (!saveUsersToKeycloak) {
// legacy: reset password only until we unlink the pandas keycloak plugin
if (user.getPassword() != null) {
Expand All @@ -118,7 +98,7 @@ public void saveUser(User user) {
return;
}

var updatedUser = KeycloakUser.from(user);
var updatedUser = KeycloakUser.from(user, credentials);
var existingUser = getUserByUsername(user.getUserid());
String userId;
if (existingUser == null) {
Expand Down
21 changes: 21 additions & 0 deletions ui/src/pandas/agency/KeycloakCredential.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package pandas.agency;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record KeycloakCredential(
String id,
String type,
String userLabel,
Long createdDate,
String secretData,
String credentialData,
Integer priority,
String value,
Boolean temporary,
String salt) {

public KeycloakCredential(String type, String value, Boolean temporary) {
this(null, type, null, null, null, null, null, value, temporary, null);
}
}
67 changes: 67 additions & 0 deletions ui/src/pandas/agency/KeycloakCredentialDumper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package pandas.agency;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.*;

public class KeycloakCredentialDumper {
private final Logger log = LoggerFactory.getLogger(getClass());
private final String keycloakRealmId = System.getenv("KEYCLOAK_REALM_ID");
private final String keycloakDbUrl = System.getenv("KEYCLOAK_DB_URL");
private final String keycloakDbUser = System.getenv("KEYCLOAK_DB_USER");
private final String keycloakDbPassword = System.getenv("KEYCLOAK_DB_PASSWORD");

public static void main(String[] args) {
new KeycloakCredentialDumper().run();
}

private void run() {
try (Connection connection = DriverManager.getConnection(keycloakDbUrl, keycloakDbUser, keycloakDbPassword)) {
try (var stmt = connection.prepareStatement("SELECT * FROM FED_USER_CREDENTIAL where REALM_ID = ?")) {
stmt.setString(1, keycloakRealmId);
try (var resultSet = stmt.executeQuery()) {
var credentials = new TreeMap<Long, List<KeycloakCredential>>();
long count = 0;
while (resultSet.next()) {
KeycloakCredential credential = new KeycloakCredential(
resultSet.getString("ID"),
resultSet.getString("TYPE").toLowerCase(),
resultSet.getString("USER_LABEL"),
resultSet.getObject("CREATED_DATE", Long.class),
resultSet.getString("SECRET_DATA"),
resultSet.getString("CREDENTIAL_DATA"),
resultSet.getObject("PRIORITY", Integer.class),
null, // value
null, // temporary
resultSet.getString("SALT")
);
long individualId = extractIndividualIdFromUserId(resultSet.getString("USER_ID"));
credentials.computeIfAbsent(individualId, id -> new ArrayList<>()).add(credential);
count++;
}
var objectMapper = new ObjectMapper();
objectMapper.writeValue(System.out, credentials);
log.info("Dumped {} credentials for {} users", count, credentials.size());
}
}
} catch (SQLException e) {
log.error("Failed to connect to MySQL", e);
} catch (IOException e) {
log.error("IO error", e);
}
}

/**
* "f:e7b8cb8e-4890-4702-b486-6cd1b2b3d6a7:12345" -> 12345
*/
private static long extractIndividualIdFromUserId(String userId) {
if (!userId.startsWith("f:")) throw new IllegalArgumentException("Not a federated user id: " + userId);
return Long.parseLong(userId.split(":")[2]);
}
}
66 changes: 66 additions & 0 deletions ui/src/pandas/agency/KeycloakCredentialImporter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package pandas.agency;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
@ConditionalOnProperty(name = "OIDC_CREDENTIALS_IMPORT_FILE")
public class KeycloakCredentialImporter implements CommandLineRunner {
private final Logger log = LoggerFactory.getLogger(KeycloakCredentialImporter.class);
private final KeycloakAdminClient keycloakAdminClient;
private final UserRepository userRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
private final Path importFile;

public KeycloakCredentialImporter(KeycloakAdminClient keycloakAdminClient, UserRepository userRepository,
@Value("${OIDC_CREDENTIALS_IMPORT_FILE}") Path importFile) {
this.keycloakAdminClient = keycloakAdminClient;
this.userRepository = userRepository;
this.importFile = importFile;
}

@Override
public void run(String... args) throws Exception {
log.info("Importing keycloak credentials from {}", importFile);
try {
var credentialsByIndividualId = objectMapper.readValue(importFile.toFile(), new TypeReference<Map<Long,List<KeycloakCredential>>>() {
});
log.info("Read {} credentials from file", credentialsByIndividualId.size());

// Get corresponding users from UserRepository
Iterable<User> users = userRepository.findAllById(credentialsByIndividualId.keySet());

// Save each user back to Keycloak with the credentials
var seenUserIds = new HashSet<Long>();
for (var user : users) {
seenUserIds.add(user.getId());
log.info("Importing credentials for user {}", user.getUserid());
List<KeycloakCredential> credentials = credentialsByIndividualId.get(user.getId());
if (credentials == null) throw new IllegalStateException("No credentials found for user " + user.getUserid());
keycloakAdminClient.saveUser(user, credentials);
}

// Check for credentials without corresponding users
var orphanedIds = credentialsByIndividualId.keySet().stream()
.filter(id -> !seenUserIds.contains(id))
.collect(Collectors.toSet());
if (!orphanedIds.isEmpty()) {
log.warn("No user found for credentials with individualIds: {}", orphanedIds);
}
} catch (Exception e) {
log.error("Error importing keycloak credentials", e);
}
}
}
26 changes: 26 additions & 0 deletions ui/src/pandas/agency/KeycloakUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package pandas.agency;

import java.util.Map;
import java.util.List;

record KeycloakUser(
String id,
String username,
String firstName,
String lastName,
String email,
Map<String, String> attributes,
List<KeycloakCredential> credentials
) {
public static KeycloakUser from(User user, List<KeycloakCredential> credentials) {
Map<String, String> attributes = Map.of("agencyId", Long.toString(user.getAgency().getId()),
"accessLevel", user.getRole().getType());
return new KeycloakUser(null,
user.getUserid(),
user.getNameGiven(),
user.getNameFamily(),
user.getEmail(),
attributes,
credentials);
}
}

0 comments on commit e9b1997

Please sign in to comment.