侧边栏壁纸
  • 累计撰写 78 篇文章
  • 累计创建 29 个标签
  • 累计收到 21 条评论

目 录CONTENT

文章目录

Spring Security 6 + JWT + Redis 实现微服务统一认证授权实战全攻略

Administrator
2026-03-31 / 0 评论 / 0 点赞 / 0 阅读 / 0 字 / 正在检测是否收录...
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

摘要:微服务架构下,认证(Authentication)与授权(Authorization)是系统安全的核心。本文基于 Spring Boot 3.x + Spring Security 6,深入讲解如何构建一套完整的 JWT 无状态认证体系,并结合 Redis 实现 Token 管理(黑名单/刷新/单点登录),最终与 Spring Cloud Gateway 打通,实现微服务统一鉴权入口。


目录

  1. 技术选型与架构设计
  2. Spring Security 6 核心变化
  3. 项目结构与依赖
  4. 用户认证模块实现
  5. JWT 工具类封装
  6. Redis Token 管理策略
  7. Spring Security 6 配置详解
  8. JWT 过滤器实现
  9. RBAC 权限模型集成
  10. Gateway 网关统一鉴权
  11. Token 刷新与单点登录
  12. 安全加固建议
  13. 完整流程测试

1. 技术选型与架构设计

1.1 为什么选择 JWT + Redis?

方案优点缺点
Session(有状态)简单,天然支持强制踢线水平扩展难,需共享存储
纯 JWT(无状态)无需服务端存储,扩展性好无法主动失效,安全性弱
JWT + Redis(混合)扩展性好 + 可主动失效 + 支持刷新多一次 Redis 查询

结论:生产环境推荐 JWT + Redis 混合模式:JWT 携带用户基本信息,Redis 存储 Token 状态(有效性、黑名单、刷新令牌)。

1.2 整体架构

客户端
  │
  ▼
Spring Cloud Gateway(统一鉴权入口)
  │  ├─ 白名单路由直接放行
  │  ├─ 请求携带 Authorization: Bearer <token>
  │  └─ 调用认证服务验证 Token / 解析用户信息
  │
  ▼
认证服务(Auth Service)
  │  ├─ /auth/login   → 登录,颁发 AccessToken + RefreshToken
  │  ├─ /auth/logout  → 登出,Token 加入 Redis 黑名单
  │  └─ /auth/refresh → 刷新,颁发新 AccessToken
  │
  ▼
业务微服务(User/Order/Product...)
  │  └─ 从 Gateway 转发的请求头中读取用户信息(无需再验证 Token)
  │
  ▼
Redis(Token 存储)
  │  ├─ auth:token:{userId}    → 当前有效 AccessToken(支持单点登录)
  │  ├─ auth:refresh:{userId}  → RefreshToken
  │  └─ auth:blacklist:{jti}   → 黑名单(TTL = Token 剩余有效期)

2. Spring Security 6 核心变化

Spring Boot 3.x 使用的是 Spring Security 6.x,相比 5.x 有以下重要变化:

2.1 废弃 WebSecurityConfigurerAdapter

Spring Security 5.x:

// ❌ 已废弃
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception { ... }
}

Spring Security 6.x:

// ✅ 推荐:直接注入 SecurityFilterChain Bean
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // ...
        return http.build();
    }
}

2.2 Lambda DSL 成为主流

