前言
在企業項目開發中,對系統的安全和權限控制往往是必需的,常見的安全框架有 Spring Security、Apache Shiro 等。本文主要簡單介紹一下 Spring Security,再通過 Spring Boot 集成開一個簡單的示例。
Spring Security
什么是 Spring Security?
Spring Security 是一種基於 Spring AOP 和 Servlet 過濾器 Filter 的安全框架,它提供了全面的安全解決方案,提供在 Web 請求和方法調用級別的用戶鑒權和權限控制。
Web 應用的安全性通常包括兩方面:用戶認證(Authentication)和用戶授權(Authorization)。
用戶認證指的是驗證某個用戶是否為系統合法用戶,也就是說用戶能否訪問該系統。用戶認證一般要求用戶提供用戶名和密碼,系統通過校驗用戶名和密碼來完成認證。
用戶授權指的是驗證某個用戶是否有權限執行某個操作。
2.原理
Spring Security 功能的實現主要是靠一系列的過濾器鏈相互配合來完成的。以下是項目啟動時打印的默認安全過濾器鏈(集成5.2.0):
[
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@5054e546,
org.springframework.security.web.context.SecurityContextPersistenceFilter@7b0c69a6,
org.springframework.security.web.header.HeaderWriterFilter@4fefa770,
org.springframework.security.web.csrf.CsrfFilter@6346aba8,
org.springframework.security.web.authentication.logout.LogoutFilter@677ac054,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@51430781,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@4203d678,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@625e20e6,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@19628fc2,
org.springframework.security.web.session.SessionManagementFilter@471f8a70,
org.springframework.security.web.access.ExceptionTranslationFilter@3e1eb569,
org.springframework.security.web.access.intercept.FilterSecurityInterceptor@3089ab62
]
- WebAsyncManagerIntegrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CsrfFilter
- LogoutFilter
- UsernamePasswordAuthenticationFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareRequestFilter
- AnonymousAuthenticationFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
詳細解讀可以參考:https://blog.csdn.net/dushiwodecuo/article/details/78913113
3.核心組件
SecurityContextHolder
用於存儲應用程序安全上下文(Spring Context)的詳細信息,如當前操作的用戶對象信息、認證狀態、角色權限信息等。默認情況下,SecurityContextHolder
會使用 ThreadLocal
來存儲這些信息,意味着安全上下文始終可用於同一執行線程中的方法。
獲取有關當前用戶的信息
因為身份信息與線程是綁定的,所以可以在程序的任何地方使用靜態方法獲取用戶信息。例如獲取當前經過身份驗證的用戶的名稱,代碼如下:
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}
其中,getAuthentication()
返回認證信息,getPrincipal()
返回身份信息,UserDetails
是對用戶信息的封裝類。
Authentication
認證信息接口,集成了 Principal
類。該接口中方法如下:
接口方法 | 功能說明 |
---|---|
getAuthorities() | 獲取權限信息列表,默認是 GrantedAuthority 接口的一些實現類,通常是代表權限信息的一系列字符串 |
getCredentials() | 獲取用戶提交的密碼憑證,用戶輸入的密碼字符竄,在認證過后通常會被移除,用於保障安全 |
getDetails() | 獲取用戶詳細信息,用於記錄 ip、sessionid、證書序列號等值 |
getPrincipal() | 獲取用戶身份信息,大部分情況下返回的是 UserDetails 接口的實現類,是框架中最常用的接口之一 |
AuthenticationManager
認證管理器,負責驗證。認證成功后,AuthenticationManager
返回一個填充了用戶認證信息(包括權限信息、身份信息、詳細信息等,但密碼通常會被移除)的 Authentication
實例。然后再將 Authentication
設置到 SecurityContextHolder
容器中。
AuthenticationManager
接口是認證相關的核心接口,也是發起認證的入口。但它一般不直接認證,其常用實現類 ProviderManager
內部會維護一個 List<AuthenticationProvider>
列表,存放里多種認證方式,默認情況下,只需要通過一個 AuthenticationProvider
的認證,就可被認為是登錄成功。
UserDetailsService
負責從特定的地方加載用戶信息,通常是通過JdbcDaoImpl
從數據庫加載實現,也可以通過內存映射InMemoryDaoImpl
實現。
UserDetails
該接口代表了最詳細的用戶信息。該接口中方法如下:
接口方法 | 功能說明 |
---|---|
getAuthorities() | 獲取授予用戶的權限 |
getPassword() | 獲取用戶正確的密碼,這個密碼在驗證時會和 Authentication 中的 getCredentials() 做比對 |
getUsername() | 獲取用於驗證的用戶名 |
isAccountNonExpired() | 指示用戶的帳戶是否已過期,無法驗證過期的用戶 |
isAccountNonLocked() | 指示用戶的賬號是否被鎖定,無法驗證被鎖定的用戶 |
isCredentialsNonExpired() | 指示用戶的憑據(密碼)是否已過期,無法驗證憑證過期的用戶 |
isEnabled() | 指示用戶是否被啟用,無法驗證被禁用的用戶 |
Spring Security 實戰
1.系統設計
本文主要使用 Spring Security 來實現系統頁面的權限控制和安全認證,本示例不做詳細的數據增刪改查,sql 可以在完整代碼里下載,主要是基於數據庫對頁面 和 ajax 請求做權限控制。
1.1 技術棧
- 編程語言:Java
- 編程框架:Spring、Spring MVC、Spring Boot
- ORM 框架:MyBatis
- 視圖模板引擎:Thymeleaf
- 安全框架:Spring Security(5.2.0)
- 數據庫:MySQL
- 前端:Layui、JQuery
1.2 功能設計
- 實現登錄、退出
- 實現菜單 url 跳轉的權限控制
- 實現按鈕 ajax 請求的權限控制
- 防止跨站請求偽造(CSRF)攻擊
1.3 數據庫層設計
t_user 用戶表
字段 | 類型 | 長度 | 是否為空 | 說明 |
---|---|---|---|---|
id | int | 8 | 否 | 主鍵,自增長 |
username | varchar | 20 | 否 | 用戶名 |
password | varchar | 255 | 否 | 密碼 |
t_role 角色表
字段 | 類型 | 長度 | 是否為空 | 說明 |
---|---|---|---|---|
id | int | 8 | 否 | 主鍵,自增長 |
role_name | varchar | 20 | 否 | 角色名稱 |
t_menu 菜單表
字段 | 類型 | 長度 | 是否為空 | 說明 |
---|---|---|---|---|
id | int | 8 | 否 | 主鍵,自增長 |
menu_name | varchar | 20 | 否 | 菜單名稱 |
menu_url | varchar | 50 | 是 | 菜單url(Controller 請求路徑) |
t_user_roles 用戶權限表
字段 | 類型 | 長度 | 是否為空 | 說明 |
---|---|---|---|---|
id | int | 8 | 否 | 主鍵,自增長 |
user_id | int | 8 | 否 | 用戶表id |
role_id | int | 8 | 否 | 角色表id |
t_role_menus 權限菜單表
字段 | 類型 | 長度 | 是否為空 | 說明 |
---|---|---|---|---|
id | int | 8 | 否 | 主鍵,自增長 |
role_id | int | 8 | 否 | 角色表id |
menu_id | int | 8 | 否 | 菜單表id |
實體類這里不詳細列了。
2.代碼實現
2.0 相關依賴
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- 熱部署模塊 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> <!-- 這個需要為 true 熱部署才有效 -->
</dependency>
<!-- mysql 數據庫驅動. -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- mybaits -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- alibaba fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
2.1 繼承 WebSecurityConfigurerAdapter 自定義 Spring Security 配置
/**
prePostEnabled :決定Spring Security的前注解是否可用 [@PreAuthorize,@PostAuthorize,..]
secureEnabled : 決定是否Spring Security的保障注解 [@Secured] 是否可用
jsr250Enabled :決定 JSR-250 annotations 注解[@RolesAllowed..] 是否可用.
*/
@Configurable
@EnableWebSecurity
//開啟 Spring Security 方法級安全注解 @EnableGlobalMethodSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true,jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Autowired
private UserDetailsService userDetailsService;
/**
* 靜態資源設置
*/
@Override
public void configure(WebSecurity webSecurity) {
//不攔截靜態資源,所有用戶均可訪問的資源
webSecurity.ignoring().antMatchers(
"/",
"/css/**",
"/js/**",
"/images/**",
"/layui/**"
);
}
/**
* http請求設置
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//http.csrf().disable(); //注釋就是使用 csrf 功能
http.headers().frameOptions().disable();//解決 in a frame because it set 'X-Frame-Options' to 'DENY' 問題
//http.anonymous().disable();
http.authorizeRequests()
.antMatchers("/login/**","/initUserData")//不攔截登錄相關方法
.permitAll()
//.antMatchers("/user").hasRole("ADMIN") // user接口只有ADMIN角色的可以訪問
// .anyRequest()
// .authenticated()// 任何尚未匹配的URL只需要驗證用戶即可訪問
.anyRequest()
.access("@rbacPermission.hasPermission(request, authentication)")//根據賬號權限訪問
.and()
.formLogin()
.loginPage("/")
.loginPage("/login") //登錄請求頁
.loginProcessingUrl("/login") //登錄POST請求路徑
.usernameParameter("username") //登錄用戶名參數
.passwordParameter("password") //登錄密碼參數
.defaultSuccessUrl("/main") //默認登錄成功頁面
.and()
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler) //無權限處理器
.and()
.logout()
.logoutSuccessUrl("/login?logout"); //退出登錄成功URL
}
/**
* 自定義獲取用戶信息接口
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 密碼加密算法
* @return
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
2.2 自定義實現 UserDetails 接口,擴展屬性
public class UserEntity implements UserDetails {
/**
*
*/
private static final long serialVersionUID = -9005214545793249372L;
private Long id;// 用戶id
private String username;// 用戶名
private String password;// 密碼
private List<Role> userRoles;// 用戶權限集合
private List<Menu> roleMenus;// 角色菜單集合
private Collection<? extends GrantedAuthority> authorities;
public UserEntity() {
}
public UserEntity(String username, String password, Collection<? extends GrantedAuthority> authorities,
List<Menu> roleMenus) {
this.username = username;
this.password = password;
this.authorities = authorities;
this.roleMenus = roleMenus;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public List<Role> getUserRoles() {
return userRoles;
}
public void setUserRoles(List<Role> userRoles) {
this.userRoles = userRoles;
}
public List<Menu> getRoleMenus() {
return roleMenus;
}
public void setRoleMenus(List<Menu> roleMenus) {
this.roleMenus = roleMenus;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
2.3 自定義實現 UserDetailsService 接口
/**
* 獲取用戶相關信息
* @author charlie
*
*/
@Service
public class UserDetailServiceImpl implements UserDetailsService {
private Logger log = LoggerFactory.getLogger(UserDetailServiceImpl.class);
@Autowired
private UserDao userDao;
@Autowired
private RoleDao roleDao;
@Autowired
private MenuDao menuDao;
@Override
public UserEntity loadUserByUsername(String username) throws UsernameNotFoundException {
// 根據用戶名查找用戶
UserEntity user = userDao.getUserByUsername(username);
System.out.println(user);
if (user != null) {
System.out.println("UserDetailsService");
//根據用戶id獲取用戶角色
List<Role> roles = roleDao.getUserRoleByUserId(user.getId());
// 填充權限
Collection<SimpleGrantedAuthority> authorities = new HashSet<SimpleGrantedAuthority>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
//填充權限菜單
List<Menu> menus=menuDao.getRoleMenuByRoles(roles);
return new UserEntity(username,user.getPassword(),authorities,menus);
} else {
System.out.println(username +" not found");
throw new UsernameNotFoundException(username +" not found");
}
}
}
2.4 自定義實現 URL 權限控制
/**
* RBAC數據模型控制權限
* @author charlie
*
*/
@Component("rbacPermission")
public class RbacPermission{
private AntPathMatcher antPathMatcher = new AntPathMatcher();
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
boolean hasPermission = false;
if (principal instanceof UserEntity) {
// 讀取用戶所擁有的權限菜單
List<Menu> menus = ((UserEntity) principal).getRoleMenus();
System.out.println(menus.size());
for (Menu menu : menus) {
if (antPathMatcher.match(menu.getMenuUrl(), request.getRequestURI())) {
hasPermission = true;
break;
}
}
}
return hasPermission;
}
}
2.5 實現 AccessDeniedHandler
自定義處理無權請求
/**
* 處理無權請求
* @author charlie
*
*/
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
boolean isAjax = ControllerTools.isAjaxRequest(request);
System.out.println("CustomAccessDeniedHandler handle");
if (!response.isCommitted()) {
if (isAjax) {
String msg = accessDeniedException.getMessage();
log.info("accessDeniedException.message:" + msg);
String accessDenyMsg = "{\"code\":\"403\",\"msg\":\"沒有權限\"}";
ControllerTools.print(response, accessDenyMsg);
} else {
request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
response.setStatus(HttpStatus.FORBIDDEN.value());
RequestDispatcher dispatcher = request.getRequestDispatcher("/403");
dispatcher.forward(request, response);
}
}
}
public static class ControllerTools {
public static boolean isAjaxRequest(HttpServletRequest request) {
return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
}
public static void print(HttpServletResponse response, String msg) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(msg);
writer.flush();
writer.close();
}
}
}
2.6 相關 Controller
登錄/退出跳轉
/**
* 登錄/退出跳轉
* @author charlie
*
*/
@Controller
public class LoginController {
@GetMapping("/login")
public ModelAndView login(@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "logout", required = false) String logout) {
ModelAndView mav = new ModelAndView();
if (error != null) {
mav.addObject("error", "用戶名或者密碼不正確");
}
if (logout != null) {
mav.addObject("msg", "退出成功");
}
mav.setViewName("login");
return mav;
}
}
登錄成功跳轉
@Controller
public class MainController {
@GetMapping("/main")
public ModelAndView toMainPage() {
//獲取登錄的用戶名
Object principal= SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String username=null;
if(principal instanceof UserDetails) {
username=((UserDetails)principal).getUsername();
}else {
username=principal.toString();
}
ModelAndView mav = new ModelAndView();
mav.setViewName("main");
mav.addObject("username", username);
return mav;
}
}
用於不同權限頁面訪問測試
/**
* 用於不同權限頁面訪問測試
* @author charlie
*
*/
@Controller
public class ResourceController {
@GetMapping("/publicResource")
public String toPublicResource() {
return "resource/public";
}
@GetMapping("/vipResource")
public String toVipResource() {
return "resource/vip";
}
}
用於不同權限ajax請求測試
/**
* 用於不同權限ajax請求測試
* @author charlie
*
*/
@RestController
@RequestMapping("/test")
public class HttptestController {
@PostMapping("/public")
public JSONObject doPublicHandler(Long id) {
JSONObject json = new JSONObject();
json.put("code", 200);
json.put("msg", "請求成功" + id);
return json;
}
@PostMapping("/vip")
public JSONObject doVipHandler(Long id) {
JSONObject json = new JSONObject();
json.put("code", 200);
json.put("msg", "請求成功" + id);
return json;
}
}
2.7 相關 html 頁面
登錄頁面
<form class="layui-form" action="/login" method="post">
<div class="layui-input-inline">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="text" name="username" required
placeholder="用戶名" autocomplete="off" class="layui-input">
</div>
<div class="layui-input-inline">
<input type="password" name="password" required placeholder="密碼" autocomplete="off"
class="layui-input">
</div>
<div class="layui-input-inline login-btn">
<button id="btnLogin" lay-submit lay-filter="*" class="layui-btn">登錄</button>
</div>
<div class="form-message">
<label th:text="${error}"></label>
<label th:text="${msg}"></label>
</div>
</form>
防止跨站請求偽造(CSRF)攻擊
退出系統
<form id="logoutForm" action="/logout" method="post"
style="display: none;">
<input type="hidden" th:name="${_csrf.parameterName}"
th:value="${_csrf.token}">
</form>
<a
href="javascript:document.getElementById('logoutForm').submit();">退出系統</a>
ajax 請求頁面
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" id="hidCSRF">
<button class="layui-btn" id="btnPublic">公共權限請求按鈕</button>
<br>
<br>
<button class="layui-btn" id="btnVip">VIP權限請求按鈕</button>
<script type="text/javascript" th:src="@{/js/jquery-1.8.3.min.js}"></script>
<script type="text/javascript" th:src="@{/layui/layui.js}"></script>
<script type="text/javascript">
layui.use('form', function() {
var form = layui.form;
$("#btnPublic").click(function(){
$.ajax({
url:"/test/public",
type:"POST",
data:{id:1},
beforeSend:function(xhr){
xhr.setRequestHeader('X-CSRF-TOKEN',$("#hidCSRF").val());
},
success:function(res){
alert(res.code+":"+res.msg);
}
});
});
$("#btnVip").click(function(){
$.ajax({
url:"/test/vip",
type:"POST",
data:{id:2},
beforeSend:function(xhr){
xhr.setRequestHeader('X-CSRF-TOKEN',$("#hidCSRF").val());
},
success:function(res){
alert(res.code+":"+res.msg);
}
});
});
});
</script>
2.8 測試
測試提供兩個賬號:user 和 admin (密碼與賬號一樣)
由於 admin 作為管理員權限,設置了全部的訪問權限,這里只展示 user 的測試結果。
完整代碼
非特殊說明,本文版權歸 朝霧輕寒 所有,轉載請注明出處.
原文標題:Spring Boot 2.X(十八):集成 Spring Security-登錄認證和權限控制
原文地址: https://www.zwqh.top/article/info/27
如果文章有不足的地方,歡迎提點,后續會完善。
如果文章對您有幫助,請給我點個贊,請掃碼關注下我的公眾號,文章持續更新中...