Initial implementation (close #1)
This commit is contained in:
@ -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
|
Reference in New Issue
Block a user