使用 Spring Security 实现 JWT 和令牌刷新

本指南扩展了我们的 Spring Security 设置,通过添加刷新令牌机制,允许用户在不重新认证的情况下续订访问令牌。我们将把刷新令牌存储在 Redis 中,以实现安全的会话管理,并实现基于数据库的用户认证。

第一步:理解 JWT 和令牌刷新

为什么需要令牌刷新?

访问令牌的生命周期较短,以最小化被盗用的风险。刷新令牌的生命周期较长,允许客户端在不要求用户重新登录的情况下请求新的访问令牌。

第二步:项目设置

确保在 pom.xml 中包含以下依赖项:

<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-security</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-data-jpa</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-data-redis</artifactId>  
</dependency>  
<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>  
<dependency>  
    <groupId>mysql</groupId>  
    <artifactId>mysql-connector-java</artifactId>  
</dependency>
 

第三步:用户实体

import jakarta.persistence.Entity;  
import jakarta.persistence.GeneratedValue;  
import jakarta.persistence.GenerationType;  
import jakarta.persistence.Id;  

@Entity  
public class User {  

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

    private String username;  
    private String password;  
    private String roles;  

    // Optional: Track refresh token (for additional checks if needed)
    private String refreshToken;  

    // Getters and Setters  
}
 

第四步:JWT 工具类

更新 JwtUtil 类以分别处理访问令牌和刷新令牌。

import io.jsonwebtoken.*;  
import org.springframework.stereotype.Component;  

import java.util.Date;  

@Component  
public class JwtUtil {  

    private static final String SECRET_KEY = "accessSecret";  
    private static final String REFRESH_SECRET_KEY = "refreshSecret";  
    private static final long ACCESS_EXPIRATION = 1000 * 60 * 15; // 15 minutes  
    private static final long REFRESH_EXPIRATION = 1000 * 60 * 60 * 24; // 1 day  

    public String generateAccessToken(String username) {  
        return Jwts.builder()  
            .setSubject(username)  
            .setIssuedAt(new Date())  
            .setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION))  
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY)  
            .compact();  
    }  

    public String generateRefreshToken(String username) {  
        return Jwts.builder()  
            .setSubject(username)  
            .setIssuedAt(new Date())  
            .setExpiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRATION))  
            .signWith(SignatureAlgorithm.HS256, REFRESH_SECRET_KEY)  
            .compact();  
    }  

    public String extractUsername(String token, boolean isRefreshToken) {  
        String secretKey = isRefreshToken ? REFRESH_SECRET_KEY : SECRET_KEY;  
        return Jwts.parser()  
            .setSigningKey(secretKey)  
            .parseClaimsJws(token)  
            .getBody()  
            .getSubject();  
    }  

    public boolean validateToken(String token, boolean isRefreshToken) {  
        try {  
            String secretKey = isRefreshToken ? REFRESH_SECRET_KEY : SECRET_KEY;  
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);  
            return true;  
        } catch (Exception e) {  
            return false;  
        }  
    }  
}

第五步:Redis 集成用于刷新令牌

配置 Redis

在 application.properties 中添加 Redis 配置:

spring.redis.host=localhost  
spring.redis.port=6379

第六步:认证控制器

认证和刷新令牌 API

import org.springframework.web.bind.annotation.*;  
import org.springframework.http.ResponseEntity;  

@RestController  
@RequestMapping("/auth")  
public class AuthController {  

    private final CustomUserDetailsService userDetailsService;  
    private final JwtUtil jwtUtil;  
    private final RefreshTokenService refreshTokenService;  

    public AuthController(CustomUserDetailsService userDetailsService, JwtUtil jwtUtil, RefreshTokenService refreshTokenService) {  
        this.userDetailsService = userDetailsService;  
        this.jwtUtil = jwtUtil;  
        this.refreshTokenService = refreshTokenService;  
    }  

