【項目實踐】一文帶你搞定Spring Security + JWT


首圖.png

以項目驅動學習,以實踐檢驗真知

前言

關於認證和授權,R之前已經寫了兩篇文章:

📖【項目實踐】在用安全框架前,我想先讓你手擼一個登陸認證

📖【項目實踐】一文帶你搞定頁面權限、按鈕權限以及數據權限

在這兩篇文章中我們沒有使用安全框架就搞定了認證和授權功能,並理解了其核心原理。R在之前就說過,核心原理掌握了,無論什么安全框架使用起來都會非常容易!那么本文就講解如何使用主流的安全框架Spring Security來實現認證和授權功能。

當然,本文並不只是對框架的使用方法進行講解,還會剖析Spring Security的源碼,看到最后你就會發現你掌握了使用方法的同時,還對框架有了深度的理解!如果沒有看過前兩篇文章的,強烈建議先看一下,因為安全框架只是幫我們封裝了一些東西,背后的原理是不會變的。

本文所有代碼都放在了Github上,克隆下來即可運行!

提綱挈領

Web系統中登錄認證(Authentication)的核心就是憑證機制,無論是Session還是JWT,都是在用戶成功登錄時返回給用戶一個憑證,后續用戶訪問接口需攜帶憑證來標明自己的身份。后端會對需要進行認證的接口進行安全判斷,若憑證沒問題則代表已登錄就放行接口,若憑證有問題則直接拒絕請求。這個安全判斷都是放在過濾器里統一處理的

認證過濾器.png

登錄認證是對用戶的身份進行確認,權限授權(Authorization)是對用戶能否訪問某個資源進行確認,授權發生都認證之后。 認證一樣,這種通用邏輯都是放在過濾器里進行的統一操作:

授權過濾器.png

LoginFilter先進行登錄認證判斷,認證通過后再由AuthFilter進行權限授權判斷,一層一層沒問題后才會執行我們真正的業務邏輯。

Spring Security對Web系統的支持就是基於這一個個過濾器組成的過濾器鏈

過濾器鏈.png

用戶請求都會經過Servlet的過濾器鏈,在之前兩篇文章中我們就是通過自定義的兩個過濾器實現了認證授權功能!而Spring Security也是做的同樣的事完成了一系列功能:

自定義過濾器鏈.png

Servlet過濾器鏈中,Spring Security向其添加了一個FilterChainProxy過濾器,這個代理過濾器會創建一套Spring Security自定義的過濾器鏈,然后執行一系列過濾器。我們可以大概看一下FilterChainProxy的大致源碼:

@Override
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) throws IOException, ServletException {
    ...省略其他代碼
    
    // 獲取Spring Security的一套過濾器
    List<Filter> filters = getFilters(request);
    // 將這一套過濾器組成Spring Security自己的過濾鏈,並開始執行
    VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
    vfc.doFilter(request, response);
    
    ...省略其他代碼
}

我們可以看一下Spring Security默認會啟用多少過濾器:

seucirty默認過濾器鏈.png

這里面我們只需要重點關注兩個過濾器即可:UsernamePasswordAuthenticationFilter負責登錄認證,FilterSecurityInterceptor負責權限授權。

💡Spring Security的核心邏輯全在這一套過濾器中,過濾器里會調用各種組件完成功能,掌握了這些過濾器和組件你就掌握了Spring Security!這個框架的使用方式就是對這些過濾器和組件進行擴展。

一定要記住這句話,帶着這句話去使用和理解Spring Security,你會像站在高處俯瞰,整個框架的脈絡一目了然。

剛才我們總覽了一下全局,現在我們就開始進行代碼編寫了。

要使用Spring Security肯定是要先引入依賴包(Web項目其他必備依賴我在之前文章中已講解,這里就不過多闡述了):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

依賴包導入后,Spring Security就默認提供了許多功能將整個應用給保護了起來:

📝要求經過身份驗證的用戶才能與應用程序進行交互

📝創建好了默認登錄表單

📝生成用戶名為user的隨機密碼並打印在控制台上

📝CSRF攻擊防護、Session Fixation攻擊防護

📝等等等等......

在實際開發中,這些默認配置好的功能往往不符合我們的實際需求,所以我們一般會自定義一些配置。配置方式很簡單,新建一個配置類即可:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

在該類中重寫WebSecurityConfigurerAdapter的方法就能對Spring Security進行自定義配置。

登錄認證

