Skip to content

Spring Security 认证与授权 ​

S3-010必学高级约 90 分钟JDK 21企业开发基础 · 认证与授权

1. 本节定位 ​

《Filter、Listener 与会话管理》 用 Filter 做了"能用的鉴权";本节把它升级成体系:

text
S3-019:一个 Filter + 一个 if        ← 能跑,但边界靠人记 ✗
S3-010:Spring Security 的过滤链 + 授权体系 + 密码加密   ← 默认安全 ✓

先记住两个词的区别(这是本节的根基):

词回答的问题例子
认证(Authentication)你是谁?登录、验证 token ★
授权(Authorization)你能做什么?这个接口只有管理员能调 ★

很多安全 bug 的根源是把两者混为一谈 ✗

2. 学习目标 ​

  • 说出认证与授权的区别;
  • 说出 Spring Security 的"默认拒绝"特性(一切请求默认要认证)★★★;
  • 会写 SecurityFilterChain 配置(放行清单 + 规则)★★;
  • 说出"密码必须用 BCrypt"与"为什么不能 MD5" ★★★;
  • 理解认证流程(登录 → Session 或 JWT);
  • 会用方法级授权(@PreAuthorize)★;
  • 知道 CSRF 与"前后端分离"的取舍(《Filter、Listener 与会话管理》)。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

手写鉴权的问题(本项目 《Filter、Listener 与会话管理》 的方案,够用但有边界):

问题说明
密码怎么存?明文/MD5 都是事故级错误 ✗(需要专业的哈希算法)
"未登录跳转到登录页"的细节重定向、错误码、响应格式……每个都要自己想 ✗
授权粒度"这个方法要 ADMIN 角色"——Filter 层做不到(《Filter、Listener 与会话管理》 讲过要用 Interceptor)⚠️
安全默认值Spring Security 自带大量"你不知道但很重要"的防护(session 固定、头信息、时序攻击……)✓

结论:鉴权这件事"自己写"的风险大于"引入框架" ✓

5. 核心概念 ​

5.1 默认行为:一切都要认证 ★★★ ​

引入依赖后(什么都不配):

xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

所有接口立刻变成 401 ✗ ——连你的公开接口都被拦了 ⚠️

同时控制台会打印一个随机密码(默认用户 user)——用于临时登录 ✓

★ 这正是"默认拒绝"(《Filter、Listener 与会话管理》)的体现:Spring Security 的哲学是"没说放行的,都拦" ✓

你的任务从"拦截谁"变成了"放行谁" ★ ——漏配会"多拦"(立刻发现),而不是"少拦"(上线才爆) ✓

5.2 SecurityFilterChain 配置 ★★ ​

java
@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)★

5.3 密码必须用 BCrypt ★★★ ​

这是本节安全上最重要的一条 ✓

java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();       // ✓
}
算法能用吗为什么
明文✗✗✗泄漏即事故
MD5 / SHA-1✗✗太快 → 暴力破解成本低;即使加盐也救不了快速哈希 ✗
SHA-256(无盐)✗同样太快(加盐也只延缓,不改变量级)✗
BCrypt ★✅故意慢 + 自带盐 + 可调强度 ✓
Argon2 / SCrypt✅更现代(内存困难型)✓

★ 密码哈希的"反直觉"设计:

好的密码哈希要"慢" ✗(与一般认知相反)

因为"慢"让攻击者暴力破解的成本指数级上升,而用户登录一次只慢 0.1 秒 ✓

BCrypt 的特性:

特性说明
自带随机盐同一个密码两次加密结果不同 ✓(防彩虹表)
自带强度参数$2a$10$... 中的 10 是强度(可调到 12+)
验证用 matches()不要自己"再次加密后比较字符串" ✗(盐不同永远不等)
java
String hash = encoder.encode(raw);                       // 注册时
encoder.matches(raw, hash);                              // 登录时 ✓

5.4 认证流程:Session 还是 JWT ​

Session(Spring 默认)JWT(无状态)
凭证Cookie(JSESSIONID)Authorization: Bearer <token> ★
服务端状态有(要共享存储)⚠️无 ✓
踢人下线✅ 容易❌ 难(《Filter、Listener 与会话管理》)
跨服务⚠️ 难✅ 天然支持 ✓
适合单体、传统 Web前后端分离、多端、微服务 ✓

配 JWT 的大致流程:

text
① 登录接口:校验用户名密码 → 生成 JWT(含 userId、角色、过期时间)→ 返回
② 后续请求:Authorization: Bearer <token>
③ 一个自定义 Filter:解析并验证 JWT → 构造 Authentication 放入 SecurityContext ★
④ 之后:@PreAuthorize 等授权检查基于该 Authentication ✓

一个 JWT 自定义 Filter 的骨架:

java
@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 ✓

5.5 授权:URL 级与方法级 ​

层写法适用
URL 级(5.2)requestMatchers("/api/admin/**").hasRole("ADMIN")粗粒度(按路径)✓
方法级 ★@PreAuthorize("hasRole('ADMIN')")细粒度(按业务方法)✓
java
@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 级规则的经典问题)✓

5.6 常见误区 ​

