SpringBoot 实现 JWT 认证及高级特性(Token刷新+黑名单机制)

73 阅读8分钟

SpringBoot 实现 JWT 认证及高级特性(Token刷新+黑名单机制)

JWT(JSON Web Token)是实现无状态认证的理想方案,但需要解决Token过期和撤销的问题。本文将详细介绍如何在SpringBoot中实现JWT认证,并添加Token刷新机制和黑名单机制。

一、技术栈选择

  • Spring Boot 3.x
  • Redis(用于存储黑名单和刷新令牌)
  • JJWT库(JWT实现)
  • Spring Security(可选,用于安全配置)

二、项目依赖配置

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- 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>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

三、配置文件

spring:
  application:
    name: springboot-jwt-refresh-demo
  
  # Redis配置
  redis:
    host: localhost
    port: 6379
    database: 0

# JWT配置
jwt:
  # 密钥
  secret: your-secret-key-change-in-production
  # 访问令牌过期时间(分钟)
  access-token-expire: 30
  # 刷新令牌过期时间(天)
  refresh-token-expire: 7
  # 请求头中Token的名称
  header: Authorization
  # Token前缀
  token-prefix: Bearer

四、核心代码实现

1. JWT配置属性类

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
    private String secret;
    private long accessTokenExpire;
    private long refreshTokenExpire;
    private String header;
    private String tokenPrefix;
}

2. Token工具类

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

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

@Component
@Slf4j
public class JwtTokenUtil {
    
    @Autowired
    private JwtProperties jwtProperties;
    
    /**
     * 生成访问令牌
     */
    public String generateAccessToken(String username, String deviceId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("deviceId", deviceId);
        claims.put("jti", UUID.randomUUID().toString()); // JWT ID,用于标识令牌
        
        Date now = new Date();
        Date expirationTime = new Date(now.getTime() + jwtProperties.getAccessTokenExpire() * 60 * 1000);
        
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expirationTime)
                .signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
                .compact();
    }
    
    /**
     * 生成刷新令牌
     */
    public String generateRefreshToken(String username, String deviceId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("deviceId", deviceId);
        claims.put("type", "refresh"); // 标识为刷新令牌
        
        Date now = new Date();
        Date expirationTime = new Date(now.getTime() + jwtProperties.getRefreshTokenExpire() * 24 * 60 * 60 * 1000);
        
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expirationTime)
                .signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
                .compact();
    }
    
    /**
     * 解析Token
     */
    public Claims parseToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecret())
                .parseClaimsJws(token)
                .getBody();
    }
    
    /**
     * 从Token中获取用户名
     */
    public String getUsernameFromToken(String token) {
        return parseToken(token).get("username", String.class);
    }
    
    /**
     * 从Token中获取设备ID
     */
    public String getDeviceIdFromToken(String token) {
        return parseToken(token).get("deviceId", String.class);
    }
    
    /**
     * 从Token中获取JWT ID
     */
    public String getJtiFromToken(String token) {
        return parseToken(token).get("jti", String.class);
    }
    
    /**
     * 验证Token是否过期
     */
    public boolean isTokenExpired(String token) {
        Date expiration = parseToken(token).getExpiration();
        return expiration.before(new Date());
    }
    
    /**
     * 获取Token剩余有效期(毫秒)
     */
    public long getTokenRemainingTime(String token) {
        Date expiration = parseToken(token).getExpiration();
        return expiration.getTime() - System.currentTimeMillis();
    }
}

