Spring Security Auth/Acl 实践指南

导语

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

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

完整的示例位于 example/spring-security 中,仓库地址://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>

//github.com/njdi/example/blob/main/pom.xml
//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);
  }
}

//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";
  }
}

//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 //localhost:8080/web/guest
curl //localhost:8080/web/user
curl //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>

用户名、密码和角色信息的存储机制有多种实现方式,可参考://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);

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

数据库使用 MySQL 时,数据表创建语句需要进行调整,可参考://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

//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 方法。

//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 //localhost:8080/web/guest
hello guest

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

curl //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. 如果不匹配,则 什么也不做,继续执行 过滤器链 上的下一个过滤器;

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

过滤器链可参考://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);
  }
}

//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);
  }
}

//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" //localhost:8080/web/user
hello user

curl -H "spring.security.user: admin" -H "spring.security.password: abcdef" //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" //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" //localhost:8080/web/user
401

curl -H "spring.security.user: user" -H "spring.security.password: 123456" //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";
  }
}

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

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

curl //localhost:8080/method/guest
hello guest

curl //localhost:8080/method/user
hello user

curl //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 表示拥有指定角色的用户可访问;

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

方法认证和鉴权支持的表达式列表,可参考://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;
  }
}

//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;
  }
}

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

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

curl //localhost:8080/acl/get?id=1
{"id":1}

curl //localhost:8080/acl/get2?id=1
{"id":1}

curl //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;

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

数据表创建语句可参考://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() {
    ...
  }
}

//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" //localhost:8080/acl/get?id=1
{"id":1}

curl -H "spring.security.user: user" -H "spring.security.password: 123456" //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" //localhost:8080/acl/gets
401

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

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

结语

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