解析spring-security權限控制和校驗的問題

前言

    在我們項目中經常會涉及到權限管理,特別是一些企業級後臺應用中,那權限管理是必不可少的。這個時候就涉及到技術選型的問題。在我以前項目中也沒用到什麼權限框架,就全部到一個spring mvc攔截器中去校驗權限,當然,對需求比較少,小型的項目這也不失一個好的實現(實現簡單,功能單一),但是對於一些比較大的應用,權限認證,session管理要求比較高的項目,如果再使用mvc攔截器,那就得不償失瞭(需要自己去實現很多的代碼)。
    現在比較流行的權限校驗框架有spring-security 和 apache-shiro。鑒於我們一直在使用spring全傢桶,那我的項目中當然首選spring-security。下面我我所認識的spring-security來一步一步的看怎麼實現。這裡我拋磚引玉,歡迎大傢指正。

我的完整代碼在我的github中 我的github ,歡迎大傢留言討論!!


一、spring-security是什麼?

    Spring Security 是 Spring 傢族中的一個安全管理框架,類似的安全框架還有apache-shiro。shiro以使用簡單,功能強大而著稱,本篇我們隻討論Spring Security,shiro就不再鋪開討論瞭。
    以前我們在使用springMVC與Security 結合的時候,那一堆堆配置文件,長篇大論的xml看的人頭大,幸好,現在有瞭springboot,可以基於java config的方式配置,實現零配置,而且又兼有springboot的約定大於配置的前提,我們項目中的配置文件或需要配置的代碼大大減少瞭。

二、spring-security能為我們做什麼?

spring-security最主要的功能包含:
1、認證(就是,你是誰)
2、授權(就是,你能幹什麼)
3、攻擊防護 (防止偽造身份)
這三點其實就是我們在應用中常用到的。現在有瞭spring-security框架,就使得我們代碼實現起來非常簡單。

下面就來跟著我一步一步來看,怎麼使用它

三、使用步驟

1.maven依賴

<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>

版本號就跟著springboot版本走

2.application.properties文件

server.port=8080
server.servlet.context-path=/demo

spring.main.allow-bean-definition-overriding=true
spring.profiles.active=dev

這個時候,我們啟動項目,就會在控制臺看到這一行信息

紅色框出來的就是spring-security為你自動分配的賬號為 user 的密碼。

3.訪問接口

    你現在如果想要訪問系統裡面的接口,那必須要經過這個權限驗證。
隨便打開一個接口都會跳轉到內置的一個登陸頁面中

    我們看到,他跳轉到一個地址為login的頁面去瞭。這個時候我們輸入用戶名 user,密碼為控制臺打印出來的一串字符, 點擊按鈕 sign in 頁面正常跳轉到接口返回的數據。
我們發現,我們沒有寫一行代碼,僅僅是在pom裡面依賴瞭spring-boot-starter-security,框架就自動為我們做瞭最簡單的驗證功能,驚不驚喜意不意外。當然僅僅這麼點當然不能滿足我們項目的要求,不急,聽我一步一步慢慢道來。

4.功能進階

下面我們以最常見的企業級應用管理後臺的權限為例
我們需要提出幾個問題
1、用戶的賬號,密碼保存在數據庫中,登錄的時候驗證
2、用戶登錄成功後,每訪問一個地址,後臺都要判斷該用戶有沒有這個菜單的權限,有,則放行;沒有,則,拒絕訪問。
3、系統中的一些靜態資源則直接放行,不需要經過權限校驗
4、系統中可能存在三種類型的資源地址
    ①:所有用戶都能訪問的地址(如:登錄頁面)
    ②:隻要登錄,就可以訪問的地址(如:首頁)
    ③:需要授權才能訪問的地址

    針對上面提出的幾個問題,我們設計最常用的權限表結構模型

sys_user(用戶表:保存用戶的基本信息,登錄名,密碼等等)sys_role(角色表:保存瞭創建的角色)sys_menu(菜單表:保存瞭系統可訪問的資源(包含菜單url等))sys_user_role(用戶關聯的角色:一個用戶可以關聯多個角色,最後用戶的權限就是這多個角色權限的並集)sys_role_menu(角色關聯的菜單:一個角色可以關聯多個菜單) 5.spring-security主配置類

    spring-security的主配置類,就需要我們自定義一個類繼承 WebSecurityConfigurerAdapter 並且實現裡面方法,如下:

package com.hp.springboot.admin.security;

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.hp.springboot.admin.constant.AdminConstants;
import com.hp.springboot.admin.interceptor.UrlAuthenticationInterceptor;
import com.hp.springboot.admin.security.handler.AdminAccessDeniedHandler;
import com.hp.springboot.admin.security.handler.AdminAuthenticationEntryPoint;
import com.hp.springboot.admin.security.handler.AdminAuthenticationFailureHandler;
import com.hp.springboot.admin.security.handler.AdminAuthenticationSuccessHandler;
import com.hp.springboot.common.configuration.CommonWebMvcConfigurer;

/**
 1. 描述:security全局配置
 2. 作者:黃平
 3. 時間:2021年1月11日
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) //開啟Security註解的功能,如果你項目中不用Security的註解(hasRole,hasAuthority等),則可以不加該註解
public class AdminWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
	
	@Autowired
	private CommonWebMvcConfigurer commonWebMvcConfigurer;
	
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		// 設置超級管理員
		auth.inMemoryAuthentication()
			.withUser(AdminConstants.ADMIN_USER)
		;
		
		// 其餘賬號通過數據庫查詢驗證
		auth.userDetailsService(adminUserDetailsService()).passwordEncoder(passwordEncoder());
	}
	
	@Override
	public void configure(WebSecurity web) throws Exception {
		// 靜態資源
		String[] ignoreArray = commonWebMvcConfigurer.getMergeStaticPatternArray();
		
		// 設置系統的靜態資源。靜態資源不會走權限框架
		web.ignoring().antMatchers(ignoreArray);
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// 驗證碼過濾器
		http.addFilterBefore(new ValidateCodeFilter(), UsernamePasswordAuthenticationFilter.class);
		
		// 第一層免過濾列表
		// 就是所有人都可以訪問的地址。區別於靜態資源
		List<String> noFilterList = new ArrayList<>();
		noFilterList.add(AdminConstants.ACCESS_DENIED_URL);
		noFilterList.add(AdminConstants.VERIFY_CODE_URL);
		noFilterList.addAll(commonWebMvcConfigurer.getMergeFirstNoFilterList());
				
		http.formLogin()// 登錄頁面使用form提交的方式
		.usernameParameter("username").passwordParameter("password")// 設置登錄頁面用戶名和密碼的input對應name值(其實默認值就是username,password,所以這裡可以不用設置)
		.loginPage(AdminConstants.LOGIN_PAGE_URL)// 設置登錄頁面的地址
		.loginProcessingUrl(AdminConstants.LOGIN_PROCESSING_URL)// 登錄頁面輸入用戶名密碼後提交的地址
		.successHandler(adminAuthenticationSuccessHandler())// 登錄成功處理
		.failureHandler(adminAuthenticationFailureHandler())// 登錄失敗的處理
		.permitAll()// 以上url全部放行,不需要校驗權限
		.and()
		
		// 註銷相關配置
		.logout()
		.logoutUrl(AdminConstants.LOGOUT_URL)// 註銷地址
		.logoutSuccessUrl(AdminConstants.LOGIN_PAGE_URL)// 註銷成功後跳轉地址(這裡就是跳轉到登錄頁面)
		.permitAll()// 以上地址全部放行
		.and().authorizeRequests()
		
		// 第一層免過濾列表
		// 不需要登錄,就可以直接訪問的地址
		.antMatchers(noFilterList.toArray(new String[noFilterList.size()])).permitAll() // 全部放行
		
		// 其他都需要權限控制
		// 這裡使用.anyRequest().access方法,把權限驗證交給指定的一個方法去處理。
		// 這裡 hasPermission接受兩個參數request和authentication
		.anyRequest().access("@UrlAuthenticationInterceptor.hasPermission(request, authentication)")
		
		// 異常處理
		.and().exceptionHandling()
		.accessDeniedHandler(new AdminAccessDeniedHandler())// 登錄用戶訪問無權限的資源
		.authenticationEntryPoint(new AdminAuthenticationEntryPoint())// 匿名用戶訪問無權限的資源
		.and().csrf().disable()// 禁用csrf
		
		// session管理
		.sessionManagement()
		.invalidSessionUrl(AdminConstants.LOGIN_PAGE_URL)// session失效後,跳轉的地址
		.maximumSessions(1)// 同一個賬號最大允許同時在線數
		;
	}
	
	/**
	 * @Title: passwordEncoder
	 * @Description: 加密方式
	 * @return
	 */
	@Bean
 public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
 }
	
	/**
	 * @Title: adminUserDetailsService
	 * @Description: 用戶信息
	 * @return
	 */
	@Bean
	public UserDetailsService adminUserDetailsService() {
		return new AdminUserDetailsService();
	}
	
	/**
	 * d
	 * @Title: adminAuthenticationFailureHandler
	 * @Description: 登錄異常處理
	 * @return
	 */
	@Bean
	public AdminAuthenticationFailureHandler adminAuthenticationFailureHandler() {
		return new AdminAuthenticationFailureHandler();
	}
	
	/**
	 * @Title: adminAuthenticationSuccessHandler
	 * @Description: 登錄成功後的處理
	 * @return
	 */
	@Bean
	public AdminAuthenticationSuccessHandler adminAuthenticationSuccessHandler() {
		return new AdminAuthenticationSuccessHandler();
	}
	
	/**
	 * @Title: urlAuthenticationInterceptor
	 * @Description: 查詢權限攔截器
	 * @return
	 */
	@Bean("UrlAuthenticationInterceptor")
	public UrlAuthenticationInterceptor urlAuthenticationInterceptor() {
		return new UrlAuthenticationInterceptor();
	}
}

解讀一下這個類:

類繼承WebSecurityConfigurerAdapter 說明是一個spring-Security配置類註解 @Configuration 說明是一個springboot的配置類註解 @EnableGlobalMethodSecurity 不是必須。開啟註解用的第一個 configure 方法,設置登錄的用戶和賬號驗證方法
    這裡設置瞭兩種方式,一個是內置的admin賬號,一個是通過數據庫驗證賬號
    這樣設置有個好處,就是我們在後臺的用戶管理頁面裡面是看不到admin賬號的,這樣就不會存在把所有用戶都刪除瞭,就登錄不瞭系統的bug(好多年前做系統的時候,一個測試人員一上來就打開用戶管理菜單,然後把所有用戶都刪除,再退出。然後就登錄不瞭系統瞭,隨即提瞭一個bug。隻能手動插入數據到數據庫才行,當時我看的一臉懵逼,還能這樣操作???)。現在有瞭這樣設置,就保證admin用戶永遠不可能被刪除,也就不存在上面提到的bug瞭。第二個configure方法。這個方法是設置一些靜態資源的。可以在這裡設置系統所有的靜態資源第三個configure方法。這個是這個類中最重要的配置。裡面設置瞭登錄方式、url過濾規則、權限校驗規則、成功處理、失敗處理、session管理、登出處理等等。這裡是鏈式的調用方式,可以把需要的都在裡面配置

這裡有幾個特別要說明的:
1、我們項目中保存到session中的用戶對象一般是我們項目中自定義的一個類(我這裡是SysUserResponseBO),在項目中我們用 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 這個方法獲取當前登錄用戶信息時,如果是admin用戶,則返回的對象是org.springframework.security.core.userdetails.User對象
2、密碼需要加密,保存在數據庫裡面的密碼也是加密方式,不允許直接保存明文(這個也是規范)
3、第三個 configure 方法中,我們使用瞭.anyRequest().access(“@UrlAuthenticationInterceptor.hasPermission(request, authentication)”)交給這個方法去驗證。驗證的方法有很多,我們也可以這樣去寫

.anyRequest().authenticated().withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {

			@Override
			public <O extends FilterSecurityInterceptor> O postProcess(O object) {
				// 權限查詢器
				// 設置你有哪些權限
				object.setSecurityMetadataSource(null);
				
				// 權限決策器
				// 判斷你有沒有權限訪問當前的url
				object.setAccessDecisionManager(null);
				return object;
			}

		})

這裡之所以沒有用.antMatchers(“/XXX”).hasRole(“ROLET_XXX”),是因為,一般項目中的權限都是動態的,所有的資源菜單都是可配置的,在這裡是無法寫死的。當然這個要根據實際項目需求來做。總之配置很靈活,可以隨意組合。

3、驗證碼過濾器那邊ValidateCodeFilter一定不能交給spring bean去管理,不然這個過濾器會執行兩遍,隻能直接new 出來。

AdminUserDetailsService類
    該類是用來在登錄的時候,進行登錄校驗的。也就是校驗你的賬號密碼是否正確(其實這裡隻根據賬號查詢,密碼的驗證是框架裡面自帶的)。來看下這個類的實現

package com.hp.springboot.admin.security;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import com.hp.springboot.admin.convert.SysUserConvert;
import com.hp.springboot.admin.dal.ISysUserDAO;
import com.hp.springboot.admin.dal.model.SysUser;
import com.hp.springboot.admin.model.response.SysUserResponseBO;
import com.hp.springboot.database.bean.SQLBuilders;
import com.hp.springboot.database.bean.SQLWhere;

/**
 * 描述:Security需要的操作用戶的接口實現
 * 執行登錄,構建Authentication對象必須的信息
 * 如果用戶不存在,則拋出UsernameNotFoundException異常
 * 作者:黃平
 * 時間:2021年1月12日
 */
public class AdminUserDetailsService implements UserDetailsService {

	private static Logger log = LoggerFactory.getLogger(AdminUserDetailsService.class);
	
	@Autowired
	private ISysUserDAO sysUserDAO;
	
	/**
	 * 執行登錄,構建Authentication對象必須的信息,
	 */
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		log.info("loadUserByUsername with username={}", username);
		
		//根據登錄名,查詢用戶
		SysUser user = sysUserDAO.selectOne(SQLBuilders.create()
				.withWhere(SQLWhere.builder()
						.eq("login_name", username)
						.build()
						)
				);
		if (user == null) {
			log.warn("loadUserByUsername with user is not exists. with username={}", username);
			throw new UsernameNotFoundException("用戶不存在");
		}
		
		// 對象裝換,轉換成SysUserResponseBO對象
		SysUserResponseBO resp = SysUserConvert.dal2BOResponse(user);
		return resp;
	}

}

這個裡面很簡單,我們的類實現 UserDetailsService 這個接口,並且實現一下loadUserByUsername這個方法。就是根據登錄名,查詢用戶的功能。

這裡有必須要主要的:
我們返回值是UserDetails,所以SysUserResponseBO必須要實現UserDetails這個接口
在這裡插入圖片描述
UserDetails裡面有好幾個必須實現的方法,基本上看方法名就可以猜到是幹什麼用的,其中最重要的的一個方法

/**
	 * 獲取該用戶的角色
	 */
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.authorities;
	}

這個就是獲取當前用戶所擁有的權限。這個需要根據用戶所擁有的角色去獲取。這個在上面使用 withObjectPostProcessor 這種方式校驗的時候是必須要的,但是我這裡.anyRequest().access()方法,在這裡校驗,並沒有使用到這個屬性,所以這個也可以直接return null.

第二個configure方法。這裡面定義瞭靜態資源,這個跟springMVC的靜態資源差不多。重點來說下第三個configure方法
    ①、如果需要,那就加上一個過濾器增加圖形驗證碼校驗
    ②、登錄成功後處理AdminAuthenticationSuccessHandler這個類。該類實現瞭AuthenticationSuccessHandler接口,必須實現一個方法,直接上代碼

@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		response.setContentType(ContentTypeConstant.APPLICATION_JSON_UTF8);
		
		// 獲取session對象
		HttpSession session = request.getSession();
		
		//設置登錄用戶session
		setUserSession(session);
		
		//查詢用戶的菜單和按鈕
		setUserMenu(session);
		
		//session中獲取當前登錄的用戶
		SysUserResponseBO user = SecuritySessionUtil.getSessionData();
		
		// 更新最近登錄時間
		sysUserService.updateLastLoginTime(user.getId());
		
		//項目名稱
		session.setAttribute("projectName", projectName);
		
		// 註銷地址
		session.setAttribute("logoutUrl", AdminConstants.LOGOUT_URL);
		
		// 返回json格式數據
		Response<Object> resp = Response.success();
		try (PrintWriter out = response.getWriter()) {
			out.write(resp.toString());
			out.flush();
		}
	}

基本上看註釋也就瞭解每一步的意義。
我們代碼中無需寫登錄的controller,因為這個方法框架已經根據你配置的loginProcessingUrl給你生成好瞭。這個是用戶輸入用戶名密碼後,點擊登錄按鈕後執行的操作。能夠進入這個方法,那說明用戶輸入的用戶名和密碼是正確的,後續隻要保存用戶的信息,查詢用戶權限等操作。
    ③、登錄失敗處理。AdminAuthenticationFailureHandler。登錄失敗後交給這個類去處理,看下代碼:

package com.hp.springboot.admin.security.handler;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import com.hp.springboot.admin.exception.ValidateCodeException;
import com.hp.springboot.common.bean.Response;
import com.hp.springboot.common.constant.ContentTypeConstant;

/**
 * 描述:登錄失敗處理 作者:黃平 時間:2021年1月15日
 */
public class AdminAuthenticationFailureHandler implements AuthenticationFailureHandler {

	private static Logger log = LoggerFactory.getLogger(AdminAuthenticationFailureHandler.class);

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		log.warn("login error with exception is {}", exception.getMessage());
		response.setContentType(ContentTypeConstant.APPLICATION_JSON_UTF8);
		String message = "";
		if (exception instanceof BadCredentialsException || exception instanceof UsernameNotFoundException) {
			message = "賬戶名或者密碼輸入錯誤!";
		} else if (exception instanceof LockedException) {
			message = "賬戶被鎖定,請聯系管理員!";
		} else if (exception instanceof CredentialsExpiredException) {
			message = "密碼過期,請聯系管理員!";
		} else if (exception instanceof AccountExpiredException) {
			message = "賬戶過期,請聯系管理員!";
		} else if (exception instanceof DisabledException) {
			message = "賬戶被禁用,請聯系管理員!";
		} else if (exception instanceof ValidateCodeException) {
			// 圖形驗證碼輸入錯誤
			message = exception.getMessage();
		} else if (exception instanceof InsufficientAuthenticationException) {
			message = exception.getMessage();
		} else {
			message = "登錄失敗!";
		}
		
		// 返回json格式數據
		Response<Object> resp = Response.error(message);
		try (PrintWriter out = response.getWriter()) {
			out.write(resp.toString());
			out.flush();
		}
	}

}

看代碼也基本上看出來每一步的作用。有用戶名密碼錯誤,有用戶被禁用,有過期,鎖定等等。我這裡前臺都是ajax請求,所以這個也是返回json格式,如果你不需要json格式,那可以按照你的要求返回指定的格式。
    ④、註銷相關。註銷接口也不需要我們在controller裡面寫,框架會自動根據你的logoutUrl配置生成註銷地址。也可以自定義一個logoutSuccessHandler去在註銷後執行。
    ⑤、權限校驗。當訪問一個除開第一層免過濾列表裡面的url的地址時,都會需要權限校驗,就都會走到UrlAuthenticationInterceptor.hasPermission(request, authentication)這個方法裡面去,這個裡面可以根據你的項目的實際邏輯去校驗。
    ⑥、異常處理。框架裡面處理異常有好多種,這裡常用的accessDeniedHandler(登錄用戶訪問無權限的資源)、authenticationEntryPoint(匿名用戶訪問無權限的資源)這些都按照項目的實際需求去寫異常處理。
    ⑦、session管理。security框架裡面對session管理非常多,可以按照鏈式調用的方式打開看看。我這裡使用瞭invalidSessionUrl來指定session無效後跳轉到的地址,maximumSessions同一個賬號最多同時在線數。

好瞭,這樣一個最基本的權限控制框架就完成瞭。
其實我這裡隻使用瞭security的一些皮毛而且,他裡面集成瞭非常復雜而又強大的功能,這個需要我們一點一點去發掘他。

總結

以上是我在項目中使用的一些總結,完整的代碼在我的github中 我的github ,歡迎大傢留言討論!!

到此這篇關於spring-security權限控制和校驗的文章就介紹到這瞭,更多相關spring-security權限控制校驗內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: