一、项目里的认证授权痛点

做Java后端项目的人,大概率都遇到过这么个头疼事:怎么让系统知道来访问的人是谁,又怎么控制他能看什么、改什么?比如一个电商后台,管理员能看所有订单,普通运营只能看自己负责区域的订单,普通用户只能看自己的订单,还不能瞎改别人的信息。以前我们可能会用Session+Cookie的方式,就是用户登录后,服务器存一个Session,把Cookie发给用户,下次用户带Cookie来,服务器找对应的Session就知道是谁了。但这种方式有几个大问题:比如服务器要存Session,用户多了占内存;如果项目要做集群,还要搞Session共享,麻烦得很;还有如果前端是小程序、APP,不支持Cookie,根本用不了。后来就有了JWT,刚好能解决这些问题,再搭配Spring Security这个专门管认证授权的框架,就能把这个事搞定。

二、JWT到底是什么

先搞懂JWT,不然后面没法说集成。JWT的全称是JSON Web Token,说白了就是一串加密过的字符串,能把用户的身份信息存在里面,不需要服务器存。比如用户登录成功后,服务器生成一个JWT,发给前端,前端之后每次访问都把这个JWT带在请求头里,服务器拿到后解密,就能知道这个用户是谁、有什么权限。

JWT的结构很简单,分成三部分,用点隔开:第一部分是头,说明用了什么加密算法;第二部分是载荷,就是你要存的信息,比如用户ID、用户名、权限;第三部分是签名,用来验证这个JWT有没有被篡改。举个例子,一个JWT可能长这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJwcm9maWxlcyI6WyJhZG1pbiIsInVzZXIiXSwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c。这里要注意,载荷部分是Base64编码的,不是加密的,所以不能存密码这种敏感信息,不然别人解码就能看到。

三、Spring Security的核心作用

Spring Security是Spring全家桶里的安全框架,专门用来处理认证和授权。认证就是“你是谁”,授权就是“你能做什么”。它的核心逻辑是用过滤器链来处理请求,每个请求进来,都会经过一堆过滤器,比如检查有没有登录、检查有没有权限,通过了才能到控制器。

比如你想让某个接口必须登录才能访问,或者只有管理员能访问,Spring Security就能帮你配置。以前可能要自己写过滤器、写拦截器,现在用它的配置就能搞定,省了很多重复代码。

四、JWT和Spring Security的集成实践

接下来是重点,怎么把JWT和Spring Security集成到Java项目里。这里用的技术栈是Spring Boot 3.x、Spring Security 6.x、JWT(用jjwt-api这个库),所有示例都基于这个技术栈,不混别的。

4.1 准备工作

首先得新建一个Spring Boot项目,然后在pom.xml里加依赖。除了Spring Web、Spring Security这些基础依赖,还要加JWT的依赖:

<!-- JWT依赖 -->
<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>

这些依赖是用来生成和解析JWT的,不用自己写加密解密的代码,直接用现成的方法。

4.2 配置JWT的工具类

先写一个JwtUtil工具类,用来生成JWT、解析JWT、验证JWT是否有效。这个类是整个集成的核心,所有和JWT相关的操作都在这里面。

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {
    // 从配置文件里读取JWT的密钥,不能写死在代码里,不然不安全
    @Value("${jwt.secret}")
    private String secret;
    // 从配置文件里读取JWT的过期时间,单位是毫秒,比如30分钟就是1800000
    @Value("${jwt.expiration}")
    private Long expiration;

    // 获取JWT的密钥,用来加密和解密
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 从JWT里提取用户名
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 从JWT里提取过期时间
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 通用的提取方法,根据传的函数提取对应的内容
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // 解析JWT,拿到所有的载荷内容
    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // 检查JWT是否过期
    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // 生成JWT,参数是用户名和用户的权限
    public String generateToken(String username, String authorities) {
        Map<String, Object> claims = new HashMap<>();
        // 把权限放到载荷里,后面授权的时候要用
        claims.put("authorities", authorities);
        return createToken(claims, username);
    }

    // 真正生成JWT的方法
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims) // 设置载荷
                .setSubject(subject) // 设置主题,一般是用户名
                .setIssuedAt(new Date(System.currentTimeMillis())) // 设置签发时间
                .setExpiration(new Date(System.currentTimeMillis() + expiration)) // 设置过期时间
                .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 设置加密算法和密钥
                .compact(); // 生成JWT字符串
    }

    // 验证JWT是否有效,检查用户名是否匹配,有没有过期
    public Boolean validateToken(String token, String username) {
        final String extractedUsername = extractUsername(token);
        return (extractedUsername.equals(username) && !isTokenExpired(token));
    }
}

这个工具类的逻辑很清晰:生成JWT的时候,把用户名、权限、过期时间这些信息放进去,用HS256算法加密;解析的时候,用密钥解密,就能拿到这些信息;验证的时候,检查用户名对不对、有没有过期。

