楼层: 首页/ 软件技术/ Spring 核心框架/ Spring Security 核心:一条过滤器链管住所有请求
11

Spring Security 核心:一条过滤器链管住所有请求

Spring Security Core · Filter Chain · Authorization

前面十章讲的是"怎么把功能写出来",这一章开始讲"怎么保证只有该来的人才能用"。Spring Security 是 Spring 生态里改动最频繁、版本差异最大的一块——同一个需求,Spring Security 5 和 6 的写法完全是两套。所以你学它,第一件事不是记注解,而是先搞清楚它在请求处理链路里到底站在哪一步。

先定位:过滤器链在 Servlet 请求处理中的位置

Servlet 规范定义了 Filter 机制:请求到达 Servlet 之前,会先穿过一串 Filter;响应返回时再反向穿回来。Spring Security 的全部魔法都装在这一串 Filter 里,它没有独立的入口,也没有拦截器那么"后置",它就是最前面的一道铁闸。

一个请求进 Spring Boot 应用的完整路径是这样的:

阶段发生了什么
① ConnectorTomcat 的 Coyote 从 socket 读出字节,解析成 HttpServletRequest。
② Filter 链依次经过 CharacterEncodingFilter、springSecurityFilterChain(DelegatingFilterProxy)、FormContentFilter 等。
③ DispatcherServlet拿到已经被"认证过"的请求,做 HandlerMapping、参数绑定。
④ HandlerInterceptorpreHandle → Controller → postHandle → afterCompletion。
⑤ Controller你的业务代码,终于跑到这里了。

论为什么安全要做在 Filter 层,而不是 Interceptor 层

① Filter 在 Servlet 之外:Filter 是 Servlet 容器级别的组件,DispatcherServlet 还没初始化就能生效。这意味着静态资源、错误页、甚至 Servlet 映射不到的路径,也一样被拦住。Interceptor 挂在 DispatcherServlet 上,管不到这些。

② Filter 能改写请求和响应:HttpServletRequestWrapper 可以包一层,把 JSON 里的 token 提前解析成认证信息塞进去。Interceptor 拿到的已经是成品请求,改起来别扭。

③ 安全是"先于业务"的事:没认证就该 401,根本不该走到参数绑定、日志打印、数据库查询。放在 Filter 层是最早能拦住的位置,省掉后面所有开销。

一句话:Filter 是门口的保安,Interceptor 是楼里的前台。保安不让你进,前台根本见不到你。

那 Spring Boot 里为什么你在配置文件里找不到任何 Filter 注册代码,请求却确实被拦住了?原因在自动配置:SecurityFilterAutoConfiguration 注册了一个名为 springSecurityFilterChain 的 DelegatingFilterProxy,它本身什么都不干,只负责把请求转交给 Spring 容器里那个真正的 FilterChainProxy。

而这个 FilterChainProxy 内部,是一条由十几个 Filter 组成的内部链,顺序是固定写死的:

序Filter职责
1DisableEncodeUrlFilter把 ;jsessionid=xxx 这种 URL 里的会话 ID 干掉,避免泄露。
2WebAsyncManagerIntegrationFilter让异步线程里也能拿到 SecurityContext。
3SecurityContextHolderFilter从 SecurityContextRepository 取出上下文,塞进 ThreadLocal。
4HeaderWriterFilter写 X-Content-Type-Options、X-Frame-Options 等安全响应头。
5CorsFilter跨域预检处理(cors() 开启时才加入)。
6CsrfFilter校验写请求里的 CSRF token。
7LogoutFilter处理 /logout。
8UsernamePasswordAuthenticationFilter表单登录:从请求里抠用户名密码,交给 AuthenticationManager。
9BearerTokenAuthenticationFilter资源服务器:解析 Authorization: Bearer <jwt>。
10RequestCacheAwareFilter记住"登录前想访问哪个页面",登录后跳回去。
11AnonymousAuthenticationFilter没人认领时塞一个匿名身份,避免到处判 null。
12SessionManagementFilter会话固定攻击防护、并发登录控制。
13ExceptionTranslationFilter捕获下游的 认证异常 和 授权异常,转成 302 登录页或 403。
14AuthorizationFilter真正的鉴权:按 authorizeHttpRequests 的规则放行或拒绝。
15FilterSecurityInterceptor老式鉴权入口,6.x 中默认已被 14 取代。
认证在前,鉴权在后

