SpringSecurity表单认证(一)

新建一个SpringSecutiry项目

创建一个新项目,名称任意

pom文件引入依赖,版本任意。

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.3.RELEASE</version>
</dependency>
</dependencies>

简略设置一下,启动文件

1
2
3
4
5
6
7
8
9
10
11
@SpringBootApplication
@RestController
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@GetMapping("/hello")
public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
return String.format("Hello %s!", name);
}
}

此时访问任意路径都会被拦截到 /login 路径,呈现以下登录页面

到此为止,就已经新建了一个SpringSecutiry最基本的demo

思考

springsecutity作为重量级的安全框架(相对于shiro),搭建起来却并没有很繁琐,其实这归功于springboot自动配置帮我们节省了繁琐的配置,springsecurity提供了很多强大的功能,但最核心的当属两方面

  • 认证 Authentication
  • 授权 Authorization

通俗来说,认证就是你是谁,授权就是你可以作什么

简述认证

springsecutiry支持多种不同的认证方式

  • 表单认证
  • OAuth2.0认证
  • SAML2.0认证
  • CAS2.0认证
  • RememberMe 自动认证
  • JAAS认证
  • OpenID 去中心化认证
  • Pre-Authentication Scenarios 认证
  • X509 认证
  • HTTP Basic认证
  • HTTP Digest认证

认证接口

认证信息主要的操作功能由Authentication 定义

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean var1) throws IllegalArgumentException;
}
  • getAuthorities 获取用户权限
  • getCredentials 获取用户凭证,一般来说就是密码
  • getDetails 获取用户携带的详细信息 请求路径等
  • getPrincipal 获取当前用户
  • isAuthenticated 是否认证成功

大致看一下Authentication 的子类实现,可以看到不同的认证方式会有不同的实现类,这里先不展开说明,大致有个印象

认证信息由Authentication和其子类提供,而认证工作则是由AuthenticationManager接口极其子类实现

AuthenticationManager接口只提供了一个认证方法。

1
2
3
public interface AuthenticationManager {
Authentication authenticate(Authentication var1) throws AuthenticationException;
}

不同的子类也代表了不同的认证方式,只需要有个印象就够了,后面碰到会详细说明

过滤器链

在Spring Security中,认证和授权等功能都是基于过滤器完成,Spring Securiy常见的过滤器如下

过滤器 过滤器作用 预加载
ChannelProcessingFilter 过滤请求协议,HTTPS/HTTP NO
WebAsyncManagerIntegrationFilter 将WebAsyncManager 与Spring Security 上下文进行集成 YES
SecurityContextPersistenceFilter 在处理请求前,将安全信息加载到SecurityContextHolder中方便后续使用,请求结束后,在擦除掉信息 YES
HeaderWriterFilter 头信息加入到相应中 YES
CorsFilter 处理跨域问题 NO
Csffilter 处理CSRF攻击 YES
LogoutFilter 处理注销登录 YES
OAuth2AuthorizationRequestRedirectFilter 处理OAuth2认证重定向 NO
Saml2WebSsoAuthenticationRequestFilter 处理SAML认证 NO
X509AuthenticationFilter 处理X509认证 NO
AbstractPreAuthenticatedProcessingFilter 处理预认证问题 NO
CasAuthenticationFilter 处理CAS单点登录 NO
OAuth2LoginAuthenticationFilter 处理OAuth2 NO
Saml2WebSsoAuthenticationFilter 处理SAML认证 NO
UsenamePasswordAuthenticationFilter 处理表单登录 YES
OpenIDAuthenticationFilter 处理OpenID认证 NO
DefaultLoginPageGenemtingFilter 配置默认登录页面 YES
DefaultLogoutPageGeneratingFilter 配置默认注销页面 YES
ConcurentSessionFilter 处理Session有效期 NO
DigestAuthenticationFilter 处理HTTP摘要认证 NO
BearerTokenAuthenticationFilter 处理OAuth2认证时的Access Token NO
BasicAuthenticationFilter 处理HttpBasic登录 YES
RequestCacheAwareFilter 处理请求缓存 YES
SecurityContextHolderAwareRequestFilter 包装原始请求 YES
JaasApiIntegrationFilter 处理JAAS认证 NO
RememberMeAuthenticationFilter 处理RememberMe登录 NO
AnonymousAuthenticationFilter 配置匿名认证 YES
OAuth2AuthorizationCodeGrantFilter 处理OAuth2认证中的授权码 NO
SessionManagementFilter 处理Session并发问题 YES
ExceptionTranslationFilter 池异常认证/授权中的情况 YES
FilterSecurityInterceptor 处理授权 YES
SwitchUserFilter 处理账户切换 NO

Spring Security提供的功能基本都是由上述过滤器来实现的,这些过滤器按照既定的优先级排列形成过滤器链。当然开发者也可以自定义过滤器的位置,这里暂不讨论。

Spring Security登录认证

上图中新建了一个最基本的Spring Security demo ,在访问/hello的时候,会自动重定向到/login页面,并且需要输入账号密码登录

而在启动时,发现终端上打印了一行

1
Using generated security password: 535d2c4d-44eb-4808-8ae1-f272b9317b29

也就是Spring Security 随机生成的密码(uuid) 默认用户名为user。

流程分析

