commit 22edd9c9ebe1be0f34eabefb949e7a81e21c4b45 Author: huiyiruciduojiao <17870108997> Date: Mon Nov 3 00:00:56 2025 +0800 feat(qrlogin): 实现基于二维码的Keycloak登录功能- 添加二维码登录核心类,包括会话管理、签名验证和二维码生成 - 实现Keycloak身份提供者SPI,支持二维码登录流程 - 集成ZXing库用于二维码生成和解析 - 添加基于内存和Redis的会话存储实现 - 实现HMAC-SHA256签名算法用于请求验证 - 添加OpenAPI文档定义二维码登录接口规范- 配置Maven构建文件,包含必要的依赖和插件 - 添加IDE配置文件和项目忽略文件 - 实现JWT令牌验证和用户身份认证 - 添加会话过期清理机制和线程安全存储 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