【專案實踐】一文帶你搞定Spring Security + JWT

RudeCrab發表於2021-01-12

首圖.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會將Authentication存入到session中,Key為HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY。也就是說,你完全可以通過如下方式獲取Authentication

Authentication = (Authentication)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後續過濾器就會知道該請求已經認證過了。

我們們這個自定義的過濾器需要替換掉Spring Security預設的認證過濾器,這樣我們的過濾器才能生效,所以我們需要進行如下配置:

// 將我們自定義的認證過濾器替換掉預設的認證過濾器
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,克隆下來即可執行。

相關文章