首頁 > 軟體

Spring Security動態許可權的實現方法詳解

2022-06-16 14:02:09

最近在做 TienChin 專案,用的是 RuoYi-Vue 腳手架,在這個腳手架中,存取某個介面需要什麼許可權,這個是在程式碼中寫死的,具體怎麼實現的,鬆哥下篇文章來和大家分析,有的小夥伴可能希望能讓這個東西像 vhr 一樣,可以在資料庫中動態設定,因此這篇文章和小夥伴們簡單介紹下 Spring Security 中的動態許可權方案,以便於小夥伴們更好的理解 TienChin 專案中的許可權方案。

1. 動態管理許可權規則

通過程式碼來設定 URL 攔截規則和請求 URL 所需要的許可權,這樣就比較死板,如果想要調整存取某一個 URL 所需要的許可權,就需要修改程式碼。

動態管理許可權規則就是我們將 URL 攔截規則和存取 URL 所需要的許可權都儲存在資料庫中,這樣,在不改變原始碼的情況下,只需要修改資料庫中的資料,就可以對許可權進行調整。

1.1 資料庫設計

簡單起見,我們這裡就不引入許可權表了,直接使用角色表,使用者和角色關聯,角色和資源關聯,設計出來的表結構如圖 13-9 所示。

圖13-9  一個簡單的許可權資料庫結構

menu 表是相當於我們的資源表,它裡邊儲存了存取規則,如圖 13-10 所示。

圖13-10  存取規則

role 是角色表,裡邊定義了系統中的角色,如圖 13-11 所示。

圖13-11  使用者角色表

user 是使用者表,如圖 13-12 所示。

圖13-12  使用者表

user_role 是使用者角色關聯表,使用者具有哪些角色,可以通過該表體現出來,如圖 13-13 所示。

圖13-13  使用者角色關聯表

menu_role 是資源角色關聯表,存取某一個資源,需要哪些角色,可以通過該表體現出來,如圖 13-14 所示。

圖13-14  資源角色關聯表

至此,一個簡易的許可權資料庫就設計好了(在本書提供的案例中,有SQL指令碼)。

1.2 實戰

專案建立

建立 Spring Boot 專案,由於涉及資料庫操作,這裡選用目前大家使用較多的 MyBatis 框架,所以除了引入 Web、Spring Security 依賴之外,還需要引入 MyBatis 以及 MySQL 依賴。

最終的 pom.xml 檔案內容如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.3</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

專案建立完成後,接下來在 application.properties 中設定資料庫連線資訊:

spring.datasource.username=root
spring.datasource.password=123
spring.datasource.url=jdbc:mysql:///security13?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

設定完成後,我們的準備工作就算完成了。

建立實體類

根據前面設計的資料庫,我們需要建立三個實體類。

首先來建立角色類 Role:

public class Role {
    private Integer id;
    private String name;
    private String nameZh;
       //省略getter/setter
}

然後建立選單類 Menu:

public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;
    //省略getter/setter
}

選單類中包含一個 roles 屬性,表示存取該項資源所需要的角色。

最後我們建立 User 類:

public class User implements UserDetails {
    private Integer id;
    private String password;
    private String username;
    private boolean enabled;
    private boolean locked;
    private List<Role> roles;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                        .map(r -> new SimpleGrantedAuthority(r.getName()))
                        .collect(Collectors.toList());
    }
    @Override
    public String getPassword() {
        return password;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return !locked;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return enabled;
    }
    //省略其他getter/setter
}

由於資料庫中有 enabled 和 locked 欄位,所以 isEnabled() 和 isAccountNonLocked() 兩個方法如實返回,其他幾個賬戶狀態方法預設返回 true 即可。在 getAuthorities() 方法中,我們對 roles 屬性進行遍歷,組裝出新的集合物件返回即可。

建立Service

接下來我們建立 UserService 和 MenuService,並提供相應的查詢方法。

先來看 UserService:

@Service
public class UserService implements UserDetailsService {
    @Autowired
    UserMapper userMapper;
    @Override
    public UserDetails loadUserByUsername(String username) 
                                             throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("使用者不存在");
        }
        user.setRoles(userMapper.getUserRoleByUid(user.getId()));
        return user;
    }
}