    @PostMapping("/login")  
    public ResponseEntity<?> login(@RequestBody AuthRequest authRequest) {  
        // Authenticate user  
        UserDetails userDetails = userDetailsService.loadUserByUsername(authRequest.getUsername());  
        if (!userDetails.getPassword().equals(authRequest.getPassword())) {  
            return ResponseEntity.status(401).body("Invalid credentials");  
        }  

        // Generate tokens  
        String accessToken = jwtUtil.generateAccessToken(userDetails.getUsername());  
        String refreshToken = jwtUtil.generateRefreshToken(userDetails.getUsername());  

        // Store refresh token in Redis  
        refreshTokenService.storeRefreshToken(userDetails.getUsername(), refreshToken);  

        return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));  
    }  

    @PostMapping("/refresh")  
    public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest refreshRequest) {  
        String username = jwtUtil.extractUsername(refreshRequest.getRefreshToken(), true);  

        if (!jwtUtil.validateToken(refreshRequest.getRefreshToken(), true)) {  
            return ResponseEntity.status(403).body("Invalid refresh token");  
        }  

        // Verify with Redis  
        String cachedToken = refreshTokenService.getRefreshToken(username);  
        if (cachedToken == null || !cachedToken.equals(refreshRequest.getRefreshToken())) {  
            return ResponseEntity.status(403).body("Invalid or expired refresh token");  
        }  

        // Generate new access token  
        String newAccessToken = jwtUtil.generateAccessToken(username);  
        return ResponseEntity.ok(new AuthResponse(newAccessToken, refreshRequest.getRefreshToken()));  
    }  
}
 

DTO 类

public class AuthRequest {  
    private String username;  
    private String password;  

    // Getters and Setters  
}  

public class AuthResponse {  
    private String accessToken;  
    private String refreshToken;  

    public AuthResponse(String accessToken, String refreshToken) {  
        this.accessToken = accessToken;  
        this.refreshToken = refreshToken;  
    }  

    // Getters  
}  

public class RefreshRequest {  
    private String refreshToken;  

    // Getters and Setters  
}

第七步:安全配置

@Configuration  
public class SecurityConfig {  

    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {  
        http.csrf().disable()  
            .authorizeHttpRequests(auth -> auth  
                .antMatchers("/auth/**").permitAll()  
                .anyRequest().authenticated())  
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);  
        return http.build();  
    }  
}

第八步:测试应用程序

  1. 登录端点 (/auth/login)

    • 发送用户名和密码。

    • 接收 accessToken 和 refreshToken

  2. 访问安全端点

    • 在 Authorization 头中使用 accessToken

  3. 刷新令牌 (/auth/refresh)

    • 发送 refreshToken 以续订 accessToken

结论

在本指南中,我们使用 Spring Security、JWT(访问令牌和刷新令牌)、Redis 和关系型数据库实现了一个强大的认证系统。以下是关键组件及其作用的总结:

  1. 访问令牌

    • 用于认证 API 请求的短期令牌。

    • 安全且高效,用于保护端点。

  2. 刷新令牌

    • 长期令牌,安全地存储在 Redis 中以续订访问令牌。

    • 允许用户在不频繁登录的情况下无缝访问。

  3. Redis

    • 作为会话和令牌管理的可扩展缓存层。

    • 确保快速查找和令牌的自动过期。

  4. Spring Security

    • 提供灵活的框架来保护端点。

    • 支持与 JWT 和自定义过滤器的集成。

通过结合这些组件,我们实现了:

  • 增强的安全性:令牌快速过期,最小化凭证被盗的风险。

  • 可扩展性:Redis 为分布式系统提供高性能的会话管理。

  • 改进的用户体验:刷新令牌允许用户在不频繁登录的情况下持续访问。

  • 可定制性:框架可以轻松扩展以满足特定的业务需求。

这种架构非常适合现代 Web 应用程序,其中安全性、性能和用户便利性至关重要。在此基础之上,你可以构建更高级的功能,例如基于角色的访问控制(RBAC)、多因素认证,甚至 OAuth 2.0 集成,以进一步增强系统。

请登录后发表评论

    没有回复内容