从零搭建微服务项目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 复制代码
<?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,用于生成随机盐。(不过由于本章没有将用户账号密码等信息存放在数据库,在代码中写死用户信息,所以这个工具类实际没有作用)。

java 复制代码
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方便测试)

java 复制代码
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,方便全局接口获取请求的用户信息。

java 复制代码
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

java 复制代码
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全局上下文

java 复制代码
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,跨域请求配置。(需要设置为自己前端运行的端口号)

java 复制代码
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,日志记录等操作。

java 复制代码
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:

封装登录请求和响应,以及用户实体类

java 复制代码
package cn.bit.pro6_1.pojo;

import lombok.Data;

@Data
public class LoginRequest {
    private String username;
    private String password;
}
java 复制代码
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;
}
java 复制代码
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;
}

接口:

全局异常抓取

java 复制代码
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();
    }
}

登录接口

java 复制代码
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刷新接口

java 复制代码
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

java 复制代码
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

java 复制代码
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.";
    }
}

前端:

java 复制代码
<!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交互获取数据库中信息等。还敬请关注!

相关推荐
4dm1n几秒前
kubernetes request limit底层是怎么限制的☆
后端
赵大仁14 分钟前
深入解析前后端分离架构:原理、实践与最佳方案
前端·架构
无名之逆18 分钟前
Hyperlane:轻量、高效、安全的 Rust Web 框架新选择
开发语言·前端·后端·安全·rust·github·ssl
Asthenia041220 分钟前
当Spring服务接入ElasticSearch:如何优雅的CRUD呢?
后端
小诸葛的博客33 分钟前
开发一个go模块并在其他项目中引入
开发语言·后端·golang
Emma歌小白33 分钟前
初步使用UML设计代码结构
后端
哔哩哔哩技术35 分钟前
2025 B站春晚直播——技术保障复盘
架构
剽悍一小兔1 小时前
Java8默认方法の终极奥义
后端
Emma歌小白1 小时前
UML(Unified Modeling Language,统一建模语言)应用方向
后端
雷渊1 小时前
mybatis底层为什么设计二层缓存?
java·后端·面试