SpringBoot2整合SpringSecurity+Swagger3系列
首先开启Security日志
logging.level.org.springframework.security.web=debug
浏览器访问http://localhost:8080/swagger-ui/index.html
,通过Spring Security的过滤器,对应的日志如下所示(从侧面印证了Spring Security是基于Filter的)
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 1 of 14 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 2 of 14 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
w.c.HttpSessionSecurityContextRepository : No SecurityContext was available from the HttpSession: null. A new one will be created.
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 3 of 14 in additional filter chain; firing Filter: 'HeaderWriterFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 4 of 14 in additional filter chain; firing Filter: 'CsrfFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 5 of 14 in additional filter chain; firing Filter: 'LogoutFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Request 'GET /swagger-ui/index.html' doesn't match 'POST /logout'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 6 of 14 in additional filter chain; firing Filter: 'UsernamePasswordAuthenticationFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Request 'GET /swagger-ui/index.html' doesn't match 'POST /login'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 7 of 14 in additional filter chain; firing Filter: 'DefaultLoginPageGeneratingFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 8 of 14 in additional filter chain; firing Filter: 'DefaultLogoutPageGeneratingFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Checking match of request : '/swagger-ui/index.html'; against '/logout'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 9 of 14 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
o.s.s.w.s.HttpSessionRequestCache : saved request doesn't match
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 10 of 14 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 11 of 14 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
o.s.s.w.a.AnonymousAuthenticationFilter : Populated SecurityContextHolder with anonymous token: 'org.springframework.security.authentication.AnonymousAuthenticationToken@710d44e1: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 12 of 14 in additional filter chain; firing Filter: 'SessionManagementFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 13 of 14 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 14 of 14 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
o.s.s.w.a.i.FilterSecurityInterceptor : Secure object: FilterInvocation: URL: /swagger-ui/index.html; Attributes: [authenticated]
o.s.s.w.a.i.FilterSecurityInterceptor : Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@710d44e1: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
o.s.s.w.a.ExceptionTranslationFilter : Access is denied (user is anonymous); redirecting to authentication entry point
在最后一个过滤器FilterSecurityInterceptor中因为用户是匿名用户(没有认证),抛出了异常:org.springframework.security.access.AccessDeniedException: Access is denied
。这个异常又会被ExceptionTranslationFilter捕获到,然后进行异常的处理。ExceptionTranslationFilter的过滤逻辑如下所示:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
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);
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);
}
}
}
在catch异常块当中,根据异常的不同,会进入到handleSpringSecurityException当中。又会将异常分为AuthenticationException、AccessDeniedException或其他异常进行不同的处理。当然了无论是认证失败还是没有访问权限都是需要进行认证的,也就是sendStartAuthentication。
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
logger.debug(
"Authentication exception occurred; redirecting to authentication entry point",
exception);
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
logger.debug(
"Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point",
exception);
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
messages.getMessage(
"ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
logger.debug(
"Access is denied (user is not anonymous); delegating to AccessDeniedHandler",
exception);
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
在进行认证授权之前会清空SecurityContext共享数据,同时会保存请求信息到缓存当中,然后开始进入认证阶段。
-- org.springframework.security.web.access.ExceptionTranslationFilter#sendStartAuthentication
protected void sendStartAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContextHolder.getContext().setAuthentication(null);
requestCache.saveRequest(request, response);
logger.debug("Calling Authentication entry point.");
authenticationEntryPoint.commence(request, response, reason);
}
所谓的认证就是用户进行登录,所以要跳转到登录页面,这里实现为LoginUrlAuthenticationEntryPoint
.commence
方法会进行请求重定向。
这里redirectStrategy的实现为默认实现DefaultRedirectStrategy,重定向本质就是调用Servlet的sendRedirect方法。
public void sendRedirect(HttpServletRequest request, HttpServletResponse response,
String url) throws IOException {
String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
if (logger.isDebugEnabled()) {
logger.debug("Redirecting to '" + redirectUrl + "'");
}
response.sendRedirect(redirectUrl);
}
此时控制台会打印如下的日志
o.s.s.w.s.HttpSessionRequestCache : DefaultSavedRequest added to Session: DefaultSavedRequest[http://localhost:8080/swagger-ui/index.html]
o.s.s.w.a.ExceptionTranslationFilter : Calling Authentication entry point.
o.s.s.web.DefaultRedirectStrategy : Redirecting to 'http://localhost:8080/login'
o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@622ee42a
w.c.HttpSessionSecurityContextRepository : SecurityContext is empty or contents are anonymous - context will not be stored in HttpSession.
s.s.w.c.SecurityContextPersistenceFilter : SecurityContextHolder now cleared, as request processing completed
从上面后三行日志也可以看出,过滤器链的其他排在前面的过滤器的finally方法都会执行。重定向之后,再次接收重定向请求。通过Tomcat的过滤器链之后,再次进入Spring Security的过滤器链,但是此次请求路径不同了。这一次的请求信息如下
************************************************************
Request received for GET '/login':
org.apache.catalina.connector.RequestFacade@6425309
servletPath:/login
pathInfo:null
headers:
host: localhost:8080
connection: keep-alive
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-fetch-site: none
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"
sec-ch-ua-mobile: ?0
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cookie: XSRF-TOKEN=6ac58825-4bfe-4e53-b62d-41c218325292; JSESSIONID=0313F59610953C442B399C32674C5E02
Security filter chain: [
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CsrfFilter
LogoutFilter
UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
]
************************************************************
在通过DefaultLoginPageGeneratingFilter过滤器的时候因为满足登录页面的条件,会向返回流写入登录页面,并且直接返回,不继续接下来的过滤器了。
控制台日志如下
o.s.security.web.FilterChainProxy : /login at position 7 of 14 in additional filter chain; firing Filter: 'DefaultLoginPageGeneratingFilter'
o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@622ee42a
w.c.HttpSessionSecurityContextRepository : SecurityContext is empty or contents are anonymous - context will not be stored in HttpSession.
s.s.w.c.SecurityContextPersistenceFilter : SecurityContextHolder now cleared, as request processing completed
而浏览器页面此时为(重定向,所以请求地址已经变化了)
从浏览器控制台也可以看出,以上过程其实是分为两步的,有两次请求(当然了,这里不包含登录页面中包含的css静态资源的再次请求)
对应login请求的信息如下所示
Request URL: http://localhost:8080/login
Request Method: GET
Status Code: 200
Remote Address: [::1]:8080
Referrer Policy: strict-origin-when-cross-origin
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Length: 1406
Content-Type: text/html;charset=UTF-8
Date: Mon, 02 Aug 2021 11:49:55 GMT
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
Cookie: XSRF-TOKEN=6ac58825-4bfe-4e53-b62d-41c218325292; JSESSIONID=0313F59610953C442B399C32674C5E02
Host: localhost:8080
sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"
sec-ch-ua-mobile: ?0
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
login请求返回的信息正是后台拼接的页面
页面返回之后,浏览器又会继续请求对应的css文件。
输入用户和密码之后发起post登录请求Request received for POST '/login'
。进入到UsernamePasswordAuthenticationFilter过滤器中,由于当前请求时一个POST的请求,所以需要进行认证。主要的逻辑是
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
Authentication authResult;
try {
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) {
// Authentication failed
unsuccessfulAuthentication(request, response, failed);
return;
}
// Authentication success
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authResult);
进行认证操作attemptAuthentication,从请求当中获取用户名和密码,并调用认证管理器进行认证。
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
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 authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
从前面章节的分析中可以直接,其实authenticationManager管理器的实现类是ProviderManager,因此会执行org.springframework.security.authentication.ProviderManager#authenticate方法。尝试对传递的 Authentication 对象进行身份验证。AuthenticationProviders 的列表将被连续尝试,直到某个AuthenticationProvider表明它能够验证所传递的Authentication对象的类型。然后将尝试使用该 AuthenticationProvider 进行身份验证。如果多个AuthenticationProvider支持传递的Authentication对象,则第一个能够成功验证Authentication对象的确定结果,覆盖由早期支持的 AuthenticationProviders抛出的任何可能的AuthenticationException。 成功验证后,将不会尝试后续的 AuthenticationProviders。如果任何支持AuthenticationProvider的身份验证未成功,最后抛出的 AuthenticationException 将被重新抛出。
这里首先看一下AuthenticationProvider接口定义
/**
* Indicates a class can process a specific
* {@link org.springframework.security.core.Authentication} implementation.
*
* @author Ben Alex
*/
public interface AuthenticationProvider {
// ~ Methods
// ========================================================================================================
/**
* Performs authentication with the same contract as
* {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
* .
*
* @param authentication the authentication request object.
*
* @return a fully authenticated object including credentials. May return
* <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
* authentication of the passed <code>Authentication</code> object. In such a case,
* the next <code>AuthenticationProvider</code> that supports the presented
* <code>Authentication</code> class will be tried.
*
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
/**
* Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
* indicated <Code>Authentication</code> object.
* <p>
* Returning <code>true</code> does not guarantee an
* <code>AuthenticationProvider</code> will be able to authenticate the presented
* instance of the <code>Authentication</code> class. It simply indicates it can
* support closer evaluation of it. An <code>AuthenticationProvider</code> can still
* return <code>null</code> from the {@link #authenticate(Authentication)} method to
* indicate another <code>AuthenticationProvider</code> should be tried.
* </p>
* <p>
* Selection of an <code>AuthenticationProvider</code> capable of performing
* authentication is conducted at runtime the <code>ProviderManager</code>.
* </p>
*
* @param authentication
*
* @return <code>true</code> if the implementation can more closely evaluate the
* <code>Authentication</code> class presented
*/
boolean supports(Class<?> authentication);
}
每一个AuthenticationProvider都支持特定类型的认证对象,比如前面的AnonymousAuthenticationProvider支持AnonymousAuthenticationToken类型的认证对象。如果满足匹配要求,则通过authenticate方法进行认证。如果返回为空,则代表不支持当前认证。如果认证没有问题,则返回带有密码信息的认证对象。当然,也可能会抛出异常,比如AuthenticationException,调用方可以根据异常类型是否需要继续下一个AuthenticationProvider认证,还是直接就结束认证。在ProviderManager当中,对于AuthenticationException是不会直接抛出异常,而是继续下一个匹配。但是AccountStatusException、InternalAuthenticationServiceException异常则抛出异常,结束认证。
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
}
catch (InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
}
catch (AuthenticationException e) {
lastException = e;
}
如果遍历了所有的providers没有支持的,则继续遍历parent认证管理器继续查找匹配的AuthenticationProvider。在这里,当前对象中的providers值包含一个匿名认证的。
对应的support方法为
public boolean supports(Class<?> authentication) {
return (AnonymousAuthenticationToken.class.isAssignableFrom(authentication));
}
所以不匹配,然后没有其他的provider,所以只能继续通过parent进行认证。
if (result == null && parent != null) {
// Allow the parent to try.
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}
parent本质上也是一个ProviderManager,只不过包含的providers内容可能不一样。
比如这里为DaoAuthenticationProvider,匹配认证对象类型为UsernamePasswordAuthenticationToken。
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
匹配成功,接下来进行认证,依次调用DaoAuthenticationProvider(authenticate)、DaoAuthenticationProvider(retrieveUser)、InMemoryUserDetailsManager(loadUserByUsername)来获取用户信息
接下来还需要通过UserDetailsChecker来进行一些前置检测(PreAuthenticationCheck),比如账户是否被锁定、用户是否无效、用户是否过期。
private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
logger.debug("User account is locked");
throw new LockedException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.locked",
"User account is locked"));
}
if (!user.isEnabled()) {
logger.debug("User account is disabled");
throw new DisabledException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.disabled",
"User is disabled"));
}
if (!user.isAccountNonExpired()) {
logger.debug("User account is expired");
throw new AccountExpiredException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.expired",
"User account has expired"));
}
}
}
正式检测,主要是进行密码的匹配操作DaoAuthenticationProvider#additionalAuthenticationChecks
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();
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"));
}
}
最终是比较传入的明文与加密后的密码是否匹配
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
接下来还要检查一下密码是否过期
private class DefaultPostAuthenticationChecks implements UserDetailsChecker {
public void check(UserDetails user) {
if (!user.isCredentialsNonExpired()) {
logger.debug("User account credentials have expired");
throw new CredentialsExpiredException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.credentialsExpired",
"User credentials have expired"));
}
}
}
以上步骤汇总如下
最后返回的认证对象如下所示
认证成功之后,相当于ProviderManager#authenticate认证成功,接下来还要擦除认证信息当中的敏感私密信息以及发布认证成功事件。
擦除敏感信息
/**
* Checks the {@code credentials}, {@code principal} and {@code details} objects,
* invoking the {@code eraseCredentials} method on any which implement
* {@link CredentialsContainer}.
*/
public void eraseCredentials() {
eraseSecret(getCredentials());
eraseSecret(getPrincipal());
eraseSecret(details);
}
private void eraseSecret(Object secret) {
if (secret instanceof CredentialsContainer) {
((CredentialsContainer) secret).eraseCredentials();
}
}
接收认证成功事件AuthenticationAuditListener#onApplicationEvent
@Override
public void onApplicationEvent(AbstractAuthenticationEvent event) {
if (event instanceof AbstractAuthenticationFailureEvent) {
onAuthenticationFailureEvent((AbstractAuthenticationFailureEvent) event);
}
else if (this.webListener != null && this.webListener.accepts(event)) {
this.webListener.process(this, event);
}
else if (event instanceof AuthenticationSuccessEvent) {
onAuthenticationSuccessEvent((AuthenticationSuccessEvent) event);
}
}
private void onAuthenticationSuccessEvent(AuthenticationSuccessEvent event) {
Map<String, Object> data = new HashMap<>();
if (event.getAuthentication().getDetails() != null) {
data.put("details", event.getAuthentication().getDetails());
}
publish(new AuditEvent(event.getAuthentication().getName(), AUTHENTICATION_SUCCESS, data));
}
这里发布了一个AuditEvent,然后转为AuditApplicationEvent,在AbstractAuditListener监听器中监听AuditApplicationEvent,在子类AuditListener监听器中处理AuditEvent事件
@Override
protected void onAuditEvent(AuditEvent event) {
if (logger.isDebugEnabled()) {
logger.debug(event);
}
this.auditEventRepository.add(event);
}
这里auditEventRepository实现为InMemoryAuditEventRepository,只是向内存中添加了事件,这里使用的是数组数据结构
通过以上步骤之后,说明认证成功,返回到UsernamePasswordAuthenticationFilter过滤器当中。
这里的SessionAuthenticationStrategy实现为CompositeSessionAuthenticationStrategy,典型的策略模式(也像代理模式),具体逻辑由代理策略列表来实现。
策略模式
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Delegating to " + delegate);
}
delegate.onAuthentication(authentication, request, response);
}
}
此时日志信息如下
s.CompositeSessionAuthenticationStrategy : Delegating to org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy@3f7fb387
s.CompositeSessionAuthenticationStrategy : Delegating to org.springframework.security.web.csrf.CsrfAuthenticationStrategy@50262c5a
前者用于修改session信息,但是由于session信息不存在,所以ChangeSessionIdAuthenticationStrategy没有执行啥具体的逻辑,而CsrfAuthenticationStrategy用于生成新的token并保存。
@Override
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken(null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
}
}
接下来回进入successfulAuthentication逻辑,首先是将认证信息保存到SecurityContext当中。通知RememberMeServices登录成功(默认没有实现),发布InteractiveAuthenticationSuccessEvent事件,
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
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);
}
日志信息如下:w.a.UsernamePasswordAuthenticationFilter : Authentication success. Updating SecurityContextHolder to contain: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cdf7a4: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@0: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: 8FDB24BD810613E622DD59196E26AE48; Granted Authorities: ADMIN
AuthenticationSuccessHandler处理认证成功事件。最后执行重定向。
当前请求结束,开始返回,完整日志如下所示
o.s.security.web.FilterChainProxy : /login at position 1 of 14 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
o.s.security.web.FilterChainProxy : /login at position 2 of 14 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
w.c.HttpSessionSecurityContextRepository : HttpSession returned null object for SPRING_SECURITY_CONTEXT
w.c.HttpSessionSecurityContextRepository : No SecurityContext was available from the HttpSession: org.apache.catalina.session.StandardSessionFacade@3625d86f. A new one will be created.
o.s.security.web.FilterChainProxy : /login at position 3 of 14 in additional filter chain; firing Filter: 'HeaderWriterFilter'
o.s.security.web.FilterChainProxy : /login at position 4 of 14 in additional filter chain; firing Filter: 'CsrfFilter'
o.s.security.web.FilterChainProxy : /login at position 5 of 14 in additional filter chain; firing Filter: 'LogoutFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Checking match of request : '/login'; against '/logout'
o.s.security.web.FilterChainProxy : /login at position 6 of 14 in additional filter chain; firing Filter: 'UsernamePasswordAuthenticationFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Checking match of request : '/login'; against '/login'
w.a.UsernamePasswordAuthenticationFilter : Request is to process authentication
s.CompositeSessionAuthenticationStrategy : Delegating to org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy@3f7fb387
s.CompositeSessionAuthenticationStrategy : Delegating to org.springframework.security.web.csrf.CsrfAuthenticationStrategy@50262c5a
w.a.UsernamePasswordAuthenticationFilter : Authentication success. Updating SecurityContextHolder to contain: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cfed88: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@21a2c: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: CD51ABB99005D448F3500E57B5F109FB; Granted Authorities: ADMIN
RequestAwareAuthenticationSuccessHandler : Redirecting to DefaultSavedRequest Url: http://localhost:8080/swagger-ui/index.html
o.s.s.web.DefaultRedirectStrategy : Redirecting to 'http://localhost:8080/swagger-ui/index.html'
o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@202268fc
w.c.HttpSessionSecurityContextRepository : SecurityContext 'org.springframework.security.core.context.SecurityContextImpl@f9cfed88: Authentication: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cfed88: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@21a2c: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: CD51ABB99005D448F3500E57B5F109FB; Granted Authorities: ADMIN' stored to HttpSession: 'org.apache.catalina.session.StandardSessionFacade@3625d86f
s.s.w.c.SecurityContextPersistenceFilter : SecurityContextHolder now cleared, as request processing completed
从以上日志也可以看出,请求执行到UsernamePasswordAuthenticationFilter之后就返回了。并重定向到http://localhost:8080/swagger-ui/index.html。
接下来浏览器继续发起重定向请求
Request received for GET '/swagger-ui/index.html':
org.apache.catalina.connector.RequestFacade@519e591d
servletPath:/swagger-ui/index.html
pathInfo:null
headers:
host: localhost:8080
connection: keep-alive
cache-control: max-age=0
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-fetch-site: same-origin
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"
sec-ch-ua-mobile: ?0
referer: http://localhost:8080/login
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cookie: JSESSIONID=D2C9F9F2637B811305A01CEB8D3869D6; XSRF-TOKEN=ad20ebcc-55eb-4793-9306-6b40c1c413f2
对比login请求时的cookie信息,可以发现token其实已经是变更了的
cookie: XSRF-TOKEN=1b07cb5d-d566-4711-95be-d01947e2f263; JSESSIONID=CD51ABB99005D448F3500E57B5F109FB
此时loadContext可以从session中获取到SecurityContext,并根据SecurityContext创建包装的反应实例和包装的请求实例。
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
if (context == null) {
if (logger.isDebugEnabled()) {
logger.debug("No SecurityContext was available from the HttpSession: "
+ httpSession + ". " + "A new one will be created.");
}
context = generateNewContext();
}
SaveToSessionResponseWrapper wrappedResponse = new SaveToSessionResponseWrapper(
response, request, httpSession != null, context);
requestResponseHolder.setResponse(wrappedResponse);
if (isServlet3) {
requestResponseHolder.setRequest(new Servlet3SaveToSessionRequestWrapper(
request, wrappedResponse));
}
return context;
}
在FilterSecurityInterceptor过滤器当中,因为当前用户已经认证过了,所以这里不需要继续认证了
控制台包含以下日志。Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9c9c3fc: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@43458: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: 6FD45537F1985AEEDA20EDE93557FD8D; Granted Authorities: ADMIN
.
这里runAsManager目前没有实现,返回runAs为空,所以只会返回一个InterceptorStatusToken
.
这个也是Spring Security的最后一个拦截器了,接下来又会返回到Tomcat容器的拦截器链。此时控制台会包含以下日志。/ reached end of additional filter chain; proceeding with original chain
.
以下为此时完整的Spring Security日志
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 1 of 14 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 2 of 14 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
w.c.HttpSessionSecurityContextRepository : Obtained a valid SecurityContext from SPRING_SECURITY_CONTEXT: 'org.springframework.security.core.context.SecurityContextImpl@f9cfed88: Authentication: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cfed88: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@21a2c: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: CD51ABB99005D448F3500E57B5F109FB; Granted Authorities: ADMIN'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 3 of 14 in additional filter chain; firing Filter: 'HeaderWriterFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 4 of 14 in additional filter chain; firing Filter: 'CsrfFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 5 of 14 in additional filter chain; firing Filter: 'LogoutFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Request 'GET /swagger-ui/index.html' doesn't match 'POST /logout'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 6 of 14 in additional filter chain; firing Filter: 'UsernamePasswordAuthenticationFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Request 'GET /swagger-ui/index.html' doesn't match 'POST /login'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 7 of 14 in additional filter chain; firing Filter: 'DefaultLoginPageGeneratingFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 8 of 14 in additional filter chain; firing Filter: 'DefaultLogoutPageGeneratingFilter'
o.s.s.w.u.matcher.AntPathRequestMatcher : Checking match of request : '/swagger-ui/index.html'; against '/logout'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 9 of 14 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
o.s.s.w.s.DefaultSavedRequest : pathInfo: both null (property equals)
o.s.s.w.s.DefaultSavedRequest : queryString: both null (property equals)
o.s.s.w.s.DefaultSavedRequest : requestURI: arg1=/swagger-ui/index.html; arg2=/swagger-ui/index.html (property equals)
o.s.s.w.s.DefaultSavedRequest : serverPort: arg1=8080; arg2=8080 (property equals)
o.s.s.w.s.DefaultSavedRequest : requestURL: arg1=http://localhost:8080/swagger-ui/index.html; arg2=http://localhost:8080/swagger-ui/index.html (property equals)
o.s.s.w.s.DefaultSavedRequest : scheme: arg1=http; arg2=http (property equals)
o.s.s.w.s.DefaultSavedRequest : serverName: arg1=localhost; arg2=localhost (property equals)
o.s.s.w.s.DefaultSavedRequest : contextPath: arg1=; arg2= (property equals)
o.s.s.w.s.DefaultSavedRequest : servletPath: arg1=/swagger-ui/index.html; arg2=/swagger-ui/index.html (property equals)
o.s.s.w.s.HttpSessionRequestCache : Removing DefaultSavedRequest from session if present
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 10 of 14 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 11 of 14 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
o.s.s.w.a.AnonymousAuthenticationFilter : SecurityContextHolder not populated with anonymous token, as it already contained: 'org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cfed88: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@21a2c: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: CD51ABB99005D448F3500E57B5F109FB; Granted Authorities: ADMIN'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 12 of 14 in additional filter chain; firing Filter: 'SessionManagementFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 13 of 14 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'
o.s.security.web.FilterChainProxy : /swagger-ui/index.html at position 14 of 14 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
o.s.s.w.a.i.FilterSecurityInterceptor : Secure object: FilterInvocation: URL: /swagger-ui/index.html; Attributes: [authenticated]
o.s.s.w.a.i.FilterSecurityInterceptor : Previously Authenticated: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@f9cfed88: Principal: org.springframework.security.core.userdetails.User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@21a2c: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: CD51ABB99005D448F3500E57B5F109FB; Granted Authorities: ADMIN
o.s.s.w.a.i.FilterSecurityInterceptor : Authorization successful
o.s.s.w.a.i.FilterSecurityInterceptor : RunAsManager did not change Authentication object
o.s.security.web.FilterChainProxy : /swagger-ui/index.html reached end of additional filter chain; proceeding with original chain
o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@202268fc
o.s.s.w.a.ExceptionTranslationFilter : Chain processed normally
s.s.w.c.SecurityContextPersistenceFilter : SecurityContextHolder now cleared, as request processing completed
通过层层过滤器,最后请求终于到了DispatchServlet。