http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/auth/**").permitAll()
        .anyRequest().authenticated()
    )
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    )
    .csrf(csrf -> csrf.disable());

2.3 AuthorizationManager 替代 AccessDecisionManager

Spring Security 6 引入 AuthorizationManager<RequestAuthorizationContext> 接口,更加灵活,可完全自定义鉴权逻辑。

2.4 路径匹配器变化

AntPathMatcher 仍可用,但推荐使用 PathPatternParser(默认),性能更好。


3. 项目结构与依赖

3.1 Maven 依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.4</version>
</parent>

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- Spring 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(推荐使用 jjwt 0.12.x) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <!-- MyBatis Plus(用户数据) -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.5</version>
    </dependency>

    <!-- MySQL Driver -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
    </dependency>
</dependencies>

3.2 项目结构

auth-service/
├── src/main/java/com/example/auth/
│   ├── config/
│   │   ├── SecurityConfig.java        # Spring Security 主配置
│   │   └── RedisConfig.java           # Redis 序列化配置
│   ├── controller/
│   │   └── AuthController.java        # 登录/登出/刷新接口
│   ├── filter/
│   │   └── JwtAuthenticationFilter.java  # JWT 验证过滤器
│   ├── handler/
│   │   ├── LoginSuccessHandler.java   # 登录成功处理器
│   │   └── LoginFailureHandler.java   # 登录失败处理器
│   ├── model/
│   │   ├── entity/User.java
│   │   └── dto/LoginRequest.java
│   ├── service/
│   │   ├── AuthService.java
│   │   └── UserDetailsServiceImpl.java
│   └── util/
│       ├── JwtUtil.java               # JWT 工具类
│       └── RedisTokenStore.java       # Redis Token 管理
└── src/main/resources/
    └── application.yml

4. 用户认证模块实现

4.1 用户实体与 UserDetails 实现

// User.java
@Data
@TableName("sys_user")
public class User {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private String username;
    private String password;
    private Integer status; // 0禁用 1启用
    private String roles;   // 角色(逗号分隔,如 ROLE_ADMIN,ROLE_USER)
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

// LoginUser.java —— 实现 UserDetails
@Data
public class LoginUser implements UserDetails {
    private User user;
    private List<GrantedAuthority> authorities;

    public LoginUser(User user) {
        this.user = user;
        // 解析角色
        this.authorities = Arrays.stream(user.getRoles().split(","))
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() { return user.getPassword(); }

    @Override
    public String getUsername() { return user.getUsername(); }

    @Override
    public boolean isEnabled() { return user.getStatus() == 1; }

    // 账号未过期、未锁定、凭证未过期均返回 true(简化处理)
    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }
}

4.2 UserDetailsService 实现

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.selectOne(
            new LambdaQueryWrapper<User>()
                .eq(User::getUsername, username)
        );
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在:" + username);
        }
        return new LoginUser(user);
    }
}

5. JWT 工具类封装

5.1 配置参数

# application.yml
jwt:
  secret: "your-256-bit-secret-key-at-least-32-characters-long"
  access-token-expire: 1800      # AccessToken 有效期(秒),30 分钟
  refresh-token-expire: 604800   # RefreshToken 有效期(秒),7 天
  issuer: "92yangyi.top"

5.2 JwtUtil 实现(基于 jjwt 0.12.x)

@Component
@Slf4j
public class JwtUtil {

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

    @Value("${jwt.access-token-expire}")
    private long accessTokenExpire;

    @Value("${jwt.refresh-token-expire}")
    private long refreshTokenExpire;

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

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(
            Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8))
        ));
    }

    /**
     * 生成 AccessToken
     */
    public String generateAccessToken(LoginUser loginUser) {
        return buildToken(loginUser, accessTokenExpire, "access");
    }

    /**
     * 生成 RefreshToken
     */
    public String generateRefreshToken(LoginUser loginUser) {
        return buildToken(loginUser, refreshTokenExpire, "refresh");
    }

    private String buildToken(LoginUser loginUser, long expireSeconds, String tokenType) {
        User user = loginUser.getUser();
        Date now = new Date();
        Date expiry = new Date(now.getTime() + expireSeconds * 1000);

        List<String> roles = loginUser.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList());

        return Jwts.builder()
            .id(UUID.randomUUID().toString())          // jti,唯一标识,用于黑名单
            .subject(String.valueOf(user.getId()))      // sub:用户 ID
            .issuer(issuer)
            .issuedAt(now)
            .expiration(expiry)
            .claim("username", user.getUsername())
            .claim("roles", roles)
            .claim("tokenType", tokenType)
            .signWith(getSigningKey(), Jwts.SIG.HS256)
            .compact();
    }

    /**
     * 解析 Token,返回 Claims
     */
    public Claims parseToken(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    /**
     * 获取用户 ID
     */
    public Long getUserId(String token) {
        return Long.valueOf(parseToken(token).getSubject());
    }

    /**
     * 获取 jti(Token 唯一标识)
     */
    public String getJti(String token) {
        return parseToken(token).getId();
    }

    /**
     * 获取 Token 过期时间(毫秒时间戳)
     */
    public long getExpiration(String token) {
        return parseToken(token).getExpiration().getTime();
    }

    /**
     * 验证 Token 是否有效(签名合法 + 未过期)
     */
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("Token 验证失败:{}", e.getMessage());
            return false;
        }
    }

    public long getAccessTokenExpire() { return accessTokenExpire; }
    public long getRefreshTokenExpire() { return refreshTokenExpire; }
}

