SpringSecurity进阶

1. 导言

完成基础认证后,下个阶段重点解决以下问题:

  • 如何在后端控制接口权限,而不是只依赖前端菜单隐藏。
  • 如何把数据库中的角色、菜单、权限标识转换为 Spring Security 可识别的权限集合。
  • 如何让认证失败、授权失败返回统一 JSON。
  • 如何处理前后端分离项目中的跨域和 CSRF 问题。
  • 如何理解并扩展 @PreAuthorizehasAuthority、认证成功/失败处理器、登出成功处理器。

2. 前置上下文

默认项目已经具备以下能力:

  • 已引入 spring-boot-starter-security
  • 已自定义 UserDetailsService,可以根据用户名查询用户。
  • 已实现 LoginUser implements UserDetails
  • 已完成登录接口、JWT 生成与校验、Redis 缓存登录用户等基础认证逻辑。
  • 已在过滤器中把认证成功的用户信息放入 SecurityContextHolder

这个阶段我要做的核心就是:认证阶段不仅要保存用户身份,还要保存用户拥有的权限。访问接口时,Spring Security 会拿当前用户权限与接口所需权限做匹配。

3. 授权的作用

权限系统的目标是让不同用户只能访问自己被允许使用的功能。

以图书馆系统为例:

  • 普通学生只能借书、还书、查看自己的借阅记录。
  • 图书管理员可以新增图书、删除图书、修改库存。
  • 系统管理员可以管理用户、角色、菜单和权限。

权限判断不能只写在前端。前端隐藏按钮只能改善用户体验,不能保证安全。只要攻击者知道接口地址,就可以绕过页面直接发送请求。所以后端必须再次判断:当前登录用户是否拥有访问该接口所需的权限。

4. Spring Security 授权流程

Spring Security 默认通过 FilterSecurityInterceptor 进行权限校验。整体流程可以理解为:

  1. 用户请求受保护接口。
  2. Spring Security 从 SecurityContextHolder 中获取 Authentication
  3. Authentication 中保存了当前用户身份和权限集合。
  4. Spring Security 判断当前用户权限是否满足接口要求。
  5. 权限足够则放行,权限不足则抛出授权异常。

所以项目中要做两件事:

  1. 登录认证成功后,把用户权限封装进 Authentication
  2. 在接口或配置中声明访问资源所需的权限。

5. 基于注解的权限控制

5.1 开启方法级权限控制

在 Spring Security 5.x 中,可以使用 @EnableGlobalMethodSecurity 开启 @PreAuthorize 等注解:

java 复制代码
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

说明:

  • prePostEnabled = true:开启 @PreAuthorize@PostAuthorize
  • @PreAuthorize:方法执行前进行权限判断,实际项目中最常用。

5.2 在接口上声明权限

java 复制代码
@RestController
public class HelloController {

    @RequestMapping("/hello")
    @PreAuthorize("hasAuthority('test')")
    public String hello() {
        return "hello";
    }
}

含义:访问 /hello 时,当前用户必须拥有 test 权限。

这里的 test 本质上是一个权限标识字符串。真实项目中通常会设计成:

text 复制代码
system:user:list
system:user:add
system:user:update
system:user:delete

6. 在 LoginUser 中封装权限信息

基础篇中 LoginUser 通常只保存用户对象。要支持授权,需要继续保存权限标识集合,并把字符串权限转换成 Spring Security 识别的 GrantedAuthority

java 复制代码
package com.sangeng.domain;

import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

    /**
     * 数据库中查询出来的权限标识,例如 system:user:list。
     */
    private List<String> permissions;

    /**
     * Spring Security 真正用于权限判断的对象集合。
     * 该字段不需要序列化到 Redis 或 JSON 响应中。
     */
    @JSONField(serialize = false)
    private List<GrantedAuthority> authorities;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }
        authorities = permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

关键点:

  • permissions 保存业务权限标识。
  • getAuthorities() 返回 Spring Security 需要的权限对象。
  • SimpleGrantedAuthority 是最常用的 GrantedAuthority 实现。
  • authorities 可以懒加载,避免每次获取权限都重复转换。
  • @JSONField(serialize = false) 可以避免 Redis/JSON 序列化时处理 GrantedAuthority 出现不必要的问题。

