spring security (史上最全)


文章很長,建議收藏起來,慢慢讀! 瘋狂創客圈為小伙伴奉上以下珍貴的學習資源:


推薦2:史上最全 Java 面試題 21 個專題

史上最全 Java 面試題 21 個專題 阿里、京東、美團、頭條.... 隨意挑、橫着走!!!
Java基礎
1: JVM面試題(史上最強、持續更新、吐血推薦) https://www.cnblogs.com/crazymakercircle/p/14365820.html
2:Java基礎面試題(史上最全、持續更新、吐血推薦) https://www.cnblogs.com/crazymakercircle/p/14366081.html
3:死鎖面試題(史上最強、持續更新) [https://www.cnblogs.com/crazymakercircle/p/14323919.html]
4:設計模式面試題 (史上最全、持續更新、吐血推薦) https://www.cnblogs.com/crazymakercircle/p/14367101.html
5:架構設計面試題 (史上最全、持續更新、吐血推薦) https://www.cnblogs.com/crazymakercircle/p/14367907.html
還有 10 +必刷、必刷 的面試題 更多 ....., 請參見【 瘋狂創客圈 高並發 總目錄

推薦3: 瘋狂創客圈 高並發 高質量博文

springCloud 高質量 博文
nacos 實戰(史上最全) sentinel (史上最全+入門教程)
springcloud + webflux 高並發實戰 Webflux(史上最全)
SpringCloud gateway (史上最全) spring security (史上最全)
還有 10 +必刷、必刷 的高質量 博文 更多 ....., 請參見【 瘋狂創客圈 高並發 總目錄

一、spring security 簡介

​ spring security 的核心功能主要包括:

  • 認證 (你是誰)
  • 授權 (你能干什么)
  • 攻擊防護 (防止偽造身份)

​ 其核心就是一組過濾器鏈,項目啟動后將會自動配置。最核心的就是 Basic Authentication Filter 用來認證用戶的身份,一個在spring security中一種過濾器處理一種認證方式。

img

比如,對於username password認證過濾器來說,

會檢查是否是一個登錄請求;

是否包含username 和 password (也就是該過濾器需要的一些認證信息) ;

如果不滿足則放行給下一個。

​ 下一個按照自身職責判定是否是自身需要的信息,basic的特征就是在請求頭中有 Authorization:Basic eHh4Onh4 的信息。中間可能還有更多的認證過濾器。最后一環是 FilterSecurityInterceptor,這里會判定該請求是否能進行訪問rest服務,判斷的依據是 BrowserSecurityConfig中的配置,如果被拒絕了就會拋出不同的異常(根據具體的原因)。Exception Translation Filter 會捕獲拋出的錯誤,然后根據不同的認證方式進行信息的返回提示。

注意:綠色的過濾器可以配置是否生效,其他的都不能控制。

二、入門項目

​ 首先創建spring boot項目HelloSecurity,其pom主要依賴如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

然后在src/main/resources/templates/目錄下創建頁面:

home.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example</title>
    </head>
    <body>
        <h1>Welcome!</h1>

        <p>Click <a th:href="@{/hello}">here</a> to see a greeting.</p>
    </body>
</html>

我們可以看到, 在這個簡單的視圖中包含了一個鏈接: “/hello”. 鏈接到了如下的頁面,Thymeleaf模板如下:

hello.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Hello World!</title>
    </head>
    <body>
        <h1>Hello world!</h1>
    </body>
</html>

Web應用程序基於Spring MVC。 因此,你需要配置Spring MVC並設置視圖控制器來暴露這些模板。 如下是一個典型的Spring MVC配置類。在src/main/java/hello目錄下(所以java都在這里):

@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home").setViewName("home");
        registry.addViewController("/").setViewName("home");
        registry.addViewController("/hello").setViewName("hello");
        registry.addViewController("/login").setViewName("login");
    }
}

​ addViewControllers()方法(覆蓋WebMvcConfigurerAdapter中同名的方法)添加了四個視圖控制器。 兩個視圖控制器引用名稱為“home”的視圖(在home.html中定義),另一個引用名為“hello”的視圖(在hello.html中定義)。 第四個視圖控制器引用另一個名為“login”的視圖。 將在下一部分中創建該視圖。此時,可以跳過來使應用程序可執行並運行應用程序,而無需登錄任何內容。然后啟動程序如下:

@SpringBootApplication
public class Application {

    public static void main(String[] args) throws Throwable {
        SpringApplication.run(Application.class, args);
    }
}

2、加入Spring Security

​ 假設你希望防止未經授權的用戶訪問“/ hello”。 此時,如果用戶點擊主頁上的鏈接,他們會看到問候語,請求被沒有被攔截。 你需要添加一個障礙,使得用戶在看到該頁面之前登錄。您可以通過在應用程序中配置Spring Security來實現。 如果Spring Security在類路徑上,則Spring Boot會使用“Basic認證”來自動保護所有HTTP端點。 同時,你可以進一步自定義安全設置。首先在pom文件中引入:

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

如下是安全配置,使得只有認證過的用戶才可以訪問到問候頁面:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/home").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .inMemoryAuthentication()
                .withUser("user").password("password").roles("USER");
    }
}

​ WebSecurityConfig類使用了@EnableWebSecurity注解 ,以啟用Spring Security的Web安全支持,並提供Spring MVC集成。它還擴展了WebSecurityConfigurerAdapter,並覆蓋了一些方法來設置Web安全配置的一些細節。

configure(HttpSecurity)方法定義了哪些URL路徑應該被保護,哪些不應該。具體來說,“/”和“/ home”路徑被配置為不需要任何身份驗證。所有其他路徑必須經過身份驗證。

​ 當用戶成功登錄時,它們將被重定向到先前請求的需要身份認證的頁面。有一個由 loginPage()指定的自定義“/登錄”頁面,每個人都可以查看它。

​ 對於configureGlobal(AuthenticationManagerBuilder) 方法,它將單個用戶設置在內存中。該用戶的用戶名為“user”,密碼為“password”,角色為“USER”。

​ 現在我們需要創建登錄頁面。前面我們已經配置了“login”的視圖控制器,因此現在只需要創建登錄頁面即可:

login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example </title>
    </head>
    <body>
        <div th:if="${param.error}">
            Invalid username and password.
        </div>
        <div th:if="${param.logout}">
            You have been logged out.
        </div>
        <form th:action="@{/login}" method="post">
            <div><label> User Name : <input type="text" name="username"/> </label></div>
            <div><label> Password: <input type="password" name="password"/> </label></div>
            <div><input type="submit" value="Sign In"/></div>
        </form>
    </body>
</html>

​ 你可以看到,這個Thymeleaf模板只是提供一個表單來獲取用戶名和密碼,並將它們提交到“/ login”。 根據配置,Spring Security提供了一個攔截該請求並驗證用戶的過濾器。 如果用戶未通過認證,該頁面將重定向到“/ login?error”,並在頁面顯示相應的錯誤消息。 注銷成功后,我們的應用程序將發送到“/ login?logout”,我們的頁面顯示相應的登出成功消息。最后,我們需要向用戶提供一個顯示當前用戶名和登出的方法。 更新hello.html 向當前用戶打印一句hello,並包含一個“注銷”表單,如下所示:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Hello World!</title>
    </head>
    <body>
        <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
        <form th:action="@{/logout}" method="post">
            <input type="submit" value="Sign Out"/>
        </form>
    </body>
</html>

三、參數詳解

1、注解 @EnableWebSecurity