6. Redis Token 管理策略

6.1 Redis 配置

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // Key 使用 String 序列化
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        // Value 使用 Jackson JSON 序列化
        Jackson2JsonRedisSerializer<Object> jackson2 =
            new Jackson2JsonRedisSerializer<>(Object.class);
        template.setValueSerializer(jackson2);
        template.setHashValueSerializer(jackson2);
        template.afterPropertiesSet();
        return template;
    }
}

6.2 RedisTokenStore 实现

@Component
@RequiredArgsConstructor
@Slf4j
public class RedisTokenStore {

    private final RedisTemplate<String, Object> redisTemplate;
    private final JwtUtil jwtUtil;

    // Key 前缀
    private static final String ACCESS_TOKEN_KEY  = "auth:token:";
    private static final String REFRESH_TOKEN_KEY = "auth:refresh:";
    private static final String BLACKLIST_KEY      = "auth:blacklist:";

    /**
     * 保存 AccessToken(同一用户只保留最新,实现单设备登录)
     */
    public void saveAccessToken(Long userId, String token) {
        String key = ACCESS_TOKEN_KEY + userId;
        long expire = jwtUtil.getAccessTokenExpire();
        redisTemplate.opsForValue().set(key, token, expire, TimeUnit.SECONDS);
        log.info("保存 AccessToken,userId={},expire={}s", userId, expire);
    }

    /**
     * 保存 RefreshToken
     */
    public void saveRefreshToken(Long userId, String refreshToken) {
        String key = REFRESH_TOKEN_KEY + userId;
        long expire = jwtUtil.getRefreshTokenExpire();
        redisTemplate.opsForValue().set(key, refreshToken, expire, TimeUnit.SECONDS);
    }

    /**
     * 获取当前有效 AccessToken(用于单点登录校验)
     */
    public String getAccessToken(Long userId) {
        return (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY + userId);
    }

    /**
     * 获取 RefreshToken
     */
    public String getRefreshToken(Long userId) {
        return (String) redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY + userId);
    }

    /**
     * Token 加入黑名单(用户登出时调用)
     * TTL = Token 剩余有效期,保证黑名单自动清理
     */
    public void addToBlacklist(String token) {
        String jti = jwtUtil.getJti(token);
        long expiration = jwtUtil.getExpiration(token);
        long ttl = expiration - System.currentTimeMillis();
        if (ttl > 0) {
            redisTemplate.opsForValue().set(
                BLACKLIST_KEY + jti, "1", ttl, TimeUnit.MILLISECONDS
            );
            log.info("Token 加入黑名单,jti={},ttl={}ms", jti, ttl);
        }
    }

    /**
     * 检查 Token 是否在黑名单中
     */
    public boolean isBlacklisted(String token) {
        String jti = jwtUtil.getJti(token);
        return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_KEY + jti));
    }

    /**
     * 删除 Token(登出时清理用户的所有 Token)
     */
    public void deleteUserTokens(Long userId) {
        redisTemplate.delete(ACCESS_TOKEN_KEY + userId);
        redisTemplate.delete(REFRESH_TOKEN_KEY + userId);
    }
}

