Java框架快速入门: Spring Security+OAuth2之JWT登录与刷新令牌实战

概述

在前后端分离架构下,传统的基于 Session 和重定向的安全模型变得笨重。

Spring Security 与 JWT(JSON Web Token)的结合,加上 OAuth2 的令牌设计思想,可以轻量、无状态地完成认证与授权。

本文通过实际代码,演示如何构建登录接口签发 Access Token 和 Refresh Token,并实现刷新令牌接口,同时用 JWT 过滤器保护资源。

纲要

  • 核心概念
    • JWT 双令牌机制:Access TokenRefresh Token
    • Spring SecurityUserDetailsPasswordEncoder
    • 自定义 JWT 工具类:签发、验证、解析
    • 自定义 OncePerRequestFilterJwtFilter
  • 核心流程
    • 登录:接收用户名密码 → 验证 → 返回令牌对
    • 刷新:携带即将过期的 Access Token 与有效 Refresh Token → 验证 → 签发新令牌对
    • 资源保护:JwtFilter 校验请求头中的 Authorization
  • 涉及代码模块
    • 实体:LoginRequestTokenResponse
    • 安全配置:SecurityConfig
    • 过滤器:JwtFilter
    • 控制器:AuthController
    • 服务:UserServiceJwtService
    • 工具类: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 TokenRefresh 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();
    }
}

流程示意

登录流程

JwtServiceUserServiceAuthControllerClientJwtServiceUserServiceAuthControllerClientPOST /auth/login {username, password}authenticate(username, rawPassword)UserDetailscreateTokenPair(user)TokenResponse(access, refresh)200 OK {accessToken, refreshToken}

刷新令牌流程

JwtUtilJwtServiceAuthControllerClientJwtUtilJwtServiceAuthControllerClientPOST /auth/refreshHeader: Authorization Bearer accessParam: refreshTokenvalidateAccessToken(access, expiredAsInvalid=false)true/falsevalidateRefreshToken(refresh, expiredAsInvalid=true)true/falserefreshTokenPair(refreshToken)getClaimsFromRefreshToken(refresh)ClaimscreateAccessToken(claims, subject)newAccessTokenTokenResponse(newAccess, refresh)200 OK {accessToken, refreshToken}

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,过滤器透明保护资源,完全适合前后端分离架构。

该方案可快速集成到现有项目中,并按需扩展角色与权限。

评论
成就一亿技术人!
拼手气红包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、付费专栏及课程。

余额充值