主题
界面
主题
《Filter、Listener 与会话管理》 用 Filter 做了"能用的鉴权";本节把它升级成体系:
S3-019:一个 Filter + 一个 if ← 能跑,但边界靠人记 ✗
S3-010:Spring Security 的过滤链 + 授权体系 + 密码加密 ← 默认安全 ✓先记住两个词的区别(这是本节的根基):
| 词 | 回答的问题 | 例子 |
|---|---|---|
| 认证(Authentication) | 你是谁? | 登录、验证 token ★ |
| 授权(Authorization) | 你能做什么? | 这个接口只有管理员能调 ★ |
很多安全 bug 的根源是把两者混为一谈 ✗
SecurityFilterChain 配置(放行清单 + 规则)★★;@PreAuthorize)★;手写鉴权的问题(本项目 《Filter、Listener 与会话管理》 的方案,够用但有边界):
| 问题 | 说明 |
|---|---|
| 密码怎么存? | 明文/MD5 都是事故级错误 ✗(需要专业的哈希算法) |
| "未登录跳转到登录页"的细节 | 重定向、错误码、响应格式……每个都要自己想 ✗ |
| 授权粒度 | "这个方法要 ADMIN 角色"——Filter 层做不到(《Filter、Listener 与会话管理》 讲过要用 Interceptor)⚠️ |
| 安全默认值 | Spring Security 自带大量"你不知道但很重要"的防护(session 固定、头信息、时序攻击……)✓ |
结论:鉴权这件事"自己写"的风险大于"引入框架" ✓
引入依赖后(什么都不配):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>所有接口立刻变成 401 ✗ ——连你的公开接口都被拦了 ⚠️
同时控制台会打印一个随机密码(默认用户 user)——用于临时登录 ✓
★ 这正是"默认拒绝"(《Filter、Listener 与会话管理》)的体现:Spring Security 的哲学是"没说放行的,都拦" ✓
你的任务从"拦截谁"变成了"放行谁" ★ ——漏配会"多拦"(立刻发现),而不是"少拦"(上线才爆) ✓
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 前后端分离 + Token 时关闭(见下)★
.authorizeHttpRequests(auth -> auth
// ★ 白名单:显式放行(与 S3-019 的思路一致)
.requestMatchers("/api/auth/login", "/actuator/health").permitAll()
// ★ 角色/权限规则
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
// 其余全部需要认证(默认拒绝 ✓)
.anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ★ 无状态(配 JWT 时)
.exceptionHandling(e -> e
// 未认证 → 401(而不是默认的"302 跳登录页" ★)
.authenticationEntryPoint((req, res, ex) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
// 已认证但无权限 → 403
.accessDeniedHandler((req, res, ex) ->
res.sendError(HttpServletResponse.SC_FORBIDDEN)));
return http.build();
}
}逐条说明:
| 配置 | 为什么 |
|---|---|
csrf.disable() | 前后端分离 + Token(无 Cookie 会话)时没有 CSRF 风险 ★;如果用 Session/Cookie,绝不能关 ✗(《Filter、Listener 与会话管理》) |
permitAll | 显式放行清单(要短、要有人 review)✓ |
hasRole("ADMIN") | 注意:role 名不带 ROLE_ 前缀(框架自动加)⚠️ |
STATELESS | 不创建/不使用 HTTP Session(配 JWT 时必须)★ |
authenticationEntryPoint | 默认会 302 跳登录页 ✗(前后端分离要的是 401 + JSON)★ |
这是本节安全上最重要的一条 ✓
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // ✓
}| 算法 | 能用吗 | 为什么 |
|---|---|---|
| 明文 | ✗✗✗ | 泄漏即事故 |
| MD5 / SHA-1 | ✗✗ | 太快 → 暴力破解成本低;即使加盐也救不了快速哈希 ✗ |
| SHA-256(无盐) | ✗ | 同样太快(加盐也只延缓,不改变量级)✗ |
| BCrypt ★ | ✅ | 故意慢 + 自带盐 + 可调强度 ✓ |
| Argon2 / SCrypt | ✅ | 更现代(内存困难型)✓ |
★ 密码哈希的"反直觉"设计:
好的密码哈希要"慢" ✗(与一般认知相反)
因为"慢"让攻击者暴力破解的成本指数级上升,而用户登录一次只慢 0.1 秒 ✓
BCrypt 的特性:
| 特性 | 说明 |
|---|---|
| 自带随机盐 | 同一个密码两次加密结果不同 ✓(防彩虹表) |
| 自带强度参数 | $2a$10$... 中的 10 是强度(可调到 12+) |
验证用 matches() | 不要自己"再次加密后比较字符串" ✗(盐不同永远不等) |
String hash = encoder.encode(raw); // 注册时
encoder.matches(raw, hash); // 登录时 ✓| Session(Spring 默认) | JWT(无状态) | |
|---|---|---|
| 凭证 | Cookie(JSESSIONID) | Authorization: Bearer <token> ★ |
| 服务端状态 | 有(要共享存储)⚠️ | 无 ✓ |
| 踢人下线 | ✅ 容易 | ❌ 难(《Filter、Listener 与会话管理》) |
| 跨服务 | ⚠️ 难 | ✅ 天然支持 ✓ |
| 适合 | 单体、传统 Web | 前后端分离、多端、微服务 ✓ |
配 JWT 的大致流程:
① 登录接口:校验用户名密码 → 生成 JWT(含 userId、角色、过期时间)→ 返回
② 后续请求:Authorization: Bearer <token>
③ 一个自定义 Filter:解析并验证 JWT → 构造 Authentication 放入 SecurityContext ★
④ 之后:@PreAuthorize 等授权检查基于该 Authentication ✓一个 JWT 自定义 Filter 的骨架:
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
String username = jwtService.parseUsername(token); // 验签 + 取用户名
UserDetails user = userDetailsService.loadUserByUsername(username);
Authentication auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth); // ★ 放进上下文
} catch (Exception ignored) {
// 无效 token → 保持"未认证",由框架返回 401 ✓
}
}
chain.doFilter(request, response);
}
}★ 这与 《Filter、Listener 与会话管理》 的 TraceIdFilter 结构完全一样——只是"放进上下文"的东西从 traceId 变成了 Authentication ✓
| 层 | 写法 | 适用 |
|---|---|---|
| URL 级(5.2) | requestMatchers("/api/admin/**").hasRole("ADMIN") | 粗粒度(按路径)✓ |
| 方法级 ★ | @PreAuthorize("hasRole('ADMIN')") | 细粒度(按业务方法)✓ |
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) { ... }
// SpEL 还能引用参数与方法(更灵活)★
@PreAuthorize("@orderService.isOwner(#orderNo, authentication.name)")
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { ... }★ 方法级的优势:规则跟着方法走——重构时不会"URL 变了规则没跟上" ✗(URL 级规则的经典问题)✓
| # | 误区 | 正确做法 |
|---|---|---|
| 1 | 密码用 MD5 ✗✗ | BCrypt(5.3)✓ |
| 2 | 在响应里返回 User 实体 | passwordHash 字段泄漏 ✗✗(《JSON 与 XML》)——用 DTO ✓ |
| 3 | 前后端分离却忘关 CSRF | 前端每个请求都要带 CSRF token,联调全是 403 ✗(反之用 Session 时绝不能关)★ |
| 4 | 401 与 403 混用 | 401 = 未认证(你是谁?)、403 = 已认证但无权限(你能做什么?) ★ |
| 5 | 放行清单里写 /actuator/** ✗ | 会暴露环境与配置信息 ✗(《Spring Boot 自动配置与项目结构》)——只放行 health ✓ |
| 6 | JWT 里放敏感信息 | JWT 的载荷是可解码的(只是不可篡改)✗ |
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/**").permitAll() // 读公开
.anyRequest().authenticated()); // 写要登录见 5.4 的 Filter 骨架 + 5.2 的配置,两块拼起来即可 ✓
@GetMapping("/admin/stats")
public Stats stats(Authentication auth) {
if (!auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) { // ✗
throw new BusinessException("FORBIDDEN", "无权限", HttpStatus.FORBIDDEN);
}
// ...
}问题:每个管理接口都要复制这段 ✗ ——漏一个就是越权 ✗✗
修:@PreAuthorize(声明式) ✓ ——规则与方法绑定,不会漏 ✓
这与 《Filter、Listener 与会话管理》 的"Filter 统一鉴权"是同一个思路:横切关注点要统一处理 ✓
// ① 安全配置(见 5.2)+ ② 密码编码器(5.3)+ ③ JWT 过滤器(5.4)
// ④ 登录接口(用 AuthenticationManager 校验密码):
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest request) {
// ★ 交给 AuthenticationManager(它会用 PasswordEncoder 验证密码 ✓)
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
String token = jwtService.generate((UserDetails) auth.getPrincipal());
return Map.of("token", token, "tokenType", "Bearer");
}
}说明:登录接口自己不比较密码 ——交给 AuthenticationManager(它会调用 PasswordEncoder.matches)✓ ——关注点分离 ✓
--debug(或 logging)看过滤器链:Spring Security 会打印整个过滤链的顺序 ★ ——你的 JWT Filter 在哪个位置一目了然 ✓;ROLE_ADMIN 写成 ADMIN 传进 hasRole:观察"永远 403"(前缀陷阱)✗;encoder.matches("明文", "错误格式的hash"):观察异常(理解 hash 的格式要求)✓。见 5.6 的六条(密码 MD5、实体泄漏、CSRF、401/403、actuator、JWT 载荷)★
再加两条:
| # | 错误 | 说明 |
|---|---|---|
| 7 | hasRole 与 authorities 前缀不一致 ✗ | hasRole("ADMIN") 要求权限名是 ROLE_ADMIN ⚠️ |
| 8 | 把登录失败做成 500 | "密码错误"是业务正常流程——应返回 401 + 明确提示(《参数校验与全局异常处理》 的级别判断)✓ |
SecurityFilterChain:放行一个公开接口,观察它从 401 变成 200 ✓;@PreAuthorize:用无权限用户访问,观察 403(并确认不是 401)★;扩展任务:把 《Filter、Listener 与会话管理》 的"手写鉴权"迁移到 Spring Security:
hasRole("USER") 与 hasAuthority("ROLE_USER") 等价吗?matches() ✓)hasRole("USER") 内部就是查 ROLE_USER ★@PreAuthorize 做方法级授权;User 实体"的原因(《JSON 与 XML》)。《HTML 与 CSS 基础》:转向前端五节的第一站。