Spring Security新版用法足夠優雅

不久前Spring Boot 2.7.0發布了,Spring Security也升級到了5.7.1版本。升級之後我發現之前一直使用的Spring Security配置方式已被廢棄,不禁感慨技術更新真快,正在用著呢就被廢棄了!今天給大家示範下Spring Security的最新用法,看看是否足夠優雅!

SpringBoot實戰電商專案mall(50k+star)地址:github.com/macrozheng/…

基礎用法



先來對比下Spring Security提供的基礎功能登入認證,看看新版是否更好用。

升級版


首先修改專案的pom.xml檔案,將Spring Boot版本升級到2.7.0。

org.springframework.boot
spring-boot-starter-parent
2.7.0
<!-- lookup parent from repository -->


舊用法


在Spring Boot 2.7.0之前的版本中,我們需要編寫一個配置類繼承WebSecurityConfigurerAdapter,然後重寫Adapter中的三個方法進行配置;
/**
* SpringSecurity配置
* Created by macro on 2018/4/26.
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UmsAdminService adminService;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
//省略HttpSecurity的配置
}

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

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

}

如果在SpringBoot 2.7.0版本中使用,你會發現WebSecurityConfigurerAdapter已被廢棄,看來Spring Security要堅決廢棄這種用法了!

新用法
新用法非常簡單,不需要繼承WebSecurityConfigurerAdapter,直接聲明配置類即可,配置一個方法產生SecurityFilterChainBean,將原來的HttpSecurity配置移到這個方法中。
/**
* SpringSecurity 5.4.x及以上版本的新用法配置
* 為避免循環相依性,僅用於配置HttpSecurity
* Created by macro on 2022/5/19.
*/
@Configuration
public class SecurityConfig {

@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
//省略HttpSecurity的配置
return httpSecurity.build();
}

}

新用法感覺非常簡潔直接,避免了繼承WebSecurityConfigurerAdapter和重寫方法的操作。強烈建議大家更新!

進階用法



升級Spring Boot 2.7.0版本後,Spring Security對配置方式進行了重大改動,那對其他用法有沒有影響呢?實際上沒有影響。下面來講講如何使用Spring Security實現動態許可權控制!

基於方法的動態許可權



先來講講基於方法的動態許可權控制,雖然這種方式實現簡單,但有一定弊端。


在配置類上使用@EnableGlobalMethodSecurity開啟;

/**
* SpringSecurity配置
* Created by macro on 2018/4/26.
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {

}


然後在方法上使用@PreAuthorize配置提供者所需的許可權;

/**
* 商品管理控制器
* Created by macro on 2018/4/26.
*/
@Controller
@Api(tags = "PmsProductController", description = "商品管理")
@RequestMapping("/product")
public class PmsProductController {
@Autowired
private PmsProductService productService;

@ApiOperation("建立商品")
@RequestMapping(value = "/create", method = RequestMethod.POST)
@ResponseBody
@PreAuthorize("hasAuthority('pms:product:create')")
public CommonResult create(@RequestBody PmsProductParam productParam, BindingResult bindingResult) {
int count = productService.create(productParam);
if (count > 0) {
return CommonResult.success(count);
} else {
return CommonResult.failed();
}
}
}


然後從資料庫查詢使用者的許可權值並設定到UserDetails對象中。雖然這種方式實現方便,但把許可權值寫在方法上並不是優雅的做法。

/**
* UmsAdminService實作類別
* Created by macro on 2018/4/26.
*/
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
@Override
public UserDetails loadUserByUsername(String username){
//擷取使用者資訊
UmsAdmin admin = getAdminByUsername(username);
if (admin != null) {
List permissionList = getPermissionList(admin.getId());
return new AdminUserDetails(admin,permissionList);
}
throw new UsernameNotFoundException("使用者名稱或密碼不正確");
}
}

基於路徑的動態許可權



實際上每個介面對應的路徑都是唯一的,通過路徑來控制介面許可權更加優雅。


首先,需要建立一個動態許可權過濾器。注意這裡的doFilter方法,用於允許存取OPTIONS和白名單請求,會調用super.beforeInvocation(fi)方法,該方法會調用AccessDecisionManager中的decide方法進行認證操作;

/**
* 動態許可權過濾器,用於基於路徑的動態許可權過濾
* Created by macro on 2020/2/7.
*/
public class DynamicSecurityFilter extends AbstractSecurityInterceptor implements Filter {

@Autowired
private DynamicSecurityMetadataSource dynamicSecurityMetadataSource;
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;

@Autowired
public void setMyAccessDecisionManager(DynamicAccessDecisionManager dynamicAccessDecisionManager) {
super.setAccessDecisionManager(dynamicAccessDecisionManager);
}

@Override
public void init(FilterConfig filterConfig) throws ServletException {
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
//OPTIONS請求直接允許存取
if(request.getMethod().equals(HttpMethod.OPTIONS.toString())){
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
return;
}
//白名單請求直接允許存取
PathMatcher pathMatcher = new AntPathMatcher();
for (String path : ignoreUrlsConfig.getUrls()) {
if(pathMatcher.match(path,request.getRequestURI())){
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
return;
}
}
//此處會調用AccessDecisionManager中的decide方法進行認證操作
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.afterInvocation(token, null);
}
}

@Override
public void destroy() {
}

@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}

@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return dynamicSecurityMetadataSource;
}

}


接下來,需要建立一個類繼承AccessDecisionManager,使用decide方法將提供者所需的許可權與使用者擁有的許可權進行匹配,匹配成功則允許存取;

/**
* 動態許可權決策管理器,用於判斷使用者是否有存取權限
* Created by macro on 2020/2/7.
*/
public class DynamicAccessDecisionManager implements AccessDecisionManager {

@Override
public void decide(Authentication authentication, Object object,
Collection configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
// 介面未配置資源時直接允許存取
if (CollUtil.isEmpty(configAttributes)) {
return;
}
Iterator iterator = configAttributes.iterator();
while (iterator.hasNext()) {
ConfigAttribute configAttribute = iterator.next();
// 對比訪問所需資源與使用者擁有的資源
String needAuthority = configAttribute.getAttribute();
for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
if (needAuthority.trim().equals(grantedAuthority.getAuthority())) {
return;
}
}
}
throw new AccessDeniedException("抱歉,您沒有存取權限");
}

@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}

@Override
public boolean supports(Class<?> aClass) {
return true;
}

}


由於上述decide方法中的configAttributes屬性是從FilterInvocationSecurityMetadataSource的getAttributes方法擷取的,我們還需要建立一個類繼承它,getAttributes方法用於擷取訪問當前路徑所需的許可權值;

/**
* 動態許可權資料來源,用於擷取動態許可權規則
* Created by macro on 2020/2/7.
*/
public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

private static Map configAttributeMap = null;
@Autowired
private DynamicSecurityService dynamicSecurityService;

@PostConstruct
public void loadDataSource() {
configAttributeMap = dynamicSecurityService.loadDataSource();
}

public void clearDataSource() {
configAttributeMap.clear();
configAttributeMap = null;
}

@Override
public Collection getAttributes(Object o) throws IllegalArgumentException {
if (configAttributeMap == null) this.loadDataSource();
List configAttributes = new ArrayList<>();
//擷取當前訪問的路徑
String url = ((FilterInvocation) o).getRequestUrl();
String path = URLUtil.getPath(url);
PathMatcher pathMatcher = new AntPathMatcher();
Iterator iterator = configAttributeMap.keySet().iterator();
//擷取訪問該路徑所需的資源
while (iterator.hasNext()) {
String pattern = iterator.next();
if (pathMatcher.match(pattern, path)) {
configAttributes.add(configAttributeMap.get(pattern));
}
}
// 未設定作業請求許可權,返回空集合
return configAttributes;
}

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

@Override
public boolean supports(Class<?> aClass) {
return true;
}

}


需要注意的是,所有路徑對應的許可權值資料來自自訂的DynamicSecurityService;

/**
* 動態許可權相關業務類
* Created by macro on 2020/2/7.
*/
public interface DynamicSecurityService {
/**
* 載入資源ANT萬用字元和資源對應MAP
*/
Map loadDataSource();
}


萬事俱備,在FilterSecurityInterceptor前添加動態許可權過濾器;

/**
* SpringSecurity 5.4.x及以上版本的新用法配置
* 為避免循環相依性,僅用於配置HttpSecurity
* Created by macro on 2022/5/19.
*/
@Configuration
public class SecurityConfig {

@Autowired
private DynamicSecurityService dynamicSecurityService;
@Autowired
private DynamicSecurityFilter dynamicSecurityFilter;

@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
//省略部分配置...
//有動態許可權配置時添加動態許可權校正過濾器
if(dynamicSecurityService!=null){
registry.and().addFilterBefore(dynamicSecurityFilter, FilterSecurityInterceptor.class);
}
return httpSecurity.build();
}

}


如果你看過這篇文章,整合SpringSecurity+JWT實現登入認證只需四步!看過的話就知道應該配置這兩個Bean,一個負責擷取登入使用者資訊,另一個負責擷取儲存的動態許可權規則。為了適配Spring Security的新用法,我們不再繼承SecurityConfig,簡潔多了!

/**
* mall-security模組相關配置
* 自訂配置,用於配置如何擷取使用者資訊和動態許可權
* Created by macro on 2022/5/20.
*/
@Configuration
public class MallSecurityConfig {

@Autowired
private UmsAdminService adminService;

@Bean
public UserDetailsService userDetailsService() {
//擷取登入使用者資訊
return username -> {
AdminUserDetails admin = adminService.getAdminByUsername(username);
if (admin != null) {
return admin;
}
throw new UsernameNotFoundException("使用者名稱或密碼不正確");
};
}

@Bean
public DynamicSecurityService dynamicSecurityService() {
return new DynamicSecurityService() {
@Override
public Map loadDataSource() {
Map map = new ConcurrentHashMap<>();
List resourceList = adminService.getResourceList();
for (UmsResource resource : resourceList) {
map.put(resource.getUrl(), new org.springframework.security.access.SecurityConfig(resource.getId() + ":" + resource.getName()));
}
return map;
}
};
}

}

效果測試

接下來啟動樣本專案mall-tiny-security,使用以下帳號密碼登入,該帳號僅配置了訪問/brand/listAll的許可權,訪問地址為:http://localhost:8088/swagger-ui/



然後將返回的token放入Swagger的認證頭中;



訪問有許可權的介面時,可以正常擷取資料;



訪問無許可權的介面時,返回無存取權限的介面提示。


總結


Spring Security的升級用法確實足夠優雅,足夠簡潔,而且對之前的用法相容性更好!個人感覺一個成熟的架構在升級過程中不會大幅改變使用方式,即使改變了也會相容之前的用法,所以對於大多數架構來說,舊版本會用,新版本照樣會用!

相關文章

探索更多特惠

  1. 簡訊服務與郵件服務

    50000封郵件包低至1.99美元,120條簡訊僅需1.00美元

phone 聯絡我們
Hi, I'm Alibaba Cloud AI Assistant!
I can help with questions and solutions.