SpringSecurity原理解析以及CSRF跨站請求偽造攻擊

ML李嘉圖發表於2022-03-07

img

SpringSecurity

SpringSecurity是一個基於Spring開發的非常強大的許可權驗證框架,其核心功能包括:

  • 認證 (使用者登入)
  • 授權 (此使用者能夠做哪些事情)
  • 攻擊防護 (防止偽造身份攻擊)

我們為什麼需要使用更加專業的全新驗證框架,還要從CSRF說起。

CSRF跨站請求偽造攻擊

我們時常會在QQ上收到別人傳送的釣魚網站連結,只要你在上面登陸了你的QQ賬號,那麼不出意外,你的號已經在別人手中了。

實際上這一類網站都屬於惡意網站,專門用於盜取他人資訊,執行非法操作,甚至獲取他人賬戶中的財產,非法轉賬等。而這裡,我們需要了解一種比較容易發生的惡意操作,從不法分子的角度去了解整個流程。

我們在 JavaWeb 階段已經瞭解了Session和Cookie的機制,在一開始的時候,服務端會給瀏覽器一個名為JSESSION的Cookie資訊作為會話的唯一憑據,只要使用者攜帶此Cookie訪問我們的網站,那麼我們就可以認定此會話屬於哪個瀏覽器。

因此,只要此會話的使用者執行了登入操作,那麼就可以隨意訪問個人資訊等內容。

比如現在,我們的伺服器新增了一個轉賬的介面,使用者登入之後,只需要使用POST請求攜帶需要轉賬的金額和轉賬人訪問此介面就可以進行轉賬操作:

@RequestMapping("/index")
public String index(HttpSession session){
    session.setAttribute("login", true);   //這裡就正常訪問一下index表示登陸
    return "index";
}
@RequestMapping(value = "/pay", method = RequestMethod.POST, produces = "text/html;charset=utf-8") //這裡要設定一下produces不然會亂碼
@ResponseBody
public String pay(String account,
                  int amount,
                  @SessionAttribute("login") Boolean isLogin){
    if (isLogin) return "成功轉賬 ¥"+amount+" 給:"+account;
    else return "轉賬失敗,您沒有登陸!";
}

那麼,大家有沒有想過這樣一個問題,我們為了搜尋學習資料時可能一不小心訪問了一個惡意網站,而此網站攜帶了這樣一段內容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>我是(惡)學(意)習網站</title>
</head>
<body>
    <div>
        <div>對不起,您還沒有充值本站的學習會員,請先充值後再觀看學習視訊</div>
        <form action="http://localhost:8080/mvc/pay" method="post">
            <input type="text" name="account" value="hacker" hidden>
            <input type="number" name="amount" value="666666" hidden>
            <input type="submit" value="點我充值會員,觀看完整視訊">
        </form>
    </div>
</body>
</html>

注意這個頁面並不是我們官方提供的頁面,而是不法分子搭建的惡意網站。

我們發現此頁面中有一個表單,但是表單中的兩個輸入框被隱藏了,而我們看到的只有一個按鈕,我們不知道這是一個表單,也不知道表單會提交給那個地址,這時整個頁面就非常有迷惑性了。

如果我們點選此按鈕,那麼整個表單的資料會以POST的形式傳送給我們的服務端(會攜帶之前登陸我們網站的Cookie資訊),但是這裡很明顯是另一個網站跳轉,通過這樣的方式,惡意網站就成功地在我們毫不知情的情況下引導我們執行了轉賬操作,當你發現上當受騙時,錢已經被轉走了。

而這種構建惡意頁面,引導使用者訪問對應網站執行操作的方式稱為:跨站請求偽造(CSRF,Cross Site Request Forgery)


開發環境搭建

匯入以下依賴:

<!-- 建議為各個依賴進行分類,到後期我們的專案可能會匯入很多依賴,新增註釋會大幅度提高閱讀效率 -->
<dependencies>
    <!--  Spring框架依賴  -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
        <version>5.5.3</version>
    </dependency>
  	<dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
        <version>5.5.3</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>5.3.14</version>
    </dependency>

    <!--  持久層框架依賴  -->
		<dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.27</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
            <version>2.0.6</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.7</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>5.3.14</version>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>3.4.5</version>
        </dependency>

    <!--  其他工具框架依賴:Lombok、Slf4j  -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.22</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-jdk14</artifactId>
        <version>1.7.32</version>
    </dependency>

    <!--  ServletAPI  -->
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>4.0.1</version>
        <scope>provided</scope>
    </dependency>

    <!--  JUnit依賴  -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>${junit.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>${junit.version}</version>
        <scope>test</scope>
    </dependency>
</dependencies>

接著建立Initializer來配置Web應用程式:

public class MvcInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{RootConfiguration.class};
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[]{MvcConfiguration.class};
    }

    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }
}

建立MVC配置類:

@ComponentScan("book.manager.controller")
@Configuration
@EnableWebMvc
public class MvcConfiguration implements WebMvcConfigurer {

