feat(qrlogin): 实现基于二维码的Keycloak登录功能- 添加二维码登录核心类,包括会话管理、签名验证和二维码生成

- 实现Keycloak身份提供者SPI,支持二维码登录流程
- 集成ZXing库用于二维码生成和解析
- 添加基于内存和Redis的会话存储实现
- 实现HMAC-SHA256签名算法用于请求验证
- 添加OpenAPI文档定义二维码登录接口规范- 配置Maven构建文件,包含必要的依赖和插件
- 添加IDE配置文件和项目忽略文件
- 实现JWT令牌验证和用户身份认证
- 添加会话过期清理机制和线程安全存储
This commit is contained in:
huiyiruciduojiao
2025-11-03 00:00:56 +08:00
commit 22edd9c9eb
34 changed files with 2072 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
# 项目排除路径
/out/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

25
.idea/artifacts/KeycloakQRLogin_jar.xml generated Normal file
View File

@@ -0,0 +1,25 @@
<component name="ArtifactManager">
<artifact type="jar" name="KeycloakQRLogin:jar">
<output-path>$PROJECT_DIR$/out/artifacts/KeycloakQRLogin_jar</output-path>
<root id="archive" name="KeycloakQRLogin.jar">
<element id="module-output" name="KeycloakQRLogin" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/zxing/core/3.5.3/core-3.5.3.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-handler/4.1.107.Final/netty-handler-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-resolver/4.1.107.Final/netty-resolver-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/projectreactor/reactor-core/3.6.4/reactor-core-3.6.4.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-databind/2.17.2/jackson-databind-2.17.2.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-transport/4.1.107.Final/netty-transport-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/zxing/javase/3.5.3/javase-3.5.3.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/beust/jcommander/1.82/jcommander-1.82.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-codec/4.1.107.Final/netty-codec-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/lettuce/lettuce-core/6.3.2.RELEASE/lettuce-core-6.3.2.RELEASE.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-buffer/4.1.107.Final/netty-buffer-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/github/jai-imageio/jai-imageio-core/1.4.0/jai-imageio-core-1.4.0.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-common/4.1.107.Final/netty-common-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-transport-native-unix-common/4.1.107.Final/netty-transport-native-unix-common-4.1.107.Final.jar" path-in-jar="/" />
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar" path-in-jar="/" />
</root>
</artifact>
</component>

7
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>qrlogin</w>
</words>
</dictionary>
</component>

7
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

14
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
.idea/swagger-settings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwaggerSettings">
<option name="defaultPreviewType" value="REDOC" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

84
pom.xml Normal file
View File

@@ -0,0 +1,84 @@
<?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/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>top.ysit</groupId>
<artifactId>KeycloakQRLogin</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<keycloak.version>26.2.4</keycloak.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Keycloak Server SPI -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- JAX-RS, JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<!-- QR 码生成 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,99 @@
package top.ysit.qrlogin.config;
import org.keycloak.models.IdentityProviderModel;
import top.ysit.qrlogin.core.security.SignatureConfig;
public class QRLoginConfig extends IdentityProviderModel {
public static final String PROVIDER_ID = "qrlogin";
private final IdentityProviderModel model;
public QRLoginConfig(IdentityProviderModel model) {
this.model = model;
}
public int getSessionTtlSeconds() { // 二维码有效期
return getInt("sessionTtlSeconds", 120);
}
public int getPollIntervalMs() { // 轮询间隔(前端提示用)
return getInt("pollIntervalMs", 1500);
}
public String getStoreType() { // redis | memory
return get("storeType", "redis");
}
public String getRedisUri() { // redis://host:port
return get("redisUri", "redis://127.0.0.1:6379");
}
public String getRedisNamespace() { // key 前缀
return get("redisNamespace", "qrlogin:");
}
public String getHmacSecret() { // App 签名校验
return get("hmacSecret", "change-me");
}
public long getTimeWindowSeconds() {
return getLong("timeWindowSeconds", 5);
}
public SignatureConfig.SignatureAlgorithm getAlgorithm(){
return getAlgorithm("algorithm", SignatureConfig.SignatureAlgorithm.HMAC_SHA256);
}
private String get(String k, String def) {
String v = model.getConfig() == null ? null : model.getConfig().get(k);
return v == null || v.isBlank() ? def : v;
}
private int getInt(String k, int def) {
try {
return Integer.parseInt(get(k, String.valueOf(def)));
} catch (Exception e) {
return def;
}
}
private long getLong(String k, long def) {
try {
return Long.parseLong(get(k, String.valueOf(def)));
} catch (Exception e) {
return def;
}
}
private SignatureConfig.SignatureAlgorithm getAlgorithm(String k, SignatureConfig.SignatureAlgorithm def) {
try {
String algorithmName = get(k, def.name());
try {
return SignatureConfig.SignatureAlgorithm.valueOf(algorithmName);
} catch (IllegalArgumentException e) {
// 如果失败按algorithmName字段值查找
for (SignatureConfig.SignatureAlgorithm algorithm : SignatureConfig.SignatureAlgorithm.values()) {
if (algorithm.getAlgorithmName().equals(algorithmName)) {
return algorithm;
}
}
return def;
}
} catch (Exception e) {
System.out.println("error" + e);
return def;
}
}
@Override
public String getAlias() {
return model.getAlias();
}
@Override
public String getProviderId() {
return PROVIDER_ID;
}
}

View File

@@ -0,0 +1,89 @@
package top.ysit.qrlogin.core;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.time.Instant;
public class QRSession {
private String sessionId;
private QRSessionStatus status;
private String userId;
private String kcSessionId;
private Instant expireAt; // epoch millis
private Instant createdAt;
private String email;
private AuthenticationSessionModel authSession;
private String responseUrl;
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getKcSessionId() {
return kcSessionId;
}
public void setKcSessionId(String kcSessionId) {
this.kcSessionId = kcSessionId;
}
public QRSessionStatus getStatus() {
return status;
}
public void setStatus(QRSessionStatus status) {
this.status = status;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public Instant getExpireAt() {
return expireAt;
}
public void setExpireAt(Instant expireAt) {
this.expireAt = expireAt;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public AuthenticationSessionModel getAuthSession() {
return authSession;
}
public void setAuthSession(AuthenticationSessionModel authSession) {
this.authSession = authSession;
}
public String getResponseUrl() {
return responseUrl;
}
public void setResponseUrl(String responseUrl) {
this.responseUrl = responseUrl;
}
}

View File

@@ -0,0 +1,3 @@
package top.ysit.qrlogin.core;
public enum QRSessionStatus {PENDING, SCANNED, CONFIRMED, EXPIRED}

View File

@@ -0,0 +1,18 @@
package top.ysit.qrlogin.core;
public interface SessionStore {
void put(QRSession s);
QRSession get(String sessionId);
void setScanned(String sessionId);
void setConfirmed(String sessionId, String userId);
void setResponseUrl(String sessionId, String url);
void expire(String sessionId);
void delete(String sessionId);
}

View File

@@ -0,0 +1,73 @@
package top.ysit.qrlogin.core.security;
public class SignatureConfig {
private String secret;
private SignatureAlgorithm algorithm;
private long timeWindowSeconds;
public SignatureConfig() {
// 默认配置
this.algorithm = SignatureAlgorithm.HMAC_SHA256;
this.timeWindowSeconds = 5;
}
public SignatureConfig(String secret, SignatureAlgorithm algorithm, long timeWindowSeconds) {
this.secret = secret;
this.algorithm = algorithm;
this.timeWindowSeconds = timeWindowSeconds;
}
// Getters and Setters
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public SignatureAlgorithm getAlgorithm() {
return algorithm;
}
public void setAlgorithm(SignatureAlgorithm algorithm) {
this.algorithm = algorithm;
}
public long getTimeWindowSeconds() {
return timeWindowSeconds;
}
public void setTimeWindowSeconds(long timeWindowSeconds) {
this.timeWindowSeconds = timeWindowSeconds;
}
public enum SignatureAlgorithm {
HMAC_SHA256("HmacSHA256"),
HMAC_SHA1("HmacSHA1"),
HMAC_SHA384("HmacSHA384"),
HMAC_SHA512("HmacSHA512");
private final String algorithmName;
SignatureAlgorithm(String algorithmName) {
this.algorithmName = algorithmName;
}
public static java.util.List<String> toStringList() {
return java.util.Arrays.stream(values())
.map(SignatureAlgorithm::toString)
.toList();
}
public String getAlgorithmName() {
return algorithmName;
}
@Override
public String toString() {
return algorithmName;
}
}
}

View File

@@ -0,0 +1,128 @@
// 修改后的 SignatureUtil.java
package top.ysit.qrlogin.core.security;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SignatureException;
import java.util.*;
public class SignatureUtil {
private final SignatureConfig config;
public SignatureUtil(SignatureConfig config) throws SignatureException {
if (config == null) {
throw new SignatureException("Configuration must not be null");
}
if (config.getSecret() == null || config.getSecret().isEmpty()) {
throw new SignatureException("Secret must not be null or empty");
}
this.config = config;
}
public SignatureUtil(String secret) throws SignatureException {
this(new SignatureConfig(secret, SignatureConfig.SignatureAlgorithm.HMAC_SHA256, 5));
}
/**
* 客户端:生成签名
*/
public String sign(Map<String, String> params) throws SignatureException {
if (!params.containsKey("timestamp")) {
throw new SignatureException("Missing 'timestamp'");
}
String queryString = buildQueryString(params);
return hmacSha256(queryString, config.getSecret());
}
/**
* 服务端:验证签名 + 时间戳±timeWindowSeconds秒
*/
public boolean verify(Map<String, String> params) throws SignatureException {
String sign = params.get("sign");
if (sign == null || sign.isEmpty()) {
throw new SignatureException("Missing 'sign'");
}
// 验证时间戳
validateTimestamp(params.get("timestamp"));
// 重新计算签名(排除 sign
Map<String, String> signParams = new HashMap<>(params);
signParams.remove("sign");
String queryString = buildQueryString(signParams);
String expectedSign = hmacSha256(queryString, config.getSecret());
if (!safeEquals(sign, expectedSign)) {
throw new SignatureException("Invalid signature");
}
return true;
}
/**
* 验证时间戳是否在有效时间窗口内
* @param timestampStr 时间戳字符串(单位:秒)
* @throws SignatureException 当时间戳无效或超出时间窗口时抛出异常
*/
public void validateTimestamp(String timestampStr) throws SignatureException {
long timestampSec;
try {
timestampSec = Long.parseLong(timestampStr);
} catch (Exception e) {
throw new SignatureException("Invalid 'timestamp'", e);
}
long nowSec = System.currentTimeMillis() / 1000;
long diff = Math.abs(nowSec - timestampSec);
if (diff > config.getTimeWindowSeconds()) {
throw new SignatureException("Request expired: timestamp deviation > " +
config.getTimeWindowSeconds() + "s (now=" + nowSec + ", req=" + timestampSec + ")");
}
}
private String buildQueryString(Map<String, String> params) {
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
String value = params.getOrDefault(key, "");
if (i > 0) sb.append("&");
sb.append(key).append("=").append(value);
}
return sb.toString();
}
private String hmacSha256(String data, String key) throws SignatureException {
try {
Mac mac = Mac.getInstance(config.getAlgorithm().toString());
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), config.getAlgorithm().toString());
mac.init(secretKey);
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new SignatureException("HMAC-SHA256 failed", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 恒定时间比较(防时序攻击)
private boolean safeEquals(String a, String b) {
if (a == null || b == null) return false;
if (a.length() != b.length()) return false;
byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
return Arrays.equals(aBytes, bBytes); // 在大多数 JVM 实现中足够安全
}
}

View File

@@ -0,0 +1,11 @@
package top.ysit.qrlogin.core.security.exception;
public class SignatureException extends Exception {
public SignatureException(String message) {
super(message);
}
public SignatureException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,95 @@
package top.ysit.qrlogin.core.store;
import top.ysit.qrlogin.config.QRLoginConfig;
import top.ysit.qrlogin.core.QRSession;
import top.ysit.qrlogin.core.QRSessionStatus;
import top.ysit.qrlogin.core.SessionStore;
import java.time.Instant;
import java.util.concurrent.*;
public class InMemorySessionStore implements SessionStore {
private final ConcurrentMap<String, QRSession> map = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleaner;
// 清理周期
private final long cleanupInterval = 30; // 每 30 秒清理一次
// 默认会话有效期,单位:秒
private long sessionTimeout = 120; // 2 分钟
public InMemorySessionStore(QRLoginConfig cfg) {
this.sessionTimeout = cfg.getSessionTtlSeconds();
cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "qr-session-cleaner");
t.setDaemon(true);
return t;
});
cleaner.scheduleAtFixedRate(this::cleanupExpiredSessions,
cleanupInterval, cleanupInterval, TimeUnit.SECONDS);
}
@Override
public void put(QRSession s) {
s.setCreatedAt(Instant.now());
map.put(s.getSessionId(), s);
}
@Override
public QRSession get(String id) {
return map.get(id);
}
@Override
public void setConfirmed(String id, String email) {
map.computeIfPresent(id, (k, v) -> {
v.setStatus(QRSessionStatus.CONFIRMED);
v.setEmail(email);
return v;
});
}
@Override
public void setScanned(String id) {
map.computeIfPresent(id, (k, v) -> {
v.setStatus(QRSessionStatus.SCANNED);
return v;
});
}
@Override
public void setResponseUrl(String sessionId, String url) {
map.computeIfPresent(sessionId, (k, v) -> {
v.setResponseUrl(url);
return v;
});
}
@Override
public void expire(String id) {
map.computeIfPresent(id, (k, v) -> {
v.setStatus(QRSessionStatus.EXPIRED);
return v;
});
}
@Override
public void delete(String id) {
map.remove(id);
}
private void cleanupExpiredSessions() {
Instant now = Instant.now();
map.values().removeIf(s -> {
if (s.getStatus() == QRSessionStatus.EXPIRED) return true;
return s.getCreatedAt() != null &&
now.isAfter(s.getCreatedAt().plusSeconds(sessionTimeout));
});
}
@Override
public String toString() {
return "InMemorySessionStore{" + "sessions=" + map.size() + '}';
}
}

