首頁 > 軟體

詳解Spring Security如何在許可權中使用萬用字元

2022-06-28 18:06:02

前言

小夥伴們知道,在 Shiro 中,預設是支援許可權萬用字元的,例如系統使用者有如下一些許可權:

  • system:user:add
  • system:user:delete
  • system:user:select
  • system:user:update

現在給使用者授權的時候,我們可以像上面這樣,一個許可權一個許可權的設定,也可以直接用萬用字元:

system:user:*

這個萬用字元就表示擁有針對使用者的所有許可權。

今天我們來聊聊 Spring Security 中對此如何處理,也順便來看看 TienChin 專案中,這塊該如何改進。

1. SpEL

要搞明白基於註解的許可權管理,那麼得首先理解 SpEL,不需要了解多深入,我這裡就簡單介紹下。

Spring Expression Language(簡稱 SpEL)是一個支援查詢和操作執行時物件導航圖功能的強大的表示式語言。它的語法類似於傳統 EL,但提供額外的功能,最出色的就是函數呼叫和簡單字串的模板函數。

SpEL 給 Spring 社群提供一種簡單而高效的表示式語言,一種可貫穿整個 Spring 產品組的語言。這種語言的特性基於 Spring 產品的需求而設計,這是它出現的一大特色。

在我們離不開 Spring 框架的同時,其實我們也已經離不開 SpEL 了,因為它太好用、太強大了,SpEL 在整個 Spring 家族中也處於一個非常重要的位置。但是很多時候,我們對它的只瞭解一個大概,其實如果你係統的學習過 SpEL,那麼上面 Spring Security 那個註解其實很好理解。

我先通過一個簡單的例子來和大家捋一捋 SpEL。

為了省事,我就建立一個 Spring Boot 工程來和大家演示,建立的時候不用加任何額外的依賴,就最最基礎的依賴即可。

程式碼如下:

String expressionStr = "1 + 2";
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(expressionStr);

expressionStr 是我們自定義的一個表示式字串,這個字串通過一個 ExpressionParser 物件將之解析為一個 Expression,接下來就可以執行這個 exp 了。

執行的時候有兩種方式,對於我們上面這種不帶任何額外變數的,我們可以直接執行,直接執行的方式如下:

Object value = exp.getValue();
System.out.println(value.toString());

這個列印結果為 3。

我記得之前有個小夥伴在群裡問想執行一個字串表示式,但是不知道怎麼辦,js 中有 eval 函數很方便,我們 Java 中也有 SpEL,一樣也很方便。

不過很多時候,我們要執行的表示式可能比較複雜,這時候上面這種呼叫方式就不太夠用了。

此時我們可以為要呼叫的表示式設定一個上下文環境,這個時候就會用到 EvaluationContext 或者它的子類,如下:

StandardEvaluationContext context = new StandardEvaluationContext();
System.out.println(exp.getValue(context));

當然上面這個表示式不需要設定上下文環境,我舉一個需要設定上下文環境的例子。

例如我現在有一個 User 類,如下:

public class User {
    private Integer id;
    private String username;
    private String address;
    //省略 getter/setter
}

現在我的表示式是這樣:

String expression = "#user.username";
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(expression);
StandardEvaluationContext ctx = new StandardEvaluationContext();
User user = new User();
user.setAddress("廣州");
user.setUsername("javaboy");
user.setId(99);
ctx.setVariable("user", user);
String value = exp.getValue(ctx, String.class);
System.out.println("value = " + value);

這個表示式就表示獲取 user 物件的 username 屬性。將來建立一個 user 物件,放到 StandardEvaluationContext 中,並基於此物件執行表示式,就可以列印出來想要的結果。

如果我們將 user 物件設定為 rootObject,那麼表示式中就不需要 user 了,如下:

String expression = "username";
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(expression);
StandardEvaluationContext ctx = new StandardEvaluationContext();
User user = new User();
user.setAddress("廣州");
user.setUsername("javaboy");
user.setId(99);
ctx.setRootObject(user);
String value = exp.getValue(ctx, String.class);
System.out.println("value = " + value);

表示式就一個 username 字串,將來執行的時候,會自動從 user 中找到 username 的值並返回。

當然表示式也可以是方法,例如我在 User 類中新增如下兩個方法:

public String sayHello(Integer age) {
    return "hello " + username + ";age=" + age;
}
public String sayHello() {
    return "hello " + username;
}

我們就可以通過表示式呼叫這兩個方法,如下:

呼叫有參的 sayHello:

String expression = "sayHello(99)";
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(expression);
StandardEvaluationContext ctx = new StandardEvaluationContext();
User user = new User();
user.setAddress("廣州");
user.setUsername("javaboy");
user.setId(99);
ctx.setRootObject(user);
String value = exp.getValue(ctx, String.class);
System.out.println("value = " + value);