7. RBAC 权限模型

7.1 什么是 RBAC

RBAC 是 Role-Based Access Control,中文是"基于角色的访问控制"。

例子:

text 复制代码
用户 -> 用户角色关系 -> 角色 -> 角色菜单关系 -> 菜单/权限

也就是说:

  • 用户不直接绑定大量权限。
  • 用户先绑定角色。
  • 角色再绑定菜单或按钮权限。
  • 登录时根据用户查询出最终权限标识集合。

这样做的优点是权限管理更清晰。例如:

  • 张三拥有"管理员"角色。
  • "管理员"角色拥有 system:user:listsystem:user:add 等权限。
  • 张三登录后即可拥有这些权限。

7.2 常见表设计

核心表通常包括:

text 复制代码
sys_user       用户表
sys_role       角色表
sys_menu       菜单/权限表
sys_user_role  用户角色关联表
sys_role_menu  角色菜单关联表

示例建表语句:

sql 复制代码
CREATE DATABASE IF NOT EXISTS `sg_security`
  DEFAULT CHARACTER SET utf8mb4;

USE `sg_security`;

DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT '删除标志',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0未删除,1已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
  `user_id` bigint(20) NOT NULL COMMENT '用户 id',
  `role_id` bigint(20) NOT NULL COMMENT '角色 id',
  PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';

DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
  `role_id` bigint(20) NOT NULL COMMENT '角色 id',
  `menu_id` bigint(20) NOT NULL COMMENT '菜单 id',
  PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';

注意:关联表一般不建议把 role_iduser_id 设置为 AUTO_INCREMENT,因为它们不是关联表自己的业务主键,而是来自其他表的外键值。

7.3 查询用户权限

根据用户 id 查询权限标识:

sql 复制代码
SELECT DISTINCT
    m.`perms`
FROM
    sys_user_role ur
    LEFT JOIN sys_role r ON ur.`role_id` = r.`id`
    LEFT JOIN sys_role_menu rm ON ur.`role_id` = rm.`role_id`
    LEFT JOIN sys_menu m ON m.`id` = rm.`menu_id`
WHERE
    ur.`user_id` = #{userId}
    AND r.`status` = '0'
    AND m.`status` = '0'
    AND m.`perms` IS NOT NULL
    AND m.`perms` <> '';

优化点:

  • 使用 DISTINCT 去除重复权限。
  • 过滤停用角色和停用菜单。
  • 过滤空权限标识,避免生成无意义的 GrantedAuthority

8. MyBatis-Plus 查询权限实现

java 复制代码
package com.sangeng.domain;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.Date;

@TableName("sys_menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId
    private Long id;
    private String menuName;
    private String path;
    private String component;
    private String visible;
    private String status;
    private String perms;
    private String icon;
    private Long createBy;
    private Date createTime;
    private Long updateBy;
    private Date updateTime;
    private Integer delFlag;
    private String remark;
}
java 复制代码
package com.sangeng.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sangeng.domain.Menu;

import java.util.List;

public interface MenuMapper extends BaseMapper<Menu> {

    List<String> selectPermsByUserId(Long userId);
}

8.3 Mapper XML

路径示例:

text 复制代码
src/main/resources/mapper/MenuMapper.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.sangeng.mapper.MenuMapper">

    <select id="selectPermsByUserId" resultType="java.lang.String">
        SELECT DISTINCT
            m.`perms`
        FROM
            sys_user_role ur
            LEFT JOIN sys_role r ON ur.`role_id` = r.`id`
            LEFT JOIN sys_role_menu rm ON ur.`role_id` = rm.`role_id`
            LEFT JOIN sys_menu m ON m.`id` = rm.`menu_id`
        WHERE
            ur.`user_id` = #{userId}
            AND r.`status` = '0'
            AND m.`status` = '0'
            AND m.`perms` IS NOT NULL
            AND m.`perms` &lt;&gt; ''
    </select>

</mapper>