依賴包和配置類准備好后,接下來我們要完成的第一個功能那自然是登錄認證,畢竟用戶要使用我們系統第一步就是登錄。之前文章介紹了SessionJWT兩種認證方式,這里我們來用Spring Security實現這兩種認證。

最簡單的認證方式

不管哪種認證方式和框架,有些核心概念是不會變的,這些核心概念在安全框架中會以各種組件來體現,了解各個組件的同時功能也就跟着實現了功能。

我們系統中會有許多用戶,確認當前是哪個用戶正在使用我們系統就是登錄認證的最終目的。這里我們就提取出了一個核心概念:當前登錄用戶/當前認證用戶。整個系統安全都是圍繞當前登錄用戶展開的!這個不難理解,要是當前登錄用戶都不能確認了,那A下了一個訂單,下到了B的賬戶上這不就亂套了。這一概念在Spring Security中的體現就是 💡Authentication,它存儲了認證信息,代表當前登錄用戶。

我們在程序中如何獲取並使用它呢?我們需要通過 💡SecurityContext 來獲取Authentication,看了之前文章的朋友大概就猜到了這個SecurityContext就是我們的上下文對象!

這種在一個線程中橫跨若干方法調用,需要傳遞的對象,我們通常稱之為上下文(Context)。上下文對象是非常有必要的,否則你每個方法都得額外增加一個參數接收對象,實在太麻煩了。

這個上下文對象則是交由 💡SecurityContextHolder 進行管理,你可以在程序任何地方使用它:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

可以看到調用鏈路是這樣的:SecurityContextHolder👉SecurityContext👉Authentication

SecurityContextHolder原理非常簡單,就是和我們之前實現的上下文對象一樣,使用ThreadLocal來保證一個線程中傳遞同一個對象!源碼我就不貼了,具體可看之前文章寫的上下文對象實現。

現在我們已經知道了Spring Security中三個核心組件:

📝Authentication:存儲了認證信息,代表當前登錄用戶

📝SeucirtyContext:上下文對象,用來獲取Authentication

📝SecurityContextHolder:上下文管理對象,用來在程序任何地方獲取SecurityContext

他們關系如下:

securitycontextholder.png

Authentication中那三個玩意就是認證信息:

📝Principal:用戶信息,沒有認證時一般是用戶名,認證后一般是用戶對象

📝Credentials:用戶憑證,一般是密碼

📝Authorities:用戶權限

現在我們知道如何獲取並使用當前登錄用戶了,那這個用戶是怎么進行認證的呢?總不能我隨便new一個就代表用戶認證完畢了吧。所以我們還缺一個生成Authentication對象的認證過程!

認證過程就是登錄過程,不使用安全框架時咱們的認證過程是這樣的:

查詢用戶數據👉判斷賬號密碼是否正確👉正確則將用戶信息存儲到上下文中👉上下文中有了這個對象則代表該用戶登錄了

Spring Security的認證流程也是如此:

Authentication authentication = new UsernamePasswordAuthenticationToken(用戶名, 用戶密碼, 用戶的權限集合);
SecurityContextHolder.getContext().setAuthentication(authentication);

和不使用安全框架一樣,將認證信息放到上下文中就代表用戶已登錄。上面代碼演示的就是Spring Security最簡單的認證方式,直接將Authentication放置到SecurityContext中就完成認證了!

這個流程和之前獲取當前登錄用戶的流程自然是相反的:Authentication👉SecurityContext👉SecurityContextHolder

是不是覺得,就這?這就完成認證啦?這也太簡單了吧。對於Spring Security來說,這樣確實就完成了認證,但對於我們來說還少了一步,那就是判斷用戶的賬號密碼是否正確。用戶進行登錄操作時從會傳遞過來賬號密碼,我們肯定是要查詢用戶數據然后判斷傳遞過來的賬號密碼是否正確,只有正確了咱們才會將認證信息放到上下文對象中,不正確就直接提示錯誤:

// 調用service層執行判斷業務邏輯
if (!userService.login(用戶名, 用戶密碼)) {
    return "賬號密碼錯誤";
}
// 賬號密碼正確了才將認證信息放到上下文中(用戶權限需要再從數據庫中獲取,后面再說,這里省略)
Authentication authentication = new UsernamePasswordAuthenticationToken(用戶名, 用戶密碼, 用戶的權限集合);
SecurityContextHolder.getContext().setAuthentication(authentication);

這樣才算是一個完整的認證過程,和不使用安全框架時的流程是一樣的哦,只是一些組件之前是我們自己實現的。

