文章目錄
Spring Security 的前身是 Acegi Security,在被收納為Spring子項目後正式更名為Spring Security。 截止到目前(2020年4月16日),Spring Security已經更新到5.3.2 RELEASE版本,加入了原生OAuth2.0架構,支援更加現代化的密碼加密方式。
應用程式的安全性通常展現在兩個方面:認證和授權:
- 認證是确認某主體在某系統中是否合法、可用的過程。這裡的主體既可以是登入系統的使用者,也可以是接入的裝置或者其他系統。
- 授權是指當主體通過認證之後,是否允許其執行某項操作的過程。
這些概念并非Spring Security獨有,而是應用安全的基本關注點。Spring Security可以幫助我們更便捷地完成認證和授權。
Spring Security提供了一組深入的授權功能。有三個主要領域:
- 對Web 請求進行授權
- 授權某個方法是否可以被調用
- 授權通路單個領域對象執行個體
Spring Security原理簡易說明:
一旦啟用了 Spring Security, Spring IoC 容器就會建立一個名稱為 SpringSecurityFiltrChain的Spring Bean 。它的類型為 FilterChainProxy,事實上它也實作了filter 接口,隻是它是個特殊的攔截器。
FilterChainProxy
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicmbw5CMilDMhJzMyEDOjFzNkJmYkdDN0QTOmNDM0UGMkNWZ08CX5d2bs92Yl1iclB3bsVmdlR2LcNWaw9CXt92Yu4GZjlGbh5yYjV3Lc9CX6MHc0RHaiojIsJye.png)
Spring Security操作的過程中它會提供Servlet 過濾器DelegatingFilterProxy,這個過濾器會通過 Spring Web IoC 容器去擷取 Spring Security 所自動建立的 FilterChainProxy 對象,這個對象上存在一個攔截器清單( List ),清單上存在使用者驗證的攔截器、跨站點請求僞造等攔截器 ,這樣它就可以提供多種攔截功能。
于是焦點又落到了FilterChainProxy 對象上,通過它還可以注冊 Filter ,也就允許注冊自定義 Filter 來實作對應的攔截邏輯,以滿足不同的需要。當然 Spring Security 也實作了大部分常用的安全功能,并提供了相應的機制來簡化開發者的工作,是以大部分情況下并不需要自定義開發 ,使用它提供的機制即可。
Multiple SecurityFilterChain
建立 Spring Boot Web 項目,然後添加 spring-boot-starter-security 依賴即可:
<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>
寫一個簡單地hello接口:
@GetMapping("/hello")
public String hello(){
return "Hello,it's safe";
}
啟動項目,通路hello接口,會發現需要登入,,這個登入頁面是由Spring Security 提供的:
預設的使用者名是 user ,預設的登入密碼 在每次啟動項目時随機生成 檢視項目啟動日志:
從項目啟動日志中可以看到預設的登入密碼,登入成功後,就可以通路hello 接口了。
在上面代碼中,使用者名是預設的,密碼是随機生成的,可以對使用者名和密碼以及使用者角色進行配置:
spring.security.user.name=sao
spring.security.user.password=123456
spring.security.user.roles=admin
為了給 FilterChainProxy 加入自定義的初始化, SpringSecurity 供了 SecurityConfigurer 口,通過它就能夠實作現對 Spring Security 的配置。 有了這個接口還不太友善,因為它隻是能夠提供接口定義的功能,為了更友善 Spring Web 工程還提供了專門的接口 WebSecurityConfigurer, 并且在這個接口的定義上提供了以個抽象類 WebSecurityConfigAdapter—— 開發者通過繼承它就能 得到Spring Security 預設的安全功能。 也可以通過覆寫它提供的方法來自定義自 的安全攔截方案。
這裡需要 WebSecurityConfigAdapter 中預設存在的方法:
/**
*用來配置使用者簽名服務,主要是 user-details 制,你還可以給予使用者賦予角色
* @param auth 簽名管理器構造器 用于建構使用者具體權限控制
*/
protected roid configure (AuthenticationManagerBuilder auth );
/**
*用來配置Filter鍊
* @param web Spring Web Securty 對象
*/
public roid configure (WebSecurity web) ;
/**
*用來配置攔截保護的請求,比如什麼請求放行,什麼請求需要驗證
*@param http http 安全請求對象
*/
protected void configure (HttpSecurity http) throws Exception ;
繼承自 WebSecurityConfigurerAdapter ,實作基于記憶體的認證,配置方式如下:
/**
* @Author 三分惡
* @Date 2020/1/11
* @Description
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 從 Spring5 開始,強制要求密碼要加密,如果不想加密,可以使用一個過時的 PasswordEncoder 的執行個體 NoOpPasswordEncoder
* BCryptPasswordEncoder 密碼編碼工具
* @return
*/
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
//配置了兩個使用者,包括使用者的使用者名、角色、密碼,使用者密碼已經加密(123)
.withUser("zhangsan")
//角色
.roles("ADMIN")
//密碼已經加密,可以通過 passwordEncoder.encode("123")擷取
.password("$2a$10$dQLFreAJHM0F.4XAWQMTA.kB5W3H2.hjA6xBUJFHTFT7iHRzO0flm")
//連接配接方法
.and()
.withUser("lisi").roles("USER").password("$2a$10$RDdoj3sm/RD7HzqSnU864eEE5kEZZxbyQqnYQJGrO2pgkUGCDutTC");
}
}
上面雖然現在可以實作認證功能,但是受保護的資源都是預設的 , 根據實際情況進行角色管理,如果要實作這些功能 就需要重寫 WebSecurityConfigurerAdapter 的另一 方法:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() //開啟登入配置
.antMatchers("/admin/**")
//通路"admin/**"模式的URL必須具備ADMIN權限
.hasRole("ADMIN")
.antMatchers("/user/**")
//通路"user/**"模式的 URL必須具備 AD1IN、USER 的角色
.access( "hasAnyRole('ADMIN','USER') ")
//除上面配置外的其它url都需要登入
.anyRequest()
.authenticated()
.and()
//開啟表單登入
.formLogin()
//配置登入接口為“/login”
.loginProcessingUrl("/login")
//登入相關的接口不需要認證
.permitAll()
.and()
//關閉 csrf
.csrf()
.disable();
}
在HelloController中添加相應的接口,admin/hello隻能被具備ADMIN角色的使用者通路,user/hello能被具備ADMIN和UAER角色的使用者通路:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "Hello,it's safe";
}
@GetMapping("admin/hello")
public String adminHello(){
return "Hello,admin is safe";
}
@GetMapping("user/hello")
public String userHello(){
return "Hello,user is safe";
}
}
登入表單一直使 Spring Security 提供的頁面,登入成功後也是預設的頁面跳轉,但是,前後端分離正在成為企業級應用開發的主流,在前後端分離的開發方式中,前後端的資料互動通過 JSON 進行,這時,登入成功後就不是頁面跳轉了,而是一段 JSON 提示。要實作這些功能,需要繼續完善上文的配置:
//開啟表單登入
.formLogin()
//配置登入接口為“/login”
.loginProcessingUrl("/login")
//登入頁面
.loginPage("/login_page")
//自定義認證所需的使用者名和密碼的參數名
.usernameParameter("username")
.passwordParameter("password")
//定義登入成功的處理邏輯,這裡是模拟前後端分離的情況,傳回一段json,不前後端分離的話可以直接傳回頁面
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
Object principal = auth.getPrincipal();
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
response.setStatus(200);
Map<String, Object> map = new HashMap<>();
map.put("status", 200);
map.put("msg", principal);
ObjectMapper om = new ObjectMapper();
out.write(om.writeValueAsString(map));
out.flush();
out.close();
}
})
//定義登入失敗的處理邏輯
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
response.setStatus(401);
Map<String, Object> map = new HashMap<>();
map.put("status", 401);
if (e instanceof LockedException) {
map.put("msg", "賬号被鎖定,登入失敗 !");
} else if (e instanceof BadCredentialsException) {
map.put("msg", "賬戶名或密碼輸入錯誤, 登入失敗!");
} else if (e instanceof DisabledException) {
map.put("msg", "賬戶被禁用,登入失敗!");
} else if (e instanceof AccountExpiredException) {
map.put("msg", "賬戶已過期,登入失敗!");
} else {
map.put("msg", "登入失敗!");
}
ObjectMapper om = new ObjectMapper ();
out .write(om.writeValueAsString(map));
out.flush();
out.close();
}
})
//登入相關的接口不需要認證
.permitAll()
.and()
配置完成後,使用 Postman 進行登入測試,登入成功後傳回使用者的基本資訊 密碼已經過濾掉了。如果登入失敗, 會有相應的提示。
登入成功
登入失敗
如果想要登出登入 也隻需要提供簡單的配置即可:
//登出登入
.logout()
//登出登入請求url
.logoutUrl("/logout")
//清除身份認證資訊
.clearAuthentication(true)
//使 Session失效
.invalidateHttpSession(true)
//定義登出成功的業務邏輯,這裡傳回一段json
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("logout success");
out.flush();
}
})
.permitAll()
.and()
上面介紹的認證與授權都是基于 URL 的,也可以通過注解來靈活地配置方法安全,要使用相關注解,首先要通過@EnableGloba!MethodSecurity 注解開啟基于注解的安全配置:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled=true,securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
}
- prePostEnabled=true會解鎖@PreAuthorize和@PostAuthorize兩個注解,@PreAuthorize會在執行方法前驗證,@PostAuthorize會在執行方法後驗證。
- securedEnabled=true會解鎖@Secured 注解。
@Service
public class MethodService {
//示通路該方法需要 ADMIN 角色
@Secured("ROLE ADMIN")
public String admin () {
return "hello admin ";
}
//通路該方法既需要ADMIN角色又需要USER角色
@PreAuthorize("hasRole ('ADMIN') and hasRole ('USER ')")
public String user(){
return "Hello User";
}
//通路該方法需要ADMIN 或 USER角色
@PreAuthorize("hasAnyRole('ADMIN','USER')")
public String any(){
return "Hello Every One";
}
}
密碼加密一般會用到散列函數,又稱雜湊演算法、哈希函數,這是一種從任何資料中建立數字“指紋”的方法。散列函數把消息或資料壓縮成摘要,使得資料量變小,将資料的格式固定下來,然後将資料打亂混合,重新建立一個散列值。散列值通常用一個短的随機字母和數字組成的字元串來代表。好的散列函數在輸入域中很少出現散列沖突。在散清單和資料進行中,不抑制沖突來差別資料會使得資料庫記錄更難找到。我們常用的散列函數有 MD5 消息摘要算法、安全雜湊演算法(Secure Hash Algorithm )。
123456 ---MD5---> e10adc3949ba59abbe56e057f20f883e
實際上,上面的執行個體在現實使用中還存在着一個不小的問題。雖然 MD5 算法是不可逆的,但是因為它對同一個字元串計算的結果是唯一 的,是以一些人可能會使用“字典攻擊”的方式來攻破 MD5 加密的系統。這雖然屬于暴力解密,卻十分有效,因為大多數系統的使用者密碼都不會很長。
為了解決這個問題,我們可以使用鹽值加密“salt-source”,所謂加鹽加密,是指在加密之前,為原文附上額外的随機值,再進行加密。具體實作方法并不固定。
Spring Security内置了密碼加密機制,隻需使用一個PasswordEncoder接口即可。
PasswordEncoder接口定義了encode和matches兩個方法,當用資料庫存儲使用者密碼時,加密過程用 encode方法,matches方法用于判斷使用者登入時輸入的密碼是否正确。
Spring Security 還内置了幾種常用的 PasswordEncoder 接口,例如, StandardPasswordEncoder中的正常摘要算法(SHA-256等)、BCryptPasswordEncoder加密,以及類似 BCrypt的慢散列加密Pbkdf2PasswordEncoder等,官方推薦使用BCryptPasswordEncoder。
配置密碼加密非常簡單:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
同樣可以自定義加密方式,例如不想使用推薦的BCryptPasswordEncoder,想使用其它的加密方法,例如MD5加密,很簡單,我們自己實作一個PasswordEncoder,在配置中使用自定義的加密類即可。
public class Md5PasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence charSequence) {
//省略md5加密過程
return md5String;
}
@Override
public boolean matches(CharSequence charSequence, String s) {
//省略比對過程
return false;
}
}
@Bean
PasswordEncoder passwordEncoder() {
return new Md5PasswordEncoder();
}
在真實項目中,使用者的基本資訊以及角色等都存儲在資料庫中,是以需要從資料庫中擷取資料進行認證。
一共三張表,分别是使用者表、角色表、使用者_角色關聯表。
建立表并插入一些測試資料:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`rolename` varchar(50) NOT NULL COMMENT '角色名',
`note` varchar(255) NOT NULL COMMENT '角色描述',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='角色表';
-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES ('1', 'ROLE_ADMIN', '管理者');
INSERT INTO `role` VALUES ('2', 'ROLE_DBA', '資料庫管理者');
INSERT INTO `role` VALUES ('3', 'ROLE_USER', '使用者');
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`username` varchar(50) NOT NULL COMMENT '使用者名',
`password` varchar(255) NOT NULL COMMENT '密碼',
`enabled` tinyint(1) NOT NULL COMMENT '是否可用,1表示可用,0表示不可用',
`locked` tinyint(1) NOT NULL COMMENT '是否上鎖,1表示上鎖,0表示未上鎖',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='關聯表';
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', 'admin', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');
INSERT INTO `user` VALUES ('2', 'root', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');
INSERT INTO `user` VALUES ('3', 'laosan', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');
-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`uid` int(11) NOT NULL COMMENT '使用者id',
`rid` int(11) NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='使用者-角色關聯表';
-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES ('1', '1', '1');
INSERT INTO `user_role` VALUES ('2', '2', '2');
INSERT INTO `user_role` VALUES ('3', '3', '3');
INSERT INTO `user_role` VALUES ('4', '1', '3');
INSERT INTO `user_role` VALUES ('5', '1', '2');
- 角色名有一個預設的字首"ROLE_"
這裡選擇MyBatis作為持久層架構,添加依賴:
<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>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.12</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
資料庫連接配接和MyBatis相關配置:
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql://localhost:3306/demo_security?serverTimezone=CTT&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
mybatis.type-aliases-package=edu.hpu.pojo
mybatis.mapper-locations=classpath:mybatis/mapper/*.xml
使用者實體類需要實作UserDetails接口:
public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private Boolean enabled;
private Boolean locked;
private List<Role> roles;
/**
* 擷取目前使用者對象所具有的角色資訊
* @return
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>() ;
for (Role role : roles){
authorities.add (new SimpleGrantedAuthority (role.getRolename()) ) ;
}
return authorities;
}
/**
* 擷取目前使用者的密碼
* @return
*/
@Override
public String getPassword() {
return password;
}
/**
* 擷取d目前使用者的使用者名
* @return
*/
@Override
public String getUsername() {
return username;
}
/**
* 目前賬戶是否未過期
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 目前賬戶是否未鎖定
* @return
*/
@Override
public boolean isAccountNonLocked() {
return !locked;
}
/**
* 目前賬戶密碼是否未過期
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 目前賬戶是否可用
* @return
*/
@Override
public boolean isEnabled() {
return enabled;
}
//省略getter、setter
}
- 實作了UserDetails接口的七個方法,方法作用見注釋
- 使用者根據實際情況設直這個方法的傳回值 因為預設情況下不需要開發者自己進行密碼角色等資訊的比對,開發者隻需要提供相關資訊即可。例如 getPassword()方法傳回的密碼和使用者輸入的登入密碼不比對,會自動抛出BadCredentialsException 異常。
- getAuthorities()方法用來擷取目前使用者所具有的角色資訊。
角色實體類:
public class Role {
private Integer id;
private String rolename;
private String note;
//省略getter、setter
}
接口:
@Mapper
public interface UserMapper {
User loadUserByUsername(String username);
List<Role> getUserRolesByUid (Integer id) ;
}
對應的映射檔案:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="edu.hpu.mapper.UserMapper">
<select id="loadUserByUsername" resultType="User">
select * from user where username=#{username}
</select>
<select id="getUserRolesByUid" resultType="Role">
select r.* from role r
join user_role ur on r.id=ur.rid
join user u on u.id=ur.uid
where u.id=#{id}
</select>
</mapper>
UserService需要實作UserDetailsService接口
@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.getUserRolesByUid(user.getId()));
return user;
}
}
- 實作 UserDetailsService 接口,并實作該接口中的 loadUserByUsername 方法,該方法的參數就是使用者登入時輸入的使用者名,通過使用者名去資料庫中查找使用者,如果沒有查找到使用者,就抛出 個賬戶不存在的異常,如果查找到了使用者,就繼續查找該使用者所具有的角色資訊,并将擷取到的 user 對象傳回,再由系統提供的 DaoAuthenticationProvider 類去比對密碼是否正确。
- loadUserByUsername 方法将在使用者登入時自動調用。
這裡是一個比較精簡的配置,使用者名和密碼從資料庫中擷取:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserService userService;
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService (userService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/db/**").hasRole("DBA")
.antMatchers("/user/**").hasRole("USER")
.and()
.formLogin()
.loginProcessingUrl("/login").permitAll()
.and()
.csrf().disable();
}
}
根據配置類編寫不同權限的接口:
@RestController
public class HelloController {
@GetMapping("/user/hello")
public String userHello(){
return "你好,普通使用者!";
}
@GetMapping("/admin/hello")
public String adminHello(){
return "你好,管理者!";
}
@GetMapping("/db/hello")
public String dbaHello(){
return "你好,資料庫管理者!";
}
}
啟動項目,就可以通路不同權限的接口進行測試了。
在上面的執行個體中中定義了三種角色,但是這三種角色之間不具備任何關系,一 般來說角色之間是有關系的,例如 ROLE_ADMIN 一般既具有 ADMIN 的權限,又具有 USER 的權限。那麼如何配置這種角色繼承關系呢?在 pring Security 中隻需要開發者提供一個 RoleHierarchy 即可。
假設 ROLE_DBA是終極大 Boss ,具有所有的權限, ROLE_ADMIN具有 ROLE_USER權限, ROLE_USER是一個公共角色,即 ROLE_ADMIN繼承 ROLE_USER, ROLE_DBA繼承ROLE_ADMIN ,要描述這種繼承關系,隻需要開發者在 Spring Security 的配置類中提供RoleHierarchy 即可,代碼如下:
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String hireachy = "ROLE_DBA > ROLE_ADMIN> ROLE_USER";
roleHierarchy.setHierarchy(hireachy);
return roleHierarchy;
}
配置完 RoleHierarchy 之後,具有 ROLE_DBA 角色的使用者就可以通路所有資源了, 具有
ROLE_ADMIN 角色的使用者也可以通路具有 ROLE_USER 角色才能通路的資源。
使用 ttpSecurity 配置的認證授權規則還是不夠靈活,無法實作資源和角色之間的動态調整,要實作動态配置 URL 權限,就需要我們自定義權限配置,在第4節的基礎上進行改造。
這裡的資料庫在4資料庫的基礎上再增加一張資源表和資源角色關聯表,資源表中定義了使用者能夠通路的 URL 模式,資源角色表則定義了通路該模式的 URL 需要什麼樣的角色。
添加兩張表之後的資料庫表結構如下:
建立資源表和資源角色關聯表并插入一些測試資料:
DROP TABLE IF EXISTS `menu`;
CREATE TABLE `menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`pattern` varchar(50) NOT NULL COMMENT '路徑',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='資源表';
-- ----------------------------
-- Records of menu
-- ----------------------------
INSERT INTO `menu` VALUES ('1', '/db/**');
INSERT INTO `menu` VALUES ('2', '/admin/**');
INSERT INTO `menu` VALUES ('3', '/user/**');
-- ----------------------------
-- Table structure for menu_role
-- ----------------------------
DROP TABLE IF EXISTS `menu_role`;
CREATE TABLE `menu_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`mid` int(11) NOT NULL COMMENT '資源id',
`rid` int(11) NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='資源_角色關聯表';
-- ----------------------------
-- Records of menu_role
-- ----------------------------
INSERT INTO `menu_role` VALUES ('1', '1', '2');
INSERT INTO `menu_role` VALUES ('2', '2', '1');
INSERT INTO `menu_role` VALUES ('3', '3', '3');
Menu.java:
public class Menu {
private Integer id;
private String pattern;
private List<Role> roles;
//省略getter、setter
}
MenuMapper.java:
@Mapper
public interface MenuMapper {
List<Menu> getAllMenus();
}
MenuMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="edu.hpu.mapper.MenuMapper">
<resultMap id="BaseResultMap" type="edu.hpu.pojo.Menu">
<id property="id" column="id" />
<result property="pattern" column="pattern"/>
<collection property="roles" ofType="edu.hpu.pojo.Role">
<id property="id" column="id" />
<result property="rolename" column="rolename"/>
<result property="note" column="note"/>
</collection>
</resultMap>
<select id="getAllMenus" resultMap="BaseResultMap">
SELECT
m.*,
r.id AS rid,
r.rolename AS rolename,
r.note AS note
FROM
menu m
LEFT JOIN menu_role mr ON m.id=mr.mid
LEFT JOIN role r ON mr.rid= r.id
</select>
</mapper>
要實作動态配置權限,首先要自定義 FilterlnvocationSecurityMetadataSource,Spring Security中通過FilterlnvocationSecurityMetadataSource接口的getAttributes方法來确定一個請求需要哪些角色,接口的預設實作類是DefaultFilterlnvocationSecurityMetadataSource,參考DefaultFilterlnvocationSecurityMetadataSource,可以自定義FilterlnvocationSecurityMetadataSource接口實作類。
@Component
public class CustomFilterinvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
//建立一個AntPathMatcher執行個體,主要用來實作ant風格的URL比對。
AntPathMatcher antPathMatcher =new AntPathMatcher() ;
@Autowired
MenuMapper menuMapper;
/**
*
* @param o
* 參數是一個FilterInvocation,可以從中去除請求的url
* @return Collection<ConfigAttribute>:表示目前請求 URL 所需的角色。
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
//從FilterInvocation 中提取出目前請求的url
String requestUtl=( (FilterInvocation) o).getRequestUrl();
//從資料庫中取出資源資訊
List<Menu> menus=menuMapper.getAllMenus();
for (Menu menu:menus) {
if (antPathMatcher.match(menu.getPattern(),requestUtl)){
//擷取目前請求的 URL 所需要的角色資訊
List<Role> roles=menu.getRoles();
String[] roleArr =new String[roles.size()];
for (int i=0;i<roleArr.length;i++){
roleArr[i] = roles.get(i).getRolename();
}
//傳回角色資訊
return SecurityConfig.createList(roleArr);
}
}
//如果不存在比對的角色資訊,傳回ROLE_LOGIN,即登入就可通路
return SecurityConfig.createList("ROLE_LOGIN");
}
/**
*
* @return 傳回所有定義好的權限資源, Spring Security 在啟動時會校驗
* 相關配置是否正确 ,如果不需要校驗,那麼該方法直接傳回 null 即可
*/
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
/**
*
* @param aClass
* @return 傳回類對象是否支援校驗
*/
@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass) ;
}
}
當一個請求走完FilterlnvocationSecurityMetadataSource的getAttributes方法之後,會
來到AccessDecisionManager類中進行角色資訊的比對,自定義AccessDecisionManager類如下:
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
/**
* 判斷目前登入的使用者是否具備目前請求 URL 所需要的角色資訊,如果不具備,就抛出AccessDeniedException異常
* @param authentication
* 參數1:目前登入使用者的資訊
* @param o
* 參數2:Filterlnvocation對象,可以取目前請求對象等
* @param collection
* 參數3:FilterlnvocationSecurityMetadataSource中getAttributes 方法的傳回值,即目前請求URL所需的角色
* @throws AccessDeniedException
* @throws InsufficientAuthenticationException
*/
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
Collection <? extends GrantedAuthority> auths = authentication.getAuthorities();
//角色資訊對比
for(ConfigAttribute configAttribute:collection){
//目前URL權限為ROLE_LOGIN,登入即可通路
if ("ROLE_LOGIN".equals(configAttribute.getAttribute())&& authentication instanceof UsernamePasswordAuthenticationToken){
return;
}
//全新判斷
for (GrantedAuthority authority:auths){
//目前使用者具備通路目前URL的權限
if (configAttribute.getAttribute().equals(authority.getAuthority())){
return;
}
}
//目前使用者不具備通路目前URL的權限,抛出異常
throw new AccessDeniedException("權限不足");
}
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
- 重寫configure(HttpSecurity http)方法,根據資料庫中的資料動态配置設定權限
- 将寫的兩個類的執行個體設定進去
/**
* 動态配置通路權限
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//定義FilterSecurityInterceptor将自定義的兩個類放進去
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(cfisms());
o.setAccessDecisionManager(cadm());
return o;
}
})
.and()
.formLogin()
.loginProcessingUrl("/login").permitAll()
.and()
.csrf().disable() ;
}
@Bean
CustomFilterinvocationSecurityMetadataSource cfisms(){
return new CustomFilterinvocationSecurityMetadataSource () ;
}
@Bean
CustomAccessDecisionManager cadm (){
return new CustomAccessDecisionManager();
}
重新啟動,和4是一樣的效果。