注意:#{userId} 的名字最好和 Mapper 方法参数 userId 保持一致。如果项目没有开启参数名保留,也可以使用 @Param("userId")

java 复制代码
List<String> selectPermsByUserId(@Param("userId") Long userId);

8.4 application.yml 配置

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/sg_security?characterEncoding=utf-8&serverTimezone=UTC
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  redis:
    host: localhost
    port: 6379

mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml

8.5 在 UserDetailsService 中封装权限

java 复制代码
package com.sangeng.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sangeng.domain.LoginUser;
import com.sangeng.domain.User;
import com.sangeng.mapper.MenuMapper;
import com.sangeng.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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.List;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName, username);

        User user = userMapper.selectOne(wrapper);
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户名或密码错误");
        }

        List<String> permissions = menuMapper.selectPermsByUserId(user.getId());
        return new LoginUser(user, permissions);
    }
}

到这里,用户登录后就能把数据库中的权限信息放进 LoginUser,之后 @PreAuthorize("hasAuthority('xxx')") 就可以正常校验。

9. 自定义认证失败和授权失败响应

9.1 异常处理机制

在 Spring Security 中,认证或授权异常会被 ExceptionTranslationFilter 捕获。

它会区分两种情况:

  • 认证失败:抛出或转换为 AuthenticationException,交给 AuthenticationEntryPoint 处理。
  • 授权失败:抛出 AccessDeniedException,交给 AccessDeniedHandler 处理。

如果前后端分离项目希望统一返回 JSON,就需要自定义这两个处理器。

9.2 授权失败处理器

java 复制代码
package com.sangeng.handler.exception;

import com.alibaba.fastjson.JSON;
import com.sangeng.domain.ResponseResult;
import com.sangeng.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response, json);
    }
}

HTTP 状态码建议使用:

  • 403 Forbidden:用户已登录,但没有权限访问该资源。

9.3 认证失败处理器

java 复制代码
package com.sangeng.handler.exception;

import com.alibaba.fastjson.JSON;
import com.sangeng.domain.ResponseResult;
import com.sangeng.utils.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

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

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response, json);
    }
}

HTTP 状态码建议使用:

  • 401 Unauthorized:用户未登录、登录状态失效或 token 无效。

9.4 配置到 Spring Security

java 复制代码
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);
    }
}

实际项目中,这段配置通常会和 JWT 过滤器、Session 策略、接口放行规则写在同一个 configure(HttpSecurity http) 中。

10. 跨域处理

10.1 为什么会跨域

浏览器出于安全考虑,会限制 JavaScript 发起跨源请求。同源要求三者完全一致:

  • 协议一致,例如 httphttps
  • 域名一致,例如 localhostapi.example.com
  • 端口一致,例如 80805173

前后端分离项目通常是:

text 复制代码
前端:http://localhost:5173
后端:http://localhost:8080

协议和域名可能一样,但端口不同,也属于跨域。

10.2 Spring MVC 跨域配置

java 复制代码
package com.sangeng.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .maxAge(3600);
    }
}

实际生产环境建议不要长期使用 allowedOriginPatterns("*"),而是改成明确的前端域名,例如:

java 复制代码
.allowedOriginPatterns("https://admin.example.com")

10.3 Spring Security 开启 CORS

因为接口会经过 Spring Security 过滤器链,所以只配置 Spring MVC 跨域还不够,还要在 Spring Security 中开启 CORS:

java 复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/user/login").anonymous()
            .anyRequest().authenticated();

    http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    http.exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint)
            .accessDeniedHandler(accessDeniedHandler);

    http.cors();
}

这一部分的关键点:

  • http.cors():允许 Spring Security 使用 CORS 配置。
  • OPTIONS 请求通常是浏览器预检请求,应保证能正常通过。
  • 前后端分离项目一般使用 token,不依赖 Session,所以配置为 STATELESS

11. 其他权限校验方法

11.1 hasAuthority

java 复制代码
@PreAuthorize("hasAuthority('system:dept:list')")
public String hello() {
    return "hello";
}

表示当前用户必须拥有 system:dept:list 权限。

底层逻辑可以简化理解为:

text 复制代码
authentication.getAuthorities()
    -> 遍历当前用户权限
    -> 判断是否包含 system:dept:list

11.2 hasAnyAuthority

java 复制代码
@PreAuthorize("hasAnyAuthority('admin', 'test', 'system:dept:list')")
public String hello() {
    return "hello";
}

表示当前用户拥有其中任意一个权限即可访问。

11.3 hasRole

java 复制代码
@PreAuthorize("hasRole('admin')")
public String hello() {
    return "hello";
}

hasRole 会自动给传入值拼接 ROLE_ 前缀。也就是说:

text 复制代码
hasRole('admin')

实际会匹配:

text 复制代码
ROLE_admin

所以如果使用 hasRole,用户权限集合中需要保存带 ROLE_ 前缀的角色标识。

11.4 hasAnyRole

java 复制代码
@PreAuthorize("hasAnyRole('admin', 'manager')")
public String hello() {
    return "hello";
}

表示用户拥有任意一个角色即可访问。它同样会自动拼接 ROLE_ 前缀。

11.5 选择建议

业务权限控制更推荐使用:

java 复制代码
hasAuthority
hasAnyAuthority

原因是权限标识更灵活,适合表达按钮、菜单、接口级别的权限,例如 system:user:add

角色判断更适合粗粒度控制,例如:

text 复制代码
ROLE_ADMIN
ROLE_MANAGER

12. 自定义权限校验方法

如果内置表达式不能满足业务需求,可以自定义 Bean 方法,然后在 @PreAuthorize 的 SpEL 表达式中调用。

12.1 自定义校验类

java 复制代码
package com.sangeng.expression;

import com.sangeng.domain.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.List;

@Component("ex")
public class SGExpressionRoot {

    public boolean hasAuthority(String authority) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        return permissions.contains(authority);
    }
}

12.2 在注解中调用

java 复制代码
@RequestMapping("/hello")
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public String hello() {
    return "hello";
}

说明:

  • @ex 表示从 Spring 容器中取名为 ex 的 Bean。
  • .hasAuthority(...) 表示调用该 Bean 的方法。
  • 返回 true 放行,返回 false 拒绝访问。

这种方式适合封装更复杂的判断,例如:

  • 超级管理员直接放行。
  • 判断部门数据权限。
  • 判断当前用户是否拥有某个资源的所有权。

13. 基于配置的权限控制

除了注解,也可以在 HttpSecurity 配置中声明接口权限:

java 复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/user/login").anonymous()
            .antMatchers("/testCors").hasAuthority("system:dept:list")
            .anyRequest().authenticated();

    http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    http.exceptionHandling()
            .authenticationEntryPoint(authenticationEntryPoint)
            .accessDeniedHandler(accessDeniedHandler);

    http.cors();
}

对比建议:

  • 接口权限固定、数量少:可以写在配置类中。
  • 业务接口多、权限分散:更推荐使用 @PreAuthorize 写在 Controller 或 Service 方法上。
  • 动态权限系统:通常结合数据库权限标识和注解表达式。

14. CSRF

解释:CSRF 是 Cross-Site Request Forgery,中文是"跨站请求伪造"。

它的典型攻击思路是:

  1. 用户已经登录目标网站。
  2. 浏览器中保存了目标网站的 Cookie。
  3. 用户访问恶意网站。
  4. 恶意网站诱导浏览器向目标网站发请求。
  5. 浏览器自动携带 Cookie,目标网站误以为是用户本人操作。

Spring Security 默认通过 csrf_token 防御 CSRF:

  • 后端生成 csrf token。
  • 前端请求时携带 token。
  • 后端过滤器校验 token。
  • token 不存在或不正确则拒绝请求。

前后端分离项目常见做法是:

  • 使用 JWT 或其他 token 作为认证凭证。
  • token 存储在前端,并由前端代码主动放入请求头。
  • 后端不依赖 Cookie 中的 Session 完成认证。

这种情况下,CSRF 风险明显降低,所以很多前后端分离项目会关闭 CSRF:

java 复制代码
http.csrf().disable();

