从漏洞到防护:Spring MVC的CORS/CSRF/OAuth2安全三部曲

在这里插入图片描述

肖哥弹架构 跟大家“弹弹” Spring MVC设计与实战应用,需要代码关注

欢迎 点赞,点赞,点赞。

关注公号Solomon肖哥弹架构获取更多精彩内容

历史热点文章

在现代Web应用开发中,安全性已不再是可选项,而是必备的基石。随着前后端分离架构的普及和微服务的广泛应用,开发者面临着安全的关键挑战。

为什么你的API总被安全团队挑战? 可能是因为缺少这些进阶配置:

🛡️ CORS如外科手术般精准
• 基于@CrossOrigin的控制器级配置
• 动态CorsConfigurationSource实现域名白名单

🛡️ CSRF的六层防御体系
• 重要操作强制验证Referer头
• 自动排除/api/**等无需防护的接口

🛡️ OAuth2资源服务器
• 从JWT中提取多租户信息(tenant_id声明)
• 自定义AuthenticationEntryPoint返回标准错误格式

二、安全与合规

1. CORS 精细化控制

在这里插入图片描述

【作用】

实现细粒度的跨域资源共享控制,包括:

  • 动态Origin白名单
  • 多维度策略配置(方法/头部/凭证)
  • 预检请求缓存优化
  • 生产/测试环境差异化配置
【解决的问题】
  1. 前后端分离项目的跨域访问
  2. 多环境下的策略差异(如开发环境允许所有Origin)
  3. 安全合规要求的严格限制
  4. 预检请求的性能损耗
【请求/响应示例】

预检请求

OPTIONS /api/users HTTP/1.1
Origin: https://frontend.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

成功响应

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: GET,POST,PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 86400
Vary: Origin

被拒响应

HTTP/1.1 403 Forbidden
Access-Control-Allow-Origin: https://frontend.com
【核心实现代码】
1. 动态CORS配置(推荐方案)
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${app.cors.allowed-origins}")
    private String[] allowedOrigins;
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(allowedOrigins) // 动态配置源
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .exposedHeaders("X-Custom-Header")
            .allowCredentials(true)
            .maxAge(3600); // 预检结果缓存1小时
        
        // 管理接口特殊配置
        registry.addMapping("/admin/**")
            .allowedOrigins("https://admin.example.com")
            .allowedMethods("GET", "POST")
            .allowCredentials(false);
    }
}
2. 基于过滤器的动态控制(更灵活)
@Component
public class DynamicCorsFilter extends OncePerRequestFilter {

    @Autowired
    private OriginValidator originValidator;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response,
                                  FilterChain chain) throws ServletException, IOException {
        
        // 1. 设置响应头基础模板
        response.setHeader("Vary", "Origin");
        
        // 2. 获取请求Origin并验证
        String origin = request.getHeader("Origin");
        if (originValidator.isAllowed(origin)) {
            response.setHeader("Access-Control-Allow-Origin", origin);
            response.setHeader("Access-Control-Allow-Credentials", "true");
        }
        
        // 3. 处理预检请求
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            handlePreflight(request, response);
            return;
        }
        
        chain.doFilter(request, response);
    }

    private void handlePreflight(HttpServletRequest request, 
                               HttpServletResponse response) {
        // 1. 设置允许的方法
        response.setHeader("Access-Control-Allow-Methods", 
            "GET, POST, PUT, DELETE, OPTIONS");
        
        // 2. 设置允许的头部
        String requestHeaders = request.getHeader("Access-Control-Request-Headers");
        if (StringUtils.hasText(requestHeaders)) {
            response.setHeader("Access-Control-Allow-Headers", requestHeaders);
        }
        
        // 3. 设置预检缓存时间
        response.setHeader("Access-Control-Max-Age", "86400"); // 24小时
        
        response.setStatus(HttpServletResponse.SC_NO_CONTENT);
    }
}

@Service
public class OriginValidator {
    private final List<String> allowedDomains;
    private final Pattern domainPattern = 
        Pattern.compile("^https://([a-z0-9-]+\.)?example\.com$");

    public OriginValidator(@Value("${cors.allowed-domains}") String[] domains) {
        this.allowedDomains = Arrays.asList(domains);
    }

    public boolean isAllowed(String origin) {
        if (origin == null) return false;
        
        // 1. 精确匹配白名单
        if (allowedDomains.contains(origin)) {
            return true;
        }
        
        // 2. 正则匹配子域名
        return domainPattern.matcher(origin).matches();
    }
}
3. 生产/测试环境差异化配置
@Profile("dev")
@Configuration
public class DevCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("*") // 开发环境允许所有
            .allowedMethods("*");
    }
}

@Profile("prod")
@Configuration
public class ProdCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://production.com")
            .allowCredentials(true);
    }
}
4. 注解级控制(方法粒度)
@RestController
@RequestMapping("/api")
public class ApiController {

    @CrossOrigin(
        origins = "https://specific-client.com",
        methods = RequestMethod.GET,
        allowedHeaders = "X-API-Version"
    )
    @GetMapping("/data")
    public ResponseEntity<Data> getData() {
        // ...
    }
}
5. Nginx层配合配置
# nginx.conf 部分配置
location /api/ {
    # 与Spring应用保持一致的CORS策略
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
        add_header 'Access-Control-Max-Age' 86400;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
    }
    
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    proxy_pass http://backend;
}

2. CSRF 防护进阶

在这里插入图片描述

【作用】

提供企业级CSRF防护方案:

  • 分布式Token存储
  • 动态Token刷新策略
  • 多端适配(Web/API)
  • 安全事件审计
【解决的问题】
  1. 传统Session存储Token的扩展性问题
  2. 前后端分离项目的Token传递
  3. 防止Token劫持和重复使用
  4. 满足金融级安全合规要求
【请求/响应示例】

首次请求获取Token

GET /api/csrf-token HTTP/1.1
Cookie: JSESSIONID=abc123

响应

HTTP/1.1 200 OK
Set-Cookie: XSRF-TOKEN=d4e5f6; Path=/; Secure; HttpOnly
Content-Type: application/json

{
  "token": "d4e5f6",
  "expiresIn": 1800
}

提交表单请求

POST /api/transfer HTTP/1.1
Cookie: JSESSIONID=abc123; XSRF-TOKEN=d4e5f6
X-XSRF-TOKEN: d4e5f6
Content-Type: application/json

{
  "amount": 1000,
  "toAccount": "987654"
}
【核心实现代码】
1. 分布式Token存储方案
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf()
                .csrfTokenRepository(redisCsrfTokenRepository())
                .requireCsrfProtectionMatcher(csrfProtectionMatcher())
            .and()
            .addFilterAfter(new CsrfTokenLogger(), CsrfFilter.class);
    }

    @Bean
    public CsrfTokenRepository redisCsrfTokenRepository() {
        RedisCsrfTokenRepository repository = 
            new RedisCsrfTokenRepository(redisConnectionFactory);
        repository.setHeaderName("X-XSRF-TOKEN");
        repository.setParameterName("_csrf");
        repository.setTokenValiditySeconds(1800); // 30分钟有效期
        return repository;
    }

    private RequestMatcher csrfProtectionMatcher() {
        // 只对需要防护的请求启用CSRF
        return new RequestMatcher() {
            private final Pattern allowedMethods = 
                Pattern.compile("^(GET|HEAD|TRACE|OPTIONS)$");
            
            @Override
            public boolean matches(HttpServletRequest request) {
                // 跳过API文档等公共端点
                if (request.getRequestURI().startsWith("/swagger")) {
                    return false;
                }
                return !allowedMethods.matcher(request.getMethod()).matches();
            }
        };
    }
}

/**
 * 基于Redis的CSRF Token存储
 */
public class RedisCsrfTokenRepository implements CsrfTokenRepository {
    private static final String CSRF_KEY_PREFIX = "csrf:";
    
    private final RedisTemplate<String, String> redisTemplate;
    private String headerName = "X-XSRF-TOKEN";
    private int tokenValiditySeconds = 1800;

    public RedisCsrfTokenRepository(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        this.redisTemplate = template;
    }

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(
            headerName, 
            "_csrf", 
            UUID.randomUUID().toString()
        );
    }

    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, 
                         HttpServletResponse response) {
        String sessionId = request.getSession(false).getId();
        if (sessionId != null) {
            String key = CSRF_KEY_PREFIX + sessionId;
            if (token == null) {
                redisTemplate.delete(key);
            } else {
                redisTemplate.opsForValue().set(
                    key, 
                    token.getToken(), 
                    tokenValiditySeconds, 
                    TimeUnit.SECONDS
                );
                // 同时设置到Cookie供前端使用
                response.addCookie(createCookie(token));
            }
        }
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        String sessionId = request.getSession(false).getId();
        if (sessionId != null) {
            String token = redisTemplate.opsForValue()
                .get(CSRF_KEY_PREFIX + sessionId);
            if (token != null) {
                return new DefaultCsrfToken(
                    headerName, 
                    "_csrf", 
                    token
                );
            }
        }
        return null;
    }

    private Cookie createCookie(CsrfToken token) {
        Cookie cookie = new Cookie("XSRF-TOKEN", token.getToken());
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setSecure(true); // 生产环境启用
        cookie.setMaxAge(tokenValiditySeconds);
        return cookie;
    }
    
    // Setter方法省略...
}
2. 前后端分离适配方案
@RestController
public class CsrfController {

    @GetMapping("/api/csrf-token")
    public ResponseEntity<CsrfResponse> getCsrfToken(
            @CookieValue(value = "JSESSIONID", required = false) String sessionId,
            HttpServletRequest request) {
        
        CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
        return ResponseEntity.ok()
            .header(token.getHeaderName(), token.getToken())
            .body(new CsrfResponse(
                token.getToken(),
                1800 // 有效期秒数
            ));
    }

    @Data
    @AllArgsConstructor
    private static class CsrfResponse {
        private String token;
        private int expiresIn;
    }
}
3. 安全事件审计
public class CsrfTokenLogger extends OncePerRequestFilter {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain) throws ServletException, IOException {
        
        CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
        if (token != null) {
            logger.info("CSRF Token: {} for {}", token.getToken(), request.getRequestURI());
        }
        
        filterChain.doFilter(request, response);
    }
}
4. 双Token防重放攻击
public class DoubleCsrfTokenRepository implements CsrfTokenRepository {
    
    private final CsrfTokenRepository primaryRepository;
    private final CsrfTokenRepository secondaryRepository;

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        CsrfToken primary = primaryRepository.generateToken(request);
        CsrfToken secondary = secondaryRepository.generateToken(request);
        return new CompositeCsrfToken(primary, secondary);
    }

    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, 
                         HttpServletResponse response) {
        CompositeCsrfToken composite = (CompositeCsrfToken) token;
        primaryRepository.saveToken(composite.getPrimary(), request, response);
        secondaryRepository.saveToken(composite.getSecondary(), request, response);
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        CsrfToken primary = primaryRepository.loadToken(request);
        CsrfToken secondary = secondaryRepository.loadToken(request);
        if (primary != null && secondary != null) {
            return new CompositeCsrfToken(primary, secondary);
        }
        return null;
    }

    @Data
    private static class CompositeCsrfToken implements CsrfToken {
        private final CsrfToken primary;
        private final CsrfToken secondary;
        
        @Override
        public String getToken() {
            return primary.getToken() + ":" + secondary.getToken();
        }
        
        // 其他方法实现...
    }
}

3. OAuth2 资源服务器集成

在这里插入图片描述

【作用】

实现基于OAuth2协议的资源保护:

  • JWT/Opaque Token验证
  • 权限范围(Scope)检查
  • 令牌自省(Token Introspection)
  • 与Spring Security深度集成
【解决的问题】
  1. 微服务架构下的API保护
  2. 第三方应用的安全访问控制
  3. 细粒度的权限管理
  4. 集中式访问控制
【请求/响应示例】

资源请求

GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

成功响应

HTTP/1.1 200 OK
Content-Type: application/json