3. Redis Token服务(黑名单和刷新令牌管理)

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class RedisTokenService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private JwtProperties jwtProperties;
    
    // 黑名单前缀
    private static final String BLACKLIST_PREFIX = "blacklist:";
    // 刷新令牌前缀
    private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
    
    /**
     * 将Token添加到黑名单
     */
    public void addToBlacklist(String token) {
        try {
            String jti = jwtTokenUtil.getJtiFromToken(token);
            long remainingTime = jwtTokenUtil.getTokenRemainingTime(token);
            
            // 如果Token已经过期,不需要添加到黑名单
            if (remainingTime > 0) {
                String key = BLACKLIST_PREFIX + jti;
                redisTemplate.opsForValue().set(key, "1", remainingTime, TimeUnit.MILLISECONDS);
                log.info("Token已添加到黑名单: {}", jti);
            }
        } catch (Exception e) {
            log.error("添加Token到黑名单失败: {}", e.getMessage());
        }
    }
    
    /**
     * 检查Token是否在黑名单中
     */
    public boolean isInBlacklist(String token) {
        try {
            String jti = jwtTokenUtil.getJtiFromToken(token);
            String key = BLACKLIST_PREFIX + jti;
            return Boolean.TRUE.equals(redisTemplate.hasKey(key));
        } catch (Exception e) {
            log.error("检查Token是否在黑名单失败: {}", e.getMessage());
            return false;
        }
    }
    
    /**
     * 存储刷新令牌
     */
    public void storeRefreshToken(String username, String deviceId, String refreshToken) {
        String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
        // 刷新令牌有效期与JWT中设置的一致
        redisTemplate.opsForValue().set(key, refreshToken, jwtProperties.getRefreshTokenExpire(), TimeUnit.DAYS);
        log.info("刷新令牌已存储: 用户={}, 设备={}", username, deviceId);
    }
    
    /**
     * 获取存储的刷新令牌
     */
    public String getRefreshToken(String username, String deviceId) {
        String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
        return redisTemplate.opsForValue().get(key);
    }
    
    /**
     * 移除刷新令牌(用户登出时)
     */
    public void removeRefreshToken(String username, String deviceId) {
        String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
        redisTemplate.delete(key);
        log.info("刷新令牌已移除: 用户={}, 设备={}", username, deviceId);
    }
    
    /**
     * 验证刷新令牌
     */
    public boolean validateRefreshToken(String username, String deviceId, String refreshToken) {
        String storedToken = getRefreshToken(username, deviceId);
        return storedToken != null && storedToken.equals(refreshToken);
    }
}

4. JWT拦截器

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private RedisTokenService redisTokenService;
    
    @Autowired
    private JwtProperties jwtProperties;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader(jwtProperties.getHeader());
        
        // 检查请求头中是否包含Token
        if (token == null || "".equals(token.trim())) {
            response401(response, "Token不存在", "MISSING_TOKEN");
            return false;
        }
        
        // 移除Token前缀
        if (token.startsWith(jwtProperties.getTokenPrefix())) {
            token = token.replace(jwtProperties.getTokenPrefix(), "").trim();
        } else {
            response401(response, "Token格式错误", "INVALID_TOKEN_FORMAT");
            return false;
        }
        
        try {
            // 检查Token是否在黑名单中
            if (redisTokenService.isInBlacklist(token)) {
                response401(response, "Token已被撤销", "TOKEN_REVOKED");
                return false;
            }
            
            // 验证Token
            jwtTokenUtil.parseToken(token);
            
            // 将用户信息存储到请求中,供后续使用
            request.setAttribute("username", jwtTokenUtil.getUsernameFromToken(token));
            request.setAttribute("deviceId", jwtTokenUtil.getDeviceIdFromToken(token));
            
            log.info("Token验证成功");
            return true;
        } catch (ExpiredJwtException e) {
            log.error("Token已过期: {}", e.getMessage());
            response401(response, "Token已过期", "TOKEN_EXPIRED");
            return false;
        } catch (JwtException e) {
            log.error("Token无效: {}", e.getMessage());
            response401(response, "Token无效", "INVALID_TOKEN");
            return false;
        }
    }
    
    /**
     * 返回401错误响应
     */
    private void response401(HttpServletResponse response, String message, String errorCode) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        
        try (PrintWriter out = response.getWriter()) {
            out.append(String.format("{\"code\": 401, \"message\": \"%s\", \"errorCode\": \"%s\"}", message, errorCode));
        } catch (Exception e) {
            log.error("响应错误: {}", e.getMessage());
        }
    }
}

5. 拦截器配置

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    
    @Autowired
    private JwtInterceptor jwtInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器,拦截需要认证的路径
        registry.addInterceptor(jwtInterceptor)
                // 需要拦截的路径
                .addPathPatterns("/api/**")
                // 排除不需要拦截的路径
                .excludePathPatterns("/api/auth/login", "/api/auth/refresh", "/api/auth/logout");
    }
}

6. 实体类

import lombok.Data;

// 登录请求
@Data
public class LoginRequest {
    private String username;
    private String password;
    private String deviceId; // 设备ID,用于多端登录
}

// 登录响应
@Data
public class LoginResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType;
    private long expiresIn; // 访问令牌过期时间(秒)
    private String username;
}