    //我們需要使用ThymeleafViewResolver作為檢視解析器,並解析我們的HTML頁面
    @Bean
    public ThymeleafViewResolver thymeleafViewResolver(@Autowired SpringTemplateEngine springTemplateEngine){
        ThymeleafViewResolver resolver = new ThymeleafViewResolver();
        resolver.setOrder(1);
        resolver.setCharacterEncoding("UTF-8");
        resolver.setTemplateEngine(springTemplateEngine);
        return resolver;
    }

    //配置模板解析器
    @Bean
    public SpringResourceTemplateResolver templateResolver(){
        SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
        resolver.setSuffix(".html");
        resolver.setPrefix("/WEB-INF/template/");
        return resolver;
    }

    //配置模板引擎Bean
    @Bean
    public SpringTemplateEngine springTemplateEngine(@Autowired ITemplateResolver resolver){
        SpringTemplateEngine engine = new SpringTemplateEngine();
        engine.setTemplateResolver(resolver);
        return engine;
    }

    //開啟靜態資源處理
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }

    //靜態資源路徑配置
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("/WEB-INF/static/");
    }
}

建立Root配置類:

@Configuration
public class RootConfiguration {

}

最後建立一個專用於響應頁面的Controller即可:

/**
 * 專用於處理頁面響應的控制器
 */
@Controller
public class PageController {

    @RequestMapping("/index")
    public String login(){
        return "index";
    }
}

接著我們需要將前端頁面放到對應的資料夾中,然後開啟伺服器並通過瀏覽器,成功訪問。

接著我們需要配置SpringSecurity,與MVC一樣,需要一個初始化器:

public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
    //不用重寫任何內容
  	//這裡實際上會自動註冊一個Filter,SpringSecurity底層就是依靠N個過濾器實現的,我們之後再探討
}

接著我們需要再建立一個配置類用於配置SpringSecurity:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
		//繼承WebSecurityConfigurerAdapter,之後會進行配置
}

接著在根容器中新增此配置檔案即可:

@Override
protected Class<?>[] getRootConfigClasses() {
    return new Class[]{RootConfiguration.class, SecurityConfiguration.class};
}

這樣,SpringSecurity的配置就完成了,我們再次執行專案,會發現無法進入的我們的頁面中,無論我們訪問哪個頁面,都會進入到SpringSecurity為我們提供的一個預設登入頁面,之後我們會講解如何進行配置。

至此,專案環境搭建完成。


認證

直接認證

既然系統要求使用者登入之後才能使用,所以我們首先要做的就是實現使用者驗證,要實現使用者驗證,我們需要進行一些配置:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();  //這裡使用SpringSecurity提供的BCryptPasswordEncoder
    auth
            .inMemoryAuthentication() //直接驗證方式,之後會講解使用資料庫驗證
            .passwordEncoder(encoder) //密碼加密器
            .withUser("test")   //使用者名稱
            .password(encoder.encode("123456"))   //這裡需要填寫加密後的密碼
            .roles("user");   //使用者的角色(之後講解)
}

SpringSecurity的密碼校驗並不是直接使用原文進行比較,而是使用加密演算法將密碼進行加密(更準確地說應該進行Hash處理,此過程是不可逆的,無法解密),最後將使用者提供的密碼以同樣的方式加密後與密文進行比較。

對於我們來說,使用者提供的密碼屬於隱私資訊,直接明文儲存並不好,而且如果資料庫內容被竊取,那麼所有使用者的密碼將全部洩露,這是我們不希望看到的結果,我們需要一種既能隱藏使用者密碼也能完成認證的機制,而Hash處理就是一種很好的解決方案,通過將使用者的密碼進行Hash值計算,計算出來的結果無法還原為原文,如果需要驗證是否與此密碼一致,那麼需要以同樣的方式加密再比較兩個Hash值是否一致,這樣就很好的保證了使用者密碼的安全性。

點選檢視源網頁

我們這裡使用的是SpringSecurity提供的BCryptPasswordEncoder,至於加密過程,這裡不做深入講解。

現在,我們可以嘗試使用此賬號登入,在登入後,就可以隨意訪問我們的網站內容了。

使用資料庫認證

前面我們已經實現了直接認證的方式,那麼如何將其連線到資料庫,通過查詢資料庫中的內容來進行使用者登入呢?

首先我們需要將加密後的密碼新增到資料庫中作為使用者密碼:

public class MainTest {

    @Test
    public void test(){
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode("123456"));
    }
}

這裡編寫一個測試來完成。

然後我們需要建立一個Service實現,實現的是UserDetailsService,它支援我們自己返回一個UserDetails物件,我們只需直接返回一個包含資料庫中的使用者名稱、密碼等資訊的UserDetails即可,SpringSecurity會自動進行比對。

@Service
public class UserAuthService implements UserDetailsService {

    @Resource
    UserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        String password = mapper.getPasswordByUsername(s);  //從資料庫根據使用者名稱獲取密碼
        if(password == null)
            throw new UsernameNotFoundException("登入失敗,使用者名稱或密碼錯誤!");
        return User   //這裡需要返回UserDetails,SpringSecurity會根據給定的資訊進行比對
                .withUsername(s)
                .password(password)   //直接從資料庫取的密碼
                .roles("user")   //使用者角色
                .build();
    }
}

