概述
在前后端分离架构下,传统的基于 Session 和重定向的安全模型变得笨重。
Spring Security 与 JWT(JSON Web Token)的结合,加上 OAuth2 的令牌设计思想,可以轻量、无状态地完成认证与授权。
本文通过实际代码,演示如何构建登录接口签发 Access Token 和 Refresh Token,并实现刷新令牌接口,同时用 JWT 过滤器保护资源。
纲要
- 核心概念
JWT双令牌机制:Access Token与Refresh TokenSpring Security的UserDetails、PasswordEncoder- 自定义
JWT工具类:签发、验证、解析 - 自定义
OncePerRequestFilter:JwtFilter
- 核心流程
- 登录:接收用户名密码 → 验证 → 返回令牌对
- 刷新:携带即将过期的
Access Token与有效Refresh Token→ 验证 → 签发新令牌对 - 资源保护:
JwtFilter校验请求头中的Authorization
- 涉及代码模块
- 实体:
LoginRequest、TokenResponse - 安全配置:
SecurityConfig - 过滤器:
JwtFilter - 控制器:
AuthController - 服务:
UserService、JwtService - 工具类:
JwtUtil
- 实体:
项目结构
src/main/java/com/example/demo/
├── config/
│ └── SecurityConfig.java
├── controller/
│ └── AuthController.java
├── entity/
│ ├── LoginRequest.java
│ └── TokenResponse.java
├── filter/
│ └── JwtFilter.java
├── service/
│ ├── JwtService.java
│ └── UserService.java
├── util/
│ └── JwtUtil.java
└── DemoApplication.java
双令牌设计思路
Access Token 为短期令牌,用于访问受保护资源;Refresh Token 为长期令牌,用于在 Access Token 过期后换取新的令牌对。这种设计兼顾安全与体验。
核心代码实现
依赖与配置
在 pom.xml 中引入 jjwt 和 Spring Security:
<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>
JWT 工具类
JwtUtil 负责生成、验证和解析令牌。支持 Access Token 和 Refresh Token,并允许在验证时选择是否将过期视为非法。
package com.example.demo.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtil {
private final SecretKey accessKey;
private final SecretKey refreshKey;
private final long accessExpiration;
private final long refreshExpiration;
public JwtUtil(@Value("${jwt.access-secret}") String accessSecret,
@Value("${jwt.refresh-secret}") String refreshSecret,
@Value("${jwt.access-expiration}") long accessExpiration,
@Value("${jwt.refresh-expiration}") long refreshExpiration) {
this.accessKey = Keys.hmacShaKeyFor(accessSecret.getBytes(StandardCharsets.UTF_8));
this.refreshKey = Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = accessExpiration;
this.refreshExpiration = refreshExpiration;
}
public String createAccessToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessExpiration))
.signWith(accessKey, SignatureAlgorithm.HS512)
.compact();
}
public String createRefreshToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshExpiration))
.signWith(refreshKey, SignatureAlgorithm.HS512)
.compact();
}
public Claims parseAccessToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public Claims parseRefreshToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(refreshKey)
.build()
.parseClaimsJws(token)
.getBody();
}
// 验证Access Token,可控制是否将过期视为非法
public boolean validateAccessToken(String token, boolean expiredAsInvalid) {
return validateToken(token, accessKey, expiredAsInvalid);
}
public boolean validateRefreshToken(String token, boolean expiredAsInvalid) {
return validateToken(token, refreshKey, expiredAsInvalid);
}
private boolean validateToken(String token, SecretKey key, boolean expiredAsInvalid) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
return !expiredAsInvalid;
} catch (JwtException e) {
return false;
}
}
public Claims getClaimsFromRefreshToken(String refreshToken) {
return parseRefreshToken(refreshToken);
}
}
实体类
// LoginRequest.java
package com.example.demo.entity;
import lombok.Data;
@Data
public class LoginRequest {
private String username;
private String password;
}
// TokenResponse.java
package com.example.demo.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenResponse {
private String accessToken;
private String refreshToken;
}
用户实体与 UserDetails 实现
假设已有 User 实体实现 UserDetails。
package com.example.demo.entity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
public class User implements UserDetails {
private Long id;
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
// 省略 getter/setter 和构造函数
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() { return password; }
@Override
public String getUsername() { return username; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
}
UserService
负责从数据库加载用户并验证密码。
package com.example.demo.service;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User authenticate(String username, String rawPassword) {
return userRepository.findByUsername(username)
.filter(user -> passwordEncoder.matches(rawPassword, user.getPassword()))
.orElseThrow(() -> new BadCredentialsException("用户名或密码错误"));
}
}
JwtService
负责构建双令牌,以及通过刷新令牌重建 Access Token。
package com.example.demo.service;
import com.example.demo.entity.User;
import com.example.demo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtUtil jwtUtil;
public TokenResponse createTokenPair(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", user.getId());
claims.put("authorities", user.getAuthorities());
String accessToken = jwtUtil.createAccessToken(claims, user.getUsername());
String refreshToken = jwtUtil.createRefreshToken(claims, user.getUsername());
return new TokenResponse(accessToken, refreshToken);
}
public TokenResponse refreshTokenPair(String refreshToken) {
// 从Refresh Token中提取claims,重新生成Access Token,同时返回原Refresh Token(或生成新的)
Claims claims = jwtUtil.getClaimsFromRefreshToken(refreshToken);
String newAccessToken = jwtUtil.createAccessToken(claims, claims.getSubject());
// 此处为了简化,直接复用原Refresh Token,生产环境建议轮换
return new TokenResponse(newAccessToken, refreshToken);
}
}
登录与刷新接口
package com.example.demo.controller;
import com.example.demo.entity.LoginRequest;
import com.example.demo.entity.TokenResponse;
import com.example.demo.service.JwtService;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final JwtService jwtService;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
var user = userService.authenticate(request.getUsername(), request.getPassword());
TokenResponse tokenPair = jwtService.createTokenPair(user);
return ResponseEntity.ok(tokenPair);
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@RequestHeader("Authorization") String authHeader,
@RequestParam("refreshToken") String refreshToken) {
// 提取Bearer token,去除前缀
String accessToken = authHeader.startsWith("Bearer ") ? authHeader.substring(7) : authHeader;
if (!jwtService.validateAccessTokenIgnoreExpiration(accessToken)) {
throw new AuthenticationException("无效的Access Token") {};
}
if (!jwtService.validateRefreshToken(refreshToken)) {
throw new AuthenticationException("无效的Refresh Token") {};
}
TokenResponse newTokens = jwtService.refreshTokenPair(refreshToken);
return ResponseEntity.ok(newTokens);
}
}
注意:JwtService 中补充验证方法:
public boolean validateAccessTokenIgnoreExpiration(String token) {
return jwtUtil.validateAccessToken(token, false);
}
public boolean validateRefreshToken(String token) {
return jwtUtil.validateRefreshToken(token, true);
}
JWT 过滤器
package com.example.demo.filter;
import com.example.demo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
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;
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final 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);
if (jwtUtil.validateAccessToken(token, true)) {
Claims claims = jwtUtil.parseAccessToken(token);
String username = claims.getSubject();
// 构建Authentication对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
安全配置
package com.example.demo.config;
import com.example.demo.filter.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/auth/login", "/auth/refresh").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
流程示意
登录流程
刷新令牌流程
API 测试
启动应用后,使用 HTTP 客户端测试:
# 登录
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 响应示例
{"accessToken":"eyJ...","refreshToken":"eyJ..."}
# 访问受保护资源
curl http://localhost:8080/api/user \
-H "Authorization: Bearer eyJ..."
# 刷新令牌
curl -X POST "http://localhost:8080/auth/refresh?refreshToken=eyJ..." \
-H "Authorization: Bearer eyJ..."
关键设计说明
- 双密钥签名:Access Token 和 Refresh Token 使用不同的签名密钥,增强安全性。
- 过期策略:刷新接口接受已过期的 Access Token,但要求 Refresh Token 有效,仅校验签名和结构。
- 过滤器拦截:
JwtFilter仅解析 Access Token,未对 Refresh Token 做处理,职责清晰。
总结
通过 Spring Security 与 JWT 的定制,我们实现了无状态的认证与令牌刷新机制。
登录接口返回双令牌,刷新接口基于 Refresh Token 重建 Access Token,过滤器透明保护资源,完全适合前后端分离架构。
该方案可快速集成到现有项目中,并按需扩展角色与权限。

349

被折叠的 条评论
为什么被折叠?



