Spring Security JWT认证:Token签名、验证与无状态会话管理的实现细节

Spring Security JWT认证:Token签名、验证与无状态会话管理的实现细节

大家好,今天我们来深入探讨Spring Security框架下使用JWT(JSON Web Token)进行认证授权的实现细节,重点关注Token的签名、验证以及如何利用JWT实现无状态会话管理。

一、JWT基础概念回顾

在深入代码之前,我们先简单回顾一下JWT的核心概念。JWT本质上是一个字符串,它包含三部分:

  • Header(头部): 描述Token的元数据,通常指定签名算法(例如HS256, RS256)和Token类型("JWT")。
  • Payload(载荷): 包含Claims(声明),Claims是关于实体(用户)以及其他元数据的断言。Claims分为三种类型:
    • Registered Claims: 预定义的一些Claims,例如iss (issuer – 发行人), sub (subject – 主题), aud (audience – 受众), exp (expiration time – 过期时间), nbf (not before – 生效时间), iat (issued at – 签发时间), jti (JWT ID – JWT的唯一标识)。
    • Public Claims: 公开的Claims,可以在JWT标准中注册,以避免冲突。
    • Private Claims: 自定义的Claims,用于在应用程序之间传递信息。
  • Signature(签名): 通过Header指定的签名算法,将Header和Payload进行加密,确保Token的完整性和真实性。签名算法使用密钥(Secret Key 或 Private Key)进行加密。

JWT的结构形式如下:

xxxxx.yyyyy.zzzzz

其中,xxxxx代表Base64编码后的Header,yyyyy代表Base64编码后的Payload,zzzzz代表签名。

二、环境准备与依赖引入

首先,我们需要搭建一个Spring Boot项目,并引入相关的依赖。这里主要依赖Spring Security和java-jwt库。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>4.4.0</version> <!-- 使用最新版本 -->
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

三、JWT Token生成实现

接下来,我们将实现JWT Token的生成。我们创建一个JwtUtil类,负责生成和验证Token。

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

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

    public String generateToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username); // 可以添加其他的自定义claims

        return JWT.create()
                .withIssuer("your-application") // 发行人
                .withIssuedAt(now) // 签发时间
                .withExpiresAt(expiryDate) // 过期时间
                .withSubject(username) // 主题
                .withClaim("username", username) // 将username放入claim中
                .sign(Algorithm.HMAC256(secret));
    }

    public String getUsernameFromToken(String token) {
        DecodedJWT decodedJWT = JWT.decode(token);
        return decodedJWT.getClaim("username").asString();
    }

    public boolean validateToken(String token) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer("your-application")  // 校验发行人
                    .build(); //Reusable verifier instance
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

关键点解释:

  • @Value注解用于从application.propertiesapplication.yml配置文件中读取配置信息,包括密钥secret和过期时间expiration
  • generateToken方法:使用java-jwt库生成JWT Token。
    • Algorithm.HMAC256(secret): 指定使用HMAC256算法进行签名,并传入密钥。 重要:密钥的安全性至关重要,应该妥善保管,避免泄露。
    • .withIssuer("your-application"): 设置发行人。
    • .withSubject(username): 设置主题(通常是用户名)。
    • .withClaim("username", username): 添加自定义的Claim,这里我们将用户名也放入Claim中,方便后续获取。
    • .sign(Algorithm.HMAC256(secret)): 使用指定的算法和密钥对Header和Payload进行签名。
  • getUsernameFromToken方法:从Token中提取用户名。
  • validateToken方法:验证Token的有效性。通过构建JWTVerifier,并使用相同的算法和密钥来验证Token的签名。如果验证失败,会抛出异常。

配置文件 (application.properties 或 application.yml):

jwt.secret=your-secret-key  # 替换成你自己的密钥
jwt.expiration=86400000  # Token过期时间,单位毫秒,这里设置为1天

重要: 密钥jwt.secret的安全性至关重要。在生产环境中,应该使用更安全的方式来管理密钥,例如使用环境变量、配置中心或专门的密钥管理系统。 强烈建议不要将密钥硬编码在代码中。

