三周手撸企业级认证系统(二) Spring Security + JWT 完整实战

上一篇说了要写这个系列,今天兑现承诺。

这篇要做的:从零搭建一个完整的 JWT 认证系统,能直接用在项目里的那种。代码全部开源,保证能跑。

源码地址:gitee.com/sh_wangwanb...

先看整体流程

flowchart LR A[用户登录] --> B[验证密码] B --> C[生成双Token] C --> D[存储Token] D --> E[访问API] E --> F{Token有效?} F -->|是| G[返回数据] F -->|否| H[自动刷新] H --> E style A fill:#90EE90 style C fill:#87CEEB style D fill:#DDA0DD style E fill:#FFE4B5 style G fill:#FFD700 style H fill:#FFA07A

核心特性:

  • 双 Token 机制:Access Token(15分钟) + Refresh Token(7天)
  • 自动刷新:Token 过期自动换新,用户无感知
  • 可撤销:Refresh Token 存数据库,随时可撤销
  • 无状态:不依赖 Session,天然支持分布式

要解决的核心问题

做认证系统不只是登录登出,真正要考虑的是:

  • Token 过期怎么办? 总让用户登录体验太差 → 用 Refresh Token 自动刷新
  • Token 泄露怎么办? 不能让它一直有效 → Access Token 短期(15分钟),Refresh Token 可撤销
  • 分布式部署怎么办? Session 那套不好使 → JWT 无状态,哪台服务器都能验证
  • 前端怎么处理? 手动刷新太麻烦 → Axios 拦截器自动处理
  • 密码怎么存? 明文肯定不行 → BCrypt 加密,防暴力破解

技术选型

后端: Java 17 + Spring Boot 3.3.2 + Spring Security 6.x + JWT + MyBatis + MySQL 8.0

前端: Vue 3 + Vite + Axios

为什么不用 Shiro?

两个原因:

  1. 后面要做微服务、OAuth2、网关鉴权,Spring Security 生态更好
  2. Spring Security 功能更强,方法级权限、SpEL 表达式都支持

是的,Spring Security 配置复杂点,但我会讲透为什么要这样配,不是照抄代码。

为什么要用双 Token

我刚开始做 JWT 的时候,就用一个 Token,设置 7 天有效期,简单省事。

但后来发现问题:

  1. Token 泄露了怎么办?7 天内都可以拿着这个 Token 访问,没办法让它失效
  2. 缩短有效期?那用户每天都要登录,体验太差

后来参考了若依的方案,用双 Token:

flowchart TB A[登录成功] --> B[生成双Token] B --> C[Access Token] B --> D[Refresh Token] C --> E[有效期: 15分钟] C --> F[用途: 访问API] D --> G[有效期: 7天] D --> H[用途: 刷新Token] D --> I[存数据库可撤销] F --> J{过期?} J -->|否| K[正常访问] J -->|是| L[用Refresh换新Token] L --> K style B fill:#87CEEB style C fill:#98FB98 style D fill:#FFB6C1 style K fill:#FFD700 style L fill:#FFA07A

设计思路:

  • Access Token 短命(15分钟): 即使泄露,损失窗口很小
  • Refresh Token 长效(7天): 存数据库,可以随时撤销
  • 自动刷新: 前端无感知,用户体验好

实际场景举例:

场景1: 用户改密码

  • 把该用户所有 Refresh Token 状态改成 REVOKED
  • 旧 Token 立即失效,必须重新登录

场景2: 检测到异常登录

  • 撤销该设备的 Refresh Token
  • 强制用户重新认证

场景3: 正常使用

  • Access Token 过期自动刷新
  • 用户一直在用,可以保持 7 天登录态

好处是:

  • Access Token 泄露了,15 分钟后就失效,损失可控
  • Refresh Token 可以单独撤销(存数据库),比如用户改密码后,把旧的 Refresh Token 全删掉
  • 用户无感知,Access Token 过期自动刷新

这就是我说的,平衡安全和体验。

JWT 长什么样

JWT 就是一个很长的字符串,用 . 分成三段:

flowchart LR A[JWT Token] --> B[Header] A --> C[Payload] A --> D[Signature] B --> B1[算法: HS256
类型: JWT] C --> C1[用户名
角色
过期时间] D --> D1[HMAC签名
防篡改] style A fill:#E0E0E0 style B fill:#87CEEB style C fill:#90EE90 style D fill:#FFB6C1

