SPRING SECURITY · AUTHENTICATION · AUTHORIZATION

先认人,再放行

Spring Security 是站在 Controller 前面的一排保安。每个请求都要先过这一关:先查清你是谁,再看你能进哪扇门。

认证你是谁?Authentication · 认不出你,返回 401
授权你能干什么?Authorization · 认得你但不让进,返回 403
保安队SecurityFilterChain

一排过滤器,每人只管一件事。请求从头走到尾。

本质是一个 Servlet Filter
核验台AuthenticationManager

核对身份:查档案、比密码、验 token。

认证:你是谁
工牌Authentication

核验通过就发一张,挂在当前线程上,后面谁都能看。

放在 SecurityContextHolder 里
门禁AuthorizationManager

看工牌上的权限,决定这扇门让不让进。

授权:你能干什么
配角 档案柜 UserDetailsService,按用户名查用户 密码比对器 PasswordEncoder 翻译官 ExceptionTranslationFilter,把拒绝翻成 401 或 403

只加一个依赖,会发生什么

什么都不配,Spring Boot 也会先给你一套能用的默认值。

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动日志
Using generated security password: 8e557245-73e2-4286-969a-ff57fe326336
This generated password is for development use only. Your security configuration must be updated before running your application in production.
所有接口都要登录

访问任何地址,都先被带去登录页。

送一个登录页

/login 表单、/logout 都是自动生成的,还支持 HTTP Basic。

送一个账号

用户名 user,密码每次启动随机生成,印在日志里。

默认防护全开

CSRF 校验、安全响应头、登录后换一个新的 Session ID。

和第 03 篇的关系这些都是自动配置给的默认值,带着 @ConditionalOnMissingBean 这类条件:你声明了自己的 SecurityFilterChain,默认的链就让位;声明了自己的 UserDetailsService,user 这个账号就消失。

保安站在哪

Spring Security 整个就是一个 Servlet Filter,挂在 Tomcat 的过滤器链上,比 DispatcherServlet 还早。

请求
Tomcat 的过滤器链
CharacterEncodingFilterDelegatingFilterProxy你自己的 Filter
DispatcherServlet 拦截器 Controller
放大看 DelegatingFilterProxy
DelegatingFilterProxy

登记在 Tomcat 里的普通 Filter。自己不干活,只转交。

FilterChainProxy

Spring 容器里的 Bean,名字叫 springSecurityFilterChain。按 URL 挑一条链。

