Java框架快速入门: Spring Security+OAuth2之方法级安全注解

概述

在 Spring Security 的实战中,我们通常首先接触的是基于 URL 的访问控制,例如通过 antMatchers 限制某个接口的访问角色。但在复杂业务场景下,我们需要更细粒度的控制------不仅限制谁能调用某个接口,更要限制谁能执行某个 Service 方法或拿到返回结果。这就是方法级安全的用武之地。

本文将从概念、配置、核心注解到完整可运行代码,带你快速掌握 @PreAuthorize@PostAuthorize 等方法级安全注解的用法,并给出清晰的授权流程分析。

纲要

  • 方法级安全概述与启用配置
  • 四个核心注解:@PreAuthorize@PostAuthorize@PreFilter@PostFilter 的定位
  • @PreAuthorize@PostAuthorize 的执行流程与 AOP 拦截机制
  • 授权投票结果与 AccessDeniedException 的产生条件
  • 项目结构总览
  • 完整可运行代码:从实体、Repository、Service 到 Controller 与安全配置
  • 测试验证:方法前授权失败与通过场景,方法后授权通过与失败场景
  • 最佳实践与使用建议

启用方法级安全

要使用这些注解,只需在一个配置类上添加 @EnableGlobalMethodSecurity 并开启 prePostEnabled = true。该注解会激活 Spring AOP 拦截器,自动处理带有安全注解的方法。

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

这一步之后,我们就可以在任意 Bean 的公共方法上使用以下注解:

  • @PreAuthorize -- 方法执行前鉴权
  • @PostAuthorize -- 方法执行后鉴权(基于返回对象)
  • @PreFilter -- 方法执行前对集合参数进行过滤
  • @PostFilter -- 方法执行后对返回集合进行过滤

本文重点演示前两个注解的使用,后两个将在后续篇章介绍。

授权流程与 AOP 拦截原理

Spring Security 在方法级安全底层使用 AOP 切面,当一个方法被 @PreAuthorize@PostAuthorize 标注时,会触发相应的 Advice。

流程时序图

PostInvocationAdvice Service PreInvocationAdvice MethodSecurityInterceptor Controller Client PostInvocationAdvice Service PreInvocationAdvice MethodSecurityInterceptor Controller Client #mermaid-svg-UWfWEKndWdD7snqc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UWfWEKndWdD7snqc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UWfWEKndWdD7snqc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UWfWEKndWdD7snqc .error-icon{fill:#552222;}#mermaid-svg-UWfWEKndWdD7snqc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UWfWEKndWdD7snqc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UWfWEKndWdD7snqc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UWfWEKndWdD7snqc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UWfWEKndWdD7snqc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UWfWEKndWdD7snqc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UWfWEKndWdD7snqc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UWfWEKndWdD7snqc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UWfWEKndWdD7snqc .marker.cross{stroke:#333333;}#mermaid-svg-UWfWEKndWdD7snqc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UWfWEKndWdD7snqc p{margin:0;}#mermaid-svg-UWfWEKndWdD7snqc .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UWfWEKndWdD7snqc text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-UWfWEKndWdD7snqc .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UWfWEKndWdD7snqc .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-UWfWEKndWdD7snqc .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-UWfWEKndWdD7snqc .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-UWfWEKndWdD7snqc #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-UWfWEKndWdD7snqc .sequenceNumber{fill:white;}#mermaid-svg-UWfWEKndWdD7snqc #sequencenumber{fill:#333;}#mermaid-svg-UWfWEKndWdD7snqc #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-UWfWEKndWdD7snqc .messageText{fill:#333;stroke:none;}#mermaid-svg-UWfWEKndWdD7snqc .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UWfWEKndWdD7snqc .labelText,#mermaid-svg-UWfWEKndWdD7snqc .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-UWfWEKndWdD7snqc .loopText,#mermaid-svg-UWfWEKndWdD7snqc .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-UWfWEKndWdD7snqc .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UWfWEKndWdD7snqc .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UWfWEKndWdD7snqc .noteText,#mermaid-svg-UWfWEKndWdD7snqc .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-UWfWEKndWdD7snqc .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UWfWEKndWdD7snqc .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UWfWEKndWdD7snqc .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UWfWEKndWdD7snqc .actorPopupMenu{position:absolute;}#mermaid-svg-UWfWEKndWdD7snqc .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-UWfWEKndWdD7snqc .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UWfWEKndWdD7snqc .actor-man circle,#mermaid-svg-UWfWEKndWdD7snqc line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-UWfWEKndWdD7snqc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt前置投票通过前置投票拒绝 HTTP Request调用 Service 方法执行前置投票赞成票执行目标方法返回结果执行后置投票(如有注解)投票结果返回结果或异常否决票AccessDeniedExceptionResponse

Spring Security 内置的 AffirmativeBased 决策管理器要求至少有一票赞成才能通过。如果前置投票器(例如 PreInvocationAuthorizationAdviceVoter)返回否决票(-1),而其他投票器弃权,授权将失败,抛出 AccessDeniedException


项目结构

本次示例采用 Spring Boot + Spring Data JPA + H2 内存数据库,方便直接运行。

dir 复制代码
src/main/java/com/example/security
├── config
│   └── SecurityConfig.java
│   └── MethodSecurityConfig.java
├── entity
│   └── UserEntity.java
├── repository
│   └── UserRepository.java
├── service
│   └── UserService.java
├── controller
│   └── UserController.java
└── SecurityApplication.java
src/main/resources
└── application.properties

完整可运行代码

1. 依赖与入口

pom.xml 中需包含以下主要依赖(基于 Spring Boot 2.7):

  • spring-boot-starter-web
  • spring-boot-starter-security
  • spring-boot-starter-data-jpa
  • h2