就直接寫方法名然後執行就行了。

呼叫無參的 sayHello:

String expression = "sayHello";
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(expression);
StandardEvaluationContext ctx = new StandardEvaluationContext();
User user = new User();
user.setAddress("廣州");
user.setUsername("javaboy");
user.setId(99);
ctx.setRootObject(user);
String value = exp.getValue(ctx, String.class);
System.out.println("value = " + value);

這些就都好懂了。

甚至,我們的表示式也可以涉及到 Spring 中的一個 Bean,例如我們向 Spring 中註冊如下 Bean:

@Service("us")
public class UserService {
    public String sayHello(String name) {
        return "hello " + name;
    }
}

然後通過 SpEL 表示式來呼叫這個名為 us 的 bean 中的 sayHello 方法,如下:

@Autowired
BeanFactory beanFactory;
@Test
void contextLoads() {
    String expression = "@us.sayHello('javaboy')";
    ExpressionParser parser = new SpelExpressionParser();
    Expression exp = parser.parseExpression(expression);
    StandardEvaluationContext ctx = new StandardEvaluationContext();
    ctx.setBeanResolver(new BeanFactoryResolver(beanFactory));
    String value = exp.getValue(ctx, String.class);
    System.out.println("value = " + value);
}

給設定的上下文環境設定一個 bean 解析器,這個 bean 解析器會自動跟進名字從 Spring 容器中找打響應的 bean 並執行對應的方法。

當然,關於 SpEL 的玩法還有很多,我就不一一列舉了。這裡主要是想讓小夥伴們知道,有這麼個技術,方便大家理解 @PreAuthorize 註解的原理。

總結一下:

1.在使用 SpEL 的時候,如果表示式直接寫的就是方法名,那是因為在構建 SpEL 上下文的時候,已經設定了 RootObject 了,我們所呼叫的方法,實際上就是 RootObject 物件中的方法。

2.在使用 SpEL 物件的時候,如果像呼叫非 RootObject 物件中的方法,那麼表示式需要加上 @物件名 作為字首,例如前面案例的 @us。

2. 自定義許可權該如何寫

那麼自定義許可權到底該如何寫呢?首先我們來看下在 Spring Security 中,不涉及到萬用字元的許可權該怎麼處理。

鬆哥舉一個簡單的例子,我們建立一個 Spring Boot 工程,引入 Web 和 Security 依賴,為了方便,這裡的使用者我直接建立在記憶體中,設定如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager m = new InMemoryUserDetailsManager();
        m.createUser(User.withUsername("javaboy").password("{noop}123").authorities("system:user:add","system:user:delete").build());
        return m;
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .permitAll();
        return http.build();
    }

}

都是常規設定,沒啥好說的。注意前面的註解,開啟基於註解的許可權控制。

這裡我多囉嗦一句,大家看建立使用者的時候,呼叫的是 authorities 方法去設定許可權的,這個跟 roles 方法其實沒啥大的區別,呼叫 roles 方法會自動為你設定的字串新增一個 ROLE_ 字首,其他的其實都一樣。在 Spring Security 中,role 和 permission 僅僅只是人為劃分出來的東西,底層的實現包括判斷邏輯基本上都是沒有區別的。

接下來我們定義四個測試介面,如下:

@RestController
public class UserController {

    @GetMapping("/add")
    @PreAuthorize("hasPermission('/add','system:user:add')")
    public String addUser() {
        return "add";
    }
    @GetMapping("/delete")
    @PreAuthorize("hasPermission('/delete','system:user:delete')")
    public String deleteUser() {
        return "delete";
    }
    @GetMapping("/update")
    @PreAuthorize("hasPermission('/update','system:user:update')")
    public String updateUser() {
        return "update";
    }
    @GetMapping("/select")
    @PreAuthorize("hasPermission('/select','system:user:select')")
    public String selectUser() {
        return "select";
    }
}

介面存取都需要不同的許可權。

此時如果大家啟動專案去此時,系統會提示你四個介面統統都不具備許可權,這是啥原因呢?我們來繼續分析。

小夥伴們看這裡,呼叫的時候 @PreAuthorize 註解中執行寫方法名,不用寫物件名,說明呼叫的方法是 RootObject 中的方法,這裡的 RootObject 實際上就是 SecurityExpressionRoot,我們來看看這個物件中的 hasPermission 方法:

@Override
public boolean hasPermission(Object target, Object permission) {
    return this.permissionEvaluator.hasPermission(this.authentication, target, permission);
}
@Override
public boolean hasPermission(Object targetId, String targetType, Object permission) {
    return this.permissionEvaluator.hasPermission(this.authentication, (Serializable) targetId, targetType,
            permission);
}

