Java框架快速入门: Spring Security+OAuth2之JWT访问令牌与刷新令牌及外部配置

纲要

  • 令牌设计模式: 为什么需要区分 Access TokenRefresh Token
  • 前端令牌存储策略: 内存、Cookie(HTTPOnly)sessionStoragelocalStorage 安全性对比
  • Spring Security 自定义 JWT 认证过滤器核心思路: 直接构造 Authentication 对象并置入 SecurityContext
  • JWT 工具类增强: 支持签名密钥分离、创建访问/刷新令牌的 Helper 方法
  • 外部配置实现: 使用 @ConfigurationProperties 将过期时间等参数迁移至 application.yml
  • 完整项目结构: 展示包结构和关键类
  • 配置示例与单元测试修复

访问令牌与刷新令牌的设计哲学

在分布式认证体系中,令牌一旦签发即视为“已暴露”。没有任何客户端存储方案能百分之百阻止令牌被窃取,因此安全策略的核心是降低令牌泄露后的影响范围与时间窗口。由此引入两种令牌:

  • 访问令牌 (Access Token):直接携带权限访问资源,生命周期极短(分钟级),即使泄露也会快速失效。
  • 刷新令牌 (Refresh Token):不能直接访问资源,唯一用途是换取新的访问令牌,有效期较长(小时、天或月),且签发时使用不同密钥。

这种职责分离的设计,配合 IP 风控、异常行为检测、多因子认证等手段,可以构建纵深防御体系。

ResourceServerAuthServerClientResourceServerAuthServerClientAccess Token 过期后...登录 (用户名/密码)返回 Access Token + Refresh Token请求资源 (携带 Access Token)资源数据刷新令牌 (携带 Refresh Token)新的 Access Token使用新 Access Token 请求资源资源数据

令牌在前端的存储选择

存储方式安全性特点
内存 (Vuex/Redux)最高页面刷新即丢失,需重新登录
Cookie (HTTPOnly, Secure, SameSite)较高只能由服务端设置,无法通过 JS 读取,可限制域名
sessionStorage中等关闭浏览器标签页后清除,略优于 localStorage
localStorage较低永久存储,所有同源脚本均可访问,不推荐

实际项目中建议采用 Cookie (HTTPOnly) 存储刷新令牌,访问令牌可存于内存并配合静默刷新。无论采用哪种方案,都不应只依赖令牌本身的安全性,还需要在服务端结合限流、设备指纹、异地登录检测等多重措施。

Spring Security 集成 JWT 的核心步骤

Spring Security 没有内置适用于前后端分离的 JwtAuthenticationFilter,但实现一个自定义过滤器非常简单。整个认证流程的“心脏”就是将填充完整的 Authentication 对象放入 SecurityContextHolder

  1. 从请求头中提取 JWT 字符串
  2. 解析并校验令牌
  3. 构造 UsernamePasswordAuthenticationToken,设置权限
  4. 调用 SecurityContextHolder.getContext().setAuthentication(...)

认证失败时清空上下文并交由后续过滤器链处理。

下面我们就从改造 JWT 工具类开始,逐步完成一个可外部配置的生产级 JWT 过滤器。

JWT 工具类增强:支持双密钥与外部配置

新增 AppProperties 配置类

将令牌过期时间与签名密钥等抽离到配置文件中,避免硬编码。

package com.example.demo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "app")
public class AppProperties {

    private Jwt jwt = new Jwt();

    public Jwt getJwt() {
        return jwt;
    }

    public void setJwt(Jwt jwt) {
        this.jwt = jwt;
    }

    public static class Jwt {
        /** 访问令牌过期时间(毫秒),默认5分钟 */
        private long accessTokenExpireTime = 5 * 60 * 1000L;
        /** 刷新令牌过期时间(毫秒),默认30天 */
        private long refreshTokenExpireTime = 30 * 24 * 60 * 60 * 1000L;
        /** 访问令牌签名密钥 */
        private String accessTokenSecret = "default-access-secret";
        /** 刷新令牌签名密钥 */
        private String refreshTokenSecret = "default-refresh-secret";

        // getters and setters
        public long getAccessTokenExpireTime() {
            return accessTokenExpireTime;
        }

        public void setAccessTokenExpireTime(long accessTokenExpireTime) {
            this.accessTokenExpireTime = accessTokenExpireTime;
        }

        public long getRefreshTokenExpireTime() {
            return refreshTokenExpireTime;
        }

        public void setRefreshTokenExpireTime(long refreshTokenExpireTime) {
            this.refreshTokenExpireTime = refreshTokenExpireTime;
        }