入口类保持默认即可。

2. 实体 UserEntity

java 复制代码
package com.example.security.entity;

import javax.persistence.*;

@Entity
@Table(name = "users")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String email;
    private String role; // ROLE_ADMIN, ROLE_USER

    public UserEntity() {}

    public UserEntity(String username, String email, String role) {
        this.username = username;
        this.email = email;
        this.role = role;
    }

    // getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }
}

3. Repository

java 复制代码
package com.example.security.repository;

import com.example.security.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByEmail(String email);
}

4. Service

java 复制代码
package com.example.security.service;

import com.example.security.entity.UserEntity;
import com.example.security.repository.UserRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 只有 ROLE_ADMIN 可以调用
    @PreAuthorize("hasRole('ADMIN')")
    public String getAdminOnlyResource() {
        return "Sensitive admin data";
    }

    // 方法执行后,判断返回对象的 username 是否与当前认证用户一致
    @PostAuthorize("returnObject.username == authentication.name")
    public UserEntity getUserByEmail(String email) {
        Optional<UserEntity> userOpt = userRepository.findByEmail(email);
        return userOpt.orElse(null);
    }

    public Optional<UserEntity> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }
}

5. Controller

java 复制代码
package com.example.security.controller;

import com.example.security.entity.UserEntity;
import com.example.security.service.UserService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    // 该接口本身允许匿名访问,但调用的 Service 方法有 @PreAuthorize
    @GetMapping("/admin/resource")
    public String getAdminResource() {
        return userService.getAdminOnlyResource();
    }

    // 通过 email 查询用户,Service 方法上有 @PostAuthorize
    @GetMapping("/users/by-email")
    public UserEntity getUserByEmail(@RequestParam String email) {
        return userService.getUserByEmail(email);
    }
}

6. 安全配置

java 复制代码
package com.example.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                // URL 级别:允许匿名访问这两个路径(方法级会做进一步控制)
                .antMatchers("/api/admin/resource", "/api/users/by-email").permitAll()
                .anyRequest().authenticated()
            )
            .httpBasic();  // 便于测试
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails admin = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("1234")
                .roles("ADMIN")
                .build();
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user")
                .password("1234")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(admin, user);
    }
}

7. 启用方法级安全

java 复制代码
package com.example.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}

8. 初始化数据(可选测试用)

SecurityApplication 中添加 CommandLineRunner 向数据库插入用户数据:

java 复制代码
package com.example.security;

import com.example.security.entity.UserEntity;
import com.example.security.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

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

    @Bean
    CommandLineRunner init(UserRepository repo) {
        return args -> {
            repo.save(new UserEntity("admin", "admin@example.com", "ROLE_ADMIN"));
            repo.save(new UserEntity("user", "user@example.com", "ROLE_USER"));
        };
    }
}

9. application.properties

properties 复制代码
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true

测试与验证

@PreAuthorize 场景

1. 普通用户调用 /api/admin/resource

URL 允许匿名访问,但 Service 方法要求 ROLE_ADMIN

user:1234 身份通过 HTTP Basic 请求,将得到 403 Forbidden,日志中可见 PreInvocationAuthorizationAdviceVoter 返回否决票,最终抛出 AccessDeniedException

2. admin 用户调用

admin:1234 请求,返回 Sensitive admin data

@PostAuthorize 场景

1. admin 查询自己的信息

/api/users/by-email?email=admin@example.com,认证用户 admin

方法执行后返回 UserEntity,其 usernameadmin,与 authentication.name 一致,授权通过。

2. admin 查询 user 的信息

/api/users/by-email?email=user@example.com,认证用户 admin

方法执行后返回对象 usernameuser,与当前认证名 admin 不匹配,后置投票拒绝,返回 403


对比与使用建议

注解 执行时机 适用场景 注意事项
@PreAuthorize 方法执行前 权限检查、角色验证、参数值判断 推荐优先使用,能避免不必要的业务逻辑执行
@PostAuthorize 方法执行后 基于返回结果的安全判断,如数据归属校验 方法体已执行,若涉及写操作可能已产生副作用,不建议用于增删改
@PreFilter 方法执行前 过滤集合类型的入参 后续文章详述
@PostFilter 方法执行后 过滤集合类型的返回值 后续文章详述

总结

本文从 Spring Security 的方法级安全配置出发,详细剖析了 @PreAuthorize@PostAuthorize 的工作原理、投票流程及实战代码。

通过完整的可运行示例,你可以直观感受到细粒度方法控制带来的安全增强效果。在生产实践中,应优先采用 @PreAuthorize 防止危险操作发生,仅在对只读操作做所有权验证时才谨慎使用 @PostAuthorize

相关推荐
JavacKaka1 小时前
LiteFlow规则引擎实战博客-2026-09-09
java
学渣超1 小时前
记一次复杂业务功能重构——从过程式长链路,到模板方法构建体系
java·架构·代码规范
Bs_MoneyMagnet2 小时前
基于springboot+vue的旅游景区点评系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·旅游
夜不会漫长2 小时前
C++:类和对象(2)
java·javascript·c++
IT毕设实战小研2 小时前
基于大数据的国内主要农作物产量趋势分析与可视化
android·java·大数据·django·课程设计
星辞秋2 小时前
Java 入门实战:从零搭建环境到写出第一个可用的命令行工具
java
ch.ju2 小时前
Java—idea知识:如何创建新项目
java·开发语言·intellij-idea
土司大王2 小时前
LeetCode hot100——74.搜索二维矩阵:Java 二分模板
java·算法·leetcode
2302_1113 小时前
java依赖小结
java·运维·数据库