Spring Security 入門原理及實戰


在web應用開發中,安全無疑是十分重要的,選擇Spring Security來保護web應用是一個非常好的選擇。Spring Security 是spring項目之中的一個安全模塊,可以非常方便與spring項目無縫集成。特別是在spring boot項目中加入spring security更是十分簡單。本篇我們介紹spring security,以及spring security在web應用中的使用。

說明:本文最初發表之時,使用的是springboot1.x的版本,springboot2.x的版本之后有些不同了,所以。2019年12月18有稍微補充了一點。同時也補充了可以運行的實例源碼,基於springboot 2.1.8.RELEASE。

從一個Spring Security的例子開始

創建不受保護的應用

假設我們現在創建好了一個springboot的web應用,如果沒有的話,在這里下載代碼 https://github.com/xudeming/spring-security-demo(springboot2.x),有一個控制器如下:

@Controller
public class AppController {

    @RequestMapping("/hello")
    @ResponseBody
    String home() {
        return "Hello ,spring security!";
    }
}    

我們啟動應用,假設端口是8080,那么當我們在瀏覽器訪問http://localhost:8080/hello的時候可以在瀏覽器看到Hello ,spring security!

加入spring security 保護應用

此時,/hello是可以自由訪問。假設,我們需要具有某個角色的用戶才能訪問的時候,我們可以引入spring security來進行保護。加入如下依賴,並重啟應用:

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

再次訪問/hello,我們可以得到一個http-basic的認證彈窗,如下:

http basic認證窗口

說明spring security 已經起作用了。如果我們點擊取消,則會看到錯誤信息,如下所示:

There was an unexpected error (type=Unauthorized, status=401).

2019年12月補充說明:升級springboot到2.x之后,沒有這個http-batic的彈窗了,這個后面說。

關閉security.basic ,使用form表單頁面登錄

我們在實際項目中不可能會使用,上面http-basic方式的彈窗來讓用戶完成登錄,而是會有一個登錄頁面。所以,我們需要關閉http-basic的方式,關閉http-basic方式的認證彈窗的配置如下:

security.basic.enabled=false

spring security 默認提供了表單登錄的功能。我們新建一個類SecurityConfiguration,並加入一些代碼,如下所示:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.authorizeRequests()
				.anyRequest().authenticated()
				.and()
				.formLogin().and()
				.httpBasic();
	}
}

上面的代碼其實就是 一種配置,authorizeRequests() 定義哪些URL需要被保護、哪些不需要被保護。 formLogin() 定義當需要用戶登錄時候,轉到的登錄頁面。此時,我們並沒有寫登錄頁面,但是spring security默認提供了一個登錄頁面,以及登錄控制器。

加完了上面的配置類之后,我們重啟應用。然后繼續訪問http://localhost:8080/hello。會發現自動跳轉到一個登錄頁面了,如下所示:

login form

升級springboot到2.x之后,這個頁面變漂亮了,是這樣的:

這個頁面是spring security 提供的默認的登錄頁面,其的html內容如下:

<html><head><title>Login Page</title></head><body onload='document.f.username.focus();'>
<h3>Login with Username and Password</h3><form name='f' action='/login' method='POST'>
<table>
	<tr><td>User:</td><td><input type='text' name='username' value=''></td></tr>
	<tr><td>Password:</td><td><input type='password' name='password'/></td></tr>
	<tr><td colspan='2'><input name="submit" type="submit" value="Login"/></td></tr>
	<input name="_csrf" type="hidden" value="635780a5-6853-4fcd-ba14-77db85dbd8bd" />
</table>
</form></body></html>

我們可以發現,這里有個form 。action="/login",這個/login依然是spring security提供的。form表單提交了三個數據:

  • username 用戶名
  • password 密碼
  • _csrf CSRF保護方面的內容,暫時先不展開解釋

為了登錄系統,我們需要知道用戶名密碼,spring security 默認的用戶名是user,spring security啟動的時候會生成默認密碼(在啟動日志中可以看到)。本例,我們指定一個用戶名密碼,在配置文件中加入如下內容:

# security
security.basic.enabled=false
security.user.name=admin
security.user.password=admin

重啟項目,訪問被保護的/hello頁面。自動跳轉到了spring security 默認的登錄頁面,我們輸入用戶名admin密碼admin。點擊Login按鈕。會發現登錄成功並跳轉到了/hello。除了登錄,spring security還提供了rememberMe功能,這里不做過多解釋。

補充說明:springboot2.x之后,上面的配置security.user.name會提示錯誤Deprecated configuration property 'security.user.password ,因為springboot2.x之后,spring security升級了
刪了一些配置,如果使用的舊的,需要遷移的話,可以參考Spring-Boot-2.0-Migration-Guide https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.0-Migration-Guide#security

