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 應用整合的優勢;但是整個體系十分龐大,涉及的概念也非常多,剛開始接觸的時候僅藉助官方的示例並不能很好地上手,很容易遇到一些「坑」,希望本文的內容能夠對大家有所幫助。