別忘了在配置類中進行掃描,將其註冊為Bean,接著我們需要編寫一個Mapper用於和資料庫互動:

@Mapper
public interface UserMapper {

    @Select("select password from users where username = #{username}")
    String getPasswordByUsername(String username);
}

和之前一樣,配置一下Mybatis和資料來源:

@ComponentScans({
        @ComponentScan("book.manager.service")
})
@MapperScan("book.manager.mapper")
@Configuration
public class RootConfiguration {
    @Bean
    public DataSource dataSource(){
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/study");
        dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        return dataSource;
    }

    @Bean
    public SqlSessionFactoryBean sqlSessionFactoryBean(@Autowired DataSource dataSource){
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        return bean;
    }
}

最後再修改一下Security配置:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth
      .userDetailsService(service)   //使用自定義的Service實現類進行驗證
      .passwordEncoder(new BCryptPasswordEncoder());   //依然使用BCryptPasswordEncoder
}

這樣,登陸就會從資料庫中進行查詢。

自定義登入介面

前面我們已經瞭解瞭如何實現資料庫許可權驗證,那麼現在我們接著來看看,如何將登陸頁面修改為我們自定義的樣式。

首先我們要了解一下SpringSecurity是如何進行登陸驗證的,我們可以觀察一下預設的登陸介面中,表單內有哪些內容:

<div class="container">
      <form class="form-signin" method="post" action="/book_manager/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="">
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required="">
        </p>
<input name="_csrf" type="hidden" value="83421936-b84b-44e3-be47-58bb2c14571a">
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      </form>
</div>

我們發現,首先有一個使用者名稱的輸入框和一個密碼的輸入框,我們需要在其中填寫使用者名稱和密碼,但是我們發現,除了這兩個輸入框以外,還有一個input標籤,它是隱藏的,並且它儲存了一串類似於Hash值的東西,名稱為"_csrf",其實看名字就知道,這玩意八成都是為了防止CSRF攻擊而存在的。

Spring Security 4.0開始,預設情況下會啟用CSRF保護,以防止CSRF攻擊應用程式,Spring Security CSRF會針對PATCH,POST,PUT和DELETE方法的請求(不僅僅只是登陸請求,這裡指的是任何請求路徑)進行防護,而這裡的登陸表單正好是一個POST型別的請求。

在預設配置下,無論是否登陸,頁面中只要發起了PATCH,POST,PUT和DELETE請求一定會被拒絕,並返回403錯誤(注意,這裡是個究極大坑),需要在請求的時候加入csrfToken才行,也就是"83421936-b84b-44e3-be47-58bb2c14571a",正是csrfToken。

如果提交的是表單型別的資料,那麼表單中必須包含此Token字串,鍵名稱為"_csrf";如果是JSON資料格式傳送的,那麼就需要在請求頭中包含此Token字串。

綜上所述,我們最後提交的登陸表單,除了必須的使用者名稱和密碼,還包含了一個csrfToken字串用於驗證,防止攻擊。

因此,我們在編寫自己的登陸頁面時,需要新增這樣一個輸入框:

<input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>

隱藏即可,但是必須要有,而Token的鍵名稱和Token字串可以通過Thymeleaf從Model中獲取,SpringSecurity會自動將Token資訊新增到Model中。

接著我們就可以將我們自己的頁面替換掉預設的頁面了,我們需要重寫另一個方法來實現:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()   //首先需要配置哪些請求會被攔截,哪些請求必須具有什麼角色才能訪問
        .antMatchers("/static/**").permitAll()    //靜態資源,使用permitAll來執行任何人訪問(注意一定要放在前面)
        .antMatchers("/**").hasRole("user")     //所有請求必須登陸並且是user角色才可以訪問(不包含上面的靜態資源)
}

首先我們需要配置攔截規則,也就是當使用者未登入時,哪些路徑可以訪問,哪些路徑不可以訪問,如果不可以訪問,那麼會被自動重定向到登陸頁面。

接著我們需要配置表單登陸和登入頁面:

.formLogin()       //配置Form表單登陸
.loginPage("/login")       //登陸頁面地址(GET)
.loginProcessingUrl("/doLogin")    //form表單提交地址(POST)
.defaultSuccessUrl("/index")    //登陸成功後跳轉的頁面,也可以通過Handler實現高度自定義
.permitAll()    //登陸頁面也需要允許所有人訪問

需要配置登陸頁面的地址和登陸請求傳送的地址,這裡登陸頁面填寫為/login,登陸請求地址為/doLogin,登陸頁面需要我們自己去編寫Controller來實現,登陸請求提交處理由SpringSecurity提供,只需要寫路徑就可以了。

@RequestMapping("/login")
public String login(){
    return "login";
}

配置好後,我們還需要配置一下退出登陸操作:

.and()
.logout()
.logoutUrl("/logout")    //退出登陸的請求地址
.logoutSuccessUrl("/login");    //退出後重定向的地址

注意這裡的退出登陸請求也必須是POST請求方式(因為開啟了CSFR防護,需要新增Token),否則無法訪問,這裡主頁面就這樣寫:

<body>
    <form action="logout" method="post">
        <input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
        <button>退出登陸</button>
    </form>
</body>
</html>

登陸成功後,點選退出登陸按鈕,就可以成功退出並回到登陸介面了。

由於我們在學習的過程中暫時用不到CSFR防護,因此可以將其關閉,這樣直接使用get請求也可以退出登陸,並且登陸請求中無需再攜帶Token了,推薦關閉,因為不關閉後面可能會因為沒考慮CSRF防護而遇到一連串的問題:

.and()
.csrf().disable();

這樣就可以直接關閉此功能了,但是注意,這樣將會導致您的Web網站存在安全漏洞。


授權

使用者登入後,可能會根據使用者當前是身份進行角色劃分。

比如我們最常用的QQ,一個QQ群裡面,有群主、管理員和普通群成員三種角色,其中群主具有最高許可權,群主可以管理整個群的任何板塊,並且具有解散和升級群的資格,而管理員只有群主的一部分許可權,只能用於日常管理,普通群成員則只能進行最基本的聊天操作。

對於我們來說,使用者的一個操作實際上就是在訪問我們提供的介面(編寫的對應訪問路徑的Servlet),比如登陸,就需要呼叫/login介面,退出登陸就要呼叫/logout介面。

因此,從我們開發者的角度來說,決定使用者能否使用某個功能,只需要決定使用者是否能夠訪問對應的Servlet即可。

我們可以大致像下面這樣進行劃分:

  • 群主:/login/logout/chat/edit/delete/upgrade
  • 管理員:/login/logout/chat/edit
  • 普通群成員:/login/logout/chat

也就是說,我們需要做的就是指定哪些請求可以由哪些使用者發起。

SpringSecurity為我們提供了兩種授權方式:

  • 基於許可權的授權:只要擁有某許可權的使用者,就可以訪問某個路徑
  • 基於角色的授權:根據使用者屬於哪個角色來決定是否可以訪問某個路徑

兩者只是概念上的不同,實際上使用起來效果差不多。這裡我們就先演示以角色方式來進行授權。

基於角色的授權

現在我們希望建立兩個角色,普通使用者和管理員,普通使用者只能訪問index頁面,而管理員可以訪問任何頁面。

首先我們需要對資料庫中的角色表進行一些修改,新增一個使用者角色欄位,並建立一個新的使用者,Test使用者的角色為user,而Admin使用者的角色為admin。

接著我們需要配置SpringSecurity,決定哪些角色可以訪問哪些頁面:

http.authorizeRequests()
    .antMatchers("/static/**").permitAll()
  	.antMatchers("/index").hasAnyRole("user", "admin")   //index頁面可以由user或admin訪問
    .anyRequest().hasRole("admin")   //除了上面以外的所有內容,只能是admin訪問

接著我們需要稍微修改一下驗證邏輯,首先建立一個實體類用於表示資料庫中的使用者名稱、密碼和角色:

@Data
public class AuthUser {
    String username;
    String password;
    String role;
}

接著修改一下Mapper:

@Mapper
public interface UserMapper {

    @Select("select * from users where username = #{username}")
    AuthUser getPasswordByUsername(String username);
}

最後再修改一下Service:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    AuthUser user = mapper.getPasswordByUsername(s);
    if(user == null)
        throw new UsernameNotFoundException("登入失敗,使用者名稱或密碼錯誤!");
    return User
            .withUsername(user.getUsername())
            .password(user.getPassword())
            .roles(user.getRole())
            .build();
}

現在我們可以嘗試登陸,接著訪問一下/index/admin兩個頁面。

基於許可權的授權

基於許可權的授權與角色類似,需要以hasAnyAuthorityhasAuthority進行判斷:

.anyRequest().hasAnyAuthority("page:index")
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    AuthUser user = mapper.getPasswordByUsername(s);
    if(user == null)
        throw new UsernameNotFoundException("登入失敗,使用者名稱或密碼錯誤!");
    return User
            .withUsername(user.getUsername())
            .password(user.getPassword())
            .authorities("page:index")
            .build();
}

使用註解判斷許可權

除了直接配置以外,我們還可以以註解形式直接配置,首先需要在配置類(注意這裡是在MVC的配置類上新增,因為這裡只針對Controller進行過濾,所有的Controller是由MVC配置類進行註冊的,如果需要為Service或其他Bean也啟用許可權判斷,則需要在Security的配置類上新增)上開啟:

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

接著我們可以直接在需要新增許可權驗證的請求對映上新增註解:

@PreAuthorize("hasRole('user')")   //判斷是否為user角色,只有此角色才可以訪問
@RequestMapping("/index")
public String index(){
    return "index";
}

