Initial commit

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

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