/api/**BearerToken → Anonymous → ExceptionTranslation → Authorization
/**Csrf → Logout → UsernamePassword → … → Authorization
可以有多条 SecurityFilterChain,按 @Order 排队,只走第一条匹配的。GET /api/orders 走第一条,第二条不再看。

为什么要转一手?Tomcat 启动时就要拿到所有 Filter,那时 Spring 的 Bean 不一定造好了。先放一个代理占位,等请求来了,再去容器里找这个 Bean。

比拦截器、Controller 都早

没过门禁的请求,到不了 DispatcherServlet。

@ControllerAdvice 管不到它

过滤器里抛的异常不经过 Spring MVC。第 01 篇讲过。

站位可以调

Boot 里由 spring.security.filter.order 决定,默认 -100。

保安队名单

按真实执行顺序排。每人只管一件事,不归自己管就放行。用了哪些功能,链上才有哪些人。

准备先把场子收拾好
1取工牌SecurityContextHolderFilter
2加安全响应头HeaderWriterFilter
3跨域CorsFilter
4查 CSRF 令牌CsrfFilter
5处理 /logoutLogoutFilter
认证各认各的凭证
6表单 POST /loginUsernamePasswordAuthenticationFilter
7Bearer tokenBearerTokenAuthenticationFilter
8Basic 请求头BasicAuthenticationFilter
9记住我 CookieRememberMeAuthenticationFilter
10没工牌就发游客牌AnonymousAuthenticationFilter
兜底 + 门禁最后两关
11接住异常,翻成 401 / 403ExceptionTranslationFilter
12门禁:能不能进AuthorizationFilter

想看自己项目的完整名单:配上 logging.level.org.springframework.security=TRACE,每个请求经过了哪几个过滤器,会逐个打印出来。

跟着一个请求走一遍

选一个剧情,一步一步看:请求走到了哪个保安面前,核验台在干什么,工牌上写着什么,最后返回了什么。

POST /login
  1. 1取工牌SecurityContextHolderFilter
  2. 2查 CSRF 令牌CsrfFilter这条链关掉了
  3. 3处理 /logoutLogoutFilter
  4. 4表单登录UsernamePasswordAuthenticationFilterformLogin()这条链没配
  5. 5Bearer tokenBearerTokenAuthenticationFilteroauth2ResourceServer()这条链没配
  6. 6发游客牌AnonymousAuthenticationFilter
  7. 7翻译官ExceptionTranslationFilter
  8. 8门禁AuthorizationFilter
  9. Spring Security 到此为止
  10. →终点DispatcherServlet → 拦截器 → Controller
只列了和剧情有关的几位。Spring Security 5 里,第 1 位叫 SecurityContextPersistenceFilter,门禁叫 FilterSecurityInterceptor。

工牌 · SecurityContextHolder

核验台 · AuthenticationManager

返回给浏览器

1/9
SecurityContextHolderFilter

工牌长什么样,怎么拿到

三层套娃。最外层挂在当前线程上,所以同一个请求里,Controller、Service 哪儿都能取到。

SecurityContextHolder挂在当前线程上(ThreadLocal)
SecurityContext工牌套
Authentication工牌本身
principal你是谁。登录后通常是一个 UserDetails
credentials凭证,比如密码。认证完就擦掉
authorities权限清单:ROLE_USER、order:read …
在代码里拿当前用户
// 同一个请求线程里,哪儿都能取
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String name = auth.getName();                        // "tom"

// Controller 里更省事
@GetMapping("/me")
public String me(@AuthenticationPrincipal UserDetails user) {
    return user.getUsername();
}
  • 请求结束就清空,线程要还给线程池。下一个请求靠 Session 或 token 重新找回来。
  • 换了线程就没了:@Async、自己开的线程池里取到的是空的。

核验台里面

进去一张“待核验单”,出来一张“正式工牌”。是同一个类,只是从“没认证”变成了“认证过”。

进去的 待核验单 UsernamePasswordAuthenticationToken
principal
"tom"
credentials
"123456"
认证过
否
ProviderManagerAuthenticationManager 的默认实现。挨个问 Provider:这种单子你管吗?
DaoAuthenticationProvider
UserDetailsService 按用户名查出 UserDetails:密文密码、权限、账号有没有被锁
PasswordEncoder 比对 "123456" 和 {bcrypt}$2a$10$…
其他 Provider管 JWT、LDAP、记住我……这次轮不到
出来的 正式工牌 UsernamePasswordAuthenticationToken
principal
UserDetails(tom)
credentials
null,擦掉了
authorities
[ROLE_USER]
认证过
是
你要写的,通常只有这两个 Bean
@Bean
UserDetailsService userDetailsService(UserMapper mapper) {
    return username -> {
        UserEntity u = mapper.findByUsername(username);
        if (u == null) throw new UsernameNotFoundException(username);
        return User.withUsername(u.getUsername())
                .password(u.getPassword())   // 库里存的是 {bcrypt}$2a$10$…
                .roles(u.getRole())          // "USER" 会变成 ROLE_USER
                .build();
    };
}

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();  // 默认 bcrypt
}
  • 声明了这两个 Bean,Spring Security 会自己把 DaoAuthenticationProvider 装好。
  • 用户不存在,也只说“用户名或密码错误”。默认把 UsernameNotFoundException 换成 BadCredentialsException,免得被人试出哪些账号存在。
  • {bcrypt} 前缀决定用哪种算法。以后换算法,老用户的密码照样能比对。
  • JWT、LDAP、记住我,用的是同一个总管,只是换了一个 Provider。

授权:两道门

第一道门在过滤器链的最后一关,按 URL 管;第二道门在方法外面,按方法管。

第一道门:按 URLauthorizeHttpRequests → AuthorizationFilter

看请求的地址和方法。粗粒度,一整片 URL 一起管。

第二道门:按方法@PreAuthorize → AOP 代理

进了 Controller 之后,在 Service 方法外面再查一次。细粒度,能拿参数、返回值来判断。

靠 AOP 代理实现,所以第 02 篇说的“自己调自己不生效”,这里一样。

第一道门的规则,试一试
谁
请求
http.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/orders/**").hasAuthority("order:delete")
.anyRequest().authenticated()
);
403 不让进
两条规矩从上往下,第一条命中的说了算,后面的不再看,所以越具体的越要往上写;anyRequest() 只能放最后,写在它后面启动就报错。另外 hasRole("ADMIN") 就是 hasAuthority("ROLE_ADMIN"),角色只是带 ROLE_ 前缀的权限。
第二道门:写在方法上
@Service
public class OrderService {

    @PreAuthorize("hasAuthority('order:delete')")
    public void delete(Long id) { … }

    @PreAuthorize("#username == authentication.name")          // 只能查自己的
    public List<Order> listOf(String username) { … }

    @PostAuthorize("returnObject.owner == authentication.name")  // 先执行,再看结果给不给
    public Order get(Long id) { … }
}

401 还是 403,谁说了算

被拒绝之后,ExceptionTranslationFilter 看你手里拿的是什么牌,再决定交给谁。

认证失败密码错、token 过期或被改过
认证过滤器当场处理:表单登录跳回 /login?error,Bearer token 交给 AuthenticationEntryPoint
401或回登录页
不让进 · 手里是游客牌还没登录
AuthenticationEntryPoint:先记下这次请求,登录后跳回来
401或去登录页
不让进 · 手里是正式工牌登录了,权限不够
AccessDeniedHandler:再登录一次也没用
403Forbidden

@PreAuthorize 抛出的 AccessDeniedException 也会一路冒泡到 ExceptionTranslationFilter,按上表分拣。前提是中途没被你的 @ControllerAdvice 接走。

JDK 8 和 JDK 17:流程没变,写法变了

Boot 2 配 Spring Security 5,Boot 3 配 Spring Security 6。保安队、核验台、工牌、门禁这套模型没变。

Security 5 · Boot 2

JDK 8+
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .formLogin();
    }
}
  • 继承 WebSecurityConfigurerAdapter,重写 configure。5.7 起标记过时
  • authorizeRequests() + antMatchers,门禁叫 FilterSecurityInterceptor
  • 方法注解要手动打开:@EnableGlobalMethodSecurity(prePostEnabled = true)
  • 请求结束时,SecurityContextPersistenceFilter 自动把工牌存回 Session
  • javax.servlet 包。Boot 2.7(Security 5.7、5.8)已经能用右边的新写法,升级前先在这一步改

Security 6 · Boot 3

JDK 17+
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}
  • WebSecurityConfigurerAdapter 删除,只能声明 SecurityFilterChain Bean
  • authorizeHttpRequests() + requestMatchers,门禁换成 AuthorizationFilter;antMatchers、mvcMatchers 删除
  • @EnableMethodSecurity,@PreAuthorize 默认就开;@EnableWebSecurity 不再自带 @Configuration,要自己加
  • SecurityContextHolderFilter 只取不存:自己写的登录接口,要手动保存工牌
  • 门禁对每次派发都检查,转发到 /error 也要过
  • jakarta.servlet 包;.and() 写法 6.1 起标记过时,统一用 lambda
没变的:过滤器链模型认证三件套工牌放在 SecurityContextHolder401 / 403 的分工

常踩的坑

多数是没想清楚:这一刻请求走到了哪个保安面前,手里拿的是什么牌。

用 Postman 发 POST,一律 403

CSRF 默认开着,没带令牌就拒。用 Session 登录的页面要带上 _csrf;纯 token 的 API 可以 csrf(c -> c.disable())。

hasRole("ADMIN") 一直 403

库里存的权限是 ADMIN,hasRole 查的却是 ROLE_ADMIN。存成 ROLE_ADMIN,或者改用 hasAuthority("ADMIN")。

There is no PasswordEncoder mapped for the id "null"

库里的密码是明文,或者没有 {bcrypt} 这样的前缀。用 PasswordEncoder 加密后再存。

自己写的 JWT 过滤器,一个请求执行两次

标了 @Component,Boot 又把它注册成了普通 Filter。去掉 @Component 直接 new,或者用 FilterRegistrationBean 关掉自动注册。

token 过期的异常,@ControllerAdvice 接不到

是过滤器里抛的,不经过 Spring MVC。用 AuthenticationEntryPoint 统一返回 401 JSON。

@PreAuthorize 拒绝后,前端拿到的是 500

全局 @ExceptionHandler(Exception.class) 先把 AccessDeniedException 吃掉了。单独接住它再抛出去,交还给 Spring Security。

permitAll 的接口一报错,返回的却是 401

出错后会转发到 /error,而 /error 没放行。把 "/error" 也加进 permitAll。

@Async、线程池里拿不到当前用户

工牌挂在 ThreadLocal 上,换了线程就没了。用 DelegatingSecurityContextExecutor 包一下线程池。

判断“登录了没有”,游客也算登录了

游客牌的 isAuthenticated() 也返回 true。要排除 AnonymousAuthenticationToken,或者用 AuthenticationTrustResolver.isAnonymous(…)。

Security 6 自己写登录接口,下一个请求又成了游客

6 里工牌不会自动存回 Session。认证成功后要调用 securityContextRepository.saveContext(…)。