From 22edd9c9ebe1be0f34eabefb949e7a81e21c4b45 Mon Sep 17 00:00:00 2001
From: huiyiruciduojiao <17870108997>
Date: Mon, 3 Nov 2025 00:00:56 +0800
Subject: [PATCH] =?UTF-8?q?feat(qrlogin):=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?=
=?UTF-8?q?=E4=BA=8E=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=9A=84Keycloak=E7=99=BB?=
=?UTF-8?q?=E5=BD=95=E5=8A=9F=E8=83=BD-=20=E6=B7=BB=E5=8A=A0=E4=BA=8C?=
=?UTF-8?q?=E7=BB=B4=E7=A0=81=E7=99=BB=E5=BD=95=E6=A0=B8=E5=BF=83=E7=B1=BB?=
=?UTF-8?q?=EF=BC=8C=E5=8C=85=E6=8B=AC=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E3=80=81=E7=AD=BE=E5=90=8D=E9=AA=8C=E8=AF=81=E5=92=8C=E4=BA=8C?=
=?UTF-8?q?=E7=BB=B4=E7=A0=81=E7=94=9F=E6=88=90=20-=20=E5=AE=9E=E7=8E=B0Ke?=
=?UTF-8?q?ycloak=E8=BA=AB=E4=BB=BD=E6=8F=90=E4=BE=9B=E8=80=85SPI=EF=BC=8C?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=20-=20=E9=9B=86=E6=88=90ZXing=E5=BA=93?=
=?UTF-8?q?=E7=94=A8=E4=BA=8E=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E5=92=8C=E8=A7=A3=E6=9E=90=20-=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?=
=?UTF-8?q?=E4=BA=8E=E5=86=85=E5=AD=98=E5=92=8CRedis=E7=9A=84=E4=BC=9A?=
=?UTF-8?q?=E8=AF=9D=E5=AD=98=E5=82=A8=E5=AE=9E=E7=8E=B0=20-=20=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0HMAC-SHA256=E7=AD=BE=E5=90=8D=E7=AE=97=E6=B3=95?=
=?UTF-8?q?=E7=94=A8=E4=BA=8E=E8=AF=B7=E6=B1=82=E9=AA=8C=E8=AF=81=20-=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0OpenAPI=E6=96=87=E6=A1=A3=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3?=
=?UTF-8?q?=E8=A7=84=E8=8C=83-=20=E9=85=8D=E7=BD=AEMaven=E6=9E=84=E5=BB=BA?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E5=8C=85=E5=90=AB=E5=BF=85=E8=A6=81?=
=?UTF-8?q?=E7=9A=84=E4=BE=9D=E8=B5=96=E5=92=8C=E6=8F=92=E4=BB=B6=20-=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0IDE=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E5=92=8C=E9=A1=B9=E7=9B=AE=E5=BF=BD=E7=95=A5=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=20-=20=E5=AE=9E=E7=8E=B0JWT=E4=BB=A4=E7=89=8C=E9=AA=8C?=
=?UTF-8?q?=E8=AF=81=E5=92=8C=E7=94=A8=E6=88=B7=E8=BA=AB=E4=BB=BD=E8=AE=A4?=
=?UTF-8?q?=E8=AF=81=20-=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D=E8=BF=87?=
=?UTF-8?q?=E6=9C=9F=E6=B8=85=E7=90=86=E6=9C=BA=E5=88=B6=E5=92=8C=E7=BA=BF?=
=?UTF-8?q?=E7=A8=8B=E5=AE=89=E5=85=A8=E5=AD=98=E5=82=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 40 +++
.idea/.gitignore | 8 +
.idea/artifacts/KeycloakQRLogin_jar.xml | 25 ++
.idea/dictionaries/project.xml | 7 +
.idea/encodings.xml | 7 +
.idea/misc.xml | 14 ++
.idea/swagger-settings.xml | 6 +
.idea/vcs.xml | 6 +
pom.xml | 84 +++++++
.../ysit/qrlogin/config/QRLoginConfig.java | 99 ++++++++
.../java/top/ysit/qrlogin/core/QRSession.java | 89 +++++++
.../ysit/qrlogin/core/QRSessionStatus.java | 3 +
.../top/ysit/qrlogin/core/SessionStore.java | 18 ++
.../core/security/SignatureConfig.java | 73 ++++++
.../qrlogin/core/security/SignatureUtil.java | 128 ++++++++++
.../exception/SignatureException.java | 11 +
.../core/store/InMemorySessionStore.java | 95 +++++++
.../qrlogin/core/store/RedisSessionStore.java | 82 ++++++
.../ysit/qrlogin/core/util/CryptoUtil.java | 35 +++
.../top/ysit/qrlogin/core/util/JsonUtil.java | 9 +
.../ysit/qrlogin/core/util/QRCodeUtil.java | 22 ++
.../qrlogin/core/util/QRLoginStoreUtil.java | 55 ++++
.../top/ysit/qrlogin/core/util/TokenUtil.java | 137 ++++++++++
.../qrlogin/idp/QRLoginIdentityProvider.java | 160 ++++++++++++
.../idp/QRLoginIdentityProviderFactory.java | 124 +++++++++
.../resource/endpoint/QRLoginEndpoint.java | 235 ++++++++++++++++++
.../QRLoginEndpointProviderFactory.java | 54 ++++
.../spi/KeycloakLifecycleListener.java | 4 +
...ak.broker.provider.IdentityProviderFactory | 1 +
...ices.resource.RealmResourceProviderFactory | 1 +
.../resources/openapi/qrlogin-openapi.yaml | 173 +++++++++++++
.../qrlogin/login/resources/js/script.js | 125 ++++++++++
.../theme/qrlogin/login/theme.properties | 7 +
src/test/java/sign/test.java | 135 ++++++++++
34 files changed, 2072 insertions(+)
create mode 100644 .gitignore
create mode 100644 .idea/.gitignore
create mode 100644 .idea/artifacts/KeycloakQRLogin_jar.xml
create mode 100644 .idea/dictionaries/project.xml
create mode 100644 .idea/encodings.xml
create mode 100644 .idea/misc.xml
create mode 100644 .idea/swagger-settings.xml
create mode 100644 .idea/vcs.xml
create mode 100644 pom.xml
create mode 100644 src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/QRSession.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/SessionStore.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/security/SignatureConfig.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/security/exception/SignatureException.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/store/InMemorySessionStore.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/store/RedisSessionStore.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/util/QRLoginStoreUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java
create mode 100644 src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java
create mode 100644 src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProviderFactory.java
create mode 100644 src/main/java/top/ysit/qrlogin/idp/resource/endpoint/QRLoginEndpoint.java
create mode 100644 src/main/java/top/ysit/qrlogin/idp/resource/factory/QRLoginEndpointProviderFactory.java
create mode 100644 src/main/java/top/ysit/qrlogin/spi/KeycloakLifecycleListener.java
create mode 100644 src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory
create mode 100644 src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory
create mode 100644 src/main/resources/openapi/qrlogin-openapi.yaml
create mode 100644 src/main/resources/theme/qrlogin/login/resources/js/script.js
create mode 100644 src/main/resources/theme/qrlogin/login/theme.properties
create mode 100644 src/test/java/sign/test.java
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c80bd6c
--- /dev/null
+++ b/.gitignore
@@ -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/
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..35410ca
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/artifacts/KeycloakQRLogin_jar.xml b/.idea/artifacts/KeycloakQRLogin_jar.xml
new file mode 100644
index 0000000..1d2185b
--- /dev/null
+++ b/.idea/artifacts/KeycloakQRLogin_jar.xml
@@ -0,0 +1,25 @@
+
+
+ $PROJECT_DIR$/out/artifacts/KeycloakQRLogin_jar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..856fe23
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ qrlogin
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..aa00ffa
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..82dbec8
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/swagger-settings.xml b/.idea/swagger-settings.xml
new file mode 100644
index 0000000..2d089b8
--- /dev/null
+++ b/.idea/swagger-settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..314fe92
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,84 @@
+
+
+ 4.0.0
+
+ top.ysit
+ KeycloakQRLogin
+ 1.0-SNAPSHOT
+
+
+ 17
+ 17
+ 26.2.4
+ UTF-8
+
+
+
+
+
+ org.keycloak
+ keycloak-services
+ ${keycloak.version}
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi
+ ${keycloak.version}
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ ${keycloak.version}
+ provided
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.2
+
+
+
+
+
+ com.google.zxing
+ core
+ 3.5.3
+
+
+ com.google.zxing
+ javase
+ 3.5.3
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+ shade
+
+ false
+ false
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java b/src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java
new file mode 100644
index 0000000..b9b9d8e
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java
@@ -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;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/QRSession.java b/src/main/java/top/ysit/qrlogin/core/QRSession.java
new file mode 100644
index 0000000..5a1ad0b
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/QRSession.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java b/src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java
new file mode 100644
index 0000000..96e7ff1
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java
@@ -0,0 +1,3 @@
+package top.ysit.qrlogin.core;
+
+public enum QRSessionStatus {PENDING, SCANNED, CONFIRMED, EXPIRED}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/SessionStore.java b/src/main/java/top/ysit/qrlogin/core/SessionStore.java
new file mode 100644
index 0000000..71b3bcb
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/SessionStore.java
@@ -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);
+
+}
diff --git a/src/main/java/top/ysit/qrlogin/core/security/SignatureConfig.java b/src/main/java/top/ysit/qrlogin/core/security/SignatureConfig.java
new file mode 100644
index 0000000..558ac33
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/security/SignatureConfig.java
@@ -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 toStringList() {
+
+ return java.util.Arrays.stream(values())
+ .map(SignatureAlgorithm::toString)
+ .toList();
+ }
+
+ public String getAlgorithmName() {
+ return algorithmName;
+ }
+
+ @Override
+ public String toString() {
+ return algorithmName;
+ }
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java b/src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java
new file mode 100644
index 0000000..5cb7271
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java
@@ -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 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 params) throws SignatureException {
+ String sign = params.get("sign");
+ if (sign == null || sign.isEmpty()) {
+ throw new SignatureException("Missing 'sign'");
+ }
+
+ // 验证时间戳
+ validateTimestamp(params.get("timestamp"));
+
+ // 重新计算签名(排除 sign)
+ Map 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 params) {
+ List 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 实现中足够安全
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/core/security/exception/SignatureException.java b/src/main/java/top/ysit/qrlogin/core/security/exception/SignatureException.java
new file mode 100644
index 0000000..f521f46
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/security/exception/SignatureException.java
@@ -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);
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/core/store/InMemorySessionStore.java b/src/main/java/top/ysit/qrlogin/core/store/InMemorySessionStore.java
new file mode 100644
index 0000000..c056b18
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/store/InMemorySessionStore.java
@@ -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 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() + '}';
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/core/store/RedisSessionStore.java b/src/main/java/top/ysit/qrlogin/core/store/RedisSessionStore.java
new file mode 100644
index 0000000..808ca1c
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/store/RedisSessionStore.java
@@ -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 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 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();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java b/src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java
new file mode 100644
index 0000000..3aebba6
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java
@@ -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;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java b/src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java
new file mode 100644
index 0000000..59c9664
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java
@@ -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 fromJson(String s, Class c){ try { return MAPPER.readValue(s,c);} catch (Exception e){ throw new RuntimeException(e);} }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java b/src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java
new file mode 100644
index 0000000..ac2ce2f
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java
@@ -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());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/util/QRLoginStoreUtil.java b/src/main/java/top/ysit/qrlogin/core/util/QRLoginStoreUtil.java
new file mode 100644
index 0000000..25b8aec
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/util/QRLoginStoreUtil.java
@@ -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());
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java b/src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java
new file mode 100644
index 0000000..d102126
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java
@@ -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 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 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 scope, Long expiration) {
+
+
+ public static TokenValidationResult valid(String username, String clientId, String email, String sub, List 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);
+ }
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java b/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java
new file mode 100644
index 0000000..92608b5
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java
@@ -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 implements IdentityProvider {
+ 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 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);
+
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProviderFactory.java b/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProviderFactory.java
new file mode 100644
index 0000000..3576910
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProviderFactory.java
@@ -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 {
+ 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 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 getDefaultConfig() {
+ Map 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 getConfigProperties() {
+ List 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 options) {
+ ProviderConfigProperty p = prop(name, label, help, ProviderConfigProperty.LIST_TYPE, def);
+ p.setOptions(options);
+ return p;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/top/ysit/qrlogin/idp/resource/endpoint/QRLoginEndpoint.java b/src/main/java/top/ysit/qrlogin/idp/resource/endpoint/QRLoginEndpoint.java
new file mode 100644
index 0000000..03eeaba
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/idp/resource/endpoint/QRLoginEndpoint.java
@@ -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 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 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 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 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 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);
+ }
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/idp/resource/factory/QRLoginEndpointProviderFactory.java b/src/main/java/top/ysit/qrlogin/idp/resource/factory/QRLoginEndpointProviderFactory.java
new file mode 100644
index 0000000..9e973e0
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/idp/resource/factory/QRLoginEndpointProviderFactory.java
@@ -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;
+ }
+}
diff --git a/src/main/java/top/ysit/qrlogin/spi/KeycloakLifecycleListener.java b/src/main/java/top/ysit/qrlogin/spi/KeycloakLifecycleListener.java
new file mode 100644
index 0000000..8d53e7f
--- /dev/null
+++ b/src/main/java/top/ysit/qrlogin/spi/KeycloakLifecycleListener.java
@@ -0,0 +1,4 @@
+package top.ysit.qrlogin.spi;
+
+public class KeycloakLifecycleListener {
+}
diff --git a/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory
new file mode 100644
index 0000000..381b541
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory
@@ -0,0 +1 @@
+top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory
\ No newline at end of file
diff --git a/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory
new file mode 100644
index 0000000..2ec6ce0
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory
@@ -0,0 +1 @@
+top.ysit.qrlogin.idp.resource.factory.QRLoginEndpointProviderFactory
\ No newline at end of file
diff --git a/src/main/resources/openapi/qrlogin-openapi.yaml b/src/main/resources/openapi/qrlogin-openapi.yaml
new file mode 100644
index 0000000..cbb0e9a
--- /dev/null
+++ b/src/main/resources/openapi/qrlogin-openapi.yaml
@@ -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: 会话不存在或已过期
diff --git a/src/main/resources/theme/qrlogin/login/resources/js/script.js b/src/main/resources/theme/qrlogin/login/resources/js/script.js
new file mode 100644
index 0000000..eadb61b
--- /dev/null
+++ b/src/main/resources/theme/qrlogin/login/resources/js/script.js
@@ -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 = `
+ 扫码登录易识IT账号
+
+ 请使用易识IT App 扫描二维码以继续登录
+ 二维码将在 ${ttl} 秒后失效
+
+ `;
+ 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();
+ });
+});
diff --git a/src/main/resources/theme/qrlogin/login/theme.properties b/src/main/resources/theme/qrlogin/login/theme.properties
new file mode 100644
index 0000000..45cddf6
--- /dev/null
+++ b/src/main/resources/theme/qrlogin/login/theme.properties
@@ -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
\ No newline at end of file
diff --git a/src/test/java/sign/test.java b/src/test/java/sign/test.java
new file mode 100644
index 0000000..90a38e8
--- /dev/null
+++ b/src/test/java/sign/test.java
@@ -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 params) {
+ if (!params.containsKey("timestamp")) {
+ throw new IllegalArgumentException("Missing 'timestamp'");
+ }
+ String queryString = buildQueryString(params);
+ return hmacSha256(queryString, secret);
+ }
+
+ /**
+ * 服务端:验证签名 + 时间戳(±5秒)
+ */
+ public boolean verify(Map 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 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 params) {
+ List 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 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);
+
+ }
+}
\ No newline at end of file