1. 八年 Java 开发手敲:SpringBoot+SpringSecurity+JWT 实战,前后分离权限注解落地就能跑

作为一名在 Java 开发圈摸爬滚打八年的 “老鸟”,从当年在 SSH 框架里对着 Session 调试到现在扛着微服务权限模块,对 “认证授权” 这事儿的理解早就跳出了 “加个过滤器判断一下” 的初级阶段。尤其是前后端分离成为行业标配后,传统 Session-Cookie 那套玩法越来越难顶 —— 跨域坑、集群 Session 同步坑、CSRF 防护坑,踩过的坑比写过的 BUG 还多。

而现在,SpringSecurity+JWT 几乎成了企业级项目权限方案的 “最优解”:无状态、跨域友好、能细粒度控权,还能配合注解快速落地。今天就从实战角度出发,带大家把这套方案撸通 —— 不搞虚头巴脑的理论堆砌,所有代码都是企业项目里跑过的 “硬货”,新手跟着敲也能直接跑通,老鸟也能 get 到几个避坑点。

一、先聊透:为什么现在都用 SpringSecurity+JWT?

八年开发经验告诉我:“选型比编码重要”,先搞懂 “为什么选”,后面写代码才不会盲目。

1. 传统 Session 方案的 “三宗罪”(前后端分离场景下)

早年前后端不分离时,Session 存用户信息、Cookie 带 SessionId 的玩法还行,但到了前端部署在localhost:8080、后端在localhost:8081的场景,直接歇菜:

  • 跨域卡脖子:Cookie 默认不跨域,SessionId 传不过去,前端调接口全是 “未登录”;
  • 集群扩不动:Session 存在单个服务器,用户下次请求落到其他节点就 “登录失效”,还得额外搭 Redis 存 Session,多了一层复杂度;
  • 安全隐患大:SessionId 在 Cookie 里裸奔,容易遭 CSRF 攻击,还得额外加 Token 防护,多此一举。

2. SpringSecurity+JWT 的 “四大利器”

  • 无状态:JWT 令牌里直接塞了用户信息和权限,后端不用存 Session,集群部署随便扩,拿令牌解析就行;
  • 跨域友好:令牌放请求头(比如Authorization: Bearer xxx),配合 CORS 配置,跨域直接通;
  • 注解控权爽:SpringSecurity 自带@PreAuthorize这类注解,接口级权限控制一句话的事儿,不用写一堆 if-else;
  • 安全性可控:令牌能设过期时间,签名密钥只存在后端,篡改令牌直接失效,配合 HTTPS 直接拉满安全等级。

二、实战准备:环境 & 依赖(稳定为王)

企业项目不追新,稳定第一!选版本时优先挑 “经过市场验证的稳定版”,避免踩新特性的坑:

  • SpringBoot:2.7.x(避开 3.x 的 Jakarta 包适配问题,大部分企业还在用 2.x);
  • JDK:1.8(不用杠,很多老项目还在跑 1.8,兼容性拉满);
  • 核心依赖:SpringSecurity(权限核心)、JJWT(处理 JWT,官方推荐轻量级库)、SpringWeb(接口)、Lombok(少写模板代码)。

直接上pom.xml依赖(复制过去就能用):

<!-- SpringWeb (接口基础) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- SpringSecurity (权限核心) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT依赖 (JJWT三件套,别漏了impl和jackson) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

<!-- Lombok (省掉getter/setter/构造器) -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

三、核心步骤:从 0 到 1 落地(代码能直接跑)

整个方案拆成 5 个核心模块,按顺序来,每一步都有 “为什么这么写” 的经验注解:

1. 写一个 “能复用十年” 的 JWT 工具类

JWT 的核心操作就三个:生成令牌、解析令牌、验证令牌。这里要注意几个企业级细节:

  • 密钥(secretKey)别硬编码,放配置文件,且至少 32 位(HS512 算法要求);
  • 过期时间按业务定,一般访问令牌 1 小时,刷新令牌 7 天;
  • 用不可变对象存密钥,避免被篡改。

先在application.yml配 JWT 参数:

jwt:
  secret-key: 8a9b0c1d2e3f4g5h6i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2  # 自己换个32位以上的复杂密钥
  expiration: 3600000  # 令牌过期时间:1小时(单位:毫秒)

再写 JWT 工具类(JwtTokenProvider.java):

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

/**
 * JWT工具类:生成/解析/验证令牌
 * 八年经验注解:工具类交给Spring托管(单例),参数可配置,异常统一捕获
 */
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    // 从配置文件读密钥和过期时间
    @Value("${jwt.secret-key}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long expiration;

    /**
     * 生成JWT令牌(核心:用SpringSecurity的Authentication对象拿用户信息)
     */
    public String generateToken(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(userDetails.getUsername())  // 存用户名(也能存用户ID,看业务)
                .setIssuedAt(now)  // 签发时间
                .setExpiration(expiryDate)  // 过期时间
                .signWith(getSecretKey(), SignatureAlgorithm.HS512)  // HS512算法,安全度够高
                .compact();
    }

    /**
     * 从令牌里拿用户名
     */
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
        return claims.getSubject();
    }

    /**
     * 验证令牌有效性(两大校验:签名对不对 + 没过期)
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSecretKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            // 捕获所有JWT异常(签名错误、过期、格式错等),返回false表示无效
            // 实际项目可细分异常类型,返回具体错误信息(如“令牌已过期”)
            return false;
        }
    }

    /**
     * 把字符串密钥转成SecretKey(JJWT要求的格式,避免新手踩“密钥格式错”的坑)
     */
    private SecretKey getSecretKey() {
        // Keys.hmacShaKeyFor()会自动校验密钥长度,不够会抛异常
        return Keys.hmacShaKeyFor(secretKey.getBytes());
    }
}

2. 自定义用户认证逻辑(对接数据库)

SpringSecurity 默认从 “内存读用户”(比如inMemoryAuthentication),这在实际项目里就是个摆设 —— 我们得从数据库查用户,还得校验加密后的密码。

这里用 “模拟数据库查询”(实际项目替换成 MyBatis/MyBatis-Plus 调用即可),核心是实现UserDetailsService接口:

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Collections;

