CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/Spring Security 基础
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

Spring Security 基础

过滤器链、认证与授权概念。

40 分钟

Spring Security 基础

这一课你将能做到

  1. 解释过滤器链、认证(Authentication)与授权(Authorization)三个核心概念的关系
  2. 自定义 SecurityFilterChain:放行公开接口、拦截受保护接口、按角色控制访问
  3. 使用 BCryptPasswordEncoder 存储密码,并解释「为什么不能明文存密码」

为什么要学

到目前为止你写的接口是「裸奔」的:任何人知道 URL 就能查、能删。真实系统必须有门卫——你是谁(认证)、你能干什么(授权)。本站的登录、按用户隔离的刷题记录、管理后台权限,全部建立在这套机制上。

Spring Security 是 Servlet 过滤器链 + Spring 容器的结合体:一组按序执行的过滤器,每个管一件事(解析 token、检查权限、处理异常……)。它以「默认全锁死」著称——第一次加上依赖,所有接口突然 401,很多人当场懵掉。这一课的目标就是把「默认行为」和「自定义方法」都拆开看清。

先建立图像

HTTP 请求
  ↓
┌─ Security 过滤器链(按序执行,任何一个不放行就到此为止)─┐
│  SecurityContextPersistenceFilter  恢复/保存登录状态    │
│  UsernamePasswordAuthenticationFilter  处理表单登录     │
│  ExceptionTranslationFilter      把后面的异常翻译成 401/403 │
│  FilterSecurityInterceptor  ★ 授权裁决:这个 URL 你能进吗 │
└──────────────────────────────────────────────────────┘
  ↓ 全部通过
你的 Controller

两个核心概念第一次分清:

概念回答的问题例子
认证 Authentication你是谁?账号密码登录、JWT 校验
授权 Authorization你能干什么?普通用户不能删别人的任务,ADMIN 可以
主体 Principal认证后的身份对象用户名、用户 ID、角色集合

登录成功后,认证信息存进 SecurityContextHolder(本质是 ThreadLocal,请求线程内随处可取),后续过滤器据此裁决。

跟着做

步骤 1:先体验「默认全锁死」

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

只加依赖不加任何配置,重启后访问 /api/tasks:

{ "status": 401, "error": "Unauthorized" }

同时启动日志出现一行随机密码:

Using generated security password: 7a3f9e2b-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Boot 自动配置了一个用户名 user、密码为随机串的账号,并且默认拦截所有请求、启用表单登录页。访问任意接口会先跳到登录表单——这就是「默认安全」:宁可全锁,不可漏放。接下来把它改成 API 服务的样子。

步骤 2:第一个自定义 SecurityFilterChain

Spring Security 6(Boot 3 配套)的写法是声明一个 SecurityFilterChain Bean:

package com.example.taskflow.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/hello", "/actuator/health").permitAll()
                    .anyRequest().authenticated())          // 其余都要登录
            .httpBasic(Customizer.withDefaults());          // 先用 HTTP Basic 体验
        return http.build();
    }
}

重启验证:

curl http://localhost:8080/api/hello          # 200,放行了
curl http://localhost:8080/api/tasks          # 401,被拦了
curl -u user:启动日志里的随机密码 http://localhost:8080/api/tasks   # 200

-u user:密码 是 HTTP Basic 认证:用户名密码以 Base64 放在 Authorization 头里。注意 authorizeHttpRequests 从上到下匹配,先写具体规则,anyRequest() 必须放最后——它兜底拦截一切。

步骤 3:定义用户与角色(内存版)

@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = User.builder()
            .username("alice")
            .password(passwordEncoder().encode("alice123"))
            .roles("USER")                       // 内部自动加前缀存为 ROLE_USER
            .build();

    UserDetails admin = User.builder()
            .username("admin")
            .password(passwordEncoder().encode("admin123"))
            .roles("USER", "ADMIN")
            .build();

    return new InMemoryUserDetailsManager(user, admin);
}

加上角色控制:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/hello").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/tasks/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated())
        .httpBasic(Customizer.withDefaults());
    return http.build();
}

验证授权差异:

curl -u alice:alice123 http://localhost:8080/api/tasks     # 200
curl -u alice:alice123 http://localhost:8080/api/admin/x   # 403 Forbidden(登录了但没权限)
curl -u admin:admin123 http://localhost:8080/api/admin/x   # 200

401(未认证)和 403(已认证但无权限)从此可以亲眼区分。

步骤 4:密码必须加密——BCryptPasswordEncoder

上面的 .encode("alice123") 就是加密入口。声明 Encoder Bean:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

为什么不能明文存?数据库被拖库(安全事件里最常见的一种)时,明文密码直接泄露,且用户往往多平台共用密码,等于泄露了他的全部账号。BCrypt 的特性:

  • 单向散列 + 自动加盐:同一密码每次加密结果都不同,彩虹表失效;
  • 可调强度:默认强度 10,算力提升后调大参数即可,「暴力破解成本」随硬件增长;
  • 验证用 matches 而不是比对字符串:
boolean ok = passwordEncoder.matches("alice123", "$2a$10$存储在库里的哈希串");

本站后端的用户表密码列就是 BCrypt 串($2a$10$... 开头)。公司里若发现同事在代码或日志里打印明文密码,属于必须立即叫停的问题。

步骤 5:调整成前后端分离形态(API 服务版)

浏览器表单登录、重定向、CSRF 页面防护都是「服务端渲染」时代的机制,对纯 API 服务需要显式调整:

@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())                        // API 无 cookie 会话,CSRF 不适用
        .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 不创建会话
        .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
        .httpBasic(Customizer.withDefaults());               // 下一课换成 JWT 过滤器
    return http.build();
}

三条调整的因果:API 用 token 认证 → 不依赖 cookie 会话 → 没有 cookie 就没有 CSRF 攻击面 → csrf 可以关。顺序不能倒过来背——没有无状态前提就关 CSRF 是错误的。

步骤 6:在业务代码里拿当前登录人

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

@GetMapping("/api/me")
public String me(Authentication authentication) {          // 方式一:直接注入参数
    return "当前用户: " + authentication.getName();
}

@GetMapping("/api/me2")
public String me2() {                                       // 方式二:静态取(工具类场景)
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    return "当前用户: " + auth.getName();
}

SecurityContextHolder 基于 ThreadLocal,同一线程内随时可取;异步线程、线程池复用场景要显式传递,直接取会拿到 null。

讲清楚要点

  1. Security 的一切都在过滤器链里发生:你的 Controller 执行之前,链上的过滤器已经完成了认证解析与授权裁决。排查「为什么 401/403」永远先看链的配置顺序。
  2. 规则从上到下,先具体后兜底:requestMatchers("/api/admin/**") 必须写在 anyRequest() 之前;permitAll 的白名单是「门卫免检名单」,每加一个公开接口都要有意识确认它真的可以公开。
  3. 认证与授权是两道门:401 = 第一道门没过(没登录/token 无效),403 = 第二道门没过(登录了但角色不够)。前端拿到这两种码的处理逻辑完全不同(401 去登录,403 提示无权限)。
  4. 密码存储的唯一正解是单向加盐散列:BCrypt/SCrypt/Argon2。MD5/SHA-256 无盐可被彩虹表破解,明文存储是事故。验证走 matches,永远不存在「解密」。
  5. UserDetailsService 是认证的扩展点:Security 只问「给我这个用户名,我看看他的密码和角色」。内存版换成了查数据库的用户实现,就完成了从玩具到真项目的过渡——下一课 JWT 登录正是围绕它构建。

你可能会懵的地方

  • 为什么加上依赖后所有接口都 401 了? 默认策略就是「全拦截 + 生成随机用户」。这是特性不是 bug:安全组件宁可先锁死。定义自己的 SecurityFilterChain 后即可按白名单放行。

  • ROLE_ 前缀怎么回事?hasRole 和 hasAuthority 有什么区别? roles("USER") 内部自动补成 ROLE_USER 存储,hasRole("USER") 匹配时也自动补前缀;hasAuthority 用原始字符串精确匹配(如 hasAuthority("task:delete") 细粒度权限点)。团队实践:粗粒度用角色,细粒度用权限点。

  • CSRF 是什么?为什么 API 服务能关? 攻击者诱导已登录用户的浏览器带着 cookie 自动发起伪造请求。前提是「认证状态存 cookie 且浏览器自动携带」。纯 token 认证(放 header)不带自动凭证,攻击面不存在,所以可以关。

  • Session 和 JWT 的关系?下一课为什么要 STATELESS? Session:状态存服务端,浏览器带 JSESSIONID cookie。JWT:状态编码进 token 由客户端持有,服务端只验签名。API 集群/多端场景 JWT 更轻,因此声明 STATELESS 不创建会话——下一课正式落地。

  • SecurityContextHolder 为什么在异步线程里取不到? ThreadLocal 只在创建它的线程可见。@Async 方法、线程池任务里要用 DelegatingSecurityContextAsyncTaskProcessor 或显式传 Authentication,初学阶段记住「别在新线程裸取」即可。

