Java 权限设计:Spring Security + JWT 实现认证授权

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-10-07 07:33 ·4 浏览 ·4 回复
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-734.html
转载请注明出处,版权归原作者所有。

全部回复 4

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-10-07 07:38

Spring Security 6 这套骨架最容易翻车的不是 JWT 本身,而是配置类写法——建议第三步之前先把 SecurityFilterChain 配好,否则后面 DbUserDetailsService 写得再对也跑不通。

配置上有几个硬性变化要记住:WebSecurityConfigurerAdapter 已彻底废弃,改成暴露 SecurityFilterChain Bean;authorizeRequests() 换成 authorizeHttpRequests(),antMatchers 换成 requestMatchers。放行登录接口用 requestMatchers("/api/auth/**").permitAll(),其余 anyRequest().authenticated()。自定义 JWT 过滤器用 addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) 挂上,同时在链上关掉 csrf、把 sessionManagement 设成 STATELESS,不然前后端分离场景会一直拿不到登录态。

角色这块有个高频坑:hasRole("ADMIN") 内部会自动拼 ROLE_ 前缀,所以你存进 claim("roles") 的字符串必须本身就是 ROLE_ADMIN,写成 ADMIN 会 403;不想被前缀绑架就统一用 hasAuthority。另外 PasswordEncoder 一定要显式暴露成 Bean(推荐 BCryptPasswordEncoder),否则登录时容易撞上 There is no PasswordEncoder mapped for the id "null"。

最后补两点:一是 JWT 只是签名不是加密,payload 用 Base64 解一下就是明文,手机号、权限明细这类别往里塞;二是异常别交给默认处理,配个 AuthenticationEntryPoint 返回 401、AccessDeniedHandler 返回 403,否则前端拿到的是 Spring 那个 HTML 登录页,联调时特别迷惑。

顺带一提,帖子里第三步的 loadUserByUsername 代码被截断了,建议补全,User.withUsername(...).password(...).authorities(roles) 那段才是新手最容易写错的地方。

dp32323
dp32323 正式会员正式会员 #479 2楼 2026-10-07 07:48
一只冷漠的狐狸:Spring Security 6 这套骨架最容易翻车的不是 JWT 本身,而是配置类写法——建议第三步之前先把 `SecurityFilterChain` 配…

配置先行这点我完全赞成,而且我建议比「先配好再写 Service」再激进一点——第一步直接写一个 anyRequest().permitAll() 的空链,先把应用跑起来,确认 Spring Security 6 的依赖没冲突,再往链上加 JWT 过滤器、逐步收紧。这样出问题时排查面永远是「刚加的那一行」。

补两个你这套说法里可以再抠一下的点。一是 There is no PasswordEncoder mapped for the id "null",除了没暴露 PasswordEncoder Bean,更常见的触发条件是 UserDetails 里的 password 本身就是 null——DelegatingPasswordEncoder 拼出来的是 {null}密文,照样报这个错。所以配合帖子被截断的那段,我一般这么补:

@Override
public UserDetails loadUserByUsername(String username) {
    SysUser u = userMapper.findByUsername(username);
    if (u == null) throw new UsernameNotFoundException("用户不存在");
    return User.builder()
        .username(u.getUsername())
        .password(u.getPassword())   // 必须非空,存 BCrypt 密文
        .authorities(roles)          // 这里要么带 ROLE_,要么接口侧统一用 hasAuthority
        .disabled(u.getStatus() == 0)
        .build();
}

二是异常和跨域这两处,联调时比 401/403 本身更迷惑。JWT 过滤器里解析失败(过期、验签不过)不要直接往外抛,捕获后清空 SecurityContext,主动交给 AuthenticationEntryPoint,否则前端收到的是 500 而不是 401,拦截器没法统一跳登录。另外别忘了 http.cors(Customizer.withDefaults()) 加一个 CorsConfigurationSource Bean,预检的 OPTIONS 请求不带 Authorization 头,不加这一行会被链直接拦掉,表现为「Postman 能通、浏览器全挂」。

收尾给个笨办法:三个分支(不带 token / 篡改 token / 正常 token)各用 curl 打一遍,状态码对上 401/401/200 再动前端,比在浏览器里来回试快得多。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #482 3楼 2026-10-07 07:52
dp32323:配置先行这点我完全赞成,而且我建议比「先配好再写 Service」再激进一点——第一步直接写一个 `anyRequest().permitAll()` 的空链,…

「空链先跑通、再逐步收紧」这个思路我完全赞同,但有个前提容易被忽略:方法级安全是另一层,别只盯着过滤器链。

如果项目开了 @EnableMethodSecurity,方法上的 @PreAuthorize 独立于链生效。所以空链阶段接口能通,不代表带注解的接口能通——排查时把「链放行」和「方法放行」当两层看,否则很容易在链上找半天,结果是注解拦的。另外空链跑通后尽快把 permitAll 缩到具体路径,别让它活到联调中期,安全测试一跑就红。

PasswordEncoder 那块再抠一下:no PasswordEncoder mapped for the id "null" 其实只在用 DelegatingPasswordEncoder(比如 PasswordEncoderFactories.createDelegatingPasswordEncoder())时才会出现。如果你直接 new BCryptPasswordEncoder() 暴露成 Bean,压根不拼前缀,报错形态会变成 IllegalArgumentException: rawPassword cannot be null 或直接 BadCredentialsException,反而更难看出是空密码。所以我的习惯是单算法就用裸 BCrypt 省心,同时在 Service 里对 password 加断言兜底,别指望框架把话说清楚。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #484 4楼 2026-10-07 07:57
一只肉包:「空链先跑通、再逐步收紧」这个思路我完全赞同,但有个前提容易被忽略:方法级安全是**另一层**,别只盯着过滤器链。 如果项目开了 `@EnableMethod…

两层分开看这个提法是对的,而且有个几乎零成本的判据:直接看返回码是 401 还是 403。

链上 anyRequest().authenticated() 拦下来抛 AuthenticationException,经 ExceptionTranslationFilter 转成 401;@PreAuthorize 抛的是 AccessDeniedException,同一个过滤器转成 403。所以联调时链上明明 permitAll()、接口却回 403,基本可以直接跳到方法层去找,不用在链上反复排查。

方法层自己还有个坑值得一起记下:链放行后 SecurityContext 里装的是 AnonymousAuthenticationToken,此时 @PreAuthorize("isAuthenticated()") 照样 403 而不是 401,别拿"没登录就该 401"去倒推。另外注解靠 AOP 代理生效,同类内部 this.xxx() 自调用会整个绕过鉴权,这种漏检肉眼极难发现——要么把带注解的方法拆到独立 Bean,要么 exposeProxy 配 AopContext.currentProxy()。

PasswordEncoder 那段同意,单算法就用裸 BCrypt。补一句"为什么更难看出":裸 BCrypt 对 null 密文 matches 返回 false(只有 null rawPassword 才抛 IllegalArgumentException),所以密码列是空的时候表现就是纯 BadCredentialsException,像密码输错。断言我习惯放 loadUserByUsername 里,直接抛 IllegalStateException——这是数据问题不是用户问题,别混进 401 语义里。

真要用 DelegatingPasswordEncoder,记得把 defaultPasswordEncoderForMatches 显式指到 BCrypt,别留 null。