通過新增@PreAuthorize註解,在執行之前判斷判斷許可權,如果沒有對應的許可權或是對應的角色,將無法訪問頁面。

這裡其實是使用了SpEL表示式,相當於可以執行一些邏輯再得到結果,而不是直接傳值,官方文件地址:https://docs.spring.io/spring-framework/docs/5.2.13.RELEASE/spring-framework-reference/core.html#expressions,內容比較多,不是重點,這裡就不再詳細介紹了。

同樣的還有@PostAuthorize註解,但是它是在方法執行之後再進行攔截:

@PostAuthorize("hasRole('user')")
@RequestMapping("/index")
public String index(){
    System.out.println("執行了");
    return "index";
}

除了Controller以外,只要是由Spring管理的Bean都可以使用註解形式來控制許可權,只要不具備訪問許可權,那麼就無法執行方法並且會返回403頁面。

@Service
public class UserService {

    @PreAuthorize("hasAnyRole('user')")
    public void test(){
        System.out.println("成功執行");
    }
}

注意Service是由根容器進行註冊,需要在Security配置類上新增@EnableGlobalMethodSecurity註解才可以生效。與具有相同功能的還有@Secure但是它不支援SpEL表示式的許可權表示形式,並且需要新增"ROLE_"字首,這裡就不做演示了。

我們還可以使用@PreFilter@PostFilter對集合型別的引數或返回值進行過濾。

比如:

@PreFilter("filterObject.equals('lbwnb')")   //filterObject代表集合中每個元素,只要滿足條件的元素才會留下
public void test(List<String> list){
    System.out.println("成功執行"+list);
}
@RequestMapping("/index")
public String index(){
    List<String> list = new LinkedList<>();
    list.add("lbwnb");
    list.add("yyds");
    service.test(list);
    return "index";
}

@PreFilter類似的@PostFilter這裡就不做演示了,它用於處理返回值,使用方法是一樣的。

當有多個集合時,需要使用filterTarget進行指定:

@PreFilter(value = "filterObject.equals('lbwnb')", filterTarget = "list2")
public void test(List<String> list, List<String> list2){
    System.out.println("成功執行"+list);
}

記住我

我們的網站還有一個重要的功能,就是記住我,也就是說我們可以在登陸之後的一段時間內,無需再次輸入賬號和密碼進行登陸,相當於服務端已經記住當前使用者,再次訪問時就可以免登陸進入,這是一個非常常用的功能。

我們之前在JavaWeb階段,使用本地Cookie儲存的方式實現了記住我功能,但是這種方式並不安全,同時在程式碼編寫上也比較麻煩,那麼能否有一種更加高效的記住我功能實現呢?

SpringSecurity為我們提供了一種優秀的實現,它為每個已經登陸的瀏覽器分配一個攜帶Token的Cookie,並且此Cookie預設會被保留14天,只要我們不清理瀏覽器的Cookie,那麼下次攜帶此Cookie訪問伺服器將無需登陸,直接繼續使用之前登陸的身份,這樣顯然比我們之前的寫法更加簡便。並且我們需要進行簡單配置,即可開啟記住我功能:

.and()
.rememberMe()   //開啟記住我功能
.rememberMeParameter("remember")  //登陸請求表單中需要攜帶的引數,如果攜帶,那麼本次登陸會被記住
.tokenRepository(new InMemoryTokenRepositoryImpl())  //這裡使用的是直接在記憶體中儲存的TokenRepository實現
  //TokenRepository有很多種實現,InMemoryTokenRepositoryImpl直接基於Map實現的,缺點就是佔記憶體、伺服器重啟後記住我功能將失效
  //後面我們還會講解如何使用資料庫來持久化儲存Token資訊

接著我們需要在前端修改一下記住我勾選框的名稱,將名稱修改與上面一致,如果上面沒有配置名稱,那麼預設使用"remember-me"作為名稱:

<input type="checkbox" name="remember" class="ad-checkbox">

現在我們啟動伺服器,在登陸時勾選記住我勾選框,觀察Cookie的變化。

雖然現在已經可以實現記住我功能了,但是還有一定的缺陷,如果伺服器重新啟動(因為Token資訊全部存在HashMap中,也就是存在記憶體中),那麼所有記錄的Token資訊將全部丟失,這時即使瀏覽器攜帶了之前的Token也無法恢復之前登陸的身份。

我們可以將Token資訊記錄全部存放到資料庫中(學習了Redis之後還可以放到Redis伺服器中)利用資料庫的持久化儲存機制,即使伺服器重新啟動,所有的Token資訊也不會丟失,配置資料庫儲存也很簡單:

@Resource
PersistentTokenRepository repository;

@Bean
public PersistentTokenRepository jdbcRepository(@Autowired DataSource dataSource){
    JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();  //使用基於JDBC的實現
    repository.setDataSource(dataSource);   //配置資料來源
  	repository.setCreateTableOnStartup(true);   //啟動時自動建立用於儲存Token的表(建議第一次啟動之後刪除該行)
    return repository;
}
.and()
.rememberMe()
.rememberMeParameter("remember")
.tokenRepository(repository)
.tokenValiditySeconds(60 * 60 * 24 * 7)  //Token的有效時間(秒)預設為14天