4.3 自定义用户服务

Spring Security需要知道怎么加载用户的信息,所以要自定义一个UserDetailsService,用来从数据库或者别的地方查用户。这里我们简化一下,用内存里的用户来模拟,实际项目里换成查数据库就行。

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.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 模拟从数据库查用户,实际项目里这里是查数据库的代码
        if ("admin".equals(username)) {
            // 密码是用BCryptPasswordEncoder加密的,明文是123456
            return User.withUsername("admin")
                    .password("$2a$10$eZ5X7X7X7X7X7X7X7X7X7eZ5X7X7X7X7X7X7X7X7X7X7X7X7X7")
                    .roles("ADMIN", "USER") // 管理员有ADMIN和USER权限
                    .build();
        } else if ("user".equals(username)) {
            return User.withUsername("user")
                    .password("$2a$10$eZ5X7X7X7X7X7X7X7X7X7eZ5X7X7X7X7X7X7X7X7X7X7X7X7X7")
                    .roles("USER") // 普通用户只有USER权限
                    .build();
        } else {
            throw new UsernameNotFoundException("用户不存在");
        }
    }
}

这里要注意,密码不能存明文,必须用加密算法加密,Spring Security默认用的是BCryptPasswordEncoder,所以这里的密码是加密后的结果。

4.4 自定义认证过滤器

原来的Spring Security是用Session来认证的,现在我们要换成JWT,所以要自定义一个过滤器,用来处理JWT的认证。这个过滤器会在每个请求进来的时候,检查请求头里有没有JWT,有的话就解析JWT,拿到用户信息,然后告诉Spring Security这个用户已经登录了。

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    // 构造注入,把JwtUtil和CustomUserDetailsService传进来
    public JwtAuthenticationFilter(JwtUtil jwtUtil, CustomUserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 从请求头里获取JWT,请求头的名字是Authorization,值的格式是Bearer 你的JWT
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

        // 如果请求头里没有Authorization,或者不是Bearer开头的,就直接放行,让后面的过滤器处理
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 提取JWT,去掉前面的Bearer 前缀
        jwt = authHeader.substring(7);
        // 从JWT里提取用户名
        username = jwtUtil.extractUsername(jwt);

        // 如果用户名不为空,并且当前SecurityContext里没有认证信息,说明这个用户还没登录
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            // 加载用户信息
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            // 验证JWT是否有效
            if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
                // 创建认证对象,告诉Spring Security这个用户已经登录了
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );
                // 设置认证的详情,比如请求的IP、端口这些
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // 把认证对象放到SecurityContext里,这样后面的过滤器就能拿到用户信息了
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        // 放行,让请求继续走后面的过滤器
        filterChain.doFilter(request, response);
    }
}

这个过滤器的逻辑很关键:每个请求进来,先找有没有JWT,有的话就解析验证,验证通过就把用户信息放到Spring Security的上下文里,这样后面的授权逻辑就能知道这个用户是谁、有什么权限了。

4.5 配置Spring Security

最后一步,配置Spring Security,把我们自定义的过滤器加进去,还要配置哪些接口需要登录、哪些接口可以匿名访问、哪些接口需要什么权限。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthFilter;
    private final CustomUserDetailsService userDetailsService;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter, CustomUserDetailsService userDetailsService) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.userDetailsService = userDetailsService;
    }

    // 配置密码编码器,用来加密和解密密码
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 配置认证提供者,用来验证用户名和密码
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    // 配置Spring Security的过滤器链
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 关闭CSRF,因为JWT是无状态的,不需要CSRF
                .csrf(csrf -> csrf.disable())
                // 配置请求的授权规则
                .authorizeHttpRequests(auth -> auth
                        // 登录接口可以匿名访问,不需要登录
                        .requestMatchers("/api/auth/login").permitAll()
                        // 管理员接口需要ADMIN权限
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        // 普通用户接口需要USER权限
                        .requestMatchers("/api/user/**").hasRole("USER")
                        // 其他所有接口都需要登录
                        .anyRequest().authenticated()
                )
                // 配置会话管理,因为JWT是无状态的,所以不需要Session,这里设置为无状态
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                // 配置认证提供者
                .authenticationProvider(authenticationProvider())
                // 把我们自定义的JWT过滤器加到Spring Security的过滤器链里,放在UsernamePasswordAuthenticationFilter前面
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

这个配置里有几个关键点:关闭CSRF是因为JWT是无状态的,不需要用CSRF来防止跨站请求伪造;设置会话管理为无状态,就是告诉Spring Security不要用Session来存用户信息;把自定义的JWT过滤器加到Spring Security的过滤器链里,这样每个请求都会先经过我们的JWT过滤器。

4.6 写登录接口和测试接口

最后写一个登录接口,用来生成JWT,再写几个测试接口,用来测试认证授权是否生效。

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.web.bind.annotation.GetMapping;
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;