角色-資源 訪問控制

通常情況下,我們需要實現“特定資源只能由特定角色訪問”的功能。假設我們的系統有如下兩個角色:

  • ADMIN 可以訪問所有資源
  • USER 只能訪問特定資源

現在我們給系統增加“/product” 代表商品信息方面的資源(USER可以訪問);增加"/admin"代碼管理員方面的資源(USER不能訪問)。代碼如下:

@Controller
@RequestMapping("/product")
public class ProductTestController {

	@RequestMapping("/info")
	@ResponseBody
	public String productInfo(){
		return " some product info ";
	}
}
-------------------------------------------
@Controller
@RequestMapping("/admin")
public class AdminTestController {

	@RequestMapping("/home")
	@ResponseBody
	public String productInfo(){
		return " admin home page ";
	}
}

在正式的應用中,我們的用戶和角色是保存在數據庫中的;本例為了方便演示,我們來創建兩個存放於內存的用戶和角色。我們在上一步中創建的SecurityConfiguration中增加角色用戶,如下代碼:

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth
				.inMemoryAuthentication()
				.withUser("admin1") // 管理員,同事具有 ADMIN,USER權限,可以訪問所有資源
					.password("admin1")
					.roles("ADMIN", "USER")
					.and()
				.withUser("user1").password("user1") // 普通用戶,只能訪問 /product/**
					.roles("USER");
	}

這里,我們增加了 管理員(admin1,密碼admin1),以及普通用戶(user1,密碼user1)

補充說明:上面的代碼是springboot1.x的,springboot2.x之后,上面的配置就得稍作調整了,否者會報錯java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

在springboot2.x的版本中上面的代碼會調整車這樣:

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth
				.inMemoryAuthentication()
				.withUser("admin1") // 管理員,同事具有 ADMIN,USER權限,可以訪問所有資源
					.password("{noop}admin1")  // 
					.roles("ADMIN", "USER")
					.and()
				.withUser("user1").password("{noop}user1") // 普通用戶,只能訪問 /product/**
					.roles("USER");
	}

變化的地方在:.password("{noop}admin1") 這里。

繼續增加“鏈接-角色”控制配置,代碼如下:

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

這個配置在上一步中登錄配置的基礎上增加了鏈接對應的角色配置。上面的配置,我們可以知道:

  • 使用 user1 登錄,只能訪問/product/**
  • 使用 admin1登錄,可以訪問所有。

下面來驗證一下普通用戶登錄,重啟項目,在瀏覽器中輸入:http://localhost:8080/admin/home。同樣,我們會到達登錄頁面,我們輸入用戶名user1,密碼也為user1 結果錯誤頁面了,拒絕訪問了,信息為:

There was an unexpected error (type=Forbidden, status=403).
Access is denied

我們把瀏覽器中的uri修改成:/product/info,結果訪問成功。可以看到some product info。說明 user1只能訪問 product/** ,這個結果與我們預期一致。

再來驗證一下管理員用戶登錄,重啟瀏覽器之后,輸入http://localhost:8080/admin/home。在登錄頁面中輸入用戶名admin1,密碼admin1,提交之后,可以看到admin home page ,說明訪問管理員資源了。我們再將瀏覽器uri修改成/product/info,刷新之后,也能看到some product info,說明 admin1用戶可以訪問所有資源,這個也和我們的預期一致。

獲取當前登錄用戶信息

上面我們實現了“資源 - 角色”的訪問控制,效果和我們預期的一致,但是並不直觀,我們不妨嘗試在控制器中獲取“當前登錄用戶”的信息,直接輸出,看看效果。以/product/info為例,我們修改其代碼,如下:

	@RequestMapping("/info")
	@ResponseBody
	public String productInfo(){
		String currentUser = "";
		Object principl = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
		if(principl instanceof UserDetails) {
			currentUser = ((UserDetails)principl).getUsername();
		}else {
			currentUser = principl.toString();
		}
		return " some product info,currentUser is: "+currentUser;
	}

這里,我們通過SecurityContextHolder來獲取了用戶信息,並拼接成字符串輸出。重啟項目,在瀏覽器訪問http://localhost:8080/product/info. 使用 admin1的身份登錄,可以看到瀏覽器顯示some product info,currentUser is: admin1.

小結

至此,我們已經對spring security有了一個基本的認識了。了解了如何在項目中加入spring security,以及如何控制資源的角色訪問控制。spring security原不止這么簡單,我們才剛剛開始。為了能夠更好的在實戰中使用spring security 我們需要更深入的了解。下面我們先來了解spring security的一些核心概念。

Spring Security 核心組件

spring security核心組件有:SecurityContext、SecurityContextHolder、Authentication、Userdetails 和 AuthenticationManager,下面分別介紹。

SecurityContext

安全上下文,用戶通過Spring Security 的校驗之后,驗證信息存儲在SecurityContext中,SecurityContext的接口定義如下:

public interface SecurityContext extends Serializable {
	/**
	 * Obtains the currently authenticated principal, or an authentication request token.
	 *
	 * @return the <code>Authentication</code> or <code>null</code> if no authentication
	 * information is available
	 */
	Authentication getAuthentication();

	/**
	 * Changes the currently authenticated principal, or removes the authentication
	 * information.
	 *
	 * @param authentication the new <code>Authentication</code> token, or
	 * <code>null</code> if no further authentication information should be stored
	 */
	void setAuthentication(Authentication authentication);
}

