Initial implementation (close #1)

This commit is contained in:
Denys Konovalov 2023-12-22 00:33:49 +01:00
parent 5bd690e5b1
commit ae3fdb775f
Signed by: Denys Konovalov
GPG Key ID: 0037E1B0E33BD2C9
9 changed files with 630 additions and 1 deletions

3
.vscode/settings.json vendored Normal file

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 gcg
Copyright (c) 2023 Georg-Cantor-Gymnasium Halle (Saale)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

74
pom.xml Normal file

@ -0,0 +1,74 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<name>Keycloak mailcow</name>
<description />
<modelVersion>4.0.0</modelVersion>
<groupId>de.cantorgymnasium</groupId>
<artifactId>keycloak-mailcow</artifactId>
<version>0.0.2</version>
<packaging>jar</packaging>
<properties>
<version.keycloak>23.0.1</version.keycloak>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
<version>${version.keycloak}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
<version>${version.keycloak}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-legacy</artifactId>
<scope>provided</scope>
<version>${version.keycloak}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
<version>${version.keycloak}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
<version>${version.keycloak}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>6.2.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

@ -0,0 +1,25 @@
package de.cantorgymnasium.auth.provider.user;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import org.keycloak.component.ComponentModel;
import static de.cantorgymnasium.auth.provider.user.mailcowUserStorageProviderConstants.*;
public class DbUtil {
public static Connection getConnection(ComponentModel config) throws SQLException {
String driverClass = config.get(CONFIG_KEY_JDBC_DRIVER);
try {
Class.forName(driverClass);
} catch (ClassNotFoundException nfe) {
throw new RuntimeException(
"Invalid JDBC driver: " + driverClass + ". Please check if your driver if properly installed");
}
return DriverManager.getConnection(config.get(CONFIG_KEY_JDBC_URL),
config.get(CONFIG_KEY_DB_USERNAME),
config.get(CONFIG_KEY_DB_PASSWORD));
}
}

@ -0,0 +1,129 @@
package de.cantorgymnasium.auth.provider.user;
import java.util.List;
import java.util.Map;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.LegacyUserCredentialManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SubjectCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.storage.adapter.AbstractUserAdapter;
class mailcowUser extends AbstractUserAdapter {
private final String username;
private final String email;
private final String firstName;
private final String lastName;
private final String domain;
private mailcowUser(KeycloakSession session, RealmModel realm,
ComponentModel storageProviderModel,
String username,
String email,
String firstName,
String lastName,
String domain) {
super(session, realm, storageProviderModel);
this.username = username;
this.email = email;
this.firstName = firstName;
this.lastName = lastName;
this.domain = domain;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getFirstName() {
return firstName;
}
@Override
public String getLastName() {
return lastName;
}
@Override
public String getEmail() {
return email;
}
public String getDomain() {
return domain;
}
@Override
public Map<String, List<String>> getAttributes() {
MultivaluedHashMap<String, String> attributes = new MultivaluedHashMap<>();
attributes.add(UserModel.USERNAME, getUsername());
attributes.add(UserModel.EMAIL, getEmail());
attributes.add(UserModel.FIRST_NAME, getFirstName());
attributes.add(UserModel.LAST_NAME, getLastName());
attributes.add("domain", getDomain());
return attributes;
}
static class Builder {
private final KeycloakSession session;
private final RealmModel realm;
private final ComponentModel storageProviderModel;
private String username;
private String email;
private String firstName;
private String lastName;
private String domain;
Builder(KeycloakSession session, RealmModel realm, ComponentModel storageProviderModel, String username) {
this.session = session;
this.realm = realm;
this.storageProviderModel = storageProviderModel;
this.username = username;
}
mailcowUser.Builder email(String email) {
this.email = email;
return this;
}
mailcowUser.Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
mailcowUser.Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
mailcowUser.Builder domain(String domain) {
this.domain = domain;
return this;
}
mailcowUser build() {
return new mailcowUser(
session,
realm,
storageProviderModel,
username,
email,
firstName,
lastName,
domain);
}
}
@Override
public SubjectCredentialManager credentialManager() {
return new LegacyUserCredentialManager(session, realm, this);
}
}

@ -0,0 +1,282 @@
package de.cantorgymnasium.auth.provider.user;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.bcrypt.BCrypt;
public class mailcowUserStorageProvider implements UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
UserQueryProvider,
CredentialInputUpdater {
private static final Logger logger = LoggerFactory.getLogger(mailcowUserStorageProvider.class);
private KeycloakSession ksession;
private ComponentModel model;
public mailcowUserStorageProvider(KeycloakSession ksession, ComponentModel model) {
this.ksession = ksession;
this.model = model;
}
@Override
public void close() {
logger.info("[mailcow] close()");
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
logger.info("[mailcow] getUserById({})", id);
StorageId sid = new StorageId(id);
return getUserByUsername(realm, sid.getExternalId());
}
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
logger.info("[mailcow] getUserByUsername({})", username);
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"select username, name, `mailbox`.`domain`, local_part FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' AND username = ?");
st.setString(1, username);
st.execute();
ResultSet rs = st.getResultSet();
if (rs.next()) {
return mapUser(realm, rs);
} else {
return null;
}
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
logger.info("[mailcow] getUserByEmail({})", email);
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"select username, name, `mailbox`.`domain`, local_part FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' AND username = ?");
st.setString(1, email);
st.execute();
ResultSet rs = st.getResultSet();
if (rs.next()) {
return mapUser(realm, rs);
} else {
return null;
}
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
@Override
public boolean supportsCredentialType(String credentialType) {
logger.info("[mailcow] supportsCredentialType({})", credentialType);
return PasswordCredentialModel.TYPE.endsWith(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
logger.info("[mailcow] isConfiguredFor(realm={},user={},credentialType={})", realm.getName(),
user.getUsername(), credentialType);
// In our case, password is the only type of credential, so we allways return
// 'true' if
// this is the credentialType
return supportsCredentialType(credentialType);
}
private boolean verifyHash(String hash, String password) {
logger.info("[mailcow] verifyHash");
Pattern pattern = Pattern.compile("\\{(.+)\\}(.+)");
Matcher matcher = pattern.matcher(hash);
while (matcher.find()) {
if (matcher.group(1).contains("BLF-CRYPT")) {
return BCrypt.checkpw(password, matcher.group(2));
}
}
return false;
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
logger.info("[mailcow] isValid(realm={},user={},credentialInput.type={})", realm.getName(), user.getUsername(),
credentialInput.getType());
if (!this.supportsCredentialType(credentialInput.getType())) {
return false;
}
StorageId sid = new StorageId(user.getId());
String username = sid.getExternalId();
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"SELECT `password` FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' AND `username` = ?");
st.setString(1, username);
st.execute();
ResultSet rs = st.getResultSet();
if (rs.next()) {
String hash = rs.getString(1);
return verifyHash(hash, credentialInput.getChallengeResponse());
} else {
return false;
}
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
// UserQueryProvider implementation
@Override
public int getUsersCount(RealmModel realm) {
logger.info("[mailcow] getUsersCount: realm={}", realm.getName());
try (Connection c = DbUtil.getConnection(this.model)) {
Statement st = c.createStatement();
st.execute(
"SELECT count(*) FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1'");
ResultSet rs = st.getResultSet();
rs.next();
return rs.getInt(1);
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult,
Integer maxResults) {
logger.info("[mailcow] getUsers: realm={}", realm.getName());
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"select username, name, `mailbox`.`domain`, local_part FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' order by `username` limit ? offset ?");
st.setInt(1, maxResults);
st.setInt(2, firstResult);
st.execute();
ResultSet rs = st.getResultSet();
logger.info(rs.toString());
List<UserModel> users = new ArrayList<>();
while (rs.next()) {
users.add(mapUser(realm, rs));
}
return users.stream();
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult,
Integer maxResults) {
String search = params.get(UserModel.SEARCH);
logger.info("[mailcow] searchForUser: realm={}, search={}", realm.getName(), search);
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st;
if (search != null && !search.isEmpty() && !search.isBlank()) {
if (search.contains("*")) {
search = "@";
}
st = c.prepareStatement(
"select username, name, `mailbox`.`domain`, local_part FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' AND `username` like ? order by `username` limit ? offset ?");
st.setString(1, '%' + search + '%');
st.setInt(2, maxResults);
st.setInt(3, firstResult);
} else {
st = c.prepareStatement(
"select username, name, `mailbox`.`domain`, local_part FROM `mailbox` INNER JOIN domain on mailbox.domain = domain.domain WHERE `mailbox`.`active` = '1' AND `domain`.`active`='1' order by `username` limit ? offset ?");
st.setInt(1, maxResults);
st.setInt(2, firstResult);
}
st.execute();
ResultSet rs = st.getResultSet();
List<UserModel> users = new ArrayList<>();
while (rs.next()) {
users.add(mapUser(realm, rs));
}
return users.stream();
} catch (SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(), ex);
}
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
return getGroupMembersStream(realm, null);
}
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (input.getType().equals(PasswordCredentialModel.TYPE))
throw new ReadOnlyException("user is read only for this update");
return false;
}
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
}
@Override
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
return Stream.empty();
}
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapterFederatedStorage(ksession, realm, model) {
@Override
public String getUsername() {
return username;
}
@Override
public void setUsername(String username) {
throw new UnsupportedOperationException("Unimplemented method 'setUsername'");
}
};
}
// ------------------- Implementation
private UserModel mapUser(RealmModel realm, ResultSet rs) throws SQLException {
String[] name = rs.getString("name").trim().split("\\s+");
mailcowUser user = new mailcowUser.Builder(ksession, realm, model, rs.getString("username"))
.email(rs.getString("username"))
.firstName(name.length > 0 ? name[0] : "")
.lastName(name.length > 1 ? name[1] : "")
.domain(rs.getString("domain"))
.build();
return user;
}
}