#误区正确做法
1密码用 MD5 ✗✗BCrypt(5.3)✓
2在响应里返回 User 实体passwordHash 字段泄漏 ✗✗(《JSON 与 XML》)——用 DTO ✓
3前后端分离却忘关 CSRF前端每个请求都要带 CSRF token,联调全是 403 ✗(反之用 Session 时绝不能关)★
4401 与 403 混用401 = 未认证(你是谁?)、403 = 已认证但无权限(你能做什么?) ★
5放行清单里写 /actuator/** ✗会暴露环境与配置信息 ✗(《Spring Boot 自动配置与项目结构》)——只放行 health ✓
6JWT 里放敏感信息JWT 的载荷是可解码的(只是不可篡改)✗

6. 递进示例 ​

6.1 最小示例:只保护写接口 ​

java
http.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.GET, "/api/**").permitAll()     // 读公开
    .anyRequest().authenticated());                              // 写要登录

6.2 业务示例:完整登录 + JWT ​

见 5.4 的 Filter 骨架 + 5.2 的配置,两块拼起来即可 ✓

6.3 反例:在 Controller 里手工校验角色 ​

java
@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 统一鉴权"是同一个思路:横切关注点要统一处理 ✓

7. 配置模板(完整可拼装) ​

java
// ① 安全配置(见 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)✓ ——关注点分离 ✓

8. Debug 观察点 ​

  1. 引依赖后访问任意接口 → 401:体会"默认拒绝" ✓;
  2. 开 --debug(或 logging)看过滤器链:Spring Security 会打印整个过滤链的顺序 ★ ——你的 JWT Filter 在哪个位置一目了然 ✓;
  3. 故意把 ROLE_ADMIN 写成 ADMIN 传进 hasRole:观察"永远 403"(前缀陷阱)✗;
  4. 用 encoder.matches("明文", "错误格式的hash"):观察异常(理解 hash 的格式要求)✓。

9. 常见错误 ​

见 5.6 的六条(密码 MD5、实体泄漏、CSRF、401/403、actuator、JWT 载荷)★

再加两条:

#错误说明
7hasRole 与 authorities 前缀不一致 ✗hasRole("ADMIN") 要求权限名是 ROLE_ADMIN ⚠️
8把登录失败做成 500"密码错误"是业务正常流程——应返回 401 + 明确提示(《参数校验与全局异常处理》 的级别判断)✓

10. 动手实验 ​

  1. 引依赖,什么都不配:观察"全部 401"与控制台的随机密码 ✓;
  2. 写 SecurityFilterChain:放行一个公开接口,观察它从 401 变成 200 ✓;
  3. 实现 BCrypt 注册与登录:对比同一个密码两次 encode 的结果(盐的存在)✓;
  4. 加 @PreAuthorize:用无权限用户访问,观察 403(并确认不是 401)★;
  5. 验证"未认证返回 401 而不是 302"(自定义 entryPoint 前后对比)★。

扩展任务:把 《Filter、Listener 与会话管理》 的"手写鉴权"迁移到 Spring Security:

  • 列出迁移清单(哪些 Filter 逻辑要挪到哪里);
  • 说明:哪些逻辑被框架"接管"了(如 CSRF、会话固定、安全头)——这就是"引入框架"的价值 ✓

11. 练习题 ​

  1. 判断:BCrypt 加密同一个密码两次,结果相同。(对 / 错)
  2. 填空:认证回答 ______,授权回答 ______;Spring Security 默认对未配置的请求执行 ______。
  3. 简答:为什么 MD5 不适合做密码哈希?
  4. (代码阅读)hasRole("USER") 与 hasAuthority("ROLE_USER") 等价吗?
  5. (编程)实现"登录失败 5 次锁定 10 分钟"——说明锁定信息存哪里、为什么不用内存 Map(多实例问题)。
参考答案(要点)
  1. 错。BCrypt 自带随机盐——同一密码每次结果不同 ✓(验证用 matches() ✓)
  2. "你是谁?";"你能做什么?";认证(authenticated) ✓
  3. MD5 太快(GPU 每秒数十亿次)→ 暴力破解成本低;且无盐(相同密码 → 相同 hash → 彩虹表攻击)✗。 BCrypt 故意慢 + 自带盐 + 强度可调 ✓
  4. 等价 ✓ ——hasRole("USER") 内部就是查 ROLE_USER ★
  5. 锁定信息放 Redis(多实例共享 + 自带过期时间)——内存 Map 在多实例下各自为政 ✗

13. 掌握标准 ​

  • [ ] 能区分认证与授权 ★★★;
  • [ ] 知道 Spring Security 默认"全拦",并会写放行清单;
  • [ ] 密码用 BCrypt(并解释为什么不用 MD5)★★★;
  • [ ] 知道前后端分离时 CSRF 的取舍(《Filter、Listener 与会话管理》);
  • [ ] 会用 @PreAuthorize 做方法级授权;
  • [ ] 401 与 403 不混用 ✓;
  • [ ] 知道"不返回 User 实体"的原因(《JSON 与 XML》)。

14. 扩展资源 ​

15. 下一步学习 ​

《HTML 与 CSS 基础》:转向前端五节的第一站。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引