這里查詢用戶信息並校驗賬號密碼是完全由我們自己在業務層編寫所有邏輯,其實這一塊Spring Security也有組件供我們使用:

AuthenticationManager認證方式

💡AuthenticationManager 就是Spring Security用於執行身份驗證的組件,只需要調用它的authenticate方法即可完成認證。Spring Security默認的認證方式就是在UsernamePasswordAuthenticationFilter這個過濾器中調用這個組件,該過濾器負責認證邏輯。

我們要按照自己的方式使用這個組件,先在之前配置類配置一下:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
}

這里我們寫上完整的登錄接口代碼:

@RestController
@RequestMapping("/API")
public class LoginController {
    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public String login(@RequestBody LoginParam param) {
        // 生成一個包含賬號密碼的認證信息
        Authentication token = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword());
        // AuthenticationManager校驗這個認證信息,返回一個已認證的Authentication
        Authentication authentication = authenticationManager.authenticate(token);
        // 將返回的Authentication存到上下文中
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return "登錄成功";
    }
}

注意,這里流程和之前說的流程是完全一樣的,只是用戶身份驗證改成了使用AuthenticationManager來進行。

AuthenticationManager的校驗邏輯非常簡單:

根據用戶名先查詢出用戶對象(沒有查到則拋出異常)👉將用戶對象的密碼和傳遞過來的密碼進行校驗,密碼不匹配則拋出異常

這個邏輯沒啥好說的,再簡單不過了。重點是這里每一個步驟Spring Security都提供了組件:

📝是誰執行 根據用戶名查詢出用戶對象 邏輯的呢?用戶對象數據可以存在內存中、文件中、數據庫中,你得確定好怎么查才行。這一部分就是交由💡UserDetialsService 處理,該接口只有一個方法loadUserByUsername(String username),通過用戶名查詢用戶對象,默認實現是在內存中查詢。

📝那查詢出來的 用戶對象 又是什么呢?每個系統中的用戶對象數據都不盡相同,咱們需要確認我們的用戶數據是啥樣的才行。Spring Security中的用戶數據則是由💡UserDetails來體現,該接口中提供了賬號、密碼等通用屬性。

📝對密碼進行校驗大家可能會覺得比較簡單,if、else搞定,就沒必要用什么組件了吧?但框架畢竟是框架考慮的比較周全,除了if、else外還解決了密碼加密的問題,這個組件就是💡PasswordEncoder,負責密碼加密與校驗。

我們可以看下AuthenticationManager校驗邏輯的大概源碼:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    ...省略其他代碼
    
    // 傳遞過來的用戶名
    String username = authentication.getName();
    // 調用UserDetailService的方法,通過用戶名查詢出用戶對象UserDetail(查詢不出來UserDetailService則會拋出異常)
    UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
    String presentedPassword = authentication.getCredentials().toString();
	
    // 傳遞過來的密碼
    String password = authentication.getCredentials().toString();
    // 使用密碼解析器PasswordEncoder傳遞過來的密碼是否和真實的用戶密碼匹配
    if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        // 密碼錯誤則拋出異常
        throw new BadCredentialsException("錯誤信息...");
    }
    
    // 注意哦,這里返回的已認證Authentication,是將整個UserDetails放進去充當Principal
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
				authentication.getCredentials(), userDetails.getAuthorities());
	return result;
    
    ...省略其他代碼
}

UserDetialsService👉UserDetails👉PasswordEncoder,這三個組件Spring Security都有默認實現,這一般是滿足不了我們的實際需求的,所以這里我們自己來實現這些組件!

加密器PasswordEncoder

首先是PasswordEncoder,這個接口很簡單就兩個重要方法:

public interface PasswordEncoder {
    /**
 	 * 加密
 	 */
    String encode(CharSequence rawPassword);
    /**
 	 * 將未加密的字符串(前端傳遞過來的密碼)和已加密的字符串(數據庫中存儲的密碼)進行校驗
 	 */
    boolean matches(CharSequence rawPassword, String encodedPassword);
}

你可以實現此接口定義自己的加密規則和校驗規則,不過Spring Security提供了很多加密器實現,我們這里選定一個就好。可以在之前所說的配置類里進行如下配置:

@Bean
public PasswordEncoder passwordEncoder() {
    // 這里我們使用bcrypt加密算法,安全性比較高
    return new BCryptPasswordEncoder();
}