​ 在 Spring boot 應用中使用 Spring Security,用到了 @EnableWebSecurity注解,官方說明為,該注解和 @Configuration 注解一起使用, 注解 WebSecurityConfigurer 類型的類,或者利用@EnableWebSecurity 注解繼承 WebSecurityConfigurerAdapter的類,這樣就構成了 Spring Security 的配置。

2、抽象類 WebSecurityConfigurerAdapter

​ 一般情況,會選擇繼承 WebSecurityConfigurerAdapter 類,其官方說明為:WebSecurityConfigurerAdapter 提供了一種便利的方式去創建 WebSecurityConfigurer的實例,只需要重寫 WebSecurityConfigurerAdapter 的方法,即可配置攔截什么URL、設置什么權限等安全控制。

3、方法 configure(AuthenticationManagerBuilder auth) 和 configure(HttpSecurity http)

​ Demo 中重寫了 WebSecurityConfigurerAdapter 的兩個方法:

   /**
     * 通過 {@link #authenticationManager()} 方法的默認實現嘗試獲取一個 {@link AuthenticationManager}.
     * 如果被復寫, 應該使用{@link AuthenticationManagerBuilder} 來指定 {@link AuthenticationManager}.
     *
     * 例如, 可以使用以下配置在內存中進行注冊公開內存的身份驗證{@link UserDetailsService}:
     *
     * // 在內存中添加 user 和 admin 用戶
     * @Override
     * protected void configure(AuthenticationManagerBuilder auth) {
     *     auth
     *       .inMemoryAuthentication().withUser("user").password("password").roles("USER").and()
     *         .withUser("admin").password("password").roles("USER", "ADMIN");
     * }
     *
     * // 將 UserDetailsService 顯示為 Bean
     * @Bean
     * @Override
     * public UserDetailsService userDetailsServiceBean() throws Exception {
     *     return super.userDetailsServiceBean();
     * }
     *
     */
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        this.disableLocalConfigureAuthenticationBldr = true;
    }


    /**
     * 復寫這個方法來配置 {@link HttpSecurity}. 
     * 通常,子類不能通過調用 super 來調用此方法,因為它可能會覆蓋其配置。 默認配置為:
     * 
     * http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
     *
     */
    protected void configure(HttpSecurity http) throws Exception {
        logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");

        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin().and()
            .httpBasic();
    }

4、final 類 HttpSecurity

HttpSecurity 常用方法及說明:

方法 說明
openidLogin() 用於基於 OpenId 的驗證
headers() 將安全標頭添加到響應
cors() 配置跨域資源共享( CORS )
sessionManagement() 允許配置會話管理
portMapper() 允許配置一個PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfigurer的對象使用 PortMapper 從 HTTP 重定向到 HTTPS 或者從 HTTPS 重定向到 HTTP。默認情況下,Spring Security使用一個PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口443
jee() 配置基於容器的預認證。 在這種情況下,認證由Servlet容器管理
x509() 配置基於x509的認證
rememberMe 允許配置“記住我”的驗證
authorizeRequests() 允許基於使用HttpServletRequest限制訪問
requestCache() 允許配置請求緩存
exceptionHandling() 允許配置錯誤處理
securityContext() HttpServletRequests之間的SecurityContextHolder上設置SecurityContext的管理。 當使用WebSecurityConfigurerAdapter時,這將自動應用
servletApi() HttpServletRequest方法與在其上找到的值集成到SecurityContext中。 當使用WebSecurityConfigurerAdapter時,這將自動應用
csrf() 添加 CSRF 支持,使用WebSecurityConfigurerAdapter時,默認啟用
logout() 添加退出登錄支持。當使用WebSecurityConfigurerAdapter時,這將自動應用。默認情況是,訪問URL”/ logout”,使HTTP Session無效來清除用戶,清除已配置的任何#rememberMe()身份驗證,清除SecurityContextHolder,然后重定向到”/login?success”
anonymous() 允許配置匿名用戶的表示方法。 當與WebSecurityConfigurerAdapter結合使用時,這將自動應用。 默認情況下,匿名用戶將使用org.springframework.security.authentication.AnonymousAuthenticationToken表示,並包含角色 “ROLE_ANONYMOUS”
formLogin() 指定支持基於表單的身份驗證。如果未指定FormLoginConfigurer#loginPage(String),則將生成默認登錄頁面
oauth2Login() 根據外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份驗證
requiresChannel() 配置通道安全。為了使該配置有用,必須提供至少一個到所需信道的映射
httpBasic() 配置 Http Basic 驗證
addFilterAt() 在指定的Filter類的位置添加過濾器

5、類 AuthenticationManagerBuilder

/**
* {@link SecurityBuilder} used to create an {@link AuthenticationManager}. Allows for
* easily building in memory authentication, LDAP authentication, JDBC based
* authentication, adding {@link UserDetailsService}, and adding
* {@link AuthenticationProvider}'s.
*/

​ 意思是,AuthenticationManagerBuilder 用於創建一個 AuthenticationManager,讓我能夠輕松的實現內存驗證、LADP驗證、基於JDBC的驗證、添加UserDetailsService、添加AuthenticationProvider。

使用yaml文件定義的用戶名、密碼登錄

在application.yaml中定義用戶名密碼:

spring:
  security:
    user:
      name: root
      password: root

使用root/root登錄,可以正常訪問/hello

使用代碼中指定的用戶名、密碼登錄

  • 使用configure(AuthenticationManagerBuilder) 添加認證。
  • 使用configure(httpSecurity) 添加權限
