diff --git a/ui/src/pandas/agency/KeycloakAdminClient.java b/ui/src/pandas/agency/KeycloakAdminClient.java index 67139ec..c706661 100644 --- a/ui/src/pandas/agency/KeycloakAdminClient.java +++ b/ui/src/pandas/agency/KeycloakAdminClient.java @@ -21,7 +21,6 @@ import java.util.List; import java.util.Locale; -import java.util.Map; /** * Client for the Keycloak Admin REST API. @@ -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 attributes - ) { - public static KeycloakUser from(User user) { - Map 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. @@ -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 credentials) { if (!saveUsersToKeycloak) { // legacy: reset password only until we unlink the pandas keycloak plugin if (user.getPassword() != null) { @@ -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) { diff --git a/ui/src/pandas/agency/KeycloakCredential.java b/ui/src/pandas/agency/KeycloakCredential.java new file mode 100644 index 0000000..6ec8639 --- /dev/null +++ b/ui/src/pandas/agency/KeycloakCredential.java @@ -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); + } +} diff --git a/ui/src/pandas/agency/KeycloakCredentialDumper.java b/ui/src/pandas/agency/KeycloakCredentialDumper.java new file mode 100644 index 0000000..cf55120 --- /dev/null +++ b/ui/src/pandas/agency/KeycloakCredentialDumper.java @@ -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 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]); + } +} diff --git a/ui/src/pandas/agency/KeycloakCredentialImporter.java b/ui/src/pandas/agency/KeycloakCredentialImporter.java new file mode 100644 index 0000000..3221a51 --- /dev/null +++ b/ui/src/pandas/agency/KeycloakCredentialImporter.java @@ -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>>() { + }); + log.info("Read {} credentials from file", credentialsByIndividualId.size()); + + // Get corresponding users from UserRepository + Iterable users = userRepository.findAllById(credentialsByIndividualId.keySet()); + + // Save each user back to Keycloak with the credentials + var seenUserIds = new HashSet(); + for (var user : users) { + seenUserIds.add(user.getId()); + log.info("Importing credentials for user {}", user.getUserid()); + List 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); + } + } +} diff --git a/ui/src/pandas/agency/KeycloakUser.java b/ui/src/pandas/agency/KeycloakUser.java new file mode 100644 index 0000000..954a3d1 --- /dev/null +++ b/ui/src/pandas/agency/KeycloakUser.java @@ -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 attributes, + List credentials +) { + public static KeycloakUser from(User user, List credentials) { + Map 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); + } +}