因為密碼加密是我前面文章少數沒有介紹的功能,所以這里額外提一嘴。往數據庫中添加用戶數據時就要將密碼進行加密,否則后續進行密碼校驗時從數據庫拿出來的還是明文密碼,是無法通過校驗的。比如我們有一個用戶注冊的接口:

@Autowired
private PasswordEncoder passwordEncoder;

@PostMapping("/register")
public String register(@RequestBody UserParam param) {
    UserEntity user = new UserEntity();
    // 調用加密器將前端傳遞過來的密碼進行加密
    user.setUsername(param.getUsername()).setPassword(passwordEncoder.encode(param.getPassword()));
    // 將用戶實體對象添加到數據庫
    userService.save(user);
    return "注冊成功";
}

這樣數據庫中存儲的密碼都是已加密的了:

密碼加密.png

用戶對象UserDetails

該接口就是我們所說的用戶對象,它提供了用戶的一些通用屬性:

public interface UserDetails extends Serializable {
   /**
    * 用戶權限集合(這個權限對象現在不管它,到權限時我會講解)
    */
   Collection<? extends GrantedAuthority> getAuthorities();
   /**
    * 用戶密碼
    */
   String getPassword();
   /**
    * 用戶名
    */
   String getUsername();
   /**
    * 用戶沒過期返回true,反之則false
    */
   boolean isAccountNonExpired();
   /**
    * 用戶沒鎖定返回true,反之則false
    */
   boolean isAccountNonLocked();
   /**
    * 用戶憑據(通常為密碼)沒過期返回true,反之則false
    */
   boolean isCredentialsNonExpired();
   /**
    * 用戶是啟用狀態返回true,反之則false
    */
   boolean isEnabled();
}

實際開發中我們的用戶屬性各種各樣,這些默認屬性必然是滿足不了,所以我們一般會自己實現該接口,然后設置好我們實際的用戶實體對象。實現此接口要重寫很多方法比較麻煩,我們可以繼承Spring Security提供的org.springframework.security.core.userdetails.User類,該類實現了UserDetails接口幫我們省去了重寫方法的工作:

public class UserDetail extends User {
    /**
     * 我們自己的用戶實體對象,要調取用戶信息時直接獲取這個實體對象。(這里我就不寫get/set方法了)
     */
    private UserEntity userEntity;

    public UserDetail(UserEntity userEntity, Collection<? extends GrantedAuthority> authorities) {
        // 必須調用父類的構造方法,以初始化用戶名、密碼、權限
        super(userEntity.getUsername(), userEntity.getPassword(), authorities);
        this.userEntity = userEntity;
    }
}

業務對象UserDetailsService

該接口很簡單只有一個方法:

public interface UserDetailsService {
	/**
	 * 根據用戶名獲取用戶對象(獲取不到直接拋異常)
	 */
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

咱們自己的用戶業務類該接口即可完成自己的邏輯:

@Service
public class UserServiceImpl implements UserService, UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        // 從數據庫中查詢出用戶實體對象
        UserEntity user = userMapper.selectByUsername(username);
        // 若沒查詢到一定要拋出該異常,這樣才能被Spring Security的錯誤處理器處理
        if (user == null) {
            throw new UsernameNotFoundException("沒有找到該用戶");
        }
        // 走到這代表查詢到了實體對象,那就返回我們自定義的UserDetail對象(這里權限暫時放個空集合,后面我會講解)
        return new UserDetail(user, Collections.emptyList());
    }
}

AuthenticationManager校驗所調用的三個組件我們就已經做好實現了!

不知道大家注意到沒有,當我們查詢用戶失敗時或者校驗密碼失敗時都會拋出Spring Security的自定義異常。這些異常不可能放任不管,Spring Security對於這些異常都是在ExceptionTranslationFilter過濾器中進行處理(可以回顧一下前面的過濾器截圖),而💡AuthenticationEntryPoint則專門處理認證異常!

認證異常處理器AuthenticationEntryPoint

該接口也只有一個方法:

public interface AuthenticationEntryPoint {
	/**
	 * 接收異常並處理
	 */
	void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException);
}

我們來自定義一個類實現我們自己的錯誤處理邏輯:

public class MyEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        // 直接提示前端認證錯誤
        out.write("認證錯誤");
        out.flush();
        out.close();
    }
}

用戶傳遞過來賬號密碼👉認證校驗👉異常處理,這一整套流程的組件我們就都給定義完了!現在只差最后一步,就是在Spring Security配置類里面進行一些配置,才能讓這些生效。