举个例子:

erlang 复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImp0aSI6IjEyMzQ1IiwiaWF0IjoxNzAxMjM0NTY3LCJleHAiOjE3MDEyMzU0NjcsImlzcyI6InN1cmZpbmctYXV0aCIsInR5cGUiOiJhY2Nlc3MifQ.abc123def456...

看着乱,其实就是 Base64 编码,解码后就是 JSON:

Header(头部):

json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

说明用的是 HMAC SHA-256 签名算法。

Payload(载荷):

json 复制代码
{
  "sub": "admin",
  "roles": ["admin"],
  "jti": "12345",
  "iat": 1701234567,
  "exp": 1701235467,
  "iss": "surfing-auth",
  "type": "access"
}

这里面:

  • sub: 用户名
  • roles: 角色列表(直接存 Token 里,不用每次查数据库)
  • jti: Token 的唯一 ID,用来追踪和撤销
  • iat: 签发时间
  • exp: 过期时间
  • type: 标记是 access 还是 refresh

Signature(签名):

scss 复制代码
HMACSHA256(
  base64(header) + "." + base64(payload),
  secret
)

签名的作用是防篡改。用户拿到 Token 后,可以自己解码看里面的内容(所以别放敏感信息),但改不了。一改签名就对不上,后端验证就会失败。

数据库怎么设计

认证系统涉及三张核心表:

用户表(sys_user):

sql 复制代码
CREATE TABLE sys_user (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(50) UNIQUE NOT NULL,
  password VARCHAR(100) NOT NULL,  -- BCrypt 加密后的密码
  nickname VARCHAR(50),
  status TINYINT DEFAULT 1,  -- 1启用 0禁用
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

角色表(sys_role):

sql 复制代码
CREATE TABLE sys_role (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  role_name VARCHAR(50),
  role_code VARCHAR(50) UNIQUE NOT NULL  -- 如: admin, user
);

用户角色关联表(sys_user_role):

sql 复制代码
CREATE TABLE sys_user_role (
  user_id BIGINT,
  role_id BIGINT,
  PRIMARY KEY (user_id, role_id)
);

Refresh Token 表(sys_refresh_token):

这个是重点,用来管理 Refresh Token 的生命周期:

sql 复制代码
CREATE TABLE sys_refresh_token (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  jti VARCHAR(36) UNIQUE NOT NULL,  -- JWT ID
  status VARCHAR(20) DEFAULT 'ACTIVE',  -- ACTIVE, REVOKED
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  revoked_at DATETIME,
  INDEX idx_user_id (user_id),
  INDEX idx_jti (jti),
  INDEX idx_user_status (user_id, status)
);

为什么要单独存 Refresh Token?

因为 JWT 一旦签发,没法主动让它失效。但我们可以通过数据库记录它的状态:

stateDiagram-v2 [*] --> ACTIVE: 创建Token ACTIVE --> REVOKED: 用户登出 ACTIVE --> REVOKED: 用户改密码 ACTIVE --> REVOKED: Token被刷新(旋转) REVOKED --> [*]: 过期删除 note right of ACTIVE 可以刷新 end note note right of REVOKED 拒绝刷新 end note
  • 用户改密码?把他所有的 Refresh Token 状态改成 REVOKED
  • 用户登出?撤销当前的 Refresh Token
  • Refresh Token 被用来刷新?旧的标记 REVOKED,生成新的

这样就实现了 JWT 的可控撤销。

后端核心代码讲解

1. JWT 生成和验证

我封装了一个 JwtService,专门处理 Token 的生成和解析:

java 复制代码
@Service
public class JwtService {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.access-ttl-minutes}")
    private long accessTtlMinutes;
    
    @Value("${jwt.refresh-ttl-days}")
    private long refreshTtlDays;
    
    // 生成 Access Token
    public String createAccessToken(String username, List<String> roleCodes) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + accessTtlMinutes * 60 * 1000);
        
        return Jwts.builder()
            .setSubject(username)
            .claim("roles", roleCodes)
            .claim("type", "access")
            .setId(UUID.randomUUID().toString())
            .setIssuedAt(now)
            .setExpiration(expiry)
            .setIssuer("surfing-auth")
            .signWith(SignatureAlgorithm.HS256, secret)
            .compact();
    }
    
    // 生成 Refresh Token
    public String createRefreshToken(Long userId, String username) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + refreshTtlDays * 24 * 3600 * 1000);
        String jti = UUID.randomUUID().toString();
        
        // 存数据库
        SysRefreshToken refreshToken = new SysRefreshToken();
        refreshToken.setUserId(userId);
        refreshToken.setJti(jti);
        refreshToken.setExpiresAt(expiry);
        refreshTokenMapper.insert(refreshToken);
        
        return Jwts.builder()
            .setSubject(username)
            .claim("userId", userId)
            .claim("type", "refresh")
            .setId(jti)
            .setIssuedAt(now)
            .setExpiration(expiry)
            .setIssuer("surfing-auth")
            .signWith(SignatureAlgorithm.HS256, secret)
            .compact();
    }
    
    // 解析 Token
    public Claims parseClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(secret)
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}

