在web應用開發中,安全無疑是十分重要的,選擇Spring Security來保護web應用是一個非常好的選擇。Spring Security 是spring專案之中的一個安全模組,可以非常方便與spring專案無縫整合。特別是在spring boot專案中加入spring security更是十分簡單。本篇我們介紹spring security,以及spring security在web應用中的使用。
從一個Spring Security的例子開始
建立不受保護的應用
假設我們現在建立好了一個springboot
的web應用,有一個控制器如下:
@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
的認證彈窗,如下:
說明spring security 已經起作用了。如果我們點選取消,則會看到錯誤資訊,如下所示:
There was an unexpected error (type=Unauthorized, status=401).
關閉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。會發現自動跳轉到一個登入頁面了,如下所示:
這個頁面是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功能,這裡不做過多解釋。
角色-資源 訪問控制
通常情況下,我們需要實現“特定資源只能由特定角色訪問”的功能。假設我們的系統有如下兩個角色:
- 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)
繼續增加“連結-角色”控制配置,程式碼如下:
@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
,加入CustomUserDetailsService
bean配置,如下:
@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,以及錯誤密碼,結果如下:
再輸入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來識別呼叫者是誰。
通常我們的系統流程是這樣的:
- 客戶端(react前端,IOS,安卓)呼叫“登入介面”獲得一個包含token的響應(通常是個JSON,如 {"token":"abcd","expires":1234567890})
- 客戶端獲取資料,並攜帶 token引數。
- 服務端根據token發現token過期/錯誤,返回"請登入"狀態碼
- 伺服器發現token正常,並解析出來是A,返回A的資料。
- ……
如果我們想在spring security專案中使用自定義的token,那麼我們需要思考下面的問題:
- 怎麼發token(即怎麼登入?)
- 發token怎麼和spring security整合。
- 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 功能也非常強大,但是還是挺複雜的,本篇內容如有差錯還請指出。
參考文件: