从JWT看Spring Security

27 阅读11分钟

1.相关代码

AuthController

package com.example.netdisk.controller;

import com.example.netdisk.common.Result;
import com.example.netdisk.dto.User.UpDTO;
import com.example.netdisk.entity.SysUser;
import com.example.netdisk.exception.BusinessException;
import com.example.netdisk.security.jwt.JwtUtil;
import com.example.netdisk.service.SysUserService;
import com.example.netdisk.vo.LoginVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
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.*;

/**
 * 身份认证
 */
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final SysUserService userService;
    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    /**
     * 注册
     */
    @PostMapping("/register")
    public Result<Void> register(@RequestBody UpDTO dto) {

        if(userService.existsByUsername(dto.getUsername())) {
            throw new BusinessException(409, "用户名已存在");
        }

        SysUser user = new SysUser();
        user.setUsername(dto.getUsername());
        user.setPassword(dto.getPassword());

        // 默认给普通用户角色
        userService.createUser(user, Collections.singletonList(2L));

        return Result.successMsg("注册成功");
    }

    /**
     * 登录
     */
    @PostMapping("/login")
    public Result<LoginVO> login(@RequestBody UpDTO dto) {

        // 触发 Spring Security 的认证流程
        // 如果认证失败,这行代码会抛出异常,后面的代码不会执行
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        dto.getUsername(),
                        dto.getPassword()
                )
        );

        UserDetails userDetails = (UserDetails)authentication.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

        List<String> roles = new ArrayList<>();
        List<String> perms = new ArrayList<>();
        for (GrantedAuthority authority : authorities) {
            String s = authority.getAuthority();
            if (s != null) {
                if (s.startsWith("ROLE_")) {
                    roles.add(s);
                } else {
                    perms.add(s);
                }
            }
        }

        SysUser user = userService.findUserByUsername(dto.getUsername());

        // 上面从内存里取,代替这里从数据库取
//        List<String> roles = userService.findRolesByUserId(user.getId());
//        List<String> perms = userService.findPermsByUserId(user.getId());

        LoginVO vo = new LoginVO();
        vo.setUserId(user.getId());
        vo.setUsername(user.getUsername());
        vo.setNickname(user.getNickname());
        vo.setStatus(user.getStatus());
        vo.setRoles(roles);
        vo.setPerms(perms);

        String token = jwtUtil.generateToken(dto.getUsername());
        vo.setToken(token);

        Date expiration = jwtUtil.getExpiration(token);
        vo.setExpiration(expiration);

        return Result.success(vo);
    }
}

SecurityConfig

package com.example.netdisk.security.config;

import com.example.netdisk.security.filter.JwtFilter;
import com.example.netdisk.security.service.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * 这个 SecurityConfig 类是 Spring Security 的核心配置类
 * 它定义了整个应用的安全策略:哪些请求需要认证、使用什么认证方式、密码如何加密等
 */

@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    // 暴露 AuthenticationManager 作为 Bean(登录用到了)
    // Spring Security的核心认证接口,处理认证请求(如登录验证)
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();//拿到默认的 认证管理器
    }

    /**
     *  Spring Security 的密码校验逻辑在 DaoAuthenticationProvider
     *  Spring Boot 的自动配置发现缺少 AuthenticationProvider,
     *  会自动创建一个 DaoAuthenticationProvider,然后从容器中找到
     *  UserDetailsService 和 PasswordEncoder 并注入,
     *  流程大概如下
     */
//    @Bean
//    public DaoAuthenticationProvider authenticationProvider(
//            CustomUserDetailsService userDetailsService,
//            PasswordEncoder passwordEncoder) {
//
//        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
//
//        provider.setUserDetailsService(userDetailsService);     // 告诉provider如何查用户
//        provider.setPasswordEncoder(passwordEncoder);           // 告诉provider如何比对密码
//
//        return provider;
//    }

    /**
     * Bean注解:将当前方法的返回值注册为一个 Bean(一个由 Spring 管理的对象)
     * JWT认证方式,必须自定义SecurityFilterChain来跳过默认的 (Session认证)
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf().disable()// 关闭CSRF保护:JWT认证通常用于REST API,且JWT存在浏览器LocalStorage中,CSRF防护与无状态API不兼容
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)// 设置无状态会话

                .and()//返回 HttpSecurity
                .authorizeRequests()// 开始配置URL授权(新版可能用 authorizeHttpRequests)
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()// 预检请求放行
                .antMatchers("/auth/**").permitAll()// 登录接口所有人都可访问
                .anyRequest().authenticated()// 其他所有请求都需要认证

                .and()//返回 HttpSecurity
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);// 将自定义的 JwtFilter 插入到过滤器链中
                // 为什么放在它之前:因为我们希望先验证JWT,如果JWT有效就直接认证成功,这种情况下免了一次密码登录
                // [匿名过滤器] → [JwtFilter] → [UsernamePasswordAuthenticationFilter] → [其他过滤器] → [目标Controller]

        return http.build();
    }

    /**
     * 提供密码加密和验证功能
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

/**
 * 更详细的授权配置
 * .authorizeRequests()
 *     .antMatchers("/auth/login", "/auth/register", "/public/**").permitAll()  // 公共接口
 *     .antMatchers("/admin/**").hasRole("ADMIN")  // 需要ADMIN角色
 *     .antMatchers("/user/**").hasAnyRole("USER", "ADMIN")  // USER或ADMIN角色
 *     .antMatchers("/api/**").authenticated()  // 需要认证
 *     .anyRequest().denyAll()  // 其他所有请求都拒绝
 */

/**
 * 默认过滤器链包含(简化版):
 * ├── CsrfFilter                 // CSRF防护 (❌️被禁用了)
 * ├── LogoutFilter               // 注销处理
 * ├── JwtFilter                  // 自定义JWT验证
 * ├── UsernamePasswordAuthenticationFilter  // 表单登录
 * ├── DefaultLoginPageGeneratingFilter      // 生成登录页
 * ├── DefaultLogoutPageGeneratingFilter     // 生成注销页
 * ├── BasicAuthenticationFilter  // HTTP Basic认证
 * ├── RequestCacheFilter         // 请求缓存
 * ├── SecurityContextHolderFilter // 安全上下文
 * ├── AnonymousAuthenticationFilter // 匿名认证
 * ├── SessionManagementFilter    // 会话管理
 * ├── ExceptionTranslationFilter  // 异常转换
 * └── FilterSecurityInterceptor  // 权限校验
 *
 * 请求到达
 *     ↓
 * 1. JwtFilter先执行
 *    ↓ 如果有有效的JWT
 *    ↓ 创建UsernamePasswordAuthenticationToken
 *    ↓ 设置到SecurityContext
 *     ↓
 * 2. UsernamePasswordAuthenticationFilter执行
 *    ↓ 发现SecurityContext中已经有认证信息
 *    ↓ 跳过用户名密码认证
 *     ↓
 * 3. 请求继续
 */

CustomUserDetailsService

package com.example.netdisk.security.service;

import com.example.netdisk.entity.SysUser;
import com.example.netdisk.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
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.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final SysUserMapper sysUserMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        SysUser user = sysUserMapper.findUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<String> roles = sysUserMapper.findRolesByUserId(user.getId());
        List<String> perms = sysUserMapper.findPermsByUserId(user.getId());

        List<GrantedAuthority> authorities = new ArrayList<>();

        roles.forEach(r -> authorities.add(new SimpleGrantedAuthority(r)));
        perms.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));

        return new User(user.getUsername(), user.getPassword(), user.getStatus(), true, true, true, authorities);
    }
}

JwtUtil

package com.example.netdisk.security.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;

/**
 * 这个注解的意义:在需要此类实例化的地方,由 Spring 代替我们手动 new 一个对象
 * 相当于手动挡换自动挡
 */
@Component
public class JwtUtil {

//    @Value("${jwt.secret}")
    private String secret;

    //字段初始化的顺序早于依赖注入,这里secret是null,进而抛出异常(方案:用构造函数注入)
//    private final SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());

    private final SecretKey key;

    // 使用构造函数注入
    public JwtUtil(@Value("${jwt.secret}") String secret) {
        this.secret = secret;
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    /**
     * 签发令牌(登录成功后的"通行证")
     * 服务器确认了你的身份(username),现在给你签发一个带有效期的加密令牌,以后你拿着这个令牌来访问,就不用再重复登录了。
     */
    public String generateToken(String username) {
        return Jwts.builder()// 1. 创建JWT

                // 标准字段
                .subject(username)// 2. 设置主题(Subject):把用户名存入令牌中,标识这个令牌属于谁
                .issuedAt(new Date()) // 令牌的签发时刻(与过期时间配合使用)
                .expiration(new Date(System.currentTimeMillis() + 7200_000))// 3. 设置过期时间:令牌有效期2小时

                // 自定义字段
//                .claim("uid", userId)

                .signWith(key)// 4. 签名加密:用服务器的密钥对令牌进行签名,防止被篡改
                .compact();// 5. 压缩成最终字符串(例如:eyJhbGciOiJIUzI1NiJ9.eyJz...4qd7...)
    }

    /**
     * 解析验证令牌,获取用户名
     * 客户端拿着令牌来访问了,我要验证这个令牌是不是我签发的、有没有被篡改、有没有过期。如果一切正常,取出里面的用户名,确认是谁在访问。
     */
    public Claims parseToken(String token) {

        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public String getUserName(String token) {
        return parseToken(token).getSubject();
    }

//    public Long getUserId(String token) {
//        return parseToken(token).get("uid", Long.class);
//    }

    public Date getExpiration(String token) {
        return parseToken(token).getExpiration();
    }

}

/**
 * 【生成令牌】
 * 用户名"tom"
 *     → .subject("tom")                 (存入sub字段)
 *     → .expiration(明天此时)            (存入exp字段)
 *     → .signWith(密钥)                  (生成签名)
 *     → .compact()                       (打包成: header.payload.signature)
 *     → 返回: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0b20iLCJleHAiOjE2Nzg5MDAwMDB9.4qD7X9..."
 *
 * 【解析令牌】
 * 传入: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0b20iLCJleHAiOjE2Nzg5MDAwMDB9.4qD7X9..."
 *     → .verifyWith(密钥)                (设置验证密钥)
 *     → .parseSignedClaims(token)        (分割、验签、验期)
 *         ↓ 验证通过
 *     → .getPayload()                    (取出载荷部分: {sub:"tom", exp:1678900000})
 *     → .getSubject()                     (取出sub的值: "tom")
 *     → 返回: "tom"
 */

JwtFilter

package com.example.netdisk.security.filter;

import com.example.netdisk.security.jwt.JwtUtil;
import com.example.netdisk.security.service.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * OncePerRequestFilter:确保在一个外部请求的处理过程中,本过滤器只会被执行一次。
 */

@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {

        String header = request.getHeader("Authorization");

        if(header != null && header.startsWith("Bearer ")) {

            String token = header.substring(7);
            String username = jwtUtil.getUserName(token);

            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

            // 将认证信息绑定到当前线程的上下文中
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 放行请求,继续执行后续的过滤器或目标 Controller
        filterChain.doFilter(request, response);
    }
}

/**
 * 登录:
 *
 * 1. 用户 POST /auth/login (用户名+密码)
 *     ↓
 * 2. 请求到达,JwtFilter检查Authorization头 → 没有令牌
 *     ↓
 * 3. 请求继续传递到登录Controller
 *     ↓
 * 4. 登录Controller验证用户名密码(用PasswordEncoder比对)
 *     ↓
 * 5. 验证成功 → 调用JwtUtil生成令牌 → 返回给客户端
 */

/**
 * 登录后访问受保护的资源:
 *
 * 1. 用户 GET /api/user/info (携带 Bearer token)
 *     ↓
 * 2. 请求到达JwtFilter
 *     ↓
 * 3. 解析token → 获取用户名 → 加载用户信息 → 存入SecurityContext
 *     ↓
 * 4. 请求继续传递
 *     ↓
 * 5. 后续过滤器检查SecurityContext → 已有认证信息 → 允许访问
 *     ↓
 * 6. 到达目标Controller
 */

SysUserController

package com.example.netdisk.controller;

import com.example.netdisk.common.PageResult;
import com.example.netdisk.common.Result;
import com.example.netdisk.dto.User.UserCreateDTO;
import com.example.netdisk.entity.SysUser;
import com.example.netdisk.service.SysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 用户管理
 */
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class SysUserController {

    private final SysUserService userService;

    /**
     * 创建用户
     */
    @PostMapping
    @PreAuthorize("hasAuthority('user:add')")
    public void create(@RequestBody UserCreateDTO dto) {
        userService.createUser(dto.getUser(), dto.getRoleIds());
    }

    /**
     * 删除用户
     *
     * 检查当前请求的发送者是否有 user:delete 权限,
     * 是从 SecurityContext 中获取的
     */
    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('user:delete')")
    public void delete(@PathVariable Long id) {
        userService.deleteUser(id);
    }

    /**
     * 给用户分配角色
     */
    @PutMapping("/{id}/roles")
    @PreAuthorize("hasAuthority('user:add')")
    public void assignRoles(@PathVariable Long id, @RequestBody List<Long> roleIds) {
        userService.assignRoles(id, roleIds);
    }

    /**
     * 查询所有用户
     */
    @GetMapping
    public List<SysUser> list() {
        return userService.list();
    }

    /**
     * 分页查询用户
     */
    @GetMapping("/page")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<PageResult<SysUser>> page(@RequestParam int pageNum, @RequestParam int pageSize) {
        return Result.success(userService.page(pageNum, pageSize));
    }
}

2.关系图

StringSecurity

3.项目启动后流程

Spring启动过程:

  1. 找到启动类 NetdiskApplication
  2. 读取 @SpringBootApplication
  3. 发现其中包含 @ComponentScan
  4. 扫描包:com.example.netdisk 及其子包
  5. 扫描到:
    • @RestController
    • @Controller
    • @Service
    • @Component
    • @Configuration
  6. 把这些类注册为 Bean

其中,扫描到@Configuration时,就有SecurityConfig,就会执行@Bean方法,这里就会把自定义的JwtFilter放到SecurityFilterChain里UsernamePasswordAuthenticationFilter的前面。

package com.example.netdisk;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
 * @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan
 * @SpringBootConfiguration
 * @EnableAutoConfiguration
 * @ComponentScan: 没写 basePackages,意味着默认扫描“当前类所在包 + 所有子包”
 *
 * Spring启动过程:
 * 1. 找到启动类 NetdiskApplication
 * 2. 读取 @SpringBootApplication
 * 3. 发现其中包含 @ComponentScan
 * 4. 扫描包:com.example.netdisk 及其子包
 * 5. 扫描到:
 *    - @RestController
 *    - @Controller
 *    - @Service
 *    - @Component
 *    - @Configuration
 * 6. 把这些类注册为 Bean
 */
@SpringBootApplication
@EnableScheduling
/**
 * 报错:Parameter 0 of constructor in com.example.netdisk.controller.SysUserController
 * required a bean of type 'com.example.netdisk.mapper.SysUserMapper'
 * that could not be found.
 *
 * 翻译:未在容器中找到UserMapper的Bean
 *
 * 解决:@MapperScan("com.example.netdisk.mapper")
 *
 * 启动时扫描 com.example.netdisk.mapper 包下所有 Mapper 接口
 * 并把它们注册成 Spring Bean
 * 就可以在Service里直接注入(@Autowired)使用
 */
@MapperScan("com.example.netdisk.mapper")
public class NetdiskApplication {

    public static void main(String[] args) {
        SpringApplication.run(NetdiskApplication.class, args);
    }

}

4.请求处理流程

请求处理流程

4-1.登录请求处理流程

  1. 前端调用/auth/login接口,经过SecurityFilterChain,没发现token,进入Controller,触发AuthControllerlogin方法
Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
                dto.getUsername(),
                dto.getPassword()
        )
);
  1. new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword())创建一个未认证的令牌对象(包含用户提交的原始凭证——用户名与密码)
  2. authenticationManager.authenticate(...)委托 认证管理器(AuthenticationManager) 进行验证,AuthenticationManager来自SecurityConfig,它会依据开发人员配置的 认证提供者(AuthenticationProvider) 去验证凭证。