关键点:

  • secret 必须够长,至少 256 位,否则不安全
  • Refresh Token 的 jti 要存数据库,用来追踪状态
  • 角色信息直接放 Access Token 里,避免每次请求都查库

2. Spring Security 配置

这是最关键的部分,告诉 Spring Security 怎么做认证。

先看下 Spring Security 的过滤器链是怎么工作的:

flowchart LR A[请求] --> B[JWT过滤器] B --> C{有Token?} C -->|有| D[验证Token] C -->|无| E[继续] D --> F{验证通过?} F -->|是| G[设置认证信息] F -->|否| E G --> E E --> H{需要认证?} H -->|是且未认证| I[返回401] H -->|已认证| J[执行业务] style B fill:#87CEEB style D fill:#98FB98 style G fill:#90EE90 style J fill:#FFD700 style I fill:#FFA07A

关键配置项:

  • STATELESS: 完全无状态,不创建 Session
  • permitAll: 登录接口必须放行
  • 过滤器顺序: JWT 过滤器要在认证过滤器之前

代码实现:

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationTokenFilter jwtFilter;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF,因为是无状态 API
            .csrf(csrf -> csrf.disable())
            
            // 完全无状态,不创建 Session
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            
            // 配置哪些接口需要认证
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 登录接口放行
                .anyRequest().authenticated()  // 其他都需要认证
            )
            
            // 添加 JWT 过滤器
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            
            // 异常处理
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)  // 401
                .accessDeniedHandler(accessDeniedHandler)  // 403
            );
        
        return http.build();
    }
    
    // 密码加密器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

重点说几个:

STATELESS:这个配置很重要,告诉 Spring Security 不要创建 Session。传统的 Session 认证,每次请求都要查 Session,分布式部署还要做 Session 共享。用 JWT 就是为了无状态,所以必须设置成 STATELESS。

permitAll:登录接口必须放行,不然用户还没登录,怎么访问登录接口?这个我刚开始配置时忘了,结果陷入死循环,登录接口也要认证,认证又需要先登录。

过滤器顺序:JWT 过滤器必须放在 UsernamePasswordAuthenticationFilter 之前。因为我们要在 Spring Security 处理认证之前,先从 Token 里提取用户信息,设置到 SecurityContext。

3. JWT 过滤器

这个过滤器的作用是:拦截每个请求,从 Header 里提取 Token,验证后设置认证信息。

java 复制代码
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtService jwtService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain chain) 
            throws ServletException, IOException {
        
        // 1. 从 Header 提取 Token
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }
        
        String token = bearerToken.substring(7);
        
        try {
            // 2. 解析 Token
            Claims claims = jwtService.parseClaims(token);
            
            // 3. 验证类型(必须是 access)
            String type = claims.get("type", String.class);
            if (!"access".equals(type)) {
                chain.doFilter(request, response);
                return;
            }
            
            // 4. 提取用户信息
            String username = claims.getSubject();
            List<String> roles = claims.get("roles", List.class);
            
            // 5. 转换成 Spring Security 的权限对象
            List<GrantedAuthority> authorities = roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
            
            // 6. 创建认证对象
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(username, null, authorities);
            
            // 7. 设置到 SecurityContext(线程本地变量)
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
        } catch (Exception e) {
            // Token 无效或过期,不设置认证信息
            // 后续会被 AuthenticationEntryPoint 处理,返回 401
        }
        
        // 8. 继续过滤链
        chain.doFilter(request, response);
    }
}

为什么要继承 OncePerRequestFilter?

因为 Spring 的过滤器可能会被调用多次(转发、包含等场景),OncePerRequestFilter 保证每个请求只执行一次。