可以看到SecurityContext接口只定義了兩個方法,實際上其主要作用就是獲取Authentication對象。

SecurityContextHolder

SecurityContextHolder看名知義,是一個holder,用來hold住SecurityContext實例的。在典型的web應用程序中,用戶登錄一次,然后由其會話ID標識。服務器緩存持續時間會話的主體信息。在Spring Security中,在請求之間存儲SecurityContext的責任落在SecurityContextPersistenceFilter上,默認情況下,該上下文將上下文存儲為HTTP請求之間的HttpSession屬性。它會為每個請求恢復上下文SecurityContextHolder,並且最重要的是,在請求完成時清除SecurityContextHolder。SecurityContextHolder是一個類,他的功能方法都是靜態的(static)。

SecurityContextHolder可以設置指定JVM策略(SecurityContext的存儲策略),這個策略有三種:

  • MODE_THREADLOCAL:SecurityContext 存儲在線程中。
  • MODE_INHERITABLETHREADLOCAL:SecurityContext 存儲在線程中,但子線程可以獲取到父線程中的 SecurityContext。
  • MODE_GLOBAL:SecurityContext 在所有線程中都相同。

SecurityContextHolder默認使用MODE_THREADLOCAL模式,即存儲在當前線程中。在spring security應用中,我們通常能看到類似如下的代碼:

 SecurityContextHolder.getContext().setAuthentication(token);

其作用就是存儲當前認證信息。

Authentication

authentication 直譯過來是“認證”的意思,在Spring Security 中Authentication用來表示當前用戶是誰,一般來講你可以理解為authentication就是一組用戶名密碼信息。Authentication也是一個接口,其定義如下:

public interface Authentication extends Principal, Serializable {
 
	Collection<? extends GrantedAuthority> getAuthorities();
	Object getCredentials();
	Object getDetails();
	Object getPrincipal();
	boolean isAuthenticated();
	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

接口有4個get方法,分別獲取

  • Authorities, 填充的是用戶角色信息。
  • Credentials,直譯,證書。填充的是密碼。
  • Details ,用戶信息。
  • ,Principal 直譯,形容詞是“主要的,最重要的”,名詞是“負責人,資本,本金”。感覺很別扭,所以,還是不翻譯了,直接用原詞principal來表示這個概念,其填充的是用戶名。

因此可以推斷其實現類有這4個屬性。這幾個方法作用如下:

  • getAuthorities: 獲取用戶權限,一般情況下獲取到的是用戶的角色信息。
  • getCredentials: 獲取證明用戶認證的信息,通常情況下獲取到的是密碼等信息。
  • getDetails: 獲取用戶的額外信息,(這部分信息可以是我們的用戶表中的信息)
  • getPrincipal: 獲取用戶身份信息,在未認證的情況下獲取到的是用戶名,在已認證的情況下獲取到的是 UserDetails (UserDetails也是一個接口,里邊的方法有getUsername,getPassword等)。
  • isAuthenticated: 獲取當前 Authentication 是否已認證。
  • setAuthenticated: 設置當前 Authentication 是否已認證(true or false)。

UserDetails

UserDetails,看命知義,是用戶信息的意思。其存儲的就是用戶信息,其定義如下:

public interface UserDetails extends Serializable {

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

方法含義如下:

  • getAuthorites:獲取用戶權限,本質上是用戶的角色信息。
  • getPassword: 獲取密碼。
  • getUserName: 獲取用戶名。
  • isAccountNonExpired: 賬戶是否過期。
  • isAccountNonLocked: 賬戶是否被鎖定。
  • isCredentialsNonExpired: 密碼是否過期。
  • isEnabled: 賬戶是否可用。

UserDetailsService

提到了UserDetails就必須得提到UserDetailsService, UserDetailsService也是一個接口,且只有一個方法loadUserByUsername,他可以用來獲取UserDetails。

通常在spring security應用中,我們會自定義一個CustomUserDetailsService來實現UserDetailsService接口,並實現其public UserDetails loadUserByUsername(final String login);方法。我們在實現loadUserByUsername方法的時候,就可以通過查詢數據庫(或者是緩存、或者是其他的存儲形式)來獲取用戶信息,然后組裝成一個UserDetails,(通常是一個org.springframework.security.core.userdetails.User,它繼承自UserDetails) 並返回。

在實現loadUserByUsername方法的時候,如果我們通過查庫沒有查到相關記錄,需要拋出一個異常來告訴spring security來“善后”。這個異常是org.springframework.security.core.userdetails.UsernameNotFoundException

AuthenticationManager

AuthenticationManager 是一個接口,它只有一個方法,接收參數為Authentication,其定義如下:

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication)
			throws AuthenticationException;
}