@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("admin") // 添加用戶admin
                .password("{noop}admin")  // 不設置密碼加密
                .roles("ADMIN", "USER")// 添加角色為admin,user
                .and()
                .withUser("user") // 添加用戶user
                .password("{noop}user") 
                .roles("USER")
            	.and()
            	.withUser("tmp") // 添加用戶tmp
                .password("{noop}tmp")
            	.roles(); // 沒有角色
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/product/**").hasRole("USER") //添加/product/** 下的所有請求只能由user角色才能訪問
                .antMatchers("/admin/**").hasRole("ADMIN") //添加/admin/** 下的所有請求只能由admin角色才能訪問
                .anyRequest().authenticated() // 沒有定義的請求,所有的角色都可以訪問(tmp也可以)。
                .and()
                .formLogin().and()
                .httpBasic();
    }
}

添加AdminController、ProductController

@RestController
@RequestMapping("/admin")
public class AdminController {
    @RequestMapping("/hello")
    public String hello(){
        return "admin hello";
    }
}
@RestController
@RequestMapping("/product")
public class ProductController {
    @RequestMapping("/hello")
    public String hello(){
        return "product hello";
    }
}

通過上面的設置,訪問http://localhost:8080/admin/hello只能由admin訪問,http://localhost:8080/product/hello admin和user都可以訪問,http://localhost:8080/hello 所有用戶(包括tmp)都可以訪問。

使用數據庫的用戶名、密碼登錄

添加依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

添加數據庫配置

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

配置spring-security認證和授權

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)// 設置自定義的userDetailsService
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/product/**").hasRole("USER")
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated() //
                .and()
                .formLogin()
                .and()
                .httpBasic()
                .and().logout().logoutUrl("/logout");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();// 使用不使用加密算法保持密碼
//        return new BCryptPasswordEncoder();
    }
}

如果需要使用BCryptPasswordEncoder,可以先在測試環境中加密后放到數據庫中:

@Test
void encode() {
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    String password = bCryptPasswordEncoder.encode("user");
    String password2 = bCryptPasswordEncoder.encode("admin");
    System.out.println(password);
    System.out.println(password2);
}

配置自定義UserDetailsService來進行驗證

@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {

   @Autowired
   UserRepository userRepository;

   @Override
   public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
         // 1. 查詢用戶
      User userFromDatabase = userRepository.findOneByLogin(login);
      if (userFromDatabase == null) {
         //log.warn("User: {} not found", login);
       throw new UsernameNotFoundException("User " + login + " was not found in db");
            //這里找不到必須拋異常
      }

       // 2. 設置角色
      Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
      GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(userFromDatabase.getRole());
      grantedAuthorities.add(grantedAuthority);

      return new org.springframework.security.core.userdetails.User(login,
            userFromDatabase.getPassword(), grantedAuthorities);
   }

}

配置JPA中的UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User findOneByLogin(String login);
}

添加數據庫數據

image-20201130200749622

CREATE TABLE `user` (
  `id` int(28) NOT NULL,
  `login` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `password` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `role` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (1, 'user', 'user', 'ROLE_USER');
INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (2, 'admin', 'admin', 'ROLE_ADMIN');

默認角色前綴必須是ROLE_,因為spring-security會在授權的時候自動使用match中的角色加上ROLE_后進行比較。

四:獲取登錄信息

@RequestMapping("/info")
public String info(){
    String userDetails = null;
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if(principal instanceof UserDetails) {
        userDetails = ((UserDetails)principal).getUsername();
    }else {
        userDetails = principal.toString();
    }
    return userDetails;
}

使用SecurityContextHolder.getContext().getAuthentication().getPrincipal();獲取當前的登錄信息。

五: Spring Security 核心組件

SecurityContext

SecurityContext是安全的上下文,所有的數據都是保存到SecurityContext中。

可以通過SecurityContext獲取的對象有:

  • Authentication

SecurityContextHolder

SecurityContextHolder用來獲取SecurityContext中保存的數據的工具。通過使用靜態方法獲取SecurityContext的相對應的數據。

SecurityContext context = SecurityContextHolder.getContext();

Authentication

Authentication表示當前的認證情況,可以獲取的對象有:

UserDetails:獲取用戶信息,是否鎖定等額外信息。

Credentials:獲取密碼。

isAuthenticated:獲取是否已經認證過。

Principal:獲取用戶,如果沒有認證,那么就是用戶名,如果認證了,返回UserDetails。

UserDetails

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();
	String getPassword();
	String getUsername();
	boolean isAccountNonExpired();
	boolean isAccountNonLocked();
	boolean isCredentialsNonExpired();
	boolean isEnabled();
}

UserDetailsService

UserDetailsService可以通過loadUserByUsername獲取UserDetails對象。該接口供spring security進行用戶驗證。

通常使用自定義一個CustomUserDetailsService來實現UserDetailsService接口,通過自定義查詢UserDetails。

AuthenticationManager

AuthenticationManager用來進行驗證,如果驗證失敗會拋出相對應的異常。

PasswordEncoder

密碼加密器。通常是自定義指定。

BCryptPasswordEncoder:哈希算法加密

NoOpPasswordEncoder:不使用加密

六:spring security session 無狀態支持權限控制(前后分離)

spring security會在默認的情況下將認證信息放到HttpSession中。

但是對於我們的前后端分離的情況,如app,小程序,web前后分離等,httpSession就沒有用武之地了。這時我們可以通過configure(httpSecurity)設置spring security是否使用httpSession。

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
            	//設置無狀態,所有的值如下所示。
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // code...
    }
    // code...
}

共有四種值,其中默認的是ifRequired。

  • always – a session will always be created if one doesn’t already exist,沒有session就創建。
  • ifRequired – a session will be created only if required (default),如果需要就創建(默認)。
  • never – the framework will never create a session itself but it will use one if it already exists
  • stateless – no session will be created or used by Spring Security 不創建不使用session

由於前后端不通過保存session和cookie來進行判斷,所以為了保證spring security能夠記錄登錄狀態,所以需要傳遞一個值,讓這個值能夠自我驗證來源,同時能夠得到數據信息。選型我們選擇JWT。對於java客戶端我們選擇使用jjwt

添加依賴

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.2</version>
    <scope>runtime</scope>
</dependency>

創建工具類JWTProvider

JWTProvider需要至少提供兩個方法,一個用來創建我們的token,另一個根據token獲取Authentication。

provider需要保證Key密鑰是唯一的,使用init()構建,否則會拋出異常。

@Component
@Slf4j
public class JWTProvider {
    private Key key;	// 私鑰
    private long tokenValidityInMilliseconds; // 有效時間
    private long tokenValidityInMillisecondsForRememberMe; // 記住我有效時間
    @Autowired
    private JJWTProperties jjwtProperties; // jwt配置參數
    @Autowired
    private UserRepository userRepository; 
    @PostConstruct
    public void init() {
        byte[] keyBytes;
        String secret = jjwtProperties.getSecret();
        if (StringUtils.hasText(secret)) {
            log.warn("Warning: the JWT key used is not Base64-encoded. " +
                    "We recommend using the `jhipster.security.authentication.jwt.base64-secret` key for optimum security.");
            keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        } else {
            log.debug("Using a Base64-encoded JWT secret key");
            keyBytes = Decoders.BASE64.decode(jjwtProperties.getBase64Secret());
        }
        this.key = Keys.hmacShaKeyFor(keyBytes); // 使用mac-sha算法的密鑰
        this.tokenValidityInMilliseconds =
                1000 * jjwtProperties.getTokenValidityInSeconds();
        this.tokenValidityInMillisecondsForRememberMe =
                1000 * jjwtProperties.getTokenValidityInSecondsForRememberMe();
    }
    public String createToken(Authentication authentication, boolean rememberMe) {
        long now = (new Date()).getTime();
        Date validity;
        if (rememberMe) {
            validity = new Date(now + this.tokenValidityInMillisecondsForRememberMe);
        } else {
            validity = new Date(now + this.tokenValidityInMilliseconds);
        }
        User user = userRepository.findOneByLogin(authentication.getName());
        Map<String ,Object> map = new HashMap<>();
        map.put("sub",authentication.getName());
        map.put("user",user);
        return Jwts.builder()
                .setClaims(map) // 添加body
                .signWith(key, SignatureAlgorithm.HS512) // 指定摘要算法
                .setExpiration(validity) // 設置有效時間
                .compact();
    }
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token).getBody(); // 根據token獲取body
        User principal;
        Collection<? extends GrantedAuthority> authorities;
        principal = userRepository.findOneByLogin(claims.getSubject());
        authorities = principal.getAuthorities();
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }
}

注意這里我們創建的User需要實現UserDetails對象,這樣我們可以根據principal.getAuthorities()獲取到權限,如果不實現UserDetails,那么需要自定義authorities並添加到UsernamePasswordAuthenticationToken中。

@Data
@Entity
@Table(name="user")
public class User implements UserDetails {
    @Id
    @Column
    private Long id;
    @Column
    private String login;
    @Column
    private String password;
    @Column
    private String role;
    @Override
    // 獲取權限,這里就用簡單的方法
    // 在spring security中,Authorities既可以是ROLE也可以是Authorities
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(role));
    }
    @Override
    public String getUsername() {
        return login;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return false;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}

創建登錄成功,登出成功處理器

登錄成功后向前台發送jwt。

認證成功,返回jwt:

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
    void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException{
        PrintWriter writer = response.getWriter();
        writer.println(jwtProvider.createToken(authentication, true));
    }
}

登出成功:

public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    void onLogoutSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3) throws IOException, ServletException{
        PrintWriter writer = response.getWriter();
        writer.println("logout success");
        writer.flush();
    }
}

設置登錄、登出、取消csrf防護

登出無法對token進行失效操作,可以使用數據庫保存token,然后在登出時刪除該token。

@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
       http
           // code...
           // 添加登錄處理器
           .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
           PrintWriter writer = response.getWriter();
           writer.println(jwtProvider.createToken(authentication, true));
       })
           // 取消csrf防護
           .and().csrf().disable() 
           // code...
           // 添加登出處理器
           .and().logout().logoutUrl("/logout").logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
           PrintWriter writer = response.getWriter();
           writer.println("logout success");
           writer.flush();
       })
        	// code...
    }
    // code...
}

使用JWT集成spring-security

添加Filter供spring-security解析token,並向securityContext中添加我們的用戶信息。

在UsernamePasswordAuthenticationFilter.class之前我們需要執行根據token添加authentication。關鍵方法是從jwt中獲取authentication,然后添加到securityContext中。

在SecurityConfiguration中需要設置Filter添加的位置。

創建自定義Filter,用於jwt獲取authentication:

@Slf4j
public class JWTFilter extends GenericFilterBean {

    private final static String HEADER_AUTH_NAME = "auth";

    private JWTProvider jwtProvider;

    public JWTFilter(JWTProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
            String authToken = httpServletRequest.getHeader(HEADER_AUTH_NAME);
            if (StringUtils.hasText(authToken)) {
                // 從自定義tokenProvider中解析用戶
                Authentication authentication = this.jwtProvider.getAuthentication(authToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            // 調用后續的Filter,如果上面的代碼邏輯未能復原“session”,SecurityContext中沒有想過信息,后面的流程會檢測出"需要登錄"
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
}

向HttpSecurity添加Filter和設置Filter位置:

public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    // code...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
            	//設置添加Filter和位置
                .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
                // code...
    }
    // code...
}

MySecurityConfiguration代碼

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JWTProvider jwtProvider;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)// 設置自定義的userDetailsService
                .passwordEncoder(passwordEncoder());
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//設置無狀態
                .and()
                .authorizeRequests() // 配置請求權限
                .antMatchers("/product/**").hasRole("USER") // 需要角色
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated() // 所有的請求都需要登錄
                .and()
            	// 配置登錄url,和登錄成功處理器
                .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
                    PrintWriter writer = response.getWriter();
                    writer.println(jwtProvider.createToken(authentication, true));
                })
            	// 取消csrf防護
                .and().csrf().disable() 
                .httpBasic()
            	// 配置登出url,和登出成功處理器
				.and().logout().logoutUrl("/logout")
            	.logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
                    PrintWriter writer = response.getWriter();
                    writer.println("logout success");
                    writer.flush();
                })
            	// 在UsernamePasswordAuthenticationFilter之前執行我們添加的JWTFilter
                .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Override
    public void configure(WebSecurity web) {
        // 添加不做權限的URL
        web.ignoring()
                .antMatchers("/swagger-resources/**")
                .antMatchers("/swagger-ui.html")
                .antMatchers("/webjars/**")
                .antMatchers("/v2/**")
                .antMatchers("/h2-console/**");
    }
}

使用注解對方法進行權限管理

需要在MySecurityConfiguration上添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,prePostEnabled默認為false,需要設置為true后才能全局的注解權限控制。

prePostEnabled設置為true后,可以使用四個注解:

添加實體類School:

@Data
public class School implements Serializable {
    private Long id;
    private String name;
    private String address;
}
  • @PreAuthorize

    在訪問之前就進行權限判斷

    @RestController
    public class AnnoController {
        @Autowired
        private JWTProvider jwtProvider;
        @RequestMapping("/annotation")
    //    @PreAuthorize("hasRole('ADMIN')")
        @PreAuthorize("hasAuthority('ROLE_ADMIN')")
        public String info(){
            return "擁有admin權限";
        }
    }
    

    hasRole和hasAuthority都會對UserDetails中的getAuthorities進行判斷區別是hasRole會對字段加上ROLE_后再進行判斷,上例中使用了hasRole('ADMIN'),那么就會使用ROLE_ADMIN進行判斷,如果是hasAuthority('ADMIN'),那么就使用ADMIN進行判斷。

  • @PostAuthorize

    在請求之后進行判斷,如果返回值不滿足條件,會拋出異常,但是方法本身是已經執行過了的。

    @RequestMapping("/postAuthorize")
    @PreAuthorize("hasRole('ADMIN')")
    @PostAuthorize("returnObject.id%2==0")
    public School postAuthorize(Long id) {
        School school = new School();
        school.setId(id);
        return school;
    }
    

    returnObject是內置對象,引用的是方法的返回值。

    如果returnObject.id%2==0為 true,那么返回方法值。如果為false,會返回403 Forbidden。

  • @PreFilter

    在方法執行之前,用於過濾集合中的值。

    @RequestMapping("/preFilter")
    @PreAuthorize("hasRole('ADMIN')")
    @PreFilter("filterObject%2==0")
    public List<Long> preFilter(@RequestParam("ids") List<Long> ids) {
        return ids;
    }
    

    filterObject是內置對象,引用的是集合中的泛型類,如果有多個集合,需要指定filterTarget

    @PreFilter(filterTarget="ids", value="filterObject%2==0")
    public List<Long> preFilter(@RequestParam("ids") List<Long> ids,@RequestParam("ids") List<User> users,) {
        return ids;
    }
    

    filterObject%2==0會對集合中的值會進行過濾,為true的值會保留。

    第一個例子返回的值在執行前過濾返回2,4。

    image-20201202115120854

  • @PostFilter

    會對返回的集合進行過濾。

    @RequestMapping("/postFilter")
    @PreAuthorize("hasRole('ADMIN')")
    @PostFilter("filterObject.id%2==0")
    public List<School> postFilter() {
        List<School> schools = new ArrayList<School>();
        School school;
        for (int i = 0; i < 10; i++) {
            school = new School();
            school.setId((long)i);
            schools.add(school);
        }
        return schools;
    }
    

    上面的方法返回結果為:id為0,2,4,6,8的School對象。

七、原理講解

1、校驗流程圖

img

2、源碼分析

  • AbstractAuthenticationProcessingFilter 抽象類
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (!requiresAuthentication(request, response)) {
            chain.doFilter(request, response);

            return;
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Request is to process authentication");
        }

        Authentication authResult;

        try {
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                // return immediately as subclass has indicated that it hasn't completed
                // authentication
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        }
        catch (InternalAuthenticationServiceException failed) {
            logger.error(
                    "An internal error occurred while trying to authenticate the user.",
                    failed);
            unsuccessfulAuthentication(request, response, failed);

            return;
        }
        catch (AuthenticationException failed) {
            // Authentication failed
            unsuccessfulAuthentication(request, response, failed);

            return;
        }

        // Authentication success
        if (continueChainBeforeSuccessfulAuthentication) {
            chain.doFilter(request, response);
        }

        successfulAuthentication(request, response, chain, authResult);
    }

調用 requiresAuthentication(HttpServletRequest, HttpServletResponse) 決定是否需要進行驗證操作。如果需要驗證,則會調用 attemptAuthentication(HttpServletRequest, HttpServletResponse) 方法,有三種結果:

  1. 返回一個 Authentication 對象。配置的 SessionAuthenticationStrategy` 將被調用,然后 然后調用 successfulAuthentication(HttpServletRequest,HttpServletResponse,FilterChain,Authentication) 方法。
  2. 驗證時發生 AuthenticationException。unsuccessfulAuthentication(HttpServletRequest, HttpServletResponse, AuthenticationException) 方法將被調用。
  3. 返回Null,表示身份驗證不完整。假設子類做了一些必要的工作(如重定向)來繼續處理驗證,方法將立即返回。假設后一個請求將被這種方法接收,其中返回的Authentication對象不為空。
  • UsernamePasswordAuthenticationFilter(AbstractAuthenticationProcessingFilter的子類)
public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

attemptAuthentication () 方法將 request 中的 username 和 password 生成 UsernamePasswordAuthenticationToken 對象,用於 AuthenticationManager 的驗證(即 this.getAuthenticationManager().authenticate(authRequest) )。默認情況下注入 Spring 容器的 AuthenticationManager 是 ProviderManager。

  • ProviderManager(AuthenticationManager的實現類)
public Authentication authenticate(Authentication authentication)
    throws AuthenticationException {
    Class<? extends Authentication> toTest = authentication.getClass();
    AuthenticationException lastException = null;
    Authentication result = null;
    boolean debug = logger.isDebugEnabled();

    for (AuthenticationProvider provider : getProviders()) {
        if (!provider.supports(toTest)) {
            continue;
        }

        if (debug) {
            logger.debug("Authentication attempt using "
                         + provider.getClass().getName());
        }

        try {
            result = provider.authenticate(authentication);

            if (result != null) {
                copyDetails(authentication, result);
                break;
            }
        }
        catch (AccountStatusException e) {
            prepareException(e, authentication);
            // SEC-546: Avoid polling additional providers if auth failure is due to
            // invalid account status
            throw e;
        }
        catch (InternalAuthenticationServiceException e) {
            prepareException(e, authentication);
            throw e;
        }
        catch (AuthenticationException e) {
            lastException = e;
        }
    }

    if (result == null && parent != null) {
        // Allow the parent to try.
        try {
            result = parent.authenticate(authentication);
        }
        catch (ProviderNotFoundException e) {
            // ignore as we will throw below if no other exception occurred prior to
            // calling parent and the parent
            // may throw ProviderNotFound even though a provider in the child already
            // handled the request
        }
        catch (AuthenticationException e) {
            lastException = e;
        }
    }

    if (result != null) {
        if (eraseCredentialsAfterAuthentication
            && (result instanceof CredentialsContainer)) {
            // Authentication is complete. Remove credentials and other secret data
            // from authentication
            ((CredentialsContainer) result).eraseCredentials();
        }

        eventPublisher.publishAuthenticationSuccess(result);
        return result;
    }

    // Parent was null, or didn't authenticate (or throw an exception).

    if (lastException == null) {
        lastException = new ProviderNotFoundException(messages.getMessage(
            "ProviderManager.providerNotFound",
            new Object[] { toTest.getName() },
            "No AuthenticationProvider found for {0}"));
    }

    prepareException(lastException, authentication);

    throw lastException;
}

嘗試驗證 Authentication 對象。AuthenticationProvider 列表將被連續嘗試,直到 AuthenticationProvider 表示它能夠認證傳遞的過來的Authentication 對象。然后將使用該 AuthenticationProvider 嘗試身份驗證。如果有多個 AuthenticationProvider 支持驗證傳遞過來的Authentication 對象,那么由第一個來確定結果,覆蓋早期支持AuthenticationProviders 所引發的任何可能的AuthenticationException。 成功驗證后,將不會嘗試后續的AuthenticationProvider。如果最后所有的 AuthenticationProviders 都沒有成功驗證 Authentication 對象,將拋出 AuthenticationException。從代碼中不難看出,由 provider 來驗證 authentication, 核心點方法是:

Authentication result = provider.authenticate(authentication);

此處的 provider 是 AbstractUserDetailsAuthenticationProvider,AbstractUserDetailsAuthenticationProvider 是AuthenticationProvider的實現,看看它的 authenticate(authentication) 方法:

// 驗證 authentication
public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.onlySupports",
                        "Only UsernamePasswordAuthenticationToken is supported"));

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                : authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;

            try {
                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);
        }
        catch (AuthenticationException exception) {
            if (cacheWasUsed) {
                // There was a problem, so try again after checking
                // we're using latest data (i.e. not from the cache)
                cacheWasUsed = false;
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
                preAuthenticationChecks.check(user);
                additionalAuthenticationChecks(user,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            else {
                throw exception;
            }
        }

        postAuthenticationChecks.check(user);

        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }

        Object principalToReturn = user;

        if (forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }

        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

AbstractUserDetailsAuthenticationProvider 內置了緩存機制,從緩存中獲取不到的 UserDetails 信息的話,就調用如下方法獲取用戶信息,然后和 用戶傳來的信息進行對比來判斷是否驗證成功。

// 獲取用戶信息
UserDetails user = retrieveUser(username,
 (UsernamePasswordAuthenticationToken) authentication);

retrieveUser() 方法在 DaoAuthenticationProvider 中實現,DaoAuthenticationProvider 是 AbstractUserDetailsAuthenticationProvider的子類。具體實現如下:

protected final UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        UserDetails loadedUser;

        try {
            loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        }
        catch (UsernameNotFoundException notFound) {
            if (authentication.getCredentials() != null) {
                String presentedPassword = authentication.getCredentials().toString();
                passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
                        presentedPassword, null);
            }
            throw notFound;
        }
        catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(
                    repositoryProblem.getMessage(), repositoryProblem);
        }

        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException(
                    "UserDetailsService returned null, which is an interface contract violation");
        }
        return loadedUser;
    }

可以看到此處的返回對象 userDetails 是由 UserDetailsService 的 #loadUserByUsername(username) 來獲取的。

八、玩轉自定義登錄

1. form 登錄的流程

下面是 form 登錄的基本流程:

img

只要是 form 登錄基本都能轉化為上面的流程。接下來我們看看 Spring Security 是如何處理的。

3. Spring Security 中的登錄

默認它提供了三種登錄方式:

  • formLogin() 普通表單登錄
  • oauth2Login() 基於 OAuth2.0 認證/授權協議
  • openidLogin() 基於 OpenID 身份認證規范

以上三種方式統統是 AbstractAuthenticationFilterConfigurer 實現的,

4. HttpSecurity 中的 form 表單登錄

啟用表單登錄通過兩種方式一種是通過 HttpSecurityapply(C configurer) 方法自己構造一個 AbstractAuthenticationFilterConfigurer 的實現,這種是比較高級的玩法。 另一種是我們常見的使用 HttpSecurityformLogin() 方法來自定義 FormLoginConfigurer 。我們先搞一下比較常規的第二種。

4.1 FormLoginConfigurer

該類是 form 表單登錄的配置類。它提供了一些我們常用的配置方法:

  • loginPage(String loginPage) : 登錄 頁面而並不是接口,對於前后分離模式需要我們進行改造 默認為 /login
  • loginProcessingUrl(String loginProcessingUrl) 實際表單向后台提交用戶信息的 Action,再由過濾器UsernamePasswordAuthenticationFilter 攔截處理,該 Action 其實不會處理任何邏輯。
  • usernameParameter(String usernameParameter) 用來自定義用戶參數名,默認 username
  • passwordParameter(String passwordParameter) 用來自定義用戶密碼名,默認 password
  • failureUrl(String authenticationFailureUrl) 登錄失敗后會重定向到此路徑, 一般前后分離不會使用它。
  • failureForwardUrl(String forwardUrl) 登錄失敗會轉發到此, 一般前后分離用到它。 可定義一個 Controller (控制器)來處理返回值,但是要注意 RequestMethod
  • defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) 默認登陸成功后跳轉到此 ,如果 alwaysUsetrue 只要進行認證流程而且成功,會一直跳轉到此。一般推薦默認值 false
  • successForwardUrl(String forwardUrl) 效果等同於上面 defaultSuccessUrlalwaysUsetrue 但是要注意 RequestMethod
  • successHandler(AuthenticationSuccessHandler successHandler) 自定義認證成功處理器,可替代上面所有的 success 方式
  • failureHandler(AuthenticationFailureHandler authenticationFailureHandler) 自定義失敗成功處理器,可替代上面所有的 failure 方式
  • permitAll(boolean permitAll) form 表單登錄是否放開

知道了這些我們就能來搞個定制化的登錄了。

5. Spring Security 聚合登錄 實戰

接下來是我們最激動人心的實戰登錄操作。 有疑問的可認真閱讀 Spring 實戰 的一系列預熱文章。

5.1 簡單需求

我們的接口訪問都要通過認證,登陸錯誤后返回錯誤信息(json),成功后前台可以獲取到對應數據庫用戶信息(json)(實戰中記得脫敏)。

我們定義處理成功失敗的控制器:

 @RestController
 @RequestMapping("/login")
 public class LoginController {
     @Resource
     private SysUserService sysUserService;
 
     /**
      * 登錄失敗返回 401 以及提示信息.
      *
      * @return the rest
      */
     @PostMapping("/failure")
     public Rest loginFailure() {
 
         return RestBody.failure(HttpStatus.UNAUTHORIZED.value(), "登錄失敗了,老哥");
     }
 
     /**
      * 登錄成功后拿到個人信息.
      *
      * @return the rest
      */
     @PostMapping("/success")
     public Rest loginSuccess() {
           // 登錄成功后用戶的認證信息 UserDetails會存在 安全上下文寄存器 SecurityContextHolder 中
         User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
         String username = principal.getUsername();
         SysUser sysUser = sysUserService.queryByUsername(username);
         // 脫敏
         sysUser.setEncodePassword("[PROTECT]");
         return RestBody.okData(sysUser,"登錄成功");
     }
 }

