• Spring Security Auth/Acl 实践指南


    导语

    本文旨在使用简单的业务场景,重点介绍 Spring Security Authentication/Authorization 和 Spring Security Acl 实践过程的关键知识点,并给出相应的代码和配置示例,主要包含以下三个部分:

    • Web Api Authentication/Authorization
    • Method Authentication/Authorization
    • Acl

    完整的示例位于 example/spring-security 中,仓库地址:https://github.com/njdi/example.git。

    Web Api Authentication/Authorization

    假设有三个接口:

    • /web/guest:任意用户可访问;
    • /web/user:访问时需要提供用户名和密码,且访问用户必须拥有角色 USER;
    • /web/admin:访问时需要提供用户名和密码,且访问用户必须拥有角色 ADMIN;

    其中,用户名和密码就是 Authentication(认证),拥有指定角色就是 Authorization(鉴权)。

    示例接口

    添加 Maven 依赖

    spring-security/pom.xml

        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </dependency>
    

    https://github.com/njdi/example/blob/main/pom.xml
    https://github.com/njdi/example/blob/main/spring-security/pom.xml

    实现接口

    Main

    package io.njdi.example.spring.security;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class Main {
      public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
      }
    }
    
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/Main.java

    WebController

    package io.njdi.example.spring.security.controller;
    
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @RequestMapping("/web")
    public class WebController {
      @GetMapping("/guest")
      public String helloGuest() {
        return "hello guest";
      }
    
      @GetMapping("/user")
      public String helloUser() {
        return "hello user";
      }
    
      @GetMapping("/admin")
      public String helloAdmin() {
        return "hello admin";
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/controller/WebController.java

    访问接口

    编译

    cd example/spring-security
    
    mvn clean package -Dmaven.test.skip=true
    

    启动应用

    java -cp spring-security/target/example-spring-security-0.1.jar:spring-security/target/example-spring-security-0.1-lib/* io.njdi.example.spring.security.Main
    

    访问接口

    curl http://localhost:8080/web/guest
    curl http://localhost:8080/web/user
    curl http://localhost:8080/web/admin
    

    目前接口无任何认证/鉴权机制,均可正常访问且返回结果:hello guest、hello user 和 hello admin。

    认证/鉴权

    配置认证/鉴权

    添加 Maven 依赖

    spring-security/pom.xml

        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
    
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
    
        <dependency>
          <groupId>mysql</groupId>
          <artifactId>mysql-connector-java</artifactId>
        </dependency>
    

    用户名、密码和角色信息的存储机制有多种实现方式,可参考:https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/storage.html。

    本文使用数据库(MySQL),需要添加 jdbc 和 mysql 相关依赖。

    创建数据库/数据表

    假设数据库名称:spring_security,创建数据表:

    create table users(
        username varchar(50) not null primary key,
        password varchar(500) not null,
        enabled boolean not null
    );
    
    create table authorities (
        username varchar(50) not null,
        authority varchar(50) not null,
        constraint fk_authorities_users foreign key(username) references users(username)
    );
    
    create unique index ix_auth_username on authorities (username,authority);
    

    数据表创建语句可参考:https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html#servlet-authentication-jdbc-schema-user。

    数据库使用 MySQL 时,数据表创建语句需要进行调整,可参考:https://github.com/njdi/example/blob/main/spring-security/sql/auth.sql。

    配置数据源

    spring-security/application.yml

    spring:
      datasource:
        url: jdbc:mysql://mysql_dev:13306/spring_security?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true
        username: spring_security
        password: spring_security
        hikari:
          keepaliveTime: 30000
          maxLifetime: 600000
          maximumPoolSize: 30
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/resources/application.yml。

    创建认证/鉴权配置类

    SpringSecurityConfig

    @Configuration
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    
      @Override
      protected void configure(HttpSecurity http) throws Exception {
        ...
      }
    
    }
    

    认证/鉴权配置类(@Configuration)需要继承 WebSecurityConfigurerAdapter,通过重写若干方法完成认证/鉴权的具体配置,本文仅使用 configure 方法。

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/conf/SpringSecurityConfig.java

    注入认证/鉴权实例

    Spring Security 认证/鉴权的实现过程依赖于 UserDetailsService,用于完成用户名、密码和角色等相关信息的检索/存储。本文需要使用数据库的实现 JdbcUserDetailsManager

    SpringSecurityConfig

      @Autowired
      private DataSource dataSource;
    
      @Bean
      public JdbcUserDetailsManager createJdbcUserDetailsManager() {
        return new JdbcUserDetailsManager(dataSource);
      }
    

    JdbcUserDetailsManager 实例的创建依赖于数据源实例 dataSource,如前文所述,我们添加了 Maven 依赖 spring-boot-starter-jdbc,它会帮助我们自动注入数据库实例。

    声明认证/鉴权

    某个接口需要什么样的认证/鉴权需要通过重写 WebSecurityConfigurerAdapter.configure 方法实现:

    SpringSecurityConfig

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .csrf().disable()
                .httpBasic().disable()
                .logout().disable()
                .authorizeRequests(authorize -> authorize.mvcMatchers("/web/guest").permitAll()
                        .mvcMatchers("/web/user").hasRole("USER")
                        .mvcMatchers("/web/admin").hasRole("ADMIN"))
                ...
    

    mvcMatchers("/web/guest").permitAll(),表示接口 /web/guest 可以被任意用户访问;
    mvcMatchers("/web/user").hasRole("USER"),表示接口 /web/user 可以被拥有角色 USER 的用户访问;
    mvcMatchers("/web/admin").hasRole("ADMIN"),表示接口 /web/admin 可以被拥有角色 ADMIN 的用户访问。

    用户拥有角色的前提是用户必须可以被认证。

    SessionCreationPolicy.STATELESS 用于声明接口服务是无状态的,可以禁用名称为 JSESSIONID 的 Cookie;disable() 用于禁用一些我们不需要的功能。

    编译启动应用,再次访问接口:

    curl http://localhost:8080/web/guest
    hello guest
    
    curl http://localhost:8080/web/user
    {"timestamp":"2022-02-13T03:52:34.754+00:00","status":403,"error":"Forbidden","path":"/web/user"}
    
    curl http://localhost:8080/web/admin
    {"timestamp":"2022-02-13T03:52:40.910+00:00","status":403,"error":"Forbidden","path":"/web/admin"}
    

    我们会发现:接口 /web/guest 可以正常访问;接口 /web/user 和 /web/admin 会提示 403,没有权限访问,表示声明的接口认证/鉴权已生效。

    注意,目前我们仅声明访问接口需要认证/鉴权,即需要用户属于指定角色;但是并没有声明具体使用哪一种认证/鉴权机制,即如何判断用户是谁,以及用户属于哪些角色。

    Spring Security 内置多种认证机制和一种标准的鉴权机制;有时我们可能会遇到已提供的认证机制不满足我们需求的情况,假如我们要求通过请求头属性提供用户名和密码来进行认证:

    • spring.security.user
    • spring.security.password

    Spring Security 自身不支持这种认证机制,这时就需要我们自定义认证机制。

    自定义认证机制

    Spring Security 本质上就是通过 过滤器链 实现的,自定义认证实际上就是提供一个我们自定义认证逻辑的 过滤器

    1. 从请求头获取用户名和密码;
    2. 校验用户名和密码是否匹配;
    3. 如果匹配,则设置环境上下文,标识认证通过;
    4. 如果不匹配,则 什么也不做,继续执行 过滤器链 上的下一个过滤器;

    然后,把这个 过滤器 添加到 过滤器链 上合适的位置。

    过滤器链可参考:https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-security-filters。

    服务启动时,可以通过日志查看过滤器链上具体有哪些过滤器:
    2022-02-14 11:06:15.212 INFO 71515 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [...]

    AuthenticationFilter

    public class AuthenticationFilter extends OncePerRequestFilter {
      private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationFilter.class);
    
      private final JdbcUserDetailsManager manager;
    
      public AuthenticationFilter(JdbcUserDetailsManager manager) {
        this.manager = manager;
      }
    
      private void authenticate(String user, String password) {
        ......
      }
    
      @Override
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                      FilterChain filterChain) throws ServletException, IOException {
        String user = request.getHeader("spring.security.user");
        String password = request.getHeader("spring.security.password");
        LOGGER.info("user: {}, password: {}", user, password);
    
        authenticate(user, password);
    
        filterChain.doFilter(request, response);
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/filter/AuthenticationFilter.java

    自定义过滤器 AuthenticationFilter 继承自 OncePerRequestFilter,需要我们重写 doFilterInternal 方法实现自定义认证逻辑。

    为什么需要继承 OncePerRequestFilter
    OncePerRequestFilter 可以保证我们自定义的过滤器在一次请求的处理过程中仅被执行一次。

    doFilterInternal 执行逻辑如下:

    1. 获取用户名和密码 request.getHeader();
    2. 检验用户名和密码,完成认证 authenticate();
    3. 继续执行过滤器链的下一个过滤器 filterChain.doFilter();

    filterChain.doFilter() 需要特别注意。

    authenticate()

      private void authenticate(String user, String password) {
        if (!StringUtils.hasLength(user) || !StringUtils.hasLength(password)) {
          // 用户名或密码为空
          return;
        }
    
        if (!manager.userExists(user)) {
          // 用户不存在
          return;
        }
    
        UserDetails userDetails = manager.loadUserByUsername(user);
        String encodedPassword = userDetails.getPassword();
    
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        if (!encoder.matches(password, encodedPassword)) {
          // 密码不匹配
          return;
        }
    
        /*
          用户认证通过
         */
        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(
                        userDetails,
                        userDetails.getPassword(),
                        // 用户角色
                        userDetails.getAuthorities());
    
        LOGGER.info("userDetails.getAuthorities(): {}", userDetails.getAuthorities());
    
        SecurityContext context =
                SecurityContextHolder.createEmptyContext();
        context.setAuthentication(token);
    
        SecurityContextHolder.setContext(context);
      }
    

    authenticate() 的实现依赖于 JdbcUserDetailsManager 实例 manager

    1. 如果用户名或密码为空,直接返回;
    2. 如果用户不存在 manager.userExists(),直接返回;
    3. 根据用户名检索用户 manager.loadUserByUsername(),包含:用户名、密码(加密)和角色(多个);
    4. 如果密码不匹配 encoder.matches() ,直接返回;
    5. 用户认证通过,设置环境上下文 SecurityContextHolder.setContext();

    UsernamePasswordAuthenticationToken 有两个重载的构造函数,调用不同的构造函数会将实例属性 authenticated 设置为不同的值:true 或 false,表示认证通过或不通过。

    自定义认证过滤器 AuthenticationFilter 实现完成之后,需要将其添加到 过滤器链 上合适的位置:

    SpringSecurityConfig

      @Bean
      AuthenticationFilter createAuthenticationFilter() {
        return new AuthenticationFilter(createJdbcUserDetailsManager());
      }
    
      @Override
      protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .csrf().disable()
                .httpBasic().disable()
                .logout().disable()
                .authorizeRequests(authorize -> authorize.mvcMatchers("/web/guest").permitAll()
                        .mvcMatchers("/web/user").hasRole("USER")
                        .mvcMatchers("/web/admin").hasRole("ADMIN"))
                .addFilterBefore(createAuthenticationFilter(), BasicAuthenticationFilter.class)
                ......
      }
    

    什么是 合适 的位置?
    实际取决于整个 过滤器链 的逻辑。BasicAuthenticationFilter 是用于 Basic 认证的,我们自定义的过滤器也是认证的,放在它的 周围 肯定是没有错的。

    添加用户

    目前系统里没有任何用户,我们需要通过 JdbcUserDetailsManager 实现用户的增/删/查/改,这里以测试用例的方式演示用户增加:

    添加 Maven 依赖

        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    
        <dependency>
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
        </dependency>
    

    JdbcUserDetailsManagerTestCase

    @SpringBootTest
    @RunWith(SpringRunner.class)
    public class JdbcUserDetailsManagerTestCase {
      @Autowired
      private JdbcUserDetailsManager manager;
    
      @Test
      public void add() {
        UserDetails user = User.builder()
                .username("user")
                // 123456
                .password("{bcrypt}$2a$10$Z3/1/TTZsraq.9jWiXfkTumjy1XTwMk9Q.Pb8mUd83c/eSaviSuRC")
                .roles("USER")
                .build();
    
        UserDetails admin = User.builder()
                .username("admin")
                // adcdef
                .password("{bcrypt}$2a$10$vlDmj4YMosNAa59rLEmLqOiruJIqDdOKXZxa83ai/YGsm2sgVg58e")
                .roles("ADMIN")
                .build();
    
        manager.createUser(user);
        manager.createUser(admin);
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/test/java/io/njdi/example/spring/security/test/JdbcUserDetailsManagerTestCase.java

    roles() 可以设置多个角色。

    我们添加了两个用户:

    • 用户名:user,密码:123456,角色:USER
    • 用户名:admin,密码:abcdefg,角色:ADMIN

    编译启动应用,使用已创建的用户访问接口:

    curl -H "spring.security.user: user" -H "spring.security.password: 123456" http://localhost:8080/web/user
    hello user
    
    curl -H "spring.security.user: admin" -H "spring.security.password: abcdef" http://localhost:8080/web/admin
    hello admin
    

    使用用户 user 可以访问接口 /web/user,使用用户 admin 可以访问接口 /web/admin;但是使用用户 admin 不可以访问接口 /web/user,即:角色 ADMIN 的用户不可以访问属于角色 USER 的接口。

    如果用户 admin 想访问接口 /web/user,可以通过两种方式实现:

    1. 用户 admin 同时设置角色 USER 和角色 ADMIN,添加用户时可以通过 roles() 方法设置;
    2. 角色 ADMIN 包含 角色 USER,即:角色 USER 的用户可以访问的接口,角色 ADMIN 的用户也可以访问;

    角色层级

    Spring Security 支持角色之间的 包含 关系:

    SpringSecurityConfig

      @Bean
      RoleHierarchy hierarchy() {
        RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
        hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
    
        return hierarchy;
      }
    

    角色 ADMIN 包含 角色 USER。

    hierarchy.setHierarchy() 支持多个 关系对

    如果角色名称不带有 ROLE_ 前缀,Spring Security 会为我们自动添加。

    编译启动应用,使用用户 admin 访问接口 /web/user:

    curl -H "spring.security.user: admin" -H "spring.security.password: abcdef" http://localhost:8080/web/user
    hello user
    

    可以访问。

    自定义认证/鉴权失败处理器

    访问接口时,如果

    • 认证失败,如:用户名或密码不匹配;
    • 鉴权失败,如:用户没有相应的的接口角色;

    均会返回如下的结果:

    {"timestamp":"2022-02-14T04:07:42.031+00:00","status":403,"error":"Forbidden","path":"/web/user"}
    

    如果我们想实现认证失败返回 401,鉴权失败返回 403,可以通过自定义认证/鉴权失败处理器实现。

    自定义认证失败处理器(AuthenticationEntryPoint):

    SpringSecurityConfig

      @Bean
      AuthenticationEntryPoint createAuthenticationEntryPoint() {
        return (request, response, authException) -> response.getWriter().println("401");
      }
    

    自定义鉴权失败处理器(AccessDeniedHandler):

    SpringSecurityConfig

      @Bean
      AccessDeniedHandler createAccessDeniedHandler() {
        return (request, response, accessDeniedException) -> response.getWriter().println("403");
      }
    

    使用自定义认证失败处理器和鉴权失败处理器:

    SpringSecurityConfig

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

    编译启动应用,使用错误的用户名和密码访问接口,或者访问没有权限的接口:

    curl -H "spring.security.user: user" -H "spring.security.password: 111111" http://localhost:8080/web/user
    401
    
    curl -H "spring.security.user: user" -H "spring.security.password: 123456" http://localhost:8080/web/admin
    403
    

    按预期正常返回。

    Method Authentication/Authorization

    认证和鉴权不但可以声明在接口上(mvcMatchers),还可以声明在方法上,如:Controller/Service/Dao 层方法。

    假设有三个接口:

    /method/guest:任意用户可访问;
    /method/user:访问时需要提供用户名和密码,且访问用户必须拥有角色 USER;
    /method/admin:访问时需要提供用户名和密码,且访问用户必须拥有角色 ADMIN;

    使用与前文类似的认证和鉴权要求,仅接口路径略有不同。

    MethodController

    @RestController
    @RequestMapping("/method")
    public class MethodController {
      @GetMapping("/guest")
      public String helloGuest() {
        return "hello guest";
      }
    
      @GetMapping("/user")
      public String helloUser() {
        return "hello user";
      }
    
      @GetMapping("/admin")
      public String helloAdmin() {
        return "hello admin";
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/controller/MethodController.java

    编译启动应用,访问接口:

    curl http://localhost:8080/method/guest
    hello guest
    
    curl http://localhost:8080/method/user
    hello user
    
    curl http://localhost:8080/method/admin
    hello admin
    

    均可以正常访问。

    这次我们在接口方法 helloGuest()、helloUser()、helloAdmin() 上面声明认证和鉴权。

    启动方法认证和鉴权

    Main

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

    @EnableMethodSecurity 启用方法认证和鉴权。

    声明方法认证和鉴权

    MethodController

    @RestController
    @RequestMapping("/method")
    public class MethodController {
      @GetMapping("/guest")
      @PreAuthorize("permitAll")
      public String helloGuest() {
        return "hello guest";
      }
    
      @GetMapping("/user")
      @PreAuthorize("hasRole('USER')")
      public String helloUser() {
        return "hello user";
      }
    
      @GetMapping("/admin")
      @PreAuthorize("hasRole('ADMIN')")
      public String helloAdmin() {
        return "hello admin";
      }
    }
    

    @PreAuthorize 表示在方法运行前执行认证和鉴权,支持使用 表达式 声明具体的认证和鉴权:

    • permitAll 表示任意用户可访问;
    • hasRole 表示拥有指定角色的用户可访问;

    方法认证和鉴权还支持其它注解,可参考:https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html。

    方法认证和鉴权支持的表达式列表,可参考:https://docs.spring.io/spring-security/reference/servlet/authorization/expression-based.html#el-common-built-in。

    Acl

    目前我们已经可以实现对接口或方法层面(接口本质上也是方法)的认证和鉴权,如果我们想更细粒度的控制接口或方法中 对象 层面的认证和鉴权:

    • 用户仅可以访问有权限的对象

    就需要使用 Spring Security Acl。

    假设存在对象 Entity:

    Entity

    public class Entity {
      private final Integer id;
    
      public Entity(int id) {
        this.id = id;
      }
    
      public int getId() {
        return id;
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/controller/Entity.java

    Spring Security Acl 要求对象必须拥有 getId 方法,且方法返回值必须与 long 兼容;而且对象类不可以是 内部类

    假设存在三个接口:

    • /acl/get:查询并返回指定 id 的对象;
    • /acl/get2:同 /acl/get,详情见后;
    • /acl/gets:查询并返回所有的对象列表;

    AclController

    @RestController
    @RequestMapping("/acl")
    public class AclController {
      private final List<Entity> entities;
    
      {
        entities = new ArrayList<>();
    
        entities.add(new Entity(1));
        entities.add(new Entity(2));
        entities.add(new Entity(3));
      }
    
      @GetMapping("/get")
      public Entity get(@RequestParam int id) {
        return entities.stream().filter(entity -> entity.getId() == id).findFirst().orElse(null);
      }
    
      @GetMapping("/get2")
      public Entity get2(@RequestParam int id) {
        return entities.stream().filter(entity -> entity.getId() == id).findFirst().orElse(null);
      }
    
      @GetMapping("/gets")
      public List<Entity> gets() {
        return entities;
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/controller/AclController.java

    编译启动应用,访问接口:

    curl http://localhost:8080/acl/get?id=1
    {"id":1}
    
    curl http://localhost:8080/acl/get2?id=1
    {"id":1}
    
    curl http://localhost:8080/acl/gets
    [{"id":1},{"id":2},{"id":3}]
    

    添加 Maven 依赖

        <dependency>
          <groupId>org.springframework.security</groupId>
          <artifactId>spring-security-acl</artifactId>
        </dependency>
    
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
    
        <dependency>
          <groupId>org.ehcache</groupId>
          <artifactId>ehcache</artifactId>
        </dependency>
    

    Spring Security Acl 实现依赖于缓存,这里使用 Ehcache。

    启用缓存

    Main

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

    @EnableCaching 表明启用缓存。

    创建数据表

    数据表中存储着用户和对象之间的授权关系。

    CREATE TABLE acl_sid (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        principal BOOLEAN NOT NULL,
        sid VARCHAR(100) NOT NULL,
        UNIQUE KEY unique_acl_sid (sid, principal)
    ) ENGINE=InnoDB;
    
    CREATE TABLE acl_class (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        class VARCHAR(100) NOT NULL,
        class_id_type VARCHAR(100) NOT NULL,
        UNIQUE KEY uk_acl_class (class)
    ) ENGINE=InnoDB;
    
    CREATE TABLE acl_object_identity (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        object_id_class BIGINT UNSIGNED NOT NULL,
        object_id_identity VARCHAR(36) NOT NULL,
        parent_object BIGINT UNSIGNED,
        owner_sid BIGINT UNSIGNED,
        entries_inheriting BOOLEAN NOT NULL,
        UNIQUE KEY uk_acl_object_identity (object_id_class, object_id_identity),
        CONSTRAINT fk_acl_object_identity_parent FOREIGN KEY (parent_object) REFERENCES acl_object_identity (id),
        CONSTRAINT fk_acl_object_identity_class FOREIGN KEY (object_id_class) REFERENCES acl_class (id),
        CONSTRAINT fk_acl_object_identity_owner FOREIGN KEY (owner_sid) REFERENCES acl_sid (id)
    ) ENGINE=InnoDB;
    
    CREATE TABLE acl_entry (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        acl_object_identity BIGINT UNSIGNED NOT NULL,
        ace_order INTEGER NOT NULL,
        sid BIGINT UNSIGNED NOT NULL,
        mask INTEGER UNSIGNED NOT NULL,
        granting BOOLEAN NOT NULL,
        audit_success BOOLEAN NOT NULL,
        audit_failure BOOLEAN NOT NULL,
        UNIQUE KEY unique_acl_entry (acl_object_identity, ace_order),
        CONSTRAINT fk_acl_entry_object FOREIGN KEY (acl_object_identity) REFERENCES acl_object_identity (id),
        CONSTRAINT fk_acl_entry_acl FOREIGN KEY (sid) REFERENCES acl_sid (id)
    ) ENGINE=InnoDB;
    

    https://github.com/njdi/example/blob/main/spring-security/sql/acl.sql

    数据表创建语句可参考:https://docs.spring.io/spring-security/reference/servlet/appendix/database-schema.html#_mysql_and_mariadb,实际使用时需要添加字段 acl_class.class_id_type

    创建 Acl 配置类及注入相关实例

    AclConfig

    @Configuration
    public class AclConfig {
      @Autowired
      private DataSource dataSource;
    
      @Autowired
      private CacheManager cacheManager;
    
      @Bean
      public AuditLogger createAuditLogger() {
        ...
      }
    
      @Bean
      public AclAuthorizationStrategy createAclAuthorizationStrategy() {
        ...
      }
    
      @Bean
      public PermissionGrantingStrategy createPermissionGrantingStrategy() {
        ...
      }
    
      @Bean
      public AclCache createAclCache() {
        ...
      }
    
      @Bean
      public LookupStrategy createLookupStrategy() {
        ...
      }
    
      @Bean
      public AclService createAclService() {
        ...
      }
    
      @Bean
      public MethodSecurityExpressionHandler createMethodSecurityExpressionHandler() {
        ...
      }
    }
    

    https://github.com/njdi/example/blob/main/spring-security/src/main/java/io/njdi/example/spring/security/conf/AclConfig.java

    AuditLogger

    用于记录 Acl 日志。

      @Bean
      public AuditLogger createAuditLogger() {
        return new ConsoleAuditLogger();
      }
    

    AclAuthorizationStrategy

    用于判断什么样的用户可以管理 Acl 权限授予或回收。

      @Bean
      public AclAuthorizationStrategy createAclAuthorizationStrategy() {
        String role = "ROLE_ADMIN";
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(role);
    
        return new AclAuthorizationStrategyImpl(grantedAuthority);
      }
    

    这里设置为具有角色 ADMIN 的用户可以管理 Acl 权限的授予或回收。

    PermissionGrantingStrategy

    用于判断用户是否被授予权限。

      @Bean
      public PermissionGrantingStrategy createPermissionGrantingStrategy() {
        return new DefaultPermissionGrantingStrategy(createAuditLogger());
      }
    

    AclCache

    Acl 专用缓存。

      @Bean
      public AclCache createAclCache() {
        Cache cache = cacheManager.getCache("aclCache");
    
        return new SpringCacheBasedAclCache(cache, createPermissionGrantingStrategy(), createAclAuthorizationStrategy());
      }
    

    LookupStrategy

    Acl 查询策略。

      @Bean
      public LookupStrategy createLookupStrategy() {
        BasicLookupStrategy basicLookupStrategy = new BasicLookupStrategy(dataSource, createAclCache(),
                createAclAuthorizationStrategy(), createPermissionGrantingStrategy());
    
        basicLookupStrategy.setAclClassIdSupported(true);
    
        return basicLookupStrategy;
      }
    

    注意 basicLookupStrategy.setAclClassIdSupported(true) 的使用,与数据库添加字段 acl_class.class_id_type 有关。

    AclService

    用于管理 Acl 权限的授予或回收。

      @Bean
      public AclService createAclService() {
        JdbcMutableAclService jdbcMutableAclService = new JdbcMutableAclService(dataSource, createLookupStrategy(),
                createAclCache());
    
        jdbcMutableAclService.setClassIdentityQuery("SELECT @@IDENTITY");
        jdbcMutableAclService.setSidIdentityQuery("SELECT @@IDENTITY");
    
        jdbcMutableAclService.setAclClassIdSupported(true);
    
        return jdbcMutableAclService;
      }
    

    注意 jdbcMutableAclService.setClassIdentityQuery("SELECT @@IDENTITY")jdbcMutableAclService.setSidIdentityQuery("SELECT @@IDENTITY") 的使用,与数据库使用 MySQL 有关。

    注意 jdbcMutableAclService.setAclClassIdSupported(true) 的使用,与数据库添加字段 acl_class.class_id_type 有关。

    MethodSecurityExpressionHandler

    方法表达式处理器,联动 AclService 校验权限。

      @Bean
      public MethodSecurityExpressionHandler createMethodSecurityExpressionHandler() {
        PermissionEvaluator permissionEvaluator
                = new AclPermissionEvaluator(createAclService());
    
        DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler =
                new DefaultMethodSecurityExpressionHandler();
        methodSecurityExpressionHandler.setPermissionEvaluator(permissionEvaluator);
    
        return methodSecurityExpressionHandler;
      }
    

    Spring Security Acl 涉及类较多,建议查看相关类的 JavaDoc 了解详情。

    添加 Acl

    添加 Maven 依赖

        <dependency>
          <groupId>org.springframework.security</groupId>
          <artifactId>spring-security-test</artifactId>
        </dependency>
    

    AclTestCase

    @SpringBootTest
    @RunWith(SpringRunner.class)
    public class AclTestCase {
      @Autowired
      private JdbcMutableAclService aclService;
    
      @Test
      @WithMockUser
      @Transactional
      @Rollback(false)
      public void insert() {
        ObjectIdentity oi = new ObjectIdentityImpl(Entity.class, 1);
        MutableAcl acl;
        try {
          acl = (MutableAcl) aclService.readAclById(oi);
        } catch (NotFoundException nfe) {
          acl = aclService.createAcl(oi);
        }
    
        Sid sid = new GrantedAuthoritySid("ROLE_USER");
        Permission permission = BasePermission.READ;
    
        acl.insertAce(acl.getEntries().size(), permission, sid, true);
    
        aclService.updateAcl(acl);
      }
    }
    

    @WithMockUser 用于模拟用户,如前文所述,Acl 角色 ADMIN 的用户可授予或回收,实际使用默认即可。

    ID 为 1 的对象(Entity)的 (READ)权限被授予给角色 USER,即:属于角色 USER 的用户可以读取 ID 为 1 的对象。

    用户和角色的对应关系在前文中的认证和鉴权部分已定义。

    声明 Acl

    AclController

      @GetMapping("/get")
      @PreAuthorize("hasPermission(#id, 'io.njdi.example.spring.security.controller.Entity', 'read')")
      public Entity get(@RequestParam int id) {
        return entities.stream().filter(entity -> entity.getId() == id).findFirst().orElse(null);
      }
    

    id 表示使用请求参数 id 的值作为 ID 执行 Acl 校验。

    访问接口 /acl/get 或调用方法 get 之前,要求用户具有指定 ID 对象的读权限:

    curl -H "spring.security.user: user" -H "spring.security.password: 123456" http://localhost:8080/acl/get?id=1
    {"id":1}
    
    curl -H "spring.security.user: user" -H "spring.security.password: 123456" http://localhost:8080/acl/get?id=2
    403
    

    用户 user 属于角色 USER,仅可以访问 ID 为 1 的 Entity 对象;访问ID 不为 1 的 Entity 对象会返回 403。

      @GetMapping("/get2")
      @PostAuthorize("hasPermission(returnObject, 'read')")
      public Entity get2(@RequestParam int id) {
        return entities.stream().filter(entity -> entity.getId() == id).findFirst().orElse(null);
      }
    

    访问接口 /acl/get2 或调用方法 get2 之后,要求用户具有返回对象的读权限,本质上也是基于 Entity ID 校验。

      @GetMapping("/gets")
      @PreAuthorize("isAuthenticated()")
      @PostFilter("hasPermission(filterObject, 'read')")
      public List<Entity> gets() {
        return entities;
      }
    

    访问接口 /acl/gets 或调用方法 gets 之前,要求用户必须被认证;之后,要求 过滤 用户不具备读权限的对象:

    curl -H "spring.security.user: user" -H "spring.security.password: 111111" http://localhost:8080/acl/gets
    401
    
    curl -H "spring.security.user: user" -H "spring.security.password: 123456" http://localhost:8080/acl/gets
    [{"id":1}]
    

    认证不通过,返回 401;认证通过,仅返回 ID 为 1 的对象,其余对象未授权,会被过滤掉。

    结语

    Spring Security Auth/Acl 提供的功能十分强大,设计的也很精巧,天然具备和 SpringBoot 应用整合的优势;但是整个体系十分庞大,涉及的概念也非常多,刚开始接触的时候仅借助官方的示例并不能很好地上手,很容易遇到一些“坑”,希望本文的内容能够对大家有所帮助。

  • 相关阅读:
    linux系统java环境变量的下载与安装
    秋招-算法-查分与前缀和数组篇
    PHP语言基础知识,电商API代码生成
    linux高频面试题目
    总结tab栏切换实现的方法,以及增加滚动实现tab栏切换的效果
    【Go入门】struct类型
    大众集团「官宣」造芯计划,汽车芯片「玩法」大变样
    从零开始的力扣刷题记录-第九十天
    .NET周刊【7月第2期 2024-07-14】
    数据挖掘与分析应用2:大厂制作周报报表制作方法与标准格式,联动使用index和match配合sumif和sumifs函数
  • 原文地址:https://www.cnblogs.com/yurunmiao/p/15893955.html