AuthenticationManager 的作用就是校驗Authentication,如果驗證失敗會拋出AuthenticationException 異常。AuthenticationException是一個抽象類,因此代碼邏輯並不能實例化一個AuthenticationException異常並拋出,實際上拋出的異常通常是其實現類,如DisabledException,LockedException,BadCredentialsException等。BadCredentialsException可能會比較常見,即密碼錯誤的時候。

小結

這里,我們只是簡單的了解了spring security中有哪些東西,先混個臉熟。這里並不需要我們一下子全記住這些名詞和概念。先大概看看,有個印象。

Spring Security的一些工作原理

在第一節中,我們通過在pom文件中增加spring-boot-starter-security依賴,便使得我們的項目收到了spring security保護,又通過增加SecurityConfiguration實現了一些安全配置,實現了鏈接資源的個性化訪問控制。那么這是如何實現的呢?了解其原理,可以使我們使用起來得心應手。

spring security 在web應用中是基於filter的

在spring security的官方文檔中,我們可以看到這么一句話:

Spring Security’s web infrastructure is based entirely on standard servlet filters.

我們可以得知,spring security 在web應用中是基於filter的。filter我們就很熟了,在沒有struts,沒有spring mvc之前,我們就是通過一個個servlet,一個個filter來實現業務功能的,通常我們會有多個filter,他們按序執行,一個執行完之后,調用filterChain中的下一個doFilter。Spring Security 在 Filter 中創建 Authentication 對象,並調用 AuthenticationManager 進行校驗

spring security 維護了一個filter chain,chain中的每一個filter都具有特定的責任,並根據所需的服務在配置總添加。filter的順序很重要,因為他們之間存在依賴關系。spring security中有如下filter(按順序的):

  • ChannelProcessingFilter,因為它可能需要重定向到不同的協議
  • SecurityContextPersistenceFilter,可以在web請求開頭的SecurityContextHolder中設置SecurityContext,並且SecurityContext的任何更改都可以復制到HttpSession當web請求結束時(准備好與下一個web請求一起使用)
  • ConcurrentSessionFilter,
  • 身份驗證處理-UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter等。以便SecurityContextHolder可以修改為包含有效的Authentication請求令牌
  • SecurityContextHolderAwareRequestFilter
  • JaasApiIntegrationFilter
  • RememberMeAuthenticationFilter,記住我服務處理
  • AnonymousAuthenticationFilter,匿名身份處理,更新SecurityContextHolder
  • ExceptionTranslationFilter,獲任何Spring Security異常,以便可以返回HTTP錯誤響應或啟動適當的AuthenticationEntryPoint
  • FilterSecurityInterceptor,用於保護web URI並在訪問被拒絕時引發異常

這里我們列舉了幾乎所有的spring security filter。正是這些filter完成了spring security的各種功能。目前我們只是知道了有這些filter,並不清楚他們是怎么集成到應用中的。在繼續深入了解之前,我們需要了解一下DelegatingFilterProxy

DelegatingFilterProxy

DelegatingFilterProxy是一個特殊的filter,存在於spring-web模塊中。DelegatingFilterProxy通過繼承GenericFilterBean 使得自己變為了一個Filter(因為GenericFilterBean implements Filter)。它是一個Filter,其命名卻以proxy結尾。非常有意思,為了了解其功能,我們看一下它的使用配置:

<filter>
    <filter-name>myFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>myFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

這個配置是我們使用web.xml配置Filter時做法。但是與普通的Filter不同的是DelegatingFilterProxy並沒有實際的過濾邏輯,他會嘗試尋找filter-name節點所配置的myFilter,並將過濾行為委托給myFilter來處理。這種方式能夠利用Spring豐富的依賴注入工具和生命周期接口,因此DelegatingFilterProxy提供了web.xml與應用程序上下文之間的鏈接。非常有意思,可以慢慢體會。

spring security入口——springSecurityFilterChain

spring security的入口filter就是springSecurityFilterChain。在沒有spring boot之前,我們要使用spring security的話,通常在web.xml中添加如下配置:

   <filter>
       <filter-name>springSecurityFilterChain</filter-name>
       <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
   </filter>

   <filter-mapping>
       <filter-name>springSecurityFilterChain</filter-name>
       <url-pattern>/*</url-pattern>
   </filter-mapping>

看到沒,這里配置的是DelegatingFilterProxy。有了上面的介紹之后,我們就知道,它實際上會去找到filter-name節點中的Filter——springSecurityFilterChain,並將實際的過濾工作交給springSecurityFilterChain處理。

在使用spring boot之后,這一xml配置被Java類配置給代替了。我們前面在代碼種使用過@EnableWebSecurity 注解,通過跟蹤源碼可以發現@EnableWebSecurity會加載WebSecurityConfiguration類,而WebSecurityConfiguration類中就有創建springSecurityFilterChain這個Filter的代碼:

 @Bean(name = {"springSecurityFilterChain"})
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = this.webSecurityConfigurers != null && !this.webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = (WebSecurityConfigurerAdapter)this.objectObjectPostProcessor.postProcess(new WebSecurityConfigurerAdapter() {
            });
            this.webSecurity.apply(adapter);
        }

        return (Filter)this.webSecurity.build();
    }

這里,我們介紹了spring security的入口——springSecurityFilterChain,也介紹了它的兩種配置形式。但是,springSecurityFilterChain是誰,怎么起作用的,我們還不清楚,下面繼續看。

FilterChainProxy 和SecurityFilterChain

在spring的官方文檔中,我們可以發現這么一句話:

Spring Security’s web infrastructure should only be used by delegating to an instance of FilterChainProxy. The security filters should not be used by themselves.

spring security 的web基礎設施(上面介紹的那一堆filter)只能通過委托給FilterChainProxy實例的方式來使用。而不能直接使用那些安全filter。

這句話似乎透漏了一個信號,上面說的入口springSecurityFilterChain其實就是FilterChainProxy ,如果不信,調試一下 代碼也能發現,確實就是FilterChainProxy。它的全路徑名稱是org.springframework.security.web.FilterChainProxy。打開其源碼,第一行注釋是這樣:

Delegates {@code Filter} requests to a list of Spring-managed filter beans.

所以,沒錯了。它就是DelegatingFilterProxy要找的人,它就是DelegatingFilterProxy要委托過濾任務的人。下面貼出其部分代碼:

public class FilterChainProxy extends GenericFilterBean {
   
   private List<SecurityFilterChain> filterChains;// 

   public FilterChainProxy(SecurityFilterChain chain) {
      this(Arrays.asList(chain));
   }

   public FilterChainProxy(List<SecurityFilterChain> filterChains) {
      this.filterChains = filterChains;
   }

   public void doFilter(ServletRequest request, ServletResponse response,
         FilterChain chain) throws IOException, ServletException {
         doFilterInternal(request, response, chain);
   }

   private void doFilterInternal(ServletRequest request, ServletResponse response,
         FilterChain chain) throws IOException, ServletException {

      FirewalledRequest fwRequest = firewall
            .getFirewalledRequest((HttpServletRequest) request);
      HttpServletResponse fwResponse = firewall
            .getFirewalledResponse((HttpServletResponse) response);
		
      List<Filter> filters = getFilters(fwRequest);

      if (filters == null || filters.size() == 0) {
         fwRequest.reset();
         chain.doFilter(fwRequest, fwResponse);
         return;
      }

      VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
      vfc.doFilter(fwRequest, fwResponse);
   }

   private List<Filter> getFilters(HttpServletRequest request) {
      for (SecurityFilterChain chain : filterChains) {
         if (chain.matches(request)) {
            return chain.getFilters();
         }
      }
      return null;
   }

}

可以看到,里邊有個SecurityFilterChain的集合。這個才是眾多security filter藏身之處,doFilter的時候會從SecurityFilterChain取出第一個匹配的Filter集合並返回。

小結

說到這里,可能有點模糊了。這里小結一下,梳理一下。

  • spring security 的核心是基於filter
  • 入口filter是springSecurityFilterChain(它會被DelegatingFilterProxy委托來執行過濾任務)
  • springSecurityFilterChain實際上是FilterChainProxy (一個filter)
  • FilterChainProxy里邊有一個SecurityFilterChain集合,doFIlter的時候會從其中取。

到這里,思路清楚多了,現在還不知道SecurityFilterChain是怎么來的。下面介紹。

再說SecurityFilterChain

前面我們介紹了springSecurityFilterChain,它是由xml配置的,或者是由@EnableWebSecurity注解的作用下初始化的(@Import({WebSecurityConfiguration.class))。具體是在WebSecurityConfiguration類中。上面我們貼過代碼,你可以返回看,這里再次貼出刪減版:

   @Bean( name = {"springSecurityFilterChain"})
    public Filter springSecurityFilterChain() throws Exception {
        // 刪除部分代碼
        return (Filter)this.webSecurity.build();
    }

最后一行,發現webSecurity.build() 產生了FilterChainProxy。因此,推斷SecurityFilterChain就是webSecurity里邊弄的。貼出源碼:

public final class WebSecurity extends
      AbstractConfiguredSecurityBuilder<Filter, WebSecurity> implements
      SecurityBuilder<Filter>, ApplicationContextAware {
    
    @Override
	protected Filter performBuild() throws Exception {
		int chainSize = ignoredRequests.size() + securityFilterChainBuilders.size();
        // 我們要找的 securityFilterChains
		List<SecurityFilterChain> securityFilterChains = new ArrayList<SecurityFilterChain>(
				chainSize);
		for (RequestMatcher ignoredRequest : ignoredRequests) {
			securityFilterChains.add(new DefaultSecurityFilterChain(ignoredRequest));
		}
		for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : securityFilterChainBuilders) {
			securityFilterChains.add(securityFilterChainBuilder.build());
		}
        // 創建 FilterChainProxy  ,傳入securityFilterChains
		FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
		if (httpFirewall != null) {
			filterChainProxy.setFirewall(httpFirewall);
		}
		filterChainProxy.afterPropertiesSet();

		Filter result = filterChainProxy;
		postBuildAction.run();
		return result;
	}
}

至此,我們清楚了,spring security 是怎么在spring web應用中工作的了。具體的細節就是執行filter里的代碼了,這里不再繼續深入了。我們的目的是摸清楚他是怎么工作的,大致的脈路是怎樣,目前整理的內容已經達到這個目的了。

Spring Security 的一些實戰

下面開始一些實戰使用spring security 的實例。依然依托開篇的例子,並在此基礎上調整。

通過數據庫查詢,存儲用戶和角色實現安全認證

開篇的例子中,我們使用了內存用戶角色來演示登錄認證。但是實際項目我們肯定是通過數據庫完成的。實際項目中,我們可能會有3張表:用戶表,角色表,用戶角色關聯表。當然,不同的系統會有不同的設計,不一定非得是這樣的三張表。本例演示的意義在於:如果我們想在已有項目中增加spring security的話,就需要調整登錄了。主要是自定義UserDetailsService,此外,可能還需要處理密碼的問題,因為spring並不知道我們怎么加密用戶登錄密碼的。這時,我們可能需要自定義PasswordEncoder,下面也會提到。

添加spring-data-jpa , 創建數據表,並添加數據

繼續完善開篇的項目,現在給項目添加spring-data-jpa,並使用MySQL數據庫。因此在POM文件中加入如下配置:

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

在application.properties文件中加入數據庫連接信息:

spring.datasource.url=jdbc:mysql://localhost:3306/yourDB?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=dbuser
spring.datasource.password=******
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

這里,為了簡單方便演示,我們只創建一張表,字段如下:

@Entity
public class User implements java.io.Serializable{

	@Id
	@Column
	private Long id;
	@Column
	private String login;
	@Column
	private String password;
	@Column
	private String role;
    // 省略get set 等
}

然后我們添加2條數據,如下:

id login password role
1 user1 $2a$10$PhynBxXaIYdBzd/OgXrKzeVr3Bj4fiDxdii14fMOVIwJTqoDoFL1e USER
2 admin $2a$10$PhynBxXaIYdBzd/OgXrKzeVr3Bj4fiDxdii14fMOVIwJTqoDoFL1e ADMIN

密碼這里都是使用了BCryptPasswordEncoder 需在SecurityConfiguration中加入配置,后面會貼。

自定義UserDetailsService

前面我們提到過,UserDetailsService,spring security在認證過程中需要查找用戶,會調用UserDetailsService的loadUserByUsername方法得到一個UserDetails,下面我們來實現他。代碼如下:

@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);
	}
}

這個方法做了2件事情,查詢用戶以及設置角色,通常一個用戶會有多個角色,即上面的userFromDatabase.getRole()通常是一個list,所以設置角色的時候,就是for循環new 多個SimpleGrantedAuthority並設置。(本例為了簡單沒有設置角色表以及用戶角色關聯表,只在用戶中增加了一個角色字段,所以grantedAuthorities只有一個)

同時修改之前的SecurityConfiguration,加入CustomUserDetailsServicebean配置,如下:

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService)// 設置自定義的userDetailsService
				.passwordEncoder(passwordEncoder());
		/*auth
			.inMemoryAuthentication()
			.withUser("admin1")
				.password("admin1")
				.roles("ADMIN", "USER")
				.and()
			.withUser("user1").password("user1")
				.roles("USER");*/
	}

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