7. Spring Security 6 配置详解

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)  // 启用方法级鉴权 @PreAuthorize
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {

    private final UserDetailsServiceImpl userDetailsService;
    private final JwtAuthenticationFilter jwtAuthFilter;

    /**
     * 密码编码器(BCrypt)
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * AuthenticationManager(登录时手动调用)
     */
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    /**
     * 核心安全过滤链配置
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF(JWT 无状态模式不需要)
            .csrf(csrf -> csrf.disable())

            // 禁用 Session(纯无状态)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // 路由权限配置
            .authorizeHttpRequests(auth -> auth
                // 认证接口放行
                .requestMatchers(
                    "/auth/login",
                    "/auth/refresh",
                    "/actuator/health"
                ).permitAll()
                // 管理员接口
                .requestMatchers("/admin/**").hasRole("ADMIN")
                // 其他请求需要认证
                .anyRequest().authenticated()
            )

            // 自定义异常处理
            .exceptionHandling(ex -> ex
                // 未认证(401)
                .authenticationEntryPoint((request, response, e) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}");
                })
                // 无权限(403)
                .accessDeniedHandler((request, response, e) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}");
                })
            )

            // 在 UsernamePasswordAuthenticationFilter 之前插入 JWT 过滤器
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

8. JWT 过滤器实现

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final RedisTokenStore redisTokenStore;
    private final UserDetailsServiceImpl userDetailsService;

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

        // 1. 从请求头提取 Token
        String token = extractToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 2. 验证 Token 签名和有效期
        if (!jwtUtil.validateToken(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 3. 检查黑名单(是否已登出)
        if (redisTokenStore.isBlacklisted(token)) {
            log.warn("Token 已在黑名单中,拒绝访问");
            filterChain.doFilter(request, response);
            return;
        }

        // 4. 单点登录校验:比对 Redis 中最新 Token
        Long userId = jwtUtil.getUserId(token);
        String storedToken = redisTokenStore.getAccessToken(userId);
        if (!token.equals(storedToken)) {
            log.warn("Token 与 Redis 不一致,可能已在其他设备登录,userId={}", userId);
            filterChain.doFilter(request, response);
            return;
        }

        // 5. 加载用户信息,构建认证对象
        Claims claims = jwtUtil.parseToken(token);
        String username = claims.get("username", String.class);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities()
            );
        authentication.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request)
        );

        // 6. 存入 SecurityContext
        SecurityContextHolder.getContext().setAuthentication(authentication);

        filterChain.doFilter(request, response);
    }

    /**
     * 从 Authorization 头提取 Bearer Token
     */
    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

9. RBAC 权限模型集成

9.1 数据库表设计

-- 用户表
CREATE TABLE sys_user (
    id          BIGINT PRIMARY KEY,
    username    VARCHAR(64) NOT NULL UNIQUE,
    password    VARCHAR(128) NOT NULL,
    status      TINYINT DEFAULT 1,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id          BIGINT PRIMARY KEY,
    role_code   VARCHAR(64) NOT NULL UNIQUE COMMENT '如 ROLE_ADMIN',
    role_name   VARCHAR(64) NOT NULL,
    status      TINYINT DEFAULT 1
);

-- 权限表
CREATE TABLE sys_permission (
    id           BIGINT PRIMARY KEY,
    perm_code    VARCHAR(128) NOT NULL UNIQUE COMMENT '如 user:read',
    perm_name    VARCHAR(64)  NOT NULL,
    resource_url VARCHAR(256)
);

-- 用户-角色关联
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色-权限关联
CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    perm_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, perm_id)
);

9.2 方法级鉴权使用

@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {

    @GetMapping("/list")
    @PreAuthorize("hasRole('ADMIN')")       // 需要管理员角色
    public List<User> listUsers() { ... }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('user:delete')")  // 需要具体权限
    public void deleteUser(@PathVariable Long id) { ... }

    @GetMapping("/profile")
    @PreAuthorize("isAuthenticated()")      // 仅需登录
    public User getProfile() { ... }
}

10. Gateway 网关统一鉴权

