1. 导言
完成基础认证后,下个阶段重点解决以下问题:
- 如何在后端控制接口权限,而不是只依赖前端菜单隐藏。
- 如何把数据库中的角色、菜单、权限标识转换为 Spring Security 可识别的权限集合。
- 如何让认证失败、授权失败返回统一 JSON。
- 如何处理前后端分离项目中的跨域和 CSRF 问题。
- 如何理解并扩展
@PreAuthorize、hasAuthority、认证成功/失败处理器、登出成功处理器。
2. 前置上下文
默认项目已经具备以下能力:
- 已引入
spring-boot-starter-security。 - 已自定义
UserDetailsService,可以根据用户名查询用户。 - 已实现
LoginUser implements UserDetails。 - 已完成登录接口、JWT 生成与校验、Redis 缓存登录用户等基础认证逻辑。
- 已在过滤器中把认证成功的用户信息放入
SecurityContextHolder。
这个阶段我要做的核心就是:认证阶段不仅要保存用户身份,还要保存用户拥有的权限。访问接口时,Spring Security 会拿当前用户权限与接口所需权限做匹配。
3. 授权的作用
权限系统的目标是让不同用户只能访问自己被允许使用的功能。
以图书馆系统为例:
- 普通学生只能借书、还书、查看自己的借阅记录。
- 图书管理员可以新增图书、删除图书、修改库存。
- 系统管理员可以管理用户、角色、菜单和权限。
权限判断不能只写在前端。前端隐藏按钮只能改善用户体验,不能保证安全。只要攻击者知道接口地址,就可以绕过页面直接发送请求。所以后端必须再次判断:当前登录用户是否拥有访问该接口所需的权限。
4. Spring Security 授权流程
Spring Security 默认通过 FilterSecurityInterceptor 进行权限校验。整体流程可以理解为:
- 用户请求受保护接口。
- Spring Security 从
SecurityContextHolder中获取Authentication。 Authentication中保存了当前用户身份和权限集合。- Spring Security 判断当前用户权限是否满足接口要求。
- 权限足够则放行,权限不足则抛出授权异常。
所以项目中要做两件事:
- 登录认证成功后,把用户权限封装进
Authentication。 - 在接口或配置中声明访问资源所需的权限。
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:list、system: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_id 或 user_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 查询权限实现
8.1 Menu 实体类
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;
}
8.2 MenuMapper 接口
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` <> ''
</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 发起跨源请求。同源要求三者完全一致:
- 协议一致,例如
http或https。 - 域名一致,例如
localhost、api.example.com。 - 端口一致,例如
8080、5173。
前后端分离项目通常是:
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,中文是"跨站请求伪造"。
它的典型攻击思路是:
- 用户已经登录目标网站。
- 浏览器中保存了目标网站的 Cookie。
- 用户访问恶意网站。
- 恶意网站诱导浏览器向目标网站发请求。
- 浏览器自动携带 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 授权排查顺序
接口权限不生效时,可以按这个顺序排查:
- 是否开启
@EnableGlobalMethodSecurity(prePostEnabled = true)。 - 接口上是否正确添加
@PreAuthorize。 - 登录时是否查询到了权限标识。
LoginUser#getAuthorities()是否把字符串权限转换成SimpleGrantedAuthority。- JWT 过滤器是否把
LoginUser放入SecurityContextHolder。 - 数据库里的权限标识是否和注解里的字符串完全一致。
- 认证失败和授权失败是否被对应处理器统一处理。