从零搭建微服务项目Pro(第6-1章——Spring Security+JWT实现用户鉴权访问与token刷新)
前言:
在现代的微服务架构中,用户鉴权和访问控制是非常重要的一部分。Spring Security 是 Spring 生态中用于处理安全性的强大框架,而 JWT(JSON Web Token)则是一种轻量级的、自包含的令牌机制,广泛用于分布式系统中的用户身份验证和信息交换。
本章实现了一个门槛极低的Spring Security+JWT实现用户鉴权访问与token刷新demo项目。具体效果可看测试部分内容。
只需要创建一个spring-boot项目,导入下文pom依赖以及项目结构如下,将各类的内容粘贴即可。(不需要nacos、数据库等配置,也不需要动yml配置文件。且用ai生成了html网页,减去了用postman测试接口的麻烦)。
也可直接选择下载项目源码,链接如下:
wlf728050719/SpringCloudPro6-1https://github.com/wlf728050719/SpringCloudPro6-1
以及本专栏会持续更新微服务项目,每一章的项目都会基于前一章项目进行功能的完善,欢迎小伙伴们关注!同时如果只是对单章感兴趣也不用从头看,只需下载前一章项目即可,每一章都会有前置项目准备部分,跟着操作就能实现上一章的最终效果,当然如果是一直跟着做可以直接跳过这一部分。专栏目录链接如下,其中Base篇为基础微服务搭建,Pro篇为复杂模块实现。
从零搭建微服务项目(全)-CSDN博客https://blog.csdn.net/wlf2030/article/details/145799620
依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.bit</groupId>
<artifactId>Pro6_1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Pro6_1</name>
<description>Pro6_1</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Authorization Server (Spring Boot 3.x 推荐) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
核心:
工具类:
SaltUtil,用于生成随机盐。(不过由于本章没有将用户账号密码等信息存放在数据库,在代码中写死用户信息,所以这个工具类实际没有作用)。
package cn.bit.pro6_1.core.util;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 盐值工具类
* @author muze
*/
public class SaltUtil {
/**
* 生成盐值
* @return 盐值
*/
public static String generateSalt() {
// 声明并初始化长度为16的字节数组,用于存储随机生成的盐值
byte[] saltBytes = new byte[16];
// 创建SecureRandom实例,用于生成强随机数
SecureRandom secureRandom = new SecureRandom();
// 将随机生成的盐值填充到字节数组
secureRandom.nextBytes(saltBytes);
// 将字节数组编码为Base64格式的字符串后返回
return Base64.getEncoder().encodeToString(saltBytes);
}
}
JwtUtil,用于生成和验证token。(密钥为了不写配置文件就直接写代码里了,以及设置access token和refresh token失效时间为10s和20s方便测试)
package cn.bit.pro6_1.core.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtUtil {
private String secret = "wlf18086270070";
private final Long accessTokenExpiration = 10L; // 1 小时
private final Long refreshTokenExpiration = 20L; // 7 天
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername(), accessTokenExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername(), refreshTokenExpiration);
}
private String createToken(Map<String, Object> claims, String subject, Long expiration) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public Date getAccessTokenExpiration() {
return new Date(System.currentTimeMillis() + accessTokenExpiration * 1000);
}
public Date getRefreshTokenExpiration() {
return new Date(System.currentTimeMillis() + refreshTokenExpiration * 1000);
}
}
SecurityUtils,方便全局接口获取请求的用户信息。
package cn.bit.pro6_1.core.util;
import lombok.experimental.UtilityClass;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
/**
* 安全工具类
*
* @author L.cm
*/
@UtilityClass
public class SecurityUtils {
/**
* 获取Authentication
*/
public Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
/**
* 获取用户
* @param authentication
* @return HnqzUser
* <p>
*/
public User getUser(Authentication authentication) {
if (authentication == null || authentication.getPrincipal() == null) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof User) {
return (User) principal;
}
return null;
}
/**
* 获取用户
*/
public User getUser() {
Authentication authentication = getAuthentication();
return getUser(authentication);
}
}
用户加载:
UserService,模拟数据库中有admin和buyer两个用户密码分别为123456和654321
package cn.bit.pro6_1.core.service;
import cn.bit.pro6_1.core.util.SaltUtil;
import cn.bit.pro6_1.pojo.UserPO;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
@AllArgsConstructor
public class UserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//模拟通过username通过feign拿取到了对应用户
UserPO user;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
if (username.equals("admin")) {
user = new UserPO();
user.setUsername(username);
user.setPassword(encoder.encode("123456"));
user.setRoles("ROLE_ADMIN");
user.setSalt(SaltUtil.generateSalt());
}
else if(username.equals("buyer")){
user = new UserPO();
user.setUsername(username);
user.setPassword(encoder.encode("654321"));
user.setRoles("ROLE_BUYER");
user.setSalt(SaltUtil.generateSalt());
}
else
throw new UsernameNotFoundException("not found");
//模拟通过role从数据库字典项中获取对应角色权限,暂不考虑多角色用户
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(user.getRoles()));//先加入用户角色
//加入用户对应角色权限
if(user.getRoles().contains("ROLE_ADMIN"))
{
authorities.add(new SimpleGrantedAuthority("READ"));
authorities.add(new SimpleGrantedAuthority("WRITE"));
}
else if(user.getRoles().contains("ROLE_BUYER"))
{
authorities.add(new SimpleGrantedAuthority("READ"));
}
return new User(user.getUsername(), user.getPassword(),authorities);
}
}
过滤器:
JwtRequestFilter,用户鉴权并将鉴权信息放secruity全局上下文
package cn.bit.pro6_1.core.filter;
import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@AllArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {
private JwtUtil jwtUtil;
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
username = jwtUtil.extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
配置类:
CorsConfig,跨域请求配置。(需要设置为自己前端运行的端口号)
package cn.bit.pro6_1.core.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:63342")); // 明确列出允许的域名
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); // 允许的请求方法
configuration.setAllowedHeaders(List.of("*")); // 允许的请求头
configuration.setAllowCredentials(true); // 允许携带凭证(如 Cookie)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 对所有路径生效
return source;
}
}
ResourceServerConfig,资源服务器配置。配置鉴权过滤器链,以及退出登录处理逻辑。在登录认证和刷新token时不进行access token校验,其余接口均进行token校验。这里需要将jwt的过滤器放在logout的过滤器前,否则logout无法获取secruity上下文中的用户信息,报空指针错误,从而无法做后续比如清除redis中token,日志记录等操作。
package cn.bit.pro6_1.core.config;
import cn.bit.pro6_1.core.filter.JwtRequestFilter;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.cors.CorsConfigurationSource;
import jakarta.servlet.http.HttpServletResponse;
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class ResourceServerConfig {
private final JwtRequestFilter jwtRequestFilter;
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF
.authorizeHttpRequests(auth -> auth
.requestMatchers("/authenticate", "/refresh-token").permitAll() // 允许匿名访问
.requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN 角色可访问
.requestMatchers("/buyer/**").hasRole("BUYER") // BUYER 角色可访问
.anyRequest().authenticated() // 其他请求需要认证
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
)
.logout(logout -> logout
.logoutUrl("/auth/logout") // 退出登录的 URL
.addLogoutHandler(logoutHandler()) // 自定义退出登录处理逻辑
.logoutSuccessHandler(logoutSuccessHandler()) // 退出登录成功后的处理逻辑
.invalidateHttpSession(true) // 使 HTTP Session 失效
.deleteCookies("JSESSIONID") // 删除指定的 Cookie
)
.addFilterBefore(jwtRequestFilter, LogoutFilter.class); // 添加 JWT 过滤器
return http.build();
}
@Bean
public LogoutHandler logoutHandler() {
return (request, response, authentication) -> {
if (authentication != null) {
// 用户已认证,执行正常的登出逻辑
System.out.println("User logged out: " + authentication.getName());
// 这里可以添加其他逻辑,例如记录日志、清理资源等
} else {
// 用户未认证,处理未登录的情况
System.out.println("Logout attempt without authentication");
// 可以选择记录日志或执行其他操作
}
};
}
@Bean
public LogoutSuccessHandler logoutSuccessHandler() {
return (request, response, authentication) -> {
// 退出登录成功后的逻辑,例如返回 JSON 响应
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("Logout successful");
};
}
}
Pojo:
封装登录请求和响应,以及用户实体类
package cn.bit.pro6_1.pojo;
import lombok.Data;
@Data
public class LoginRequest {
private String username;
private String password;
}
package cn.bit.pro6_1.pojo;
import lombok.Data;
import java.util.Date;
@Data
public class LoginResponse {
private String accessToken;
private String refreshToken;
private Date accessTokenExpires;
private Date refreshTokenExpires;
}
package cn.bit.pro6_1.pojo;
import lombok.Data;
@Data
public class UserPO {
private Integer id;
private String username;
private String password;
private String roles;
private String salt;
}
接口:
全局异常抓取
package cn.bit.pro6_1.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import io.jsonwebtoken.ExpiredJwtException;
import java.nio.file.AccessDeniedException;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 全局异常.
* @param e the e
* @return R
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public String handleGlobalException(Exception e) {
log.error("全局异常信息 ex={}", e.getMessage(), e);
return e.getLocalizedMessage();
}
/**
* AccessDeniedException
* @param e the e
* @return R
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public String handleAccessDeniedException(AccessDeniedException e) {
log.error("拒绝授权异常信息 ex={}", e.getLocalizedMessage(),e);
return e.getLocalizedMessage();
}
/**
*
* @param e the e
* @return R
*/
@ExceptionHandler(ExpiredJwtException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public String handleExpiredJwtException(ExpiredJwtException e) {
log.error("Token过期 ex={}", e.getLocalizedMessage(),e);
return e.getLocalizedMessage();
}
}
登录接口
package cn.bit.pro6_1.controller;
import cn.bit.pro6_1.pojo.LoginRequest;
import cn.bit.pro6_1.pojo.LoginResponse;
import cn.bit.pro6_1.core.service.UserService;
import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
@RestController
@RequestMapping("/authenticate")
@AllArgsConstructor
public class AuthenticationController {
private final JwtUtil jwtUtil;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@PostMapping
public ResponseEntity<LoginResponse> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {
// 生成 Access Token 和 Refresh Token
UserDetails userDetails = userService.loadUserByUsername(loginRequest.getUsername());
if(!passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) {
throw new RuntimeException("密码错误");
}
String accessToken = jwtUtil.generateAccessToken(userDetails);
String refreshToken = jwtUtil.generateRefreshToken(userDetails);
// 获取 Token 过期时间
Date accessTokenExpires = jwtUtil.getAccessTokenExpiration();
Date refreshTokenExpires = jwtUtil.getRefreshTokenExpiration();
// 返回 Token 和过期时间
LoginResponse loginResponse = new LoginResponse();
loginResponse.setAccessToken(accessToken);
loginResponse.setRefreshToken(refreshToken);
loginResponse.setAccessTokenExpires(accessTokenExpires);
loginResponse.setRefreshTokenExpires(refreshTokenExpires);
return ResponseEntity.ok(loginResponse);
}
}
access token刷新接口
package cn.bit.pro6_1.controller;
import cn.bit.pro6_1.pojo.LoginRequest;
import cn.bit.pro6_1.pojo.LoginResponse;
import cn.bit.pro6_1.core.service.UserService;
import cn.bit.pro6_1.core.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
@RestController
@RequestMapping("/authenticate")
@AllArgsConstructor
public class AuthenticationController {
private final JwtUtil jwtUtil;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
@PostMapping
public ResponseEntity<LoginResponse> createAuthenticationToken(@RequestBody LoginRequest loginRequest) {
// 生成 Access Token 和 Refresh Token
UserDetails userDetails = userService.loadUserByUsername(loginRequest.getUsername());
if(!passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) {
throw new RuntimeException("密码错误");
}
String accessToken = jwtUtil.generateAccessToken(userDetails);
String refreshToken = jwtUtil.generateRefreshToken(userDetails);
// 获取 Token 过期时间
Date accessTokenExpires = jwtUtil.getAccessTokenExpiration();
Date refreshTokenExpires = jwtUtil.getRefreshTokenExpiration();
// 返回 Token 和过期时间
LoginResponse loginResponse = new LoginResponse();
loginResponse.setAccessToken(accessToken);
loginResponse.setRefreshToken(refreshToken);
loginResponse.setAccessTokenExpires(accessTokenExpires);
loginResponse.setRefreshTokenExpires(refreshTokenExpires);
return ResponseEntity.ok(loginResponse);
}
}
admin
package cn.bit.pro6_1.controller;
import cn.bit.pro6_1.core.util.SecurityUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin")
public class AdminController {
@GetMapping("/info")
@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色可以访问
public String adminInfo() {
User user = SecurityUtils.getUser();
System.out.println(user.getUsername());
return "This is admin info. Only ADMIN can access this.";
}
@GetMapping("/manage")
@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 角色可以访问
public String adminManage() {
return "This is admin management. Only ADMIN can access this.";
}
}
buyer
package cn.bit.pro6_1.controller;
import cn.bit.pro6_1.core.util.SecurityUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/buyer")
public class BuyerController {
@GetMapping("/info")
@PreAuthorize("hasRole('BUYER')") // 只有 BUYER 角色可以访问
public String buyerInfo() {
User user = SecurityUtils.getUser();
System.out.println(user.getUsername());
return "This is buyer info. Only BUYER can access this.";
}
@GetMapping("/order")
@PreAuthorize("hasRole('BUYER')") // 只有 BUYER 角色可以访问
public String buyerOrder() {
return "This is buyer order. Only BUYER can access this.";
}
}
前端:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>权限控制测试</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: auto;
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1, h2 {
color: #333;
}
label {
display: block;
margin: 10px 0 5px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #28a745;
color: white;
padding: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
width: 100%;
}
button:hover {
background-color: #218838;
}
.result {
margin-top: 20px;
}
.error {
color: red;
}
.logout-button {
background-color: #dc3545; /* 红色按钮 */
margin-top: 10px;
}
.logout-button:hover {
background-color: #c82333;
}
</style>
</head>
<body>
<div class="container">
<h1>登录</h1>
<form id="loginForm">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
<button type="submit">登录</button>
</form>
<div class="result" id="loginResult"></div>
<h2>Token 失效倒计时</h2>
<div id="accessTokenCountdown"></div>
<div id="refreshTokenCountdown"></div>
<h2>测试接口</h2>
<button onclick="testAdminInfo()">测试 /admin/info</button>
<button onclick="testBuyerInfo()">测试 /buyer/info</button>
<!-- 退出按钮 -->
<button class="logout-button" onclick="logout()">退出登录</button>
<div class="result" id="apiResult"></div>
</div>
<script>
let accessToken = '';
let refreshToken = '';
let accessTokenExpires;
let refreshTokenExpires;
let accessTokenCountdownInterval;
let refreshTokenCountdownInterval;
document.getElementById('loginForm').addEventListener('submit', async (event) => {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const response = await fetch('http://localhost:8080/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (response.ok) {
const data = await response.json();
accessToken = data.accessToken;
refreshToken = data.refreshToken;
accessTokenExpires = new Date(data.accessTokenExpires).getTime();
refreshTokenExpires = new Date(data.refreshTokenExpires).getTime();
document.getElementById('loginResult').innerHTML = `<p>登录成功!Access Token: ${accessToken}</p>`;
startCountdown('accessTokenCountdown', accessTokenExpires, 'Access Token 将在 ');
startCountdown('refreshTokenCountdown', refreshTokenExpires, 'Refresh Token 将在 ');
} else {
document.getElementById('loginResult').innerHTML = `<p class="error">登录失败,状态码: ${response.status}</p>`;
}
});
function startCountdown(elementId, expirationTime, prefix) {
const countdownElement = document.getElementById(elementId);
const interval = setInterval(() => {
const now = new Date().getTime();
const distance = expirationTime - now;
if (distance <= 0) {
clearInterval(interval);
countdownElement.innerHTML = `${prefix}已过期`;
} else {
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
countdownElement.innerHTML = `${prefix}${hours} 小时 ${minutes} 分钟 ${seconds} 秒后过期`;
}
}, 1000);
// 根据元素 ID 记录对应的计时器
if (elementId === 'accessTokenCountdown') {
accessTokenCountdownInterval = interval;
} else if (elementId === 'refreshTokenCountdown') {
refreshTokenCountdownInterval = interval;
}
}
async function testAdminInfo() {
if (!accessToken) {
alert('请先登录!');
return;
}
const response = await fetch('http://localhost:8080/admin/info', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.ok) {
const data = await response.text();
document.getElementById('apiResult').innerHTML = `<p>响应: ${data}</p>`;
} else if (response.status === 403) {
await refreshAccessToken();
await testAdminInfo(); // 重新尝试
} else {
document.getElementById('apiResult').innerHTML = `<p class="error">访问失败,状态码: ${response.status}</p>`;
}
}
async function testBuyerInfo() {
if (!accessToken) {
alert('请先登录!');
return;
}
const response = await fetch('http://localhost:8080/buyer/info', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.ok) {
const data = await response.text();
document.getElementById('apiResult').innerHTML = `<p>响应: ${data}</p>`;
} else if (response.status === 403) {
await refreshAccessToken();
await testBuyerInfo(); // 重新尝试
} else {
document.getElementById('apiResult').innerHTML = `<p class="error">访问失败,状态码: ${response.status}</p>`;
}
}
async function refreshAccessToken() {
const response = await fetch('http://localhost:8080/refresh-token', {
method: 'POST',
headers: {
'Authorization': refreshToken
}
});
if (response.ok) {
const data = await response.json();
accessToken = data.accessToken; // 更新 access token
accessTokenExpires = new Date(data.accessTokenExpires).getTime(); // 更新过期时间
document.getElementById('loginResult').innerHTML = `<p>Access Token 刷新成功!新的 Access Token: ${accessToken}</p>`;
// 更新 accessToken 的倒计时
startCountdown('accessTokenCountdown', accessTokenExpires, 'Access Token 将在 ');
} else if (response.status === 403) {
// 清除 tokens 并提示用户重新登录
accessToken = '';
refreshToken = '';
document.getElementById('loginResult').innerHTML = `<p class="error">刷新 Token 失败,请重新登录。</p>`;
alert('请重新登录!');
} else {
document.getElementById('loginResult').innerHTML = `<p class="error">刷新 Token 失败,状态码: ${response.status}</p>`;
}
}
// 退出登录逻辑
async function logout() {
// 调用退出登录接口
const response = await fetch('http://localhost:8080/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.ok) {
// 清除本地存储的 tokens
accessToken = '';
refreshToken = '';
// 停止倒计时
clearInterval(accessTokenCountdownInterval);
clearInterval(refreshTokenCountdownInterval);
// 更新页面显示
document.getElementById('loginResult').innerHTML = `<p>退出登录成功!</p>`;
document.getElementById('accessTokenCountdown').innerHTML = '';
document.getElementById('refreshTokenCountdown').innerHTML = '';
document.getElementById('apiResult').innerHTML = '';
} else {
document.getElementById('loginResult').innerHTML = `<p class="error">退出登录失败,状态码: ${response.status}</p>`;
}
}
</script>
</body>
</html>
测试:
启动服务,打开前端:
1.输入错误的账号
后端抛出用户名未找到的异常
2.输入错误密码
后端抛出密码错误异常
3.正确登录
显示两个token有效期倒计时以及access-token的值
4.访问admin接口
5.访问buyer接口
会看到access-token会不断刷新,但不会显示"This is buyer info. Only BUYER can access this."字体,看上去有点鬼畜,原因是前端写的是在收到403状态码后会以为是access-token过期而会访问fresh接口并再次执行一次接口。但实际上这个403是因为没有对应权限所导致的,这个问题无论改前端还是后端都能解决,但前端是ai生成的且我自己也不是很了解,后端也可限定不同异常的错误响应码,但正如开篇所说本章只是各基础demo所以就懒的改了。反正请求确实是拦截到了。
6.测试token刷新
在access-token过期但refresh-token未过期时测试admin,能够看到刷新成功且重新访问接口成功
fresh-token过期后则显示重新登录
最后:
auth模块在微服务项目中的重要性都不言而喻,目前只是实现了一个简单的框架,在后面几章会添加feign调用的鉴权,以及redis存放token从而同时获取有状态和无状态校验的优点,以及mysql交互获取数据库中信息等。还敬请关注!