SecurityContextHolder 是什么?

它是 Spring Security 的核心,用 ThreadLocal 存储当前请求的认证信息。后续的业务代码可以通过它获取当前用户:

java 复制代码
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();

4. 登录接口

java 复制代码
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    @Autowired
    private AuthService authService;
    
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginRequest request) {
        LoginResponse response = authService.login(
            request.getUsername(), 
            request.getPassword()
        );
        return AjaxResult.success(response);
    }
    
    @PostMapping("/refresh")
    public AjaxResult refresh(@RequestBody RefreshRequest request) {
        RefreshResponse response = authService.refresh(request.getRefreshToken());
        return AjaxResult.success(response);
    }
    
    @PostMapping("/logout")
    public AjaxResult logout(@RequestBody LogoutRequest request) {
        authService.logout(request.getRefreshToken());
        return AjaxResult.success();
    }
}

AuthService 的核心逻辑:

java 复制代码
@Service
public class AuthService {
    
    @Autowired
    private SysUserMapper userMapper;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Autowired
    private JwtService jwtService;
    
    @Autowired
    private RefreshTokenMapper refreshTokenMapper;
    
    public LoginResponse login(String username, String password) {
        // 1. 查询用户
        SysUser user = userMapper.findByUsername(username);
        if (user == null) {
            throw new RuntimeException("用户名或密码错误");
        }
        
        // 2. 验证密码(BCrypt 会自动处理盐值)
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException("用户名或密码错误");
        }
        
        // 3. 查询角色
        List<String> roleCodes = userMapper.findRoleCodesByUsername(username);
        
        // 4. 生成 Token
        String accessToken = jwtService.createAccessToken(username, roleCodes);
        String refreshToken = jwtService.createRefreshToken(user.getId(), username);
        
        // 5. 返回
        LoginResponse response = new LoginResponse();
        response.setAccessToken(accessToken);
        response.setRefreshToken(refreshToken);
        return response;
    }
    
    public RefreshResponse refresh(String refreshToken) {
        // 1. 解析 Token
        Claims claims = jwtService.parseClaims(refreshToken);
        
        // 2. 验证类型
        String type = claims.get("type", String.class);
        if (!"refresh".equals(type)) {
            throw new RuntimeException("无效的 Refresh Token");
        }
        
        // 3. 验证数据库状态
        String jti = claims.getId();
        SysRefreshToken dbToken = refreshTokenMapper.findByJti(jti);
        if (dbToken == null || !"ACTIVE".equals(dbToken.getStatus())) {
            throw new RuntimeException("Refresh Token 已失效");
        }
        
        // 4. 撤销旧 Token(Token 旋转机制)
        refreshTokenMapper.revokeByJti(jti);
        
        // 5. 查询角色
        String username = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        List<String> roleCodes = userMapper.findRoleCodesByUsername(username);
        
        // 6. 生成新 Token
        String newAccessToken = jwtService.createAccessToken(username, roleCodes);
        String newRefreshToken = jwtService.createRefreshToken(userId, username);
        
        // 7. 返回
        RefreshResponse response = new RefreshResponse();
        response.setAccessToken(newAccessToken);
        response.setRefreshToken(newRefreshToken);
        return response;
    }
    
    public void logout(String refreshToken) {
        Claims claims = jwtService.parseClaims(refreshToken);
        String jti = claims.getId();
        refreshTokenMapper.revokeByJti(jti);
    }
}

关键点:

BCrypt 密码加密:BCrypt 是专门用来加密密码的算法,慢速设计,防暴力破解。每次加密同一个密码,结果都不一样,因为内部有随机盐值。验证时用 matches 方法,它会自动提取盐值比对。

Token 旋转机制:每次刷新,旧的 Refresh Token 立即失效,生成新的。这样即使 Refresh Token 泄露,也只能用一次。如果攻击者用旧 Token 再次刷新,会被检测到,可以撤销该用户所有 Token。

前端怎么处理

前端的核心是 Axios 拦截器,自动处理 Token 刷新。

1. 存储 Token

javascript 复制代码
// authStore.js
export const authStore = {
  getAccessToken() {
    return localStorage.getItem('access_token')
  },
  
  getRefreshToken() {
    return localStorage.getItem('refresh_token')
  },
  
  setTokens(accessToken, refreshToken) {
    localStorage.setItem('access_token', accessToken)
    localStorage.setItem('refresh_token', refreshToken)
  },
  
  clear() {
    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
  }
}

