fix(qr-login): restore AuthenticationSession on external QR callback flow

解决扫码登录在跨设备回调场景下丢失 AuthenticationSession 的问题。

在 QR 登录流程中,浏览器端发起登录请求时会创建 AuthenticationSession,
但移动端扫码确认回调是独立请求,不携带浏览器 Cookie 和会话上下文,
导致直接持有的 AuthenticationSessionModel 引用无效并返回 null。

修复内容:
- 移除直接缓存 AuthenticationSessionModel 引用的方式
- 使用 AuthenticationSessionManager + kc_session_id + tabId 恢复浏览器会话
- 确保扫码回调能够正确续接原始认证流程并完成 Broker 登录

该修复提高了跨设备扫码登录的稳定性与兼容性,避免因会话断链导致登录失败。
This commit is contained in:
huiyiruciduojiao
2025-11-06 00:52:48 +08:00
parent e1c7f42b8d
commit b39f9d109f
5 changed files with 45 additions and 8 deletions

View File

@@ -11,7 +11,7 @@
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<keycloak.version>26.2.4</keycloak.version> <keycloak.version>26.4.2</keycloak.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>

View File

@@ -86,4 +86,6 @@ public class QRSession {
public void setResponseUrl(String responseUrl) { public void setResponseUrl(String responseUrl) {
this.responseUrl = responseUrl; this.responseUrl = responseUrl;
} }
} }

View File

@@ -18,7 +18,7 @@ public class TokenUtil {
if (token == null || token.isEmpty()) { if (token == null || token.isEmpty()) {
return TokenValidationResult.invalid("token 为空或无效"); return TokenValidationResult.invalid("token 为空或无效");
} }
if (config.baseUrl == null || config.realm == null || config.clientId == null || config.clientSecret == null){ if (config.baseUrl == null || config.realm == null || config.clientId == null || config.clientSecret == null) {
return TokenValidationResult.invalid("配置错误"); return TokenValidationResult.invalid("配置错误");
} }
@@ -62,8 +62,6 @@ public class TokenUtil {
JsonObject payload = tokenToJson(token); JsonObject payload = tokenToJson(token);
String email = payload.getString("email", null); String email = payload.getString("email", null);
String sub = json.getString("sub", null); String sub = json.getString("sub", null);
return TokenValidationResult.valid(username, clientId, email, sub, scope, exp); return TokenValidationResult.valid(username, clientId, email, sub, scope, exp);
} catch (Exception e) { } catch (Exception e) {
@@ -82,7 +80,6 @@ public class TokenUtil {
if (token == null || token.isEmpty()) { if (token == null || token.isEmpty()) {
return null; return null;
} }
try { try {
// JWT token 由三部分组成用点分隔header.payload.signature // JWT token 由三部分组成用点分隔header.payload.signature
String[] parts = token.split("\\."); String[] parts = token.split("\\.");
@@ -103,7 +100,8 @@ public class TokenUtil {
} }
// Base64 解码 // Base64 解码
byte[] decodedBytes = java.util.Base64.getDecoder().decode(payload); // 使用URL安全的Base64解码器
byte[] decodedBytes = java.util.Base64.getUrlDecoder().decode(payload);
String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); String decodedString = new String(decodedBytes, StandardCharsets.UTF_8);
// 解析为 JsonObject // 解析为 JsonObject

View File

@@ -12,7 +12,9 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import top.ysit.qrlogin.config.QRLoginConfig; import top.ysit.qrlogin.config.QRLoginConfig;
@@ -71,6 +73,7 @@ public class QRLoginIdentityProvider extends AbstractIdentityProvider<IdentityPr
s.setCreatedAt(Instant.now()); s.setCreatedAt(Instant.now());
s.setKcSessionId(authSession.getParentSession().getId()); s.setKcSessionId(authSession.getParentSession().getId());
s.setAuthSession(authSession); s.setAuthSession(authSession);
store.put(s); store.put(s);
@@ -166,13 +169,16 @@ public class QRLoginIdentityProvider extends AbstractIdentityProvider<IdentityPr
if (!tokenValidationResult.valid()) { if (!tokenValidationResult.valid()) {
return Response.status(Response.Status.NOT_FOUND).build(); return Response.status(Response.Status.NOT_FOUND).build();
} }
AuthenticationSessionManager asm = new AuthenticationSessionManager(qrIdp.session);
RootAuthenticationSessionModel root = asm.getCurrentRootAuthenticationSession(qrIdp.session.getContext().getRealm());
AuthenticationSessionModel authSession = root.getAuthenticationSession(qrs.getAuthSession().getClient(), qrs.getAuthSession().getTabId());
BrokeredIdentityContext federatedIdentity = new BrokeredIdentityContext(tokenValidationResult.sub(), qrIdp.getConfig()); BrokeredIdentityContext federatedIdentity = new BrokeredIdentityContext(tokenValidationResult.sub(), qrIdp.getConfig());
federatedIdentity.setIdp(qrIdp); federatedIdentity.setIdp(qrIdp);
federatedIdentity.setUsername(tokenValidationResult.username()); federatedIdentity.setUsername(tokenValidationResult.username());
federatedIdentity.setEmail(tokenValidationResult.email()); federatedIdentity.setEmail(tokenValidationResult.email());
federatedIdentity.setAuthenticationSession(qrs.getAuthSession()); federatedIdentity.setAuthenticationSession(authSession);
return callback.authenticated(federatedIdentity); return callback.authenticated(federatedIdentity);

View File

@@ -61,8 +61,20 @@ document.addEventListener("DOMContentLoaded", () => {
if (expired) return; if (expired) return;
try { try {
const resp = await fetch(`${statusUrl}${statusUrl.includes('?') ? '&' : '?'}timestamp=${Math.floor(Date.now() / 1000)}`); const resp = await fetch(`${statusUrl}${statusUrl.includes('?') ? '&' : '?'}timestamp=${Math.floor(Date.now() / 1000)}`);
// 检查HTTP状态码处理404等情况
if (!resp.ok) {
if (resp.status === 404) {
// 处理404错误视为二维码失效
handleQRCodeExpired();
return;
} else {
throw new Error(`HTTP error! status: ${resp.status}`);
}
}
const data = await resp.json(); const data = await resp.json();
switch (data.status) { switch (data.status) {
case "CONFIRMED": case "CONFIRMED":
const url = data.url; const url = data.url;
@@ -121,5 +133,24 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}; };
poll(); poll();
const handleQRCodeExpired = () => {
expired = true;
tip.innerText = "二维码已失效,请重新开始登录流程";
tip.style.color = "#dc3545";
count.style.display = "none";
// 模糊二维码图像
const qrImage = box.querySelector('img');
if (qrImage) {
qrImage.style.filter = "blur(4px)";
}
btn.innerText = "重新开始";
btn.style.background = "#007bff";
btn.onclick = () => {
window.location.reload();
};
};
}); });
}); });