-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Keycloak credential dumper and importer
- Loading branch information
Showing
5 changed files
with
185 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |