肖哥弹架构 跟大家“弹弹” Spring MVC设计与实战应用,需要代码关注
欢迎 点赞,点赞,点赞。
关注公号Solomon肖哥弹架构获取更多精彩内容
历史热点文章
- MyCat应用实战:分布式数据库中间件的实践与优化(篇幅一)
- 图解深度剖析:MyCat 架构设计与组件协同 (篇幅二)
- 一个项目代码讲清楚DO/PO/BO/AO/E/DTO/DAO/ POJO/VO
- 写代码总被Dis:5个项目案例带你掌握SOLID技巧,代码有架构风格
- 里氏替换原则在金融交易系统中的实践,再不懂你咬我
在现代Web应用开发中,安全性已不再是可选项,而是必备的基石。随着前后端分离架构的普及和微服务的广泛应用,开发者面临着安全的关键挑战。
为什么你的API总被安全团队挑战? 可能是因为缺少这些进阶配置:
🛡️ CORS如外科手术般精准
• 基于@CrossOrigin
的控制器级配置
• 动态CorsConfigurationSource
实现域名白名单
🛡️ CSRF的六层防御体系
• 重要操作强制验证Referer头
• 自动排除/api/**
等无需防护的接口
🛡️ OAuth2资源服务器
• 从JWT中提取多租户信息(tenant_id
声明)
• 自定义AuthenticationEntryPoint
返回标准错误格式
二、安全与合规
1. CORS 精细化控制
【作用】
实现细粒度的跨域资源共享控制,包括:
- 动态Origin白名单
- 多维度策略配置(方法/头部/凭证)
- 预检请求缓存优化
- 生产/测试环境差异化配置
【解决的问题】
- 前后端分离项目的跨域访问
- 多环境下的策略差异(如开发环境允许所有Origin)
- 安全合规要求的严格限制
- 预检请求的性能损耗
【请求/响应示例】
预检请求:
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)
- 安全事件审计
【解决的问题】
- 传统Session存储Token的扩展性问题
- 前后端分离项目的Token传递
- 防止Token劫持和重复使用
- 满足金融级安全合规要求
【请求/响应示例】
首次请求获取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深度集成
【解决的问题】
- 微服务架构下的API保护
- 第三方应用的安全访问控制
- 细粒度的权限管理
- 集中式访问控制
【请求/响应示例】
资源请求:
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);
}
}