从零搭建微服务项目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交互获取数据库中信息等。还敬请关注!

相关推荐
xuejianxinokok10 小时前
新版本 python 3.14 性能到底如何?
后端·python
Ray6610 小时前
代理模式
后端
考虑考虑10 小时前
Jpa中的枚举类型
spring boot·后端·spring
peter52710 小时前
LangChain4j入门使用
后端
ArabySide10 小时前
【ASP.NET Core】分布式场景下ASP.NET Core中JWT应用教程
分布式·后端·asp.net core
hui函数11 小时前
Python全栈(基础篇)——Day06:后端内容(定义函数+调用函数+实战演示+每日一题)
后端·python
用户8740348525111 小时前
家政小程序源码实战:快速部署+多端适配,打造高效家政服务生态
spring boot
bcbnb11 小时前
Charles隐藏功能全攻略,10个高效技巧让你的抓包调试更专业
后端
用户40993225021211 小时前
PostgreSQL选Join策略有啥小九九?Nested Loop/Merge/Hash谁是它的菜?
后端·ai编程·trae
小杨的全栈之路11 小时前
从 SSLHandshakeException 到成功调用:RestTemplate 攻克自签 HTTPS 全记录
spring boot