紙上得來終覺淺,覺知此事要躬行。java
本文適合: 對Spring Security有一點了解或者跑過簡單demo可是對總體運行流程不明白的同窗,對SpringSecurity有興趣的也能夠看成大家的入門教程,示例代碼中也有不少註釋。
本文代碼: 碼雲地址 GitHub地址
你們在作系統的時候,通常作的第一個模塊就是認證與受權模塊,由於這是一個系統的入口,也是一個系統最重要最基礎的一環,在認證與受權服務設計搭建好了以後,剩下的模塊才得以安全訪問。
市面上通常作認證受權的框架就是shiro
和Spring Security
,也有大部分公司選擇本身研製。出於以前看過不少Spring Security
的入門教程,但都以爲講的不是太好,因此我這兩天在本身鼓搗Spring Security
的時候萌生了分享一下的想法,但願能夠幫助到有興趣的人。mysql
Spring Security
框架咱們主要用它就是解決一個認證受權功能,因此個人文章主要會分爲兩部分:git
我會爲你們用一個Spring Security + JWT + 緩存的一個demo來展示我要講的東西,畢竟腦子的東西要體如今具體事物上才能夠更直觀的讓你們去了解去認識。
學習一件新事物的時候,我推薦使用自頂向下的學習方法,這樣能夠更好的認識新事物,而不是盲人摸象。github
注:只涉及到用戶認證受權不涉及oauth2之類的第三方受權。web
想上手 Spring Security 必定要先了解它的工做流程,由於它不像工具包同樣,拿來即用,必需要對它有必定的瞭解,再根據它的用法進行自定義操做。spring
咱們能夠先來看看它的工做流程:
在Spring Security的
官方文檔上有這麼一句話:sql
Spring Security’s web infrastructure is based entirely on standard servlet filters.數據庫
Spring Security 的web基礎是Filters。json
這句話展現了Spring Security
的設計思想:即經過一層層的Filters來對web請求作處理。
後端
放到真實的Spring Security
中,用文字表述的話能夠這樣說:
一個web請求會通過一條過濾器鏈,在通過過濾器鏈的過程當中會完成認證與受權,若是中間發現這條請求未認證或者未受權,會根據被保護API的權限去拋出異常,而後由異常處理器去處理這些異常。
用圖片表述的話能夠這樣畫,這是我在百度找到的一張圖片:
如上圖,一個請求想要訪問到API就會以從左到右的形式通過藍線框框裏面的過濾器,其中綠色部分是咱們本篇主要講的負責認證的過濾器,藍色部分負責異常處理,橙色部分則是負責受權。
圖中的這兩個綠色過濾器咱們今天不會去說,由於這是Spring Security對form表單認證和Basic認證內置的兩個Filter,而咱們的demo是JWT認證方式因此用不上。
若是你用過Spring Security
就應該知道配置中有兩個叫formLogin
和httpBasic
的配置項,在配置中打開了它倆就對應着打開了上面的過濾器。
formLogin
對應着你form表單認證方式,即UsernamePasswordAuthenticationFilter。
httpBasic
對應着Basic認證方式,即BasicAuthenticationFilter。
換言之,你配置了這兩種認證方式,過濾器鏈中才會加入它們,不然它們是不會被加到過濾器鏈中去的。
由於Spring Security
自帶的過濾器中是沒有針對JWT這種認證方式的,因此咱們的demo中會寫一個JWT的認證過濾器,而後放在綠色的位置進行認證工做。
知道了Spring Security的大體工做流程以後,咱們還須要知道一些很是重要的概念也能夠說是組件:
Authentication
對象會放在裏面。
Authentication
,返回一個認證完成後的
Authentication
對象。
上下文對象,認證後的數據就放在這裏面,接口定義以下:
public interface SecurityContext extends Serializable {
// 獲取Authentication對象 Authentication getAuthentication(); // 放入Authentication對象 void setAuthentication(Authentication authentication); } 複製代碼
這個接口裏面只有兩個方法,其主要做用就是get or set Authentication
。
public class SecurityContextHolder {
public static void clearContext() { strategy.clearContext(); } public static SecurityContext getContext() { return strategy.getContext(); } public static void setContext(SecurityContext context) { strategy.setContext(context); } } 複製代碼
能夠說是SecurityContext
的工具類,用於get or set or clear SecurityContext
,默認會把數據都存儲到當前線程中。
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); Object getCredentials(); Object getDetails(); Object getPrincipal(); boolean isAuthenticated(); void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; } 複製代碼
這幾個方法效果以下:
getAuthorities
: 獲取用戶權限,通常狀況下獲取到的是
用戶的角色信息。
getCredentials
: 獲取證實用戶認證的信息,一般狀況下獲取到的是密碼等信息。
getDetails
: 獲取用戶的額外信息,(這部分信息能夠是咱們的用戶表中的信息)。
getPrincipal
: 獲取用戶身份信息,在未認證的狀況下獲取到的是用戶名,
在已認證的狀況下獲取到的是 UserDetails。
isAuthenticated
: 獲取當前
Authentication
是否已認證。
setAuthenticated
: 設置當前
Authentication
是否已認證(true or false)。
Authentication
只是定義了一種在SpringSecurity進行認證過的數據的數據形式應該是怎麼樣的,要有權限,要有密碼,要有身份信息,要有額外信息。
public interface AuthenticationManager {
// 認證方法 Authentication authenticate(Authentication authentication) throws AuthenticationException; } 複製代碼
AuthenticationManager
定義了一個認證方法,它將一個未認證的Authentication
傳入,返回一個已認證的Authentication
,默認使用的實現類爲:ProviderManager。
接下來你們能夠構思一下如何將這四個部分,串聯起來,構成Spring Security進行認證的流程:
1. 👉先是一個請求帶着身份信息進來
2. 👉通過AuthenticationManager
的認證,
3. 👉再經過SecurityContextHolder
獲取SecurityContext
,
4. 👉最後將認證後的信息放入到SecurityContext
。
真正開始講訴咱們的認證代碼以前,咱們首先須要導入必要的依賴,數據庫相關的依賴能夠自行選擇什麼JDBC框架,我這裏用的是國人二次開發的myabtis-plus。
<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>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency> 複製代碼
接着,咱們須要定義幾個必須的組件。
因爲我用的Spring-Boot是2.X因此必需要咱們本身定義一個加密器:
@Bean
public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } 複製代碼
這個Bean是沒必要可少的,Spring Security
在認證操做時會使用咱們定義的這個加密器,若是沒有則會出現異常。
@Bean
public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } 複製代碼
這裏將Spring Security
自帶的authenticationManager
聲明成Bean,聲明它的做用是用它幫咱們進行認證操做,調用這個Bean的authenticate
方法會由Spring Security
自動幫咱們作認證。
public class CustomUserDetailsService implements UserDetailsService {
@Autowired private UserService userService; @Autowired private RoleInfoService roleInfoService; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { log.debug("開始登錄驗證,用戶名爲: {}",s); // 根據用戶名驗證用戶 QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>(); queryWrapper.lambda().eq(UserInfo::getLoginAccount,s); UserInfo userInfo = userService.getOne(queryWrapper); if (userInfo == null) { throw new UsernameNotFoundException("用戶名不存在,登錄失敗。"); } // 構建UserDetail對象 UserDetail userDetail = new UserDetail(); userDetail.setUserInfo(userInfo); List<RoleInfo> roleInfoList = roleInfoService.listRoleByUserId(userInfo.getUserId()); userDetail.setRoleInfoList(roleInfoList); return userDetail; } } 複製代碼
實現UserDetailsService
的抽象方法並返回一個UserDetails對象,認證過程當中SpringSecurity會調用這個方法訪問數據庫進行對用戶的搜索,邏輯什麼均可以自定義,不管是從數據庫中仍是從緩存中,可是咱們須要將咱們查詢出來的用戶信息和權限信息組裝成一個UserDetails返回。
UserDetails 也是一個定義了數據形式的接口,用於保存咱們從數據庫中查出來的數據,其功能主要是驗證帳號狀態和獲取權限,具體實現能夠查閱我倉庫的代碼。
因爲咱們是JWT的認證模式,因此咱們也須要一個幫咱們操做Token的工具類,通常來講它具備如下三個方法就夠了:
在下文個人代碼裏面,JwtProvider充當了Token工具類的角色,具體實現能夠查閱我倉庫的代碼。
有了前面的講解以後,你們應該都知道用SpringSecurity
作JWT認證須要咱們本身寫一個過濾器來作JWT的校驗,而後將這個過濾器放到綠色部分。
在咱們編寫這個過濾器以前,咱們還須要進行一個認證操做,由於咱們要先訪問認證接口拿到token,才能把token放到請求頭上,進行接下來請求。
若是你不太明白,沒關係,先接着往下看我會在這節結束再次梳理一下。
訪問一個系統,通常最早訪問的是認證方法,這裏我寫了最簡略的認證須要的幾個步驟,由於實際系統中咱們還要寫登陸記錄啊,前臺密碼解密啊這些操做。
@Override
public ApiResult login(String loginAccount, String password) { // 1 建立UsernamePasswordAuthenticationToken UsernamePasswordAuthenticationToken usernameAuthentication = new UsernamePasswordAuthenticationToken(loginAccount, password); // 2 認證 Authentication authentication = this.authenticationManager.authenticate(usernameAuthentication); // 3 保存認證信息 SecurityContextHolder.getContext().setAuthentication(authentication); // 4 生成自定義token UserDetail userDetail = (UserDetail) authentication.getPrincipal(); AccessToken accessToken = jwtProvider.createToken((UserDetails) authentication.getPrincipal()); // 5 放入緩存 caffeineCache.put(CacheName.USER, userDetail.getUsername(), userDetail); return ApiResult.ok(accessToken); } 複製代碼
這裏一共五個步驟,大概只有前四步是比較陌生的:
UsernamePasswordAuthenticationToken
對象,這是咱們前面說過的
Authentication
的實現類,傳入用戶名和密碼作構造參數,這個對象就是咱們建立出來的未認證的
Authentication
對象。
authenticationManager
調用它的
authenticate
方法進行認證,返回一個認證完成的
Authentication
對象。
SecurityContextHolder
獲取
SecurityContext
以後,將認證完成以後的
Authentication
對象,放入上下文對象。
Authentication
對象中拿到咱們的
UserDetails
對象,以前咱們說過,認證後的
Authentication
對象調用它的
getPrincipal()
方法就能夠拿到咱們先前數據庫查詢後組裝出來的
UserDetails
對象,而後建立token。
UserDetails
對象放入緩存中,方便後面過濾器使用。
這樣的話就算完成了,感受上很簡單,由於主要認證操做都會由authenticationManager.authenticate()
幫咱們完成。
接下來咱們能夠看看源碼,從中窺得Spring Security是如何幫咱們作這個認證的(省略了一部分):
// AbstractUserDetailsAuthenticationProvider
public Authentication authenticate(Authentication authentication){ // 校驗未認證的Authentication對象裏面有沒有用戶名 String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true; // 從緩存中去查用戶名爲XXX的對象 UserDetails user = this.userCache.getUserFromCache(username); // 若是沒有就進入到這個方法 if (user == null) { cacheWasUsed = false; try { // 調用咱們重寫UserDetailsService的loadUserByUsername方法 // 拿到咱們本身組裝好的UserDetails對象 user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found"); if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { // 校驗帳號是否禁用 preAuthenticationChecks.check(user); // 校驗數據庫查出來的密碼,和咱們傳入的密碼是否一致 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } } 複製代碼
看了源碼以後你會發現和咱們日常寫的同樣,其主要邏輯也是查數據庫而後對比密碼。
登陸以後效果以下:
咱們返回token以後,下次請求其餘API的時候就要在請求頭中帶上這個token,都按照JWT的標準來作就能夠。
有了token以後,咱們要把過濾器放在過濾器鏈中,用於解析token,由於咱們沒有session,因此咱們每次去辨別這是哪一個用戶的請求的時候,都是根據請求中的token來解析出來當前是哪一個用戶。
因此咱們須要一個過濾器去攔截全部請求,前文咱們也說過,這個過濾器咱們會放在綠色部分用來替代UsernamePasswordAuthenticationFilter
,因此咱們新建一個JwtAuthenticationTokenFilter
,而後將它註冊爲Bean,並在編寫配置文件的時候須要加上這個:
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() { return new JwtAuthenticationTokenFilter(); } @Override protected void configure(HttpSecurity http) throws Exception { http.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class); } 複製代碼
addFilterBefore
的語義是添加一個Filter到XXXFilter以前,放在這裏就是把JwtAuthenticationTokenFilter
放在UsernamePasswordAuthenticationFilter
以前,由於filter的執行也是有順序的,咱們必需要把咱們的filter放在過濾器鏈中綠色的部分纔會起到自動認證的效果。
接下來咱們能夠看看JwtAuthenticationTokenFilter
的具體實現了:
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException { log.info("JWT過濾器經過校驗請求頭token進行自動登陸..."); // 拿到Authorization請求頭內的信息 String authToken = jwtProvider.getToken(request); // 判斷一下內容是否爲空且是否爲(Bearer )開頭 if (StrUtil.isNotEmpty(authToken) && authToken.startsWith(jwtProperties.getTokenPrefix())) { // 去掉token前綴(Bearer ),拿到真實token authToken = authToken.substring(jwtProperties.getTokenPrefix().length()); // 拿到token裏面的登陸帳號 String loginAccount = jwtProvider.getSubjectFromToken(authToken); if (StrUtil.isNotEmpty(loginAccount) && SecurityContextHolder.getContext().getAuthentication() == null) { // 緩存裏查詢用戶,不存在須要從新登錄。 UserDetail userDetails = caffeineCache.get(CacheName.USER, loginAccount, UserDetail.class); // 拿到用戶信息後驗證用戶信息與token if (userDetails != null && jwtProvider.validateToken(authToken, userDetails)) { // 組裝authentication對象,構造參數是Principal Credentials 與 Authorities // 後面的攔截器裏面會用到 grantedAuthorities 方法 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); // 將authentication信息放入到上下文對象中 SecurityContextHolder.getContext().setAuthentication(authentication); log.info("JWT過濾器經過校驗請求頭token自動登陸成功, user : {}", userDetails.getUsername()); } } } chain.doFilter(request, response); } 複製代碼
代碼裏步驟雖說的很詳細了,可是可能由於代碼過長不利於閱讀,我仍是簡單說說,也能夠直接去倉庫查看源碼:
Authorization
請求頭對應的token信息
UserDetail
信息便可
UserDetail
用戶名與token中的是否一直。
authentication
對象,把它放在上下文對象中,這樣後面的過濾器看到咱們上下文對象中有
authentication
對象,就至關於咱們已經認證過了。
這樣的話,每個帶有正確token的請求進來以後,都會找到它的帳號信息,並放在上下文對象中,咱們可使用SecurityContextHolder
很方便的拿到上下文對象中的Authentication
對象。
完成以後,啓動咱們的demo,能夠看到過濾器鏈中有如下過濾器,其中咱們自定義的是第5個:
🐱🏍就醬,咱們登陸完了以後獲取到的帳號信息與角色信息咱們都會放到緩存中,當帶着token的請求來到時,咱們就把它從緩存中拿出來,再次放到上下文對象中去。
結合認證方法,咱們的邏輯鏈就變成了:
登陸👉拿到token👉請求帶上token👉JWT過濾器攔截👉校驗token👉將從緩存中查出來的對象放到上下文中
這樣以後,咱們認證的邏輯就算完成了。
認證和JWT過濾器完成後,這個JWT的項目其實就能夠跑起來了,能夠實現咱們想要的效果,若是想讓程序更健壯,咱們還須要再加一些輔助功能,讓代碼更友好。
當用戶未登陸或者token解析失敗時會觸發這個處理器,返回一個非法訪問的結果。
當用戶自己權限不知足所訪問API須要的權限時,觸發這個處理器,返回一個權限不足的結果。
用戶退出通常就是清除掉上下文對象和緩存就好了,你也能夠作一下附加操做,這兩步是必須的。
JWT的項目token刷新也是必不可少的,這裏刷新token的主要方法放在了token工具類裏面,刷新完了把緩存重載一遍就好了,由於緩存是有有效期的,從新put能夠重置失效時間。
Spring Security
的上手的確有點難度,在我第一次去了解它的時候看的是尚硅谷的教程,那個視頻的講師拿它和Thymeleaf結合,這就致使網上也有不少博客去講Spring Security
的時候也是這種方式,而沒有去關注先後端分離。
也有教程作過濾器的時候是直接繼承UsernamePasswordAuthenticationFilter
,這樣的方法也是可行的,不過咱們瞭解了總體的運行流程以後你就知道不必這樣作,不須要去繼承XXX,只要寫個過濾器而後放在那個位置就能夠了。