Initial commit

This commit is contained in:
2025-10-12 13:03:56 +03:00
commit a8165ae07a
12 changed files with 601 additions and 0 deletions

14
.env.example Normal file
View File

@@ -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'

26
Dockerfile Normal file
View File

@@ -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"]

18
LICENSE Normal file
View File

@@ -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.

8
README.md Normal file
View File

@@ -0,0 +1,8 @@
Caddyfile
```
account.yourdomain.com {
reverse_proxy keycloak:8080
}
```

26
compose.yaml Normal file
View File

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

1
meta-user-provider/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

View File

@@ -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`

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>meta-user-provider</artifactId>
<name>Meta User Storage Provider</name>
<version>1.0.0</version>
<description>Custom User Storage Provider for Keycloak</description>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>26.4.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>26.4.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>26.4.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>26.4.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
<keycloak.version>26.4.0</keycloak.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

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

View File

@@ -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<UserModel> 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<UserModel> searchForUserStream(RealmModel realm, Map<String, String> 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<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
@Override
public Stream<UserModel> 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<UserModel> createUserModelStream(RealmModel realm, ResultSet rs) throws SQLException {
Stream.Builder<UserModel> streamBuilder = Stream.builder();
while (rs.next()) {
streamBuilder.add(createUserModel(realm, rs));
}
return streamBuilder.build();
}
}

View File

@@ -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<MetaUserStorageProvider> {
public static final String PROVIDER_ID = "meta-user-provider";
// Конфигурационные свойства
private static final List<ProviderConfigProperty> 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<ProviderConfigProperty> 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);
}
}
}

View File

@@ -0,0 +1 @@
com.example.keycloak.MetaUserStorageProviderFactory