驗證效果

上面我們自定義了userDetailsService,此時,spring security 在其作用流程中會調用,不出意外的話,重啟系統,我們使用user1登錄可以看到/product/info,但是不能看/admin/home。下面我們來重啟項目驗證一下。

先輸入user1,以及錯誤密碼,結果如下:

Alt text

再輸入user1 ,以及正確密碼,結果如下:

再將瀏覽器鏈接修改為/admin/home,結果顯示:

There was an unexpected error (type=Forbidden, status=403).
Access is denied

這與我們的預期完全一致,至此,我們已經在項目中加入了spring security,並且能夠通過查詢數據庫用戶,角色信息交給spring security完成認證授權。

spring security session 無狀態

還記得我們開篇所舉的例子嗎?我們使用管理員賬號密碼登錄之后,就可以訪問/admin/home了,此時修改瀏覽器地址欄為/product/info之后刷新頁面,仍然可以訪問,說明認證狀態被保持了;如果關閉瀏覽器重新輸入/admin/home就會提示我們重新登錄,這有點session的感覺。如果此時,我們將瀏覽器cookie禁用掉,你會發現登錄之后自動跳轉只會得到403,403是拒絕訪問的意思,是沒有權限的意思,說明這種情況下授權狀態和session是掛鈎的。即這時spring security使用了session。但是不是所有的系統都需要session,我們能讓spring security不適用session嗎?答案是可以!