看这张表的顺序:第 8/9 步是认证(你是谁),第 14 步才是鉴权(你能干啥)。这个顺序不能颠倒——先知道你是谁,才谈得上你有没有权限。所以你写 @PreAuthorize 拿不到用户名,八成是认证那一步就没成功,而不是注解写错了。

另外记住:ExceptionTranslationFilter 在第 13 步、AuthorizationFilter 在第 14 步,是故意的。鉴权抛出的异常能被外层那圈 try-catch 接住,转成 401/403 而不是 500。

SecurityContextHolder:当前用户到底存在哪

认证成功后,"你是谁"这个东西放在 SecurityContextHolder 里。它默认是一个 ThreadLocal——这是理解 Spring Security 一切"诡异现象"的钥匙。

// 两种常见写法:一个能改,一个只读(推荐的取字段方式) Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 判断是否已认证(匿名用户也会有一个 Authentication,但 authenticated=false) if (auth != null && auth.isAuthenticated()) { String username = auth.getName(); // 用户名 Collection<? extends GrantedAuthority> roles = auth.getAuthorities(); // 角色 / 权限集合 } // 只读快照,取对象用这个(防手滑覆盖) Authentication ro = SecurityContextHolder.getContext() .getAuthentication();

关于"用户信息"的存放,还有几个容易混的地方:

存取方式含义与适用场景
SecurityContextHolder.getContext()当前线程的上下文,方法里随取随用。
@AuthenticationPrincipalController 方法参数注入 UserDetails,比手动取干净。
Authentication 参数直接在 Controller 方法上声明 Authentication 参数,Spring Security 自动注入。
Authentication.getPrincipal()拿主体对象。匿名用户时它的类型是 String("anonymousUser")而不是 UserDetails,强转就 ClassCastException。
Principal 参数标准 Servlet 接口,值同 getPrincipal(),同样有匿名类型坑。

论ThreadLocal 的三个后果,必须背下来

① 子线程拿不到:new Thread(...).start() 里调用 SecurityContextHolder.getContext().getAuthentication(),得到的是 null。因为 ThreadLocal 是线程私有的,子线程没有那段内存。要传就手动 set,或者用 DelegatingSecurityContextExecutor 包装线程池。

② 线程池会串号:这才是最要命的。Tomcat 的线程是复用的,如果某个 Filter 忘了清理上下文,上一个用户的身份就留在 ThreadLocal 里,下一个请求可能"继承"它。Spring Security 的 SecurityContextHolderFilter 会在 finally 里清理,所以你千万别自己往 ThreadLocal 塞用户对象而不清理。

③ 异步接口要特殊照顾:Spring MVC 的 DeferredResult / Callable 会切线程,Spring Security 专门有 WebAsyncManagerIntegrationFilter 来传递上下文。而在 Java 21 虚拟线程里,因为每个请求可能挂在不同的载体线程上,你更不能依赖"某个线程的 ThreadLocal"——JDK 的 ThreadLocal 在虚拟线程上仍按虚拟线程隔离,行为是对的,但跨虚拟线程一样传不过去。

记住一句:SecurityContextHolder 是"当前线程的当前用户",不是"当前请求的当前用户"。这两者在同步阻塞模型下恰好相等,一旦涉及多线程就不等。

MODEL_INHERITABLE 与"手动 set"的诱惑

有人发现子线程拿不到用户,就去改成 SecurityContextHolder.setStrategyName(MODE_INHERITABLE_THREAD_LOCAL)。这是个坑:它会让线程池里所有线程共享创建者那一瞬间的上下文快照,在自定义线程池场景下极易出现身份污染。官方明确建议:不要开 Inheritable,用 DelegatingSecurityContextExecutor 显式包装。少写两行代码,换来一个偶发的越权 bug,不值。

认证与授权:两个必须分清的阶段

面试里最能看出一个人是否真用过 Spring Security 的问题就是:"认证和授权什么区别?"标准答案不长,但要说清它们各自的对象和异常。

维度认证 Authentication授权 Authorization
回答的问题你是谁你能干什么
核心对象Authentication、UserDetails、AuthenticationManagerGrantedAuthority、AuthorizationManager
失败异常AuthenticationException(BadCredentialsException 等)AccessDeniedException
失败响应未登录 → 302 跳登录页;API → 401 Unauthorized已登录但无权限 → 403 Forbidden
发生时机链中第 8/9 个 Filter链中第 14 个 Filter 或方法调用时
典型配置formLogin() / oauth2ResourceServer()authorizeHttpRequests() / @PreAuthorize

论401 和 403 千万别混着用

① 401 Unauthorized 的真实含义是"未认证":HTTP 规范里它叫 Unauthorized,但语义是 "you need to authenticate"。响应要带 WWW-Authenticate 头告诉客户端怎么认证。用户没登录、token 过期、token 签名错,都该回 401。

② 403 Forbidden 的含义是"认证过了,但不该给你":用户已登录、身份有效,只是角色不够(比如普通用户去调管理员接口)。不带 WWW-Authenticate,因为再登录一次也没用。

③ 前端是根据状态码做不同动作的:401 → 跳登录页 / 刷新 token;403 → 弹"无权限"提示,别跳登录。你要是把 403 也返回 401,前端会陷入"刷新 token → 还是 401 → 再刷新"的死循环。

④ 前后端分离时最容易踩:默认 ExceptionTranslationFilter 对未认证请求是 302 重定向到登录页,前端拿到一个 HTML 登录页的响应体,JSON.parse 直接炸。必须配 authenticationEntryPoint 改成返回 401 JSON。这是前后端分离项目上线前必查项。

UserDetailsService 与 PasswordEncoder:账号从哪来,密码怎么存

AuthenticationManager 是认证的总指挥,但它自己不会查数据库。它把活派给 AuthenticationProvider,而 DaoAuthenticationProvider 又会做两件事:用 UserDetailsService 按用户名捞出账号,再用 PasswordEncoder 比对密码。这两个接口就是你日常要实现的全部。

从数据库读用户:实现 UserDetailsService

@Service public class DbUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public DbUserDetailsService(UserMapper userMapper) { this.userMapper = userMapper; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserEntity u = userMapper.findByUsername(username); if (u == null) { // 注意:抛这个异常,Spring Security 才能正确翻译成"用户名或密码错误" throw new UsernameNotFoundException("用户不存在: " + username); } // authorities 里一定要带 ROLE_ 前缀,hasRole 会自动补,hasAuthority 不会 List<GrantedAuthority> authorities = u.getRoles().stream() .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) .toList(); return User.withUsername(u.getUsername()) .password(u.getPasswordHash()) // 库里的哈希,绝不能是明文 .authorities(authorities) .accountLocked(u.isLocked()) .disabled(!u.isEnabled()) .build(); } }

密码编码器这块,2026 年的选择其实很清晰了。先看对比:

算法类型强度 / 开销什么时候用
BCrypt自适应哈希中等,strength 默认 10默认首选。工业界用了二十年,成熟、库多、CPU 开销可控。
Argon2内存硬哈希高,吃内存(可配)新项目、安全要求高、有足够内存时。能抵抗 GPU/ASIC 暴力破解,是密码哈希竞赛冠军。需要 bouncycastle 依赖。
PBKDF2迭代哈希可调FIPS 合规场景(FIPS 不认 BCrypt/Argon2)。
SCrypt内存硬哈希高Argon2 不可用时的替代。
NoOpPasswordEncoder明文无只允许在测试里用。生产用了就是不设防。
MD5 / SHA-256 裸哈希快速哈希形同虚设永远不要。无盐、速度快,彩虹表和 GPU 一秒几十亿次。

密码编码器:委托式配置,平滑升级老密码

@Bean public PasswordEncoder passwordEncoder() { // 1) 只想用 BCrypt:一行搞定 // return new BCryptPasswordEncoder(12); // 数字越大越慢越安全,10~12 是常用区间 // 2) 想用 Argon2 + 平滑迁移老密码:用 DelegatingPasswordEncoder // 存储格式变成 {argon2}$argon2id$v=19$m=16384,t=2,p=1$xxxx String idForEncode = "argon2"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("bcrypt", new BCryptPasswordEncoder()); return new DelegatingPasswordEncoder(idForEncode, encoders); // 老数据 {bcrypt}xxx 仍能验证通过;新写入的用 {argon2} }
<!-- Argon2 需要显式加 BouncyCastle,否则启动就报类找不到 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> </dependency>
密码这块的四个经典坑

① 两个 PasswordEncoder Bean:项目里既有自建的 BCryptPasswordEncoder,Spring Boot 又自动配了一个 DelegatingPasswordEncoder,注入时按类型找不到唯一的 Bean 直接启动失败。解法:只留一个 @Bean,或者给自定义的加 @Primary。

② 用户不存在时抛错太直接:UsernameNotFoundException 会被 DaoAuthenticationProvider 统一翻译成 BadCredentialsException,所以外部无法据此判断"用户是否存在"。这是好事——你自己写 Controller 时也别返回"用户不存在",那等于给攻击者当用户名探测器,统一回"用户名或密码错误"。

③ 忘了加盐就换算法:BCrypt 自带随机盐(同一密码两次 encode 结果不同,这是正常的,别拿字符串相等去判断),所以你不需要自己加盐。但如果你用了 MessageDigestPasswordEncoder 这类老式编码器,就得自己配 salt,一旦换 salt,全库密码作废。

④ 强度参数调到 14 以上:BCrypt strength 每加 1,耗时翻倍。设成 14 会让单次登录耗时超过 1 秒,登录接口瞬间变成 DoS 放大器——攻击者拿 100 个并发就能打满你的 CPU。测好你的机器,10~12 足够了。

方法级鉴权:@PreAuthorize 与 SpEL

URL 级鉴权管的是"哪个路径谁能进",但真实业务里权限往往和数据有关:同一个 /orders/{id},订单主人能看,别人不能看。这种判断只能写在方法上。

注意一个前提:方法级鉴权默认是不开的,必须在配置类上加 @EnableMethodSecurity(Spring Security 5.6 之前叫 @EnableGlobalMethodSecurity(prePostEnabled = true),这个名字在 6.x 已被移除)。

方法级鉴权:从最简单的角色到数据级所有权判断

@Configuration @EnableMethodSecurity // 6.x 的名字;不写这行,下面所有注解全部静默失效 public class MethodSecurityConfig { } @Service public class OrderService { // 1) 最常用:要求某个角色。hasRole('ADMIN') 会自动补 ROLE_ 前缀 @PreAuthorize("hasRole('ADMIN')") public void closeAllOrders() { } // 2) 要求具体权限码,与角色无关。hasAuthority 不会补前缀 @PreAuthorize("hasAuthority('order:export')") public byte[] export() { return null; } // 3) 数据级:只能查自己的订单。参数名可通过 #id 直接引用 @PreAuthorize("@orderAuth.canAccess(#id, authentication.name)") public Order getById(long id) { return null; } // 4) 用 returnObject 做"返回后过滤":结果不满足条件就抛 AccessDenied @PostAuthorize("returnObject.ownerId == authentication.name") public Order getByIdStrict(long id) { return null; } // 5) 过滤集合:只保留有权限看的那部分,不报错 @PostFilter("filterObject.ownerId == authentication.name") public List<Order> listAll() { return null; } }
注解什么时候用 / 注意事项
@PreAuthorize调用前判断,最常用。方法与参数都能写进 SpEL。
@PostAuthorize调用后判断,能拿 returnObject。缺点:业务代码已经执行了,如果它写了库,异常回滚也来不及了。
@PreFilter过滤入参集合,只让有权限的元素进方法。
@PostFilter过滤返回值集合。逐元素反射调用 SpEL,大集合时性能很差,几千条以上就别用了,改在 SQL 里过滤。
hasRole('X')实际判断 ROLE_X。传参别自己带前缀,写成 hasRole('ROLE_X') 会变成 ROLE_ROLE_X。
hasAuthority('X')精确匹配,不补前缀。适合 order:read 这种细粒度权限码。
authenticationSpEL 内置变量,指向当前 Authentication,可取 .name、.principal。
@beanName.method(...)调用容器里自己的 Bean 做复杂判断(如上面例 3 的 @orderAuth)。
@PreAuthorize 不生效:九成是这四种情况

① 忘了 @EnableMethodSecurity:注解写了但不生效,还不报错,最隐蔽。

② 同类内部调用:this.internalMethod() 绕过代理。方法级鉴权跟 @Transactional 一样走 AOP 代理,自调用等于没写。

③ 注解贴在接口上而代理是 CGLIB 类代理:默认 proxyTargetClass=true,注解要写在实现类方法上才稳。

④ Controller 上写 @PreAuthorize 却返回 500 而不是 403:因为 AccessDeniedException 抛在 DispatcherServlet 里,ExceptionTranslationFilter 已经不在栈上了。要么加 @ExceptionHandler(AccessDeniedException.class) 自己处理,要么给 @RestControllerAdvice 加一个统一处理。这是方法级鉴权最容易漏的收尾工作。

CSRF 到底该不该关:一句话判断法

CSRF(跨站请求伪造)的攻击场景是:用户已登录 A 站,浏览器里存着 A 站的 Cookie;此时访问恶意 B 站,B 站悄悄向 A 站发一个 POST /transfer。浏览器会自动带上 A 站的 Cookie,A 站以为是用户本人操作,钱就转出去了。

关键在于攻击的成立条件,只有一条:认证凭据是浏览器自动携带的。抓住这一条,关不关 CSRF 立刻就能判断。

认证方式CSRF 该开吗原因
Session / Cookie(含 JSESSIONID)必须开浏览器自动带 Cookie,攻击者的表单提交天然生效。必须校验 token。
JWT 放在 Authorization 头可以关头部需要 JS 显式设置,跨站表单无法伪造。但前提是你真的没把 token 存 Cookie。
JWT 存在 Cookie 里必须开虽然名字叫 JWT,但存储载体是 Cookie,浏览器照样自动带。这种情况下 CSRF 风险与 Session 完全一致。
纯静态资源 / 只读公开接口可以关无状态、无凭据,没什么可伪造的。
网关统一处理鉴权看情况如果浏览器到网关还是带 Cookie 的,网关要开;网关后面服务间调用是内网 token,可以关。

论不想关 CSRF?SPA 的正确配置方式

① 官方推荐 CookieCsrfTokenRepository.withHttpOnlyFalse():把 CSRF token 写进一个可被 JS 读取的 Cookie(默认名 XSRF-TOKEN),前端读到后放进请求头,Spring Security 6.1 默认接受的头名是 X-XSRF-TOKEN。

② 关键点是"双重提交":攻击者的跨站请求能自动带 Cookie,但读不到 Cookie 内容(同源策略),所以伪造不出请求头。Cookie 里有一份、请求头里有一份,两份对上才放行——这就是它安全的原因。

③ 6.x 有个容易卡住人的变化:CSRF token 从 6.0 起改成延迟加载。如果你自定义了 CsrfTokenRequestAttributeHandler 但没处理延迟,前端第一次 GET 拿不到 XSRF-TOKEN Cookie,于是后续 POST 全部 403。6.1 里的解法是改用 CsrfTokenRequestAttributeHandler 并把 setCsrfRequestAttributeName(null),强制提前生成 token;或者干脆给 /csrf 加一个只读接口让前端主动去拿一次。

④ 记住这条经验:你的 403 报错信息里出现 "Invalid CSRF token" 或者 "Expected CSRF token not found",八成不是权限问题,是 token 没传对。先去看浏览器有没有 XSRF-TOKEN 这个 Cookie,比翻代码快得多。

SecurityFilterChain 函数式配置:一条链还是一个 Bean

Spring Security 6 的配置方式只有一种:声明一个 SecurityFilterChain 类型的 @Bean,用 Lambda DSL 描述规则。老的 WebSecurityConfigurerAdapter(继承 + configure(HttpSecurity))在 6.0 里已经被彻底删除——注意是删除,不是废弃,抄老代码会直接编译不过。

标准的 SecurityFilterChain:纯 API,无 Session,返回 JSON 错误

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain apiChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception { http // 1) 关掉默认的 Basic 表单登录、关掉 Session 创建 .csrf(AbstractHttpConfigurer::disable) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 2) 鉴权规则:注意顺序,先具体后宽泛,写反了具体规则永远不生效 .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health", "/v3/api-docs/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) // 3) 认证入口:把默认的 302 重定向改成 401 JSON .exceptionHandling(ex -> ex .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) .accessDeniedHandler((req, res, e) -> { res.setStatus(HttpStatus.FORBIDDEN.value()); res.setContentType("application/json;charset=UTF-8"); res.getWriter().write("{\"code\":403,\"msg\":\"无权限\"}"); }) ) // 4) 无状态 JWT 认证 .oauth2ResourceServer(o -> o.jwt(j -> j.decoder(jwtDecoder))) // 5) 关掉默认退出登录过滤器(无状态应用没有 logout) .logout(AbstractHttpConfigurer::disable); return http.build(); } }
// 需要多条链时:用 @Order 声明优先级。请求只会命中"第一条匹配的链" @Bean @Order(1) // 小的先执行 public SecurityFilterChain adminChain(HttpSecurity http) throws Exception { http.securityMatcher("/admin/**") // 只接管这个前缀 .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(a -> a.anyRequest().hasRole("ADMIN")); return http.build(); } @Bean @Order(2) public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a.anyRequest().permitAll()); return http.build(); }
函数式配置的五个坑

① requestMatchers 顺序就是优先级:它从上往下匹配,命中就停。把 anyRequest() 写在前面,后面所有规则全废。

② antMatchers / mvcMatchers 已删除:6.x 统一成 requestMatchers。看到教程里写 antMatchers,那教程至少五年前的。

③ hasRole("ADMIN") vs hasAuthority("ADMIN") 差一个前缀:前者查 ROLE_ADMIN,后者查 ADMIN。数据库里存的角色名如果没带前缀,用 hasRole 就永远 403。统一约定:库里存 ADMIN,UserDetailsService 里拼 ROLE_,配置里用 hasRole。

④ 多条链时忘了 securityMatcher:不写就等于接管全部请求,后面的链永远不执行,你会看到"配置明明加了却不生效"。

⑤ 自定义 Filter 忘了插在正确位置:http.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class)。如果插在 AuthorizationFilter 之后,你的认证逻辑白写——因为鉴权已经判完了。

记
Spring Security 核心一句话总结

① 它是一条 Servlet 过滤器链:安全放在 Filter 层而不是 Interceptor 层,因为要拦住所有请求、且要最早拦。

② 认证在前、鉴权在后:第 8/9 个 Filter 认人,第 14 个 Filter 判权。401 是"没认出来",403 是"认出来了但不给"。

③ SecurityContextHolder 是 ThreadLocal:子线程和线程池都不安全,异步场景必须显式传递。

④ 账号靠 UserDetailsService,密码靠 PasswordEncoder:BCrypt 是默认答案,Argon2 是升级答案,明文哈希是错误答案。

⑤ CSRF 只看一条:认证凭据是不是浏览器自动带的。Cookie 就开,纯请求头 token 才能关。

⑥ 配置只有一个入口:@Bean SecurityFilterChain,Lambda DSL,WebSecurityConfigurerAdapter 已经删了。

章末面试 · Spring Security 核心(5 题)

1.(概念题)为什么 Spring Security 要基于 Servlet Filter 实现,而不是 Spring MVC 的 HandlerInterceptor?

查看答案

答案:① Filter 在 Servlet 容器层,DispatcherServlet 初始化前就生效,能覆盖静态资源、错误页、未映射路径;Interceptor 只管得到 DispatcherServlet 分发的请求。② Filter 能包装并改写请求/响应对象。③ 安全必须最早拦截,避免无谓的解析与查询开销。解析:面试官想听的是"能拦得更早、拦得更全"。

2.(排错题)某个接口错了会返回 401,但前端日志显示浏览器收到了一个 302 跳到 /login 的 HTML 响应。什么问题?

查看答案

答案:ExceptionTranslationFilter 默认的 AuthenticationEntryPoint 是 LoginUrlAuthenticationEntryPoint,未认证时走 302 重定向到登录页。前后端分离要配 exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))),或者自定义返回 JSON 的输出流。解析:这是前后端分离项目上线前的必查项,否则前端 JSON.parse 一定炸。