稍後伺服器啟動我們可以觀察一下資料庫,如果出現名為persistent_logins的表,那麼證明配置沒有問題。

當我們登陸並勾選了記住我之後,那麼資料庫中會新增一條Token記錄。


SecurityContext

使用者登入之後,怎麼獲取當前已經登入使用者的資訊呢?

通過使用SecurityContextHolder就可以很方便地得到SecurityContext物件了,我們可以直接使用SecurityContext物件來獲取當前的認證資訊:

@RequestMapping("/index")
    public String index(){
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        User user = (User) authentication.getPrincipal();
        System.out.println(user.getUsername());
        System.out.println(user.getAuthorities());
        return "index";
    }

通過SecurityContext我們就可以快速獲取當前使用者的名稱和授權資訊等。

除了這種方式以外,我們還可以直接從Session中獲取:

@RequestMapping("/index")
public String index(@SessionAttribute("SPRING_SECURITY_CONTEXT") SecurityContext context){
    Authentication authentication = context.getAuthentication();
    User user = (User) authentication.getPrincipal();
    System.out.println(user.getUsername());
    System.out.println(user.getAuthorities());
    return "index";
}

注意SecurityContextHolder是有一定的儲存策略的,SecurityContextHolder中的SecurityContext物件會在一開始請求到來時被設定,至於儲存方式其實是由儲存策略決定的,如果我們這樣編寫,那麼在預設情況下是無法獲取到認證資訊的:

@RequestMapping("/index")
public String index(){
    new Thread(() -> {   //建立一個子執行緒去獲取
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        User user = (User) authentication.getPrincipal();   //NPE
        System.out.println(user.getUsername());
        System.out.println(user.getAuthorities()); 
    });
    return "index";
}

這是因為SecurityContextHolder的儲存策略預設是MODE_THREADLOCAL,它是基於ThreadLocal實現的,getContext()方法本質上呼叫的是對應的儲存策略實現的方法:

public static SecurityContext getContext() {
    return strategy.getContext();
}

SecurityContextHolderStrategy有三個實現類:

  • GlobalSecurityContextHolderStrategy:全域性模式,不常用
  • ThreadLocalSecurityContextHolderStrategy:基於ThreadLocal實現,執行緒內可見
  • InheritableThreadLocalSecurityContextHolderStrategy:基於InheritableThreadLocal實現,執行緒和子執行緒可見

因此,如果上述情況需要在子執行緒中獲取,那麼需要修改SecurityContextHolder的儲存策略,在初始化的時候設定:

@PostConstruct
public void init(){
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}

這樣在子執行緒中也可以獲取認證資訊了。

因為使用者的驗證資訊是基於SecurityContext進行判斷的,我們可以直接修改SecurityContext的內容,來手動為使用者進行登陸:

@RequestMapping("/auth")
@ResponseBody
public String auth(){
    SecurityContext context = SecurityContextHolder.getContext();  //獲取SecurityContext物件(當前會話肯定是沒有登陸的)
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("Test", null,
            AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_user"));  //手動建立一個UsernamePasswordAuthenticationToken物件,也就是使用者的認證資訊,角色需要新增ROLE_字首,許可權直接寫
    context.setAuthentication(token);  //手動為SecurityContext設定認證資訊
    return "Login success!";
}

在未登陸的情況下,訪問此地址將直接進行手動登陸,再次訪問/index頁面,可以直接訪問,說明手動設定認證資訊成功。

疑惑:SecurityContext這玩意不是預設執行緒獨佔嗎,那每次請求都是一個新的執行緒,按理說上一次的SecurityContext物件應該沒了才對啊,為什麼再次請求依然能夠繼續使用上一次SecurityContext中的認證資訊呢?

SecurityContext的生命週期:請求到來時從Session中取出,放入SecurityContextHolder中,請求結束時從SecurityContextHolder取出,並放到Session中,實際上就是依靠Session來儲存的,一旦會話過期驗證資訊也跟著消失。


SpringSecurity原理

最後我們再來聊一下SpringSecurity的實現原理,它本質上是依靠N個Filter實現的,也就是一個完整的過濾鏈(注意這裡是過濾器,不是攔截器)

我們就從AbstractSecurityWebApplicationInitializer開始下手,我們來看看它配置了什麼:

//此方法會在啟動時被呼叫
public final void onStartup(ServletContext servletContext) {
    this.beforeSpringSecurityFilterChain(servletContext);
    if (this.configurationClasses != null) {
        AnnotationConfigWebApplicationContext rootAppContext = new AnnotationConfigWebApplicationContext();
        rootAppContext.register(this.configurationClasses);
        servletContext.addListener(new ContextLoaderListener(rootAppContext));
    }

    if (this.enableHttpSessionEventPublisher()) {
        servletContext.addListener("org.springframework.security.web.session.HttpSessionEventPublisher");
    }

    servletContext.setSessionTrackingModes(this.getSessionTrackingModes());
  	//重點在這裡,這裡插入了關鍵的FilterChain
    this.insertSpringSecurityFilterChain(servletContext);
    this.afterSpringSecurityFilterChain(servletContext);
}
private void insertSpringSecurityFilterChain(ServletContext servletContext) {
    String filterName = "springSecurityFilterChain";
  	//建立了一個DelegatingFilterProxy物件,它本質上也是一個Filter
    DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(filterName);
    String contextAttribute = this.getWebApplicationContextAttribute();
    if (contextAttribute != null) {
        springSecurityFilterChain.setContextAttribute(contextAttribute);
    }
		//通過ServletContext註冊DelegatingFilterProxy這個Filter
    this.registerFilter(servletContext, true, filterName, springSecurityFilterChain);
}

我們接著來看看,DelegatingFilterProxy在做什麼:

//這個是初始化方法,它由GenericFilterBean(父類)定義,在afterPropertiesSet方法中被呼叫
protected void initFilterBean() throws ServletException {
    synchronized(this.delegateMonitor) {
        if (this.delegate == null) {
            if (this.targetBeanName == null) {
                this.targetBeanName = this.getFilterName();
            }

            WebApplicationContext wac = this.findWebApplicationContext();
            if (wac != null) {
              	//耐心點,套娃很正常
                this.delegate = this.initDelegate(wac);
            }
        }

    }
}
protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
    String targetBeanName = this.getTargetBeanName();
    Assert.state(targetBeanName != null, "No target bean name set");
  	//這裡通過WebApplicationContext獲取了一個Bean
    Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);
    if (this.isTargetFilterLifecycle()) {
        delegate.init(this.getFilterConfig());
    }

  	//返回Filter
    return delegate;
}

