Spring Security 源码分析七:Spring Security 登录认证流程

前言

本文是对 Spring Security Core 4.0.4 Release 进行源码分析的系列文章之一;

本文将使用 Spring Security 源码分析四:Spring MVC 例子 作为调试用例,进行源码分析;并且由其可知,该例子初始化实现了一个 DefaultDelegatingFilterProxy,该 DelegatingFilterProxy 包含了前文所描述的相关的所有的 filters;下面,笔者将带领大家看看基于该 DefaultDelegatingFilterProxy 的 Spring Security 的登录流程是如何实现的;

本文为作者的原创作品,转载需注明出处;

调试过程

使用调试模式启动 Spring Security 源码分析四:Spring MVC 例子,浏览器中输入 http://localhost:8080/servletapi/login, 然后输入账户密码 admin / password 进行调试即可;调试中,将断点打在 AffirmativeBased#decide 方法中;

源码分析

调用流程

如图所示,显示了登录认证过程中的 filters 相关的调用流程,作者将几个自认为重要的 filters 标注了出来,

从中可以看到,其执行顺序为 HeaderWriterFilter -> CsrfFilter -> UsernamePasswordAuthenticationFilter -> BasicAuthenticationFilter -> AnonymousAuthenticationFilter -> SessionManagementFilter -> SessionManagementFilter -> FilterSecurityInterceptor => AffirmativeBased.decide(Authentication, Object, Collection );备注,该调用过程中省略了部分 Filters;

来看看几个作者认为比较重要的 Filter 的处理逻辑,CsrfFilter,UsernamePasswordAuthenticationFilter,BasicAuthenticationFilter,FilterSecurityInterceptor 以及相关的处理流程如下所述;

CsrfFilter

CsrfFilter 主要是通过验证 CSRF Token 来验证,判断是否受到了跨站点攻击;处理流程简单描述如下,

每当用户登录系统某个页面的时候,通过系统后台随机生成一个 CSRF Token,通过 response 返回给客户端;客户端在发送 POST 表单提交的时候,需要将该 CSRF Token 作为隐藏字段(一般将该表单字段命名为 _csrf)提交到系统后台进行处理;系统后台会在当前的 session 中一直保存该 CSRF Token,这样,当后台收到前端所提交的 CSRF Token 以后,将会与当前 session 中缓存的 CSRF Token 进行比对,若两者相同,则验证通过,若两者不相等,则验证失败,拒绝访问;Spring Security 正式通过这样的逻辑来避免 CSRF 攻击的;

CsrfFilter

CsrfFilter 是验证 CSRF Token 逻辑的核心类,其核心实现方法在 doFilterInternal 中,

CsrfFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 从当前的 session 中获取 csrf token
CsrfToken csrfToken = tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
// 2. 若当前 session 中不存在 csrf token,则生成一个 token
if (missingToken) {
CsrfToken generatedToken = tokenRepository.generateToken(request);
csrfToken = new SaveOnAccessCsrfToken(tokenRepository, request, response,
generatedToken);
}
// 2.1 将新生成的 csrf token 作为 attr 放入 request
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);

// 3. 这里重要了,若是 GET|HEAD|TRACE|OPTIONS 中的任何一种请求,这里将进入下一个 filter,并直接 return;若不是其中任一的一种请求,则跳过该步骤,进入 #4
if (!requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}

// 4. 验证客户端传递过来的 csrf token 与 session 中的 csrf token 是否一致,一致则验证通过,不一致则失败;
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (logger.isDebugEnabled()) {
logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}

filterChain.doFilter(request, response);
}

可见,主流程有这样四个逻辑,

  1. 从当前 session 中获取 CSRF Token 并放入变量 csrfToken 中;
  2. 若当前 session 中没有 CSRF Token 则创建一个新的,并放入变量 csrfToken 中;
  3. 这一步比较重要,是 POST、PUT 请求与 GET、HEAD、TRACE、OPTIONS 请求的分水岭
    如果是 GET、HEAD、TRACE、OPTIONS 请求,则进入下一个 filter,直接返回
    如果是 POST、PUT 请求,那么直接进入 #4
    补充,这一步的判断是通过正则表达式^(GET|HEAD|TRACE|OPTIONS)$来进行判断的;
  4. 这一步是关键,验证 CSRF Token
    验证逻辑是,通过从 Request 的 Header 或 Parameters 中获取到客户端的 CSRF Token 与服务器端 session 中保存的 CSRF Token 进行比对,若相同则验证成功,若失败,则验证失败;