上述流程以图标的方式展现如下

  1. 客户端(浏览器)发起请求 /hello 接口,由于引入了Spring Security,登录需要认证
  2. 请求会走一遍Spring Security 中的过滤链,在最后的FilterSecurityInterceptor过滤其中被拦截下来,因为系统发现用户未认证。请求拦截下拉后会抛出 AccessDeniedException 异常
  3. 抛出的AccessDeniedException 异常 在 ExceptionTranslationFilter过滤器中被捕获,ExceptionTranslationFilter 过滤器通过调用 LoginUrlAuthenticationEntryPointcommmence方法返回客户端302,要求客户端重定向到/login页面
  4. 客户端重新发送 /login请求
  5. /login 请求被 DefaultLoginPageGeneratingFilter过滤器拦截下来,并在该过滤器中返回登录页面。

代码分析

FilterSecurityInterceptor

当过滤链执行到FilterSecurityInterceptor时,会调用doFilter方法如下,可以看到主要调用了invoke方法,FilterInvocation分装了当前请求信息。

1
2
3
4
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
this.invoke(fi);
}

invoke()方法中,它会调用beforeInvocation()方法,该方法是AbstractSecurityInterceptor的一个抽象方法,具体的实现由子类提供。beforeInvocation()方法会调用AccessDecisionManager来进行访问决策。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if (fi.getRequest() != null && fi.getRequest().getAttribute("__spring_security_filterSecurityInterceptor_filterApplied") != null && this.observeOncePerRequest) {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} else {
if (fi.getRequest() != null && this.observeOncePerRequest) {
fi.getRequest().setAttribute("__spring_security_filterSecurityInterceptor_filterApplied", Boolean.TRUE);
}

InterceptorStatusToken token = super.beforeInvocation(fi);

try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.finallyInvocation(token);
}

super.afterInvocation(token, (Object)null);
}

}

beforeInvocation方法中并不会直接抛出异常。相反,它会委托给AccessDecisionManager来进行访问决策。如果访问决策失败,AccessDecisionManager将抛出AccessDeniedException异常,表示访问被拒绝。主要语句是

1
2
3
4
5
6
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
} catch (AccessDeniedException var7) {
this.publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, var7));
throw var7;
}

这里,FilterSecurityInterceptor会检查用户的认证状态和权限,然后调用AccessDecisionManagerdecide方法来进行访问决策。

这里默认的决策器是AffirmativeBased,相关代码为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while(var5.hasNext()) {
AccessDecisionVoter voter = (AccessDecisionVoter)var5.next();
int result = voter.vote(authentication, object, configAttributes);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Voter: " + voter + ", returned: " + result);
}

switch(result) {
case -1:
++deny;
break;
case 1:
return;
}
}

if (deny > 0) {
throw new AccessDeniedException(this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
} else {
this.checkAllowIfAllAbstainDecisions();
}

AffirmativeBased: 如果任何一个访问决策器返回ACCESS_GRANTED(访问允许),则决策通过;否则,如果所有访问决策器都返回ACCESS_ABSTAIN(弃权)或没有决策器,那么访问也会被允许。

AffirmativeBased 默认传入的构造器只有一个 WebExpressionVoter,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果,这里返回 -1,deny > 0 直接抛出异常。

ExceptionTranslationFilter

ExceptionTranslationFilter 捕获FilterSecurityInterceptor抛出的AccessDeniedException异常,通过调用 LoginUrlAuthenticationEntryPointcommmence方法返回客户端302,要求客户端重定向到/login页面。

这里走的是catch(Excception var10)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
this.logger.debug("Chain processed normally");
} catch (IOException var9) {
throw var9;
} catch (Exception var10) {
...........

this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase);
}

}

handleSpringSecurityException方法主要是禁止访问和重定向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) {
this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception);
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}

}

显而易见这里走的是 this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));

sendStartAuthentication实际上调用的就是LoginUrlAuthenticationEntryPointcommence方法

1
2
3
4
5
6
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContextHolder.getContext().setAuthentication((Authentication)null);
this.requestCache.saveRequest(request, response);
this.logger.debug("Calling Authentication entry point.");
this.authenticationEntryPoint.commence(request, response, reason);
}

commence真正实现了由Spring Security 提供的默认/login 路径的重定向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String redirectUrl = null;
if (this.useForward) {
if (this.forceHttps && "http".equals(request.getScheme())) {
redirectUrl = this.buildHttpsRedirectUrlForRequest(request);
}

if (redirectUrl == null) {
String loginForm = this.determineUrlToUseForThisRequest(request, response, authException);
if (logger.isDebugEnabled()) {
logger.debug("Server side forward to: " + loginForm);
}

RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
} else {
redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException);
}

this.redirectStrategy.sendRedirect(request, response, redirectUrl);
}

DefaultLoginPageGeneratingFilter

经过上述重定向操作,客户端重新发起请求/hello,这个请求会在DefaultLoginPageGeneratingFilter被拦截下拉,并返回默认的登录页面

DefaultLoginPageGeneratingFilter的操作要简单些,当匹配到登录操作的时候(else),会发送默认生成的页面,也就是我们看到的demo 登录页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
boolean loginError = this.isErrorPage(request);
boolean logoutSuccess = this.isLogoutSuccess(request);
if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
chain.doFilter(request, response);
} else {
// 顾名思义,生成默认页面,详细代码可以点开generateLoginPageHtml方法
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
}
}

小结

至此,解释了从输入/hello 到 重定向到 /login代码的原理,由于它的实现过程非常复杂,这里也只是定位到本次请求经过的代码链路操作。

除此之外,还有一个问题,就是生成了默认的登录页,那在哪里生成的默认账号和密码呢?后续将继续介绍

参考资料

《深入浅出Spring Security》王松