1.前言
本文主要讲解 spring-security 在不做任何配置情况下,它的启动流程和认证过程。
1. 准备工作
这里是基于springboot 2.2.5版本对应 spring-security 5.2.2版本演示的 (按我下面导入即可,版本是它自己匹配的)
-
引入依赖
<properties><java.version>1.8</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><spring-boot.version>2.2.5.RELEASE</spring-boot.version> </properties><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
-
编写程序
-
这里我们直接创建一个springboot 启动程序即可,可以不做任何配置,在导入spring-security 依赖后,它会自动给我们进行了一些默认配置。我在这里简单配置了一下端口号
# 应用名称 spring.application.name=spring-security # 应用服务 WEB 访问端口 server.port=8082
-
编写一个controller
@RestController public class HelloController {@GetMapping("/hello")public String hello() {return "hello";} }
- 启动项目
2. 访问 /hello
我们访问 http://localhost:8082/hello , 发现它会给我们重定向到 http://localhost:8082/login 这个页面。
-思考:我们只写了一个处理 /hello 的方法
2. 流程分析
- 我们都知道spring-security 主要是基于 一层层的 Filters 来对web 请求做处理的,完成其中的认证授权等一系列功能。
1. 自定义一个Filter
@Component
public class MyFilter implements Filter {@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {System.out.println("进入自定义的filter");filterChain.doFilter(servletRequest,servletResponse);}
}
我们自定义的过滤器逻辑很简单,只做一个简单的输出 (在这个地方打个断点,方便我们后续调试),然后放行,把它添加到容器中
@Configuration
public class MyConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {super.configure(http);// 这个方法表示把我们自定义的过滤器 加在 UsernamePasswordAuthenticationFilter 这个过滤器之前http.addFilterBefore(new MyFilter(), UsernamePasswordAuthenticationFilter.class);}
}
2. debug模式重启项目,访问 http://localhost:8082/login ,随便输入参数,提交表单
- 我们点开filterChain(过滤器链,主要是用来管理过滤器的),我们发现除掉我们自定义的过滤器,它自己给我们添加了十五个 过滤器
至于为什么是这个顺序?
FilterComparator() {FilterComparator.Step order = new FilterComparator.Step(100, 100);this.put(ChannelProcessingFilter.class, order.next());this.put(ConcurrentSessionFilter.class, order.next());this.put(WebAsyncManagerIntegrationFilter.class, order.next());this.put(SecurityContextPersistenceFilter.class, order.next());this.put(HeaderWriterFilter.class, order.next());this.put(CorsFilter.class, order.next());this.put(CsrfFilter.class, order.next());this.put(LogoutFilter.class, order.next());this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter", order.next());this.filterToOrder.put("org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationRequestFilter", order.next());this.put(X509AuthenticationFilter.class, order.next());this.put(AbstractPreAuthenticatedProcessingFilter.class, order.next());this.filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", order.next());this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter", order.next());this.filterToOrder.put("org.springframework.security.saml2.provider.service.servlet.filter.Saml2WebSsoAuthenticationFilter", order.next());this.put(UsernamePasswordAuthenticationFilter.class, order.next());this.put(ConcurrentSessionFilter.class, order.next());this.filterToOrder.put("org.springframework.security.openid.OpenIDAuthenticationFilter", order.next());this.put(DefaultLoginPageGeneratingFilter.class, order.next());this.put(DefaultLogoutPageGeneratingFilter.class, order.next());this.put(ConcurrentSessionFilter.class, order.next());this.put(DigestAuthenticationFilter.class, order.next());this.filterToOrder.put("org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter", order.next());this.put(BasicAuthenticationFilter.class, order.next());this.put(RequestCacheAwareFilter.class, order.next());this.put(SecurityContextHolderAwareRequestFilter.class, order.next());this.put(JaasApiIntegrationFilter.class, order.next());this.put(RememberMeAuthenticationFilter.class, order.next());this.put(AnonymousAuthenticationFilter.class, order.next());this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter", order.next());this.put(SessionManagementFilter.class, order.next());this.put(ExceptionTranslationFilter.class, order.next());this.put(FilterSecurityInterceptor.class, order.next());this.put(SwitchUserFilter.class, order.next());}
3. WebAsyncManagerIntegrationFilte
- GenericFilterBean 子类,将Security上下文与Spring Web中用于处理异步请求映射的 WebAsyncManager(spring 中用于管理异步请求的核心类) 进行集成。
4. SecurityContextPersistenceFilter
- GenericFilterBean 子类,在每次请求处理之前将该请求相关的安全上下文信息加载到SecurityContextHolder中,然后在该次请求处理完成之后,将SecurityContextHolder中关于这次请求的信息存储到一个仓储中,然后将SecurityContextHolder中的信息清除
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;// FILTER_APPLIED : 如果有值,说明当前请求已经被这个 Filter 拦截过,直接放行if (request.getAttribute(FILTER_APPLIED) != null) {// ensure that filter is only applied once per requestchain.doFilter(request, response);return;}final boolean debug = logger.isDebugEnabled();// 给 FILTER_APPLIED 设置一个值,表示当前请求已被 当前Filter 处理过 request.setAttribute(FILTER_APPLIED, Boolean.TRUE);// private boolean forceEagerSessionCreation = false;// 默认是false , 日志记录一下 早起创建的sessionif (forceEagerSessionCreation) {HttpSession session = request.getSession();if (debug && session.isNew()) {logger.debug("Eagerly created session: " + session.getId());}}// 创建一个 HttpRequestResponseHolder HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);// private SecurityContextRepository repo; 默认实现是 : HttpSessionSecurityContextRepository// 初次进入的时候,它会先尝试从session 中获取,第一次获取不到,它会创建一个默认的没有权限认证的 SecurityContext// 认证通过后 它会保存在sesion 中,下次进入可以直接取出来SecurityContext contextBeforeChainExecution = repo.loadContext(holder);try {// 把从对应的 SecurityContextRepository 获取的securityContext存入SecurityContextHolder中 SecurityContextHolder.setContext(contextBeforeChainExecution);// 放行chain.doFilter(holder.getRequest(), holder.getResponse());}// 这里代码 是当当前请求被所有过滤器处理完毕后,才会执行的finally {// 从 SecurityContextHolder 中获取 所有Filter执行完毕后的 SecurityContext SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();// Crucial removal of SecurityContextHolder contents - do this before anything// else.// 清除 SecurityContextHolder 中的 SecurityContextSecurityContextHolder.clearContext();// 把 前面获取的 SecurityContext 保存到 SecurityContextRepository reporepo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse());// 清除掉这个标记,下次进入还会拦截 request.removeAttribute(FILTER_APPLIED);if (debug) {logger.debug("SecurityContextHolder now cleared, as request processing completed");}}}
5. HeaderWriterFilter
GenericFilterBean 子类,主要是处理请求头信息的
@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {// shouldWriteHeadersEagerly : 指示是否需要在请求开始前,写入请求头数据if (this.shouldWriteHeadersEagerly) {// 如果需要的话 就写入请求头放行doHeadersBefore(request, response, filterChain);} else {// 如果不需要,它会把请求包装一下,等所有过滤器链执行完毕后,在写入请求头信息doHeadersAfter(request, response, filterChain);}}private void doHeadersBefore(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {writeHeaders(request, response);filterChain.doFilter(request, response);}private void doHeadersAfter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {HeaderWriterResponse headerWriterResponse = new HeaderWriterResponse(request,response);HeaderWriterRequest headerWriterRequest = new HeaderWriterRequest(request,headerWriterResponse);try {filterChain.doFilter(headerWriterRequest, headerWriterResponse);} finally {headerWriterResponse.writeHeaders();}}
6. CsrfFilter
GenericFilterBean 子类,用于处理跨站请求伪造
protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {request.setAttribute(HttpServletResponse.class.getName(), response);// 从token 仓库中查看这个请求是否携带有 csrfToken CsrfToken csrfToken = this.tokenRepository.loadToken(request);final boolean missingToken = csrfToken == null;// 如果没有的话,就给这个 request 创建一个默认的token,uuid随机生成的if (missingToken) {csrfToken = this.tokenRepository.generateToken(request);this.tokenRepository.saveToken(csrfToken, request, response);}// 把token 信息设置到 请求中request.setAttribute(CsrfToken.class.getName(), csrfToken);request.setAttribute(csrfToken.getParameterName(), csrfToken);// 决定策略实现的规则是否与提供的请求匹配。 如果匹配直接放行if (!this.requireCsrfProtectionMatcher.matches(request)) {filterChain.doFilter(request, response);return;}// 根据前面设置的csrfToken ,从请求头中获取名为csrfToken.getHeaderName()的属性值 默认"X-CSRF-TOKEN"String actualToken = request.getHeader(csrfToken.getHeaderName());// 如果为null的话 就会从请求参数中获取if (actualToken == null) {actualToken = request.getParameter(csrfToken.getParameterName());}// 这里会拿 actualToken 和 前面创建的默认的 csrfToken的token 进行比对,如果不相等,说明这里被修改过 打印日志,回显异常if (!csrfToken.getToken().equals(actualToken)) {if (this.logger.isDebugEnabled()) {this.logger.debug("Invalid CSRF token found for "+ UrlUtils.buildFullRequestUrl(request));}if (missingToken) {this.accessDeniedHandler.handle(request, response,new MissingCsrfTokenException(actualToken));}else {this.accessDeniedHandler.handle(request, response,new InvalidCsrfTokenException(csrfToken, actualToken));}return;}// 走到这里说明校验通过,放行filterChain.doFilter(request, response);}
7. LogoutFilter
主要是用来处理登出的。
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;// 确认是否进行登出操作,里面主要是一些路径方法匹配if (requiresLogout(request, response)) {// SecurityContextHolder的容器中的一些权限认证信息Authentication auth = SecurityContextHolder.getContext().getAuthentication();if (logger.isDebugEnabled()) {logger.debug("Logging out user '" + auth+ "' and transferring to logout destination");}// 这个LogoutHandler是一个复合handler 里面维护了一个 List<LogoutHandler> // 遍历执行每一个 LogoutHandler 的登出操作,处理各种认证信息this.handler.logout(request, response, auth);// 默认情况下是一个 SimpleUrlLogoutSuccessHandler,处理重定向信息logoutSuccessHandler.onLogoutSuccess(request, response, auth);return;}// 如果没有登出操作直接放行。chain.doFilter(request, response);}
8. UsernamePasswordAuthenticationFilter
这个应该是我们大家比较了解,也是重写比较多的一个 Filter,用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理post方式“/login”的请求。
从表单中获取用户名和密码时,默认使用的表单name值为“username”和“password”,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;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());}// 从request 中获取 参数名为 username,和 password 对应的参数String username = obtainUsername(request);String password = obtainPassword(request);// 当它们是null时,给他们一个 ""字符串if (username == null) {username = "";}if (password == null) {password = "";}username = username.trim();// 创建一个 UsernamePasswordAuthenticationToken 这里会保存用户的权限认证等信息UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);// Allow subclasses to set the "details" propertysetDetails(request, authRequest);// 默认会得到 一个 ProviderManager,调用它的 authenticate(),因为我们没配置他第一次进入是会 // 从 InMemoryUserDetailsManager的一个 map中根据用户名查找用户信息,用户名:user,密码:前面启动项目打印的那个// 拿着这个查找后的用户信息,和我们输入的密码进行比对,相同则认证成功,否则失败return this.getAuthenticationManager().authenticate(authRequest);}
this.getAuthenticationManager()
-----》ProviderManager
----》result = provider.authenticate(authentication)
-----》 AbstractUserDetailsAuthenticationProvider
----》UserDetails user = this.userCache.getUserFromCache(username); 先从缓存中判断没有再往下执行
----》 user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
—》DaoAuthenticationProvider
—》UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
—》InMemoryUserDetailsManager
—》UserDetails user = (UserDetails)this.users.get(username.toLowerCase()); 这个users是一个map,通过username 从这里面获取 用户信息
// private final Map<String, MutableUserDetails> users = new HashMap();
—》接着回到 AbstractUserDetailsAuthenticationProvider
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication); 检查当前用户密码和从map中查到的信息是否匹配
9. DefaultLoginPageGeneratingFilter
这个是当我们没有配置登录页面时,系统会在启动的时候帮我们加入这个过滤器,帮我们生成一个默认的登录页面。
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;boolean loginError = isErrorPage(request);boolean logoutSuccess = isLogoutSuccess(request);// 访问登录页 || 或者前面登录失败 || 登录退出成功if (isLoginUrlRequest(request) || loginError || logoutSuccess) {// 只有这几种情况会生成一个登录页面 返回给前台String loginPageHtml = generateLoginPageHtml(request, loginError,logoutSuccess);response.setContentType("text/html;charset=UTF-8");response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);response.getWriter().write(loginPageHtml);return;}
10 DefaultLogoutPageGeneratingFilter
当我们没有配置退出登录页面时,生成一个的用户退出登录页面,默认情况下,当用户请求为GET /logout时,该过滤器会起作用,生成并展示相应的用户退出登录表单页面。
@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {if (this.matcher.matches(request)) {renderLogout(request, response);} else {filterChain.doFilter(request, response);}}private void renderLogout(HttpServletRequest request, HttpServletResponse response)throws IOException {String page = "<!DOCTYPE html>\n"+ "<html lang=\"en\">\n"+ " <head>\n"+ " <meta charset=\"utf-8\">\n"+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n"+ " <meta name=\"description\" content=\"\">\n"+ " <meta name=\"author\" content=\"\">\n"+ " <title>Confirm Log Out?</title>\n"+ " <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n"+ " <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n"+ " </head>\n"+ " <body>\n"+ " <div class=\"container\">\n"+ " <form class=\"form-signin\" method=\"post\" action=\"" + request.getContextPath() + "/logout\">\n"+ " <h2 class=\"form-signin-heading\">Are you sure you want to log out?</h2>\n"+ renderHiddenInputs(request)+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Log Out</button>\n"+ " </form>\n"+ " </div>\n"+ " </body>\n"+ "</html>";response.setContentType("text/html;charset=UTF-8");response.getWriter().write(page);}
11 BasicAuthenticationFilter
这个拦截器主要是处理请求头上认证信息,如果有,则通过basic64 编码器解密请求头所携带的信息,
封装成一个 UsernamePasswordAuthenticationToken 对象,然后比对认证是否成功,认证成功的话把认证成功的信息放到当前上下文中。
protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain chain)throws IOException, ServletException {final boolean debug = this.logger.isDebugEnabled();try {// 尝试获取请求头封装的信息返回的 UsernamePasswordAuthenticationToken ,若为null,直接放行,详解见下面UsernamePasswordAuthenticationToken authRequest = authenticationConverter.convert(request);// 如果没有直接返回if (authRequest == null) {chain.doFilter(request, response);return;}// 获取用户名String username = authRequest.getName();if (debug) {this.logger.debug("Basic Authentication Authorization header found for user '"+ username + "'");}// 仅当用户名与SecurityContextHolder和用户不匹配时或者用户名没经过认证 返回true 需进行验证if (authenticationIsRequired(username)) {// 具体的校验逻辑 可自己重写,认证成功后把它放到容器中即可。Authentication authResult = this.authenticationManager.authenticate(authRequest);if (debug) {this.logger.debug("Authentication success: " + authResult);}SecurityContextHolder.getContext().setAuthentication(authResult);this.rememberMeServices.loginSuccess(request, response, authResult);onSuccessfulAuthentication(request, response, authResult);}}catch (AuthenticationException failed) {SecurityContextHolder.clearContext();if (debug) {this.logger.debug("Authentication request for failed: " + failed);}this.rememberMeServices.loginFail(request, response);onUnsuccessfulAuthentication(request, response, failed);if (this.ignoreFailure) {chain.doFilter(request, response);}else {this.authenticationEntryPoint.commence(request, response, failed);}return;}chain.doFilter(request, response);}public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {// public static final String AUTHORIZATION = "Authorization";String header = request.getHeader(AUTHORIZATION);// 如果这个 Authorization 请求头为null 直接返回。if (header == null) {return null;}// 获取 Authorization Basic 后面的信息header = header.trim();if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {return null;}// base64 解密请求头认证信息byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);byte[] decoded;try {decoded = Base64.getDecoder().decode(base64Token);}catch (IllegalArgumentException e) {throw new BadCredentialsException("Failed to decode basic authentication token");}// getCredentialsCharset(request) 默认 UTF-8String token = new String(decoded, getCredentialsCharset(request));int delim = token.indexOf(":");if (delim == -1) {throw new BadCredentialsException("Invalid basic authentication token");}// 构造这个对象 UsernamePasswordAuthenticationToken 最后返回UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(token.substring(0, delim), token.substring(delim + 1));result.setDetails(this.authenticationDetailsSource.buildDetails(request));return result;}
12 RequestCacheAwareFilter
主要作用是用于用户登录成功后,重新恢复因为登录被打断的请求,被打断也是有前提条件的,支持打断后可以被恢复的异常有AuthenticationException、AccessDeniedException,这个操作是ExceptionTranslationFilter中触发的,并且RequestCacheAwareFilter只支持GET方法,而默认TokenEndpoint支持Post获取Token信息,进行登录.
public void doFilter(ServletRequest request, ServletResponse response,FilterChain chain) throws IOException, ServletException {// 匹配有没有需要恢复登录的请求,有的话直接替换到请求路径HttpServletRequest wrappedSavedRequest = requestCache.getMatchingRequest((HttpServletRequest) request, (HttpServletResponse) response);chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest,response);}
13 SecurityContextHolderAwareRequestFilter
Spring Security TokenEndpoint中获取token的请求,有这样一个参数:Principal。 对于一个普通HttpServletRequest,是没有Principal参数类型的。SecurityContextHolderAwareRequestFilter通过HttpServletRequestFactory将HttpServletRequest请求包装成SecurityContextHolderAwareRequestWrapper,它实现了HttpServletRequest,并进行了扩展,添加一些额外的方法,比如:getPrincipal()方法等。这样就可以那些需要Principal等参数的Controller就可以接收到对应参数了。除了这个地方的应用,在其他地方,也可以直接调用request#getUserPrincipal()获取对应信息。