然后 我們自定義配置覆寫 void configure(HttpSecurity http) 方法進行如下配置(這里需要禁用crsf):

 @Configuration
 @ConditionalOnClass(WebSecurityConfigurerAdapter.class)
 @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
 public class CustomSpringBootWebSecurityConfiguration {
 
     @Configuration
     @Order(SecurityProperties.BASIC_AUTH_ORDER)
     static class DefaultConfigurerAdapter extends WebSecurityConfigurerAdapter {
         @Override
         protected void configure(AuthenticationManagerBuilder auth) throws Exception {
             super.configure(auth);
         }
 
         @Override
         public void configure(WebSecurity web) throws Exception {
             super.configure(web);
         }
 
         @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     .authorizeRequests().anyRequest().authenticated()
                     .and()
                     .formLogin()
                     .loginProcessingUrl("/process")
                     .successForwardUrl("/login/success").
                     failureForwardUrl("/login/failure");
 
         }
     }
 }

使用 Postman 或者其它工具進行 Post 方式的表單提交 http://localhost:8080/process?username=Felordcn&password=12345 會返回用戶信息:

 {
     "httpStatus": 200,
     "data": {
         "userId": 1,
         "username": "Felordcn",
         "encodePassword": "[PROTECT]",
         "age": 18
     },
     "msg": "登錄成功",
     "identifier": ""
 }