View File

@@ -0,0 +1,82 @@
package top.ysit.qrlogin.core.store;
import com.webauthn4j.util.exception.NotImplementedException;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import top.ysit.qrlogin.core.QRSession;
import top.ysit.qrlogin.core.QRSessionStatus;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.util.JsonUtil;
public class RedisSessionStore implements SessionStore, AutoCloseable {
private final RedisClient client;
private final StatefulRedisConnection<String, String> conn;
private final String ns;
private final int ttlSeconds;
public RedisSessionStore(String uri, String namespace, int ttlSeconds) {
this.client = RedisClient.create(uri);
this.conn = client.connect();
this.ns = namespace;
this.ttlSeconds = ttlSeconds;
}
private String key(String id) {
return ns + id;
}
@Override
public void put(QRSession s) {
RedisCommands<String, String> cmd = conn.sync();
cmd.setex(key(s.getSessionId()), ttlSeconds, JsonUtil.toJson(s));
}
@Override
public QRSession get(String id) {
String json = conn.sync().get(key(id));
return json == null ? null : JsonUtil.fromJson(json, QRSession.class);
}
@Override
public void setScanned(String id) {
QRSession s = get(id);
if (s == null) return;
s.setStatus(QRSessionStatus.SCANNED);
put(s);
}
@Override
public void setConfirmed(String id, String userId) {
QRSession s = get(id);
if (s == null) return;
s.setStatus(QRSessionStatus.CONFIRMED);
s.setUserId(userId);
put(s);
}
@Override
public void setResponseUrl(String sessionId, String url) {
throw new NotImplementedException("Not Impl");
}
@Override
public void expire(String id) {
QRSession s = get(id);
if (s == null) return;
s.setStatus(QRSessionStatus.EXPIRED);
put(s);
}
@Override
public void delete(String id) {
conn.sync().del(key(id));
}
@Override
public void close() {
conn.close();
client.shutdown();
}
}

View File

@@ -0,0 +1,35 @@
package top.ysit.qrlogin.core.util;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class CryptoUtil {
public static String hmacSha256Base64(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 验证签名
* @param data 数据
* @param signature 签名
* @param secret 密钥
* @return 验证结果
*/
public static boolean verifyHmacSha256Base64(String data, String signature, String secret) {
try {
String expectedSignature = hmacSha256Base64(data, secret);
return expectedSignature.equals(signature);
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
package top.ysit.qrlogin.core.util;
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonUtil {
private static final ObjectMapper MAPPER = new ObjectMapper();
public static String toJson(Object o){ try { return MAPPER.writeValueAsString(o);} catch (Exception e){ throw new RuntimeException(e);} }
public static <T> T fromJson(String s, Class<T> c){ try { return MAPPER.readValue(s,c);} catch (Exception e){ throw new RuntimeException(e);} }
}

View File

@@ -0,0 +1,22 @@
package top.ysit.qrlogin.core.util;
import com.google.zxing.*;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
public class QRCodeUtil {
public static String toDataUrl(String text, int size) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size);
BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", baos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
}
}
}

View File

@@ -0,0 +1,55 @@
package top.ysit.qrlogin.core.util;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderStorageProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import top.ysit.qrlogin.config.QRLoginConfig;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.store.InMemorySessionStore;
import top.ysit.qrlogin.core.store.RedisSessionStore;
import top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory;
public class QRLoginStoreUtil {
private static SessionStore sharedStore;
/**
* 获取共享的 SessionStore 实例
*/
public static SessionStore getSharedSessionStore(KeycloakSession session, RealmModel realm) {
if (sharedStore == null) {
IdentityProviderModel idpModel = getIdentityProviderModel(session);
if (idpModel != null) {
QRLoginConfig cfg = new QRLoginConfig(idpModel);
sharedStore = createStore(cfg);
}
}
return sharedStore;
}
public static IdentityProviderModel getIdentityProviderModel(KeycloakSession session) {
return session.getProvider(IdentityProviderStorageProvider.class).getByAlias(QRLoginIdentityProviderFactory.PROVIDER_ID);
}
public static QRLoginConfig getQRLoginConfig(KeycloakSession session) {
return new QRLoginConfig(getIdentityProviderModel(session));
}
/**
* 根据配置创建 SessionStore 实例
*/
public static SessionStore createStore(QRLoginConfig cfg) {
System.out.println("cfg.getStoreType() = " + cfg.getStoreType());
if ("memory".equalsIgnoreCase(cfg.getStoreType())) {
return new InMemorySessionStore(cfg);
}
return new RedisSessionStore(cfg.getRedisUri(), cfg.getRedisNamespace(), cfg.getSessionTtlSeconds());
}
}

View File

@@ -0,0 +1,137 @@
package top.ysit.qrlogin.core.util;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class TokenUtil {
private static final String KEYCLOAK_URL = "http://localhost:8080";
private static final String REALM = "master";
private static final String CLIENT_ID = "test001";
private static final String CLIENT_SECRET = "JEKhtMyFAic68UdA8CRXChE1OtDOwHzh";
public static TokenValidationResult verifyToken(String token) {
if (token == null || token.isEmpty()) {
return TokenValidationResult.invalid("token 为空或无效");
}
try {
// 构造 introspect URL
String urlString = KEYCLOAK_URL + "/realms/" + REALM + "/protocol/openid-connect/token/introspect";
URL url = new URL(urlString);
// 设置请求
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setDoOutput(true);
String body = String.format("client_id=%s&client_secret=%s&token=%s", CLIENT_ID, CLIENT_SECRET, token);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
JsonObject json = Json.createReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)).readObject();
boolean active = json.getBoolean("active", false);
if (!active) {
return TokenValidationResult.invalid("Token is not active");
}
// 安全提取 exp 字段
Long exp = null;
if (json.containsKey("exp") && !json.isNull("exp")) {
exp = json.getJsonNumber("exp").longValue();
}
// 提取其他信息
String username = json.getString("username", null);
String clientId = json.getString("client_id", null);
List<String> scope = parseScope(json.getString("scope", null));
// 解析token内容获取email
JsonObject payload = tokenToJson(token);
String email = payload.getString("email", null);
String sub = json.getString("sub", null);
return TokenValidationResult.valid(username, clientId, email, sub, scope, exp);
} catch (Exception e) {
return TokenValidationResult.error("验证异常: " + e.getMessage());
}
}
private static List<String> parseScope(String scope) {
if (scope == null || scope.trim().isEmpty()) {
return Collections.emptyList();
}
return Arrays.asList(scope.split(" "));
}
public static JsonObject tokenToJson(String token) {
if (token == null || token.isEmpty()) {
return null;
}
try {
// JWT token 由三部分组成用点分隔header.payload.signature
String[] parts = token.split("\\.");
if (parts.length < 2) {
return null;
}
// 解码 payload 部分(第二部分)
String payload = parts[1];
// 补充可能缺失的填充字符
switch (payload.length() % 4) {
case 2:
payload += "==";
break;
case 3:
payload += "=";
break;
}
// Base64 解码
byte[] decodedBytes = java.util.Base64.getDecoder().decode(payload);
String decodedString = new String(decodedBytes, StandardCharsets.UTF_8);
// 解析为 JsonObject
return Json.createReader(new java.io.StringReader(decodedString)).readObject();
} catch (Exception e) {
// 解析失败时返回 null
return null;
}
}
public record TokenValidationResult(boolean valid, String error, String username, String clientId, String email,
String sub, List<String> scope, Long expiration) {
public static TokenValidationResult valid(String username, String clientId, String email, String sub, List<String> scope, Long expiration) {
System.out.println("valid" + username + " " + clientId + " " + email + " " + scope + " " + expiration);
return new TokenValidationResult(true, null, username, clientId, email, sub, scope, expiration);
}
public static TokenValidationResult invalid(String error) {
return new TokenValidationResult(false, error, null, null, null, null, null, null);
}
public static TokenValidationResult error(String error) {
return new TokenValidationResult(false, error, null, null, null, null, null, null);
}
}
}

