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写前端了
评论