在 Spring Cloud Gateway 中,通过全局过滤器实现 Token 预验证,下游服务无需再次验证。

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final JwtUtil jwtUtil;
    private final RedisTokenStore redisTokenStore;

    // 白名单路径(不需要鉴权)
    private static final List<String> WHITE_LIST = Arrays.asList(
        "/auth/login", "/auth/refresh", "/actuator/**"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 白名单直接放行
        if (isWhiteList(path)) {
            return chain.filter(exchange);
        }

        // 提取 Token
        String token = extractToken(exchange.getRequest());
        if (token == null) {
            return unauthorized(exchange, "缺少认证 Token");
        }

        // 验证 Token
        if (!jwtUtil.validateToken(token) || redisTokenStore.isBlacklisted(token)) {
            return unauthorized(exchange, "Token 无效或已过期");
        }

        // 解析用户信息,注入下游请求头(避免下游重复解析 JWT)
        Claims claims = jwtUtil.parseToken(token);
        Long userId = Long.valueOf(claims.getSubject());
        String username = claims.get("username", String.class);

        ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
            .header("X-User-Id", String.valueOf(userId))
            .header("X-Username", username)
            .header("X-User-Roles", claims.get("roles", List.class).toString())
            .build();

        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = String.format("{\"code\":401,\"msg\":\"%s\"}", msg);
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    private boolean isWhiteList(String path) {
        return WHITE_LIST.stream().anyMatch(p ->
            new AntPathMatcher().match(p, path)
        );
    }

    private String extractToken(ServerHttpRequest request) {
        List<String> headers = request.getHeaders().get("Authorization");
        if (headers != null && !headers.isEmpty()) {
            String header = headers.get(0);
            if (header.startsWith("Bearer ")) {
                return header.substring(7);
            }
        }
        return null;
    }

    @Override
    public int getOrder() { return -100; }  // 高优先级
}

11. Token 刷新与单点登录

11.1 认证控制器

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

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;
    private final RedisTokenStore redisTokenStore;
    private final UserDetailsServiceImpl userDetailsService;

    /**
     * 登录接口
     */
    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest request) {
        // 1. Spring Security 认证(内部调用 UserDetailsService.loadUserByUsername)
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );

        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        Long userId = loginUser.getUser().getId();

        // 2. 生成 Token
        String accessToken  = jwtUtil.generateAccessToken(loginUser);
        String refreshToken = jwtUtil.generateRefreshToken(loginUser);

        // 3. 保存到 Redis(覆盖旧 Token,实现单点登录)
        redisTokenStore.saveAccessToken(userId, accessToken);
        redisTokenStore.saveRefreshToken(userId, refreshToken);

        log.info("用户登录成功,userId={}", userId);
        return ResponseEntity.ok(Map.of(
            "accessToken",  accessToken,
            "refreshToken", refreshToken,
            "expiresIn",    jwtUtil.getAccessTokenExpire()
        ));
    }

    /**
     * 登出接口
     */
    @PostMapping("/logout")
    public ResponseEntity<String> logout(HttpServletRequest request) {
        String token = extractToken(request);
        if (token != null && jwtUtil.validateToken(token)) {
            Long userId = jwtUtil.getUserId(token);
            // 加入黑名单
            redisTokenStore.addToBlacklist(token);
            // 清理 Redis 中的 Token(可选:多设备模式不清理,单设备登录则清理)
            redisTokenStore.deleteUserTokens(userId);
            log.info("用户登出,userId={}", userId);
        }
        return ResponseEntity.ok("登出成功");
    }

    /**
     * 刷新 Token 接口
     * 使用 RefreshToken 换取新的 AccessToken
     */
    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@RequestHeader("X-Refresh-Token") String refreshToken) {
        // 1. 验证 RefreshToken
        if (!jwtUtil.validateToken(refreshToken)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("msg", "RefreshToken 无效或已过期,请重新登录"));
        }

        // 2. 检查 Redis 中的 RefreshToken
        Long userId = jwtUtil.getUserId(refreshToken);
        String storedRefreshToken = redisTokenStore.getRefreshToken(userId);
        if (!refreshToken.equals(storedRefreshToken)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("msg", "RefreshToken 已失效,请重新登录"));
        }

        // 3. 加载用户信息
        Claims claims = jwtUtil.parseToken(refreshToken);
        String username = claims.get("username", String.class);
        LoginUser loginUser = (LoginUser) userDetailsService.loadUserByUsername(username);

        // 4. 颁发新的 AccessToken
        String newAccessToken = jwtUtil.generateAccessToken(loginUser);
        redisTokenStore.saveAccessToken(userId, newAccessToken);

        return ResponseEntity.ok(Map.of(
            "accessToken", newAccessToken,
            "expiresIn",   jwtUtil.getAccessTokenExpire()
        ));
    }

    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

