原文部落格地址: pjmike的部落格
前言
上一篇文章淺析Spring Security 核心元件中介紹了Spring Security的基本元件,有了前面的基礎,這篇文章就來詳細分析下Spring Security的認證過程。
Spring Security 的核心之一就是它的過濾器鏈,我們就從它的過濾器鏈入手,下圖是Spring Security 過濾器鏈的一個執行過程,本文將依照該過程來逐步的剖析其認證過程。
核心過濾器鏈簡介
Spring Security 中的過濾器有很多,一般正常的專案中都有十幾個過濾器,有時候還包含自定義的過濾器,當然我們不可能對每一個過濾器都進行分析,我們需要抓住重點,找比較關鍵的幾個過濾器,它們在認證過程中扮演著重要角色,下面列舉幾個核心的過濾器:
- SecurityContextPersistenceFilter: 整個Spring Security 過濾器鏈的開端,它有兩個作用:一是當請求到來時,檢查
Session
中是否存在SecurityContext
,如果不存在,就建立一個新的SecurityContext
。二是請求結束時將SecurityContext
放入Session
中,並清空SecurityContextHolder
。 - UsernamePasswordAuthenticationFilter: 繼承自抽象類
AbstractAuthenticationProcessingFilter
,當進行表單登入時,該Filter將使用者名稱和密碼封裝成一個UsernamePasswordAuthentication
進行驗證。 - AnonymousAuthenticationFilter: 匿名身份過濾器,當前面的Filter認證後依然沒有使用者資訊時,該Filter會生成一個匿名身份——
AnonymousAuthenticationToken
。一般的作用是用於匿名登入。 - ExceptionTranslationFilter: 異常轉換過濾器,用於處理
FilterSecurityInterceptor
丟擲的異常。 - FilterSecurityInterceptor: 過濾器鏈最後的關卡,從 SecurityContextHolder中獲取 Authentication,比對使用者擁有的許可權和所訪問資源需要的許可權。
表單登入認證過程
當我們訪問一個受保護的資源時,如果之前沒有進行登入認證,那麼系統將返回一個登入表單或者一個響應結果提示我們要先進行登入操作。我們這裡的分析過程只針對表單登入,所以我們先在表單中填寫使用者名稱和密碼進行登入驗證。
上面已經簡述了一堆核心過濾器,這裡先從 SecurityContextPersistenceFilter
這個過濾器的開端開始分析整個表單登入的認證過程。
SecurityContextPersistenceFilter
當我們填寫表單完畢後,點選登入按鈕,請求先經過 SecurityContextPersistenceFilter
過濾器,在前面就曾提到,該Filter有兩個作用,其中之一就是在請求到來時,建立 SecurityContext
安全上下文,我們來看看它內部是如何做的,部分原始碼如下:
public class SecurityContextPersistenceFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_scpf_applied";
//安全上下文儲存的倉庫
private SecurityContextRepository repo;
private boolean forceEagerSessionCreation = false;
public SecurityContextPersistenceFilter() {
//使用HttpSession來儲存 SecurityContext
this(new HttpSessionSecurityContextRepository());
}
public SecurityContextPersistenceFilter(SecurityContextRepository repo) {
this.repo = repo;
}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 如果是第一次請求,request中肯定沒有 FILTER_APPLIED屬性
if (request.getAttribute(FILTER_APPLIED) != null) {
// 確保每個請求只應用一次過濾器
chain.doFilter(request, response);
return;
}
final boolean debug = logger.isDebugEnabled();
// 在request 設定 FILTER_APPLIED 屬性為 true,這樣同一個請求再次訪問時,就直接進入後續Filter的操作
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (forceEagerSessionCreation) {
HttpSession session = request.getSession();
if (debug && session.isNew()) {
logger.debug("Eagerly created session: " + session.getId());
}
}
// 封裝 requset 和 response
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
response);
// 從儲存安全上下文的倉庫中載入 SecurityContext 安全上下文,其內部是從 Session中獲取上下文資訊
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
//安全上下文資訊設定到 SecurityContextHolder 中,以便在同一個執行緒中,後續訪問 SecurityContextHolder 能獲取到 SecuritContext
SecurityContextHolder.setContext(contextBeforeChainExecution);
//進入下一個過濾器操作
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
// 請求結束後,清空安全上下文資訊
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
// Crucial removal of SecurityContextHolder contents - do this before anything
// else.
SecurityContextHolder.clearContext();
//將安全上下文資訊儲存到 Session中,相當於登入態的維護
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared, as request processing completed");
}
}
}
public void setForceEagerSessionCreation(boolean forceEagerSessionCreation) {
this.forceEagerSessionCreation = forceEagerSessionCreation;
}
}
複製程式碼
請求到來時,利用HttpSessionSecurityContextRepository
讀取安全上下文。我們這裡是第一次請求,讀取的安全上下文中是沒有 Authentication
身份資訊的,將安全上下文設定到 SecurityContextHolder
之後,進入下一個過濾器。
請求結束時,同樣利用HttpSessionSecurityContextRepository
該儲存安全上下文的倉庫將認證後的SecurityContext
放入 Session
中,這也是登入態維護的關鍵,具體的操作這裡就不細說了。
UsernamePasswordAuthenticationFilter
經過 SecurityContextPersistenceFilter
過濾器後來到 UsernamePasswordAuthenticationFilter
過濾器,因為我們假定的是第一次請求,所以 SecurityContext
並沒有包含認證過的 Authentication
。從此過濾器開始的操作對於表單登入來說是非常關鍵的,包含了表單登入的核心認證步驟,下面畫了一張在此過濾器中的認證過程圖:
UsernamePasswordAuthenticationFilter
的父類是 AbstractAuthenticationProcessingFilter
,首先進入父類的 foFilter
方法,部分原始碼如下:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
...
Authentication authResult;
try {
//呼叫子類 UsernamePasswordAuthenticationFilter 的 attemptAuthentication 方法
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
//子類未完成認證,立刻返回
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
logger.error(
"An internal error occurred while trying to authenticate the user.",
failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
//認證失敗
unsuccessfulAuthentication(request, response, failed);
return;
}
// 認證成功
if (continueChainBeforeSuccessfulAuthentication) {
//繼續呼叫下一個 Filter
chain.doFilter(request, response);
}
//將成功認證後的Authentication寫入 SecurityContext中
successfulAuthentication(request, response, chain, authResult);
}
}
複製程式碼
該doFilter
方法中一個核心就是呼叫子類 UsernamePasswordAuthenticationFilter
的attemptAuthentication
方法,該方法進入真正的認證過程,並返回認證後的 Authentication
,該方法的原始碼如下:
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
//必須是POST請求
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
//獲取表單中的使用者名稱和密碼
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
//將使用者名稱和密碼封裝成一個 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//核心部分,交給內部的AuthenticationManager去認證,並返回認證後的 Authentication
return this.getAuthenticationManager().authenticate(authRequest);
}
複製程式碼
該方法中有一個關鍵點就是 his.getAuthenticationManager().authenticate(authRequest)
,呼叫內部的 AuthenticationManager
去認證,在之前的文章就介紹過AuthenticationManager,它是身份認證的核心介面,它的實現類是 ProviderManager
,而 ProviderManager
又將請求委託給一個 AuthenticationProvider
列表,列表中的每一個 AuthenticationProvider將會被依次查詢是否需要通過其進行驗證,每個 provider的驗證結果只有兩個情況:丟擲一個異常或者完全填充一個 Authentication物件的所有屬性
下面來分析一個關鍵的 AuthenticationProvider
,它就是 DaoAuthenticationProvider
,它是框架最早的provider,也是最最常用的 provider。大多數情況下我們會依靠它來進行身份認證,它的父類是 AbstractUserDetailsAuthenticationProvider
,認證過程首先會呼叫父類的 authenticate
方法,核心原始碼如下:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// Determine username
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
1 //呼叫子類 DaoAuthenticationProvider 的 retrieveUser()方法獲取 UserDetails
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
//沒拿到UserDetails會丟擲異常資訊
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
2 //對UserDetails的一些屬性進行預檢查,即判斷使用者是否鎖定,是否可用以及使用者是否過期
preAuthenticationChecks.check(user);
3 //對UserDetails附加的檢查,對傳入的Authentication與從資料庫中獲取的UserDetails進行密碼匹配
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}
4 //對UserDetails進行後檢查,檢查UserDetails的密碼是否過期
postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
5 //上面所有檢查成功後,用傳入的使用者資訊和獲取的UserDetails生成一個成功驗證的Authentication
return createSuccessAuthentication(principalToReturn, authentication, user);
}
複製程式碼
從上面一大串原始碼中,提取幾個關鍵的方法:
- retrieveUser(...): 呼叫子類 DaoAuthenticationProvider 的 retrieveUser()方法獲取 UserDetails
- preAuthenticationChecks.check(user): 對從上面獲取的UserDetails進行預檢查,即判斷使用者是否鎖定,是否可用以及使用者是否過期
- additionalAuthenticationChecks(user,authentication): 對UserDetails附加的檢查,對傳入的Authentication與獲取的UserDetails進行密碼匹配
- postAuthenticationChecks.check(user): 對UserDetails進行後檢查,即檢查UserDetails的密碼是否過期
- createSuccessAuthentication(principalToReturn, authentication, user): 上面所有檢查成功後,利用傳入的Authentication 和獲取的UserDetails生成一個成功驗證的Authentication
retrieveUser(...)方法
接下來詳細說說 retrieveUser(...)
方法, DaoAuthenticationProvider 的 retrieveUser() 原始碼如下:
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
//經過UserDetailsService 獲取 UserDetails
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
複製程式碼
該方法最核心的部分就是呼叫內部的UserDetailsServices 載入 UserDetails,UserDetailsServices
本質上就是載入UserDetails的介面,UserDetails包含了比Authentication更加詳細的使用者資訊。UserDetailsService常見的實現類有JdbcDaoImpl,InMemoryUserDetailsManager,前者從資料庫載入使用者,後者從記憶體中載入使用者。我們也可以自己實現UserDetailsServices介面,比如我們是如果是基於資料庫進行身份認證,那麼我們可以手動實現該介面,而不用JdbcDaoImpl。
additionalAuthenticationChecks()
UserDetails的預檢查和後檢查比較簡單,這裡就不細說了,下面來看一下密碼匹配校驗,程式碼如下:
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
//利用 PasswordEncoder編碼器校驗密碼
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
複製程式碼
這個方法實際上是呼叫DaoAuthenticationProvider
的additionalAuthenticationChecks
方法,內部呼叫加密解密器進行密碼匹配,如果匹配失敗,則丟擲一個 BadCredentialsException
異常
最後通過createSuccessAuthentication(..)
方法生成一個成功認證的 Authentication,簡單說就是組合獲取的UserDetails和傳入的Authentication,得到一個完全填充的Authentication。
該Authentication最終一步一步向上返回,到AbstractAuthenticationProcessingFilter
過濾器中,將其設定到 SecurityContextHolder
。
AnonymousAuthenticationFilter
匿名認證過濾器,它主要是針對匿名登入,如果前面的Filter,比如UsernamePasswordAuthenticationFilter
執行完畢後,SecurityContext依舊沒有使用者資訊,那麼AnonymousAuthenticationFilter
才會起作用,生成一個匿名身份資訊——AnonymousAuthenticationToken
ExceptionTranslationFilter
ExceptionTranslationFilter
簡單的說就是處理 FilterSecurityInterceptor 丟擲的異常,其內部 doFilter
方法原始碼如下:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
//直接進入下一個Filter
chain.doFilter(request, response);
logger.debug("Chain processed normally");
}
catch (IOException ex) {
throw ex;
}
//真正的作用在這裡,處理丟擲的異常
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
RuntimeException ase = (AuthenticationException) throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
//這裡會處理 FilterSecurityInterceptor 丟擲的AccessDeniedException
if (ase == null) {
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
AccessDeniedException.class, causeChain);
}
if (ase != null) {
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, ase);
}
else {
// Rethrow ServletExceptions and RuntimeExceptions as-is
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
else if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap other Exceptions. This shouldn't actually happen
// as we've already covered all the possibilities for doFilter
throw new RuntimeException(ex);
}
}
}
複製程式碼
FilterSecurityInterceptor
FilterSecurityInterceptor
過濾器是最後的關卡,之前的請求最終會來到這裡,它的大致工作流程就是
- 封裝請求資訊
- 從系統中讀取配資訊,即資源所需的許可權資訊
- 從
SecurityContextHolder
中獲取之前認證過的Authentication
物件,即表示當前使用者所擁有的許可權 - 然後根據上面獲取到的三種資訊,傳入一個許可權校驗器中,對於當前請求來說,比對使用者擁有的許可權和資源所需的許可權。若比對成功,則進入真正系統的請求處理邏輯,反之,會丟擲相應的異常
下面畫一張簡易的流程圖來闡述 FilterSecurityInterceptor
的執行過程,如下:
根據上圖內容,我們再來看看 FilterSecurityInterceptor
的原始碼,
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter {
...
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 封裝request、response請求
FilterInvocation fi = new FilterInvocation(request, response, chain);
//呼叫核心方法
invoke(fi);
}
...
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// 判斷當前請求之前是否經歷過該過濾器
if (fi.getRequest() != null && observeOncePerRequest) {
// 如果當前請求已經經歷過這個安全過濾器判斷,那麼不再執行後續邏輯,直接往下走,呼叫請求的處理方法
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//呼叫父類的方法,執行授權判斷邏輯
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
}
複製程式碼
原始碼中已經對請求進行了封裝,然後進入核心部分, 呼叫父類的授權判斷方法——beforeInvocation(FilterInvocation)
,原始碼如下:
protected InterceptorStatusToken beforeInvocation(Object object) {
Assert.notNull(object, "Object was null");
final boolean debug = logger.isDebugEnabled();
if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
throw new IllegalArgumentException(
"Security invocation attempted for object "
+ object.getClass().getName()
+ " but AbstractSecurityInterceptor only configured to support secure objects of type: "
+ getSecureObjectClass());
}
//讀取Spring Security的配置資訊,將其封裝成 ConfigAttribute
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
if (attributes == null || attributes.isEmpty()) {
if (rejectPublicInvocations) {
throw new IllegalArgumentException(
"Secure object invocation "
+ object
+ " was denied as public invocations are not allowed via this interceptor. "
+ "This indicates a configuration error because the "
+ "rejectPublicInvocations property is set to 'true'");
}
...
return null; // no further work post-invocation
}
...
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(messages.getMessage(
"AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"),
object, attributes);
}
//從SecurityContextHolder中獲取Authentication
Authentication authenticated = authenticateIfRequired();
// 啟動授權匹配
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
...
}
複製程式碼
beforeInvocation
的原始碼比較多,我這裡只保留了相對核心的部分,從原始碼就可以看出,拿到配置資訊和使用者資訊後,連同請求資訊一同傳入AccessDecisionManager
的 decide(Authentication authentication, Object object,Collection<ConfigAttribute> configAttributes)
方法。該方法是最終執行授權校驗邏輯的地方。
AccessDecisionManager 本身是一個介面,它的 實現類是 AbstractAccessDecisionManager
,而 AbstractAccessDecisionManager
也是一個抽象類,它的實現類有三個,常用的是 AffirmativeBased
,最終的授權校驗邏輯是 AffirmativeBased 實現的,部分原始碼如下:
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
//投票器執行投票
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
...
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
...
}
複製程式碼
該方法的邏輯比較簡單,就是執行AccessDecisionVoter
的校驗邏輯,如果校驗失敗就丟擲AccessDeniedException
異常。對於AccessDecisionVoter的vote
投票邏輯這裡就不細說了,在 Spring Security 3.0以後,一般預設使用 AccessDecisionVoter
介面的實現類WebExpressionVoter來完成最終的校驗過程。
小結
上面從過濾器出發,對 Spring Security的認證過程做了一個還算詳細的分析,當然還存在很多細節問題沒有涉及到。