最終的呼叫又指向了 permissionEvaluator 物件。

在 Spring Security 中,permissionEvaluator 有一個統一的介面就是 PermissionEvaluator,但是這個介面只有一個實現類,就是 DenyAllPermissionEvaluator,看名字就知道,這是拒絕所有。

public class DenyAllPermissionEvaluator implements PermissionEvaluator {

	private final Log logger = LogFactory.getLog(getClass());

	/**
	 * @return false always
	 */
	@Override
	public boolean hasPermission(Authentication authentication, Object target, Object permission) {
		return false;
	}

	/**
	 * @return false always
	 */
	@Override
	public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
			Object permission) {
		return false;
	}

}

這兩個方法裡啥都沒幹,直接返回了 false,這下就破案了!

所以,在 Spring Security 中,如果想判斷許可權,需要自己提供一個 PermissionEvaluator 的範例,我們來看下:

@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority authority : authorities) {
            if (authority.getAuthority().equals(permission)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        return false;
    }
}

我這裡的判斷邏輯比較簡單,所以只需要實現第一個方法就行了,這個方法三個引數,第一個引數就是當前登入成功的使用者物件,後面兩個引數則是我們在 @PreAuthorize("hasPermission('/select','system:user:select')") 註解中的兩個引數,現在該有的東西都有了,我們只需要判斷需要的許可權當前使用者是否有就行了。

這個自定義的許可權評估器寫好之後,註冊到 Spring 容器就行了,其他什麼事情都不用做。

接下來我們就可以對剛才的四個介面進行測試了,測試過程我就不演示了,小夥伴們自行用 postman 測試就行了。

3. 許可權萬用字元

看明白了上面的邏輯,現在不用我說,大家也知道許可權萬用字元在 Spring Security 中是不支援的(無論你在 @PreAuthorize 註解中寫的 SpEL 是哪個,呼叫的是哪個方法,都是不支援許可權萬用字元的)。

例如我現在這樣描述我的使用者許可權:

@Bean
UserDetailsService userDetailsService() {
    InMemoryUserDetailsManager m = new InMemoryUserDetailsManager();
    m.createUser(User.withUsername("javaboy").password("{noop}123").authorities("system:user:*").build());
    return m;
}

我想用 system:user:* 字串表示 javaboy 具有針對使用者的所有許可權。

直接這樣寫肯定是不行的,最終字串比較一定是不會通過的。

那麼怎麼辦呢?用正則似乎也不太行,因為 * 在正則中不代表所有字元,如果拆解字串去比較,功能雖然也行得通,但是比較麻煩。

想來想去,想到一個辦法,不知道小夥伴們是否還記得我們之前在 vhr 中用過的 AntPathMatcher,用這個不就行了!

修改後的 CustomPermissionEvaluator 如下:

@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {

    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority authority : authorities) {
            if (antPathMatcher.match(authority.getAuthority(), (String) permission)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        return false;
    }
}

修改之後,現在只要使用者具備 system:user:* 許可權,就四個介面都能存取了。

4. TienChin 專案怎麼做的

TienChin 專案用的是 RuoYi-Vue 腳手架,我們來看下這個腳手架的實現方式:

@PreAuthorize("@ss.hasPermi('tienchin:channel:query')")
@GetMapping("/list")
public TableDataInfo getChannelList() {
    startPage();
    List<Channel> list = channelService.list();
    return getDataTable(list);
}

看了前面的講解,現在 @ss.hasPermi('tienchin:channel:query') 應該很好懂了:

ss 是一個註冊在 Spring 容器中的 bean,對應的類位於 org.javaboy.tienchin.framework.web.service.PermissionService 中。

很明顯,hasPermi 就是這個類中的方法。

這個 hasPermi 方法的邏輯其實很簡單:

public boolean hasPermi(String permission) {
    if (StringUtils.isEmpty(permission)) {
        return false;
    }
    LoginUser loginUser = SecurityUtils.getLoginUser();
    if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
        return false;
    }
    return hasPermissions(loginUser.getPermissions(), permission);
}
private boolean hasPermissions(Set<String> permissions, String permission) {
    return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}

這個判斷邏輯很簡單,就是獲取到當前登入的使用者,判斷當前登入使用者的許可權集合中是否具備當前請求所需要的許可權。具體的判斷邏輯沒啥好說的,就是看集合中是否存在某個字串,從判斷的邏輯中我們也可以看出來,這個許可權也是不支援萬用字元的。

到此這篇關於詳解Spring Security如何在許可權中使用萬用字元的文章就介紹到這了,更多相關Spring Security使用萬用字元內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


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