配置

Spring Security對哪些接口進行保護、什么組件生效、某些功能是否啟用等等都需要在配置類中進行配置,注意看代碼注釋:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 關閉csrf和frameOptions,如果不關閉會影響前端請求接口(這里不展開細講了,感興趣的自行了解)
        http.csrf().disable();
        http.headers().frameOptions().disable();
        // 開啟跨域以便前端調用接口
        http.cors();

        // 這是配置的關鍵,決定哪些接口開啟防護,哪些接口繞過防護
        http.authorizeRequests()
            	// 注意這里,是允許前端跨域聯調的一個必要配置
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                // 指定某些接口不需要通過驗證即可訪問。登陸、注冊接口肯定是不需要認證的
                .antMatchers("/API/login", "/API/register").permitAll()
                // 這里意思是其它所有接口需要認證才能訪問
                .anyRequest().authenticated()
                // 指定認證錯誤處理器
                .and().exceptionHandling().authenticationEntryPoint(new MyEntryPoint());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

其中用的最多的就是configure(HttpSecurity http)方法,可以通過HttpSecurity 進行許多配置。當我們重寫這個方法時,就已經關閉了默認的表單登錄方式,然后我們再配置好啟用哪些組件、指定哪些接口需要認證,就搞定了!

假設現在我們有一個/API/test接口,在沒有登錄的時候調用該接口看下效果:

認證錯誤.png

我們登錄一下:

登錄接口.png

然后再調用測試接口:

認證通過.png

可以看到未登錄時測試接口是無法正常訪問的,會按照我們在EntryPoint中的邏輯返回錯誤提示。

總結和補充

有人可能會問,用AuthenticationManager認證方式要配置好多東西啊,我就用之前說的那種最簡單的方式不行嗎?當然是可以的啦,用哪種方式都隨便,只要完成功能都行。其實不管哪種方式我們的認證的邏輯代碼一樣都沒少,只不過一個是我們自己業務類全部搞定,一個是可以集成框架的組件。這里也順帶再總結一下流程:

  1. 用戶調進行登錄操作,傳遞賬號密碼過來👉登錄接口調用AuthenticationManager
  2. 根據用戶名查詢出用戶數據👉UserDetailService查詢出UserDetails
  3. 將傳遞過來的密碼和數據庫中的密碼進行對比校驗👉PasswordEncoder
  4. 校驗通過則將認證信息存入到上下文中👉將UserDetails存入到Authentication,將Authentication存入到SecurityContext
  5. 如果認證失敗則拋出異常👉由AuthenticationEntryPoint處理

剛才我們講的認證方式都是基於session機制,認證后Spring Security會將SecurityContext存入到session中,Key為HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY。也就是說,你完全可以通過如下方式獲取SecurityContext

SecurityContext securityContext = (SecurityContext)session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)

當然,官方還是不推薦這樣直接操作的,因為統一通過SecurityContextHolder操作更利於管理!使用SecurityContextHolder除了獲取當前用戶外,退出登錄的操作也是很方便的:

@GetMapping("/logout")
public String logout() {
    SecurityContextHolder.clearContext();
    return "退出成功";
}

session認證咱們就講解到此,接下來咱們講解JWT的認證。

JWT集成

關於JWT的介紹和工具類等我在前面文章已經講的很清楚了,這里我就不額外說明了,直接帶大家實現代碼。

采用JWT的方式進行認證首先做的第一步就是在配置類里禁用掉session

// 禁用session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

注意,這里的禁用是指Spring Security不采用session機制了,不代表你禁用掉了整個系統的session功能。

然后我們再修改一下登錄接口,當用戶登錄成功的同時,我們需要生成token並返回給前端,這樣前端才能訪問其他接口時攜帶token

@Autowired
private UserService userService;

@PostMapping("/login")
public UserVO login(@RequestBody @Validated LoginParam user) {
    // 調用業務層執行登錄操作
    return userService.login(user);
}

業務層方法:

public UserVO login(LoginParam param) {
    // 根據用戶名查詢出用戶實體對象
    UserEntity user = userMapper.selectByUsername(param.getUsername());
    // 若沒有查到用戶 或者 密碼校驗失敗則拋出自定義異常
    if (user == null || !passwordEncoder.matches(param.getPassword(), user.getPassword())) {
        throw new ApiException("賬號密碼錯誤");
    }

    // 需要返回給前端的VO對象
    UserVO userVO = new UserVO();
    userVO.setId(user.getId())
        .setUsername(user.getUsername())
        // 生成JWT,將用戶名數據存入其中
        .setToken(jwtManager.generate(user.getUsername()));
    return userVO;
}