为什么用 localStorage 不用 Cookie?

  • JWT 不依赖 Cookie,移动端友好
  • 跨域时 Cookie 比较麻烦
  • localStorage 前端控制更灵活

但要注意 XSS 攻击,所以前端要做好输入验证和输出转义。

2. 请求拦截器

javascript 复制代码
// 请求拦截:自动带上 Token
http.interceptors.request.use(
  config => {
    const token = authStore.getAccessToken()
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    return config
  },
  error => Promise.reject(error)
)

3. 响应拦截器(自动刷新 Token)

这是最复杂的部分,要处理并发请求时的刷新。

先看下并发场景的问题:

sequenceDiagram participant A as 请求A participant B as 请求B participant C as 请求C participant S as 刷新接口 Note over A,C: 同时发起3个请求 A->>S: Token过期,发起刷新 B->>S: Token过期,发起刷新 C->>S: Token过期,发起刷新 S-->>A: 新Token1 S-->>B: 新Token2(Token1已失效) S-->>C: 新Token3(Token2已失效) Note over A,C: 结果:只有最后一个有效,前面的白刷新了

所以需要单飞控制,只发起一次刷新:

sequenceDiagram participant A as 请求A participant B as 请求B participant C as 请求C participant Q as 请求队列 participant S as 刷新接口 Note over A,C: 同时发起3个请求 A->>S: 设置刷新标志,发起刷新 B->>Q: 加入等待队列 C->>Q: 加入等待队列 S-->>A: 返回新Token A->>Q: 通知队列 Q-->>B: 使用新Token重试 Q-->>C: 使用新Token重试 Note over A,C: 结果:只刷新一次,所有请求都成功

代码实现:

javascript 复制代码
let isRefreshing = false  // 是否正在刷新
let failedQueue = []  // 失败的请求队列

// 处理队列中的请求
function processQueue(error, token = null) {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error)
    } else {
      prom.resolve(token)
    }
  })
  failedQueue = []
}

// 响应拦截:401 自动刷新
http.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config
    
    // 如果是 401 且不是刷新接口本身
    if (error.response?.status === 401 && !originalRequest._retry) {
      
      // 如果正在刷新,把请求加入队列
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        }).then(token => {
          originalRequest.headers['Authorization'] = `Bearer ${token}`
          return http(originalRequest)
        })
      }
      
      originalRequest._retry = true
      isRefreshing = true
      
      const refreshToken = authStore.getRefreshToken()
      if (!refreshToken) {
        // 没有 Refresh Token,跳转登录
        router.push('/login')
        return Promise.reject(error)
      }
      
      try {
        // 刷新 Token
        const resp = await http.post('/api/auth/refresh', { refreshToken })
        const { accessToken, refreshToken: newRefreshToken } = resp.data.data
        
        // 更新存储
        authStore.setTokens(accessToken, newRefreshToken)
        
        // 处理队列
        processQueue(null, accessToken)
        
        // 重试原请求
        originalRequest.headers['Authorization'] = `Bearer ${accessToken}`
        return http(originalRequest)
        
      } catch (err) {
        // 刷新失败,清空 Token,跳转登录
        processQueue(err, null)
        authStore.clear()
        router.push('/login')
        return Promise.reject(err)
        
      } finally {
        isRefreshing = false
      }
    }
    
    return Promise.reject(error)
  }
)

关键点:

单飞控制:isRefreshing 标志位保证同一时间只有一个刷新请求。如果多个接口同时返回 401,只发起一次刷新,其他请求等待。

请求队列:等待刷新的请求放到 failedQueue,刷新成功后统一重试。

_retry 标记:防止刷新失败后再次进入拦截器,死循环。

4. 登录组件

vue 复制代码
<template>
  <div class="login-page">
    <form @submit.prevent="handleLogin">
      <input v-model="username" placeholder="用户名" />
      <input v-model="password" type="password" placeholder="密码" />
      <button type="submit">登录</button>
    </form>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/utils/http'
import { authStore } from '@/stores/auth'

const router = useRouter()
const username = ref('')
const password = ref('')

async function handleLogin() {
  try {
    const resp = await http.post('/api/auth/login', {
      username: username.value,
      password: password.value
    })
    
    const { accessToken, refreshToken } = resp.data.data
    authStore.setTokens(accessToken, refreshToken)
    
    router.push('/dashboard')
  } catch (err) {
    alert('登录失败:' + err.message)
  }
}
</script>