View File

@@ -0,0 +1,160 @@
package top.ysit.qrlogin.idp;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.utils.MediaType;
import top.ysit.qrlogin.config.QRLoginConfig;
import top.ysit.qrlogin.core.QRSession;
import top.ysit.qrlogin.core.QRSessionStatus;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.security.SignatureConfig;
import top.ysit.qrlogin.core.security.SignatureUtil;
import top.ysit.qrlogin.core.util.QRCodeUtil;
import top.ysit.qrlogin.core.util.TokenUtil;
import top.ysit.qrlogin.idp.resource.factory.QRLoginEndpointProviderFactory;
import java.security.SignatureException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class QRLoginIdentityProvider extends AbstractIdentityProvider<IdentityProviderModel> implements IdentityProvider<IdentityProviderModel> {
private final QRLoginConfig cfg;
private final SessionStore store;
private final SignatureUtil signatureUtil;
public QRLoginIdentityProvider(KeycloakSession session, QRLoginConfig cfg, SessionStore store) {
super(session, cfg);
this.cfg = cfg;
this.store = store;
SignatureConfig signatureConfig = new SignatureConfig(cfg.getHmacSecret(), cfg.getAlgorithm(), cfg.getTimeWindowSeconds());
try {
this.signatureUtil = new SignatureUtil(signatureConfig);
} catch (SignatureException e) {
throw new RuntimeException(e);
}
}
@Override
public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) {
return new Endpoint(callback, realm, event, this);
}
/**
* 在登录页渲染“使用二维码登录”按钮;点击后进入我们的 iframe 页面。
*/
@Override
public Response performLogin(AuthenticationRequest request) {
AuthenticationSessionModel authSession = request.getAuthenticationSession();
String sessionId = UUID.randomUUID().toString();
QRSession s = new QRSession();
s.setSessionId(sessionId);
s.setStatus(QRSessionStatus.PENDING);
s.setExpireAt(Instant.now().plusSeconds(cfg.getSessionTtlSeconds()));
s.setCreatedAt(Instant.now());
s.setKcSessionId(authSession.getParentSession().getId());
s.setAuthSession(authSession);
store.put(s);
String baseUrl = session.getContext().getUri().getBaseUri().toString();
// 构造请求地址
String confirmUrl = baseUrl + "realms/" + request.getRealm().getName() + "/" + QRLoginEndpointProviderFactory.ID + "/" + "qr/confirm?qr_session=" + s.getSessionId() + "&kc_session=" + authSession.getParentSession().getId();
String checkUrl = baseUrl + "realms/" + request.getRealm().getName() + "/" + QRLoginEndpointProviderFactory.ID + "/" + "qr/status" + "?qr_session=" + s.getSessionId() + "&kc_session=" + authSession.getParentSession().getId();
String imageData;
try {
imageData = QRCodeUtil.toDataUrl(confirmUrl, 512);
} catch (Exception e) {
throw new RuntimeException(e);
}
return Response.ok(Map.of("qr_session", s.getSessionId(),
"kc_session", authSession.getParentSession().getId(),
"qr_image_data", imageData,
"confirm", confirmUrl,
"statusUrl", checkUrl,
"ttl", cfg.getSessionTtlSeconds(),
"interval", cfg.getPollIntervalMs()
))
.type(MediaType.APPLICATION_JSON).build();
}
@Override
public Response retrieveToken(KeycloakSession keycloakSession, FederatedIdentityModel federatedIdentityModel) {
return null;
}
protected static class Endpoint {
protected AuthenticationCallback callback;
protected RealmModel realm;
protected EventBuilder event;
protected QRLoginIdentityProvider qrIdp;
public Endpoint(AuthenticationCallback callback, RealmModel realm, EventBuilder event, QRLoginIdentityProvider qrIdp) {
this.callback = callback;
this.realm = realm;
this.event = event;
this.qrIdp = qrIdp;
}
@GET
public Response authResponse(@QueryParam("kc_session") String kcSessionId, @QueryParam("qr_session") String qrSessionId, @QueryParam("token") String token, @QueryParam("timestamp") String timestamp, @QueryParam("sign") String sign) {
if (token == null || timestamp == null || sign == null || qrSessionId == null || kcSessionId == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
Map<String, String> params = new HashMap<>();
params.put("qr_session", qrSessionId);
params.put("kc_session", kcSessionId);
params.put("token", token);
params.put("timestamp", timestamp);
params.put("sign", sign);
try {
qrIdp.signatureUtil.verify(params);
} catch (SignatureException e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
QRSession qrs = qrIdp.store.get(qrSessionId);
if (qrs == null || !qrs.getKcSessionId().equals(kcSessionId) || qrs.getStatus() != QRSessionStatus.CONFIRMED) {;
return Response.status(Response.Status.NOT_FOUND).build();
}
TokenUtil.TokenValidationResult tokenValidationResult = TokenUtil.verifyToken(token);
if (!tokenValidationResult.valid()) {;
return Response.status(Response.Status.NOT_FOUND).build();
}
BrokeredIdentityContext federatedIdentity = new BrokeredIdentityContext(tokenValidationResult.sub(), qrIdp.getConfig());
federatedIdentity.setIdp(qrIdp);
federatedIdentity.setUsername(tokenValidationResult.username());
federatedIdentity.setEmail(tokenValidationResult.email());
federatedIdentity.setAuthenticationSession(qrs.getAuthSession());
return callback.authenticated(federatedIdentity);
}
}
}

View File

@@ -0,0 +1,124 @@
package top.ysit.qrlogin.idp;
import org.keycloak.Config.Scope;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import top.ysit.qrlogin.config.QRLoginConfig;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.security.SignatureConfig;
import top.ysit.qrlogin.core.util.QRLoginStoreUtil;
import java.util.*;
public class QRLoginIdentityProviderFactory extends AbstractIdentityProviderFactory<QRLoginIdentityProvider> {
public static final String PROVIDER_ID = "qrlogin";
@Override
public String getName() {
return "QR Login";
}
@Override
public QRLoginIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
// 直接实例化,不依赖注入
SessionStore store = QRLoginStoreUtil.getSharedSessionStore(session, session.getContext().getRealm());
QRLoginConfig config = new QRLoginConfig(model);
return new QRLoginIdentityProvider(session, config, store);
}
@Override
public QRLoginIdentityProvider create(KeycloakSession session) {
IdentityProviderModel model = createConfig();
QRLoginConfig config = new QRLoginConfig(model);
SessionStore store = QRLoginStoreUtil.getSharedSessionStore(session, session.getContext().getRealm());
return new QRLoginIdentityProvider(session, config, store);
}
@Override
public Map<String, String> parseConfig(KeycloakSession session, String configJson) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to parse provider config JSON", e);
}
}
@Override
public IdentityProviderModel createConfig() {
// 返回带有默认配置的 IdentityProviderModel
IdentityProviderModel model = new IdentityProviderModel();
model.setConfig(getDefaultConfig());
return model;
}
private Map<String, String> getDefaultConfig() {
Map<String, String> config = new HashMap<>();
config.put("sessionTtlSeconds", "120");
config.put("pollIntervalMs", "1500");
config.put("storeType", "memory");
config.put("redisUri", "redis://127.0.0.1:6379");
config.put("redisNamespace", "qrlogin:");
config.put("hmacSecret", "change-me");
config.put("algorithm", SignatureConfig.SignatureAlgorithm.HMAC_SHA256.toString());
config.put("timeWindowSeconds", "5");
return config;
}
@Override
public void init(Scope config) {
// 初始化逻辑
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 后初始化逻辑
}
@Override
public void close() {
// 清理资源
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> props = new ArrayList<>();
props.add(prop("sessionTtlSeconds", "Session TTL (s)", "二维码会话有效期(秒)", ProviderConfigProperty.STRING_TYPE, "120"));
props.add(prop("pollIntervalMs", "Poll Interval (ms)", "前端轮询间隔(毫秒)", ProviderConfigProperty.STRING_TYPE, "1500"));
props.add(prop("storeType", "Store Type", "redis 或 memory", "memory", Arrays.asList("redis", "memory")));
props.add(prop("redisUri", "Redis URI", "如 redis://127.0.0.1:6379", ProviderConfigProperty.STRING_TYPE, "redis://127.0.0.1:6379"));
props.add(prop("redisNamespace", "Redis Namespace", "键前缀", ProviderConfigProperty.STRING_TYPE, "qrlogin:"));
props.add(prop("hmacSecret", "HMAC Secret", "App 签名校验秘钥", ProviderConfigProperty.PASSWORD, "change-me"));
props.add(prop("algorithm", "Algorithm", "签名算法", SignatureConfig.SignatureAlgorithm.HMAC_SHA256.toString(), SignatureConfig.SignatureAlgorithm.toStringList()));
props.add(prop("timeWindowSeconds", "Time Window Seconds", "请求有效时间范围(秒)", ProviderConfigProperty.INTEGER_TYPE,"5"));
return props;
}
private ProviderConfigProperty prop(String name, String label, String help, String type, String def) {
ProviderConfigProperty p = new ProviderConfigProperty();
p.setName(name);
p.setLabel(label);
p.setHelpText(help);
p.setType(type);
p.setDefaultValue(def);
return p;
}
private ProviderConfigProperty prop(String name, String label, String help, String def, List<String> options) {
ProviderConfigProperty p = prop(name, label, help, ProviderConfigProperty.LIST_TYPE, def);
p.setOptions(options);
return p;
}
}