{
  "userId": "123",
  "name": "张三",
  "email": "zhangsan@example.com"
}

无权限响应

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="profile"
【核心实现代码】
1. 基础配置
@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Value("${security.oauth2.resource.jwt.key-uri}")
    private String jwkSetUri;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .antMatchers("/api/admin/**").hasRole("ADMIN")
                .antMatchers(HttpMethod.GET, "/api/**").hasAuthority("SCOPE_read")
                .antMatchers(HttpMethod.POST, "/api/**").hasAuthority("SCOPE_write")
                .anyRequest().authenticated()
            .and()
            .oauth2ResourceServer()
                .jwt()
                .decoder(jwtDecoder());
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
    }
}
2. JWT自定义解析
public class CustomJwtDecoder implements JwtDecoder {
    private final JwtDecoder delegate;

    public CustomJwtDecoder(JwtDecoder delegate) {
        this.delegate = delegate;
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        Jwt jwt = delegate.decode(token);
        
        // 自定义校验逻辑
        if (!"https://auth.example.com".equals(jwt.getIssuer())) {
            throw new JwtValidationException("Invalid token issuer");
        }
        
        // 添加自定义声明
        Map<String, Object> claims = new HashMap<>(jwt.getClaims());
        claims.put("custom_claim", "value");
        
        return new Jwt(
            jwt.getTokenValue(),
            jwt.getIssuedAt(),
            jwt.getExpiresAt(),
            jwt.getHeaders(),
            claims
        );
    }
}
3. 方法级权限控制
@RestController
@RequestMapping("/api/users")
public class UserController {

    @PreAuthorize("hasAuthority('SCOPE_profile') and #id == principal.claims['user_id']")
    @GetMapping("/{id}")
    public User getUser(@PathVariable String id) {
        // 只能访问自己的profile
    }

    @PostAuthorize("returnObject.owner == principal.name")
    @GetMapping("/{id}/details")
    public UserDetails getDetails(@PathVariable String id) {
        // 后置权限检查
    }
}
4. 令牌自省配置(Opaque Token)
@Configuration
@EnableResourceServer
public class OpaqueTokenConfig extends ResourceServerConfigurerAdapter {

    @Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}")
    private String introspectionUri;

    @Value("${spring.security.oauth2.resourceserver.opaque.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.resourceserver.opaque.client-secret}")
    private String clientSecret;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.tokenServices(tokenServices());
    }

    @Bean
    public OpaqueTokenIntrospector introspector() {
        return new NimbusOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
    }

    @Bean
    public RemoteTokenServices tokenServices() {
        RemoteTokenServices services = new RemoteTokenServices();
        services.setCheckTokenEndpointUrl(introspectionUri);
        services.setClientId(clientId);
        services.setClientSecret(clientSecret);
        return services;
    }
}
5. 自定义权限转换
@Component
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        // 从JWT提取权限
        Collection<String> scopes = jwt.getClaimAsStringList("scope");
        Set<SimpleGrantedAuthority> authorities = scopes.stream()
            .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
            .collect(Collectors.toSet());

        // 提取自定义声明
        String userId = jwt.getClaim("user_id");
        
        // 构建认证对象
        return new JwtAuthenticationToken(jwt, authorities, userId);
    }
}

// 配置使用自定义转换器
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(new CustomJwtAuthenticationConverter());
    return converter;
}
6. 资源服务器异常处理
@ControllerAdvice
public class OAuth2ExceptionHandler {

    @ExceptionHandler({
        InvalidTokenException.class,
        JwtValidationException.class
    })
    public ResponseEntity<ErrorResponse> handleInvalidToken(RuntimeException ex) {
        ErrorResponse response = new ErrorResponse(
            "invalid_token",
            ex.getMessage(),
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .header("WWW-Authenticate", "Bearer error="invalid_token"")
            .body(response);
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        ErrorResponse response = new ErrorResponse(
            "insufficient_scope",
            "缺少必要权限",
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .header("WWW-Authenticate", "Bearer error="insufficient_scope"")
            .body(response);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Solomon_肖哥弹架构

你的欣赏就是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值