使用spring security 我們可以准確控制session何時創建以及Spring Security如何與之交互:

  • 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

這里,我們要關注的是 stateless,通常稱為無狀態的。為啥要關注這個stateless無狀態的情況的呢?因為目前,我們的應用基本都是前后端分離的應用。比方說,你的一套java api是給react前端、安卓端、IOS端 調用的。這個時候你還提什么session啊,這時候我們需要的是無狀態,通常以一種token的方式來交互。

spring security 配置stateless 的方式如下,依然是修改我們之前定義的SecurityConfiguration:

http
    .sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

前后端分離應用中自定義token整合spring security

上面我們提到了stateless,實際中我們的前后端分離項目都是無狀態的,並沒有登錄狀態保持,服務器通過客戶端調用傳遞的token來識別調用者是誰。

通常我們的系統流程是這樣的:

  1. 客戶端(react前端,IOS,安卓)調用“登錄接口”獲得一個包含token的響應(通常是個JSON,如 {"token":"abcd","expires":1234567890})
  2. 客戶端獲取數據,並攜帶 token參數。
  3. 服務端根據token發現token過期/錯誤,返回"請登錄"狀態碼
  4. 服務器發現token正常,並解析出來是A,返回A的數據。
  5. ……

如果我們想在spring security項目中使用自定義的token,那么我們需要思考下面的問題:

  1. 怎么發token(即怎么登錄?)
  2. 發token怎么和spring security整合。
  3. spring security怎么根據token得到授權認證信息。

下面從登錄發token開始,這里需要使用到UsernamePasswordAuthenticationToken,以及SecurityContextHolder,代碼如下:

    @RequestMapping(value = "/authenticate",method = RequestMethod.POST)
    public Token authorize(@RequestParam String username, @RequestParam String password) {
        // 1 創建UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken token 
                           = new UsernamePasswordAuthenticationToken(username, password);
        // 2 認證
        Authentication authentication = this.authenticationManager.authenticate(token);
        // 3 保存認證信息
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 4 加載UserDetails
        UserDetails details = this.userDetailsService.loadUserByUsername(username);
        // 5 生成自定義token
        return tokenProvider.createToken(details);
    }
    @Inject
    private AuthenticationManager authenticationManager;

上面代碼中1,2,3,4步驟都是和spring security交互的。只有第5步是我們自己定義的,這里tokenProvider就是我們系統中token的生成方式(這個完全是個性化的,通常是個加密串,通常可能會包含用戶信息,過期時間等)。其中的Token也是我們自定義的返回對象,其中包含token信息類似{"token":"abcd","expires":1234567890}.

我們的tokenProvider通常至少具有兩個方法,即:生成token,驗證token。大致如下:

public class TokenProvider {

    private final String secretKey;
    private final int tokenValidity;

    public TokenProvider(String secretKey, int tokenValidity) {
        this.secretKey = secretKey;
        this.tokenValidity = tokenValidity;
    }
   // 生成token
    public Token createToken(UserDetails userDetails) {
        long expires = System.currentTimeMillis() + 1000L * tokenValidity;
        String token =  computeSignature(userDetails, expires);
        return new Token(token, expires);
    }
    // 驗證token
   public boolean validateToken(String authToken, UserDetails userDetails) {
        check token
        return true or false;
    }
     // 從token中識別用戶
    public String getUserNameFromToken(String authToken) {
        // ……
        return login;
    }
    public String computeSignature(UserDetails userDetails, long expires) {
        // 一些特有的信息組裝 ,並結合某種加密活摘要算法
        return 例如 something+"|"+something2+MD5(s);
    }

}

至此,我們客戶端可以通過調用http://host/context/authenticate來獲得一個token了,類似這樣的:{"token":"abcd","expires":1234567890}。那么下次請求的時候,我們帶上 token=abcd這個參數(或者也可以是自定義的請求頭中)如何在spring security中復原“session”呢。我們需要一個filter:

public class MyTokenFilter extends GenericFilterBean {

    private final Logger log = LoggerFactory.getLogger(XAuthTokenFilter.class);

    private final static String XAUTH_TOKEN_HEADER_NAME = "my-auth-token";

    private UserDetailsService detailsService;

    private TokenProvider tokenProvider;
    public XAuthTokenFilter(UserDetailsService detailsService, TokenProvider tokenProvider) {
        this.detailsService = detailsService;
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
            String authToken = httpServletRequest.getHeader(XAUTH_TOKEN_HEADER_NAME);
            if (StringUtils.hasText(authToken)) {
               // 從自定義tokenProvider中解析用戶
                String username = this.tokenProvider.getUserNameFromToken(authToken);
                // 這里仍然是調用我們自定義的UserDetailsService,查庫,檢查用戶名是否存在,
                // 如果是偽造的token,可能DB中就找不到username這個人了,拋出異常,認證失敗
                UserDetails details = this.detailsService.loadUserByUsername(username);
                if (this.tokenProvider.validateToken(authToken, details)) {
                    log.debug(" validateToken ok...");
                    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(details, details.getPassword(), details.getAuthorities());
                    // 這里還是上面見過的,存放認證信息,如果沒有走這一步,下面的doFilter就會提示登錄了
                    SecurityContextHolder.getContext().setAuthentication(token);
                }
            }
            // 調用后續的Filter,如果上面的代碼邏輯未能復原“session”,SecurityContext中沒有想過信息,后面的流程會檢測出"需要登錄"
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
}

目前為止,我們實現了自定義的token生成類,以及通過一個filter來攔截客戶端請求,解析其中的token,復原無狀態下的"session",讓當前請求處理線程中具有認證授權數據,后面的業務邏輯才能執行。下面,我們需要將自定義的內容整合到spring security中。

首先編寫一個類,繼承SecurityConfigurerAdapter:

public class MyAuthTokenConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private TokenProvider tokenProvider;  // 我們之前自定義的 token功能類
    private UserDetailsService detailsService;// 也是我實現的UserDetailsService
    
    public MyAuthTokenConfigurer(UserDetailsService detailsService, TokenProvider tokenProvider) {
        this.detailsService = detailsService;
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        MyAuthTokenFilter customFilter = new MyAuthTokenFilter(detailsService, tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

SecurityConfiguration配置類中加入如下內容:

    // 增加方法
    private MyAuthTokenConfigurer securityConfigurerAdapter() {
      return new MyAuthTokenConfigurer(userDetailsService, tokenProvider);
    }
    // 依賴注入
    @Inject
    private UserDetailsService userDetailsService;

    @Inject
    private TokenProvider tokenProvider;

     //方法修改 , 增加securityConfigurerAdapter
     @Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.authorizeRequests()
				.anyRequest().authenticated()
                  // .... 其他配置
				.and()
                 .apply(securityConfigurerAdapter());// 這里增加securityConfigurerAdapter
				
	}

至此我們就完成了無狀態應用中token認證結合spring security。

總結

本篇內容,我們通過一個小例子開始介紹了如何給web應用引入spring security保護;在展示了http-basic驗證之后,我們使用了內存用戶實驗了“角色-資源”訪問控制;然后我們介紹了spring security的一些核心概念;之后我們介紹了spring security 是通過filter的形式在web應用中發生作用的,並列舉了filter列表,介紹了入口filter,介紹了springboot是如何載入spring security入口filter的。最后我們通過兩個實戰中的例子展示了spring security的使用。

spring security 功能也非常強大,但是還是挺復雜的,本篇內容如有差錯還請指出。

示例代碼:https://github.com/xudeming/spring-security-demo

參考文檔:
spring security 官方文檔
spring-security-session

其他推薦:
SpringMVC是怎么工作的,SpringMVC的工作原理
Mybatis Mapper接口是如何找到實現類的-源碼分析
小程序雲開發:菜鳥也能全棧做產品
CORS詳解,CORS原理分析
Java8系列- 如何用Java8 Stream API找到心儀的女朋友
Java8系列- 何用Java8 Stream API進行數據抽取與收集
Docker & k8s 系列一:快速上手docker
Docker & k8s 系列二:本機k8s環境搭建
Docker & k8s 系列三:在k8s中部署單個服務實例
Docker & Kubenetes 系列四:集群,擴容,升級,回滾
alt 逃離沙漠公眾號


免責聲明!

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



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