CsrfToken

CsrfToken 是一个接口,定义了三个与 Token 相关的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface CsrfToken extends Serializable {

/**
* Gets the HTTP header that the CSRF is populated on the response and can be placed
* on requests instead of the parameter. Cannot be null.
*
* @return the HTTP header that the CSRF is populated on the response and can be
* placed on requests instead of the parameter
*/
String getHeaderName();

/**
* Gets the HTTP parameter name that should contain the token. Cannot be null.
* @return the HTTP parameter name that should contain the token.
*/
String getParameterName();

/**
* Gets the token value. Cannot be null.
* @return the token value
*/
String getToken();

}

这里我们只需要关注的是 getToken() 方法;

class CsrfFilter.SaveOnAccessCsrfToken 实现了 CsrfToken 接口,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private static final class SaveOnAccessCsrfToken implements CsrfToken {
private transient CsrfTokenRepository tokenRepository;
private transient HttpServletRequest request;
private transient HttpServletResponse response;

private final CsrfToken delegate;

public SaveOnAccessCsrfToken(CsrfTokenRepository tokenRepository,
HttpServletRequest request, HttpServletResponse response,
CsrfToken delegate) {
super();
this.tokenRepository = tokenRepository;
this.request = request;
this.response = response;
this.delegate = delegate;
}

public String getHeaderName() {
return delegate.getHeaderName();
}

public String getParameterName() {
return delegate.getParameterName();
}

public String getToken() {
saveTokenIfNecessary();
return delegate.getToken();
}

....

private void saveTokenIfNecessary() {
if (this.tokenRepository == null) {
return;
}

synchronized (this) {
if (tokenRepository != null) {
this.tokenRepository.saveToken(delegate, request, response);
this.tokenRepository = null;
this.request = null;
this.response = null;
}
}
}

}

同样,作者删除了部分与主流程逻辑无关的代码,这里唯一需要注意的是 csrf token 保存的逻辑是在 getToken 方法中实现的;

HttpSessionCsrfTokenRepository

HttpSessionCsrfTokenRepository 继承并实现了接口 CsrfTokenRepository,表述的是如何存储 Csrf Token;先看看接口 CsrfTokenRepository

CsrfTokenRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public interface CsrfTokenRepository {

/**
* Generates a {@link CsrfToken}
*
* @param request the {@link HttpServletRequest} to use
* @return the {@link CsrfToken} that was generated. Cannot be null.
*/
CsrfToken generateToken(HttpServletRequest request);

/**
* Saves the {@link CsrfToken} using the {@link HttpServletRequest} and
* {@link HttpServletResponse}. If the {@link CsrfToken} is null, it is the same as
* deleting it.
*
* @param token the {@link CsrfToken} to save or null to delete
* @param request the {@link HttpServletRequest} to use
* @param response the {@link HttpServletResponse} to use
*/
void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response);

/**
* Loads the expected {@link CsrfToken} from the {@link HttpServletRequest}
*
* @param request the {@link HttpServletRequest} to use
* @return the {@link CsrfToken} or null if none exists
*/
CsrfToken loadToken(HttpServletRequest request);
}

定了三个方法,分别表述如何生成 Token,如何保存 Token,如何加载 Token;再来看看 HttpSessionCsrfTokenRepository 的实现,

HttpSessionCsrfTokenRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";

private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";

private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class
.getName().concat(".CSRF_TOKEN");

private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

private String headerName = DEFAULT_CSRF_HEADER_NAME;

private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;

/*
* (non-Javadoc)
*
* @see
* org.springframework.security.web.csrf.CsrfTokenRepository#saveToken(org.springframework
* .security.web.csrf.CsrfToken, javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
if (token == null) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(sessionAttributeName);
}
}
else {
HttpSession session = request.getSession();
session.setAttribute(sessionAttributeName, token);
}
}

/*
* (non-Javadoc)
*
* @see
* org.springframework.security.web.csrf.CsrfTokenRepository#loadToken(javax.servlet
* .http.HttpServletRequest)
*/
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(sessionAttributeName);
}

/*
* (non-Javadoc)
*
* @see
* org.springframework.security.web.csrf.CsrfTokenRepository#generateToken(javax.servlet
* .http.HttpServletRequest)
*/
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(headerName, parameterName, createNewToken());
}

/**
* Sets the {@link HttpServletRequest} parameter name that the {@link CsrfToken} is
* expected to appear on
* @param parameterName the new parameter name to use
*/
public void setParameterName(String parameterName) {
Assert.hasLength(parameterName, "parameterName cannot be null or empty");
this.parameterName = parameterName;
}

/**
* Sets the header name that the {@link CsrfToken} is expected to appear on and the
* header that the response will contain the {@link CsrfToken}.
*
* @param headerName the new header name to use
*/
public void setHeaderName(String headerName) {
Assert.hasLength(headerName, "headerName cannot be null or empty");
this.headerName = headerName;
}

/**
* Sets the {@link HttpSession} attribute name that the {@link CsrfToken} is stored in
* @param sessionAttributeName the new attribute name to use
*/
public void setSessionAttributeName(String sessionAttributeName) {
Assert.hasLength(sessionAttributeName,
"sessionAttributename cannot be null or empty");
this.sessionAttributeName = sessionAttributeName;
}

private String createNewToken() {
return UUID.randomUUID().toString();
}
}

我们主要关注 saveToken 和 loadToken 两个方法,

  1. saveToken 把当前的 csrf token 保存到 session 中;
  2. loadToken 从当前的 session 中加载 csrf token;

流程分析

下面,作者就一个简单的登录场景来对流程以及源码进行梳理和分析;这里可以使用我之前写的一个例子 https://github.com/comedsh/spirngcloud-demo 来做为 demo,用于流程分析,使用的场景是,使用 Authentication Server 作为测试即可,启动 Authentication Server,将断点分别打在 CsrfFilter#doFilterInternal 和 CsrfTokenRepository#saveToken 的方法上;

登录

在浏览器中访问 http://localhost:9999/uaa/login 会跳转入 CsrfFilter#doFilterInternal 方法中,由前面对 CsrfFilter 的源码分析可知,这一步是 GET 请求,且 session 中并没有保存针对当前 session (也可理解为 session id) 相关联的 Csrf Token,所以,这里会创建一个新的,并放入 request 的 attrs 中;然后直接跳转到下一个 filter,最后就直接返回了;这里读者应该会好奇的是,不是说,该新生成的 Csrf Token 需要保存在 session 当中吗,这里我怎么没看到呢?对的,你的判断是正确的,的确,该新生成的 Csrf Token 不是在 CsrfFilter#doFilterInternal 中保存的,虽然,作者和你的感知是相同的,我也认为,Csrf Token 的保存逻辑应该放在 CsrfFilter#doFilterInternal 中,但是,Spring Security 的源码告诉我,的确不是这样的;那么,该新创建的 Csrf Token 是在什么地方创建的呢?

的确,Spring 在 session 中保存了 Csrf Token,但是,让人大跌眼镜的是,它很后面很后面很后面,几乎跟 Filter 的逻辑不沾边的地方创建的,给一张调用链图,

对的,你没看错,csrf token 的保存是在 render 既是在页面渲染的时候才保存的,那么 render 又是什么呢?它意味着什么呢?Ok,作者为什么说 csrf token 保存的逻辑非常滞后,而且几乎与 Filter 的逻辑不沾边了呢?那是因为 render 是在所有的 Filters 执行完成、所有的 Controller 方法执行完成、所有的 Services 执行完成、几乎所有的数据库调用执行完成等等机会所有业务相关的、系统相关的逻辑都调用完成以后,才轮到 render,而本来 csrf token 从业务逻辑和流程上来将,无论如何都是与 Filter 相关的东西,却没有在 Filters 中实现;这对一个源码分析者而言,是多大的痛苦… 之前,分析到这里,真的是百思不得其解;有时候,分析 Spring 相关的源码,真的比较痛苦,不是因为毅力不够,而是代码逻辑太匪夷所思,也是作者有时候读 Spring 源码到吐血的原因…

表单提交 POST

输入用户名密码登录,进入 CsrfFilter#doFilterInternal 方法,由前面对 CsrfFilter 的源码分析可知,因为这次是 POST 请求,所以会进入 CsrfFilter 中所描述的第 4 步;执行的逻辑就非常的简单直接了,直接从 Session 中拿到与当前 session id 相关联的 csrf token,然后与客户端提交过来的 csrf token 进行比较,若相等则验证通过,若不等,则验证失败;

UsernamePasswordAuthenticationFilter

整个调用流程是,先调用其父类 AbstractAuthenticationProcessingFilter#doFilter 方法,然后再执行 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法进行验证;

AbstractAuthenticationProcessingFilter.doFilter

摘录其相关核心代码如下,同样只留下了代码最核心的骨架,

AbstractAuthenticationProcessingFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

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

...

Authentication authResult;

try {
// 1. 调用 UsernamePasswordAuthenticationFilter 执行验证
authResult = attemptAuthentication(request, response);

...
// 2. 当认证成功以后,回调一些与 session 相关的方法
sessionStrategy.onAuthentication(authResult, request, response);
}catch{
...
}

...
// 3. 认证成功后的相关回调方法
successfulAuthentication(request, response, chain, authResult);
}

整个程序的执行流程如下,

  1. 调用 UsernamePasswordAuthenticationFilter 执行验证;
    该部分的详细分析参考下一小节,UsernamePasswordAuthenticationFilter.attemptAuthentication

  2. 认证成功以后,回调一些与 session 相关的方法;
    该方法会调用 CompositeSessionAuthenticationStrategy.onAuthentication,主要做了两件事情

    • 调用 ChangeSessionIdAuthenticationStrategy.onAuthentication 方法,使得登录成功的用户替换当前的 sessionid
    • 调用 CsrfAuthenticationStrategy.onAuthentication 方法,目的是为登录成功以后的用户生成新的 csrf token,该 csrf token 主要用于后续的连接请求;
  3. 认证成功以后,认证成功后的相关回调方法;相关代码如下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    protected void successfulAuthentication(HttpServletRequest request,
    HttpServletResponse response, FilterChain chain, Authentication authResult)
    throws IOException, ServletException {

    SecurityContextHolder.getContext().setAuthentication(authResult);

    rememberMeServices.loginSuccess(request, response, authResult);

    // Fire event
    if (this.eventPublisher != null) {
    eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
    authResult, this.getClass()));
    }

    successHandler.onAuthenticationSuccess(request, response, authResult);
    }
    • 将当前认证成功的 Authentication 放置到 SecurityContextHolder 中;
    • 通知 remember me 设置认证成功的用户信息
    • 调用其它可扩展的 handlers 继续处理该认证成功以后的回调事件;

UsernamePasswordAuthenticationFilter.attemptAuthentication

UsernamePasswordAuthenticationFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
// 1. 从 request 中获取 username 和 password
String username = obtainUsername(request);
String password = obtainPassword(request);

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

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

username = username.trim();

// 2. 构建相关的 Authentication Object
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);

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

// 3. 调用 AuthenticationManager 进行验证
return this.getAuthenticationManager().authenticate(authRequest);
}

  1. 从 request 中获取 username 和 password
  2. 封装 username 和 password 生成相关的 Authentication 对象 UsernamePasswordAuthenticationToken;
  3. 调用 AuthenticationManager 的 authenticate 方法进行验证
    这部分源码的分析参考 ProviderManager 部分,需要注意的是,这里的 AuthenticationProvider 使用的是 DaoAuthenticationProvider 对象;

BasicAuthenticationFilter

当执行完 UsernamePasswordAuthenticationFilter 的相关逻辑后,将会立刻执行 BasicAuthenticationFilter;同样,该调用过程分为两个部分,先是调用其父类 OncePerRequestFilter#doFilter 方法,然后再执行 BasicAuthenticationFilter#doFilterInternal 方法;其父类的执行方法非常简单,这里不做分析,直接看看 BasicAuthenticationFilter#doFilterInternal 方法的源码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws IOException,
ServletException {
final boolean debug = logger.isDebugEnabled();

// 1. 从 header 中的 Authorization 的 base 64 编码字符串中获得 username 和 password
String header = request.getHeader("Authorization");

try {

String[] tokens = extractAndDecodeHeader(header, request);

assert tokens.length == 2;

String username = tokens[0];

// 2. 执行验证
if (authenticationIsRequired(username)) {
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, tokens[1]);
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
// 调用 Authentication Manager 进行验证
Authentication authResult = authenticationManager
.authenticate(authRequest);

if (debug) {
logger.debug("Authentication success: " + authResult);
}

// 认证成功以后的回调方法;
SecurityContextHolder.getContext().setAuthentication(authResult);

rememberMeServices.loginSuccess(request, response, authResult);

onSuccessfulAuthentication(request, response, authResult);
}

}
catch (AuthenticationException failed) {
...
}

chain.doFilter(request, response);
}

核心逻辑分为三个步骤,

  1. 从 header 中的 Authorization 的 base 64 编码字符串中获得 username 和 password;
    这里的 extractAndDecodeHeader(header, request) 方法内容是精华,可以清楚的看到这里是如何解析 header 中的 Authorization 得到 username 和 password 的,相关代码摘录如下,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    private String[] extractAndDecodeHeader(String header, HttpServletRequest request)
    throws IOException {

    byte[] base64Token = header.substring(6).getBytes("UTF-8");
    byte[] decoded;
    try {
    decoded = Base64.decode(base64Token);
    }
    catch (IllegalArgumentException e) {
    throw new BadCredentialsException(
    "Failed to decode basic authentication token");
    }

    String token = new String(decoded, getCredentialsCharset(request));

    int delim = token.indexOf(":");

    if (delim == -1) {
    throw new BadCredentialsException("Invalid basic authentication token");
    }
    return new String[] { token.substring(0, delim), token.substring(delim + 1) };
    }
  2. 执行验证
    这里主要的逻辑就是调用 AuthenticationManager 对当前的 UsernamePasswordAuthenticationToken 进行验证,相关逻辑和 UsernamePasswordAuthenticationFilter 的认证过程类似,不再赘述;

  3. 认证成功以后的回调方法

FilterSecurityInterceptor

相关的核心代码摘录如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void invoke(FilterInvocation fi) throws IOException, ServletException {

// 1. before invocation
InterceptorStatusToken token = super.beforeInvocation(fi);

// 2. 调用余下的 filter chains
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}

// 3. 完成调用以后的相关回调方法
super.afterInvocation(token, null);

}

FilterSecurityInterceptor 的执行逻辑主要分为三个部分,

  1. before invocation
  2. 调用余下的 filter chains
  3. 完成调用以后的相关回调方法

三个部分中,最重要的是 #1,该过程中会调用 AccessDecisionManager 来验证当前已认证成功的用户是否有权限访问该资源;该部分的详细分析参考 before invocation: AccessDecisionManager

before invocation: AccessDecisionManager

其核心代码摘要如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
protected InterceptorStatusToken beforeInvocation(Object object) {

...

Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

...

Authentication authenticated = authenticateIfRequired();

// 1. 调用 AccessDecisionManager 来决定当前用户是否有权限访问资源
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
...
}

// 2. Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
attributes);

if (runAs == null) {

// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

} else {

SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);

// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}

首先,要搞懂这里的入参 object 指的是什么,得到的 attributes 又是什么;

object 指的是什么?

打印出来为FilterInvocation: URL: /,这表示的是什么呢?它其实表示的就是当前用户想要访问的资源的路径,但是,我明明访问的是 http://localhost:8080/servletapi/login 怎么变成 ‘/‘ 了呢?回顾一下我们的 demo,该 demo 关于权限的配置非常的简单,其相关的配置如下,

1
2
3
4
5
6
7
8
9
10
11
<http auto-config="true">
<intercept-url pattern="/**" access="permitAll"/>
</http>
<authentication-manager>
<authentication-provider>
<user-service>
<user name="user" password="password" authorities="ROLE_USER" />
<user name="admin" password="password" authorities="ROLE_USER,ROLE_ADMIN"/>
</user-service>
</authentication-provider>
</authentication-manager>