把密碼修改為其它值再次請求認證失敗后 :

  {
      "httpStatus": 401,
      "data": null,
      "msg": "登錄失敗了,老哥",
      "identifier": "-9999"
  }

6. 多種登錄方式的簡單實現

就這么完了了么?現在登錄的花樣繁多。常規的就有短信、郵箱、掃碼 ,第三方是以后我要講的不在今天范圍之內。 如何應對想法多的產品經理? 我們來搞一個可擴展各種姿勢的登錄方式。我們在上面 2. form 登錄的流程 中的 用戶判定 之間增加一個適配器來適配即可。 我們知道這個所謂的 判定就是 UsernamePasswordAuthenticationFilter

我們只需要保證 uri 為上面配置的/process 並且能夠通過 getParameter(String name) 獲取用戶名和密碼即可

我突然覺得可以模仿 DelegatingPasswordEncoder 的搞法, 維護一個注冊表執行不同的處理策略。當然我們要實現一個 GenericFilterBeanUsernamePasswordAuthenticationFilter 之前執行。同時制定登錄的策略。

6.1 登錄方式定義

定義登錄方式枚舉 ``。

  public enum LoginTypeEnum {
  
      /**
       * 原始登錄方式.
       */
      FORM,
      /**
       * Json 提交.
       */
      JSON,
      /**
       * 驗證碼.
       */
      CAPTCHA
  
  }