11.2 单点登录(SSO)原理

用户 A 在设备1登录 → Redis 保存 AccessToken1
用户 A 在设备2登录 → Redis 保存 AccessToken2(覆盖 AccessToken1)

设备1 再次请求 → 过滤器比对 Redis 中 Token != AccessToken1 → 拒绝 → 强制重新登录

12. 安全加固建议

12.1 HTTPS 强制

server:
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-password: yourpassword
    key-store-type: PKCS12

12.2 防止暴力破解(登录限流)

@Service
@RequiredArgsConstructor
public class LoginAttemptService {

    private final RedisTemplate<String, Object> redisTemplate;
    private static final int MAX_ATTEMPTS = 5;
    private static final long LOCK_DURATION = 15 * 60; // 锁定 15 分钟

    private String getKey(String username) {
        return "auth:attempts:" + username;
    }

    public void recordFailure(String username) {
        String key = getKey(username);
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, LOCK_DURATION, TimeUnit.SECONDS);
    }

    public boolean isLocked(String username) {
        Object count = redisTemplate.opsForValue().get(getKey(username));
        return count != null && Integer.parseInt(count.toString()) >= MAX_ATTEMPTS;
    }

    public void resetAttempts(String username) {
        redisTemplate.delete(getKey(username));
    }
}

12.3 Token 密钥轮换

生产环境建议使用 RSA 非对称加密(私钥签名、公钥验证),避免密钥泄露导致全局风险:

// 使用 RSA 密钥对(Spring Security OAuth2 Resource Server 原生支持)
@Bean
public JwtDecoder jwtDecoder() throws Exception {
    RSAPublicKey publicKey = (RSAPublicKey) loadPublicKey();
    return NimbusJwtDecoder.withPublicKey(publicKey).build();
}

12.4 CORS 配置

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOriginPatterns(List.of("https://92yangyi.top", "http://localhost:*"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

13. 完整流程测试

13.1 登录获取 Token

curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"Admin@123"}'

# 响应
{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
  "expiresIn": 1800
}

13.2 访问受保护接口

curl -X GET http://localhost:8080/user/list \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

# 无权限时返回
{"code": 403, "msg": "权限不足"}

13.3 刷新 Token

curl -X POST http://localhost:8080/auth/refresh \
  -H "X-Refresh-Token: eyJhbGciOiJIUzI1NiJ9..."

# 响应
{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...(新的)",
  "expiresIn": 1800
}

13.4 登出

curl -X POST http://localhost:8080/auth/logout \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

# 响应
"登出成功"

# 再次使用该 Token 访问 → 401(已在黑名单中)

13.5 Redis 数据查看

redis-cli

KEYS auth:*
# auth:token:1001       → AccessToken
# auth:refresh:1001     → RefreshToken
# auth:blacklist:xxx    → 黑名单(已登出的 Token jti)
# auth:attempts:admin   → 登录失败次数

TTL auth:token:1001     → 剩余有效时间(秒)
GET auth:token:1001     → 查看 Token 内容

总结

本文系统讲解了基于 Spring Boot 3.x + Spring Security 6 + JWT + Redis 的微服务统一认证授权方案:

模块核心技术关键点
认证Spring Security 6 + AuthenticationManagerSecurityFilterChain Lambda DSL
Tokenjjwt 0.12.xAccessToken + RefreshToken 双 Token 机制
状态管理Redis黑名单、单点登录、登录失败限流
鉴权RBAC + @PreAuthorize方法级权限控制
网关集成Spring Cloud Gateway + GlobalFilter统一鉴权入口,下游透传用户信息
安全加固HTTPS + 限流 + CORS生产环境必备

与博客已有内容的衔接

  • 搭配 Spring Cloud Gateway 网关鉴权(见 Gateway 实战文章)
  • 结合 Redis 实现 Token 管理(见 Redis 系列)
  • 作为 微服务架构安全层的核心组件,与 Docker 部署、K8s 集群方案完美融合

参考资料

0

评论区