错法对照

错法问题正解
anyRequest() 写在 requestMatchers 前面后面的规则永远匹配不到兜底规则放链尾
数据库存明文密码或 MD5拖库即泄露;MD5 可彩虹表破解BCryptPasswordEncoder + 盐
没开 STATELESS 却照常使用 session集群下 session 不共享,行为诡异API 服务显式 STATELESS
无状态前提不清就 disable CSRF有 cookie 认证时留下 CSRF 攻击面先确认认证机制,再决定 CSRF
把 BCrypt 串当可逆数据「解密」回来BCrypt 是单向散列,不可能解密验证用 encoder.matches()

卡点急救

  1. 自定义配置后依然全 401:确认类上有 @Configuration + @EnableWebSecurity;确认 SecurityFilterChain Bean 没被写错方法名导致没注册;确认放行的 requestMatchers 路径与实际请求完全一致(含 /api 前缀)。
  2. 登录成功但一直 403:roles 前缀问题(库里存的是 ROLE_USER 时用 hasAuthority("ROLE_USER") 或 hasRole("USER"),别混配);或用户没分配该角色。打印 Authentication 的 authorities 实查。
  3. 循环依赖或启动报 UserDetailsService 相关错误:SecurityConfig 里注入 UserDetailsService 时,避免在同一个类里互相引用;密码 Encoder 单独声明为 Bean。
  4. 加了 Security 后静态资源/文档页也 401:把 swagger、h2-console 等路径加入 permitAll(h2-console 还需允许 frameOptions),再逐个收敛。

面试常问

  • Spring Security 的核心组件和请求流程? FilterChainProxy 统筹一组 SecurityFilterChain;请求依次经过认证相关过滤器(解析凭证)→ ExceptionTranslationFilter(翻译异常)→ 授权过滤器(URL/方法级裁决)→ 达到业务。认证成功后 Authentication 存入 SecurityContextHolder(ThreadLocal)。

  • 认证和授权的区别?分别在哪里实现? 认证解决「你是谁」(UserDetailsService 校验凭证、生成 Authentication);授权解决「你能不能」(URL 级 authorizeHttpRequests、方法级 @PreAuthorize)。401/403 分别对应两道关卡。

  • 密码加密为什么用 BCrypt 而不是 MD5+盐? BCrypt 自带随机盐、内置强度因子可随算力增长而调高、单次计算刻意变慢(抗暴力破解);MD5 快且无盐机制(需手动加),已被认为不安全。Argon2 是更新的标准。

  • @PreAuthorize 怎么用? 方法级授权:@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name"),SpEL 可引用参数与认证信息;需开启 @EnableMethodSecurity。适合「同一 URL 不同数据权限」的场景。

自测三问

  1. 认证和授权分别回答什么问题?分别对应哪个 HTTP 状态码?
  2. 写出「/api/auth/** 公开、/api/admin/** 仅 ADMIN、其余需登录」的完整过滤器链配置。
  3. 为什么同一密码两次 BCrypt 加密结果不同?登录时怎么验证?

小结一句话

Spring Security 用过滤器链在请求到达业务前完成「认证定身份、授权定边界」,密码以 BCrypt 单向散列存储——先分清两道门,再谈自定义。

延伸

  • Spring Security 官方架构文档
  • OWASP 认证清单
  • 下一课:JWT 与 API 鉴权——把登录态装进 token,实现真正的无状态认证。
加载本节练习…
← 上一课事务管理下一课 →JWT 与 API 鉴权