Spring Boot 安全管理
1. 前言
安全管理是軟件系統(tǒng)必不可少的的功能。根據(jù)經(jīng)典的“墨菲定律”——凡是可能,總會(huì)發(fā)生。如果系統(tǒng)存在安全隱患,最終必然會(huì)出現(xiàn)問(wèn)題。
本節(jié)就來(lái)演示下,如何使用 Spring Boot + Spring Security 開(kāi)發(fā)前后端分離的權(quán)限管理功能。
2. Spring Security 用法簡(jiǎn)介
作為一個(gè)知名的安全管理框架, Spring Security 對(duì)安全管理功能的封裝已經(jīng)非常完整了。
我們?cè)谑褂?Spring Security 時(shí),只需要從配置文件或者數(shù)據(jù)庫(kù)中,把用戶(hù)、權(quán)限相關(guān)的信息取出來(lái)。然后通過(guò)配置類(lèi)方法告訴 Spring Security , Spring Security 就能自動(dòng)實(shí)現(xiàn)認(rèn)證、授權(quán)等安全管理操作了。
- 系統(tǒng)初始化時(shí),告訴 Spring Security 訪問(wèn)路徑所需要的對(duì)應(yīng)權(quán)限。
- 登錄時(shí),告訴 Spring Security 真實(shí)用戶(hù)名和密碼。
- 登錄成功時(shí),告訴 Spring Security 當(dāng)前用戶(hù)具備的權(quán)限。
- 用戶(hù)訪問(wèn)接口時(shí),Spring Security 已經(jīng)知道用戶(hù)具備的權(quán)限,也知道訪問(wèn)路徑需要的對(duì)應(yīng)權(quán)限,所以自動(dòng)判斷能否訪問(wèn)。
3. 數(shù)據(jù)庫(kù)模塊實(shí)現(xiàn)
3.1 定義表結(jié)構(gòu)
需要 4 張表:
- 用戶(hù)表 user:保存用戶(hù)名、密碼,及用戶(hù)擁有的角色 id 。
- 角色表 role :保存角色 id 與角色名稱(chēng)。
- 角色權(quán)限表 roleapi:保存角色擁有的權(quán)限信息。
- 權(quán)限表 api:保存權(quán)限信息,在前后端分離的項(xiàng)目中,權(quán)限指的是控制器中的開(kāi)放接口。
具體表結(jié)構(gòu)如下,需要注意的是 api 表中的 path 字段表示接口的訪問(wèn)路徑,另外所有的 id 都是自增主鍵。
3.2 構(gòu)造測(cè)試數(shù)據(jù)
執(zhí)行如下 SQL 語(yǔ)句插入測(cè)試數(shù)據(jù),下面的語(yǔ)句指定了 admin 用戶(hù)可以訪問(wèn) viewGoods 和 addGoods 接口,而 guest 用戶(hù)只能訪問(wèn) viewGoods 接口。
實(shí)例:
-- 用戶(hù)
INSERT INTO `user` VALUES (1, 'admin', '$2a$10$D0OvhHj2Lh92rNey1EFmM.OqltxhH1vZA8mDpxz7jEofDEqLRplQy', 1);
INSERT INTO `user` VALUES (2, 'guest', '$2a$10$D0OvhHj2Lh92rNey1EFmM.OqltxhH1vZA8mDpxz7jEofDEqLRplQy', 2);
-- 角色
INSERT INTO `role` VALUES (1, '管理員');
INSERT INTO `role` VALUES (2, '游客');
-- 角色權(quán)限
INSERT INTO `roleapi` VALUES (1, 1, 1);
INSERT INTO `roleapi` VALUES (2, 1, 2);
INSERT INTO `roleapi` VALUES (3, 2, 1);
-- 權(quán)限
INSERT INTO `api` VALUES (1, 'viewGoods');
INSERT INTO `api` VALUES (2, 'addGoods');
Tips:用戶(hù)密碼是 123 加密后的值,大家了解即可,稍后再進(jìn)行解釋。
4. Spring Boot 后端實(shí)現(xiàn)
我們新建一個(gè) Spring Boot 項(xiàng)目,并利用 Spring Security 實(shí)現(xiàn)安全管理功能。
4.1 使用 Spring Initializr 創(chuàng)建項(xiàng)目
Spring Boot 版本選擇 2.2.5 ,Group 為 com.imooc , Artifact 為 spring-boot-security,生成項(xiàng)目后導(dǎo)入 Eclipse 開(kāi)發(fā)環(huán)境。
4.2 引入項(xiàng)目依賴(lài)
我們引入 Web 項(xiàng)目依賴(lài)、安全管理依賴(lài),由于要訪問(wèn)數(shù)據(jù)庫(kù)所以引入 JDBC 和 MySQL 依賴(lài)。
實(shí)例:
<!-- Web項(xiàng)目依賴(lài) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 安全管理依賴(lài) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
4.3 定義數(shù)據(jù)對(duì)象
安全管理,肯定需要從數(shù)據(jù)庫(kù)中讀取用戶(hù)信息,以便判斷用戶(hù)登錄名、密碼是否正確,所以需要定義用戶(hù)數(shù)據(jù)對(duì)象。
實(shí)例:
public class UserDo {
private Long id;
private String username;
private String password;
private String roleId;
// 省略 get set
}
4.4 開(kāi)發(fā)數(shù)據(jù)訪問(wèn)類(lèi)
系統(tǒng)初始化時(shí),告訴 Spring Security 訪問(wèn)路徑所需要的對(duì)應(yīng)權(quán)限,所以我們開(kāi)發(fā)從數(shù)據(jù)庫(kù)獲取權(quán)限列表的方法。
實(shí)例:
@Repository
public class ApiDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 獲取所有api
*/
public List<String> getApiPaths() {
String sql = "select path from api";
return jdbcTemplate.queryForList(sql, String.class);
}
}
登錄時(shí),告訴 Spring Security 真實(shí)用戶(hù)名和密碼。 登錄成功時(shí),告訴 Spring Security 當(dāng)前用戶(hù)具備的權(quán)限。
所以我們開(kāi)發(fā)根據(jù)用戶(hù)名獲取用戶(hù)信息和根據(jù)用戶(hù)名獲取其可訪問(wèn)的 api 列表方法。
實(shí)例:
@Repository
public class UserDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 根據(jù)用戶(hù)名獲取用戶(hù)信息
*/
public List<UserDo> getUsersByUsername(String username) {
String sql = "select id, username, password from user where username = ?";
return jdbcTemplate.query(sql, new String[] { username }, new BeanPropertyRowMapper<>(UserDo.class));
}
/**
* 根據(jù)用戶(hù)名獲取其可訪問(wèn)的api列表
*/
public List<String> getApisByUsername(String username) {
String sql = "select path from user left join roleapi on user.roleId=roleapi.roleId left join api on roleapi.apiId=api.id where username = ?";
return jdbcTemplate.queryForList(sql, new String[] { username }, String.class);
}
}
4.5 開(kāi)發(fā)服務(wù)類(lèi)
開(kāi)發(fā) SecurityService 類(lèi),保存安全管理相關(guān)的業(yè)務(wù)方法。
實(shí)例:
@Service
public class SecurityService {
@Autowired
private UserDao userDao;
@Autowired
private ApiDao apiDao;
public List<UserDo> getUserByUsername(String username) {
return userDao.getUsersByUsername(username);
}
public List<String> getApisByUsername(String username) {
return userDao.getApisByUsername(username);
}
public List<String> getApiPaths() {
return apiDao.getApiPaths();
}
}
4.6 開(kāi)發(fā)控制器類(lèi)
開(kāi)發(fā)控制器類(lèi),其中 notLogin 方法是用戶(hù)未登錄時(shí)調(diào)用的方法,其他方法與權(quán)限表中的 api 一一對(duì)應(yīng)。
實(shí)例:
@RestController
public class TestController {
/**
* 未登錄時(shí)調(diào)用該方法
*/
@RequestMapping("/notLogin")
public ResultBo notLogin() {
return new ResultBo(new Exception("未登錄"));
}
/**
* 查看商品
*/
@RequestMapping("/viewGoods")
public ResultBo viewGoods() {
return new ResultBo<>("viewGoods is ok");
}
/**
* 添加商品
*/
@RequestMapping("/addGoods")
public ResultBo addGoods() {
return new ResultBo<>("addGoods is ok");
}
}
由于是前后端分離的項(xiàng)目,為了便于前端統(tǒng)一處理,我們封裝了返回?cái)?shù)據(jù)業(yè)務(wù)邏輯對(duì)象 ResultBo 。
實(shí)例:
public class ResultBo<T> {
/**
* 錯(cuò)誤碼 0表示沒(méi)有錯(cuò)誤(異常) 其他數(shù)字代表具體錯(cuò)誤碼
*/
private int code;
/**
* 后端返回消息
*/
private String msg;
/**
* 后端返回的數(shù)據(jù)
*/
private T data;
/**
* 無(wú)參數(shù)構(gòu)造函數(shù)
*/
public ResultBo() {
this.code = 0;
this.msg = "操作成功";
}
/**
* 帶數(shù)據(jù)data構(gòu)造函數(shù)
*/
public ResultBo(T data) {
this();
this.data = data;
}
/**
* 存在異常的構(gòu)造函數(shù)
*/
public ResultBo(Exception ex) {
this.code = 99999;// 其他未定義異常
this.msg = ex.getMessage();
}
}
4.7 開(kāi)發(fā) Spring Security 配置類(lèi)
現(xiàn)在,我們就需要將用戶(hù)、權(quán)限等信息通過(guò)配置類(lèi)告知 Spring Security 了。
4.7.1 定義配置類(lèi)
定義 Spring Security 配置類(lèi),通過(guò)注解 @EnableWebSecurity 開(kāi)啟安全管理功能。
實(shí)例:
@Configuration
@EnableWebSecurity // 開(kāi)啟安全管理
public class SecurityConfig {
@Autowired
private SecurityService securityService;
}
4.7.2 注冊(cè)密碼加密組件
Spring Security 提供了很多種密碼加密組件,我們使用官方推薦的 BCryptPasswordEncoder ,直接注冊(cè)為 Bean 即可。
我們之前在數(shù)據(jù)庫(kù)中預(yù)定義的密碼字符串即為 123 加密后的結(jié)果。 Spring Security 在驗(yàn)證密碼時(shí),會(huì)自動(dòng)調(diào)用注冊(cè)的加密組件,將用戶(hù)輸入的密碼加密后再與數(shù)據(jù)庫(kù)密碼比對(duì)。
實(shí)例:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
public static void main(String[] args) {
//輸出 $2a$10$kLQpA8S1z0KdgR3Cr6jJJ.R.QsIT7nrCdAfsF4Of84ZBX2lvgtbE.
System.out.println(new BCryptPasswordEncoder().encode("123"));
}
4.7.3 將用戶(hù)密碼及權(quán)限告知 Spring Security
通過(guò)注冊(cè) UserDetailsService 類(lèi)型的組件,組件中設(shè)置用戶(hù)密碼及權(quán)限信息即可。
實(shí)例:
@Bean
public UserDetailsService userDetailsService() {
return username -> {
List<UserDo> users = securityService.getUserByUsername(username);
if (users == null || users.size() == 0) {
throw new UsernameNotFoundException("用戶(hù)名錯(cuò)誤");
}
String password = users.get(0).getPassword();
List<String> apis = securityService.getApisByUsername(username);
// 將用戶(hù)名username、密碼password、對(duì)應(yīng)權(quán)限列表apis放入組件
return User.withUsername(username).password(password).authorities(apis.toArray(new String[apis.size()]))
.build();
};
}
4.7.4 設(shè)置訪問(wèn)路徑需要的權(quán)限信息
同樣,我們通過(guò)注冊(cè)組件,將訪問(wèn)路徑需要的權(quán)限信息告知 Spring Security 。
實(shí)例:
@Bean
public WebSecurityConfigurerAdapter webSecurityConfigurerAdapter() {
return new WebSecurityConfigurerAdapter() {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
// 開(kāi)啟跨域支持
httpSecurity.cors();
// 從數(shù)據(jù)庫(kù)中獲取權(quán)限列表
List<String> paths = securityService.getApiPaths();
for (String path : paths) {
/* 對(duì)/xxx/**路徑的訪問(wèn),需要具備xxx權(quán)限
例如訪問(wèn) /addGoods,需要具備addGoods權(quán)限 */
httpSecurity.authorizeRequests().antMatchers("/" + path + "/**").hasAuthority(path);
}
// 未登錄時(shí)自動(dòng)跳轉(zhuǎn)/notLogin
httpSecurity.authorizeRequests().and().formLogin().loginPage("/notLogin")
// 登錄處理路徑、用戶(hù)名、密碼
.loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password")
.permitAll()
// 登錄成功處理
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse, Authentication authentication)
throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=utf-8");
ResultBo result = new ResultBo<>();
ObjectMapper mapper = new ObjectMapper();
PrintWriter out = httpServletResponse.getWriter();
out.write(mapper.writeValueAsString(result));
out.close();
}
})
// 登錄失敗處理
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse, AuthenticationException e)
throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=utf-8");
ResultBo result = new ResultBo<>(new Exception("登錄失敗"));
ObjectMapper mapper = new ObjectMapper();
PrintWriter out = httpServletResponse.getWriter();
out.write(mapper.writeValueAsString(result));
out.flush();
out.close();
}
});
// 禁用csrf(跨站請(qǐng)求偽造)
httpSecurity.authorizeRequests().and().csrf().disable();
}
};
}
按上面的設(shè)計(jì),當(dāng)用戶(hù)發(fā)起訪問(wèn)時(shí):
- 未登錄的訪問(wèn)會(huì)自動(dòng)跳轉(zhuǎn)到
/notLogin
訪問(wèn)路徑。 - 通過(guò)
/login
訪問(wèn)路徑可以發(fā)起登錄請(qǐng)求,用戶(hù)名和密碼參數(shù)名分別為 username 和 password 。 - 登錄成功或失敗會(huì)返回 ResultBo 序列化后的 JSON 字符串,包含登錄成功或失敗信息。
- 訪問(wèn)
/xxx
形式的路徑,需要具備xxx
權(quán)限。用戶(hù)所具備的權(quán)限已經(jīng)通過(guò)上面的 UserDetailsService 組件告知 Spring Security 了。
5. 測(cè)試
啟動(dòng)項(xiàng)目后,我們使用 PostMan 進(jìn)行驗(yàn)證測(cè)試。
5.1 未登錄測(cè)試
在未登錄時(shí),直接訪問(wèn)控制器方法,會(huì)自動(dòng)跳轉(zhuǎn) /notLogin
訪問(wèn)路徑,返回未登錄
提示信息。
5.2 錯(cuò)誤登錄密碼測(cè)試
調(diào)用登錄接口,當(dāng)密碼不對(duì)時(shí),返回登錄失敗
提示信息。
5.3 以 guest 用戶(hù)登錄
使用 guest 用戶(hù)及正確命名登錄,返回操作成功
提示信息。
5.4 guest 用戶(hù)訪問(wèn)授權(quán)接口
按照數(shù)據(jù)庫(kù)中定義的規(guī)則, guest 用戶(hù)可以訪問(wèn) viewGoods 接口方法。
5.5 guest 用戶(hù)訪問(wèn)未授權(quán)接口
按照數(shù)據(jù)庫(kù)中定義的規(guī)則, guest 沒(méi)有訪問(wèn) addGoods 接口方法的權(quán)限。
5.6 admin 用戶(hù)登錄及訪問(wèn)授權(quán)接口
按照數(shù)據(jù)庫(kù)中定義的規(guī)則, admin 用戶(hù)登錄后可以訪問(wèn) viewGoods 和 addGoods 兩個(gè)接口方法。
6. 小結(jié)
Spring Boot 整合 Spring Security ,實(shí)際上大部分工作都在安全管理配置類(lèi)上。
我們通過(guò)安全管理配置類(lèi),將用戶(hù)、密碼及其對(duì)應(yīng)的權(quán)限信息放入容器,同時(shí)將訪問(wèn)路徑所需要的權(quán)限信息放入容器, Spring Security 就會(huì)按照用戶(hù)訪問(wèn)路徑--判斷所需權(quán)限--用戶(hù)是否具備該權(quán)限--允許或拒絕訪問(wèn)
這樣的邏輯實(shí)施權(quán)限管理了。