在前面的文章中,我們通過在 SecurityConfig 配置文件 中配置對應路徑所需要的角色,然后在設置用戶擁有的角色,以此來判斷用戶是否能訪問路徑。
在我們實際的項目開發中,隨着系統升級和迭代,我們開發出的接口越來越多,我們就不得不在配置文件中追加很多類似的代碼,這不僅是費時費力,而且還對系統原有的代碼造成一定的破壞,這明顯是有大問題的。
如果我們可以像加載動態中賬號那樣,也動態加載權限,那就好了。
下面我們就來討論如何動態加載權限吧
在 Security中,我們可以在配置認證和授權的策略中配置
對象后處理器 ObjectPostProcessor
,通過它我們可以自定義的判斷每次請求url應該如何處理。
一、對象后處理器ObjectPostProcessor
ObjectPostProcessor是配置在HttpSecurity中的,ObjectPostProcessor主要配置2個參數,他們分別是 SecurityMetadataSource
授權的元數據和 AccessDecisionManager
權限決策管理。
1. SecurityMetadataSource
SecurityMetadataSource的參數為 FilterInvocationSecurityMetadataSource
,這個類主要是用來獲取當前訪問的地址需要哪些權限的,這是一個接口,我們可以實現它,動態的到數據源中獲取。
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Console;
import cn.hutool.core.util.ArrayUtil;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
/**
* 自定義 獲取當前訪問的地址需要哪些權限規則
*
* @author lixingwu
*/
@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
/*** ant 路徑匹配規則 */
private final static AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
/**
* 這個是主要的方法,該方法會需要返回url需要的權限列表
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
FilterInvocation filterInvocation = (FilterInvocation) object;
String requestUrl = filterInvocation.getRequestUrl();
Console.log("請求地址為[{}]", requestUrl);
// 忽略指定的url
Set<String> permitSet = permitAll();
if (CollUtil.isNotEmpty(permitSet)) {
for (String matcher : permitSet) {
// 如果當前url和需要忽略的url匹配上就直接返回null,直接放行
if (ANT_PATH_MATCHER.match(matcher, requestUrl)) {
Console.log("請求地址為[{}]和忽略規則[{}]匹配,直接放行。", requestUrl, matcher);
return null;
}
}
}
// 根據路徑查詢路徑需要什么權限才能訪問
String[] permissions = findByPath(requestUrl);
if (ArrayUtil.isNotEmpty(permissions)) {
Console.log("請求地址為[{}]需要權限[{}]", requestUrl, permissions);
return createList(permissions);
}
// 沒有匹配上的資源,都是登錄訪問
// 這里直接給一個權限login,用於標識登錄后才能訪問,此處做不處理
// 是否應該放行是決策管理器AccessDecisionManager需要做的事情
return createList("login");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
private List<ConfigAttribute> createList(String... attributeNames) {
return org.springframework.security.access.SecurityConfig.createList(attributeNames);
}
/*-----------------------------------------------*/
/*--------------- 以下是模擬的數據 ----------------*/
/*-----------------------------------------------*/
/***
* 該方法用於模擬那些路徑不需要任何權限就可以訪問的
* 真實情況下要去數據庫中查詢,這樣便於修改
*/
private Set<String> permitAll() {
return CollUtil.newHashSet("/doLogin", "/code", "/open/**");
}
/***
* 該方法用於模擬獲取訪問指定值地址需要的權限
* 真實情況下要去數據庫中查詢,這樣便於修改
* @param requestUrl 請求的地址
*/
private String[] findByPath(String requestUrl) {
HashMap<String, String[]> map = new HashMap<>(5);
map.put("/admin/**", new String[]{"admin"});
map.put("/guest/**", new String[]{"admin", "guest"});
map.put("/loginUser", new String[]{"login"});
for (String key : map.keySet()) {
if (ANT_PATH_MATCHER.match(key, requestUrl)) {
return map.get(key);
}
}
return new String[0];
}
}
2.AccessDecisionManager
權限決策管理 AccessDecisionManager
主要用於判斷當前用戶和 SecurityMetadataSource 提供的權限數據進行決策是通過還是阻斷。我們可以實現 AccessDecisionManager 接口來自定義這些決策的策略。
import cn.hutool.core.lang.Console;
import cn.hutool.json.JSONUtil;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Iterator;
/**
* 決策管理器,判斷請求的url是通過還是阻斷.
*
* @author lixin
*/
@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
Console.log("當前路徑需要的權限有{}", configAttributes);
Console.log("當前登錄用戶{}", JSONUtil.toJsonStr(authentication));
// 當前訪問的地址需要哪些用戶角色
for (ConfigAttribute configAttribute : configAttributes) {
String needRole = configAttribute.getAttribute();
// 需要登錄權限,但是已經登錄的,直接通過
if ("login".equals(needRole)) {
if (authentication instanceof AnonymousAuthenticationToken) {
throw new AccessDeniedException("未登錄或者登錄失效");
}else {
return;
}
}
//當前用戶所具有的權限,如果用戶有路徑需要的權限,就通過
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(needRole)) {
return;
}
}
}
throw new AccessDeniedException("沒有權限訪問");
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
}
二、配置ObjectPostProcessor到HttpSecurity
先把 SecurityMetadataSource 和 AccessDecisionManager 配置到 ObjectPostProcessor ,然后再 ObjectPostProcessor 配置 HttpSecurity 中,詳情看代碼:
import com.miaopasi.securitydemo.config.security.handler.*;
import com.miaopasi.securitydemo.config.security.impl.UrlAccessDecisionManager;
import com.miaopasi.securitydemo.config.security.impl.UrlFilterInvocationSecurityMetadataSource;
import com.miaopasi.securitydemo.config.security.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
/**
* Security配置類,會覆蓋yml配置文件的內容
*
* @author lixin
*/
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JsonSuccessHandler successHandler;
private final JsonFailureHandler failureHandler;
private final JsonAccessDeniedHandler accessDeniedHandler;
private final JsonAuthenticationEntryPoint authenticationEntryPoint;
private final JsonLogoutSuccessHandler logoutSuccessHandler;
private final UserDetailsServiceImpl userDetailsService;
private final UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;
private final UrlAccessDecisionManager accessDecisionManager;
@Autowired
public SecurityConfig(JsonSuccessHandler successHandler, JsonFailureHandler failureHandler, JsonAccessDeniedHandler accessDeniedHandler, JsonAuthenticationEntryPoint authenticationEntryPoint, JsonLogoutSuccessHandler logoutSuccessHandler, UserDetailsServiceImpl userDetailsService, UrlFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource, UrlAccessDecisionManager accessDecisionManager) {
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.accessDeniedHandler = accessDeniedHandler;
this.authenticationEntryPoint = authenticationEntryPoint;
this.logoutSuccessHandler = logoutSuccessHandler;
this.userDetailsService = userDetailsService;
this.filterInvocationSecurityMetadataSource = filterInvocationSecurityMetadataSource;
this.accessDecisionManager = accessDecisionManager;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 配置請求對象的處理器
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
// 配置url元數據
object.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
// 配置url權限的決策器
object.setAccessDecisionManager(accessDecisionManager);
return object;
}
})
.anyRequest().authenticated()
.and().formLogin()
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/doLogin")
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().logout().logoutUrl("/doLogout")
.logoutSuccessHandler(logoutSuccessHandler)
.and().exceptionHandling()
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
.and().cors()
.and().csrf().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
三、給登錄用戶設置權限
在上面的步驟中,我們給url資源設置了權限,只有指定權限的用戶才能訪問。現在我們需要給用戶分配指定的權限,不然用戶沒有權限將不能訪問。現在我們只需要在登錄時,把用戶的權限信息設置到用戶對象里面去,自定義決策管理器 UrlAccessDecisionManager 就能獲取到用戶有權限,然后執行決策管理器里面的邏輯。
用戶在登錄時會調用我們的 UserDetailsServiceImpl 類里面的 loadUserByUsername 方法,我們可以在這里把用戶擁有的權限查詢出來,然后設置給登錄的用戶。完整代碼如下:
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.lang.Console;
import com.miaopasi.securitydemo.config.security.SysUser;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
/**
* 自定查詢UserDetails
*
* @author lixin
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Console.log("根據用戶名查詢UserDetails,{}", username);
Optional<UserDetails> first = userDetailsList().stream()
.filter(userDetails -> Objects.equals(userDetails.getUsername(), username))
.findFirst();
if (first.isPresent()) {
return first.get();
}
throw new BadCredentialsException("[" + username + "]用戶不存在");
}
/**
* 正常情況下,我們應該是去數據庫中查詢數據,但是為了方便顯示,這里就使用模擬的查詢出來的數據
* 當然,除了數據庫查詢,這里我們也可以去文件中、內存中、甚至網絡上爬去的方式來獲取到用戶信息,只要能提供數據來源就行了。
* 模擬從數據庫查詢出來的用戶信息列表,
*/
private List<UserDetails> userDetailsList() {
List<UserDetails> userDetails = new ArrayList<>(10);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
SysUser user;
for (int i = 0; i < 10; i++) {
user = new SysUser();
user.setId(Convert.toLong(i));
user.setGmtCreate(DateTime.now());
user.setOperator("管理員");
user.setIsDelete(false);
user.setSort(BigDecimal.valueOf(0));
user.setStatus(0);
user.setRemarks("測試用戶" + i);
user.setUsername("user" + i);
user.setPassword(passwordEncoder.encode("pwd_" + i));
// 設置用戶的權限信息
user.setAuthorities(listByUserId(user.getId()));
userDetails.add(user);
}
userDetails.forEach(Console::log);
return userDetails;
}
/**
* 【數據模擬】根據用戶id查詢出用戶需要的權限.
* 這里我們模擬 user0 -> [admin,guest]
* 其他用戶 user[0|9] -> [guest]
*
* @param userId 用戶id
* @return the list
*/
private List<GrantedAuthority> listByUserId(Long userId) {
HashMap<Long, List<GrantedAuthority>> map = new HashMap<>();
for (int i = 0; i < 10; i++) {
List<GrantedAuthority> list = new ArrayList<>();
// 只要0這個用戶才有admin的權限
if (userId == 0) {
list.add(new SimpleGrantedAuthority("admin"));
}
list.add(new SimpleGrantedAuthority("guest"));
map.put(userId, list);
}
return map.get(userId);
}
}
四、測試
(1)根據我們模擬的數據,我們設置了一些地址不需要任何權限就能訪問:
/doLogin, /code, /open/**
,我們現在不登錄依次訪問這些接口,發現正常返回數據。
# /doLogin
{
"msg": "登錄成功",
"code": 0,
"data": {
"authenticated": true,
"authorities": [
{}
],
"principal": {
"isDelete": false,
"sort": 0,
"gmtCreate": 1594827663999,
"operator": "管理員",
"authorities": [
{}
],
"id": 1,
"remarks": "測試用戶1",
"username": "user1",
"status": 0
},
"details": {
"remoteAddress": "127.0.0.1"
}
}
}
# /code
QXAN8A
# /open/get
open get
如果我們訪問一下沒有忽略的接口,比如:/admin/get,返回JSON字符串:
{
"msg": "未登錄或者登錄失效",
"code": 1001,
"data": "Full authentication is required to access this resource"
}
(2)我們在模擬數據時給路徑分配的權限策略為:
表達式 | 權限 |
---|---|
/admin/** | admin |
/guest/** | admin, guest |
[其他或者沒有明確指定的] | login |
用戶分配的權限策略為:
賬號 | 權限 |
---|---|
user0 | admin, guest |
user[1|9] | guest |
根據分配的策略,我們可以得出:
user0用戶登錄后可以訪問全部的接口,user1到user9 除了 /admin/** 接口不能訪問,其他都可以訪問。
我們現在登錄user0后訪問/admin/get 、/guest/get 、/open/get、test/get 都正常返回JSON字符串。
然后我們再登錄user1后訪問/guest/get 、/open/get、test/get 都正常返回JSON字符串,訪問 /admin/get ,返回JSON字符:
{
"msg": "沒有權限訪問",
"code": 1002,
"data": "沒有權限訪問"
}
五、簡單說一下
動態權限在本篇文章中使用的是模擬的數據,項目中要根據自己的業務來加載數據。其實主要就是要查詢url的權限列表和用戶擁有的權限列表,這樣我們就可以自行對權限的列表和用戶的權限列表進行動態的操作。
spring security系列文章請 點擊這里 查看。
這是代碼 碼雲地址 。
注意注意!!!項目是使用分支的方式來提交每次測試的代碼的,請根據章節來我切換分支。