diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..42ea84b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index f4f33c3..41f2830 100644 --- a/LICENSE +++ b/LICENSE @@ -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: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5204009 --- /dev/null +++ b/pom.xml @@ -0,0 +1,74 @@ + + + Keycloak mailcow + + 4.0.0 + + de.cantorgymnasium + keycloak-mailcow + 0.0.2 + jar + + + 23.0.1 + + + + + org.keycloak + keycloak-core + provided + ${version.keycloak} + + + org.keycloak + keycloak-server-spi + provided + ${version.keycloak} + + + org.keycloak + keycloak-model-legacy + provided + ${version.keycloak} + + + org.keycloak + keycloak-server-spi-private + provided + ${version.keycloak} + + + org.keycloak + keycloak-services + provided + ${version.keycloak} + + + org.slf4j + slf4j-api + 2.0.9 + + + org.springframework.security + spring-security-crypto + 6.2.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + \ No newline at end of file diff --git a/src/main/java/de/cantorgymnasium/auth/provider/user/DbUtil.java b/src/main/java/de/cantorgymnasium/auth/provider/user/DbUtil.java new file mode 100644 index 0000000..fe8102f --- /dev/null +++ b/src/main/java/de/cantorgymnasium/auth/provider/user/DbUtil.java @@ -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)); + } +} diff --git a/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUser.java b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUser.java new file mode 100644 index 0000000..35dc69c --- /dev/null +++ b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUser.java @@ -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> getAttributes() { + MultivaluedHashMap 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); + } +} \ No newline at end of file diff --git a/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProvider.java b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProvider.java new file mode 100644 index 0000000..9f43e18 --- /dev/null +++ b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProvider.java @@ -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 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 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 searchForUserStream(RealmModel realm, Map 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 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 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 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; + } + +} diff --git a/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderConstants.java b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderConstants.java new file mode 100644 index 0000000..e9f8801 --- /dev/null +++ b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderConstants.java @@ -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"; +} diff --git a/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderFactory.java b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderFactory.java new file mode 100644 index 0000000..8314ad6 --- /dev/null +++ b/src/main/java/de/cantorgymnasium/auth/provider/user/mailcowUserStorageProviderFactory.java @@ -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 { + private static final Logger logger = LoggerFactory.getLogger(mailcowUserStorageProviderFactory.class); + protected final List 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 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()"); + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory new file mode 100644 index 0000000..521a32e --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +de.cantorgymnasium.auth.provider.user.mailcowUserStorageProviderFactory \ No newline at end of file