四、Spring Security配置与JWT集成

现在,我们将Spring Security与JWT集成,实现基于JWT的认证授权。

首先,创建一个JwtRequestFilter,用于拦截请求,从请求头中提取JWT Token,并进行验证。

import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.Component;
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;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            try {
                username = jwtUtil.getUsernameFromToken(jwt);
            } catch (Exception e) {
                // Token 解析失败,例如过期,无效签名
                System.out.println("Token解析失败: " + e.getMessage());
            }
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(jwt)) {

                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }

}

关键点解释:

  • OncePerRequestFilter确保每个请求只被过滤一次。
  • 从请求头Authorization中提取JWT Token,并移除"Bearer "前缀。
  • 使用jwtUtil.getUsernameFromToken(jwt)从Token中提取用户名。
  • 如果用户名存在且SecurityContextHolder中没有认证信息,则从UserDetailsService加载用户信息。
  • 使用jwtUtil.validateToken(jwt)验证Token的有效性。
  • 如果Token有效,则创建一个UsernamePasswordAuthenticationToken,并将其设置到SecurityContextHolder中,表示用户已认证。

接下来,配置WebSecurityConfigurerAdapter,将JwtRequestFilter添加到Spring Security的Filter链中。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/authenticate").permitAll() // 允许 /authenticate 接口匿名访问
                .anyRequest().authenticated() // 其他所有请求都需要认证
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 设置 session 为无状态

        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); // 将 JWT 过滤器添加到 Spring Security 过滤器链中
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance(); // 生产环境不要使用 NoOpPasswordEncoder
    }
}

关键点解释:

  • @EnableWebSecurity启用Spring Security。
  • configure(AuthenticationManagerBuilder auth)配置AuthenticationManager,指定使用UserDetailsService来加载用户信息。
  • configure(HttpSecurity http)配置HTTP请求的安全策略。
    • .csrf().disable()禁用CSRF保护,因为我们使用JWT进行认证,不再需要CSRF保护。
    • .authorizeRequests()配置授权规则。
      • .antMatchers("/authenticate").permitAll()允许/authenticate接口匿名访问,用于用户登录获取Token。
      • .anyRequest().authenticated()其他所有请求都需要认证。
    • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)设置Session创建策略为STATELESS,表示不创建Session,实现无状态会话管理。
    • .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)JwtRequestFilter添加到UsernamePasswordAuthenticationFilter之前,确保在用户名密码认证之前进行JWT Token的验证。
  • authenticationManagerBean()AuthenticationManager暴露为一个Bean,方便在其他地方使用。
  • passwordEncoder()配置密码编码器。 重要: 在生产环境中,不要使用NoOpPasswordEncoder,应该使用更安全的密码编码器,例如BCryptPasswordEncoder

五、用户认证逻辑实现

现在,我们需要实现用户认证的逻辑。首先,创建一个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;

import java.util.ArrayList;

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 在这里从数据库或缓存中加载用户信息
        // 这里只是一个示例,直接返回一个User对象
        if ("admin".equals(username)) {
            return new User("admin", "password", new ArrayList<>()); // 生产环境需要使用加密后的密码
        } else {
            throw new UsernameNotFoundException("User not found: " + username);
        }
    }
}

关键点解释:

  • loadUserByUsername(String username)方法根据用户名从数据库或缓存中加载用户信息。
  • User对象是Spring Security提供的UserDetails接口的一个实现类,用于表示用户信息。
  • 重要: 在生产环境中,应该从数据库中加载用户信息,并且使用加密后的密码。 这里只是一个示例,直接返回一个硬编码的User对象。

接下来,创建一个AuthenticationController,用于处理用户登录请求,生成JWT Token。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public class AuthenticationController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/authenticate")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {

        try {
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(authenticationRequest.getUsername(), authenticationRequest.getPassword())
            );
        } catch (AuthenticationException e) {
            throw new Exception("Incorrect username or password", e);
        }

        final UserDetails userDetails = userDetailsService
                .loadUserByUsername(authenticationRequest.getUsername());

        final String jwt = jwtUtil.generateToken(userDetails.getUsername());

        Map<String, String> response = new HashMap<>();
        response.put("token", jwt);

        return ResponseEntity.ok(response);
    }

}

