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

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-10-07 07:33 ·6 浏览 ·4 回复

学完这篇,你能搭出一套前后端分离项目里真正跑得起来的认证授权骨架:登录签发 JWT、请求验签、接口按角色放行,并且清楚坑埋在哪。

第一步:引入依赖

Spring Boot 3.x 对应 Spring Security 6,pom.xml 加四样:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.5</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.12.5</version>
  <scope>runtime</scope>
</dependency>

第二步:写 JWT 工具类

JwtUtil 只要三个方法:生成、解析、取用户名。

private final SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

public String generate(String username, List<String> roles) {
    return Jwts.builder()
        .subject(username)
        .claim("roles", roles)
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + 7200_000L))
        .signWith(key)
        .compact();
}

解析用 Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload()。

注意:密钥别写死在代码里,放 application.yml 或环境变量,且长度至少 32 字节(HS256 要求 256 位),短了 jjwt 直接抛 WeakKeyException。

第三步:实现 UserDetailsService 和登录接口

@Service
public class DbUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        User u = userMapper.findByUsername(username);
        if (u == null) throw new UsernameNotFoundException("用户不存在");
        return org.springframework.security.core.userdetails.User
            .withUsername(u.getUsername())
            .password(u.getPassword())        // 库里存 bcrypt 密文
            .authorities(u.getRoles().stream()
                .map(r -> "ROLE_" + r).toArray(String[]::new))
            .build();
    }
}

登录接口里交给 AuthenticationManager 认证,成功再签 token:

@PostMapping("/login")
public Map<String, Object> login(@RequestBody LoginDTO dto) {
    Authentication auth = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(dto.username(), dto.password()));
    return Map.of("token", jwtUtil.generate(auth.getName(), rolesOf(auth)));
}

注意:hasRole("ADMIN") 会自动补 ROLE_ 前缀,所以 authorities 里必须存 ROLE_ADMIN。存成 ADMIN 的话规则永远匹配不上,排查起来很费时间。

第四步:写 JWT 过滤器

继承 OncePerRequestFilter,读 Authorization: Bearer xxx,验签通过就塞进 SecurityContext:

public class JwtFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
                                    FilterChain chain) throws ServletException, IOException {
        String h = req.getHeader("Authorization");
        if (h != null && h.startsWith("Bearer ")) {
            try {
                Claims claims = jwtUtil.parse(h.substring(7));
                var auth = new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null,
                    ((List<String>) claims.get("roles")).stream()
                        .map(SimpleGrantedAuthority::new).toList());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                SecurityContextHolder.clearContext();   // 过期或篡改,当匿名处理
            }
        }
        chain.doFilter(req, resp);
    }
}

第五步:配置 SecurityFilterChain

@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // 打开 @PreAuthorize
public class SecurityConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/auth/**", "/error").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .exceptionHandling(e -> e
                .authenticationEntryPoint((q, s, ex) -> writeJson(s, 401, "未登录或 token 失效"))
                .accessDeniedHandler((q, s, ex) -> writeJson(s, 403, "权限不足")))
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

注意:过滤器里抛的异常不会被 @RestControllerAdvice 捕获,因为它不在 Controller 调用链上。401/403 必须靠 authenticationEntryPoint 和 accessDeniedHandler 自己返回 JSON,否则前端收到的是 Spring 默认的 HTML 错误页。

第六步:方法级细粒度授权

@EnableMethodSecurity 之后就能在 Service 上写 SpEL:

@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public UserVO get(Long userId) { ... }

「只能改自己的帖子」这类数据级权限,要么在 SpEL 里判,要么在 service 里统一取 SecurityContextHolder.getContext().getAuthentication().getName() 比对。

本文转载自 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。