項目實踐】一文帶你搞定先後端分離下的認證和受權|Spring Security + JWT

前言

關於認證和受權,R以前已經寫了兩篇文章:前端

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

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

📖【項目實踐】2020最新Java基礎精講視頻教程和學習路線github

在這兩篇文章中咱們沒有使用安全框架就搞定了認證和受權功能,並理解了其核心原理。R在以前就說過,核心原理掌握了,不管什麼安全框架使用起來都會很是容易!那麼本文就講解如何使用主流的安全框架Spring Security來實現認證和受權功能。算法

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

本文全部代碼都放在了Github上,克隆下來便可運行!數據庫

提綱挈領

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

認證過濾器.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
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,克隆下來便可運行。

原文連接:https://juejin.cn/post/690072...

相關文章
相關標籤/搜索