该配置使用了 http 的自动化配置,并且,所有的连接路径都是 permitAll 的;所以,实际上并不会因为访问到了某个非法页面以后,触发拦截,再跳转到认证登录页面,这种场景在以后有时间再来详细的分析;这里呢,我们依然回到这个简单的场景上来,输入登录连接 http://localhost:8080/servletapi/login 然后直接登录,不存在拦截跳转的过程,这种场景下,实际上访问请求的资源默认是 “/“,对应也就是连接 http://localhost:8080/servletapi/ 好了,当这些前置条件分析清楚了以后,这里的实现逻辑就是,验证当前已登录成功的用户是否有权限访问资源 “/“,这就是认证和授权分开的一个典型实现;

attributes 又是什么?

1
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

这里的 attributes 其实就是从 Security 相关的配置元数据中去找到相关的 access 决策;什么意思呢?其实它就是从所有的 <intercept-url> 的配置元素中去找到匹配的 access 规则;demo 中相关的配置如下,

1
2
3
<http auto-config="true">
<intercept-url pattern="/**" access="permitAll"/>
</http>

那么查找的逻辑就是,使用当前的访问资源路径 “/“ 去匹配所有的 <intercept-url> 找到最合适的 access 值;当然,这里找到的就是属性值 permitAll;所以,这里返回的 attributes 就是 permitAll;换句话说,就是当用户访问资源 “/“ 的时候,匹配的 access 规则是 permitAll,而该属性由当前的 attributes 变量保存;

当然,这个官方的例子实际上确实太过于简单,一般而言,access 会配置成为某个角色才能够进行访问,比如 access=”ROLE_ADMIN, ROLE_SUPER_ADMIN” 等等;也就是说某个特定用户角色才能够访问该特定的资源;不过也没关系,该简单的示例同样不影响我们去分析该整个大的主流程逻辑;让我们回到源码;

该部分代码最核心的逻辑包含两个部分,

  1. 调用 AccessDecisionManager 来决定当前用户是否有权限访问资源
  2. Attempt to run as a different user

第二步很少有机会或者几乎不会用到这个逻辑,所以,这里着重对第一个部分的逻辑进行阐述;由 AccessDecisionManager 的分析可知,这里的 accessDecisionManager 实际上使用的就是 AffirmativeBased;那么看看 AffirmativeBased.decide 的源码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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"));
}

// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}

可见,这里主要是通过 AccessDecisionVoter 对象的 vote 方法来决定当前的用户是否有权限访问当前的资源;实际上,通过上述的 Spring Security 的配置,这里只有一个 Voter 实例,那就是 org.springframework.security.web.access.expression.WebExpressionVoter,然后将会调用其方法 vote(Authentication, FilterInvocation, Collection) 进行验证,相关源码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int vote(Authentication authentication, FilterInvocation fi,
Collection<ConfigAttribute> attributes) {
assert authentication != null;
assert fi != null;
assert attributes != null;

WebExpressionConfigAttribute weca = findConfigAttribute(attributes);

if (weca == null) {
return ACCESS_ABSTAIN;
}

EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication,
fi);

return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED
: ACCESS_DENIED;
}

该方法的三个参数,authentication、fi、attributes 分别为当前用户信息、访问资源路径信息以及 access 属性值;这里要做的实际上很简单了,就是判断当前用户的角色 Authentication.authorities 是否有权限访问 access 当前的资源 _fi_;

总结

可见,一般而言,这类 Web Security 的验证流程就是,首先通过 UsernamePasswordAuthenticationFilter、BasicAuthenticationFilter 等 Filters 通过调用 AuthenticationManager 对用户进行身份验证,验证通过以后,再通过 FilterSecurityInterceptor 调用 AccessDecisionManager 来判断当前已通过认证的用户是否有权限访问该资源,该用户当前试图访问的资源;可见,Spring Security 严格的将用户身份认证和资源访问授权严格的区分开来,真正做到了认证和(资源访问)授权的逻辑严格分离;

plan

后续打算再写一系列有关方法级别授权的 Spring Security 的文章;不过,当务之急还是回到 Spring Cloud Security 的源码分析上来;所以这一部分就暂告一段落了;