這段程式碼應該不用多說了,不熟悉的讀者可以參考本書 2.4 節。

對應的 UserMapper 如下:

@Mapper
public interface UserMapper {
    List<Role> getUserRoleByUid(Integer uid);
    User loadUserByUsername(String username);
}

UserMapper.xml:

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.base_on_url_dy.mapper.UserMapper">
    <select id="loadUserByUsername" 
                         resultType="org.javaboy.base_on_url_dy.model.User">
        select * from user where username=#{username};
    </select>
    <select id="getUserRoleByUid" 
                         resultType="org.javaboy.base_on_url_dy.model.Role">
        select r.* from role r,user_role ur where ur.uid=#{uid} and ur.rid=r.id
    </select>
</mapper>

再來看 MenuService,該類只需要提供一個方法,就是查詢出所有的 Menu 資料,程式碼如下:

@Service
public class MenuService {
    @Autowired
    MenuMapper menuMapper;
    public List<Menu> getAllMenu() {
        return menuMapper.getAllMenu();
    }
}

MenuMapper:

@Mapper
public interface MenuMapper {
    List<Menu> getAllMenu();
}

MenuMapper.xml:

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.base_on_url_dy.mapper.MenuMapper">
    <resultMap id="MenuResultMap" 
                                type="org.javaboy.base_on_url_dy.model.Menu">
        <id property="id" column="id"/>
        <result property="pattern" column="pattern"></result>
        <collection property="roles" 
                              ofType="org.javaboy.base_on_url_dy.model.Role">
            <id column="rid" property="id"/>
            <result column="rname" property="name"/>
            <result column="rnameZh" property="nameZh"/>
        </collection>
    </resultMap>
    <select id="getAllMenu" resultMap="MenuResultMap">
        select m.*,r.id as rid,r.name as rname,r.nameZh as rnameZh from menu m left join menu_role mr on m.`id`=mr.`mid` left join role r on r.`id`=mr.`rid`
    </select>
</mapper>

需要注意,由於每一個 Menu 物件都包含了一個 Role 集合,所以這個查詢是一對多,這裡通過 resultMap 來進行查詢結果對映。

至此,所有基礎工作都完成了,接下來設定 Spring Security。

設定Spring Security

回顧 13.3.6 小節的內容,SecurityMetadataSource 介面負責提供受保護物件所需要的許可權。在本案例中,受保護物件所需要的許可權儲存在資料庫中,所以我們可以通過自定義類繼承自 FilterInvocationSecurityMetadataSource,並重寫 getAttributes 方法來提供受保護物件所需要的許可權,程式碼如下:

@Component
public class CustomSecurityMetadataSource 
                         implements FilterInvocationSecurityMetadataSource {
    @Autowired
    MenuService menuService;
    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) 
                                               throws IllegalArgumentException {
        String requestURI = 
                   ((FilterInvocation) object).getRequest().getRequestURI();
        List<Menu> allMenu = menuService.getAllMenu();
        for (Menu menu : allMenu) {
            if (antPathMatcher.match(menu.getPattern(), requestURI)) {
                String[] roles = menu.getRoles().stream()
                               .map(r -> r.getName()).toArray(String[]::new);
                return SecurityConfig.createList(roles);
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

自定義 CustomSecurityMetadataSource 類並實現 FilterInvocationSecurityMetadataSource 介面,然後重寫它裡邊的三個方法:

  • getAttributes:該方法的引數是受保護物件,在基於 URL 地址的許可權控制中,受保護物件就是 FilterInvocation;該方法的返回值則是存取受保護物件所需要的許可權。在該方法裡邊,我們首先從受保護物件 FilterInvocation 中提取出當前請求的 URL 地址,例如 /admin/hello,然後通過 menuService 物件查詢出所有的選單資料(每條資料中都包含存取該條記錄所需要的許可權),遍歷查詢出來的選單資料,如果當前請求的 URL 地址和選單中某一條記錄的 pattern 屬性匹配上了(例如 /admin/hello 匹配上 /admin/**),那麼我們就可以獲取當前請求所需要的許可權。從 menu 物件中獲取 roles 屬性,並將其轉為一個陣列,然後通過 SecurityConfig.createList 方法建立一個 Collection<ConfigAttribute> 物件並返回。如果當前請求的 URL 地址和資料庫中 menu 表的所有項都匹配不上,那麼最終返回 null。如果返回 null,那麼受保護物件到底能不能存取呢?這就要看 AbstractSecurityInterceptor 物件中的 rejectPublicInvocations 屬性了,該屬性預設為 false,表示當 getAttributes 方法返回 null 時,允許存取受保護物件(回顧 13.4.4 小節中關於 AbstractSecurityInterceptor#beforeInvocation 的講解)。
  • getAllConfigAttributes:該方法可以用來返回所有的許可權屬性,以便在專案啟動階段做校驗,如果不需要校驗,則直接返回 null 即可。
  • supports:該方法表示當前物件支援處理的受保護物件是 FilterInvocation。

CustomSecurityMetadataSource 類設定完成後,接下來我們要用它來代替預設的 SecurityMetadataSource 物件,具體設定如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    CustomSecurityMetadataSource customSecurityMetadataSource;
    @Autowired
    UserService userService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) 
                                                                throws Exception {
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ApplicationContext applicationContext = 
                              http.getSharedObject(ApplicationContext.class);
        http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
                .withObjectPostProcessor(new 
                           ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O 
                                                            postProcess(O object) {
           object.setSecurityMetadataSource(customSecurityMetadataSource);
                        return object;
                    }
                });
        http.formLogin()
                .and()
                .csrf().disable();
    }
}

關於使用者的設定無需多說,我們重點來看 configure(HttpSecurity) 方法。

由於存取路徑規則和所需要的許可權之間的對映關係已經儲存在資料庫中,所以我們就沒有必要在 Java 程式碼中設定對映關係了,同時這裡的許可權對比也不會用到許可權表示式,所以我們通過 UrlAuthorizationConfigurer 來進行設定。

在設定的過程中,通過 withObjectPostProcessor 方法呼叫 ObjectPostProcessor 物件後置處理器,在物件後置處理器中,將 FilterSecurityInterceptor 中的 SecurityMetadataSource 物件替換為我們自定義的 customSecurityMetadataSource 物件即可。

2. 測試

接下來建立 HelloController,程式碼如下:

@RestController
public class HelloController {
    @GetMapping("/admin/hello")
    public String admin() {
        return "hello admin";
    }
    @GetMapping("/user/hello")
    public String user() {
        return "hello user";
    }
    @GetMapping("/guest/hello")
    public String guest() {
        return "hello guest";
    }
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

最後啟動專案進行測試。

首先使用 admin/123 進行登入,該使用者具備 ROLE_ADMIN 角色,ROLE_ADMIN 可以存取 /admin/hello/user/hello 以及 /guest/hello 三個介面。

接下來使用 user/123 進行登入,該使用者具備 ROLE_USER 角色,ROLE_USER 可以存取 /user/hello 以及 /guest/hello 兩個介面。

最後使用 javaboy/123 進行登入,該使用者具備 ROLE_GUEST 角色,ROLE_GUEST 可以存取 /guest/hello 介面。

由於 /hello 介面不包含在 URL-許可權 對映關係中,所以任何使用者都可以存取 /hello 介面,包括匿名使用者。如果希望所有的 URL 地址都必須在資料庫中設定 URL-許可權 對映關係後才能存取,那麼可以通過如下設定實現:

http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
        .withObjectPostProcessor(new  
                           ObjectPostProcessor<FilterSecurityInterceptor>() {
            @Override
            public <O extends FilterSecurityInterceptor> O 
                                                           postProcess(O object) {   
           object.setSecurityMetadataSource(customSecurityMetadataSource);
                object.setRejectPublicInvocations(true);
                return object;
            }
        });

通過設定 FilterSecurityInterceptor 中的 rejectPublicInvocations 屬性為 true,就可以關閉URL的公開存取,所有 URL 必須具備對應的許可權才能存取。

以上就是Spring Security動態許可權的實現方法詳解的詳細內容,更多關於Spring Security動態許可權的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com