@RestController
@RequestMapping("/api")
public class AuthController {
    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public AuthController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    // 登录接口,接收用户名和密码,验证通过后返回JWT
    @PostMapping("/auth/login")
    public ResponseEntity<String> login(@RequestBody LoginRequest request) {
        // 验证用户名和密码,AuthenticationManager会调用我们的CustomUserDetailsService来查用户,然后验证密码
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );
        // 验证通过后,生成JWT,把用户的权限转成字符串放到JWT里
        String jwt = jwtUtil.generateToken(
                request.getUsername(),
                authentication.getAuthorities().toString()
        );
        return ResponseEntity.ok(jwt);
    }

    // 管理员接口,只有ADMIN权限的用户能访问
    @GetMapping("/admin/info")
    public ResponseEntity<String> getAdminInfo() {
        return ResponseEntity.ok("这是管理员接口,只有管理员能访问");
    }

    // 普通用户接口,只有USER权限的用户能访问
    @GetMapping("/user/info")
    public ResponseEntity<String> getUserInfo() {
        return ResponseEntity.ok("这是普通用户接口,只有普通用户能访问");
    }

    // 登录请求的实体类,用来接收前端传的用户名和密码
    public static class LoginRequest {
        private String username;
        private String password;

        // 构造方法、getter、setter
        public LoginRequest() {}

        public LoginRequest(String username, String password) {
            this.username = username;
            this.password = password;
        }

        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;
        }
    }
}

现在整个项目就搭好了。测试的时候,先调用登录接口,传用户名admin和密码123456,会返回一个JWT;然后把这个JWT放到请求头的Authorization里,值是Bearer 你的JWT,再调用/api/admin/info接口,就能正常访问;如果用user登录,调用/api/admin/info接口,就会返回403权限不足的错误;如果不登录直接调用任何接口,都会返回401未认证的错误。

五、应用场景、优缺点和注意事项

5.1 应用场景

这种集成方式适合很多场景:比如前后端分离的项目,前端是Vue、React,后端是Java,不需要用Cookie;比如移动端项目,APP里不能用Cookie,用JWT很合适;比如分布式项目,多个后端服务之间要做认证,JWT可以跨服务使用;比如需要单点登录的项目,多个系统共享一个JWT,登录一次就能访问所有系统。

5.2 技术优缺点

优点很明显:首先是无状态,服务器不需要存Session,节省内存,集群部署的时候不需要做Session共享,很方便;其次是跨端支持,不管是Web、APP、小程序,都能支持;然后是可扩展性,JWT里可以存很多自定义的信息,比如用户的角色、权限、部门,不用再查数据库;还有就是安全,JWT可以设置过期时间,密钥只有服务器知道,不容易被伪造。

缺点也有:首先是JWT一旦生成,在过期之前就不能修改,除非重新生成,所以如果要修改用户的权限,只能等JWT过期或者重新登录;其次是JWT的大小,因为要存很多信息,所以比Cookie大,每次请求都要带,会增加请求的大小;然后是安全性问题,JWT的载荷是Base64编码的,不是加密的,所以不能存敏感信息,比如密码;还有就是如果JWT被别人拿到,别人就能冒充用户访问系统,所以要保证JWT在传输过程中是安全的,比如用HTTPS。

5.3 注意事项

使用的时候要注意几个点:第一,JWT的密钥要足够复杂,不能用简单的字符串,最好用随机生成的长字符串,还要存在配置文件里,不能写死在代码里;第二,JWT的过期时间不能太长,也不能太短,太长了不安全,太短了用户体验不好,比如可以设置30分钟,然后用刷新令牌的方式来延长过期时间;第三,不能在JWT里存敏感信息,比如密码、银行卡号,因为别人能解码;第四,要保证JWT的传输安全,必须用HTTPS,不然JWT会被拦截;第五,要处理JWT过期的情况,比如前端拿到401错误的时候,提示用户重新登录;第六,要考虑JWT的注销问题,因为JWT是无状态的,服务器不能直接注销,所以可以用黑名单的方式,把注销的JWT存到Redis里,每次请求的时候检查是不是在黑名单里。

六、文章总结

JWT和Spring Security的集成,解决了Java后端项目里认证授权的很多痛点,尤其是无状态、跨端、集群的问题。整个集成的过程其实很清晰:先写JWT的工具类,用来生成解析验证JWT;然后自定义用户服务,用来加载用户信息;再写自定义的过滤器,用来处理JWT的认证;最后配置Spring Security,把过滤器加进去,配置授权规则。

实际项目里,还可以根据需求扩展,比如加刷新令牌、加JWT黑名单、加权限的动态配置,这些都是基于我们现在的基础来改的。只要搞懂了JWT和Spring Security的核心逻辑,集成起来就很简单,也能解决大部分的认证授权问题。