這裡我們需要新增一個斷點來檢視到底獲取到了什麼Bean。

通過斷點除錯,我們發現這裡放回的物件是一個FilterChainProxy型別的,並且呼叫了它的初始化方法,但是FilterChainProxy類中並沒有重寫init方法或是initFilterBean方法。

我們倒回去看,當Filter返回之後,DelegatingFilterProxy的一個成員變數delegate被賦值為得到的Filter,也就是FilterChainProxy物件,接著我們來看看,DelegatingFilterProxy是如何執行doFilter方法的。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    Filter delegateToUse = this.delegate;
    if (delegateToUse == null) {
        //非正常情況,這裡省略...
    }
		//這裡才是真正的呼叫,別忘了delegateToUse就是初始化的FilterChainProxy物件
    this.invokeDelegate(delegateToUse, request, response, filterChain);
}
protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
  //最後實際上呼叫的是FilterChainProxy的doFilter方法
    delegate.doFilter(request, response, filterChain);
}

所以我們接著來看,FilterChainProxy的doFilter方法又在幹什麼:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
    if (!clearContext) {
      	//真正的過濾在這裡執行
        this.doFilterInternal(request, response, chain);
    } else {
        //...
    }
}
private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);
    HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response);
  	//這裡獲取了一個Filter列表,實際上SpringSecurity就是由N個過濾器實現的,這裡獲取的都是SpringSecurity提供的過濾器
  	//但是請注意,經過我們之前的分析,實際上真正註冊的Filter只有DelegatingFilterProxy
  	//而這裡的Filter列表中的所有Filter並沒有被註冊,而是在這裡進行內部呼叫
    List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);
  	//只要Filter列表不是空,就依次執行內建的Filter
    if (filters != null && filters.size() != 0) {
        if (logger.isDebugEnabled()) {
            logger.debug(LogMessage.of(() -> {
                return "Securing " + requestLine(firewallRequest);
            }));
        }
				//這裡建立一個虛擬的過濾鏈,過濾流程是由SpringSecurity自己實現的
        FilterChainProxy.VirtualFilterChain virtualFilterChain = new FilterChainProxy.VirtualFilterChain(firewallRequest, chain, filters);
      	//呼叫虛擬過濾鏈的doFilter
        virtualFilterChain.doFilter(firewallRequest, firewallResponse);
    } else {
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.of(() -> {
                return "No security for " + requestLine(firewallRequest);
            }));
        }

        firewallRequest.reset();
        chain.doFilter(firewallRequest, firewallResponse);
    }
}

我們來看一下虛擬過濾鏈的doFilter是怎麼處理的:

//看似沒有任何迴圈,實際上就是一個迴圈,是一個遞迴呼叫
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
  	//判斷是否已經通過全部的內建過濾器,定位是否等於當前大小
    if (this.currentPosition == this.size) {
        if (FilterChainProxy.logger.isDebugEnabled()) {
            FilterChainProxy.logger.debug(LogMessage.of(() -> {
                return "Secured " + FilterChainProxy.requestLine(this.firewalledRequest);
            }));
        }

        this.firewalledRequest.reset();
      	//所有的內建過濾器已經完成,按照正常流程走DelegatingFilterProxy的下一個Filter
      	//也就是說這裡之後就與DelegatingFilterProxy沒有任何關係了,該走其他過濾器就走其他地方配置的過濾器,SpringSecurity的過濾操作已經結束
        this.originalChain.doFilter(request, response);
    } else {
      	//定位自增
        ++this.currentPosition;
      	//獲取當前定位的Filter
        Filter nextFilter = (Filter)this.additionalFilters.get(this.currentPosition - 1);
        if (FilterChainProxy.logger.isTraceEnabled()) {
            FilterChainProxy.logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), this.currentPosition, this.size));
        }
				//執行內部過濾器的doFilter方法,傳入當前物件本身作為Filter,執行如果成功,那麼一定會再次呼叫當前物件的doFilter方法
      	//可能最不理解的就是這裡,執行的難道不是內部其他Filter的doFilter方法嗎,怎麼會讓當前物件的doFilter方法遞迴呼叫呢?
      	//沒關係,瞭解了其中一個內部過濾器就明白了
        nextFilter.doFilter(request, response, this);
    }
}

因此,我們差不多已經瞭解了整個SpringSecurity的實現機制了,那麼我們來看幾個內部的過濾器分別在做什麼。

比如用於處理登陸的過濾器UsernamePasswordAuthenticationFilter,它繼承自AbstractAuthenticationProcessingFilter,我們來看看它是怎麼進行過濾的:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
}

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
  	//如果不是登陸請求,那麼根本不會理這個請求
    if (!this.requiresAuthentication(request, response)) {
      	//直接呼叫傳入的FilterChain的doFilter方法
      	//而這裡傳入的正好是VirtualFilterChain物件
      	//這下知道為什麼上面說是遞迴了吧
        chain.doFilter(request, response);
    } else {
      	//如果是登陸請求,那麼會執行登陸請求的相關邏輯,注意執行過程中出現任何問題都會丟擲異常
      	//比如使用者名稱和密碼錯誤,我們之前也已經測試過了,會得到一個BadCredentialsException
        try {
          	//進行認證
            Authentication authenticationResult = this.attemptAuthentication(request, response);
            if (authenticationResult == null) {
                return;
            }

            this.sessionStrategy.onAuthentication(authenticationResult, request, response);
            if (this.continueChainBeforeSuccessfulAuthentication) {
                chain.doFilter(request, response);
            }

          	//如果一路綠燈,沒有報錯,那麼驗證成功,執行successfulAuthentication
            this.successfulAuthentication(request, response, chain, authenticationResult);
        } catch (InternalAuthenticationServiceException var5) {
            this.logger.error("An internal error occurred while trying to authenticate the user.", var5);
          	//驗證失敗,會執行unsuccessfulAuthentication
            this.unsuccessfulAuthentication(request, response, var5);
        } catch (AuthenticationException var6) {
            this.unsuccessfulAuthentication(request, response, var6);
        }

    }
}

那麼我們來看看successfulAuthentication和unsuccessfulAuthentication分別做了什麼:

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
  	//向SecurityContextHolder新增認證資訊,我們可以通過SecurityContextHolder物件獲取當前登陸的使用者
    SecurityContextHolder.getContext().setAuthentication(authResult);
    if (this.logger.isDebugEnabled()) {
        this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
    }

  	//記住我實現
    this.rememberMeServices.loginSuccess(request, response, authResult);
    if (this.eventPublisher != null) {
        this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
    }
		
  	//呼叫預設的或是我們自己定義的AuthenticationSuccessHandler的onAuthenticationSuccess方法
  	//這個根據我們配置檔案決定
  	//到這裡其實頁面就已經直接跳轉了
    this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
  	//登陸失敗會直接清理掉SecurityContextHolder中的認證資訊
    SecurityContextHolder.clearContext();
    this.logger.trace("Failed to process authentication request", failed);
    this.logger.trace("Cleared SecurityContextHolder");
    this.logger.trace("Handling authentication failure");
  	//登陸失敗的記住我處理
    this.rememberMeServices.loginFail(request, response);
  	//同上,呼叫預設或是我們自己定義的AuthenticationFailureHandler
    this.failureHandler.onAuthenticationFailure(request, response, failed);
}

瞭解了整個使用者驗證實現流程,其實其它的過濾器是如何實現的也就很容易聯想到了。

SpringSecurity的過濾器從某種意義上來說,更像是一個處理業務的Servlet,它做的事情不像是攔截,更像是完成自己對應的職責,只不過是使用了過濾器機制進行實現罷了。

SecurityContextPersistenceFilter也是內建的Filter,可以嘗試閱讀一下其原始碼,瞭解整個SecurityContextHolder的運作原理,這裡先說一下大致流程,各位可以依照整個流程按照原始碼進行推導:

當過濾器鏈執行到SecurityContextPersistenceFilter時,

它會從HttpSession中把SecurityContext物件取出來(是存在Session中的,跟隨會話的消失而消失),

然後放入SecurityContextHolder物件中。

請求結束後,再把SecurityContext存入HttpSession中,並清除SecurityContextHolder內的SecurityContext物件。

相關文章