// Token刷新请求
@Data
public class TokenRefreshRequest {
    private String refreshToken;
    private String deviceId;
}

// Token刷新响应
@Data
public class TokenRefreshResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType;
    private long expiresIn;
}

// 通用响应
@Data
public class ApiResponse<T> {
    private int code;
    private String message;
    private T data;
    
    public ApiResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "success", data);
    }
    
    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}

7. 用户服务接口及实现

import org.springframework.stereotype.Service;

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

public interface UserService {
    boolean login(String username, String password);
    boolean register(String username, String password);
}

@Service
public class UserServiceImpl implements UserService {
    
    // 模拟数据库,实际项目中应使用数据库存储
    private final Map<String, String> userMap = new HashMap<>();
    
    public UserServiceImpl() {
        // 初始化测试用户
        userMap.put("admin", "123456");
    }
    
    @Override
    public boolean login(String username, String password) {
        return userMap.containsKey(username) && userMap.get(username).equals(password);
    }
    
    @Override
    public boolean register(String username, String password) {
        if (userMap.containsKey(username)) {
            return false; // 用户名已存在
        }
        userMap.put(username, password);
        return true;
    }
}

8. 认证控制器

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private RedisTokenService redisTokenService;
    
    @Autowired
    private JwtProperties jwtProperties;
    
    /**
     * 用户登录
     */
    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody LoginRequest request) {
        // 验证用户名密码
        if (!userService.login(request.getUsername(), request.getPassword())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(ApiResponse.error(401, "用户名或密码错误"));
        }
        
        // 生成Access Token
        String accessToken = jwtTokenUtil.generateAccessToken(request.getUsername(), request.getDeviceId());
        
        // 生成Refresh Token
        String refreshToken = jwtTokenUtil.generateRefreshToken(request.getUsername(), request.getDeviceId());
        
        // 存储Refresh Token到Redis
        redisTokenService.storeRefreshToken(request.getUsername(), request.getDeviceId(), refreshToken);
        
        // 构建响应
        LoginResponse response = new LoginResponse();
        response.setAccessToken(accessToken);
        response.setRefreshToken(refreshToken);
        response.setTokenType(jwtProperties.getTokenPrefix().trim());
        response.setExpiresIn(jwtProperties.getAccessTokenExpire() * 60); // 转换为秒
        response.setUsername(request.getUsername());
        
        return ResponseEntity.ok(ApiResponse.success(response));
    }
    
    /**
     * 刷新Token
     */
    @PostMapping("/refresh")
    public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(@RequestBody TokenRefreshRequest request) {
        try {
            // 解析Refresh Token
            String username = jwtTokenUtil.getUsernameFromToken(request.getRefreshToken());
            String deviceId = request.getDeviceId();
            
            // 验证Refresh Token
            if (!redisTokenService.validateRefreshToken(username, deviceId, request.getRefreshToken())) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body(ApiResponse.error(401, "无效的刷新令牌"));
            }
            
            // 生成新的Access Token
            String newAccessToken = jwtTokenUtil.generateAccessToken(username, deviceId);
            
            // 生成新的Refresh Token(可选:可以选择不生成新的刷新令牌)
            String newRefreshToken = jwtTokenUtil.generateRefreshToken(username, deviceId);
            
            // 更新Redis中的Refresh Token
            redisTokenService.storeRefreshToken(username, deviceId, newRefreshToken);
            
            // 构建响应
            TokenRefreshResponse response = new TokenRefreshResponse();
            response.setAccessToken(newAccessToken);
            response.setRefreshToken(newRefreshToken);
            response.setTokenType(jwtProperties.getTokenPrefix().trim());
            response.setExpiresIn(jwtProperties.getAccessTokenExpire() * 60);
            
            return ResponseEntity.ok(ApiResponse.success(response));
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(ApiResponse.error(401, "刷新令牌已过期或无效"));
        }
    }
    
    /**
     * 用户登出
     */
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(HttpServletRequest request) {
        try {
            // 获取请求头中的Token
            String token = request.getHeader(jwtProperties.getHeader());
            if (token != null && token.startsWith(jwtProperties.getTokenPrefix())) {
                token = token.replace(jwtProperties.getTokenPrefix(), "").trim();
                
                // 解析Token获取用户信息
                String username = jwtTokenUtil.getUsernameFromToken(token);
                String deviceId = jwtTokenUtil.getDeviceIdFromToken(token);
                
                // 将Access Token添加到黑名单
                redisTokenService.addToBlacklist(token);
                
                // 移除Refresh Token
                redisTokenService.removeRefreshToken(username, deviceId);
                
                return ResponseEntity.ok(ApiResponse.success(null));
            }
        } catch (Exception e) {
            // 即使Token解析失败,也返回登出成功
            log.error("登出时处理Token失败: {}", e.getMessage());
        }
        
        return ResponseEntity.ok(ApiResponse.success(null));
    }
}

9. 测试控制器

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api")
public class TestController {
    
    /**
     * 测试需要认证的接口
     */
    @GetMapping("/test")
    public ResponseEntity<ApiResponse<String>> test(HttpServletRequest request) {
        String username = (String) request.getAttribute("username");
        String deviceId = (String) request.getAttribute("deviceId");
        
        return ResponseEntity.ok(
                ApiResponse.success("访问成功,Token有效。用户名: " + username + ",设备ID: " + deviceId)
        );
    }
}

五、前端实现建议

1. Token存储

// 存储Token
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
localStorage.setItem('tokenExpiresAt', Date.now() + response.data.expiresIn * 1000);

2. 请求拦截器(自动刷新Token)

// 创建axios实例
const axiosInstance = axios.create({
  baseURL: '/api',
  timeout: 10000
});

// 请求拦截器
axiosInstance.interceptors.request.use(
  config => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器
axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    
    // 如果是401错误且不是刷新Token的请求
    if (error.response && error.response.status === 401 && 
        error.response.data.errorCode === 'TOKEN_EXPIRED' && 
        !originalRequest._retry) {
      
      originalRequest._retry = true;
      
      try {
        // 尝试刷新Token
        const refreshToken = localStorage.getItem('refreshToken');
        const deviceId = localStorage.getItem('deviceId');
        
        const refreshResponse = await axios.post('/api/auth/refresh', {
          refreshToken: refreshToken,
          deviceId: deviceId
        });
        
        // 更新存储的Token
        localStorage.setItem('accessToken', refreshResponse.data.data.accessToken);
        localStorage.setItem('refreshToken', refreshResponse.data.data.refreshToken);
        localStorage.setItem('tokenExpiresAt', Date.now() + refreshResponse.data.data.expiresIn * 1000);
        
        // 更新当前请求的Token并重新发送
        originalRequest.headers['Authorization'] = `Bearer ${refreshResponse.data.data.accessToken}`;
        return axiosInstance(originalRequest);
      } catch (refreshError) {
        // 刷新失败,跳转到登录页
        localStorage.clear();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    
    return Promise.reject(error);
  }
);

六、最佳实践和安全性建议

  1. 密钥安全

    • 使用强密钥,至少32位
    • 生产环境中从环境变量或配置中心获取密钥
    • 考虑使用非对称加密(如RSA)增强安全性
  2. Token过期时间

    • Access Token设置较短的过期时间(15-30分钟)
    • Refresh Token可以设置较长的过期时间(7-30天)
    • 根据业务需求调整过期策略
  3. Redis优化

    • 使用Redis集群提高可用性
    • 为Redis配置密码和访问控制
    • 定期清理过期数据
  4. 多端登录管理

    • 为每个设备生成独立的Refresh Token
    • 可以实现强制下线指定设备的功能
    • 提供查看当前登录设备的接口
  5. 防重放攻击

    • 在Token中添加jti(JWT ID)字段
    • 实现请求频率限制
    • 考虑在Token中添加时间戳或随机数
  6. HTTPS传输

    • 生产环境必须使用HTTPS
    • 配置适当的安全头
  7. 日志记录

    • 记录重要操作的日志,如登录、登出、Token刷新
    • 记录异常情况,便于问题排查

七、测试方法

  1. 启动SpringBoot应用和Redis服务
  2. 使用Postman测试登录接口:POST http://localhost:8080/api/auth/login
  3. 获取返回的Access Token和Refresh Token
  4. 使用Access Token访问受保护接口:GET http://localhost:8080/api/test
  5. 测试Token刷新接口:POST http://localhost:8080/api/auth/refresh
  6. 测试登出接口:POST http://localhost:8080/api/auth/logout
  7. 验证登出后的Token是否已被加入黑名单

这个实现提供了完整的JWT认证、Token刷新机制和黑名单机制,适用于生产环境。您可以根据实际项目需求进行调整和扩展。