Spring Boot的自动配置发现缺少 AuthenticationProvider,会自动创建一个 DaoAuthenticationProvider,然后从容器中找到 UserDetailsService(来自CustomUserDetailsService) 和 PasswordEncoder(来自SecurityConfig) 并注入,前者负责告诉 provider 如何通过用户名查找到对应的用户信息,后者负责告诉 provider 如何对比密码是否一致。大致流程如下:

@Bean
public DaoAuthenticationProvider authenticationProvider(
        CustomUserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder) {

    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();

    provider.setUserDetailsService(userDetailsService);     // 告诉provider如何查用户
    provider.setPasswordEncoder(passwordEncoder);           // 告诉provider如何比对密码

    return provider;
}
  1. 如果认证失败,则抛出异常
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
	at org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider.authenticate(AbstractUserDetailsAuthenticationProvider.java:141)
	at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182)
	at com.example.netdisk.controller.AuthController.login(AuthController.java:63)
  1. 如果认证通过,则返回一个已认证的令牌(authentication),包含了用户的详细信息(UserDetails)、权限列表(Authorities)等,底层执行过程如下:
用户提交凭证
    ↓
UsernamePasswordAuthenticationToken(未认证)
    ↓
AuthenticationManager
    ↓
DaoAuthenticationProvider
    ↓
UserDetailsService.loadUserByUsername() → 从数据库加载用户信息
    ↓
PasswordEncoder.matches() → 验证密码是否匹配
    ↓
UsernamePasswordAuthenticationToken(已认证,包含权限)
    ↓
返回给调用方
  1. UserDetails userDetails = (UserDetails)authentication.getPrincipal(); 这里 getPrincipal() 返回的是 UserDetails 对象(CustomUserDetailsService中的loadUserByUsername方法返回的User对象),包含了用户名、密码、状态、权限集合等信息。
  2. Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities(); 单独拿出权限集合,里面包括角色权限
  3. 从权限集合里提取出当前登录用户的角色与权限,存到roles与perms数组里
  4. SysUser user = userService.findUserByUsername(dto.getUsername()); 根据用户名查询用户全部信息,虽然前面 UserDetails 里有用户信息,但只有用户名、密码、状态、权限集合,没有其他信息,列如昵称、性别、年龄等
  5. 构建前端响应对象,填充各个属性
  6. String token = jwtUtil.generateToken(dto.getUsername());根据用户名生成token
  7. Date expiration = jwtUtil.getExpiration(token);获取token的过期时间
  8. 将token与其过期时间设置到前端响应对象的属性中,使用统一的响应格式 Result 封装前端响应对象并返回

响应示例:

{
    "code": 200,
    "msg": "success",
    "data": {
        "userId": 1,
        "username": "admin",
        "nickname": null,
        "status": true,
        "token": "eyJhbGciOiJIUzI1NiIs...",
        "expiration": "2026-08-05T12:11:09.000+00:00",
        "roles": [
            "ROLE_ADMIN"
        ],
        "perms": [
            "user:add",
            "user:delete",
            "user:view"
        ]
    }
}

4-2.需认证角色/权限的请求的处理流程

  1. 前端调用/user/page接口,经过SecurityFilterChain,在自定义的JwtFilter中解析token,拿到用户名,加载权限角色等信息,存入SecurityContext,然后进入Controller,触发SysUserControllerpage方法.
  2. 由于SecurityConfig里写了@EnableMethodSecurity,Spring 会为带 @PreAuthorize 的方法创建代理,在执行page方法前会执行:
MethodSecurityInterceptor
   ↓
PreInvocationAuthorizationAdvice
   ↓
ExpressionHandler

3. 开始权限表达式计算:hasRole('ADMIN')内部转换为hasAuthority('ROLE_ADMIN'),然后从SecurityContext中拿到authentication,执行authentication.getAuthorities()拿到authorities,表达式判断ROLE_ADMIN ∈ authorities ? .

SecurityContext.drawio.png

  1. 如果是true,则执行对应方法;如果是false,则抛出异常AccessDeniedExceptionExceptionTranslationFilter捕获,返回403 Forbidden(实则不会被ExceptionTranslationFilter捕获,因为这里已经过了Spring Security过滤器链了,到达Controller了,而ExceptionTranslationFilter只能捕获发生在Spring Security过滤器链中的异常).

比较过程发生在内存中,唯一查询数据库的地方是拿Authorities,也有方案是把Authorities放到token中,就省去了数据库查询。权限放在token里的缺点是,修改权限不会立刻生效,需等token过期才能生效。也有方案把权限放在redis里,以减少对于数据库的查询。

5.补充

如果权限放在token里,岂不是前端可以伪造token来修改权限?

其实不然,首先token是由三部分组成的:Header.Payload.Signature

Payload是base64编码的,可以伪造:

Payload不加密

但是后面的Signature是用于校验的(校验发生在JwtUtil中的parseToken方法中的parseSignedClaims方法,会验证签名是否正确、是否过期等),signature = HMACSHA256( base64(header) + "." + base64(payload), secret-key ),前端修改权限意味着修改payload,然后这时的token是Header.新Payload.旧Signature,服务端按照HMACSHA256( base64(header) + "." + base64(新payload), secret-key )计算出来的signature跟token里的旧signature不一致(明显计算公式里的参数变了,即payload变了),所以认为该token无效。所以前端无法伪造token来修改权限。当然这需要保证签名算法(如HS256)可靠,以及密钥(secret-key)不泄露。