/**
 * 自定义用户认证服务:从数据库查用户,给SpringSecurity做校验
 * 关键:必须实现UserDetailsService,重写loadUserByUsername
 */
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    // SpringSecurity的密码编码器,后面会配置(BCrypt加密)
    private final PasswordEncoder passwordEncoder;

    /**
     * 根据用户名查用户(核心方法,SpringSecurity会自动调这个方法做认证)
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 模拟数据库查询(实际项目替换成:userMapper.selectByUsername(username))
        // 这里造两个测试用户:admin(ROLE_ADMIN)、user(ROLE_USER)
        if (!"admin".equals(username) && !"user".equals(username)) {
            throw new UsernameNotFoundException("用户名不存在:" + username);
        }

        // 2. 构造用户信息(注意:密码是加密后的!角色必须带ROLE_前缀)
        String password;
        String role;
        if ("admin".equals(username)) {
            // 密码原文:123456,用passwordEncoder.encode("123456")生成加密后的字符串
            password = "$2a$10$Z8H4k1y7G6F3d2S1a0D9f8A7b6C5e4B3c2D1e0F9g8H7h6G5";
            role = "ROLE_ADMIN"; // 管理员角色,必须带ROLE_
        } else {
            password = "$2a$10$A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2";
            role = "ROLE_USER";  // 普通用户角色
        }

        // 3. 返回SpringSecurity的User对象(封装用户名、加密密码、权限)
        return new User(
                username,
                password,  // 数据库存加密后的密码,别存明文!
                Collections.singletonList(new SimpleGrantedAuthority(role))  // 权限列表
        );
    }
}

3. SpringSecurity 核心配置(权限 “大脑”)

这一步是整个方案的 “灵魂”,要配置:

  • 哪些接口不用认证(如登录接口);
  • 如何处理 JWT 令牌;
  • 跨域、Session 策略、密码加密方式。

直接上配置类(SecurityConfig.java),每一行都有经验注解:

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

/**
 * SpringSecurity核心配置
 * 注解说明:
 * - @EnableWebSecurity:启用Security
 * - @EnableGlobalMethodSecurity(prePostEnabled = true):启用方法级权限注解(@PreAuthorize)
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;
    private final JwtTokenProvider jwtTokenProvider;

    /**
     * 密码编码器:用BCrypt加密(不可逆,企业级首选)
     * 八年经验:千万别自己写加密逻辑,用SpringSecurity现成的!
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 认证管理器:处理登录时的用户名密码校验(必须注入,后面登录接口要用)
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    /**
     * JWT过滤器:拦截请求,解析令牌,设置认证信息
     */
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtTokenProvider, customUserDetailsService);
    }

    /**
     * 跨域配置:前后端分离必配,不然前端调接口会被浏览器拦截
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList("*")); // 实际项目替换成前端域名(如http://localhost:8080)
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); // 允许的请求方法
        config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); // 允许的请求头(带令牌)
        config.setAllowCredentials(true); // 允许前端带Cookie(可选)

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); // 所有接口都允许跨域
        return source;
    }

    /**
     * 核心规则配置:拦截策略、认证流程、Session策略
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 1. 关闭CSRF:前后端分离用JWT,CSRF没用还添乱
                .csrf().disable()

                // 2. 配置跨域
                .cors().configurationSource(corsConfigurationSource()).and()

                // 3. Session策略:无状态(JWT不需要Session)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

                // 4. 配置请求拦截规则
                .authorizeRequests()
                .antMatchers("/api/auth/login").permitAll() // 登录接口放行(不用认证)
                .antMatchers("/static/**", "/swagger-ui/**").permitAll() // 静态资源、Swagger放行
                .anyRequest().authenticated(); // 其他所有接口必须认证

        // 5. 加JWT过滤器:在UsernamePasswordAuthenticationFilter之前执行(先验令牌)
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

4. 实现 JWT 认证过滤器(拦截请求验令牌)

这个过滤器的作用是:拦截所有需要认证的请求,从请求头拿 JWT 令牌,验证通过后把用户信息塞进 SpringSecurity 上下文(SecurityContext) —— 这样后面的权限注解才能 “认得到” 用户。

代码实现(JwtAuthenticationFilter.java):

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * JWT认证过滤器:每次请求都拦截,验令牌、设认证信息
 * 继承OncePerRequestFilter:确保每次请求只过滤一次
 */
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    /**
     * 核心过滤逻辑
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 1. 从请求头获取JWT令牌(格式:Authorization: Bearer xxx)
            String jwt = getJwtFromRequest(request);

            // 2. 验证令牌有效性
            if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
                // 3. 从令牌里拿用户名,再查用户详情
                String username = jwtTokenProvider.getUsernameFromToken(jwt);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                // 4. 构造认证令牌,塞进SecurityContext
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // 5. 设置认证信息(后面的权限判断会用到)
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            // 令牌验证失败,不设认证信息,后续会返回401
            logger.error("无法设置用户认证信息:", e);
        }

        // 6. 继续走过滤链(不管有没有认证,都要继续往下走)
        filterChain.doFilter(request, response);
    }

    /**
     * 从请求头提取JWT令牌(处理Bearer前缀)
     */
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            // 去掉“Bearer ”前缀,拿到纯令牌
            return bearerToken.substring(7);
        }
        return null;
    }
}

5. 写登录接口 & 权限注解实战

到这里,基础框架搭好了,接下来写两个核心:登录接口(返回 JWT)  和 权限注解(控制接口访问)

