Security
开篇:Web 安全不是可选项
想象你开了一家公司,大楼有很多房间。你不会让任何人随便进出吧?至少得做两件事:
- 认证(Authentication):你是谁?请出示工牌。
- 授权(Authorization):你能去哪?实习生不能进机房。
Spring Security 就是帮你在 Web 应用里干这两件事的框架。只要引入一个依赖,所有接口立刻被"锁上",默认用户名 user,密码在启动日志里随机生成。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>启动后访问任何接口,都会跳到一个自带的登录页面。这就是 Spring Security 的"零配置安全"。
一、Spring Security 架构
Spring Security 的核心设计可以用一句话概括:一条过滤器链(FilterChain)守住所有 HTTP 请求。
这条链上默认有十几个过滤器,每个各司其职。最重要的几个:
| 过滤器 | 职责 |
|---|---|
UsernamePasswordAuthenticationFilter | 拦截表单登录,处理用户名密码 |
SecurityContextPersistenceFilter | 在 Session 中维护 SecurityContext |
AnonymousAuthenticationFilter | 未登录时创建匿名身份 |
ExceptionTranslationFilter | 将安全异常转为 HTTP 响应 |
FilterSecurityInterceptor | 最终的授权判断 |
还有一个核心概念 SecurityContext,它就像一张"临时工牌",存在 SecurityContextHolder 里。整个请求生命周期内,任何地方都能通过它拿到当前用户信息。
二、认证流程
认证的完整链路可以用下面的流程图理解:
2.1 自定义用户查询
Spring Security 不关心你的用户存在 MySQL 还是 Redis,只要你实现 UserDetailsService:
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在:" + username);
}
// 查询用户权限
List<GrantedAuthority> authorities = permissionService
.findByUserId(user.getId()).stream()
.map(p -> new SimpleGrantedAuthority(p.getTag()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
username,
user.getPassword(), // 数据库中应存 BCrypt 加密后的密码
authorities
);
}
}在配置类中指定:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService);
}2.2 自定义登录配置
实际项目中,登录页面、成功跳转、失败处理都需要自定义:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/toLoginPage") // 自定义登录页
.loginProcessingUrl("/login") // 表单提交地址
.successForwardUrl("/") // 登录成功跳转
.and()
.authorizeRequests()
.antMatchers("/toLoginPage").permitAll()
.anyRequest().authenticated();
http.csrf().disable();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/images/**");
}
}2.3 前后端分离:JSON 登录响应
前后端分离项目不做页面跳转,而是返回 JSON。实现 AuthenticationSuccessHandler 和 AuthenticationFailureHandler:
@Service
public class JsonAuthHandler implements
AuthenticationSuccessHandler, AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest req,
HttpServletResponse resp, Authentication auth) throws IOException {
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write(
objectMapper.writeValueAsString(Map.of("code", 200, "message", "登录成功"))
);
}
@Override
public void onAuthenticationFailure(HttpServletRequest req,
HttpServletResponse resp, AuthenticationException e) throws IOException {
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write(
objectMapper.writeValueAsString(Map.of("code", 401, "message", e.getMessage()))
);
}
}配置中使用:
.successHandler(jsonAuthHandler)
.failureHandler(jsonAuthHandler)2.4 记住我与 Session 管理
"记住我"功能本质是往客户端写一个持久化 Token。推荐用数据库方案避免 Token 被窃取后的风险:
// 配置类中
.and().rememberMe()
.tokenValiditySeconds(1209600) // 两周有效
.rememberMeParameter("remember-me")
.tokenRepository(persistentTokenRepository())
// Token 持久化到数据库
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
// 首次启动建表,之后注释掉
// repo.setCreateTableOnStartup(true);
return repo;
}对于重要操作,即使用户通过 RememberMe 登录,也应强制重新认证:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (RememberMeAuthenticationToken.class.isAssignableFrom(auth.getClass())) {
throw new RememberMeAuthenticationException("请重新登录");
}Session 管理常用配置:
http.sessionManagement()
.invalidSessionUrl("/toLoginPage") // session 过期跳转
.maximumSessions(1) // 同一账号最多一处登录
.expiredUrl("/toLoginPage");集群环境下可用 Spring Session + Redis 共享 Session:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>spring.session.store-type=redis三、授权机制
认证解决"你是谁",授权解决"你能干什么"。Spring Security 提供了两个维度的授权方式。
3.1 URL 级别授权
在配置类中按路径设置权限:
http.authorizeRequests()
.antMatchers("/user/**").hasRole("ADMIN")
.antMatchers("/product/**").hasAnyRole("ADMIN", "PRODUCT")
.anyRequest().authenticated();权限不足时可以自定义响应:
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req, HttpServletResponse resp,
AccessDeniedException e) throws IOException {
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("权限不足,请联系管理员");
}
}3.2 方法级别授权(@PreAuthorize)
先开启注解支持:
@EnableGlobalMethodSecurity(prePostEnabled = true)然后在方法上直接标注:
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }
// 基于参数校验
@PreAuthorize("#id < 10")
public User getUser(@PathVariable Long id) { ... }
// 基于返回值校验
@PostAuthorize("returnObject.username == authentication.principal.username")
public User getProfile(Long id) { ... }3.3 RBAC 权限模型
实际项目中通常采用 用户-角色-权限 三表模型:
在 UserDetailsService 中查询用户权限并注入:
List<Permission> permissions = permissionService.findByUserId(user.getId());
List<GrantedAuthority> authorities = permissions.stream()
.map(p -> new SimpleGrantedAuthority(p.getPermissionTag()))
.collect(Collectors.toList());配置中根据数据库权限表动态设置:
List<Permission> allPermissions = permissionService.list();
for (Permission p : allPermissions) {
http.authorizeRequests()
.antMatchers(p.getUrl()).hasAuthority(p.getTag());
}四、JWT 集成
Session 方案在微服务和移动端场景下有天然劣势:需要服务端存储状态。JWT(JSON Web Token)提供了无状态认证方案。
JWT 的核心思路:登录成功后签发 Token,后续请求在 Header 中携带 Token,服务端解析验证即可,不依赖 Session。
典型的 JWT 过滤器实现思路:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
String jwt = token.substring(7);
// 解析 JWT,提取用户信息
// 构建 Authentication 对象放入 SecurityContext
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
}将这个过滤器加到 UsernamePasswordAuthenticationFilter 之前即可。
五、实战:验证码校验
验证码校验的思路:在用户名密码校验之前,先插入一个验证码过滤器。
5.1 生成验证码
使用 kaptcha 库生成验证码图片,将验证码文本存入 Redis:
@RestController
public class CaptchaController {
@Autowired
private DefaultKaptcha captchaProducer;
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/captcha")
public void getCaptcha(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
String text = captchaProducer.createText();
String key = "captcha:" + req.getRemoteAddr();
redisTemplate.opsForValue().set(key, text, 120, TimeUnit.SECONDS);
BufferedImage image = captchaProducer.createImage(text);
ImageIO.write(image, "jpeg", resp.getOutputStream());
}
}5.2 验证码过滤器
@Component
public class CaptchaFilter extends OncePerRequestFilter {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp, FilterChain chain)
throws ServletException, IOException {
if ("/login".equals(req.getRequestURI())
&& "POST".equalsIgnoreCase(req.getMethod())) {
try {
String input = req.getParameter("captcha");
String key = "captcha:" + req.getRemoteAddr();
String stored = redisTemplate.opsForValue().get(key);
if (!StringUtils.hasText(input)) {
throw new AuthenticationServiceException("验证码不能为空");
}
if (stored == null) {
throw new AuthenticationServiceException("验证码已过期");
}
if (!stored.equals(input)) {
throw new AuthenticationServiceException("验证码不正确");
}
redisTemplate.delete(key);
} catch (AuthenticationException e) {
failureHandler.onAuthenticationFailure(req, resp, e);
return;
}
}
chain.doFilter(req, resp);
}
}5.3 注册到过滤器链
http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class);这样验证码校验就在密码校验之前完成,不通过直接返回,不会进入后续认证流程。
六、实战:自定义数据权限注解
业务场景:不同角色看到不同范围的数据。比如部门经理只能看本部门数据,总经理看全部。
6.1 设计思路
核心思路很简单:通过 AOP 前置通知,根据用户角色动态给 SQL 追加过滤条件。
6.2 定义注解和基类
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DataScope {
String deptAlias() default "";
String userAlias() default "";
}public class BaseEntity {
@TableField(exist = false)
private Map<String, String> params = new HashMap<>();
// getter/setter
}所有需要数据权限的实体类继承 BaseEntity。
6.3 切面实现
@Aspect
@Component
public class DataScopeAspect {
@Before("@annotation(dataScope)")
public void doBefore(JoinPoint jp, DataScope dataScope) {
User user = (User) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
if (user.getUserId() == 1L) return; // 超级管理员不过滤
StringBuilder sql = new StringBuilder();
for (Role role : user.getRoles()) {
switch (role.getDataScope()) {
case "1": return; // 全部数据
case "2": // 自定义
sql.append(String.format(
" OR %s.dept_id IN (SELECT rd.dept_id FROM sys_role_dept rd WHERE rd.role_id=%d)",
dataScope.deptAlias(), role.getRoleId()));
break;
case "3": // 本部门
sql.append(String.format(
" OR %s.dept_id=%d",
dataScope.deptAlias(), user.getDeptId()));
break;
case "4": // 本部门及子部门
sql.append(String.format(
" OR %s.dept_id IN (SELECT dept_id FROM sys_dept WHERE dept_id=%d OR FIND_IN_SET(%d, ancestors))",
dataScope.deptAlias(), user.getDeptId(), user.getDeptId()));
break;
case "5": // 仅本人
if (!"".equals(dataScope.userAlias())) {
sql.append(String.format(
" OR %s.user_id=%d",
dataScope.userAlias(), user.getUserId()));
} else {
sql.append(" OR 1=0");
}
break;
}
}
Object arg = jp.getArgs()[0];
if (arg instanceof BaseEntity) {
((BaseEntity) arg).getParams().put("data_scope",
" AND (" + sql.substring(4) + ")");
}
}
}6.4 在 Mapper 中使用
@DataScope(deptAlias = "d")
public List<Dept> getAllDepts(Dept dept) {
return deptMapper.getAllDepts(dept);
}<select id="getAllDepts" resultType="Dept">
SELECT * FROM sys_dept d WHERE d.del_flag = '0'
${params.data_scope}
</select>不同角色登录后,SQL 会自动拼接不同的过滤条件。
七、常见面试题精选
Q1:Spring Security 的过滤器链是怎么工作的?
Spring Security 本质是一组 Servlet Filter,以 DelegatingFilterProxy 为入口,委托给 FilterChainProxy,后者维护一个 SecurityFilterChain 列表。每个请求进来,按顺序经过链上的过滤器(默认约 15 个),认证、授权、异常处理各有分工。开发者可以通过配置增删过滤器,比如在 UsernamePasswordAuthenticationFilter 前加验证码过滤器。
Q2:认证和授权的区别?
认证是确认"你是谁"(身份验证),授权是确认"你能做什么"(权限校验)。在 Spring Security 中,AuthenticationManager 负责认证,AccessDecisionManager 负责授权。认证通过后身份信息存入 SecurityContext,后续的授权判断基于这个上下文中的权限信息。
Q3:如何实现"动态权限"?
方案一:实现自定义的 AccessDecisionVoter,从数据库加载 URL 与权限的映射关系。方案二:自定义 FilterInvocationSecurityMetadataSource,在每次请求时动态查询权限配置。方案三(更常用):使用 @PreAuthorize 配合 SpEL 表达式调用自定义 Bean 方法做判断。
Q4:CSRF 防护的原理?Session 和 JWT 场景下有何不同?
CSRF 防护的核心是在表单中嵌入一个随机 Token,服务端验证 Token 来确保请求来自合法页面而非第三方站点。基于 Session 的应用需要开启 CSRF 防护,因为浏览器会自动发送 Cookie。基于 JWT 的无状态应用通常关闭 CSRF,因为 Token 放在 Header 中,浏览器不会自动携带,天然免疫 CSRF 攻击。
小结
Spring Security 是一个"约定优于配置"的安全框架。它的核心就三件事:
- 过滤器链:所有安全逻辑都是过滤器,像流水线一样处理每个请求
- 认证:通过
UserDetailsService加载用户,AuthenticationManager完成校验 - 授权:URL 级别用配置类,方法级别用
@PreAuthorize
掌握这三件事,再配合 JWT、验证码、数据权限等实战技巧,就能覆盖绝大多数项目的安全需求。