Spring Security是如何储存认证用户信息的
@[toc]
前言剧透
总结:Spring Security会将信息储存在SecurityContext中,请求过程中会被SecurityContextHolder进行管理,底层是基于的ThreadLocal(当然还有很多种策略) 问题:SpringBoot底层的servlet会将每个请求分配一个线程,用ThreadLocal能拿到数据?
单纯的用ThreadLocal当然不够,其自身有一个小“仓库” 而SecurityContext被“保存”于这个仓库SecurityContextRepository中(实际上是放在session里的)
请求时从repo中拿出来context,并交给给SecurityContextHolder管理
结束时把context放回repo
这样每次请求都能从仓库拿到之前的context,也就知道用户到底认证没有了
有着用户信息的SecurityContext一定是被ThreadLocal管理的吗?
我们上文提到“SecurityContext请求过程中会被SecurityContextHolder进行管理,底层是基于的ThreadLocal” 实际并不严谨,因为SecurityContextHolder底层有多种实现 上代码
我们可以看到第一个if里就判断了spring.security.strategy配置里是否有值
如果配置里没有主动指定holder的策略名称,那么默认是MODE_THREADLOCAL——ThreadLocalSecurityContextHolderStrategy类,也就是ThreadLocal
如果指定了策略那就获取指定的策略
所以我们需要纠正为“在Spring Security中,用户的信息默认被ThreadLocal储存,这不是绝对的”
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 private static String strategyName = System.getProperty("spring.security.strategy" );private static void initialize () { if (!StringUtils.hasText(strategyName)) { strategyName = MODE_THREADLOCAL; } if (strategyName.equals(MODE_THREADLOCAL)) { strategy = new ThreadLocalSecurityContextHolderStrategy(); } else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) { strategy = new InheritableThreadLocalSecurityContextHolderStrategy(); } else if (strategyName.equals(MODE_GLOBAL)) { strategy = new GlobalSecurityContextHolderStrategy(); } else { try { Class<?> clazz = Class.forName(strategyName); Constructor<?> customStrategy = clazz.getConstructor(); strategy = (SecurityContextHolderStrategy) customStrategy.newInstance(); } catch (Exception ex) { ReflectionUtils.handleReflectionException(ex); } } initializeCount++; }
ThreadLocalSecurityContextHolderStrategy类
可以看到底层就是new了一个ThreadLocal
1 2 3 4 final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy { private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>(); }
请求时源码流程
SecurityContextPersistenceFilter核心过滤器:org.springframework.security.web.context.SecurityContextPersistenceFilter#doFilter(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)
注意:如果是忽略路径的话,是不会走这个过滤器的
获取包含着认证信息的context
在filter第一句就是if判断语句:用于确保每个请求只应用一次筛选器
1 2 3 4 5 6 static final String FILTER_APPLIED = "__spring_security_scpf_applied" ;if (request.getAttribute(FILTER_APPLIED) != null ) { chain.doFilter(request, response); return ; }
创建进入repo寻找context
根据request和response新建HttpRequestResponseHolder,然后将holder放入repo寻找其context
1 2 HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response); SecurityContext contextBeforeChainExecution = this .repo.loadContext(holder);
解析request的session
获取holder中的request,并拿到request的session ,然后解析session拿到SecurityContext
1 2 3 4 HttpServletRequest request = requestResponseHolder.getRequest(); HttpServletResponse response = requestResponseHolder.getResponse(); HttpSession httpSession = request.getSession(false ); SecurityContext context = readSecurityContextFromSession(httpSession);
HttpSessionSecurityContextRepository根据session获取SecurityContext:org.springframework.security.web.context.HttpSessionSecurityContextRepository#readSecurityContextFromSession
根据key拿到session中的SecurityContext对象
可以看到从session取出了一个名为“SPRING_SECURITY_CONTEXT_KEY”的属性,并转化为了SecurityContext对象(判空等代码已省略)
1 2 3 4 5 6 private String springSecurityContextKey = SPRING_SECURITY_CONTEXT_KEY;private SecurityContext readSecurityContextFromSession (HttpSession httpSession) { Object contextFromSession = httpSession.getAttribute(this .springSecurityContextKey); return (SecurityContext) contextFromSession; }
将SecurityContext交给holder进行管理
至此,请求时的SecurityContext获取到此结束 (省略了一些打log逻辑)
1 2 3 SecurityContext contextBeforeChainExecution = this .repo.loadContext(holder); SecurityContextHolder.setContext(contextBeforeChainExecution); chain.doFilter(holder.getRequest(), holder.getResponse());
结束请求时源码
这段很简略
从holder中拿回context
holder清除context
将context放回repo
request删除之前放置的属性“FILTER_APPLIED”(防止请求的重复过滤)
1 2 3 4 5 6 7 8 static final String FILTER_APPLIED = "__spring_security_scpf_applied" ;finally { SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext(); SecurityContextHolder.clearContext(); this .repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); request.removeAttribute(FILTER_APPLIED); }
疑问:将认证信息放在session里不会被篡改吗?
session保存在服务器 ,等于用户的认证信息实际上还是被保存在了服务端
1 2 3 4 5 6 7 8 9 10 1. 打开浏览器,在浏览器上发送首次请求2. 服务器会创建一个HttpSession对象,该对象代表一次会话3. 同时生成HttpSession对象对应的Cookie对象,并且Cookie对象的name是jsessionid,Cookie的value是32 位长度的字符串(jsessionid=xxxx)4. 服务器将Cookie的value和HttpSession对象绑定到session列表中5. 服务器将Cookie完整发送给浏览器客户端6. 浏览器客户端将Cookie保存到缓存中7. 只要浏览器不关闭,Cookie就不会消失8. 当再次发送请求的时候,会自动提交缓存中当的Cookie9. 服务器接收到Cookie,验证该Cookie的name是否是jsessionid,然后获取该Cookie的value10. 通过Cookie的value去session列表中检索对应的HttpSession对象
需要知道的是:浏览器关闭之后,服务器不会销毁session对象 HTTP协议是一种无连接/无状态的协议 当一段时间后,用户没有再访问session对象,此时session对象超时,web服务器会自动回收session对象
在SpringBoot中,session默认存储30分钟
1 2 @DurationUnit(ChronoUnit.SECONDS) private Duration timeout = Duration.ofMinutes(30 );
配置
1 2 3 4 server : servlet : session : timeout : 30m
自动登录:过滤器识别cookie存的用户信息
自动登录过滤器:RememberMeAuthenticationFilter 当用户没有登录时,会读取request里的cookie来进行自动登录认证
解析cookie变成token
读取request里的cookie后遍历cookie,判断有没有一个名字为“remember-me”的cookie,若有则取出
1 2 3 4 5 6 7 8 9 10 11 12 protected String extractRememberMeCookie (HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if ((cookies == null ) || (cookies.length == 0 )) { return null ; } for (Cookie cookie : cookies) { if (this .cookieName.equals(cookie.getName())) { return cookie.getValue(); } } return null ;}
如果有这个“记住我”cookie,那么将其base64解码 解码后的字符串将其转换为“分隔列表字符串数组”,变成token 看代码吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 protected String[] decodeCookie(String cookieValue) throws InvalidCookieException { for (int j = 0 ; j < cookieValue.length() % 4 ; j++) { cookieValue = cookieValue + "=" ; } String cookieAsPlainText; try { cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes())); } catch (IllegalArgumentException ex) { throw new InvalidCookieException("Cookie token was not Base64 encoded; value was '" + cookieValue + "'" ); } String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER); for (int i = 0 ; i < tokens.length; i++) { try { tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8.toString()); } catch (UnsupportedEncodingException ex) { this .logger.error(ex.getMessage(), ex); } } return tokens; }
SpringSecurity存储token的方式
有趣的是,SpringSecurity有两种存储token的方式 一种是HashMap的内存储存:InMemoryTokenRepositoryImpl
1 2 3 4 5 6 private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();@Override public synchronized PersistentRememberMeToken getTokenForSeries (String seriesId) { return this .seriesTokens.get(seriesId); }
一种是jdbc的磁盘储存:JdbcTokenRepositoryImpl
1 2 3 4 @Override public PersistentRememberMeToken getTokenForSeries (String seriesId) { return getJdbcTemplate().queryForObject(this .tokensBySeriesSql, this ::createRememberMeToken, seriesId); }
token获取用户信息并进行认证
“解析”完cookie变成cookieTokens后,利用这个token获取用户信息并进行校验
1 2 3 4 5 6 public final Authentication autoLogin (HttpServletRequest request, HttpServletResponse response) { String[] cookieTokens = decodeCookie(rememberMeCookie); UserDetails user = processAutoLoginCookie(cookieTokens, request, response); this .userDetailsChecker.check(user); return createSuccessfulAuthentication(request, user); }
获取token后,根据username获取User信息(UserDetails)
1 2 PersistentRememberMeToken token = this .tokenRepository.getTokenForSeries(presentedSeries); return getUserDetailsService().loadUserByUsername(token.getUsername());
用户认证(验证账号是否正常)
上文讲到获取token拿到username后,根据username拿到了user信息(UserDetails) 判断用户是否被锁定、账户过期等,若存在这些情况就抛出异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public void check (UserDetails user) { if (!user.isAccountNonLocked()) { this .logger.debug("Failed to authenticate since user account is locked" ); throw new LockedException( this .messages.getMessage("AccountStatusUserDetailsChecker.locked" , "User account is locked" )); } if (!user.isEnabled()) { this .logger.debug("Failed to authenticate since user account is disabled" ); throw new DisabledException( this .messages.getMessage("AccountStatusUserDetailsChecker.disabled" , "User is disabled" )); } if (!user.isAccountNonExpired()) { this .logger.debug("Failed to authenticate since user account is expired" ); throw new AccountExpiredException( this .messages.getMessage("AccountStatusUserDetailsChecker.expired" , "User account has expired" )); } if (!user.isCredentialsNonExpired()) { this .logger.debug("Failed to authenticate since user account credentials have expired" ); throw new CredentialsExpiredException(this .messages .getMessage("AccountStatusUserDetailsChecker.credentialsExpired" , "User credentials have expired" )); } }
返回认证实例
创建从autoLogin方法返回的最终身份验证对象。 默认情况下,它将创建RememberMeAuthenticationToken实例。
1 2 3 4 5 6 protected Authentication createSuccessfulAuthentication (HttpServletRequest request, UserDetails user) { RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(this .key, user, this .authoritiesMapper.mapAuthorities(user.getAuthorities())); auth.setDetails(this .authenticationDetailsSource.buildDetails(request)); return auth; }
至此自动登录的认证完成
本个人博客提供的内容仅用于个人学习,不保证内容的正确性。通过使用本站内容随之而来的风险与本站无关!