View File

@@ -0,0 +1,235 @@
package top.ysit.qrlogin.idp.resource.endpoint;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.utils.MediaType;
import top.ysit.qrlogin.config.QRLoginConfig;
import top.ysit.qrlogin.core.QRSession;
import top.ysit.qrlogin.core.QRSessionStatus;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.security.SignatureConfig;
import top.ysit.qrlogin.core.security.SignatureUtil;
import top.ysit.qrlogin.core.util.TokenUtil;
import top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory;
import java.net.URI;
import java.security.SignatureException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
public class QRLoginEndpoint extends RealmsResource implements RealmResourceProvider {
private final KeycloakSession session;
private final SessionStore store;
private final SignatureUtil signatureUtil;
private final QRLoginConfig qrLoginConfig;
public QRLoginEndpoint(KeycloakSession session, EventBuilder event, SessionStore store, QRLoginConfig qrLoginConfig) throws SignatureException {
this.session = session;
this.store = store;
this.qrLoginConfig = qrLoginConfig;
SignatureConfig config = new SignatureConfig(
qrLoginConfig.getHmacSecret(),
qrLoginConfig.getAlgorithm(),
qrLoginConfig.getTimeWindowSeconds()
);
this.signatureUtil = new SignatureUtil(config);
}
@POST
@Path("qr/scan")
@Consumes(MediaType.APPLICATION_JSON)
public Response scan(Map<String, String> body) {
ValidationResult result = validateRequest(body);
if (!result.valid()) {
return Response.ok(
Map.of("error", result.errorMessage()),
MediaType.APPLICATION_JSON
).build();
}
if (result.qrSessionObj().getStatus() != QRSessionStatus.PENDING) {
return Response.ok(
Map.of("error", "Qr not found"),
MediaType.APPLICATION_JSON
).build();
}
// 确认特定逻辑
this.store.setScanned(result.qrSession());
return Response.ok(Map.of("status", "ok")).build();
}
@POST
@Path("qr/confirm")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response confirm(Map<String, String> body) {
ValidationResult result = validateRequest(body);
if (!result.valid()) {
return Response.ok(
Map.of("error", result.errorMessage()),
MediaType.APPLICATION_JSON
).build();
}
if (result.qrSessionObj().getStatus() != QRSessionStatus.SCANNED) {
return Response.ok(
Map.of("error", "Qr not found"),
MediaType.APPLICATION_JSON
).build();
}
RealmModel realm = session.getContext().getRealm();
String providerAlias = QRLoginIdentityProviderFactory.PROVIDER_ID;
//签名参数
long timestamp = System.currentTimeMillis() / 1000;
Map<String, String> params = new HashMap<>();
params.put("qr_session", result.qrSession);
params.put("kc_session", result.kcSession);
params.put("token", body.get("token"));
params.put("timestamp", String.valueOf(timestamp));
String signature;
try {
signature = signatureUtil.sign(params);
} catch (SignatureException e) {
return Response.ok(
Map.of("error", "Signature error"),
MediaType.APPLICATION_JSON
).build();
}
URI base = session.getContext().getUri().getBaseUri();
URI callbackUri = UriBuilder.fromUri(base)
.path("realms")
.path(realm.getName())
.path("broker")
.path(providerAlias)
.path("endpoint")
.queryParam("qr_session", result.qrSession)
.queryParam("kc_session", result.kcSession)
.queryParam("token", body.get("token"))
.queryParam("timestamp", timestamp)
.queryParam("sign", signature)
.build();
String callbackUrl = callbackUri.toString();
this.store.setResponseUrl(result.qrSession(), callbackUrl);
this.store.setConfirmed(result.qrSession(), result.tokenResult().email());
return Response.ok(Map.of("status", "ok")).build();
}
@GET
@Path("qr/status")
@Produces(MediaType.APPLICATION_JSON)
public Response status(@QueryParam("kc_session") String kcSession, @QueryParam("qr_session") String qrSession, @QueryParam("timestamp") String timestamp) {
if (kcSession == null || qrSession == null || timestamp == null) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
try {
signatureUtil.validateTimestamp(timestamp);
} catch (SignatureException e) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
QRSession qrs = this.store.get(qrSession);
if (qrs == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
if (!kcSession.equals(qrs.getKcSessionId())) {
return Response.status(Response.Status.FORBIDDEN).build();
}
if (Instant.now().isAfter(qrs.getExpireAt())) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(Map.of(
"status", qrs.getStatus(),
"url", qrs.getStatus() == QRSessionStatus.CONFIRMED ? qrs.getResponseUrl() : ""
)).build();
}
// 在 QRLoginEndpoint 类中添加以下私有方法
private ValidationResult validateRequest(Map<String, String> body) {
if (body == null || body.get("timestamp") == null) {
return ValidationResult.error("Invalid request body");
}
String kcSession = body.get("kc_session");
String qrSession = body.get("qr_session");
Long timestamp = Long.parseLong(body.get("timestamp"));
String sign = body.get("sign");
String token = body.get("token");
if (kcSession == null || qrSession == null || sign == null || token == null) {
return ValidationResult.error("Invalid request body");
}
Map<String, String> params = new HashMap<>();
params.put("qr_session", qrSession);
params.put("kc_session", kcSession);
params.put("token", token);
params.put("timestamp", String.valueOf(timestamp));
params.put("sign", sign);
try {
if (!signatureUtil.verify(params)) {
return ValidationResult.error("Invalid signature");
}
} catch (SignatureException e) {
throw new RuntimeException(e);
}
QRSession qrs = this.store.get(qrSession);
if (qrs == null) {
return ValidationResult.error("QR session not found");
}
if (!kcSession.equals(qrs.getKcSessionId())) {
return ValidationResult.error("Invalid kcSession");
}
TokenUtil.TokenValidationResult tokenValidationResult = TokenUtil.verifyToken(token);
if (!tokenValidationResult.valid()) {
return ValidationResult.error("Invalid token");
}
return ValidationResult.success(kcSession, qrSession, qrs, tokenValidationResult);
}
@Override
public Object getResource() {
return this;
}
@Override
public void close() {
}
private record ValidationResult(boolean valid, String errorMessage, String kcSession, String qrSession,
QRSession qrSessionObj, TokenUtil.TokenValidationResult tokenResult) {
public static ValidationResult error(String errorMessage) {
return new ValidationResult(false, errorMessage, null, null, null, null);
}
public static ValidationResult success(String kcSession, String qrSession, QRSession qrSessionObj, TokenUtil.TokenValidationResult tokenResult) {
return new ValidationResult(true, null, kcSession, qrSession, qrSessionObj, tokenResult);
}
}
}

View File

@@ -0,0 +1,54 @@
package top.ysit.qrlogin.idp.resource.factory;
import org.keycloak.Config;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import top.ysit.qrlogin.core.SessionStore;
import top.ysit.qrlogin.core.util.QRLoginStoreUtil;
import top.ysit.qrlogin.idp.resource.endpoint.QRLoginEndpoint;
import java.security.SignatureException;
public class QRLoginEndpointProviderFactory implements RealmResourceProviderFactory {
public static final String ID = "qr-login-endpoint";
@Override
public RealmResourceProvider create(KeycloakSession keycloakSession) {
KeycloakContext context = keycloakSession.getContext();
RealmModel realm = context.getRealm();
EventBuilder event = new EventBuilder(realm, keycloakSession, context.getConnection());
SessionStore store = QRLoginStoreUtil.getSharedSessionStore(keycloakSession, realm);
try {
return new QRLoginEndpoint(keycloakSession, event, store,QRLoginStoreUtil.getQRLoginConfig(keycloakSession));
} catch (SignatureException e) {
throw new RuntimeException(e);
}
}
@Override
public void init(Config.Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return ID;
}
}

View File

@@ -0,0 +1,4 @@
package top.ysit.qrlogin.spi;
public class KeycloakLifecycleListener {
}

View File

@@ -0,0 +1 @@
top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory

View File

@@ -0,0 +1 @@
top.ysit.qrlogin.idp.resource.factory.QRLoginEndpointProviderFactory

View File

@@ -0,0 +1,173 @@
openapi: 3.0.3
info:
title: Keycloak QR Login API
version: 0.1.0
servers:
- url: /realms/{realm}/qrlogin/endpoint
variables:
realm:
default: master
description: Keycloak realm name
components:
schemas:
ValidationError:
type: object
properties:
error:
type: string
description: 错误信息
example:
error: "Invalid request body"
StatusResponse:
type: object
properties:
status:
type: string
enum: [PENDING, SCANNED, CONFIRMED, EXPIRED]
url:
type: string
description: 确认后的回调URL
ScanRequest:
type: object
required: [kc_session, qr_session, timestamp, sign, token]
properties:
kc_session:
type: string
description: Keycloak会话ID
qr_session:
type: string
description: QR会话ID
timestamp:
type: integer
description: 时间戳(秒)
sign:
type: string
description: 请求签名
token:
type: string
description: JWT Token
ConfirmRequest:
type: object
required: [kc_session, qr_session, timestamp, sign, token]
properties:
kc_session:
type: string
description: Keycloak会话ID
qr_session:
type: string
description: QR会话ID
timestamp:
type: integer
description: 时间戳(秒)
sign:
type: string
description: 请求签名
token:
type: string
description: JWT Token
ConfirmResponse:
type: object
properties:
status:
type: string
example: "ok"
error:
type: string
description: 错误信息
paths:
/qr/scan:
post:
summary: 扫描二维码
description: 用户使用App扫描二维码后调用此接口
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ScanRequest'
responses:
'200':
description: 扫描成功
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "ok"
error:
type: string
'400':
description: 请求验证失败
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
/qr/confirm:
post:
summary: 确认登录
description: 用户在App上确认登录后调用此接口
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ConfirmRequest'
responses:
'200':
description: 确认成功
content:
application/json:
schema:
$ref: '#/components/schemas/ConfirmResponse'
'400':
description: 请求验证失败
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
/qr/status:
get:
summary: 查询二维码状态
description: 前端轮询查询二维码扫描和确认状态
parameters:
- name: kc_session
in: query
required: true
schema:
type: string
description: Keycloak会话ID
- name: qr_session
in: query
required: true
schema:
type: string
description: QR会话ID
- name: timestamp
in: query
required: true
schema:
type: integer
description: 时间戳(秒)
responses:
'200':
description: 返回二维码状态
content:
application/json:
schema:
$ref: '#/components/schemas/StatusResponse'
'400':
description: 请求参数错误
'403':
description: 会话ID不匹配
'404':
description: 会话不存在或已过期

View File

@@ -0,0 +1,125 @@
document.addEventListener("DOMContentLoaded", () => {
const qrBtn = document.querySelector('a[id^="social-qrlogin"]');
if (!qrBtn) return;
qrBtn.addEventListener("click", async (e) => {
e.preventDefault();
const res = await fetch(qrBtn.href, {method: "POST"}).then(res => res.text());
if (!res) return;
const json = JSON.parse(res);
if (!json.qr_session || !json.kc_session) return;
const {qr_image_data, ttl, statusUrl, interval} = json;
// 创建遮罩与弹窗
const mask = document.createElement("div");
mask.style = `
position:fixed;top:0;left:0;right:0;bottom:0;
background:rgba(0,0,0,0.45);backdrop-filter:blur(3px);
z-index:9998;
`;
const box = document.createElement("div");
box.style = `
position:fixed;left:50%;top:50%;
transform:translate(-50%,-50%);
width:380px;padding:24px;border-radius:14px;
background:#fff;z-index:9999;
box-shadow:0 6px 30px rgba(0,0,0,0.25);
text-align:center;font-family:-apple-system,Segoe UI,Roboto,sans-serif;
`;
box.innerHTML = `
<h3 style="margin:0 0 12px;color:#222;">扫码登录易识IT账号</h3>
<img src="${qr_image_data}" width="240" height="240" style="border:1px solid #eee;border-radius:8px;" />
<p id="qr-tip" style="font-size:14px;color:#555;margin-top:10px;">请使用易识IT App 扫描二维码以继续登录</p>
<p id="qr-count" style="font-size:12px;color:#888;margin-top:4px;">二维码将在 ${ttl} 秒后失效</p>
<button id="qr-close" style="margin-top:14px;padding:6px 20px;border:none;background:#6c757d;color:white;border-radius:6px;cursor:pointer;">取消</button>
`;
document.body.append(mask, box);
const tip = document.getElementById("qr-tip");
const count = document.getElementById("qr-count");
const btn = document.getElementById("qr-close");
const closeModal = () => {
mask.remove();
box.remove();
window.location.reload();
};
mask.onclick = closeModal;
btn.onclick = closeModal;
// 记录起始时间和结束时间
const startTime = Date.now();
const endTime = startTime + (ttl * 1000);
let expired = false;
const poll = async () => {
if (expired) return;
try {
const resp = await fetch(`${statusUrl}${statusUrl.includes('?') ? '&' : '?'}timestamp=${Math.floor(Date.now() / 1000)}`);
const data = await resp.json();
switch (data.status) {
case "CONFIRMED":
const url = data.url;
tip.innerText = "身份验证通过,正在跳转...";
count.style.display = "none";
btn.disabled = true;
btn.style.background = "#28a745";
setTimeout(() => (window.location.href = url), 600);
return;
case "SCANNED":
tip.innerText = "二维码已扫描,请在手机端确认登录";
break;
case "PENDING":
tip.innerText = "等待扫描请打开易识IT App 扫描二维码";
break;
case "EXPIRED":
expired = true;
tip.innerText = "二维码已失效,请重新开始登录流程";
count.style.display = "none";
btn.innerText = "重新开始";
btn.style.background = "#007bff";
btn.onclick = () => {
mask.remove();
box.remove();
qrBtn.click();
};
return;
default:
tip.innerText = "正在等待响应,请稍候...";
}
// 基于实际时间计算剩余时间
const now = Date.now();
const remaining = Math.max(0, endTime - now) / 1000;
if (remaining > 0) {
count.innerText = `二维码将在 ${Math.round(remaining)} 秒后失效`;
setTimeout(poll, interval);
} else {
expired = true;
tip.innerText = "二维码已失效,请重新开始登录流程";
count.style.display = "none";
btn.innerText = "重新开始";
btn.style.background = "#007bff";
btn.onclick = () => {
mask.remove();
box.remove();
qrBtn.click();
};
}
} catch (err) {
tip.innerText = "网络异常,请检查连接后重试";
count.style.display = "none";
console.error(err);
}
};
poll();
});
});

View File

@@ -0,0 +1,7 @@
parent=keycloak.v2
import=common/keycloak
locales=ar,ca,cs,da,de,el,en,es,fa,fr,fi,hr,hu,it,ja,lt,lv,nl,no,pl,pt,pt-BR,ru,sk,sv,th,tr,uk,zh-CN,zh-TW
logo=/logo.png
styles=/css/styles.css css/login.css
scripts=js/script.js

View File

@@ -0,0 +1,135 @@
package sign;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
public class test {
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final long TIME_WINDOW_MS = 5_000; // 严格限制5秒±5秒共10秒窗口
private final String secret;
public test(String secret) {
if (secret == null || secret.isEmpty()) {
throw new IllegalArgumentException("Secret must not be null or empty");
}
this.secret = secret;
}
/**
* 客户端:生成签名
*/
public String sign(Map<String, String> params) {
if (!params.containsKey("timestamp")) {
throw new IllegalArgumentException("Missing 'timestamp'");
}
String queryString = buildQueryString(params);
return hmacSha256(queryString, secret);
}
/**
* 服务端:验证签名 + 时间戳±5秒
*/
public boolean verify(Map<String, String> params) {
String sign = params.get("sign");
if (sign == null || sign.isEmpty()) {
throw new IllegalArgumentException("Missing 'sign'");
}
// 解析 timestamp单位
long timestampSec;
try {
timestampSec = Long.parseLong(params.get("timestamp"));
} catch (Exception e) {
throw new IllegalArgumentException("Invalid 'timestamp'");
}
long nowSec = System.currentTimeMillis() / 1000;
long diff = Math.abs(nowSec - timestampSec);
if (diff > 5000) { // 超过5秒
throw new IllegalArgumentException("Request expired: timestamp deviation > 5s (now=" + nowSec + ", req=" + timestampSec + ")");
}
// 重新计算签名(排除 sign
Map<String, String> signParams = new HashMap<>(params);
signParams.remove("sign");
String queryString = buildQueryString(signParams);
String expectedSign = hmacSha256(queryString, secret);
if (!safeEquals(sign, expectedSign)) {
throw new IllegalArgumentException("Invalid signature");
}
return true;
}
private String buildQueryString(Map<String, String> params) {
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
String value = params.getOrDefault(key, "");
if (i > 0) sb.append("&");
sb.append(key).append("=").append(value);
}
return sb.toString();
}
private String hmacSha256(String data, String key) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(secretKey);
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("HMAC-SHA256 failed", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 恒定时间比较(防时序攻击)
private boolean safeEquals(String a, String b) {
if (a == null || b == null) return false;
if (a.length() != b.length()) return false;
byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
return Arrays.equals(aBytes, bBytes); // 在大多数 JVM 实现中足够安全
}
public static void main(String[] args) {
long timestamp = System.currentTimeMillis() / 1000; // 必须用秒!
timestamp -= 6;
Map<String, String> params = new HashMap<>();
params.put("kc_session", "abce3cb7-6345-4e54-8708-9b5e5fe463f8");
params.put("token", "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ0REZLaWNZbWktWWZVd1RoV3djcF9IckdoSHl1YUwtdThZa2tQYmtEVlZjIn0.eyJleHAiOjE3NjIwOTU1NDYsImlhdCI6MTc2MjA5NTI0NiwiYXV0aF90aW1lIjoxNzYyMDY2NTMwLCJqdGkiOiJvZnJ0cnQ6NzIwZDNiMDEtNjNmNC00Mzg5LTkwODQtMGU0N2U4NWI2YzI3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiOTg2MDU2ZmYtZWU4ZC00NjRlLTk0ZjItNTE2YWI2MjlkYWYwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdCIsInNpZCI6IjJjM2YwY2EwLWE2ZWEtNGNiMC1iZjFmLTc5ZjA3Njk3YjcwOCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovLzEyNy4wLjAuMTo1MDAwIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1hc3RlciIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiLkvKDnjpYg5p2OIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdDAwMSIsImdpdmVuX25hbWUiOiLkvKDnjpYiLCJsb2NhbGUiOiJ6aC1DTiIsImZhbWlseV9uYW1lIjoi5p2OIiwiZW1haWwiOiIxNzg3MDEwODk5NkAxNjMuY29tIn0.BpglUjBuownC2Tc5zN_l-387zrBrqABsGNsjqBjs-rqls8AChdST5iC1mGoEGsFyly72NQlmc2uSbSXsg-s5wEkaBm2WYXmx6-wVVhXp_t4LsKg1kswAqrwi3HRYMxWYbrp-adJIIRuCJIrXjvWYRPjNWI6UAg044pmu3B5pdhikS_SK-T9LnLdqs3A70WQGV2atLnZPWdyGQobv84_JQjE0kWRRBeroJHUBp338Z55_eSDRQpj6NN_xitQtNaqmG67iLO5N7Ao5VXLokNH6waHl6sREmHWd8R7rkVba2Ah2iJezJabmfiigArbcPcuJ8Aha4n0O2WQ9wjfKTa8LOQ");
params.put("timestamp", String.valueOf(1762095272));
params.put("qr_session", "9206d317-73a2-4fd1-a5ea-aa74af5fb73b");
test signer = new test("lichuanjiu");
String sign = signer.sign(params);
params.put("sign", sign);
System.out.println("Signature: " + sign);
// 从请求中解析 params含 sign
boolean valid = signer.verify(params);
System.out.println("Valid: " + valid);
}
}