class AuthenticationRequest {
    private String username;
    private String 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;
    }
}

关键点解释:

  • @PostMapping("/authenticate")处理/authenticate接口的POST请求。
  • AuthenticationManager.authenticate()用于验证用户名和密码。
  • UserDetailsService.loadUserByUsername()用于加载用户信息。
  • jwtUtil.generateToken()用于生成JWT Token。
  • 返回包含Token的响应。

六、测试与验证

现在,我们可以测试我们的JWT认证系统了。

  1. 启动Spring Boot应用程序。
  2. 使用POST请求访问/authenticate接口,并传入用户名和密码。
    {
        "username": "admin",
        "password": "password"
    }
  3. 如果用户名和密码验证成功,服务器会返回一个包含JWT Token的响应。
    {
        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6InlvdXItYXBwbGljYXRpb24iLCJleHAiOjE2OTc1NDU2ODAsImlhdCI6MTY5NzQ1OTI4MCwidXNlcm5hbWUiOiJhZG1pbiJ9.eYtX1fN2X9jQ1Z0K8a7gW3H0jPqKz2VnL8b6wYkS90"
    }
  4. 将Token添加到请求头Authorization中,例如Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6InlvdXItYXBwbGljYXRpb24iLCJleHAiOjE2OTc1NDU2ODAsImlhdCI6MTY5NzQ1OTI4MCwidXNlcm5hbWUiOiJhZG1pbiJ9.eYtX1fN2X9jQ1Z0K8a7gW3H0jPqKz2VnL8b6wYkS90
  5. 访问需要认证的接口,例如/hello

如果Token有效,服务器会返回正常响应。如果Token无效,服务器会返回401 Unauthorized错误。

七、安全性考量与最佳实践

  • 密钥管理: 密钥的安全性至关重要。在生产环境中,应该使用更安全的方式来管理密钥,例如使用环境变量、配置中心或专门的密钥管理系统。不要将密钥硬编码在代码中。
  • Token过期时间: 设置合理的Token过期时间。过期时间过长会增加Token被盗用的风险,过期时间过短会影响用户体验。
  • HTTPS: 使用HTTPS协议来保护Token在网络传输过程中的安全。
  • 防止Token被盗用: 采取措施防止Token被盗用,例如使用短生命周期的Token、实施Token刷新机制、使用双因素认证等。
  • 算法选择: 选择安全的签名算法。HMAC算法的密钥是对称的,安全性相对较低。RSA算法的密钥是非对称的,安全性更高。
  • Claim设计: 在Claim中只包含必要的信息,避免泄露敏感信息。
  • Token存储: 不要在客户端存储敏感信息。
  • 刷新Token(Refresh Token): 可以使用刷新Token机制,允许用户在Token过期后,使用刷新Token获取新的Token,而无需重新登录。刷新Token的过期时间可以比访问Token更长。
  • 黑名单(Blacklist)机制: 可以实现一个黑名单机制,用于禁用被盗用的Token。 但是,黑名单机制会增加系统的复杂性,并且需要存储被禁用的Token,可能会影响性能。
  • Token撤销(Token Revocation): 允许用户主动撤销Token,例如在用户注销时。

八、代码示例:刷新Token的简单实现

以下是一个简单的刷新Token的实现示例。 注意,这只是一个示例,生产环境需要更完善的实现。

  1. 添加RefreshToken实体类:
import javax.persistence.*;
import java.time.Instant;

@Entity
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private Instant expiryDate;

    // Getters and Setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Instant getExpiryDate() {
        return expiryDate;
    }

    public void setExpiryDate(Instant expiryDate) {
        this.expiryDate = expiryDate;
    }
}
  1. RefreshTokenRepository:
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);
    void deleteByToken(String token);
}
  1. RefreshTokenService:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;

