mirror of
https://github.com/huiyiruciduojiao/KeycloakQRLogin.git
synced 2026-01-28 03:24:37 +08:00
feat(qrlogin): 实现基于二维码的Keycloak登录功能- 添加二维码登录核心类,包括会话管理、签名验证和二维码生成
- 实现Keycloak身份提供者SPI,支持二维码登录流程 - 集成ZXing库用于二维码生成和解析 - 添加基于内存和Redis的会话存储实现 - 实现HMAC-SHA256签名算法用于请求验证 - 添加OpenAPI文档定义二维码登录接口规范- 配置Maven构建文件,包含必要的依赖和插件 - 添加IDE配置文件和项目忽略文件 - 实现JWT令牌验证和用户身份认证 - 添加会话过期清理机制和线程安全存储
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
# 项目排除路径
|
||||
/out/
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
25
.idea/artifacts/KeycloakQRLogin_jar.xml
generated
Normal file
25
.idea/artifacts/KeycloakQRLogin_jar.xml
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="KeycloakQRLogin:jar">
|
||||
<output-path>$PROJECT_DIR$/out/artifacts/KeycloakQRLogin_jar</output-path>
|
||||
<root id="archive" name="KeycloakQRLogin.jar">
|
||||
<element id="module-output" name="KeycloakQRLogin" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/zxing/core/3.5.3/core-3.5.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-handler/4.1.107.Final/netty-handler-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-resolver/4.1.107.Final/netty-resolver-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/projectreactor/reactor-core/3.6.4/reactor-core-3.6.4.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-databind/2.17.2/jackson-databind-2.17.2.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-transport/4.1.107.Final/netty-transport-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-core/2.18.2/jackson-core-2.18.2.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/fasterxml/jackson/core/jackson-annotations/2.18.2/jackson-annotations-2.18.2.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/zxing/javase/3.5.3/javase-3.5.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/beust/jcommander/1.82/jcommander-1.82.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-codec/4.1.107.Final/netty-codec-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/lettuce/lettuce-core/6.3.2.RELEASE/lettuce-core-6.3.2.RELEASE.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-buffer/4.1.107.Final/netty-buffer-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/github/jai-imageio/jai-imageio-core/1.4.0/jai-imageio-core-1.4.0.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-common/4.1.107.Final/netty-common-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/io/netty/netty-transport-native-unix-common/4.1.107.Final/netty-transport-native-unix-common-4.1.107.Final.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar" path-in-jar="/" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
7
.idea/dictionaries/project.xml
generated
Normal file
7
.idea/dictionaries/project.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>qrlogin</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
14
.idea/misc.xml
generated
Normal file
14
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/swagger-settings.xml
generated
Normal file
6
.idea/swagger-settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SwaggerSettings">
|
||||
<option name="defaultPreviewType" value="REDOC" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
84
pom.xml
Normal file
84
pom.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>top.ysit</groupId>
|
||||
<artifactId>KeycloakQRLogin</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<keycloak.version>26.2.4</keycloak.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Keycloak Server SPI -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
<version>${keycloak.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JAX-RS, JSON -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- QR 码生成 -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<minimizeJar>false</minimizeJar>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
99
src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java
Normal file
99
src/main/java/top/ysit/qrlogin/config/QRLoginConfig.java
Normal file
@@ -0,0 +1,99 @@
|
||||
package top.ysit.qrlogin.config;
|
||||
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import top.ysit.qrlogin.core.security.SignatureConfig;
|
||||
|
||||
public class QRLoginConfig extends IdentityProviderModel {
|
||||
public static final String PROVIDER_ID = "qrlogin";
|
||||
private final IdentityProviderModel model;
|
||||
|
||||
|
||||
public QRLoginConfig(IdentityProviderModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public int getSessionTtlSeconds() { // 二维码有效期
|
||||
return getInt("sessionTtlSeconds", 120);
|
||||
}
|
||||
|
||||
public int getPollIntervalMs() { // 轮询间隔(前端提示用)
|
||||
return getInt("pollIntervalMs", 1500);
|
||||
}
|
||||
|
||||
public String getStoreType() { // redis | memory
|
||||
return get("storeType", "redis");
|
||||
}
|
||||
|
||||
public String getRedisUri() { // redis://host:port
|
||||
return get("redisUri", "redis://127.0.0.1:6379");
|
||||
}
|
||||
|
||||
public String getRedisNamespace() { // key 前缀
|
||||
return get("redisNamespace", "qrlogin:");
|
||||
}
|
||||
|
||||
public String getHmacSecret() { // App 签名校验
|
||||
return get("hmacSecret", "change-me");
|
||||
}
|
||||
|
||||
public long getTimeWindowSeconds() {
|
||||
return getLong("timeWindowSeconds", 5);
|
||||
}
|
||||
|
||||
public SignatureConfig.SignatureAlgorithm getAlgorithm(){
|
||||
return getAlgorithm("algorithm", SignatureConfig.SignatureAlgorithm.HMAC_SHA256);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private String get(String k, String def) {
|
||||
String v = model.getConfig() == null ? null : model.getConfig().get(k);
|
||||
return v == null || v.isBlank() ? def : v;
|
||||
}
|
||||
|
||||
private int getInt(String k, int def) {
|
||||
try {
|
||||
return Integer.parseInt(get(k, String.valueOf(def)));
|
||||
} catch (Exception e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private long getLong(String k, long def) {
|
||||
try {
|
||||
return Long.parseLong(get(k, String.valueOf(def)));
|
||||
} catch (Exception e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
private SignatureConfig.SignatureAlgorithm getAlgorithm(String k, SignatureConfig.SignatureAlgorithm def) {
|
||||
try {
|
||||
String algorithmName = get(k, def.name());
|
||||
try {
|
||||
return SignatureConfig.SignatureAlgorithm.valueOf(algorithmName);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 如果失败,按algorithmName字段值查找
|
||||
for (SignatureConfig.SignatureAlgorithm algorithm : SignatureConfig.SignatureAlgorithm.values()) {
|
||||
if (algorithm.getAlgorithmName().equals(algorithmName)) {
|
||||
return algorithm;
|
||||
}
|
||||
}
|
||||
return def;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("error" + e);
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAlias() {
|
||||
return model.getAlias();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
}
|
||||
89
src/main/java/top/ysit/qrlogin/core/QRSession.java
Normal file
89
src/main/java/top/ysit/qrlogin/core/QRSession.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package top.ysit.qrlogin.core;
|
||||
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class QRSession {
|
||||
private String sessionId;
|
||||
private QRSessionStatus status;
|
||||
private String userId;
|
||||
private String kcSessionId;
|
||||
private Instant expireAt; // epoch millis
|
||||
private Instant createdAt;
|
||||
private String email;
|
||||
private AuthenticationSessionModel authSession;
|
||||
private String responseUrl;
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public String getKcSessionId() {
|
||||
return kcSessionId;
|
||||
}
|
||||
|
||||
public void setKcSessionId(String kcSessionId) {
|
||||
this.kcSessionId = kcSessionId;
|
||||
}
|
||||
|
||||
public QRSessionStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(QRSessionStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Instant getExpireAt() {
|
||||
return expireAt;
|
||||
}
|
||||
|
||||
public void setExpireAt(Instant expireAt) {
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public AuthenticationSessionModel getAuthSession() {
|
||||
return authSession;
|
||||
}
|
||||
|
||||
public void setAuthSession(AuthenticationSessionModel authSession) {
|
||||
this.authSession = authSession;
|
||||
}
|
||||
|
||||
public String getResponseUrl() {
|
||||
return responseUrl;
|
||||
}
|
||||
|
||||
public void setResponseUrl(String responseUrl) {
|
||||
this.responseUrl = responseUrl;
|
||||
}
|
||||
}
|
||||
3
src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java
Normal file
3
src/main/java/top/ysit/qrlogin/core/QRSessionStatus.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package top.ysit.qrlogin.core;
|
||||
|
||||
public enum QRSessionStatus {PENDING, SCANNED, CONFIRMED, EXPIRED}
|
||||
18
src/main/java/top/ysit/qrlogin/core/SessionStore.java
Normal file
18
src/main/java/top/ysit/qrlogin/core/SessionStore.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package top.ysit.qrlogin.core;
|
||||
|
||||
public interface SessionStore {
|
||||
void put(QRSession s);
|
||||
|
||||
QRSession get(String sessionId);
|
||||
|
||||
void setScanned(String sessionId);
|
||||
|
||||
void setConfirmed(String sessionId, String userId);
|
||||
|
||||
void setResponseUrl(String sessionId, String url);
|
||||
|
||||
void expire(String sessionId);
|
||||
|
||||
void delete(String sessionId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package top.ysit.qrlogin.core.security;
|
||||
|
||||
public class SignatureConfig {
|
||||
private String secret;
|
||||
private SignatureAlgorithm algorithm;
|
||||
private long timeWindowSeconds;
|
||||
|
||||
public SignatureConfig() {
|
||||
// 默认配置
|
||||
this.algorithm = SignatureAlgorithm.HMAC_SHA256;
|
||||
this.timeWindowSeconds = 5;
|
||||
}
|
||||
|
||||
public SignatureConfig(String secret, SignatureAlgorithm algorithm, long timeWindowSeconds) {
|
||||
this.secret = secret;
|
||||
this.algorithm = algorithm;
|
||||
this.timeWindowSeconds = timeWindowSeconds;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public SignatureAlgorithm getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public void setAlgorithm(SignatureAlgorithm algorithm) {
|
||||
this.algorithm = algorithm;
|
||||
}
|
||||
|
||||
public long getTimeWindowSeconds() {
|
||||
return timeWindowSeconds;
|
||||
}
|
||||
|
||||
public void setTimeWindowSeconds(long timeWindowSeconds) {
|
||||
this.timeWindowSeconds = timeWindowSeconds;
|
||||
}
|
||||
|
||||
public enum SignatureAlgorithm {
|
||||
HMAC_SHA256("HmacSHA256"),
|
||||
HMAC_SHA1("HmacSHA1"),
|
||||
HMAC_SHA384("HmacSHA384"),
|
||||
HMAC_SHA512("HmacSHA512");
|
||||
|
||||
private final String algorithmName;
|
||||
|
||||
SignatureAlgorithm(String algorithmName) {
|
||||
this.algorithmName = algorithmName;
|
||||
}
|
||||
|
||||
public static java.util.List<String> toStringList() {
|
||||
|
||||
return java.util.Arrays.stream(values())
|
||||
.map(SignatureAlgorithm::toString)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getAlgorithmName() {
|
||||
return algorithmName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return algorithmName;
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java
Normal file
128
src/main/java/top/ysit/qrlogin/core/security/SignatureUtil.java
Normal file
@@ -0,0 +1,128 @@
|
||||
// 修改后的 SignatureUtil.java
|
||||
package top.ysit.qrlogin.core.security;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SignatureException;
|
||||
import java.util.*;
|
||||
|
||||
public class SignatureUtil {
|
||||
private final SignatureConfig config;
|
||||
|
||||
public SignatureUtil(SignatureConfig config) throws SignatureException {
|
||||
if (config == null) {
|
||||
throw new SignatureException("Configuration must not be null");
|
||||
}
|
||||
if (config.getSecret() == null || config.getSecret().isEmpty()) {
|
||||
throw new SignatureException("Secret must not be null or empty");
|
||||
}
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public SignatureUtil(String secret) throws SignatureException {
|
||||
this(new SignatureConfig(secret, SignatureConfig.SignatureAlgorithm.HMAC_SHA256, 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端:生成签名
|
||||
*/
|
||||
public String sign(Map<String, String> params) throws SignatureException {
|
||||
if (!params.containsKey("timestamp")) {
|
||||
throw new SignatureException("Missing 'timestamp'");
|
||||
}
|
||||
String queryString = buildQueryString(params);
|
||||
return hmacSha256(queryString, config.getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端:验证签名 + 时间戳(±timeWindowSeconds秒)
|
||||
*/
|
||||
public boolean verify(Map<String, String> params) throws SignatureException {
|
||||
String sign = params.get("sign");
|
||||
if (sign == null || sign.isEmpty()) {
|
||||
throw new SignatureException("Missing 'sign'");
|
||||
}
|
||||
|
||||
// 验证时间戳
|
||||
validateTimestamp(params.get("timestamp"));
|
||||
|
||||
// 重新计算签名(排除 sign)
|
||||
Map<String, String> signParams = new HashMap<>(params);
|
||||
signParams.remove("sign");
|
||||
String queryString = buildQueryString(signParams);
|
||||
String expectedSign = hmacSha256(queryString, config.getSecret());
|
||||
|
||||
if (!safeEquals(sign, expectedSign)) {
|
||||
throw new SignatureException("Invalid signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时间戳是否在有效时间窗口内
|
||||
* @param timestampStr 时间戳字符串(单位:秒)
|
||||
* @throws SignatureException 当时间戳无效或超出时间窗口时抛出异常
|
||||
*/
|
||||
public void validateTimestamp(String timestampStr) throws SignatureException {
|
||||
long timestampSec;
|
||||
try {
|
||||
timestampSec = Long.parseLong(timestampStr);
|
||||
} catch (Exception e) {
|
||||
throw new SignatureException("Invalid 'timestamp'", e);
|
||||
}
|
||||
|
||||
long nowSec = System.currentTimeMillis() / 1000;
|
||||
long diff = Math.abs(nowSec - timestampSec);
|
||||
|
||||
if (diff > config.getTimeWindowSeconds()) {
|
||||
throw new SignatureException("Request expired: timestamp deviation > " +
|
||||
config.getTimeWindowSeconds() + "s (now=" + nowSec + ", req=" + timestampSec + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildQueryString(Map<String, String> params) {
|
||||
List<String> keys = new ArrayList<>(params.keySet());
|
||||
Collections.sort(keys);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < keys.size(); i++) {
|
||||
String key = keys.get(i);
|
||||
String value = params.getOrDefault(key, "");
|
||||
if (i > 0) sb.append("&");
|
||||
sb.append(key).append("=").append(value);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String hmacSha256(String data, String key) throws SignatureException {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(config.getAlgorithm().toString());
|
||||
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), config.getAlgorithm().toString());
|
||||
mac.init(secretKey);
|
||||
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
return bytesToHex(hash);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new SignatureException("HMAC-SHA256 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 恒定时间比较(防时序攻击)
|
||||
private boolean safeEquals(String a, String b) {
|
||||
if (a == null || b == null) return false;
|
||||
if (a.length() != b.length()) return false;
|
||||
byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
|
||||
return Arrays.equals(aBytes, bBytes); // 在大多数 JVM 实现中足够安全
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package top.ysit.qrlogin.core.store;
|
||||
|
||||
import top.ysit.qrlogin.config.QRLoginConfig;
|
||||
import top.ysit.qrlogin.core.QRSession;
|
||||
import top.ysit.qrlogin.core.QRSessionStatus;
|
||||
import top.ysit.qrlogin.core.SessionStore;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
public class InMemorySessionStore implements SessionStore {
|
||||
private final ConcurrentMap<String, QRSession> map = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService cleaner;
|
||||
// 清理周期
|
||||
private final long cleanupInterval = 30; // 每 30 秒清理一次
|
||||
// 默认会话有效期,单位:秒
|
||||
private long sessionTimeout = 120; // 2 分钟
|
||||
|
||||
public InMemorySessionStore(QRLoginConfig cfg) {
|
||||
|
||||
this.sessionTimeout = cfg.getSessionTtlSeconds();
|
||||
|
||||
cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "qr-session-cleaner");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
cleaner.scheduleAtFixedRate(this::cleanupExpiredSessions,
|
||||
cleanupInterval, cleanupInterval, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(QRSession s) {
|
||||
s.setCreatedAt(Instant.now());
|
||||
map.put(s.getSessionId(), s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QRSession get(String id) {
|
||||
return map.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConfirmed(String id, String email) {
|
||||
map.computeIfPresent(id, (k, v) -> {
|
||||
v.setStatus(QRSessionStatus.CONFIRMED);
|
||||
v.setEmail(email);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setScanned(String id) {
|
||||
map.computeIfPresent(id, (k, v) -> {
|
||||
v.setStatus(QRSessionStatus.SCANNED);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setResponseUrl(String sessionId, String url) {
|
||||
map.computeIfPresent(sessionId, (k, v) -> {
|
||||
v.setResponseUrl(url);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void expire(String id) {
|
||||
map.computeIfPresent(id, (k, v) -> {
|
||||
v.setStatus(QRSessionStatus.EXPIRED);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
map.remove(id);
|
||||
}
|
||||
|
||||
private void cleanupExpiredSessions() {
|
||||
Instant now = Instant.now();
|
||||
map.values().removeIf(s -> {
|
||||
if (s.getStatus() == QRSessionStatus.EXPIRED) return true;
|
||||
return s.getCreatedAt() != null &&
|
||||
now.isAfter(s.getCreatedAt().plusSeconds(sessionTimeout));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "InMemorySessionStore{" + "sessions=" + map.size() + '}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package top.ysit.qrlogin.core.store;
|
||||
|
||||
import com.webauthn4j.util.exception.NotImplementedException;
|
||||
import io.lettuce.core.RedisClient;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.api.sync.RedisCommands;
|
||||
import top.ysit.qrlogin.core.QRSession;
|
||||
import top.ysit.qrlogin.core.QRSessionStatus;
|
||||
import top.ysit.qrlogin.core.SessionStore;
|
||||
import top.ysit.qrlogin.core.util.JsonUtil;
|
||||
|
||||
public class RedisSessionStore implements SessionStore, AutoCloseable {
|
||||
private final RedisClient client;
|
||||
private final StatefulRedisConnection<String, String> conn;
|
||||
private final String ns;
|
||||
private final int ttlSeconds;
|
||||
|
||||
public RedisSessionStore(String uri, String namespace, int ttlSeconds) {
|
||||
this.client = RedisClient.create(uri);
|
||||
this.conn = client.connect();
|
||||
this.ns = namespace;
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
private String key(String id) {
|
||||
return ns + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(QRSession s) {
|
||||
RedisCommands<String, String> cmd = conn.sync();
|
||||
cmd.setex(key(s.getSessionId()), ttlSeconds, JsonUtil.toJson(s));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QRSession get(String id) {
|
||||
String json = conn.sync().get(key(id));
|
||||
return json == null ? null : JsonUtil.fromJson(json, QRSession.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setScanned(String id) {
|
||||
QRSession s = get(id);
|
||||
if (s == null) return;
|
||||
s.setStatus(QRSessionStatus.SCANNED);
|
||||
put(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConfirmed(String id, String userId) {
|
||||
QRSession s = get(id);
|
||||
if (s == null) return;
|
||||
s.setStatus(QRSessionStatus.CONFIRMED);
|
||||
s.setUserId(userId);
|
||||
put(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setResponseUrl(String sessionId, String url) {
|
||||
throw new NotImplementedException("Not Impl");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void expire(String id) {
|
||||
QRSession s = get(id);
|
||||
if (s == null) return;
|
||||
s.setStatus(QRSessionStatus.EXPIRED);
|
||||
put(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
conn.sync().del(key(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
conn.close();
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
35
src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java
Normal file
35
src/main/java/top/ysit/qrlogin/core/util/CryptoUtil.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package top.ysit.qrlogin.core.util;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
public class CryptoUtil {
|
||||
public static String hmacSha256Base64(String data, String secret) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
|
||||
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes()));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名
|
||||
* @param data 数据
|
||||
* @param signature 签名
|
||||
* @param secret 密钥
|
||||
* @return 验证结果
|
||||
*/
|
||||
public static boolean verifyHmacSha256Base64(String data, String signature, String secret) {
|
||||
try {
|
||||
String expectedSignature = hmacSha256Base64(data, secret);
|
||||
return expectedSignature.equals(signature);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
9
src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java
Normal file
9
src/main/java/top/ysit/qrlogin/core/util/JsonUtil.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package top.ysit.qrlogin.core.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
public class JsonUtil {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
public static String toJson(Object o){ try { return MAPPER.writeValueAsString(o);} catch (Exception e){ throw new RuntimeException(e);} }
|
||||
public static <T> T fromJson(String s, Class<T> c){ try { return MAPPER.readValue(s,c);} catch (Exception e){ throw new RuntimeException(e);} }
|
||||
}
|
||||
22
src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java
Normal file
22
src/main/java/top/ysit/qrlogin/core/util/QRCodeUtil.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package top.ysit.qrlogin.core.util;
|
||||
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Base64;
|
||||
|
||||
public class QRCodeUtil {
|
||||
public static String toDataUrl(String text, int size) throws Exception {
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size);
|
||||
BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, "png", baos);
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
137
src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java
Normal file
137
src/main/java/top/ysit/qrlogin/core/util/TokenUtil.java
Normal file
@@ -0,0 +1,137 @@
|
||||
package top.ysit.qrlogin.core.util;
|
||||
|
||||
import jakarta.json.Json;
|
||||
import jakarta.json.JsonObject;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class TokenUtil {
|
||||
private static final String KEYCLOAK_URL = "http://localhost:8080";
|
||||
private static final String REALM = "master";
|
||||
private static final String CLIENT_ID = "test001";
|
||||
private static final String CLIENT_SECRET = "JEKhtMyFAic68UdA8CRXChE1OtDOwHzh";
|
||||
|
||||
public static TokenValidationResult verifyToken(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return TokenValidationResult.invalid("token 为空或无效");
|
||||
}
|
||||
|
||||
try {
|
||||
// 构造 introspect URL
|
||||
String urlString = KEYCLOAK_URL + "/realms/" + REALM + "/protocol/openid-connect/token/introspect";
|
||||
URL url = new URL(urlString);
|
||||
|
||||
// 设置请求
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
|
||||
conn.setDoOutput(true);
|
||||
|
||||
String body = String.format("client_id=%s&client_secret=%s&token=%s", CLIENT_ID, CLIENT_SECRET, token);
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
JsonObject json = Json.createReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)).readObject();
|
||||
boolean active = json.getBoolean("active", false);
|
||||
if (!active) {
|
||||
return TokenValidationResult.invalid("Token is not active");
|
||||
}
|
||||
|
||||
// 安全提取 exp 字段
|
||||
Long exp = null;
|
||||
if (json.containsKey("exp") && !json.isNull("exp")) {
|
||||
exp = json.getJsonNumber("exp").longValue();
|
||||
}
|
||||
|
||||
// 提取其他信息
|
||||
String username = json.getString("username", null);
|
||||
String clientId = json.getString("client_id", null);
|
||||
List<String> scope = parseScope(json.getString("scope", null));
|
||||
|
||||
|
||||
// 解析token内容,获取email
|
||||
JsonObject payload = tokenToJson(token);
|
||||
String email = payload.getString("email", null);
|
||||
String sub = json.getString("sub", null);
|
||||
|
||||
|
||||
return TokenValidationResult.valid(username, clientId, email, sub, scope, exp);
|
||||
|
||||
} catch (Exception e) {
|
||||
return TokenValidationResult.error("验证异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> parseScope(String scope) {
|
||||
if (scope == null || scope.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Arrays.asList(scope.split(" "));
|
||||
}
|
||||
|
||||
public static JsonObject tokenToJson(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// JWT token 由三部分组成,用点分隔:header.payload.signature
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解码 payload 部分(第二部分)
|
||||
String payload = parts[1];
|
||||
// 补充可能缺失的填充字符
|
||||
switch (payload.length() % 4) {
|
||||
case 2:
|
||||
payload += "==";
|
||||
break;
|
||||
case 3:
|
||||
payload += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
// Base64 解码
|
||||
byte[] decodedBytes = java.util.Base64.getDecoder().decode(payload);
|
||||
String decodedString = new String(decodedBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// 解析为 JsonObject
|
||||
return Json.createReader(new java.io.StringReader(decodedString)).readObject();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 解析失败时返回 null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record TokenValidationResult(boolean valid, String error, String username, String clientId, String email,
|
||||
String sub, List<String> scope, Long expiration) {
|
||||
|
||||
|
||||
public static TokenValidationResult valid(String username, String clientId, String email, String sub, List<String> scope, Long expiration) {
|
||||
System.out.println("valid" + username + " " + clientId + " " + email + " " + scope + " " + expiration);
|
||||
|
||||
return new TokenValidationResult(true, null, username, clientId, email, sub, scope, expiration);
|
||||
}
|
||||
|
||||
public static TokenValidationResult invalid(String error) {
|
||||
return new TokenValidationResult(false, error, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static TokenValidationResult error(String error) {
|
||||
return new TokenValidationResult(false, error, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java
Normal file
160
src/main/java/top/ysit/qrlogin/idp/QRLoginIdentityProvider.java
Normal file
@@ -0,0 +1,160 @@
|
||||
package top.ysit.qrlogin.idp;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.keycloak.broker.provider.AbstractIdentityProvider;
|
||||
import org.keycloak.broker.provider.AuthenticationRequest;
|
||||
import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||
import org.keycloak.broker.provider.IdentityProvider;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.FederatedIdentityModel;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.utils.MediaType;
|
||||
import top.ysit.qrlogin.config.QRLoginConfig;
|
||||
import top.ysit.qrlogin.core.QRSession;
|
||||
import top.ysit.qrlogin.core.QRSessionStatus;
|
||||
import top.ysit.qrlogin.core.SessionStore;
|
||||
import top.ysit.qrlogin.core.security.SignatureConfig;
|
||||
import top.ysit.qrlogin.core.security.SignatureUtil;
|
||||
import top.ysit.qrlogin.core.util.QRCodeUtil;
|
||||
import top.ysit.qrlogin.core.util.TokenUtil;
|
||||
import top.ysit.qrlogin.idp.resource.factory.QRLoginEndpointProviderFactory;
|
||||
|
||||
import java.security.SignatureException;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class QRLoginIdentityProvider extends AbstractIdentityProvider<IdentityProviderModel> implements IdentityProvider<IdentityProviderModel> {
|
||||
private final QRLoginConfig cfg;
|
||||
private final SessionStore store;
|
||||
|
||||
private final SignatureUtil signatureUtil;
|
||||
|
||||
public QRLoginIdentityProvider(KeycloakSession session, QRLoginConfig cfg, SessionStore store) {
|
||||
|
||||
super(session, cfg);
|
||||
|
||||
this.cfg = cfg;
|
||||
this.store = store;
|
||||
SignatureConfig signatureConfig = new SignatureConfig(cfg.getHmacSecret(), cfg.getAlgorithm(), cfg.getTimeWindowSeconds());
|
||||
try {
|
||||
this.signatureUtil = new SignatureUtil(signatureConfig);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) {
|
||||
return new Endpoint(callback, realm, event, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在登录页渲染“使用二维码登录”按钮;点击后进入我们的 iframe 页面。
|
||||
*/
|
||||
@Override
|
||||
public Response performLogin(AuthenticationRequest request) {
|
||||
AuthenticationSessionModel authSession = request.getAuthenticationSession();
|
||||
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
QRSession s = new QRSession();
|
||||
s.setSessionId(sessionId);
|
||||
s.setStatus(QRSessionStatus.PENDING);
|
||||
s.setExpireAt(Instant.now().plusSeconds(cfg.getSessionTtlSeconds()));
|
||||
s.setCreatedAt(Instant.now());
|
||||
s.setKcSessionId(authSession.getParentSession().getId());
|
||||
s.setAuthSession(authSession);
|
||||
store.put(s);
|
||||
|
||||
|
||||
String baseUrl = session.getContext().getUri().getBaseUri().toString();
|
||||
|
||||
// 构造请求地址
|
||||
String confirmUrl = baseUrl + "realms/" + request.getRealm().getName() + "/" + QRLoginEndpointProviderFactory.ID + "/" + "qr/confirm?qr_session=" + s.getSessionId() + "&kc_session=" + authSession.getParentSession().getId();
|
||||
String checkUrl = baseUrl + "realms/" + request.getRealm().getName() + "/" + QRLoginEndpointProviderFactory.ID + "/" + "qr/status" + "?qr_session=" + s.getSessionId() + "&kc_session=" + authSession.getParentSession().getId();
|
||||
|
||||
String imageData;
|
||||
try {
|
||||
imageData = QRCodeUtil.toDataUrl(confirmUrl, 512);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return Response.ok(Map.of("qr_session", s.getSessionId(),
|
||||
"kc_session", authSession.getParentSession().getId(),
|
||||
"qr_image_data", imageData,
|
||||
"confirm", confirmUrl,
|
||||
"statusUrl", checkUrl,
|
||||
"ttl", cfg.getSessionTtlSeconds(),
|
||||
"interval", cfg.getPollIntervalMs()
|
||||
))
|
||||
.type(MediaType.APPLICATION_JSON).build();
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Response retrieveToken(KeycloakSession keycloakSession, FederatedIdentityModel federatedIdentityModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected static class Endpoint {
|
||||
protected AuthenticationCallback callback;
|
||||
protected RealmModel realm;
|
||||
protected EventBuilder event;
|
||||
protected QRLoginIdentityProvider qrIdp;
|
||||
|
||||
|
||||
public Endpoint(AuthenticationCallback callback, RealmModel realm, EventBuilder event, QRLoginIdentityProvider qrIdp) {
|
||||
this.callback = callback;
|
||||
this.realm = realm;
|
||||
this.event = event;
|
||||
this.qrIdp = qrIdp;
|
||||
}
|
||||
|
||||
@GET
|
||||
public Response authResponse(@QueryParam("kc_session") String kcSessionId, @QueryParam("qr_session") String qrSessionId, @QueryParam("token") String token, @QueryParam("timestamp") String timestamp, @QueryParam("sign") String sign) {
|
||||
|
||||
if (token == null || timestamp == null || sign == null || qrSessionId == null || kcSessionId == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("qr_session", qrSessionId);
|
||||
params.put("kc_session", kcSessionId);
|
||||
params.put("token", token);
|
||||
params.put("timestamp", timestamp);
|
||||
params.put("sign", sign);
|
||||
try {
|
||||
qrIdp.signatureUtil.verify(params);
|
||||
} catch (SignatureException e) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
QRSession qrs = qrIdp.store.get(qrSessionId);
|
||||
if (qrs == null || !qrs.getKcSessionId().equals(kcSessionId) || qrs.getStatus() != QRSessionStatus.CONFIRMED) {;
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
TokenUtil.TokenValidationResult tokenValidationResult = TokenUtil.verifyToken(token);
|
||||
if (!tokenValidationResult.valid()) {;
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
|
||||
BrokeredIdentityContext federatedIdentity = new BrokeredIdentityContext(tokenValidationResult.sub(), qrIdp.getConfig());
|
||||
federatedIdentity.setIdp(qrIdp);
|
||||
federatedIdentity.setUsername(tokenValidationResult.username());
|
||||
federatedIdentity.setEmail(tokenValidationResult.email());
|
||||
federatedIdentity.setAuthenticationSession(qrs.getAuthSession());
|
||||
|
||||
|
||||
return callback.authenticated(federatedIdentity);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package top.ysit.qrlogin.idp;
|
||||
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import top.ysit.qrlogin.config.QRLoginConfig;
|
||||
import top.ysit.qrlogin.core.SessionStore;
|
||||
import top.ysit.qrlogin.core.security.SignatureConfig;
|
||||
import top.ysit.qrlogin.core.util.QRLoginStoreUtil;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class QRLoginIdentityProviderFactory extends AbstractIdentityProviderFactory<QRLoginIdentityProvider> {
|
||||
public static final String PROVIDER_ID = "qrlogin";
|
||||
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "QR Login";
|
||||
}
|
||||
|
||||
@Override
|
||||
public QRLoginIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
|
||||
// 直接实例化,不依赖注入
|
||||
SessionStore store = QRLoginStoreUtil.getSharedSessionStore(session, session.getContext().getRealm());
|
||||
QRLoginConfig config = new QRLoginConfig(model);
|
||||
return new QRLoginIdentityProvider(session, config, store);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QRLoginIdentityProvider create(KeycloakSession session) {
|
||||
IdentityProviderModel model = createConfig();
|
||||
QRLoginConfig config = new QRLoginConfig(model);
|
||||
SessionStore store = QRLoginStoreUtil.getSharedSessionStore(session, session.getContext().getRealm());
|
||||
return new QRLoginIdentityProvider(session, config, store);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Map<String, String> parseConfig(KeycloakSession session, String configJson) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse provider config JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityProviderModel createConfig() {
|
||||
// 返回带有默认配置的 IdentityProviderModel
|
||||
IdentityProviderModel model = new IdentityProviderModel();
|
||||
model.setConfig(getDefaultConfig());
|
||||
return model;
|
||||
}
|
||||
|
||||
private Map<String, String> getDefaultConfig() {
|
||||
Map<String, String> config = new HashMap<>();
|
||||
config.put("sessionTtlSeconds", "120");
|
||||
config.put("pollIntervalMs", "1500");
|
||||
config.put("storeType", "memory");
|
||||
config.put("redisUri", "redis://127.0.0.1:6379");
|
||||
config.put("redisNamespace", "qrlogin:");
|
||||
config.put("hmacSecret", "change-me");
|
||||
config.put("algorithm", SignatureConfig.SignatureAlgorithm.HMAC_SHA256.toString());
|
||||
config.put("timeWindowSeconds", "5");
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {
|
||||
// 初始化逻辑
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
// 后初始化逻辑
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// 清理资源
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
List<ProviderConfigProperty> props = new ArrayList<>();
|
||||
props.add(prop("sessionTtlSeconds", "Session TTL (s)", "二维码会话有效期(秒)", ProviderConfigProperty.STRING_TYPE, "120"));
|
||||
props.add(prop("pollIntervalMs", "Poll Interval (ms)", "前端轮询间隔(毫秒)", ProviderConfigProperty.STRING_TYPE, "1500"));
|
||||
props.add(prop("storeType", "Store Type", "redis 或 memory", "memory", Arrays.asList("redis", "memory")));
|
||||
props.add(prop("redisUri", "Redis URI", "如 redis://127.0.0.1:6379", ProviderConfigProperty.STRING_TYPE, "redis://127.0.0.1:6379"));
|
||||
props.add(prop("redisNamespace", "Redis Namespace", "键前缀", ProviderConfigProperty.STRING_TYPE, "qrlogin:"));
|
||||
props.add(prop("hmacSecret", "HMAC Secret", "App 签名校验秘钥", ProviderConfigProperty.PASSWORD, "change-me"));
|
||||
props.add(prop("algorithm", "Algorithm", "签名算法", SignatureConfig.SignatureAlgorithm.HMAC_SHA256.toString(), SignatureConfig.SignatureAlgorithm.toStringList()));
|
||||
props.add(prop("timeWindowSeconds", "Time Window Seconds", "请求有效时间范围(秒)", ProviderConfigProperty.INTEGER_TYPE,"5"));
|
||||
return props;
|
||||
}
|
||||
|
||||
private ProviderConfigProperty prop(String name, String label, String help, String type, String def) {
|
||||
ProviderConfigProperty p = new ProviderConfigProperty();
|
||||
p.setName(name);
|
||||
p.setLabel(label);
|
||||
p.setHelpText(help);
|
||||
p.setType(type);
|
||||
p.setDefaultValue(def);
|
||||
return p;
|
||||
}
|
||||
|
||||
private ProviderConfigProperty prop(String name, String label, String help, String def, List<String> options) {
|
||||
ProviderConfigProperty p = prop(name, label, help, ProviderConfigProperty.LIST_TYPE, def);
|
||||
p.setOptions(options);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package top.ysit.qrlogin.idp.resource.endpoint;
|
||||
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.services.resource.RealmResourceProvider;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.utils.MediaType;
|
||||
import top.ysit.qrlogin.config.QRLoginConfig;
|
||||
import top.ysit.qrlogin.core.QRSession;
|
||||
import top.ysit.qrlogin.core.QRSessionStatus;
|
||||
import top.ysit.qrlogin.core.SessionStore;
|
||||
import top.ysit.qrlogin.core.security.SignatureConfig;
|
||||
import top.ysit.qrlogin.core.security.SignatureUtil;
|
||||
import top.ysit.qrlogin.core.util.TokenUtil;
|
||||
import top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.security.SignatureException;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
public class QRLoginEndpoint extends RealmsResource implements RealmResourceProvider {
|
||||
private final KeycloakSession session;
|
||||
private final SessionStore store;
|
||||
private final SignatureUtil signatureUtil;
|
||||
|
||||
private final QRLoginConfig qrLoginConfig;
|
||||
|
||||
public QRLoginEndpoint(KeycloakSession session, EventBuilder event, SessionStore store, QRLoginConfig qrLoginConfig) throws SignatureException {
|
||||
this.session = session;
|
||||
this.store = store;
|
||||
this.qrLoginConfig = qrLoginConfig;
|
||||
SignatureConfig config = new SignatureConfig(
|
||||
qrLoginConfig.getHmacSecret(),
|
||||
qrLoginConfig.getAlgorithm(),
|
||||
qrLoginConfig.getTimeWindowSeconds()
|
||||
);
|
||||
this.signatureUtil = new SignatureUtil(config);
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("qr/scan")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response scan(Map<String, String> body) {
|
||||
ValidationResult result = validateRequest(body);
|
||||
if (!result.valid()) {
|
||||
return Response.ok(
|
||||
Map.of("error", result.errorMessage()),
|
||||
MediaType.APPLICATION_JSON
|
||||
).build();
|
||||
}
|
||||
if (result.qrSessionObj().getStatus() != QRSessionStatus.PENDING) {
|
||||
return Response.ok(
|
||||
Map.of("error", "Qr not found"),
|
||||
MediaType.APPLICATION_JSON
|
||||
).build();
|
||||
}
|
||||
|
||||
// 确认特定逻辑
|
||||
this.store.setScanned(result.qrSession());
|
||||
return Response.ok(Map.of("status", "ok")).build();
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("qr/confirm")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response confirm(Map<String, String> body) {
|
||||
ValidationResult result = validateRequest(body);
|
||||
if (!result.valid()) {
|
||||
return Response.ok(
|
||||
Map.of("error", result.errorMessage()),
|
||||
MediaType.APPLICATION_JSON
|
||||
).build();
|
||||
}
|
||||
if (result.qrSessionObj().getStatus() != QRSessionStatus.SCANNED) {
|
||||
return Response.ok(
|
||||
Map.of("error", "Qr not found"),
|
||||
MediaType.APPLICATION_JSON
|
||||
).build();
|
||||
}
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
String providerAlias = QRLoginIdentityProviderFactory.PROVIDER_ID;
|
||||
|
||||
//签名参数
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("qr_session", result.qrSession);
|
||||
params.put("kc_session", result.kcSession);
|
||||
params.put("token", body.get("token"));
|
||||
params.put("timestamp", String.valueOf(timestamp));
|
||||
String signature;
|
||||
try {
|
||||
signature = signatureUtil.sign(params);
|
||||
} catch (SignatureException e) {
|
||||
return Response.ok(
|
||||
Map.of("error", "Signature error"),
|
||||
MediaType.APPLICATION_JSON
|
||||
).build();
|
||||
}
|
||||
|
||||
|
||||
URI base = session.getContext().getUri().getBaseUri();
|
||||
URI callbackUri = UriBuilder.fromUri(base)
|
||||
.path("realms")
|
||||
.path(realm.getName())
|
||||
.path("broker")
|
||||
.path(providerAlias)
|
||||
.path("endpoint")
|
||||
.queryParam("qr_session", result.qrSession)
|
||||
.queryParam("kc_session", result.kcSession)
|
||||
.queryParam("token", body.get("token"))
|
||||
.queryParam("timestamp", timestamp)
|
||||
.queryParam("sign", signature)
|
||||
.build();
|
||||
|
||||
String callbackUrl = callbackUri.toString();
|
||||
this.store.setResponseUrl(result.qrSession(), callbackUrl);
|
||||
this.store.setConfirmed(result.qrSession(), result.tokenResult().email());
|
||||
return Response.ok(Map.of("status", "ok")).build();
|
||||
}
|
||||
|
||||
|
||||
@GET
|
||||
@Path("qr/status")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response status(@QueryParam("kc_session") String kcSession, @QueryParam("qr_session") String qrSession, @QueryParam("timestamp") String timestamp) {
|
||||
if (kcSession == null || qrSession == null || timestamp == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||
}
|
||||
try {
|
||||
signatureUtil.validateTimestamp(timestamp);
|
||||
} catch (SignatureException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
QRSession qrs = this.store.get(qrSession);
|
||||
if (qrs == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
if (!kcSession.equals(qrs.getKcSessionId())) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
if (Instant.now().isAfter(qrs.getExpireAt())) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(Map.of(
|
||||
"status", qrs.getStatus(),
|
||||
"url", qrs.getStatus() == QRSessionStatus.CONFIRMED ? qrs.getResponseUrl() : ""
|
||||
)).build();
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 在 QRLoginEndpoint 类中添加以下私有方法
|
||||
private ValidationResult validateRequest(Map<String, String> body) {
|
||||
if (body == null || body.get("timestamp") == null) {
|
||||
return ValidationResult.error("Invalid request body");
|
||||
}
|
||||
|
||||
|
||||
String kcSession = body.get("kc_session");
|
||||
String qrSession = body.get("qr_session");
|
||||
Long timestamp = Long.parseLong(body.get("timestamp"));
|
||||
String sign = body.get("sign");
|
||||
String token = body.get("token");
|
||||
|
||||
if (kcSession == null || qrSession == null || sign == null || token == null) {
|
||||
return ValidationResult.error("Invalid request body");
|
||||
}
|
||||
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("qr_session", qrSession);
|
||||
params.put("kc_session", kcSession);
|
||||
params.put("token", token);
|
||||
params.put("timestamp", String.valueOf(timestamp));
|
||||
params.put("sign", sign);
|
||||
|
||||
try {
|
||||
if (!signatureUtil.verify(params)) {
|
||||
return ValidationResult.error("Invalid signature");
|
||||
}
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
|
||||
QRSession qrs = this.store.get(qrSession);
|
||||
if (qrs == null) {
|
||||
return ValidationResult.error("QR session not found");
|
||||
}
|
||||
|
||||
if (!kcSession.equals(qrs.getKcSessionId())) {
|
||||
return ValidationResult.error("Invalid kcSession");
|
||||
}
|
||||
|
||||
TokenUtil.TokenValidationResult tokenValidationResult = TokenUtil.verifyToken(token);
|
||||
if (!tokenValidationResult.valid()) {
|
||||
return ValidationResult.error("Invalid token");
|
||||
}
|
||||
|
||||
return ValidationResult.success(kcSession, qrSession, qrs, tokenValidationResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getResource() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
private record ValidationResult(boolean valid, String errorMessage, String kcSession, String qrSession,
|
||||
QRSession qrSessionObj, TokenUtil.TokenValidationResult tokenResult) {
|
||||
|
||||
public static ValidationResult error(String errorMessage) {
|
||||
return new ValidationResult(false, errorMessage, null, null, null, null);
|
||||
}
|
||||
|
||||
public static ValidationResult success(String kcSession, String qrSession, QRSession qrSessionObj, TokenUtil.TokenValidationResult tokenResult) {
|
||||
return new ValidationResult(true, null, kcSession, qrSession, qrSessionObj, tokenResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package top.ysit.qrlogin.spi;
|
||||
|
||||
public class KeycloakLifecycleListener {
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
top.ysit.qrlogin.idp.QRLoginIdentityProviderFactory
|
||||
@@ -0,0 +1 @@
|
||||
top.ysit.qrlogin.idp.resource.factory.QRLoginEndpointProviderFactory
|
||||
173
src/main/resources/openapi/qrlogin-openapi.yaml
Normal file
173
src/main/resources/openapi/qrlogin-openapi.yaml
Normal file
@@ -0,0 +1,173 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Keycloak QR Login API
|
||||
version: 0.1.0
|
||||
servers:
|
||||
- url: /realms/{realm}/qrlogin/endpoint
|
||||
variables:
|
||||
realm:
|
||||
default: master
|
||||
description: Keycloak realm name
|
||||
|
||||
components:
|
||||
schemas:
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: 错误信息
|
||||
example:
|
||||
error: "Invalid request body"
|
||||
|
||||
StatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [PENDING, SCANNED, CONFIRMED, EXPIRED]
|
||||
url:
|
||||
type: string
|
||||
description: 确认后的回调URL
|
||||
|
||||
ScanRequest:
|
||||
type: object
|
||||
required: [kc_session, qr_session, timestamp, sign, token]
|
||||
properties:
|
||||
kc_session:
|
||||
type: string
|
||||
description: Keycloak会话ID
|
||||
qr_session:
|
||||
type: string
|
||||
description: QR会话ID
|
||||
timestamp:
|
||||
type: integer
|
||||
description: 时间戳(秒)
|
||||
sign:
|
||||
type: string
|
||||
description: 请求签名
|
||||
token:
|
||||
type: string
|
||||
description: JWT Token
|
||||
|
||||
ConfirmRequest:
|
||||
type: object
|
||||
required: [kc_session, qr_session, timestamp, sign, token]
|
||||
properties:
|
||||
kc_session:
|
||||
type: string
|
||||
description: Keycloak会话ID
|
||||
qr_session:
|
||||
type: string
|
||||
description: QR会话ID
|
||||
timestamp:
|
||||
type: integer
|
||||
description: 时间戳(秒)
|
||||
sign:
|
||||
type: string
|
||||
description: 请求签名
|
||||
token:
|
||||
type: string
|
||||
description: JWT Token
|
||||
|
||||
ConfirmResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: "ok"
|
||||
error:
|
||||
type: string
|
||||
description: 错误信息
|
||||
|
||||
paths:
|
||||
/qr/scan:
|
||||
post:
|
||||
summary: 扫描二维码
|
||||
description: 用户使用App扫描二维码后调用此接口
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScanRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 扫描成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: "ok"
|
||||
error:
|
||||
type: string
|
||||
'400':
|
||||
description: 请求验证失败
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
||||
/qr/confirm:
|
||||
post:
|
||||
summary: 确认登录
|
||||
description: 用户在App上确认登录后调用此接口
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConfirmRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 确认成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConfirmResponse'
|
||||
'400':
|
||||
description: 请求验证失败
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
||||
/qr/status:
|
||||
get:
|
||||
summary: 查询二维码状态
|
||||
description: 前端轮询查询二维码扫描和确认状态
|
||||
parameters:
|
||||
- name: kc_session
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Keycloak会话ID
|
||||
- name: qr_session
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: QR会话ID
|
||||
- name: timestamp
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: 时间戳(秒)
|
||||
responses:
|
||||
'200':
|
||||
description: 返回二维码状态
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StatusResponse'
|
||||
'400':
|
||||
description: 请求参数错误
|
||||
'403':
|
||||
description: 会话ID不匹配
|
||||
'404':
|
||||
description: 会话不存在或已过期
|
||||
125
src/main/resources/theme/qrlogin/login/resources/js/script.js
Normal file
125
src/main/resources/theme/qrlogin/login/resources/js/script.js
Normal file
@@ -0,0 +1,125 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const qrBtn = document.querySelector('a[id^="social-qrlogin"]');
|
||||
if (!qrBtn) return;
|
||||
|
||||
qrBtn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch(qrBtn.href, {method: "POST"}).then(res => res.text());
|
||||
if (!res) return;
|
||||
|
||||
const json = JSON.parse(res);
|
||||
if (!json.qr_session || !json.kc_session) return;
|
||||
|
||||
const {qr_image_data, ttl, statusUrl, interval} = json;
|
||||
|
||||
// 创建遮罩与弹窗
|
||||
const mask = document.createElement("div");
|
||||
mask.style = `
|
||||
position:fixed;top:0;left:0;right:0;bottom:0;
|
||||
background:rgba(0,0,0,0.45);backdrop-filter:blur(3px);
|
||||
z-index:9998;
|
||||
`;
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.style = `
|
||||
position:fixed;left:50%;top:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
width:380px;padding:24px;border-radius:14px;
|
||||
background:#fff;z-index:9999;
|
||||
box-shadow:0 6px 30px rgba(0,0,0,0.25);
|
||||
text-align:center;font-family:-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
`;
|
||||
|
||||
box.innerHTML = `
|
||||
<h3 style="margin:0 0 12px;color:#222;">扫码登录易识IT账号</h3>
|
||||
<img src="${qr_image_data}" width="240" height="240" style="border:1px solid #eee;border-radius:8px;" />
|
||||
<p id="qr-tip" style="font-size:14px;color:#555;margin-top:10px;">请使用易识IT App 扫描二维码以继续登录</p>
|
||||
<p id="qr-count" style="font-size:12px;color:#888;margin-top:4px;">二维码将在 ${ttl} 秒后失效</p>
|
||||
<button id="qr-close" style="margin-top:14px;padding:6px 20px;border:none;background:#6c757d;color:white;border-radius:6px;cursor:pointer;">取消</button>
|
||||
`;
|
||||
document.body.append(mask, box);
|
||||
|
||||
const tip = document.getElementById("qr-tip");
|
||||
const count = document.getElementById("qr-count");
|
||||
const btn = document.getElementById("qr-close");
|
||||
|
||||
const closeModal = () => {
|
||||
mask.remove();
|
||||
box.remove();
|
||||
window.location.reload();
|
||||
};
|
||||
mask.onclick = closeModal;
|
||||
btn.onclick = closeModal;
|
||||
|
||||
// 记录起始时间和结束时间
|
||||
const startTime = Date.now();
|
||||
const endTime = startTime + (ttl * 1000);
|
||||
let expired = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (expired) return;
|
||||
try {
|
||||
const resp = await fetch(`${statusUrl}${statusUrl.includes('?') ? '&' : '?'}timestamp=${Math.floor(Date.now() / 1000)}`);
|
||||
const data = await resp.json();
|
||||
|
||||
switch (data.status) {
|
||||
case "CONFIRMED":
|
||||
const url = data.url;
|
||||
|
||||
tip.innerText = "身份验证通过,正在跳转...";
|
||||
count.style.display = "none";
|
||||
btn.disabled = true;
|
||||
btn.style.background = "#28a745";
|
||||
setTimeout(() => (window.location.href = url), 600);
|
||||
return;
|
||||
case "SCANNED":
|
||||
tip.innerText = "二维码已扫描,请在手机端确认登录";
|
||||
break;
|
||||
case "PENDING":
|
||||
tip.innerText = "等待扫描,请打开易识IT App 扫描二维码";
|
||||
break;
|
||||
case "EXPIRED":
|
||||
expired = true;
|
||||
tip.innerText = "二维码已失效,请重新开始登录流程";
|
||||
count.style.display = "none";
|
||||
btn.innerText = "重新开始";
|
||||
btn.style.background = "#007bff";
|
||||
btn.onclick = () => {
|
||||
mask.remove();
|
||||
box.remove();
|
||||
qrBtn.click();
|
||||
};
|
||||
return;
|
||||
default:
|
||||
tip.innerText = "正在等待响应,请稍候...";
|
||||
}
|
||||
|
||||
// 基于实际时间计算剩余时间
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, endTime - now) / 1000;
|
||||
|
||||
if (remaining > 0) {
|
||||
count.innerText = `二维码将在 ${Math.round(remaining)} 秒后失效`;
|
||||
setTimeout(poll, interval);
|
||||
} else {
|
||||
expired = true;
|
||||
tip.innerText = "二维码已失效,请重新开始登录流程";
|
||||
count.style.display = "none";
|
||||
btn.innerText = "重新开始";
|
||||
btn.style.background = "#007bff";
|
||||
btn.onclick = () => {
|
||||
mask.remove();
|
||||
box.remove();
|
||||
qrBtn.click();
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
tip.innerText = "网络异常,请检查连接后重试";
|
||||
count.style.display = "none";
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
});
|
||||
7
src/main/resources/theme/qrlogin/login/theme.properties
Normal file
7
src/main/resources/theme/qrlogin/login/theme.properties
Normal file
@@ -0,0 +1,7 @@
|
||||
parent=keycloak.v2
|
||||
import=common/keycloak
|
||||
|
||||
locales=ar,ca,cs,da,de,el,en,es,fa,fr,fi,hr,hu,it,ja,lt,lv,nl,no,pl,pt,pt-BR,ru,sk,sv,th,tr,uk,zh-CN,zh-TW
|
||||
logo=/logo.png
|
||||
styles=/css/styles.css css/login.css
|
||||
scripts=js/script.js
|
||||
135
src/test/java/sign/test.java
Normal file
135
src/test/java/sign/test.java
Normal file
@@ -0,0 +1,135 @@
|
||||
package sign;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
public class test {
|
||||
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private static final long TIME_WINDOW_MS = 5_000; // 严格限制:5秒(±5秒,共10秒窗口)
|
||||
|
||||
private final String secret;
|
||||
|
||||
public test(String secret) {
|
||||
if (secret == null || secret.isEmpty()) {
|
||||
throw new IllegalArgumentException("Secret must not be null or empty");
|
||||
}
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端:生成签名
|
||||
*/
|
||||
public String sign(Map<String, String> params) {
|
||||
if (!params.containsKey("timestamp")) {
|
||||
throw new IllegalArgumentException("Missing 'timestamp'");
|
||||
}
|
||||
String queryString = buildQueryString(params);
|
||||
return hmacSha256(queryString, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端:验证签名 + 时间戳(±5秒)
|
||||
*/
|
||||
public boolean verify(Map<String, String> params) {
|
||||
String sign = params.get("sign");
|
||||
if (sign == null || sign.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing 'sign'");
|
||||
}
|
||||
|
||||
// 解析 timestamp(单位:秒)
|
||||
long timestampSec;
|
||||
try {
|
||||
timestampSec = Long.parseLong(params.get("timestamp"));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Invalid 'timestamp'");
|
||||
}
|
||||
|
||||
long nowSec = System.currentTimeMillis() / 1000;
|
||||
long diff = Math.abs(nowSec - timestampSec);
|
||||
|
||||
if (diff > 5000) { // 超过5秒
|
||||
throw new IllegalArgumentException("Request expired: timestamp deviation > 5s (now=" + nowSec + ", req=" + timestampSec + ")");
|
||||
}
|
||||
|
||||
// 重新计算签名(排除 sign)
|
||||
Map<String, String> signParams = new HashMap<>(params);
|
||||
signParams.remove("sign");
|
||||
String queryString = buildQueryString(signParams);
|
||||
String expectedSign = hmacSha256(queryString, secret);
|
||||
|
||||
if (!safeEquals(sign, expectedSign)) {
|
||||
throw new IllegalArgumentException("Invalid signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String buildQueryString(Map<String, String> params) {
|
||||
List<String> keys = new ArrayList<>(params.keySet());
|
||||
Collections.sort(keys);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < keys.size(); i++) {
|
||||
String key = keys.get(i);
|
||||
String value = params.getOrDefault(key, "");
|
||||
if (i > 0) sb.append("&");
|
||||
sb.append(key).append("=").append(value);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String hmacSha256(String data, String key) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
|
||||
mac.init(secretKey);
|
||||
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
return bytesToHex(hash);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new RuntimeException("HMAC-SHA256 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 恒定时间比较(防时序攻击)
|
||||
private boolean safeEquals(String a, String b) {
|
||||
if (a == null || b == null) return false;
|
||||
if (a.length() != b.length()) return false;
|
||||
byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
|
||||
return Arrays.equals(aBytes, bBytes); // 在大多数 JVM 实现中足够安全
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
long timestamp = System.currentTimeMillis() / 1000; // 必须用秒!
|
||||
timestamp -= 6;
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("kc_session", "abce3cb7-6345-4e54-8708-9b5e5fe463f8");
|
||||
params.put("token", "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ0REZLaWNZbWktWWZVd1RoV3djcF9IckdoSHl1YUwtdThZa2tQYmtEVlZjIn0.eyJleHAiOjE3NjIwOTU1NDYsImlhdCI6MTc2MjA5NTI0NiwiYXV0aF90aW1lIjoxNzYyMDY2NTMwLCJqdGkiOiJvZnJ0cnQ6NzIwZDNiMDEtNjNmNC00Mzg5LTkwODQtMGU0N2U4NWI2YzI3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiOTg2MDU2ZmYtZWU4ZC00NjRlLTk0ZjItNTE2YWI2MjlkYWYwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdCIsInNpZCI6IjJjM2YwY2EwLWE2ZWEtNGNiMC1iZjFmLTc5ZjA3Njk3YjcwOCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovLzEyNy4wLjAuMTo1MDAwIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW1hc3RlciIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiLkvKDnjpYg5p2OIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdDAwMSIsImdpdmVuX25hbWUiOiLkvKDnjpYiLCJsb2NhbGUiOiJ6aC1DTiIsImZhbWlseV9uYW1lIjoi5p2OIiwiZW1haWwiOiIxNzg3MDEwODk5NkAxNjMuY29tIn0.BpglUjBuownC2Tc5zN_l-387zrBrqABsGNsjqBjs-rqls8AChdST5iC1mGoEGsFyly72NQlmc2uSbSXsg-s5wEkaBm2WYXmx6-wVVhXp_t4LsKg1kswAqrwi3HRYMxWYbrp-adJIIRuCJIrXjvWYRPjNWI6UAg044pmu3B5pdhikS_SK-T9LnLdqs3A70WQGV2atLnZPWdyGQobv84_JQjE0kWRRBeroJHUBp338Z55_eSDRQpj6NN_xitQtNaqmG67iLO5N7Ao5VXLokNH6waHl6sREmHWd8R7rkVba2Ah2iJezJabmfiigArbcPcuJ8Aha4n0O2WQ9wjfKTa8LOQ");
|
||||
params.put("timestamp", String.valueOf(1762095272));
|
||||
params.put("qr_session", "9206d317-73a2-4fd1-a5ea-aa74af5fb73b");
|
||||
|
||||
test signer = new test("lichuanjiu");
|
||||
String sign = signer.sign(params);
|
||||
params.put("sign", sign);
|
||||
|
||||
|
||||
System.out.println("Signature: " + sign);
|
||||
|
||||
// 从请求中解析 params(含 sign)
|
||||
boolean valid = signer.verify(params);
|
||||
System.out.println("Valid: " + valid);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user