        public String getAccessTokenSecret() {
            return accessTokenSecret;
        }

        public void setAccessTokenSecret(String accessTokenSecret) {
            this.accessTokenSecret = accessTokenSecret;
        }

        public String getRefreshTokenSecret() {
            return refreshTokenSecret;
        }

        public void setRefreshTokenSecret(String refreshTokenSecret) {
            this.refreshTokenSecret = refreshTokenSecret;
        }
    }
}

改进后的 JwtUtil 工具类

工具类提供生成访问令牌、刷新令牌以及解析验证的核心逻辑,密钥和过期时间均从 AppProperties 注入。

package com.example.demo.util;

import com.example.demo.config.AppProperties;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {

    private final AppProperties appProperties;

    public JwtUtil(AppProperties appProperties) {
        this.appProperties = appProperties;
    }

    /**
     * 生成访问令牌
     */
    public String createAccessToken(String subject) {
        AppProperties.Jwt jwtConfig = appProperties.getJwt();
        return generateToken(subject, jwtConfig.getAccessTokenSecret(),
                jwtConfig.getAccessTokenExpireTime());
    }

    /**
     * 生成刷新令牌
     */
    public String createRefreshToken(String subject) {
        AppProperties.Jwt jwtConfig = appProperties.getJwt();
        return generateToken(subject, jwtConfig.getRefreshTokenSecret(),
                jwtConfig.getRefreshTokenExpireTime());
    }

    /**
     * 通用令牌生成方法
     */
    private String generateToken(String subject, String secret, long expirationMs) {
        Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        Date now = new Date();
        Date expiration = new Date(now.getTime() + expirationMs);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * 解析令牌,返回主体(用户名)
     */
    public String parseToken(String token, String secret) {
        Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        Jws<Claims> claimsJws = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);
        return claimsJws.getBody().getSubject();
    }

    /**
     * 校验令牌是否有效
     */
    public boolean validateToken(String token, String secret) {
        try {
            parseToken(token, secret);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    /**
     * 获取访问令牌密钥(供过滤器使用)
     */
    public String getAccessTokenSecret() {
        return appProperties.getJwt().getAccessTokenSecret();
    }
}

配置文件 application.yml

通过 app.jwt 前缀即可自定义密钥和过期时间,IDE 会自动提示。

app:
  jwt:
    access-token-expire-time: 300000   # 5分钟
    refresh-token-expire-time: 2592000000  # 30天
    access-token-secret: my-access-secret-key-must-be-long
    refresh-token-secret: my-refresh-secret-key-must-be-long

自定义 JWT 认证过滤器

基于上述工具类,我们可以轻松编写一个 JwtAuthenticationFilter,它只做三件事:提取、验证、写入上下文。

package com.example.demo.security;

import com.example.demo.util.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
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;
import java.util.Collections;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            String secret = jwtUtil.getAccessTokenSecret();
            if (jwtUtil.validateToken(token, secret)) {
                String username = jwtUtil.parseToken(token, secret);
                // 实际项目应从数据库加载用户权限,此处仅做示例
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } else {
                // 令牌无效,清空上下文交给后续过滤器
                SecurityContextHolder.clearContext();
            }
        }
        filterChain.doFilter(request, response);
    }
}

最后,在 Spring Security 配置类中将自定义过滤器添加至 UsernamePasswordAuthenticationFilter 之前即可生效。

项目代码结构

src/main/java/com/example/demo/
├── config
│   └── AppProperties.java
├── security
│   ├── JwtAuthenticationFilter.java
│   └── SecurityConfig.java
├── util
│   └── JwtUtil.java
└── DemoApplication.java
src/main/resources/
└── application.yml

单元测试修复

原有的测试用例需要传入 AppProperties 才能正常使用 JwtUtil。可以通过手动构造默认配置或使用 @SpringBootTest 加载上下文来解决:

// 简单构造示例
AppProperties props = new AppProperties();
// 内部 Jwt 对象已有默认值,无需额外设置
JwtUtil jwtUtil = new JwtUtil(props);
String accessToken = jwtUtil.createAccessToken("testuser");
assertNotNull(accessToken);
assertEquals("testuser", jwtUtil.parseToken(accessToken, props.getJwt().getAccessTokenSecret()));

总结

本文从令牌类型设计、安全存储策略开始,详细讲解了如何在 Spring Security 体系中集成 JWT,并通过 @ConfigurationProperties 将重要参数外部化,最终实现了一个可配置、易扩展的 JWT 认证过滤器。

所附代码包含完整的 packageimport,可直接复制运行,只需确保项目已引入 spring-boot-starter-securityjjwt 依赖。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wang's Blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值