众所周知,我们可以扩展Keycloak提供的SPI插件,可以新增Keycloak的SPI吗?答案是肯定的。这篇文件,我们希望实现将Keycloak信息发布到MQTT,这个信息可以是Keycloak事件信息,也可以是邮件信息。
代码在这里Gitee,这个项目中,定义了“mqttPublisher”的SPI,下面详细介绍代码。
POM文件
引入mqtt3的包:
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<scope>provided</scope>
</dependency>
注意下,scope必须是provider,这是因为本项目只是一个插件,mqtt3的依赖包需要在发布时手工拷贝到keycloak server 中。
定义SPI
定义服务接口
package cn.dubhe.keycloak.mqtt;
import org.keycloak.provider.Provider;
/**
* 发送消息服务
*
* @author PinWei Wan
* @since 17.0.1
*/
public interface PublisherService extends Provider {
/**
* 发布消息
* @param topic
* @param message
*/
public void publish(final String topic, final String message);
}
接口只有一个方法,功能就是发布消息
定义服务工厂
package cn.dubhe.keycloak.mqtt;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.ServerInfoAwareProviderFactory;
/**
* 服务提供者工厂
*
* @author PinWei Wan
* @since 17.0.1
*/
public interface PublisherServiceProviderFactory extends ProviderFactory<PublisherService>, ServerInfoAwareProviderFactory {
}
定义SPI
package cn.dubhe.keycloak.mqtt;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* spid定义
*
* @author PinWei Wan
* @since 17.0.1
*/
public class MqttPublisherSpi implements Spi {
@Override
public boolean isInternal() {
return false;
}
@Override
public String getName() {
return "mqttPublisher";
}
@Override
public Class<? extends Provider> getProviderClass() {
return PublisherService.class;
}
@Override
public Class<? extends ProviderFactory<?>> getProviderFactoryClass() {
return PublisherServiceProviderFactory.class;
}
}
属性配置
package cn.dubhe.keycloak.mqtt;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.builder.ReflectionToStringBuilder;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.keycloak.Config.Scope;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* 配置属性
*
* @author PinWei Wan
* @since 17.0.1
*/
@Getter
@Slf4j
public class ConfigProperties {
/** 服务地址 */
private String serverUri;
/** 用户名 */
private String username;
/** 密码 */
private String password;
/** 客户端ID */
private String clientId;
/** 自动重新连接 */
private boolean automaticReconnect;
/** 清楚会话 */
private boolean cleanSession;
/** 连接超时,单位秒 */
private int connectionTimeout;
/** 保持连接, 单位秒 */
private int keepAliveInterval;
private ConfigProperties(String serverUri, String username, String password, String clientId,
boolean automaticReconnect, boolean cleanSession, int connectionTimeout, int keepAliveInterval) {
this.serverUri = serverUri;
this.username = username;
this.password = password;
this.clientId = clientId;
this.automaticReconnect = automaticReconnect;
this.cleanSession = cleanSession;
this.connectionTimeout = connectionTimeout;
this.keepAliveInterval = keepAliveInterval;
}
public static ConfigProperties create(Scope config) {
final ConfigProperties rslt = Properties.configProperties(config);
log.info(rslt.toString());
return rslt;
}
public MqttClient mqttClient() throws MqttException {
return new MqttClient(serverUri, clientId);
}
public MqttConnectOptions mqttConnectOptions() throws MqttException {
MqttConnectOptions options = new MqttConnectOptions();
options.setAutomaticReconnect(automaticReconnect);
options.setCleanSession(cleanSession);
options.setConnectionTimeout(connectionTimeout);
options.setKeepAliveInterval(keepAliveInterval);
if (StringUtils.isNotBlank(username) && StringUtils.isNotBlank(password) ) {
options.setUserName(username);
options.setPassword(password.toCharArray());
}
return options;
}
@Override
public String toString() {
return ReflectionToStringBuilder.toStringExclude(this, "password");
}
public Map<String, String> toMap() {
final Map<String, String> rslt = new HashMap<>();
rslt.put(Properties.SERVER_URI.getCode(), this.serverUri);
rslt.put(Properties.USERNAME.getCode(), this.username);
rslt.put(Properties.CLIENT_ID.getCode(), this.clientId);
rslt.put(Properties.AUTOMATIC_RECONNECT.getCode(), String.valueOf(this.automaticReconnect));
rslt.put(Properties.CLEAN_SESSION.getCode(), String.valueOf(this.cleanSession));
rslt.put(Properties.CONNECTION_TIMEOUT.getCode(), String.valueOf(this.connectionTimeout));
rslt.put(Properties.KEEP_ALIVE_INTERVAL.getCode(), String.valueOf(this.keepAliveInterval));
return null;
}
@Getter
public enum Properties {
SERVER_URI("serverUri", "MQTT Broker 服务的 TCP/IP 地址", "tcp://localhost:1883"),
USERNAME("username", "通过发送用户名和密码来进行相关的认证和授权", null),
PASSWORD("password", "通过发送用户名和密码来进行相关的认证和授权", null),
CLIENT_ID("clientId", "服务端使用 ClientId 识别客户端。连接服务端的每个客户端都有唯一的 ClientId", "keycloak"),
AUTOMATIC_RECONNECT("automaticReconnect", "自动重新连接", true),
CLEAN_SESSION("cleanSession", "客户端和服务端可以保存会话状态,以支持跨网络连接的可靠消息传输,这个标志告诉服务器这次连接是不是一个全新的连接", true),
CONNECTION_TIMEOUT("connectionTimeout", "以秒为单位,连接超时时间", 10),
KEEP_ALIVE_INTERVAL("keepAliveInterval", "以秒为单位的时间间隔,它是指在客户端传输完成一个控制报文的时刻到发送下一个报文的时刻,两者之间允许空闲的最大时间间隔", 60),
;
private final String code;
private final String helpText;
private final Object defaultValue;
private Properties(String code, String helpText, Object defaultValue) {
this.code = code;
this.helpText = helpText;
this.defaultValue = defaultValue;
}
public String resolveConfigVar(final Scope config) {
String value = this.defaultValue == null ? null : this.defaultValue.toString();
if (config != null && config.get(this.code) != null) {
value = config.get(this.code);
} else {
// 尝试从环境变量中读取配置信息,如: S8D_MQTT_SERVERURI:
String envVariableName = "MQTT_" + this.code.toUpperCase(Locale.ENGLISH);
String env = System.getenv(envVariableName);
if (env != null) {
value = env;
}
}
return value;
}
/**
* 读取参数信息
*
* @param config
* @return
*/
public static ConfigProperties configProperties(final Scope config) {
final String serverUri = SERVER_URI.resolveConfigVar(config);
final String username = USERNAME.resolveConfigVar(config);
final String password = PASSWORD.resolveConfigVar(config);
final boolean automaticReconnect = Boolean
.parseBoolean(AUTOMATIC_RECONNECT.resolveConfigVar(config));
final boolean cleanSession = Boolean.parseBoolean(CLEAN_SESSION.resolveConfigVar(config));
final int connectionTimeout = Integer.parseInt(CONNECTION_TIMEOUT.resolveConfigVar(config));
final int keepAliveInterval = Integer.parseInt(KEEP_ALIVE_INTERVAL.resolveConfigVar(config));
final String clientId = CLIENT_ID.resolveConfigVar(config);
return new ConfigProperties(serverUri, username, password, clientId,
automaticReconnect, cleanSession, connectionTimeout, keepAliveInterval);
}
}
}
定义logger插件
logger插件并不会将消息发送给MQTT服务,只是消息输入到日期,一般情况下用于调试或测试。
服务接口实现:
package cn.dubhe.keycloak.mqtt;
import lombok.extern.slf4j.Slf4j;
/**
* 模拟服务,将消息显示到控制台
*
* @author PinWei Wan
* @since 17.0.1
*/
@Slf4j
public class LoggerPublisherService implements PublisherService {
@Override
public void close() {
}
@Override
public void publish(String topic, String message) {
log.info("***** SIMULATION MODE ***** Would publish to MTQQ server with topic {} and message: {}", topic, message);
}
}
服务提供者工厂
package cn.dubhe.keycloak.mqtt;
import java.util.Map;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* 日志的服务提供者工厂
*
* @author PinWei Wan
* @since 17.0.1
*/
public class LoggerPublisherServiceProviderFactory implements PublisherServiceProviderFactory {
private static final PublisherService SINGLETON = new LoggerPublisherService();
@Override
public PublisherService create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "logger";
}
@Override
public Map<String, String> getOperationalInfo() {
return null;
}
}
定义mqtt插件
这个插件才是将消息发送给MQTT服务的
服务接口实现
package cn.dubhe.keycloak.mqtt;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import lombok.extern.slf4j.Slf4j;
/**
* 默认的服务
*
* @author PinWei Wan
* @since 17.0.1
*/
@Slf4j
public class DefaultPublisherService implements PublisherService {
private final ConfigProperties properties;
public DefaultPublisherService(ConfigProperties properties) {
this.properties = properties;
}
@Override
public void close() {
}
@Override
public void publish(String topic, String message) {
if (log.isDebugEnabled()) {
log.debug("Send message to MQTT server, topic:{}, message:{}", topic, message);
}
if (Objects.isNull(topic) || Objects.isNull(message)) {
throw new IllegalArgumentException("Parameters 'topci' and 'message' are required");
}
try {
MqttClient client = properties.mqttClient();
MqttConnectOptions options = properties.mqttConnectOptions();
client.connect(options);
MqttMessage payload = toPayload(message);
payload.setQos(0);
payload.setRetained(true);
client.publish(topic, payload);
client.disconnect();
} catch (Exception e) {
throw new MqttPublishException(e);
}
}
private MqttMessage toPayload(String s) {
byte[] payload = s.getBytes(StandardCharsets.UTF_8);
return new MqttMessage(payload);
}
}
服务提供者工厂
package cn.dubhe.keycloak.mqtt;
import java.util.Map;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* 默认的服务提供者工厂
*
* @author PinWei Wan
* @since 17.0.1
*/
public class DefaultPublisherServiceProviderFactory implements PublisherServiceProviderFactory {
private ConfigProperties properties;
private static PublisherService SINGLETON = null;
@Override
public PublisherService create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Scope config) {
properties = ConfigProperties.create(config);
SINGLETON = new DefaultPublisherService(properties);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "mqtt";
}
@Override
public Map<String, String> getOperationalInfo() {
return properties.toMap();
}
}
服务配置
在src\main\resources\META-INF\services的目录下,创建两个文件:cn.dubhe.keycloak.mqtt.PublisherServiceProviderFactory和org.keycloak.provider.Spi,内容分别是:
cn.dubhe.keycloak.mqtt.PublisherServiceProviderFactory
cn.dubhe.keycloak.mqtt.DefaultPublisherServiceProviderFactory
cn.dubhe.keycloak.mqtt.LoggerPublisherServiceProviderFactory
org.keycloak.provider.Spi
cn.dubhe.keycloak.mqtt.MqttPublisherSpi
至此,代码已经完成了
运行服务
你可以将此插件部署到Keycloak Server中。在这里我们将插件部署到内嵌的keycloak中,参考文章[Keycloak] - 基于Spring Boot框架的,内嵌式的服务
在springboot-embedded-server项目中,需要配置如下:
配置logger插件
keycloak:
mqttPublisher:
provider: "logger"
配置mqtt插件
keycloak:
mqttPublisher:
provider: mqtt
serverUri: tcp://localhost:1883 # 必须的,MQTT服务地址
username: xxx # 可选的,登录用户名
password: xxxx # 可选的,登录密码
clientId: xxxxx # 必须的,客户端ID, 默认:keycloak
automaticReconnect: true # 可选的,自动连接,默认:true
cleanSession: true # 可选的,保存会话状态,默认:true
connectionTimeout: 10 # 可选的,连接超时时间,单位秒,默认:10
keepAliveInterval: 60 # 可选的,保持连接时间,单位秒,默认:60
上面的配置,任选其一即可
登录项目
在游览器中输入地址:http://localhost:9000/auth 然后使用admin/admin登录即可
在Providers页签中就可以找到:
注意下,我使用logger插件
总结
完整的项目可以在Gitee找到,欢迎下载。