基于Keycloak的JEE应用:如何通过OIDC Backchannel Logout及REST调用销毁用户浏览器会话
解决Keycloak Backchannel Logout下的JEE会话失效问题
我之前在处理JEE应用结合Keycloak做SSO的场景时,刚好碰到过一模一样的问题,给你分享两个核心疑问的可行解决方案:
1. 处理Backchannel Logout时定位并失效用户浏览器会话
Servlet容器本身并没有提供直接根据用户ID查找会话的API,所以我们需要自己维护一个用户ID与HttpSession的映射关系,具体步骤如下:
- 维护全局会话映射
首先定义一个线程安全的全局存储,用来关联用户唯一标识(也就是LogoutToken里的sub字段)和对应的HttpSession集合:
import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import javax.servlet.http.HttpSession; public class SessionRegistry { private static final ConcurrentMap<String, Set<HttpSession>> USER_SESSIONS = new ConcurrentHashMap<>(); // 添加会话到用户映射 public static void addSession(String userId, HttpSession session) { USER_SESSIONS.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); // 给会话绑定一个属性,方便后续销毁时识别用户ID session.setAttribute("userId", userId); } // 根据用户ID获取所有会话 public static Set<HttpSession> getSessionsForUser(String userId) { return USER_SESSIONS.getOrDefault(userId, ConcurrentHashMap.newKeySet()); } // 从映射中移除会话 public static void removeSession(HttpSession session) { String userId = (String) session.getAttribute("userId"); if (userId != null) { Set<HttpSession> sessions = USER_SESSIONS.get(userId); if (sessions != null) { sessions.remove(session); // 如果该用户没有会话了,清理映射项 if (sessions.isEmpty()) { USER_SESSIONS.remove(userId); } } } } }
- 登录时绑定会话与用户ID
创建一个Servlet Filter,在用户通过Keycloak登录成功后,把当前会话和用户的sub关联起来:
import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import org.keycloak.KeycloakSecurityContext; @WebFilter("/*") public class SessionBindingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; KeycloakSecurityContext securityContext = (KeycloakSecurityContext) httpRequest.getAttribute(KeycloakSecurityContext.class.getName()); // 只有当用户已登录且会话未绑定过用户ID时执行 if (securityContext != null && httpRequest.getSession().getAttribute("userId") == null) { String userId = securityContext.getToken().getSubject(); SessionRegistry.addSession(userId, httpRequest.getSession()); } chain.doFilter(request, response); } // 初始化和销毁方法按需实现 @Override public void init(FilterConfig filterConfig) throws ServletException {} @Override public void destroy() {} }
- 监听会话销毁,清理映射
实现HttpSessionListener,防止会话过期或手动销毁后,全局映射中残留无效会话导致内存泄漏:
import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; @WebListener public class SessionCleanupListener implements HttpSessionListener { @Override public void sessionDestroyed(HttpSessionEvent se) { SessionRegistry.removeSession(se.getSession()); } @Override public void sessionCreated(HttpSessionEvent se) {} }
- 在Backchannel Logout端点处理会话失效
现在在你的/backchannel-logout端点中,解析LogoutToken拿到sub后,就可以批量失效用户的所有会话:
import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.keycloak.jose.jwt.Jwt; import org.keycloak.jose.jwt.JwtParser; @WebServlet("/backchannel-logout") public class BackchannelLogoutServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) { String logoutTokenStr = request.getParameter("logout_token"); // 解析LogoutToken(这里可以用Keycloak的JwtParser工具) JwtParser parser = new JwtParser(); Jwt logoutToken = parser.parse(logoutTokenStr); String userId = logoutToken.getSubject(); // 获取该用户的所有会话并失效 SessionRegistry.getSessionsForUser(userId).forEach(session -> { try { session.invalidate(); } catch (Exception e) { // 处理会话已失效的异常,比如日志记录 e.printStackTrace(); } }); // 返回200表示处理成功 response.setStatus(HttpServletResponse.SC_OK); } }
2. 实现管理员批量登出用户的端点
基于上面的SessionRegistry,实现一个受权限保护的端点就非常简单了:
- 创建管理员登出端点
import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.keycloak.KeycloakSecurityContext; import org.keycloak.representations.AccessToken; @WebServlet("/admin/logout-user") public class AdminUserLogoutServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 先验证当前请求的用户是否是管理员 KeycloakSecurityContext securityContext = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName()); AccessToken token = securityContext.getToken(); if (!token.getRealmAccess().getRoles().contains("admin")) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); return; } // 获取请求参数中的userId String userId = request.getParameter("userId"); if (userId == null || userId.isEmpty()) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } // 失效该用户的所有会话 SessionRegistry.getSessionsForUser(userId).forEach(session -> { try { session.invalidate(); } catch (Exception e) { e.printStackTrace(); } }); response.setStatus(HttpServletResponse.SC_OK); } }
- 注意事项
- 一定要确保这个端点的权限控制严格,只有具备管理员角色的用户才能访问,避免滥用。
- 可以根据实际需求调整权限验证逻辑,比如用Keycloak的角色或者其他权限机制。
内容的提问来源于stack exchange,提问作者amdev
相关产品推荐
相关产品推荐