@ -0,0 +1,9 @@
package de.cantorgymnasium.auth.provider.user;
public final class mailcowUserStorageProviderConstants {
public static final String CONFIG_KEY_JDBC_DRIVER = "jdbcDriver";
public static final String CONFIG_KEY_JDBC_URL = "jdbcUrl";
public static final String CONFIG_KEY_DB_USERNAME = "username";
public static final String CONFIG_KEY_DB_PASSWORD = "password";
public static final String CONFIG_KEY_VALIDATION_QUERY = "validationQuery";
}

@ -0,0 +1,106 @@
package de.cantorgymnasium.auth.provider.user;
import java.sql.Connection;
import java.util.List;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProviderFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static de.cantorgymnasium.auth.provider.user.mailcowUserStorageProviderConstants.*;
public class mailcowUserStorageProviderFactory implements UserStorageProviderFactory<mailcowUserStorageProvider> {
private static final Logger logger = LoggerFactory.getLogger(mailcowUserStorageProviderFactory.class);
protected final List<ProviderConfigProperty> configMetadata;
public mailcowUserStorageProviderFactory() {
logger.info("[mailcow] mailcowUserStorageProviderFactory created");
// Create config metadata
configMetadata = ProviderConfigurationBuilder.create()
.property()
.name(CONFIG_KEY_JDBC_DRIVER)
.label("JDBC Driver Class")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("com.mysql.cj.jdbc.Driver")
.helpText("Fully qualified class name of the JDBC driver")
.add()
.property()
.name(CONFIG_KEY_JDBC_URL)
.label("JDBC URL")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("jdbc:localhost:3306/testdb")
.helpText("JDBC URL used to connect to the user database")
.add()
.property()
.name(CONFIG_KEY_DB_USERNAME)
.label("Database User")
.type(ProviderConfigProperty.STRING_TYPE)
.helpText("Username used to connect to the database")
.add()
.property()
.name(CONFIG_KEY_DB_PASSWORD)
.label("Database Password")
.type(ProviderConfigProperty.STRING_TYPE)
.helpText("Password used to connect to the database")
.secret(true)
.add()
.property()
.name(CONFIG_KEY_VALIDATION_QUERY)
.label("SQL Validation Query")
.type(ProviderConfigProperty.STRING_TYPE)
.helpText("SQL query used to validate a connection")
.defaultValue("select 1")
.add()
.build();
}
@Override
public mailcowUserStorageProvider create(KeycloakSession ksession, ComponentModel model) {
logger.info("[mailcow] creating new mailcowUserStorageProvider");
return new mailcowUserStorageProvider(ksession, model);
}
@Override
public String getId() {
logger.info("[mailcow] getId()");
return "mailcow-user-provider";
}
// Configuration support methods
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
throws ComponentValidationException {
try (Connection c = DbUtil.getConnection(config)) {
logger.info("[mailcow] Testing connection...");
c.createStatement().execute(config.get(CONFIG_KEY_VALIDATION_QUERY));
logger.info("[mailcow] Connection OK !");
} catch (Exception ex) {
logger.warn("[mailcow] Unable to validate connection: ex={}", ex.getMessage());
throw new ComponentValidationException("Unable to validate database connection", ex);
}
}
@Override
public void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel oldModel, ComponentModel newModel) {
logger.info("[mailcow] onUpdate()");
}
@Override
public void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {
logger.info("[mailcow] onCreate()");
}
}

@ -0,0 +1 @@
de.cantorgymnasium.auth.provider.user.mailcowUserStorageProviderFactory