Keycloak单设备登录:实现旧设备自动登出的技术问询
解决方案
一、旧设备自动登出实现
1. 基于Keycloak会话事件的实时通知
利用Keycloak的会话事件机制,结合后端SSE通道推送登出指令:
- 在Spring Boot后端通过Keycloak Admin API监听
SESSION_REMOVED事件(可采用长轮询或Keycloak 17+支持的Webhook方式)。 - 当检测到用户旧会话被终止时,调用SSE连接管理器向该用户的旧设备连接发送登出消息。
- Angular端监听SSE的
logout事件,收到消息后立即执行登出逻辑(清除本地令牌、跳转登录页),无需手动刷新。
2. 后端令牌会话校验拦截器
不要仅依赖JWT签名有效性,每次请求(包括SSE连接)都校验令牌对应的Keycloak会话状态:
- 实现Spring Boot全局拦截器,调用Keycloak的
Introspect Token API校验令牌关联的会话是否活跃。 - 若会话已失效,直接返回401状态码,Angular的HTTP拦截器捕获后触发自动登出流程。
二、SSE单活跃连接保障
1. 后端用户连接映射管理
维护全局连接映射,确保同一用户仅保留一个活跃SSE连接:
- 用
ConcurrentHashMap<String, SseEmitter>存储用户ID与对应SSE发射器的映射。 - 新连接建立时,先关闭该用户的旧连接并发送登出通知,再将新连接存入映射。
- 连接超时或完成时自动移除映射,避免资源泄漏。
2. 前端连接状态管控
Angular端封装SSE服务,确保同一时间仅存在一个有效连接:
- 页面刷新或路由切换时,先关闭旧连接再创建新连接。
- 监听连接异常,自动重试前检查是否已有有效连接,避免重复创建。
代码示例
Spring Boot SSE连接管理器
@Component public class SseConnectionManager { private final Map<String, SseEmitter> userEmitters = new ConcurrentHashMap<>(); public SseEmitter createEmitter(String userId) { // 关闭旧连接并发送登出通知 SseEmitter oldEmitter = userEmitters.remove(userId); if (oldEmitter != null) { try { oldEmitter.send(SseEmitter.event().name("logout").data("会话已被新登录终止")); oldEmitter.complete(); } catch (IOException e) { // 异常处理 } } // 创建新连接 SseEmitter emitter = new SseEmitter(3600000L); userEmitters.put(userId, emitter); // 连接结束时清理映射 emitter.onCompletion(() -> userEmitters.remove(userId)); emitter.onTimeout(() -> userEmitters.remove(userId)); return emitter; } // 主动发送登出通知 public void sendLogoutNotification(String userId) { SseEmitter emitter = userEmitters.get(userId); if (emitter != null) { try { emitter.send(SseEmitter.event().name("logout").data("你的账号在其他设备登录,当前会话已终止")); emitter.complete(); userEmitters.remove(userId); } catch (IOException e) { userEmitters.remove(userId); } } } }
Angular SSE服务与登出监听
@Injectable({ providedIn: 'root' }) export class SseService { private eventSource: EventSource | null = null; private userId: string | null = null; constructor(private authService: AuthService) { this.userId = this.authService.getCurrentUserId(); } connect(): Observable<MessageEvent> { // 关闭已有连接 if (this.eventSource) { this.eventSource.close(); } this.eventSource = new EventSource(`/api/sse/connect?userId=${this.userId}`); return new Observable(observer => { this.eventSource!.addEventListener('logout', () => { this.authService.logout(); // 执行登出:清除本地令牌、跳转登录页 }); this.eventSource!.onerror = () => { this.eventSource?.close(); this.eventSource = null; observer.error('SSE连接异常'); }; }); } }
Keycloak会话事件监听(Spring Boot)
@Component public class KeycloakSessionMonitor { private final Keycloak keycloak; private final SseConnectionManager sseManager; public KeycloakSessionMonitor(Keycloak keycloak, SseConnectionManager sseManager) { this.keycloak = keycloak; this.sseManager = sseManager; } // 长轮询检测已终止会话,可根据业务调整轮询间隔 @Scheduled(fixedDelay = 5000) public void checkTerminatedSessions() { List<UserSessionRepresentation> terminatedSessions = keycloak.realm("你的Realm名称") .users() .list() .stream() .flatMap(user -> keycloak.realm("你的Realm名称").users().get(user.getId()).getUserSessions().stream()) .filter(session -> !session.isActive()) .toList(); terminatedSessions.forEach(session -> sseManager.sendLogoutNotification(session.getUserId())); } }
内容的提问来源于stack exchange,提问作者Adrienn
相关产品推荐
相关产品推荐

