SPRING SECURITY · AUTHENTICATION · AUTHORIZATION
先认人,再放行
Spring Security 是站在 Controller 前面的一排保安。每个请求都要先过这一关:先查清你是谁,再看你能进哪扇门。
SecurityFilterChain一排过滤器,每人只管一件事。请求从头走到尾。
本质是一个 Servlet FilterAuthenticationManager核对身份:查档案、比密码、验 token。
认证:你是谁Authentication核验通过就发一张,挂在当前线程上,后面谁都能看。
放在 SecurityContextHolder 里AuthorizationManager看工牌上的权限,决定这扇门让不让进。
授权:你能干什么只加一个依赖,会发生什么
什么都不配,Spring Boot 也会先给你一套能用的默认值。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
访问任何地址,都先被带去登录页。
/login 表单、/logout 都是自动生成的,还支持 HTTP Basic。
用户名 user,密码每次启动随机生成,印在日志里。
CSRF 校验、安全响应头、登录后换一个新的 Session ID。
@ConditionalOnMissingBean 这类条件:你声明了自己的 SecurityFilterChain,默认的链就让位;声明了自己的 UserDetailsService,user 这个账号就消失。保安站在哪
Spring Security 整个就是一个 Servlet Filter,挂在 Tomcat 的过滤器链上,比 DispatcherServlet 还早。
登记在 Tomcat 里的普通 Filter。自己不干活,只转交。
Spring 容器里的 Bean,名字叫 springSecurityFilterChain。按 URL 挑一条链。
为什么要转一手?Tomcat 启动时就要拿到所有 Filter,那时 Spring 的 Bean 不一定造好了。先放一个代理占位,等请求来了,再去容器里找这个 Bean。
没过门禁的请求,到不了 DispatcherServlet。
过滤器里抛的异常不经过 Spring MVC。第 01 篇讲过。
Boot 里由 spring.security.filter.order 决定,默认 -100。
保安队名单
按真实执行顺序排。每人只管一件事,不归自己管就放行。用了哪些功能,链上才有哪些人。
SecurityContextHolderFilterHeaderWriterFilterCorsFilterCsrfFilterLogoutFilterUsernamePasswordAuthenticationFilterBearerTokenAuthenticationFilterBasicAuthenticationFilterRememberMeAuthenticationFilterAnonymousAuthenticationFilterExceptionTranslationFilterAuthorizationFilter想看自己项目的完整名单:配上 logging.level.org.springframework.security=TRACE,每个请求经过了哪几个过滤器,会逐个打印出来。
跟着一个请求走一遍
选一个剧情,一步一步看:请求走到了哪个保安面前,核验台在干什么,工牌上写着什么,最后返回了什么。
- 1取工牌
SecurityContextHolderFilter - 2查 CSRF 令牌
CsrfFilter这条链关掉了 - 3处理 /logout
LogoutFilter - 4表单登录
UsernamePasswordAuthenticationFilterformLogin()这条链没配 - 5Bearer token
BearerTokenAuthenticationFilteroauth2ResourceServer()这条链没配 - 6发游客牌
AnonymousAuthenticationFilter - 7翻译官
ExceptionTranslationFilter - 8门禁
AuthorizationFilter - Spring Security 到此为止
- →终点
DispatcherServlet → 拦截器 → Controller
工牌 · SecurityContextHolder
核验台 · AuthenticationManager
返回给浏览器
SecurityContextHolderFilter工牌长什么样,怎么拿到
三层套娃。最外层挂在当前线程上,所以同一个请求里,Controller、Service 哪儿都能取到。
principal你是谁。登录后通常是一个 UserDetailscredentials凭证,比如密码。认证完就擦掉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"
- 认证过
- 否
UserDetailsService 按用户名查出 UserDetails:密文密码、权限、账号有没有被锁PasswordEncoder 比对 "123456" 和 {bcrypt}$2a$10$…UsernamePasswordAuthenticationToken
- principal
- UserDetails(tom)
- credentials
- null,擦掉了
- authorities
- [ROLE_USER]
- 认证过
- 是
@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 篇说的“自己调自己不生效”,这里一样。
.requestMatchers("/login", "/public/**").permitAll().requestMatchers("/admin/**").hasRole("ADMIN").requestMatchers(HttpMethod.DELETE, "/orders/**").hasAuthority("order:delete").anyRequest().authenticated()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 看你手里拿的是什么牌,再决定交给谁。
/login?error,Bearer token 交给 AuthenticationEntryPointAuthenticationEntryPoint:先记下这次请求,登录后跳回来AccessDeniedHandler:再登录一次也没用@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删除,只能声明SecurityFilterChainBeanauthorizeHttpRequests()+requestMatchers,门禁换成AuthorizationFilter;antMatchers、mvcMatchers删除@EnableMethodSecurity,@PreAuthorize默认就开;@EnableWebSecurity不再自带@Configuration,要自己加SecurityContextHolderFilter只取不存:自己写的登录接口,要手动保存工牌- 门禁对每次派发都检查,转发到
/error也要过 jakarta.servlet包;.and()写法 6.1 起标记过时,统一用 lambda
常踩的坑
多数是没想清楚:这一刻请求走到了哪个保安面前,手里拿的是什么牌。
CSRF 默认开着,没带令牌就拒。用 Session 登录的页面要带上 _csrf;纯 token 的 API 可以 csrf(c -> c.disable())。
库里存的权限是 ADMIN,hasRole 查的却是 ROLE_ADMIN。存成 ROLE_ADMIN,或者改用 hasAuthority("ADMIN")。
库里的密码是明文,或者没有 {bcrypt} 这样的前缀。用 PasswordEncoder 加密后再存。
标了 @Component,Boot 又把它注册成了普通 Filter。去掉 @Component 直接 new,或者用 FilterRegistrationBean 关掉自动注册。
是过滤器里抛的,不经过 Spring MVC。用 AuthenticationEntryPoint 统一返回 401 JSON。
全局 @ExceptionHandler(Exception.class) 先把 AccessDeniedException 吃掉了。单独接住它再抛出去,交还给 Spring Security。
出错后会转发到 /error,而 /error 没放行。把 "/error" 也加进 permitAll。
工牌挂在 ThreadLocal 上,换了线程就没了。用 DelegatingSecurityContextExecutor 包一下线程池。
游客牌的 isAuthenticated() 也返回 true。要排除 AnonymousAuthenticationToken,或者用 AuthenticationTrustResolver.isAnonymous(…)。
6 里工牌不会自动存回 Session。认证成功后要调用 securityContextRepository.saveContext(…)。