From b135394d6a756398f5fc41fb4ea640723186554c Mon Sep 17 00:00:00 2001 From: y9938 Date: Sun, 12 Oct 2025 13:03:56 +0300 Subject: [PATCH] Initial commit --- .env.example | 14 ++ .gitignore | 2 + Dockerfile | 26 ++ LICENSE | 18 ++ README.md | 8 + compose.yaml | 26 ++ meta-user-provider/.gitignore | 1 + meta-user-provider/README.md | 19 ++ meta-user-provider/pom.xml | 65 +++++ .../com/example/keycloak/MetaUserModel.java | 93 +++++++ .../keycloak/MetaUserStorageProvider.java | 235 ++++++++++++++++++ .../MetaUserStorageProviderFactory.java | 95 +++++++ ...eycloak.storage.UserStorageProviderFactory | 1 + 13 files changed, 603 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 meta-user-provider/.gitignore create mode 100644 meta-user-provider/README.md create mode 100644 meta-user-provider/pom.xml create mode 100644 meta-user-provider/src/main/java/com/example/keycloak/MetaUserModel.java create mode 100644 meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProvider.java create mode 100644 meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProviderFactory.java create mode 100644 meta-user-provider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e6bfffd --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak +KC_DB_USERNAME=keycloak_user +KC_DB_PASSWORD='' + +KC_BOOTSTRAP_ADMIN_USERNAME=admin +KC_BOOTSTRAP_ADMIN_PASSWORD='' + +KC_HOSTNAME=https://account.example.com +KC_HTTP_ENABLED=true +KC_PROXY_HEADERS=xforwarded + +#JAVA_OPTS_KC_HEAP='-XX:MaxHeapFreeRatio=30 -XX:MaxRAMPercentage=65' +JAVA_OPTS_KC_HEAP='-Xms512m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC' + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14ee500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c56135 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM quay.io/keycloak/keycloak:26.4.0 AS builder + +ENV KC_DB=postgres +ENV KC_FEATURES=hostname:v2 +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true + +WORKDIR /opt/keycloak + +# MySQL JDBC driver +ADD --chown=keycloak:keycloak --chmod=644 \ + https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/8.4.0/mysql-connector-j-8.4.0.jar \ + /opt/keycloak/providers/ + +# Providers +COPY --chown=keycloak:keycloak --chmod=644 \ + ./meta-user-provider/target/meta-user-provider-1.0.0.jar \ + /opt/keycloak/providers/ + +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.4.0 +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9d9de0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 y9938 + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..db71743 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +Caddyfile + +``` +account.yourdomain.com { + reverse_proxy keycloak:8080 +} +``` + diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..276c7bf --- /dev/null +++ b/compose.yaml @@ -0,0 +1,26 @@ +services: + keycloak: + build: . + container_name: keycloak + restart: unless-stopped + command: start --optimized + env_file: .env +# mem_limit: 768M + extra_hosts: + - "host.docker.internal:host-gateway" + expose: + - "8080" + ports: + - "127.0.0.1:9000:9000" # Health/Metrics + networks: + - db + - proxify + +networks: + db: + name: db + external: true + proxify: + name: proxify + external: true + diff --git a/meta-user-provider/.gitignore b/meta-user-provider/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/meta-user-provider/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/meta-user-provider/README.md b/meta-user-provider/README.md new file mode 100644 index 0000000..3265f81 --- /dev/null +++ b/meta-user-provider/README.md @@ -0,0 +1,19 @@ +# Meta User Storage Provider + +[Keycloak](https://www.keycloak.org/) ([User Storage SPI](https://www.keycloak.org/docs/latest/server_development/index.html#_user-storage-spi)) + +## Читать + +[DOCS](https://www.keycloak.org/documentation) + +[Quickstarts](https://github.com/keycloak/keycloak-quickstarts#) + +[Maven Repository](https://mvnrepository.com/) + +## Сборка (JAR-файл) + +``` +docker run -it --rm -v "$(pwd)":/app -w /app maven:3.9.11-eclipse-temurin-17-alpine mvn clean package +``` + +`target/meta-user-provider-{version}.jar` diff --git a/meta-user-provider/pom.xml b/meta-user-provider/pom.xml new file mode 100644 index 0000000..9cd6657 --- /dev/null +++ b/meta-user-provider/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + com.example + meta-user-provider + Meta User Storage Provider + 1.0.0 + Custom User Storage Provider for Keycloak + + + + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + maven-shade-plugin + 3.4.1 + + + package + + shade + + + + + + + + + org.keycloak + keycloak-core + 26.4.0 + provided + + + org.keycloak + keycloak-server-spi + 26.4.0 + provided + + + org.keycloak + keycloak-server-spi-private + 26.4.0 + provided + + + org.keycloak + keycloak-services + 26.4.0 + provided + + + + 17 + 17 + 26.4.0 + UTF-8 + + diff --git a/meta-user-provider/src/main/java/com/example/keycloak/MetaUserModel.java b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserModel.java new file mode 100644 index 0000000..159caca --- /dev/null +++ b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserModel.java @@ -0,0 +1,93 @@ +package com.example.keycloak; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.storage.ReadOnlyException; +import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage; + +public class MetaUserModel extends AbstractUserAdapterFederatedStorage { + + private final String userId; + private final String username; + private final String email; + private final String fullName; + private final boolean enabled; + + public MetaUserModel(KeycloakSession session, RealmModel realm, ComponentModel storageProviderModel, + String userId, String username, String email, String fullName, boolean enabled) { + super(session, realm, storageProviderModel); + this.userId = userId; + this.username = username; + this.email = email; + this.fullName = fullName; + this.enabled = enabled; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getFirstName() { + // Извлекаем первое слово из full_name как имя + if (fullName != null && !fullName.trim().isEmpty()) { + String[] names = fullName.split("\\s+"); + return names.length > 0 ? names[0] : ""; + } + return ""; + } + + @Override + public String getLastName() { + // Извлекаем все кроме первого слова как фамилию + if (fullName != null && !fullName.trim().isEmpty()) { + String[] names = fullName.split("\\s+"); + if (names.length > 1) { + StringBuilder lastName = new StringBuilder(); + for (int i = 1; i < names.length; i++) { + if (i > 1) lastName.append(" "); + lastName.append(names[i]); + } + return lastName.toString(); + } + } + return ""; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public void setEmail(String email) { + throw new ReadOnlyException("User is read-only"); + } + + @Override + public void setUsername(String username) { + throw new ReadOnlyException("User is read-only"); + } + + @Override + public void setFirstName(String firstName) { + throw new ReadOnlyException("User is read-only"); + } + + @Override + public void setLastName(String lastName) { + throw new ReadOnlyException("User is read-only"); + } + + @Override + public void setEnabled(boolean enabled) { + throw new ReadOnlyException("User is read-only"); + } +} diff --git a/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProvider.java b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProvider.java new file mode 100644 index 0000000..6c6a052 --- /dev/null +++ b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProvider.java @@ -0,0 +1,235 @@ +package com.example.keycloak; + +import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialInput; +import org.keycloak.credential.CredentialInputValidator; +import org.keycloak.models.*; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.user.UserLookupProvider; +import org.keycloak.storage.user.UserQueryProvider; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.UserCredentialModel; +import org.mindrot.jbcrypt.BCrypt; + +import java.sql.*; +import java.util.Map; +import java.util.stream.Stream; + +public class MetaUserStorageProvider implements + UserStorageProvider, + UserLookupProvider, + CredentialInputValidator, + UserQueryProvider { + + private final KeycloakSession session; + private final ComponentModel model; + + public MetaUserStorageProvider(KeycloakSession session, ComponentModel model) { + this.session = session; + this.model = model; + } + + @Override + public void close() { + // Clean up resources if needed + } + + // UserLookupProvider methods + @Override + public UserModel getUserById(RealmModel realm, String id) { + StorageId storageId = new StorageId(id); + String userId = storageId.getExternalId(); + return getUserByExternalId(realm, userId); + } + + private UserModel getUserByExternalId(RealmModel realm, String userId) { + try (Connection connection = MetaUserStorageProviderFactory.getDatabaseConnection(model)) { + String sql = "SELECT * FROM keycloak_users WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, userId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return createUserModel(realm, rs); + } + } + } + } catch (SQLException e) { + System.err.println("Ошибка при поиске пользователя по ID: " + e.getMessage()); + e.printStackTrace(); + } + return null; + } + + @Override + public UserModel getUserByUsername(RealmModel realm, String username) { + try (Connection connection = MetaUserStorageProviderFactory.getDatabaseConnection(model)) { + String sql = "SELECT * FROM keycloak_users WHERE username = ? OR email = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, username); + stmt.setString(2, username); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return createUserModel(realm, rs); + } + } + } + } catch (SQLException e) { + System.err.println("Ошибка при поиске пользователя по username: " + e.getMessage()); + e.printStackTrace(); + } + return null; + } + + @Override + public UserModel getUserByEmail(RealmModel realm, String email) { + // Используем тот же метод, что и для username, так как email тоже уникален + return getUserByUsername(realm, email); + } + + // CredentialInputValidator methods + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + return PasswordCredentialModel.TYPE.equals(credentialType); + } + + @Override + public boolean supportsCredentialType(String credentialType) { + return PasswordCredentialModel.TYPE.equals(credentialType); + } + + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { + if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) { + return false; + } + + UserCredentialModel cred = (UserCredentialModel) input; + String inputPassword = cred.getChallengeResponse(); + + try (Connection connection = MetaUserStorageProviderFactory.getDatabaseConnection(model)) { + String sql = "SELECT password_hash FROM keycloak_users WHERE username = ? OR email = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, user.getUsername()); + stmt.setString(2, user.getUsername()); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String hashedPassword = rs.getString("password_hash"); + if (hashedPassword == null) { + return false; + } + // Заменяем $2y$ на $2a$ для совместимости с jbcrypt + if (hashedPassword.startsWith("$2y$")) { + hashedPassword = "$2a$" + hashedPassword.substring(4); + } + // Проверяем пароль с помощью BCrypt (как в Laravel) + return BCrypt.checkpw(inputPassword, hashedPassword); + } + } + } + } catch (SQLException e) { + System.err.println("Ошибка при проверке пароля: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("Ошибка при проверке BCrypt: " + e.getMessage()); + e.printStackTrace(); + } + + return false; + } + + // UserQueryProvider methods + @Override + public Stream searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) { + try (Connection connection = MetaUserStorageProviderFactory.getDatabaseConnection(model)) { + String sql = "SELECT * FROM keycloak_users WHERE username LIKE ? OR email LIKE ? OR full_name LIKE ? " + + "ORDER BY username LIMIT ? OFFSET ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + String searchPattern = "%" + search + "%"; + stmt.setString(1, searchPattern); + stmt.setString(2, searchPattern); + stmt.setString(3, searchPattern); + stmt.setInt(4, maxResults != null ? maxResults : 50); + stmt.setInt(5, firstResult != null ? firstResult : 0); + + try (ResultSet rs = stmt.executeQuery()) { + return createUserModelStream(realm, rs); + } + } + } catch (SQLException e) { + System.err.println("Ошибка при поиске пользователей: " + e.getMessage()); + e.printStackTrace(); + } + return Stream.empty(); + } + + @Override + public Stream searchForUserStream(RealmModel realm, Map params, Integer firstResult, Integer maxResults) { + // Поддержка поиска по конкретным параметрам + String search = params.get(UserModel.SEARCH); + if (search != null && !search.trim().isEmpty()) { + return searchForUserStream(realm, search, firstResult, maxResults); + } + return Stream.empty(); + } + + @Override + public Stream getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) { + return Stream.empty(); + } + + @Override + public Stream searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) { + return Stream.empty(); + } + + @Override + public int getUsersCount(RealmModel realm) { + try (Connection connection = MetaUserStorageProviderFactory.getDatabaseConnection(model)) { + String sql = "SELECT COUNT(*) as count FROM keycloak_users"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getInt("count"); + } + } + } + } catch (SQLException e) { + System.err.println("Ошибка при подсчете пользователей: " + e.getMessage()); + e.printStackTrace(); + } + return 0; + } + + @Override + public int getUsersCount(RealmModel realm, boolean includeServiceAccount) { + return getUsersCount(realm); + } + + // Вспомогательные методы + private UserModel createUserModel(RealmModel realm, ResultSet rs) throws SQLException { + String userId = rs.getString("user_id"); + String username = rs.getString("username"); + String email = rs.getString("email"); + String fullName = rs.getString("full_name"); + boolean enabled = rs.getBoolean("enabled"); + + return new MetaUserModel(session, realm, model, userId, username, email, fullName, enabled); + } + + private Stream createUserModelStream(RealmModel realm, ResultSet rs) throws SQLException { + Stream.Builder streamBuilder = Stream.builder(); + while (rs.next()) { + streamBuilder.add(createUserModel(realm, rs)); + } + return streamBuilder.build(); + } +} diff --git a/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProviderFactory.java b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProviderFactory.java new file mode 100644 index 0000000..b775592 --- /dev/null +++ b/meta-user-provider/src/main/java/com/example/keycloak/MetaUserStorageProviderFactory.java @@ -0,0 +1,95 @@ +package com.example.keycloak; + +import org.keycloak.component.ComponentModel; +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 java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; + +public class MetaUserStorageProviderFactory + implements UserStorageProviderFactory { + + public static final String PROVIDER_ID = "meta-user-provider"; + + // Конфигурационные свойства + private static final List configProperties; + + static { + configProperties = ProviderConfigurationBuilder.create() + .property().name("dbUrl") + .label("Database URL") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("jdbc:mysql://host.docker.internal:3306/app") + .helpText("JDBC URL для подключения к MySQL") + .add() + .property().name("dbUsername") + .label("Database Username") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("laravel") + .helpText("Пользователь с доступом к DB") + .add() + .property().name("dbPassword") + .label("Database Password") + .type(ProviderConfigProperty.PASSWORD) + .defaultValue("") + .helpText("Пароль пользователя") + .add() + .property().name("usersTable") + .label("Users Table") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("users") + .helpText("Название таблицы пользователей") + .add() + .property().name("membersTable") + .label("Members Table") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("members") + .helpText("Название вспомогательной таблицы с фио пользователей") + .add() + .property().name("viewName") + .label("View Name") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("keycloak_users") + .helpText("Название VIEW для пользователей") + .add() + .build(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public MetaUserStorageProvider create(KeycloakSession session, ComponentModel model) { + return new MetaUserStorageProvider(session, model); + } + + @Override + public String getHelpText() { + return "Meta User Storage Provider для Laravel MySQL"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + // Вспомогательный метод для тестирования подключения + public static Connection getDatabaseConnection(ComponentModel config) { + try { + String url = config.getConfig().getFirst("dbUrl"); + String username = config.getConfig().getFirst("dbUsername"); + String password = config.getConfig().getFirst("dbPassword"); + + return DriverManager.getConnection(url, username, password); + } catch (Exception e) { + throw new RuntimeException("Ошибка подключения к БД", e); + } + } +} diff --git a/meta-user-provider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/meta-user-provider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory new file mode 100644 index 0000000..44a4144 --- /dev/null +++ b/meta-user-provider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +com.example.keycloak.MetaUserStorageProviderFactory