注意:如果项目仍然使用 Cookie 自动携带认证信息,就不能简单认为 CSRF 一定安全,应根据认证方式重新评估。

15. 认证成功处理器

UsernamePasswordAuthenticationFilter 进行表单登录认证时,如果认证成功,会调用 AuthenticationSuccessHandler

15.1 自定义成功处理器

java 复制代码
package com.sangeng.handler;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

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

@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        System.out.println("认证成功");
    }
}

15.2 配置成功处理器

java 复制代码
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(successHandler);

        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

说明:如果项目已经自定义了登录接口,并且不走默认表单登录过滤器,这个处理器不一定会被触发。

16. 认证失败处理器

认证失败时会调用 AuthenticationFailureHandler

16.1 自定义失败处理器

java 复制代码
package com.sangeng.handler;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

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

@Component
public class SGFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        System.out.println("认证失败");
    }
}

16.2 配置失败处理器

java 复制代码
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(successHandler)
                .failureHandler(failureHandler);

        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

17. 登出成功处理器

登出成功后会调用 LogoutSuccessHandler

17.1 自定义登出成功处理器

java 复制代码
package com.sangeng.handler;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;

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

@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request,
                                HttpServletResponse response,
                                Authentication authentication) throws IOException, ServletException {
        System.out.println("注销成功");
    }
}

17.2 配置登出成功处理器

java 复制代码
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .successHandler(successHandler)
                .failureHandler(failureHandler);

        http.logout()
                .logoutSuccessHandler(logoutSuccessHandler);

        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

前后端分离项目中,如果登出逻辑是自己写的 /user/logout 接口,并在接口中删除 Redis 登录缓存,则不一定需要使用默认的 LogoutSuccessHandler

18. 推荐实践总结

18.1 权限标识设计

推荐格式:

text 复制代码
模块:资源:操作

示例:

text 复制代码
system:user:list
system:user:add
system:user:update
system:user:delete
system:role:list
system:menu:list

优点:

  • 可读性强。
  • 便于前后端统一约定。
  • 便于数据库维护。
  • 适合菜单、按钮、接口权限控制。

18.2 状态码约定

text 复制代码
401 Unauthorized  未认证、token 失效、token 非法
403 Forbidden     已认证,但权限不足

**注意:**不要把所有安全错误都返回 500。500 表示服务端异常,不适合表达认证或授权失败。

18.3 授权排查顺序

接口权限不生效时,可以按这个顺序排查:

  1. 是否开启 @EnableGlobalMethodSecurity(prePostEnabled = true)
  2. 接口上是否正确添加 @PreAuthorize
  3. 登录时是否查询到了权限标识。
  4. LoginUser#getAuthorities() 是否把字符串权限转换成 SimpleGrantedAuthority
  5. JWT 过滤器是否把 LoginUser 放入 SecurityContextHolder
  6. 数据库里的权限标识是否和注解里的字符串完全一致。
  7. 认证失败和授权失败是否被对应处理器统一处理。
相关推荐
J2虾虾1 小时前
Spring AI Alibaba - 工作流(Workflow)
数据库·人工智能·spring
J2虾虾2 小时前
Spring AI Alibaba - 多智能体(Multi-agent)
java·人工智能·spring
J2虾虾2 小时前
Spring AI Alibaba - 检索增强生成(RAG)
人工智能·spring·原型模式
小同志002 小时前
application.properties 和 application.yml
java·spring boot·spring·application.yml·.properities
唐青枫2 小时前
Java JdbcTemplate 实战指南:用 Spring 轻量完成数据库增删改查
java·spring boot·spring
铁皮哥2 小时前
【后端开发】什么是守护线程,和普通线程有什么区别?
java·开发语言·数据库·人工智能·python·spring·intellij-idea
轻刀快马3 小时前
从繁琐到极简,从幻象到本质:Spring AOP 架构演进与实战避坑指南
java·spring·架构
云烟成雨TD3 小时前
Spring AI Alibaba 1.x 系列【70】思考模式
java·人工智能·spring
消失的旧时光-19433 小时前
企业认证与安全体系(五):Spring Security + JWT + Redis 企业级认证实战
redis·安全·spring·spring security·jwt