(1)登录接口(AuthController.java

登录接口是唯一 “不用认证” 的接口,逻辑是:接收用户名密码 → 用 AuthenticationManager 认证 → 认证成功生成 JWT 返回。

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

/**
 * 认证控制器:登录接口
 */
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    /**
     * 登录接口:接收用户名密码,返回JWT令牌
     */
    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequest loginRequest) {
        // 1. 构造认证请求(用户名+密码)
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(),
                        loginRequest.getPassword()
                )
        );

        // 2. 把认证信息塞进SecurityContext(虽然登录后用JWT,但这里是标准流程)
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. 生成JWT令牌
        String jwt = jwtTokenProvider.generateToken(authentication);

        // 4. 返回令牌(前后端分离常用JSON格式)
        return new ResponseEntity<>(new JwtResponse(jwt), HttpStatus.OK);
    }

    // 登录请求DTO(接收前端参数)
    public static class LoginRequest {
        private String username;
        private String password;

        // getter/setter(Lombok的@Data也能省,这里为了清晰写出来)
        public String getUsername() { return username; }
        public void setUsername(String username) { this.username = username; }
        public String getPassword() { return password; }
        public void setPassword(String password) { this.password = password; }
    }

    // 登录响应DTO(返回JWT)
    public static class JwtResponse {
        private String token;

        public JwtResponse(String token) { this.token = token; }

        public String getToken() { return token; }
        public void setToken(String token) { this.token = token; }
    }
}
(2)权限注解实战(TestController.java

@PreAuthorize注解实现细粒度权限控制,比如 “只有 ADMIN 能访问”“只要有某权限就能访问”。

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 测试控制器:演示权限注解用法
 */
@RestController
@RequestMapping("/api/test")
public class TestController {

    /**
     * 所有认证用户都能访问(只要登录了就行)
     */
    @GetMapping("/all")
    public String allUsers() {
        return "所有登录用户都能看到这条信息";
    }

    /**
     * 只有ROLE_USER角色能访问(普通用户)
     */
    @PreAuthorize("hasRole('USER')")
    @GetMapping("/user")
    public String userOnly() {
        return "只有普通用户能看到这条信息";
    }

    /**
     * 只有ROLE_ADMIN角色能访问(管理员)
     */
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public String adminOnly() {
        return "只有管理员能看到这条信息";
    }

    /**
     * 复杂权限:ADMIN角色 或 有VIEW_INFO权限的用户(实际项目可扩展)
     */
    @PreAuthorize("hasRole('ADMIN') or hasAuthority('VIEW_INFO')")
    @GetMapping("/complex")
    public String complexAuth() {
        return "管理员或有VIEW_INFO权限的用户能看到这条信息";
    }
}

四、测试验证(跟着做,一次跑通)

用 Postman 或 Apifox 测试,步骤超简单:

  1. 登录获取 JWT

    • 请求地址:POST http://localhost:8080/api/auth/login

    • 请求体(JSON):

      {
          "username": "admin",
          "password": "123456"
      }
      
    • 响应会返回tokeneyJhbGciOiJIUzUxMiJ9...(很长的字符串)

  2. 访问需要认证的接口

    • 请求地址:GET http://localhost:8080/api/test/admin
    • 请求头:添加Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...(把上面的 token 填进去)
    • 用 admin 账号访问会返回 “只有管理员能看到这条信息”,用 user 账号访问会返回 403(权限不足)。

五、八年开发避坑总结(干货中的干货)

  1. 密钥安全:JWT 的 secretKey 千万别泄露,生产环境要存在配置中心(如 Nacos),别放代码里;
  2. 令牌过期:访问令牌设 1 小时,同时做 “刷新令牌” 逻辑(返回两个令牌:accessToken+refreshToken),避免用户频繁登录;
  3. 密码加密:数据库里的密码必须用 BCrypt 加密,千万别存明文或 MD5(BCrypt 自带盐值,更安全);
  4. 权限注解生效:必须加@EnableGlobalMethodSecurity(prePostEnabled = true),不然@PreAuthorize注解没用;
  5. 跨域配置AllowedOrigins在生产环境别用*,要指定具体的前端域名(如http://www.xxx.com),避免安全风险;
  6. 异常处理:添加全局异常处理器,捕获 401(未登录)、403(权限不足)等异常,返回友好的 JSON 信息(别返回默认的 HTML 页面)。

最后说两句

这套方案我在至少 5 个企业项目里落地过,从单体应用到微服务都能用,稳定性没问题。其实 SpringSecurity+JWT 不难,重点是理解 “认证流程” 和 “权限传递” 的逻辑,而不是死记代码。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]