@Service
public class RefreshTokenService {

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenDurationMs;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @Autowired
    private JwtUtil jwtUtil; // 假设你已经有了JwtUtil

    public RefreshToken createRefreshToken(String username) {
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUsername(username);
        refreshToken.setToken(UUID.randomUUID().toString());
        refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
        refreshToken = refreshTokenRepository.save(refreshToken);
        return refreshToken;
    }

    public RefreshToken verifyRefreshToken(String token) {
        RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new RuntimeException("Invalid refresh token"));

        if (refreshToken.getExpiryDate().compareTo(Instant.now()) < 0) {
            refreshTokenRepository.delete(refreshToken);
            throw new RuntimeException("Refresh token was expired. Please make a new signin request");
        }

        return refreshToken;
    }

    public String generateNewTokenFromRefreshToken(String refreshToken) {
        RefreshToken verifiedRefreshToken = verifyRefreshToken(refreshToken);
        String username = verifiedRefreshToken.getUsername();
        return jwtUtil.generateToken(username);
    }
}
  1. 在AuthenticationController中添加refreshToken接口:
@PostMapping("/refreshToken")
public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest request) {
    String newAccessToken = refreshTokenService.generateNewTokenFromRefreshToken(request.getRefreshToken());
    Map<String, String> response = new HashMap<>();
    response.put("token", newAccessToken);
    return ResponseEntity.ok(response);
}

static class RefreshTokenRequest {
    private String refreshToken;

    public String getRefreshToken() {
        return refreshToken;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }
}
  1. 配置文件添加refreshToken过期时间:
jwt.refresh.expiration=604800000 # 7 days

关键点解释:

  • 在登录成功后,除了生成accessToken,同时生成refreshToken并保存到数据库。
  • refreshToken具有更长的过期时间。
  • 当accessToken过期后,客户端可以使用refreshToken向/refreshToken接口发起请求,获取新的accessToken。
  • refreshToken只能使用一次,使用后需要删除。
  • 需要处理refreshToken过期的情况。

九、不同签名算法的选择:HMAC vs RSA

在JWT的签名过程中,选择合适的签名算法至关重要。常见的选择包括HMAC(Hash-based Message Authentication Code)和RSA(Rivest–Shamir–Adleman)。

特性 HMAC (例如 HS256) RSA (例如 RS256)
密钥类型 对称密钥(同一个密钥用于签名和验证) 非对称密钥(公钥用于验证,私钥用于签名)
安全性 相对较低,因为密钥需要安全地共享 相对较高,私钥无需共享,只需发布公钥即可
适用场景 内部系统,可信环境 分布式系统,需要与外部系统交互,安全性要求较高的场景
性能 较高 较低
复杂性 较低 较高
密钥轮换 密钥轮换需要更新所有相关系统,较为复杂 密钥轮换相对简单,只需更新公钥即可

HMAC(HS256):

  • 使用相同的密钥进行签名和验证。
  • 性能较高,适用于内部系统或可信环境。
  • 密钥需要安全地共享,因此安全性相对较低。

RSA(RS256):

  • 使用私钥进行签名,使用公钥进行验证。
  • 安全性较高,私钥无需共享,只需发布公钥即可。
  • 适用于分布式系统或需要与外部系统交互的场景。
  • 性能相对较低。

总结:

  • 如果安全性要求不高,且性能是关键因素,可以选择HMAC算法。
  • 如果安全性要求较高,且需要与外部系统交互,可以选择RSA算法。

十、总结

本文详细介绍了在Spring Security框架下使用JWT进行认证授权的实现细节,包括Token的生成、验证、Spring Security配置、用户认证逻辑以及安全性考量。通过这些内容,您应该能够构建一个安全的、无状态的基于JWT的认证授权系统。

本文主要探讨了 JWT 的基本实现和集成,并提供了刷新 Token 的示例。重要的是要根据实际需求选择合适的签名算法和密钥管理策略,并不断更新安全实践,以应对潜在的威胁。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注