Spring Boot 安全管理
1. 前言
安全管理是軟件系統必不可少的的功能。根據經典的“墨菲定律”——凡是可能,總會發生。如果系統存在安全隱患,最終必然會出現問題。
本節就來演示下,如何使用 Spring Boot + Spring Security 開發前后端分離的權限管理功能。
2. Spring Security 用法簡介
作為一個知名的安全管理框架, Spring Security 對安全管理功能的封裝已經非常完整了。
我們在使用 Spring Security 時,只需要從配置文件或者數據庫中,把用戶、權限相關的信息取出來。然后通過配置類方法告訴 Spring Security , Spring Security 就能自動實現認證、授權等安全管理操作了。
- 系統初始化時,告訴 Spring Security 訪問路徑所需要的對應權限。
- 登錄時,告訴 Spring Security 真實用戶名和密碼。
- 登錄成功時,告訴 Spring Security 當前用戶具備的權限。
- 用戶訪問接口時,Spring Security 已經知道用戶具備的權限,也知道訪問路徑需要的對應權限,所以自動判斷能否訪問。
3. 數據庫模塊實現
3.1 定義表結構
需要 4 張表:
- 用戶表 user:保存用戶名、密碼,及用戶擁有的角色 id 。
- 角色表 role :保存角色 id 與角色名稱。
- 角色權限表 roleapi:保存角色擁有的權限信息。
- 權限表 api:保存權限信息,在前后端分離的項目中,權限指的是控制器中的開放接口。
具體表結構如下,需要注意的是 api 表中的 path 字段表示接口的訪問路徑,另外所有的 id 都是自增主鍵。
3.2 構造測試數據
執行如下 SQL 語句插入測試數據,下面的語句指定了 admin 用戶可以訪問 viewGoods 和 addGoods 接口,而 guest 用戶只能訪問 viewGoods 接口。
實例:
-- 用戶
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, '游客');
-- 角色權限
INSERT INTO `roleapi` VALUES (1, 1, 1);
INSERT INTO `roleapi` VALUES (2, 1, 2);
INSERT INTO `roleapi` VALUES (3, 2, 1);
-- 權限
INSERT INTO `api` VALUES (1, 'viewGoods');
INSERT INTO `api` VALUES (2, 'addGoods');
Tips:用戶密碼是 123 加密后的值,大家了解即可,稍后再進行解釋。
4. Spring Boot 后端實現
我們新建一個 Spring Boot 項目,并利用 Spring Security 實現安全管理功能。
4.1 使用 Spring Initializr 創建項目
Spring Boot 版本選擇 2.2.5 ,Group 為 com.imooc , Artifact 為 spring-boot-security,生成項目后導入 Eclipse 開發環境。
4.2 引入項目依賴
我們引入 Web 項目依賴、安全管理依賴,由于要訪問數據庫所以引入 JDBC 和 MySQL 依賴。
實例:
<!-- Web項目依賴 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency
<!-- 安全管理依賴 -->
<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 定義數據對象
安全管理,肯定需要從數據庫中讀取用戶信息,以便判斷用戶登錄名、密碼是否正確,所以需要定義用戶數據對象。
實例:
public class UserDo {
private Long id;
private String username;
private String password;
private String roleId;
// 省略 get set
}
4.4 開發數據訪問類
系統初始化時,告訴 Spring Security 訪問路徑所需要的對應權限,所以我們開發從數據庫獲取權限列表的方法。
實例:
@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);
}
}
登錄時,告訴 Spring Security 真實用戶名和密碼。 登錄成功時,告訴 Spring Security 當前用戶具備的權限。
所以我們開發根據用戶名獲取用戶信息和根據用戶名獲取其可訪問的 api 列表方法。
實例:
@Repository
public class UserDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 根據用戶名獲取用戶信息
*/
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));
}
/**
* 根據用戶名獲取其可訪問的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 開發服務類
開發 SecurityService 類,保存安全管理相關的業務方法。
實例:
@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 開發控制器類
開發控制器類,其中 notLogin 方法是用戶未登錄時調用的方法,其他方法與權限表中的 api 一一對應。
實例:
@RestController
public class TestController {
/**
* 未登錄時調用該方法
*/
@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");
}
}
由于是前后端分離的項目,為了便于前端統一處理,我們封裝了返回數據業務邏輯對象 ResultBo 。
實例:
public class ResultBo<T> {
/**
* 錯誤碼 0表示沒有錯誤(異常) 其他數字代表具體錯誤碼
*/
private int code;
/**
* 后端返回消息
*/
private String msg;
/**
* 后端返回的數據
*/
private T data;
/**
* 無參數構造函數
*/
public ResultBo() {
this.code = 0;
this.msg = "操作成功";
}
/**
* 帶數據data構造函數
*/
public ResultBo(T data) {
this();
this.data = data;
}
/**
* 存在異常的構造函數
*/
public ResultBo(Exception ex) {
this.code = 99999;// 其他未定義異常
this.msg = ex.getMessage();
}
}
4.7 開發 Spring Security 配置類
現在,我們就需要將用戶、權限等信息通過配置類告知 Spring Security 了。
4.7.1 定義配置類
定義 Spring Security 配置類,通過注解 @EnableWebSecurity 開啟安全管理功能。
實例:
@Configuration
@EnableWebSecurity // 開啟安全管理
public class SecurityConfig {
@Autowired
private SecurityService securityService;
}
4.7.2 注冊密碼加密組件
Spring Security 提供了很多種密碼加密組件,我們使用官方推薦的 BCryptPasswordEncoder ,直接注冊為 Bean 即可。
我們之前在數據庫中預定義的密碼字符串即為 123 加密后的結果。 Spring Security 在驗證密碼時,會自動調用注冊的加密組件,將用戶輸入的密碼加密后再與數據庫密碼比對。
實例:
@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 將用戶密碼及權限告知 Spring Security
通過注冊 UserDetailsService 類型的組件,組件中設置用戶密碼及權限信息即可。
實例:
@Bean
public UserDetailsService userDetailsService() {
return username -> {
List<UserDo> users = securityService.getUserByUsername(username);
if (users == null || users.size() == 0) {
throw new UsernameNotFoundException("用戶名錯誤");
}
String password = users.get(0).getPassword();
List<String> apis = securityService.getApisByUsername(username);
// 將用戶名username、密碼password、對應權限列表apis放入組件
return User.withUsername(username).password(password).authorities(apis.toArray(new String[apis.size()]))
.build();
};
}
4.7.4 設置訪問路徑需要的權限信息
同樣,我們通過注冊組件,將訪問路徑需要的權限信息告知 Spring Security 。
實例:
@Bean
public WebSecurityConfigurerAdapter webSecurityConfigurerAdapter() {
return new WebSecurityConfigurerAdapter() {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
// 開啟跨域支持
httpSecurity.cors();
// 從數據庫中獲取權限列表
List<String> paths = securityService.getApiPaths();
for (String path : paths) {
/* 對/xxx/**路徑的訪問,需要具備xxx權限
例如訪問 /addGoods,需要具備addGoods權限 */
httpSecurity.authorizeRequests().antMatchers("/" + path + "/**").hasAuthority(path);
}
// 未登錄時自動跳轉/notLogin
httpSecurity.authorizeRequests().and().formLogin().loginPage("/notLogin")
// 登錄處理路徑、用戶名、密碼
.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(跨站請求偽造)
httpSecurity.authorizeRequests().and().csrf().disable();
}
};
}
按上面的設計,當用戶發起訪問時:
- 未登錄的訪問會自動跳轉到
/notLogin
訪問路徑。 - 通過
/login
訪問路徑可以發起登錄請求,用戶名和密碼參數名分別為 username 和 password 。 - 登錄成功或失敗會返回 ResultBo 序列化后的 JSON 字符串,包含登錄成功或失敗信息。
- 訪問
/xxx
形式的路徑,需要具備xxx
權限。用戶所具備的權限已經通過上面的 UserDetailsService 組件告知 Spring Security 了。
5. 測試
啟動項目后,我們使用 PostMan 進行驗證測試。
5.1 未登錄測試
在未登錄時,直接訪問控制器方法,會自動跳轉 /notLogin
訪問路徑,返回未登錄
提示信息。
5.2 錯誤登錄密碼測試
調用登錄接口,當密碼不對時,返回登錄失敗
提示信息。
5.3 以 guest 用戶登錄
使用 guest 用戶及正確命名登錄,返回操作成功
提示信息。
5.4 guest 用戶訪問授權接口
按照數據庫中定義的規則, guest 用戶可以訪問 viewGoods 接口方法。
5.5 guest 用戶訪問未授權接口
按照數據庫中定義的規則, guest 沒有訪問 addGoods 接口方法的權限。
5.6 admin 用戶登錄及訪問授權接口
按照數據庫中定義的規則, admin 用戶登錄后可以訪問 viewGoods 和 addGoods 兩個接口方法。
6. 小結
Spring Boot 整合 Spring Security ,實際上大部分工作都在安全管理配置類上。
我們通過安全管理配置類,將用戶、密碼及其對應的權限信息放入容器,同時將訪問路徑所需要的權限信息放入容器, Spring Security 就會按照用戶訪問路徑--判斷所需權限--用戶是否具備該權限--允許或拒絕訪問
這樣的邏輯實施權限管理了。