3.(选择题)数据库里角色字段存 ADMIN,UserDetailsService 里用 new SimpleGrantedAuthority("ADMIN"),配置写 hasRole("ADMIN")。结果如何?A. 正常放行 B. 始终 403 C. 启动报错

查看答案

答案:B,始终 403。hasRole("ADMIN") 会去找 ROLE_ADMIN,而权限集合里只有 ADMIN,匹配不上。解析:改成 hasAuthority("ADMIN"),或者构造权限时拼上 ROLE_ 前缀。这是最高频的 403 原因。

4.(判断题)前后端分离、JWT 放在 Authorization 头里,所以 CSRF 可以放心关。这个判断需要补什么前提?

查看答案

答案:前提是 token 真的只存在 JS 内存或 localStorage,绝不能放在 Cookie 里。如果为了"自动携带方便"把 JWT 写进 Cookie,浏览器就会自动带上,CSRF 风险与 Session 方案一模一样,必须重新开启 CSRF 防护。解析:决定 CSRF 开关的是"凭据载体"而不是"凭据格式"。

5.(场景题)接口先用 @PreAuthorize 拦住了,无权限时返回 500 而不是 403。为什么?怎么修?

查看答案

答案:方法级鉴权发生在 Controller 方法调用时,此时请求早已穿过 ExceptionTranslationFilter,抛出的 AccessDeniedException 没人翻译,被全局异常处理器当成未知异常返回 500。修法:在 @RestControllerAdvice 里加 @ExceptionHandler(AccessDeniedException.class) 返回 403。注意不要在 Handler 里把 AccessDeniedException 直接吞掉返回 200,那等于自废武功。解析:URL 级鉴权由 Filter 兜底所以是 403,方法级鉴权要自己收尾。