首页
在线工具
搜索
1
Kuboard与KubeSphere的区别:Kubernetes管理平台对比
2
ShardingSphere使用中的重点问题剖析
3
Flowable工作流引擎源码深度解析
4
用AI生成的原型设计稿效果还可以
5
如何将Virtualbox和VMware虚拟机相互转换
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
Search
标签搜索
Angular
Docker
Phabricator
SpringBoot
Java
Chrome
SpringSecurity
SpringCloud
DDD
Git
Mac
K8S
Kubernetes
ESLint
SSH
高并发
Eclipse
Javascript
Vim
Centos
Jonathan
累计撰写
86
篇文章
累计收到
0
条评论
首页
栏目
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
页面
搜索到
1
篇与
的结果
2019-07-21
Spring Security OAuth2在微前端架构下实现统一退出
Spring Security OAuth2在微前端架构下实现统一退出 在微前端架构中,当一个应用退出登录时,实现所有应用同步退出的功能是一个常见需求。这个问题主要源于微前端架构下各个子应用之间的分离特性,导致一个应用注销后,其他应用仍保持登录状态。下面我将详细介绍如何解决这个问题。 问题分析 微前端架构下,登出问题主要有以下几个原因: 各子应用可能使用独立的会话管理 OAuth2 token可能在各应用中独立存储 缺乏统一的退出机制来协调各个子应用 解决方案 1. 实现单点登出(Single Sign-Out) @RestController public class LogoutController { private final TokenStore tokenStore; private final ClientRegistrationRepository clientRegistrationRepository; public LogoutController(TokenStore tokenStore, ClientRegistrationRepository clientRegistrationRepository) { this.tokenStore = tokenStore; this.clientRegistrationRepository = clientRegistrationRepository; } @PostMapping("/api/logout") public ResponseEntity<Map<String, String>> logout( @RequestParam(value = "token", required = false) String token, HttpServletRequest request, HttpServletResponse response, @AuthenticationPrincipal OAuth2User principal) { // 获取当前认证信息 Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 如果存在认证信息,则进行处理 if (auth != null) { // 撤销token if (auth instanceof OAuth2AuthenticationToken) { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) auth; String clientRegistrationId = oauthToken.getAuthorizedClientRegistrationId(); // 根据需要调用OAuth2服务器的撤销接口 revokeToken(clientRegistrationId, token); } // 清除安全上下文 SecurityContextHolder.clearContext(); // 使session失效 HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } // 清除相关cookie deleteCookies(request, response); } Map<String, String> result = new HashMap<>(); result.put("message", "成功退出登录"); return ResponseEntity.ok(result); } private void revokeToken(String clientRegistrationId, String token) { try { ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(clientRegistrationId); if (clientRegistration != null && token != null) { // 根据OAuth2服务器提供的撤销端点进行token撤销 String revokeEndpoint = clientRegistration.getProviderDetails().getConfigurationMetadata().get("revocation_endpoint").toString(); RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); map.add("token", token); map.add("client_id", clientRegistration.getClientId()); map.add("client_secret", clientRegistration.getClientSecret()); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers); restTemplate.postForEntity(revokeEndpoint, request, String.class); } } catch (Exception e) { // 处理可能的异常 logger.error("撤销token失败", e); } } private void deleteCookies(HttpServletRequest request, HttpServletResponse response) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { // 删除认证相关的cookie if (cookie.getName().startsWith("OAUTH2_") || cookie.getName().equals("JSESSIONID") || cookie.getName().equals("SESSION")) { Cookie newCookie = new Cookie(cookie.getName(), null); newCookie.setPath("/"); newCookie.setMaxAge(0); newCookie.setHttpOnly(true); response.addCookie(newCookie); } } } } } 2. 创建统一的登出前端服务 // auth-service.js export class AuthService { constructor() { this.logoutListeners = []; this.setupLogoutListener(); } // 添加登出监听器 addLogoutListener(callback) { this.logoutListeners.push(callback); return () => { this.logoutListeners = this.logoutListeners.filter(cb => cb !== callback); }; } // 发送登出事件到所有注册的应用 notifyLogout() { // 通过BroadcastChannel发送消息 const bc = new BroadcastChannel('auth-logout-channel'); bc.postMessage({ type: 'LOGOUT' }); bc.close(); // 同时触发监听器 this.logoutListeners.forEach(callback => callback()); } // 监听其他应用的登出事件 setupLogoutListener() { const bc = new BroadcastChannel('auth-logout-channel'); bc.onmessage = (event) => { if (event.data.type === 'LOGOUT') { // 执行本地登出操作 this.logout(false); // 不广播,避免循环 } }; } // 登出方法 async logout(broadcast = true) { try { // 调用后端登出API const response = await fetch('/api/logout', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { // 清除本地存储 localStorage.removeItem('oauth2_token'); sessionStorage.removeItem('oauth2_token'); // 清除相关cookie this.clearAuthCookies(); // 通知其他应用 if (broadcast) { this.notifyLogout(); } return true; } else { console.error('登出失败:', await response.text()); return false; } } catch (error) { console.error('登出过程中发生错误:', error); return false; } } // 清除认证相关cookie clearAuthCookies() { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i]; const eqPos = cookie.indexOf('='); const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim(); if (name.startsWith('OAUTH2_') || name === 'JSESSIONID' || name === 'SESSION') { document.cookie = `${name}=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`; } } } // 检查是否已登录 isAuthenticated() { // 根据实际存储方式检查认证状态 return localStorage.getItem('oauth2_token') !== null || sessionStorage.getItem('oauth2_token') !== null; } } // 导出单例 export const authService = new AuthService(); 3. 创建主应用的登出组件 // LogoutButton.jsx (React组件示例) import React from 'react'; import { authService } from './auth-service'; const LogoutButton = () => { const handleLogout = async () => { const success = await authService.logout(); if (success) { // 重定向到登录页或首页 window.location.href = '/login'; } else { alert('登出失败,请重试'); } }; return ( <button className="logout-button" onClick={handleLogout} > 退出登录 </button> ); }; export default LogoutButton; 4. 为各个微前端应用添加登出监听 // 在每个子应用的入口文件中添加 import { authService } from 'shared-lib/auth-service'; class MicroApp { constructor() { this.init(); } init() { // 注册登出监听器 this.unsubscribeLogout = authService.addLogoutListener(() => { // 当收到登出事件时,清理本地状态 this.handleLogout(); }); // 监听卸载事件,移除监听器 window.addEventListener('unload', () => { if (this.unsubscribeLogout) { this.unsubscribeLogout(); } }); // 设置全局登出处理方法 window.microAppLogout = () => { authService.logout(); }; } handleLogout() { // 清理本地存储 localStorage.removeItem('app_state'); sessionStorage.removeItem('app_state'); // 重置应用状态 this.resetAppState(); // 可选:重定向到登录页 window.location.href = '/login'; } resetAppState() { // 重置应用状态逻辑 // ... } } // 初始化应用 const app = new MicroApp(); 5. 使用Redis实现会话共享 为了确保后端会话的统一管理,应使用Redis存储会话: @Configuration @EnableRedisHttpSession public class RedisSessionConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // 使用Jackson2为序列化和反序列化 Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } @Bean public HttpSessionIdResolver httpSessionIdResolver() { // 使用Cookie和Header双重策略,提高灵活性 return new CookieAndHeaderHttpSessionIdResolver(); } } // 自定义会话ID解析器,支持Cookie和Header class CookieAndHeaderHttpSessionIdResolver implements HttpSessionIdResolver { private static final String HEADER_NAME = "X-Auth-Token"; private final CookieHttpSessionIdResolver cookieResolver = new CookieHttpSessionIdResolver(); private final HeaderHttpSessionIdResolver headerResolver = HeaderHttpSessionIdResolver.xAuthToken(); @Override public List<String> resolveSessionIds(HttpServletRequest request) { List<String> sessionIds = cookieResolver.resolveSessionIds(request); if (sessionIds.isEmpty()) { sessionIds = headerResolver.resolveSessionIds(request); } return sessionIds; } @Override public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) { cookieResolver.setSessionId(request, response, sessionId); headerResolver.setSessionId(request, response, sessionId); } @Override public void expireSession(HttpServletRequest request, HttpServletResponse response) { cookieResolver.expireSession(request, response); headerResolver.expireSession(request, response); } } 6. 实现OAuth2统一退出端点 为了完全支持OAuth2退出,还需要实现一个注销端点连接到OAuth2服务器: @Configuration public class OAuth2LogoutConfig { private final ClientRegistrationRepository clientRegistrationRepository; public OAuth2LogoutConfig(ClientRegistrationRepository clientRegistrationRepository) { this.clientRegistrationRepository = clientRegistrationRepository; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/public/**", "/login", "/oauth2/authorization/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .loginPage("/login") .defaultSuccessUrl("/dashboard") ) .logout(logout -> logout .logoutUrl("/api/logout") .logoutSuccessHandler(this.oidcLogoutSuccessHandler()) .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID", "SESSION") ); return http.build(); } private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository); // 设置退出后的重定向URL oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout=success"); return (request, response, authentication) -> { // 获取会话中的信息用于撤销token等操作 HttpSession session = request.getSession(false); if (session != null) { String idToken = (String) session.getAttribute("id_token"); if (idToken != null && authentication instanceof OAuth2AuthenticationToken) { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; ClientRegistration clientRegistration = this.clientRegistrationRepository .findByRegistrationId(oauthToken.getAuthorizedClientRegistrationId()); // 尝试撤销令牌 try { revokeTokens(clientRegistration, request); } catch (Exception e) { // 记录错误但继续退出流程 e.printStackTrace(); } } } // 调用OIDC退出处理器 oidcLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); }; } private void revokeTokens(ClientRegistration clientRegistration, HttpServletRequest request) { // 实现令牌撤销逻辑 HttpSession session = request.getSession(false); if (session != null && clientRegistration != null) { String accessToken = (String) session.getAttribute("access_token"); String refreshToken = (String) session.getAttribute("refresh_token"); if (accessToken != null || refreshToken != null) { // 检查是否有撤销端点配置 Object revocationUri = clientRegistration.getProviderDetails() .getConfigurationMetadata().get("revocation_endpoint"); if (revocationUri != null) { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); // 撤销访问令牌 if (accessToken != null) { MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("token", accessToken); body.add("token_type_hint", "access_token"); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers); restTemplate.postForEntity(revocationUri.toString(), entity, String.class); } // 撤销刷新令牌 if (refreshToken != null) { MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("token", refreshToken); body.add("token_type_hint", "refresh_token"); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers); restTemplate.postForEntity(revocationUri.toString(), entity, String.class); } } } } } } 实现要点解析 统一后端登出端点 清除服务器端会话 撤销OAuth2令牌 删除相关Cookie 通知OAuth2授权服务器 前端统一登出机制 使用BroadcastChannel进行跨应用通信 共享认证服务实现登出状态同步 提供全局登出方法 使用Redis共享会话 确保所有后端服务访问相同的会话存储 支持会话的快速失效 前端存储清理 清除localStorage和sessionStorage中的认证信息 删除认证相关Cookie 最佳实践 使用OpenID Connect(OIDC)标准 利用OIDC的标准登出流程 实现前后端退出的协调 使用统一的会话ID 为所有微前端应用使用相同的会话标识 避免使用多种会话管理方式 实现健壮的错误处理 当部分退出流程失败时仍能完成整体退出 提供清晰的用户反馈 安全考虑 确保退出端点的安全访问 防止CSRF攻击 结论 通过上述方案的实现,可以确保微前端架构下的统一退出机制,使得用户在任一子应用退出后,所有应用都同步退出登录状态。这种方案既保证了用户体验的一致性,也提高了系统的安全性,避免了部分应用仍保持登录状态可能带来的安全风险。 刚好再学react,就用react写前端了
2019年07月21日