Spring Security OAuth2在微前端架构下实现统一退出

Spring Security OAuth2在微前端架构下实现统一退出

jonathan
2019-07-21 / 0 评论

Spring Security OAuth2在微前端架构下实现统一退出

在微前端架构中,当一个应用退出登录时,实现所有应用同步退出的功能是一个常见需求。这个问题主要源于微前端架构下各个子应用之间的分离特性,导致一个应用注销后,其他应用仍保持登录状态。下面我将详细介绍如何解决这个问题。

问题分析

微前端架构下,登出问题主要有以下几个原因:

  1. 各子应用可能使用独立的会话管理
  2. OAuth2 token可能在各应用中独立存储
  3. 缺乏统一的退出机制来协调各个子应用

解决方案

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);
                    }
                }
            }
        }
    }
}

实现要点解析

  1. 统一后端登出端点

    • 清除服务器端会话
    • 撤销OAuth2令牌
    • 删除相关Cookie
    • 通知OAuth2授权服务器
  2. 前端统一登出机制

    • 使用BroadcastChannel进行跨应用通信
    • 共享认证服务实现登出状态同步
    • 提供全局登出方法
  3. 使用Redis共享会话

    • 确保所有后端服务访问相同的会话存储
    • 支持会话的快速失效
  4. 前端存储清理

    • 清除localStorage和sessionStorage中的认证信息
    • 删除认证相关Cookie

最佳实践

  1. 使用OpenID Connect(OIDC)标准

    • 利用OIDC的标准登出流程
    • 实现前后端退出的协调
  2. 使用统一的会话ID

    • 为所有微前端应用使用相同的会话标识
    • 避免使用多种会话管理方式
  3. 实现健壮的错误处理

    • 当部分退出流程失败时仍能完成整体退出
    • 提供清晰的用户反馈
  4. 安全考虑

    • 确保退出端点的安全访问
    • 防止CSRF攻击

结论

通过上述方案的实现,可以确保微前端架构下的统一退出机制,使得用户在任一子应用退出后,所有应用都同步退出登录状态。这种方案既保证了用户体验的一致性,也提高了系统的安全性,避免了部分应用仍保持登录状态可能带来的安全风险。

刚好再学react,就用react写前端了

评论

博主关闭了当前页面的评论