踩过的坑

1. CORS 跨域问题

前后端分离,肯定会遇到跨域。后端配置:

java 复制代码
@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");  // 开发环境
        // config.addAllowedOrigin("https://yourdomain.com");  // 生产环境
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

生产环境记得把 * 改成具体域名,不然不安全。

2. JWT 密钥太短

我一开始用的密钥是 "mySecret",结果报错:

vbnet 复制代码
The signing key's size is 64 bits which is not secure enough for the HS256 algorithm.

HS256 算法要求密钥至少 256 位,也就是 32 个字节。后来改成:

yaml 复制代码
jwt:
  secret: W3jv9u2yLq8Zr1Pd5Xs6Cg0NmBvQkRtUyHaJeKpLxMnOpQrStUvWxYz12345678

用随机字符串生成,够长够复杂。

3. 前端刷新 Token 时的并发问题

最开始没做单飞控制,多个接口同时 401,就发起多个刷新请求。结果:

  • 第一个刷新成功,拿到新 Token
  • 第二个刷新也成功,又拿到新 Token
  • 第一个新 Token 还没用,就被第二个撤销了
  • 用户又要重新登录

后来加了 isRefreshing 标志位和请求队列,解决了。

4. Refresh Token 没存数据库

刚开始我只把 Refresh Token 发给前端,没存数据库。结果用户改密码后,旧 Token 还能用,没法撤销。

后来加了 sys_refresh_token 表,每次刷新都检查数据库状态,解决了。

安全建议

做认证系统,安全最重要:

  1. 密码加密:必须用 BCrypt,别用 MD5,太容易被破解
  2. HTTPS:生产环境必须 HTTPS,否则 Token 明文传输
  3. JWT 密钥:够长够复杂,别泄露,定期更换
  4. 敏感信息别放 Payload:JWT 可以被解码,别放密码、身份证号这些
  5. XSS 防护:前端做好输入验证和输出转义
  6. CORS 限制 :生产环境别用 *,只允许信任的域名
  7. Token 有效期:Access Token 别太长,15 分钟合适
  8. Refresh Token 旋转:每次刷新生成新的,撤销旧的

性能优化

虽然 JWT 无状态,性能已经不错了,但还能优化:

  1. 数据库索引:username、jti 字段加索引,查询快
  2. 角色信息缓存:直接放 Access Token 里,不用每次查库
  3. 连接池配置:HikariCP 配置合理的最大连接数
  4. 前端缓存用户信息:不用每次都请求 /profile 接口

总结

这套认证系统,虽然代码不多,但把核心问题都解决了:

  • JWT 双 Token 机制,平衡安全和体验
  • Token 旋转,限制泄露风险
  • Spring Security 无状态配置,适合分布式
  • 前端自动刷新,用户无感知
  • BCrypt 密码加密,防暴力破解

代码已经开源,地址在文章开头,保证能跑起来。

如果你在实现过程中遇到问题,欢迎在评论区留言,我会回复。

下一篇打算写:Spring Cloud Gateway 统一鉴权。微服务场景下,在网关统一验证 JWT,下游服务就不用重复验证了。

如果你对这个系列感兴趣,建议关注我,后续文章会第一时间推送。

评论区见。

相关推荐
invicinble2 小时前
关于maven的全域理解
java·spring boot·maven
向上的车轮2 小时前
从“能用”到“好用”:基于 DevUI 构建高维护性、多端自适应的企业级前端架构实践
前端·架构
JavaEdge.2 小时前
零距离拆解银行司库系统(TMS)的微服务设计与实践
微服务·云原生·架构
赵渝强老师2 小时前
【赵渝强老师】国产金仓数据库的体系架构
数据库·oracle·架构
汤姆yu2 小时前
基于SpringBoot的人工智能学习网站
spring boot·后端·学习·人工智能学习
听风吟丶2 小时前
日志中心实战:ELK Stack 构建微服务智能日志管理体系
elk·微服务·架构
拾忆,想起2 小时前
Dubbo 监控数据采集全链路实战:构建微服务可观测性体系
前端·微服务·云原生·架构·dubbo
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue服装商城系统(源码+数据库+文档)
数据库·vue.js·spring boot
JIngJaneIL2 小时前
基于Java在线考试管理系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot