Initial implementation (close #1)

This commit is contained in:
2023-12-22 00:33:49 +01:00
parent 5bd690e5b1
commit ae3fdb775f
9 changed files with 630 additions and 1 deletions

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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";
}

View File

@ -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()");
}
}

View File

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