我們執行一下登錄操作:

JWT登錄.png

我們可以看到登錄成功時接口會返回token,后續我們再訪問其它接口時需要將token放到請求頭中。這里我們需要自定義一個認證過濾器,來對token進行校驗:

@Component
public class LoginFilter extends OncePerRequestFilter {
    @Autowired
    private JwtManager jwtManager;
    @Autowired
    private UserServiceImpl userService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 從請求頭中獲取token字符串並解析(JwtManager之前文章有詳解,這里不多說了)
        Claims claims = jwtManager.parse(request.getHeader("Authorization"));
        if (claims != null) {
            // 從`JWT`中提取出之前存儲好的用戶名
            String username = claims.getSubject();
            // 查詢出用戶對象
            UserDetails user = userService.loadUserByUsername(username);
            // 手動組裝一個認證對象
            Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
            // 將認證對象放到上下文中
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

過濾器中的邏輯和之前介紹的最簡單的認證方式邏輯是一致的,每當一個請求來時我們都會校驗JWT進行認證,上下文對象中有了Authentication后續過濾器就會知道該請求已經認證過了。

這個自定義的認證過濾器需要插入到默認的認證過濾器之前,這樣我們的過濾器才能生效,所以需要進行如下配置:

http.addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class);

我們可以斷點調試看一下現在的過濾器是怎樣的:

自定義過濾器.png

可以看到我們自定義的過濾器已經在過濾器鏈中,因為沒有啟用表單認證所以UsernamePasswordAuthenticationFilter被移除了。

攜帶token訪問接口時可以查看效果:

JWT認證生效.png

登錄認證到此就講解完畢了,接下來我們一鼓作氣來實現權限授權!

權限授權

菜單權限主要是通過前端渲染,數據權限主要靠SQL攔截,和Spring Security沒太大耦合,就不多展開了。我們來梳理一下接口權限的授權的流程:

  1. 當一個請求過來,我們先得知道這個請求的規則,即需要怎樣的權限才能訪問
  2. 然后獲取當前登錄用戶所擁有的權限
  3. 再校驗當前用戶是否擁有該請求的權限
  4. 用戶擁有這個權限則正常返回數據,沒有權限則拒絕請求

完成了登錄認證功能后,想必大家已經有點感覺:Spring Security將流程功能分得很細,每一個小功能都會有一個組件專門去做,我們要做的就是去自定義這些組件!Spring Security針對上述流程也提供了許多組件。

Spring Security的授權發生在FilterSecurityInterceptor過濾器中:

  1. 首先調用的是💡SecurityMetadataSource,來獲取當前請求的鑒權規則
  2. 然后通過Authentication獲取當前登錄用戶所有權限數據:💡GrantedAuthority,這個我們前面提過,認證對象里存放這權限數據
  3. 再調用💡AccessDecisionManager 來校驗當前用戶是否擁有該權限
  4. 如果有就放行接口,沒有則拋出異常,該異常會被💡AccessDeniedHandler 處理

我們可以來看一下過濾器里大概的源碼:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    ...省略其它代碼
        
    // 這是Spring Security封裝的對象,該對象里包含了request等信息
    FilterInvocation fi = new FilterInvocation(request, response, chain);
    // 這里調用了父類的AbstractSecurityInterceptor的方法,認證核心邏輯基本全在父類里
    InterceptorStatusToken token = super.beforeInvocation(fi);

    ...省略其它代碼
}

父類的beforeInvocation大概源碼如下:

protected InterceptorStatusToken beforeInvocation(Object object) {
    ...省略其它代碼
    
    // 調用SecurityMetadataSource來獲取當前請求的鑒權規則,這個ConfigAttribue就是規則,后面我會講
    Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
    // 如果當前請求啥規則也沒有,就代表該請求無需授權即可訪問,直接結束方法
    if (CollectionUtils.isEmpty(attributes)) {
        return null;
    }
    
    // 獲取當前登錄用戶
    Authentication authenticated = authenticateIfRequired();
    // 調用AccessDecisionManager來校驗當前用戶是否擁有該權限,沒有權限則拋出異常
    this.accessDecisionManager.decide(authenticated, object, attributes);
    
    ...省略其它代碼
}

