By integrating Keycloak into an existing system, it is highly likely that you will have to load users from an ancient database during authentication, where information about them can be stored in a rather fancy form. This task is solved by creating your own user provider (User Federation Provider in Keycloak terminology). Below is a short guide to writing such a provider.
In case you are not familiar with Keycloak, here is a quote from Wikipedia:
Keycloak is an open source single sign-on product with access control, aimed at modern applications and services.
In the modern microservice world, Keycloak is interesting primarily as an OAuth 2.0 provider, with which you can issue tokens to clients to access certain services.
Technically, Keycloak is a web application inside the WildFly server, which can give someone goosebumps from memories of a bloody enterprise. But enough theory, it's time to roll up your sleeves!
Our Keycloak plugin will be a small WAR packaged application. To build it, Java 8 will be enough. Take Gradle as a build tool, and specify the following modules in the dependencies:
compileOnly "org.keycloak:keycloak-core:12.0.3"
compileOnly "org.keycloak:keycloak-server-spi:12.0.3"
compileOnly "org.jboss.logging:jboss-logging:3.4.1.Final"
implementation "org.springframework:spring-core:5.3.3"
implementation "org.springframework:spring-jdbc:5.3.3"
implementation "org.springframework.security:spring-security-core:5.4.4"
testImplementation platform('org.junit:junit-bom:5.7.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:3.7.7'
testRuntimeOnly 'javax.ws.rs:javax.ws.rs-api:2.1.1'
testRuntimeOnly 'com.h2database:h2:1.4.200'
Spring Framework , , Spring. , .
- Keycloak. , Keycloak .
JBoss Logging, "" WildFly. , , - , SLF4J.
io.freefair.lombok
.
Keycloak org.keycloak.storage.UserStorageProvider
, , . , , , org.keycloak.storage.user.UserLookupProvider
org.keycloak.credential.CredentialInputValidator
. , . - . .
org.keycloak.models.UserModel
( ). org.keycloak.storage.adapter.AbstractUserAdapter
, org.keycloak.models.UserModel
:
public class LegacyDatabaseUserModel extends AbstractUserAdapter {
public static final String ATTRIBUTE_PASSWORD = "password";
private final MultivaluedHashMap<String, String> attributes = new MultivaluedHashMap<>();
private final Set<RoleModel> roles;
private LegacyDatabaseUserModel(Builder builder) {
super(builder.session, builder.realm, builder.storageProviderModel);
this.attributes.putSingle(UserModel.USERNAME, builder.username);
this.attributes.putSingle(UserModel.FIRST_NAME, builder.firstName);
this.attributes.putSingle(UserModel.LAST_NAME, builder.lastName);
this.attributes.putSingle(ATTRIBUTE_PASSWORD, builder.password);
this.roles = Collections.unmodifiableSet(builder.roles);
}
public static Builder builder() {
return new Builder();
}
@Override
public String getUsername() {
return getFirstAttribute(UserModel.USERNAME);
}
@Override
public String getFirstName() {
return getFirstAttribute(UserModel.FIRST_NAME);
}
@Override
public String getLastName() {
return getFirstAttribute(UserModel.LAST_NAME);
}
@Override
public Map<String, List<String>> getAttributes() {
return new MultivaluedHashMap<>(attributes);
}
@Override
public String getFirstAttribute(String name) {
return attributes.getFirst(name);
}
@Override
public List<String> getAttribute(String name) {
return attributes.get(name);
}
@Override
protected Set<RoleModel> getRoleMappingsInternal() {
return roles;
}
public static class Builder {
...
}
}
, , , - . Map
, , - .
org.keycloak.models.RoleModel
. , :
@AllArgsConstructor
public class LegacyDatabaseRoleModel implements RoleModel {
@Getter
private final RoleContainerModel container;
@Getter
private final String name;
@Override
public String getId() {
return getName();
}
@Override
public void setName(String name) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public String getDescription() {
return null;
}
@Override
public void setDescription(String description) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public boolean isComposite() {
return false;
}
@Override
public void addCompositeRole(RoleModel role) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public void removeCompositeRole(RoleModel role) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public Stream<RoleModel> getCompositesStream() {
return Stream.empty();
}
@Override
public boolean isClientRole() {
return false;
}
@Override
public String getContainerId() {
return container.getId();
}
@Override
public boolean hasRole(RoleModel role) {
return false;
}
@Override
public Map<String, List<String>> getAttributes() {
return Collections.emptyMap();
}
@Override
public void setSingleAttribute(String name, String value) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public void setAttribute(String name, List<String> values) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public void removeAttribute(String name) {
throw new ReadOnlyException("Role is read only for this update");
}
@Override
public Stream<String> getAttributeStream(String name) {
return Stream.empty();
}
}
, . , - .
. . . :
private final ConcurrentMap<UserModelKey, LegacyDatabaseUserModel> loadedUsers = new ConcurrentHashMap<>();
@Override
public LegacyDatabaseUserModel getUserByUsername(String username, RealmModel realm) {
UserModelKey userKey = new UserModelKey(username, realm.getId());
return loadedUsers.computeIfAbsent(userKey, k -> {
LegacyDatabaseUserModel user = findUserByName(username, realm);
if (user != null) {
log.debugv("User is loaded by name \"{0}\"", username);
}
return user;
});
}
, Keycloak . java.util.concurrent.ConcurrentMap
. findUserByName
, org.springframework.jdbc.core.JdbcTemplate
org.springframework.jdbc.core.ResultSetExtractor
, , .
private LegacyDatabaseUserModel findUserByName(String username, RealmModel realm) {
return jdbcTemplate.query(SQL_FIND_USER_BY_NAME, new Object[]{username}, new int[]{Types.VARCHAR},
new LegacyDatabaseUserModelResultSetExtractor(realm));
}
@RequiredArgsConstructor
private class LegacyDatabaseUserModelResultSetExtractor implements ResultSetExtractor<LegacyDatabaseUserModel> {
final RealmModel realm;
@Override
public LegacyDatabaseUserModel extractData(ResultSet rs) throws SQLException, DataAccessException {
if (!rs.next()) {
return null;
}
LegacyDatabaseUserModel.Builder userModelBuilder = LegacyDatabaseUserModel.builder()
.session(session)
.realm(realm)
.storageProviderModel(storageProviderModel)
.username(rs.getString(1))
.password(rs.getString(2))
.firstName(rs.getString(3))
.lastName(rs.getString(4))
.withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));
while (rs.next()) {
userModelBuilder.withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));
}
return userModelBuilder.build();
}
}
. :
@Override
public LegacyDatabaseUserModel getUserById(String id, RealmModel realm) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(username, realm);
}
: f:<storageProvideId>:<username>
. org.keycloak.storage.StorageId
.
, :
@Override
public boolean isValid(RealmModel realm, UserModel userModel, CredentialInput credentialInput) {
if (!supportsCredentialType(credentialInput.getType())) {
log.debugv("Credential type \"{0}\" is not supported", credentialInput.getType());
return false;
}
String password = user.getFirstAttribute(LegacyDatabaseUserModel.ATTRIBUTE_PASSWORD);
return passwordEncoder.matches(credentialInput.getChallengeResponse(), password);
}
, , ( ). . - Map
. org.keycloak.models.UserModel
, , "" isValid
com.habr.keycloak.model.LegacyDatabaseUserModel
- . org.springframework.security.crypto.password.PasswordEncoder
.
Keycloak . . org.keycloak.storage.UserStorageProviderFactory
. :
;
.
@Override
public void init(Config.Scope config) {
initDataSource();
initPasswordEncoder();
}
:
private PropertySource<Map<String, Object>> getPropertySource() {
if (propertySource == null) {
propertySource = getDefaultPropertySource();
}
return propertySource;
}
private PropertySource<Map<String, Object>> getDefaultPropertySource() {
return new PropertiesPropertySource("default", System.getProperties());
}
, Keycloak-way *.properties
, standalone.xml
:
@Override
public void init(Config.Scope config) {
String propertyFilePath = config.get("property-file-path");
...
.
, :
private void initDataSource() {
String driverClassName = getDataSourceDriverClassName();
String url = getDataSourceUrl();
SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
try {
dataSource.setDriverClass((Class<? extends Driver>) Class.forName(driverClassName));
dataSource.setUrl(url);
dataSource.setUsername(getDataSourceUsername());
dataSource.setPassword(getDataSourcePassword());
this.dataSource = dataSource;
log.debugv("Data source to connect with database \"{0}\" is created", url);
} catch (ClassNotFoundException e) {
throw new IllegalStateException("JDBC driver class \"" + driverClassName + "\" is not found", e);
}
}
: WAR- , Keycloak. , , . , . Keycloak.
org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
.
- Factory Finder', META-INF/services
org.keycloak.storage.UserStorageProviderFactory
, .
As stated earlier, the plugin loads the database driver from a specific Keycloak module. In order to tell Keycloak that we depend on this module, you will need to additionally create a file jboss-deployment-structure.xml
in the directory META-INF
:
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
<deployment>
<dependencies>
<module name="org.postgresql"/>
</dependencies>
</deployment>
</jboss-deployment-structure>
For Keycloak to pick up our plugin, it (plugin) should be placed in the directory $KEYCLOAK_HOME/standalone/deployments
. If the plugin is successfully deployed in the Keycloak admin panel, in the User Federation section, it will be possible to add a provider with an identifier habr.legacy-database
, after which you can start issuing tokens.
The plugin source code is available on GitHub .
That's all. Thank you for attention!