6.2 定義前置處理器接口

定義前置處理器接口用來處理接收的各種特色的登錄參數 並處理具體的邏輯。這個借口其實有點隨意 ,重要的是你要學會思路。我實現了一個 默認的 form' 表單登錄 和 通過RequestBody放入json` 的兩種方式,篇幅限制這里就不展示了。具體的 DEMO 參見底部。

   public interface LoginPostProcessor {
   
   
   
       /**
        * 獲取 登錄類型
        *
        * @return the type
        */
       LoginTypeEnum getLoginTypeEnum();
   
       /**
        * 獲取用戶名
        *
        * @param request the request
        * @return the string
        */
       String obtainUsername(ServletRequest request);
   
       /**
        * 獲取密碼
        *
        * @param request the request
        * @return the string
        */
       String obtainPassword(ServletRequest request);
   
   }

6.3 實現登錄前置處理過濾器

該過濾器維護了 LoginPostProcessor 映射表。 通過前端來判定登錄方式進行策略上的預處理,最終還是會交給 UsernamePasswordAuthenticationFilter 。通過 HttpSecurityaddFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)方法進行前置。

 package cn.felord.spring.security.filter;
 
 import cn.felord.spring.security.enumation.LoginTypeEnum;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.filter.GenericFilterBean;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY;
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
 
 /**
  * 預登錄控制器
  *
  * @author Felordcn
  * @since 16 :21 2019/10/17
  */
 public class PreLoginFilter extends GenericFilterBean {
 
 
     private static final String LOGIN_TYPE_KEY = "login_type";
 
 
     private RequestMatcher requiresAuthenticationRequestMatcher;
     private Map<LoginTypeEnum, LoginPostProcessor> processors = new HashMap<>();
 
 
     public PreLoginFilter(String loginProcessingUrl, Collection<LoginPostProcessor> loginPostProcessors) {
         Assert.notNull(loginProcessingUrl, "loginProcessingUrl must not be null");
         requiresAuthenticationRequestMatcher = new AntPathRequestMatcher(loginProcessingUrl, "POST");
         LoginPostProcessor loginPostProcessor = defaultLoginPostProcessor();
         processors.put(loginPostProcessor.getLoginTypeEnum(), loginPostProcessor);
 
         if (!CollectionUtils.isEmpty(loginPostProcessors)) {
             loginPostProcessors.forEach(element -> processors.put(element.getLoginTypeEnum(), element));
         }
 
     }
 
 
     private LoginTypeEnum getTypeFromReq(ServletRequest request) {
         String parameter = request.getParameter(LOGIN_TYPE_KEY);
 
         int i = Integer.parseInt(parameter);
         LoginTypeEnum[] values = LoginTypeEnum.values();
         return values[i];
     }
 
 
     /**
      * 默認還是Form .
      *
      * @return the login post processor
      */
     private LoginPostProcessor defaultLoginPostProcessor() {
         return new LoginPostProcessor() {
 
 
             @Override
             public LoginTypeEnum getLoginTypeEnum() {
 
                 return LoginTypeEnum.FORM;
             }
 
             @Override
             public String obtainUsername(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_USERNAME_KEY);
             }
 
             @Override
             public String obtainPassword(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_PASSWORD_KEY);
             }
         };
     }
 
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         ParameterRequestWrapper parameterRequestWrapper = new ParameterRequestWrapper((HttpServletRequest) request);
         if (requiresAuthenticationRequestMatcher.matches((HttpServletRequest) request)) {
 
             LoginTypeEnum typeFromReq = getTypeFromReq(request);
 
             LoginPostProcessor loginPostProcessor = processors.get(typeFromReq);
 
 
             String username = loginPostProcessor.obtainUsername(request);
 
             String password = loginPostProcessor.obtainPassword(request);
 
 
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_USERNAME_KEY, username);
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_PASSWORD_KEY, password);
 
         }
 
         chain.doFilter(parameterRequestWrapper, response);
 
 
     }
 }