老生常談,核心流程都是一樣的。我們接下來自定義這些組件,以完成我們自己的鑒權邏輯。

鑒權規則源SecurityMetadataSource

該接口我們只需要關注一個方法:

public interface SecurityMetadataSource {
	/**
	 * 獲取當前請求的鑒權規則
	 
	 * @param object 該參數就是Spring Security封裝的FilterInvocation對象,包含了很多request信息
	 * @return 鑒權規則對象
	 */
	Collection<ConfigAttribute> getAttributes(Object object);

}

ConfigAttribute就是我們所說的鑒權規則,該接口只有一個方法:

public interface ConfigAttribute {
	/**
	 * 這個字符串就是規則,它可以是角色名、權限名、表達式等等。
	 * 你完全可以按照自己想法來定義,后面AccessDecisionManager會用這個字符串
	 */
	String getAttribute();
}

在之前文章中我們授權的實現全是靠着資源id,用戶id關聯角色id,角色id關聯資源id,這樣用戶就相當於關聯了資源,而我們接口資源在數據庫中的體現是這樣的:

資源表.png

這里還是一樣,我們照樣以資源id作為權限的標記。接下咱們就來自定義SecurityMetadataSource組件:

@Component
public class MySecurityMetadataSource implements SecurityMetadataSource {
    /**
     * 當前系統所有接口資源對象,放在這里相當於一個緩存的功能。
     * 你可以在應用啟動時將該緩存給初始化,也可以在使用過程中加載數據,這里我就不多展開說明了
     */
    private static final Set<Resource> RESOURCES = new HashSet<>();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) {
        // 該對象是Spring Security幫我們封裝好的,可以通過該對象獲取request等信息
        FilterInvocation filterInvocation = (FilterInvocation) object;
        HttpServletRequest request = filterInvocation.getRequest();
        // 遍歷所有權限資源,以和當前請求進行匹配
        for (Resource resource : RESOURCES) {
            // 因為我們url資源是這種格式:GET:/API/user/test/{id},冒號前面是請求方法,冒號后面是請求路徑,所以要字符串拆分
            String[] split = resource.getPath().split(":");
            // 因為/API/user/test/{id}這種路徑參數不能直接equals來判斷請求路徑是否匹配,所以需要用Ant類來匹配
            AntPathRequestMatcher ant = new AntPathRequestMatcher(split[1]);
            // 如果請求方法和請求路徑都匹配上了,則代表找到了這個請求所需的權限資源
            if (request.getMethod().equals(split[0]) && ant.matches(request)) {
                // 將我們權限資源id返回,這個SecurityConfig就是ConfigAttribute一個簡單實現
                return Collections.singletonList(new SecurityConfig(resource.getId().toString()));
            }
        }
        // 走到這里就代表該請求無需授權即可訪問,返回空
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        // 不用管,這么寫就行
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        // 不用管,這么寫就行
        return true;
    }
}

注意,我們這里返回的ConfigAttribute鑒權規則,就是我們的資源id

用戶權限GrantedAuthority

該組件代表用戶所擁有的權限,和ConfigAttribute一樣也只有一個方法,該方法返回的字符串就是代表着權限

public interface GrantedAuthority extends Serializable {
	String getAuthority();
}

GrantedAuthorityConfigAttribute一對比,就知道用戶是否擁有某個權限了。

Spring Security對GrantedAuthority有一個簡單實現SimpleGrantedAuthority,對咱們來說夠用了,所以我們額外再新建一個實現。我們要做的就是在UserDetialsService中,獲取用戶對象的同時也將權限數據查詢出來:

@Override
public UserDetails loadUserByUsername(String username) {
    UserEntity user = userMapper.selectByUsername(username);
    if (user == null) {
        throw new UsernameNotFoundException("沒有找到該用戶");
    }
    // 先將該用戶所擁有的資源id全部查詢出來,再轉換成`SimpleGrantedAuthority`權限對象
    Set<SimpleGrantedAuthority> authorities = resourceService.getIdsByUserId(user.getId())
        .stream()
        .map(String::valueOf)
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toSet());
    // 將用戶實體和權限集合都放到UserDetail中,
    return new UserDetail(user, authorities);
}

這樣當認證完畢時,Authentication就會擁有用戶信息和權限數據了。

授權管理AccessDecisionManager

終於要來到我們真正的授權組件了,這個組件才最終決定了你有沒有某個權限,該接口我們只需關注一個方法:

public interface AccessDecisionManager {