6.4 驗證

通過 POST 表單提交方式 http://localhost:8080/process?username=Felordcn&password=12345&login_type=0 可以請求成功。或者以下列方式也可以提交成功:

img

更多的方式 只需要實現接口 LoginPostProcessor 注入 PreLoginFilter

九 整合JWT做登錄認證

JWT是JSON Web Token的縮寫,是目前最流行的跨域認證解決方法。

互聯網服務認證的一般流程是:

  1. 用戶向服務器發送賬號、密碼
  2. 服務器驗證通過后,將用戶的角色、登錄時間等信息保存到當前會話中
  3. 同時,服務器向用戶返回一個session_id(一般保存在cookie里)
  4. 用戶再次發送請求時,把含有session_id的cookie發送給服務器
  5. 服務器收到session_id,查找session,提取用戶信息

上面的認證模式,存在以下缺點:

  • cookie不允許跨域
  • 因為每台服務器都必須保存session對象,所以擴展性不好

JWT認證原理是:

  1. 用戶向服務器發送賬號、密碼
  2. 服務器驗證通過后,生成token令牌返回給客戶端(token可以包含用戶信息)
  3. 用戶再次請求時,把token放到請求頭Authorization
  4. 服務器收到請求,驗證token合法后放行請求

JWT token令牌可以包含用戶身份、登錄時間等信息,這樣登錄狀態保持者由服務器端變為客戶端,服務器變成無狀態了;token放到請求頭,實現了跨域

JWT的組成

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT由三部分組成:

  • Header(頭部)
  • Payload(負載)
  • Signature(簽名)

表現形式為:Header.Payload.Signature

Header 部分是一個 JSON 對象,描述 JWT 的元數據,通常是下面的樣子:

{
  "alg": "HS256",
  "typ": "JWT"
}

上面代碼中,alg屬性表示簽名的算法(algorithm),默認是 HMAC SHA256(寫成 HS256);typ屬性表示這個令牌(token)的類型(type),JWT 令牌統一寫為JWT

上面的 JSON 對象使用 Base64URL 算法轉成字符串

Payload

Payload 部分也是一個 JSON 對象,用來存放實際需要傳遞的數據。JWT 規定了7個官方字段:

  • iss (issuer):簽發人
  • exp (expiration time):過期時間
  • sub (subject):主題
  • aud (audience):受眾
  • nbf (Not Before):生效時間
  • iat (Issued At):簽發時間
  • jti (JWT ID):編號

當然,用戶也可以定義私有字段。

這個 JSON 對象也要使用 Base64URL 算法轉成字符串

Signature

Signature 部分是對前兩部分的簽名,防止數據篡改

簽名算法如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

算出簽名以后,把 Header、Payload、Signature 三個部分拼成一個字符串,每個部分之間用"."分隔

JWT認證和授權

Security是基於AOP和Servlet過濾器的安全框架,為了實現JWT要重寫那些方法、自定義那些過濾器需要首先了解security自帶的過濾器。security默認過濾器鏈如下:

  1. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  2. org.springframework.security.web.context.SecurityContextPersistenceFilter
  3. org.springframework.security.web.header.HeaderWriterFilter
  4. org.springframework.security.web.authentication.logout.LogoutFilter
  5. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
  6. org.springframework.security.web.savedrequest.RequestCacheAwareFilter
  7. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  8. org.springframework.security.web.authentication.AnonymousAuthenticationFilter
  9. org.springframework.security.web.session.SessionManagementFilter
  10. org.springframework.security.web.access.ExceptionTranslationFilter
  11. org.springframework.security.web.access.intercept.FilterSecurityInterceptor

SecurityContextPersistenceFilter

這個過濾器有兩個作用:

  • 用戶發送請求時,從session對象提取用戶信息,保存到SecurityContextHolder的securitycontext中
  • 當前請求響應結束時,把SecurityContextHolder的securitycontext保存的用戶信息放到session,便於下次請求時共享數據;同時將SecurityContextHolder的securitycontext清空

由於禁用session功能,所以該過濾器只剩一個作用即把SecurityContextHolder的securitycontext清空。舉例來說明為何要清空securitycontext:用戶1發送一個請求,由線程M處理,當響應完成線程M放回線程池;用戶2發送一個請求,本次請求同樣由線程M處理,由於securitycontext沒有清空,理應儲存用戶2的信息但此時儲存的是用戶1的信息,造成用戶信息不符

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter繼承自AbstractAuthenticationProcessingFilter,處理邏輯在doFilter方法中:

  1. 當請求被UsernamePasswordAuthenticationFilter攔截時,判斷請求路徑是否匹配登錄URL,若不匹配繼續執行下個過濾器;否則,執行步驟2
  2. 調用attemptAuthentication方法進行認證。UsernamePasswordAuthenticationFilter重寫了attemptAuthentication方法,負責讀取表單登錄參數,委托AuthenticationManager進行認證,返回一個認證過的token(null表示認證失敗)
  3. 判斷token是否為null,非null表示認證成功,null表示認證失敗
  4. 若認證成功,調用successfulAuthentication。該方法把認證過的token放入securitycontext供后續請求授權,同時該方法預留一個擴展點(AuthenticationSuccessHandler.onAuthenticationSuccess方法),進行認證成功后的處理
  5. 若認證失敗,同樣可以擴展uthenticationFailureHandler.onAuthenticationFailure進行認證失敗后的處理
  6. 只要當前請求路徑匹配登錄URL,那么無論認證成功還是失敗,當前請求都會響應完成,不再執行過濾器鏈

UsernamePasswordAuthenticationFilterattemptAuthentication方法,執行邏輯如下:

  1. 從請求中獲取表單參數。因為使用HttpServletRequest.getParameter方法獲取參數,它只能處理Content-Type為application/x-www-form-urlencoded或multipart/form-data的請求,若是application/json則無法獲取值
  2. 把步驟1獲取的賬號、密碼封裝成UsernamePasswordAuthenticationToken對象,創建未認證的token。UsernamePasswordAuthenticationToken有兩個重載的構造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)創建未經認證的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)創建已認證的token
  3. 獲取認證管理器AuthenticationManager,其缺省實現為ProviderManager,調用其authenticate進行認證
  4. ProviderManagerauthenticate是個模板方法,它遍歷所有AuthenticationProvider,直至找到支持認證某類型token的AuthenticationProvider,調用AuthenticationProvider.authenticate方法認證,AuthenticationProvider.authenticate加載正確的賬號、密碼進行比較驗證
  5. AuthenticationManager.authenticate方法返回一個已認證的token

AnonymousAuthenticationFilter

AnonymousAuthenticationFilter負責創建匿名token:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.of(() -> {
                    return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
                }));
            } else {
                this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
            }
        } else if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.of(() -> {
                return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
            }));
        }

        chain.doFilter(req, res);
    }

如果當前用戶沒有認證,會創建一個匿名token,用戶是否能讀取資源交由FilterSecurityInterceptor過濾器委托給決策管理器判斷是否有權限讀取

實現思路

JWT認證思路:

  1. 利用Security原生的表單認證過濾器驗證用戶名、密碼
  2. 驗證通過后自定義AuthenticationSuccessHandler認證成功處理器,由該處理器生成token令牌

JWT授權思路:

  1. 使用JWT目的是讓服務器變成無狀態,不用session共享數據,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  2. token令牌數據結構設計時,payload部分要儲存用戶名、角色信息
  3. token令牌有兩個作用:
    1. 認證, 用戶發送的token合法即代表認證成功
    2. 授權,令牌驗證成功后提取角色信息,構造認證過的token,將其放到securitycontext,具體權限判斷交給security框架處理
  4. 自己實現一個過濾器,攔截用戶請求,實現(3)中所說的功能

代碼實現 創建JWT工具類

<dependency>
	<groupId>com.auth0</groupId>
	<artifactId>java-jwt</artifactId>
	<version>3.12.0</version>
 </dependency>

我們對java-jwt提供的API進行封裝,便於創建、驗證、提取claim

@Slf4j
public class JWTUtil {
    // 攜帶token的請求頭名字
    public final static String TOKEN_HEADER = "Authorization";
    //token的前綴
    public final static String TOKEN_PREFIX = "Bearer ";
    // 默認密鑰
    public final static String DEFAULT_SECRET = "mySecret";
    // 用戶身份
    private final static String ROLES_CLAIM = "roles";
    // token有效期,單位分鍾;
    private final static long EXPIRE_TIME = 5 * 60 * 1000;
    // 設置Remember-me功能后的token有效期
    private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;

    // 創建token
    public static String createToken(String username, List role, String secret, boolean rememberMe) {

        Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
        try {
            // 創建簽名的算法實例
            Algorithm algorithm = Algorithm.HMAC256(secret);
            String token = JWT.create()
                    .withExpiresAt(expireDate)
                    .withClaim("username", username)
                    .withClaim(ROLES_CLAIM, role)
                    .sign(algorithm);
            return token;
        } catch (JWTCreationException jwtCreationException) {
            log.warn("Token create failed");
            return null;
        }
    }

    // 驗證token
    public static boolean verifyToken(String token, String secret) {
        try{
            Algorithm algorithm = Algorithm.HMAC256(secret);
            // 構建JWT驗證器,token合法同時pyload必須含有私有字段username且值一致
            // token過期也會驗證失敗
            JWTVerifier verifier = JWT.require(algorithm)
                    .build();
            // 驗證token
            DecodedJWT decodedJWT = verifier.verify(token);
            return true;
        } catch (JWTVerificationException jwtVerificationException) {
            log.warn("token驗證失敗");
            return false;
        }

    }

    // 獲取username
    public static String getUsername(String token) {
        try {
            // 因此獲取載荷信息不需要密鑰
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取用戶姓名時,token解碼失敗");
            return null;
        }
    }

    public static List<String> getRole(String token) {
        try {
            // 因此獲取載荷信息不需要密鑰
            DecodedJWT jwt = JWT.decode(token);
            // asList方法需要指定容器元素的類型
            return jwt.getClaim(ROLES_CLAIM).asList(String.class);
        } catch (JWTDecodeException jwtDecodeException) {
            log.warn("提取身份時,token解碼失敗");
            return null;
        }
    }
}

代碼實現認證

驗證賬號、密碼交給UsernamePasswordAuthenticationFilter,不用修改代碼

認證成功后,需要生成token返回給客戶端,我們通過擴展AuthenticationSuccessHandler.onAuthenticationSuccess方法實現

@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("200");
        responseData.setMessage("登錄成功!");
		
        // 提取用戶名,准備寫入token
        String username = authentication.getName();
        // 提取角色,轉為List<String>對象,寫入token
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority authority : authorities){
            roles.add(authority.getAuthority());
        }
		
        // 創建token
        String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
        httpServletResponse.setCharacterEncoding("utf-8");
        // 為了跨域,把token放到響應頭WWW-Authenticate里
        httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
		// 寫入響應里
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

為了統一返回值,我們封裝了一個ResponseData對象

代碼實現 授權

自定義一個過濾器JWTAuthorizationFilter,驗證token,token驗證成功后認為認證成功

@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = getTokenFromRequestHeader(request);
        Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
        if (verifyResult == null) {
            // 即便驗證失敗,也繼續調用過濾鏈,匿名過濾器生成匿名令牌
            chain.doFilter(request, response);
            return;
        } else {
            log.info("token令牌驗證成功");
            SecurityContextHolder.getContext().setAuthentication(verifyResult);
            chain.doFilter(request, response);
        }
    }
	
    // 從請求頭獲取token
    private String getTokenFromRequestHeader(HttpServletRequest request) {
        String header = request.getHeader(JWTUtil.TOKEN_HEADER);
        if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
            log.info("請求頭不含JWT token, 調用下個過濾器");
            return null;
        }

        String token = header.split(" ")[1].trim();
        return token;
    }
	
    // 驗證token,並生成認證后的token
    private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
        if (token == null) {
            return null;
        }
		
        // 認證失敗,返回null
        if (!JWTUtil.verifyToken(token, secret)) {
            return null;
        }

        // 提取用戶名
        String username = JWTUtil.getUsername(token);
        // 定義權限列表
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 從token提取角色
        List<String> roles = JWTUtil.getRole(token);
        for (String role : roles) {
            log.info("用戶身份是:" + role);
            authorities.add(new SimpleGrantedAuthority(role));
        }
        // 構建認證過的token
        return new UsernamePasswordAuthenticationToken(username, null, authorities);
    }
}
OncePerRequestFilter`保證當前請求中,此過濾器只被調用一次,執行邏輯在`doFilterInternal

代碼實現 security配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
    @Autowired
    private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;

    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

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

    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler(jwtAuthenticationSuccessHandler)
                .failureHandler(ajaxAuthenticationFailureHandler)
                .permitAll()
                .and()
                .addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);

    }
}

配置里取消了session功能,把我們定義的過濾器添加到過濾鏈中;同時,定義ajaxAuthenticationEntryPoint處理未認證用戶訪問未授權資源時拋出的異常

@Component
public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseData responseData = new ResponseData();
        responseData.setCode("401");
        responseData.setMessage("匿名用戶,請先登錄再訪問!");

        httpServletResponse.setCharacterEncoding("utf-8");
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(httpServletResponse.getWriter(), responseData);
    }
}

參考

JSON Web Token 入門教程

Spring Security-5-認證流程梳理

Spring Security3源碼分析(5)-SecurityContextPersistenceFilter分析

Spring Security addFilter() 順序問題

前后端聯調之Form Data與Request Payload,你真的了解嗎?

Spring Boot 2 + Spring Security 5 + JWT 的單頁應用 Restful 解決方案

SpringBoot實戰派-第十章源碼


免責聲明!

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



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