	/**
	 * 授權操作,如果沒有權限則拋出異常 
	 *
     * @param authentication 當前登錄用戶,以獲取當前用戶權限信息
	 * @param object FilterInvocation對象,以獲取request信息
	 * @param configAttributes 當前請求鑒權規則
	 */
	void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
			throws AccessDeniedException, InsufficientAuthenticationException;
}

該方法接受了這幾個參數后完全能做到權限校驗了,我們來實現自己的邏輯:

@Component
public class MyDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) {
        // 如果授權規則為空則代表此URL無需授權就能訪問
        if (Collections.isEmpty(configAttributes)) {
            return;
        }
        // 判斷授權規則和當前用戶所屬權限是否匹配
        for (ConfigAttribute ca : configAttributes) {
            for (GrantedAuthority authority : authentication.getAuthorities()) {
                // 如果匹配上了,代表當前登錄用戶是有該權限的,直接結束方法
                if (Objects.equals(authority.getAuthority(), ca.getAttribute())) {
                    return;
                }
            }
        }
        // 走到這里就代表沒有權限,必須要拋出異常,否則錯誤處理器捕捉不到
        throw new AccessDeniedException("沒有相關權限");
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        // 不用管,這么寫就行
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        // 不用管,這么寫就行
        return true;
    }
}

授權錯誤處理器AccessDeniedHandler

該組件和之前的認證異常處理器一樣,只有一個方法用來處理異常,只不過這個是用來處理授權異常的。我們直接來實現:

public class MyDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        out.write("沒有相關權限");
        out.flush();
        out.close();
    }
}

配置

組件都定義好了,那我們接下來就是最后一步咯,就是讓這些組件生效。我們的鑒權規則源組件SecurityMetadataSource和授權管理組件AccessDecisionManager必須通過授權過濾器FilterSecurityInterceptor來配置生效,所以我們得自己先寫一個過濾器,這個過濾器的核心代碼基本按照父類的寫就行,主要就是屬性的配置:

@Component
public class AuthFilter extends AbstractSecurityInterceptor implements Filter {
    @Autowired
    private SecurityMetadataSource securityMetadataSource;

    @Override
    public SecurityMetadataSource obtainSecurityMetadataSource() {
        // 將我們自定義的SecurityMetadataSource給返回
        return this.securityMetadataSource;
    }

    @Override
    @Autowired
    public void setAccessDecisionManager(AccessDecisionManager accessDecisionManager) {
        // 將我們自定義的AccessDecisionManager給注入
        super.setAccessDecisionManager(accessDecisionManager);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 下面的就是按照父類寫法寫的
        FilterInvocation fi = new FilterInvocation(request, response, chain);
        InterceptorStatusToken token = super.beforeInvocation(fi);
        try {
            // 執行下一個攔截器
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }  finally {
            // 請求之后的處理
            super.afterInvocation(token, null);
        }
    }

    @Override
    public Class<?> getSecureObjectClass() {
        return FilterInvocation.class;
    }

    @Override
    public void init(FilterConfig filterConfig) {}

    @Override
    public void destroy() {}
}

過濾器定義好了,我們回到Spring Security配置類讓這個過濾器插入到原有的鑒權過濾器之前就一切都搞定啦:

http.addFilterBefore(authFilter, FilterSecurityInterceptor.class);

我們可以來看下效果,沒有權限的情況下訪問接口:

授權失敗.png

有權限的情況下訪問接口:

授權通過.png

總結

整個Spring Security就講解完畢了,我們對兩個過濾器、N多個組件進行了自定義實現,從而達到了我們的功能。這里做了一個思維導圖方便大家理解:

思維導圖.png

別看組件這么多,認證授權的核心流程和一些概念是不會變的,什么安全框架都萬變不離其宗。比如Shiro,其中最基本的概念Subject就代表當前用戶,SubjectManager就是用戶管理器……

在我前兩篇文章中有人也談到用安全框架還不如自己手寫,確實,手寫可以最大靈活度按照自己的想法來(並且也不復雜),使用安全框架反而要配合框架的定式,好像被束縛了。那安全框架對比手寫有什么優勢呢?我覺得優勢主要有如下兩點:

  1. 一些功能開箱即用,比如Spring Security的加密器,非常方便
  2. 框架的定式既是束縛也是規范,無論誰接手你的項目,一看到熟悉的安全框架就能立馬上手

講解到這里就結束了,本文所有代碼、SQL語句都放在Github,克隆下來即可運行。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM