Spring Security是如何储存认证用户信息的
文章目录
前言剧透
总结: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储存,这不是绝对的”
private static String strategyName = System.getProperty("spring.security.strategy");
private static void initialize() {
if (!StringUtils.hasText(strategyName)) {
// Set default
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 to load a custom strategy
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
}
catch (Exception ex) {
ReflectionUtils.handleReflectionException(ex);
}
}
initializeCount++;
}
ThreadLocalSecurityContextHolderStrategy类
可以看到底层就是new了一个ThreadLocal
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判断语句:用于确保每个请求只应用一次筛选器
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
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
解析request的session
获取holder中的request,并拿到request的session,然后解析session拿到SecurityContext
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对象(判空等代码已省略)
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逻辑)
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
结束请求时源码
这段很简略
- 从holder中拿回context
- holder清除context
- 将context放回repo
- request删除之前放置的属性“FILTER_APPLIED”(防止请求的重复过滤)
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. 服务器会创建一个HttpSession对象,该对象代表一次会话
3. 同时生成HttpSession对象对应的Cookie对象,并且Cookie对象的name是jsessionid,Cookie的value是32位长度的字符串(jsessionid=xxxx)
4. 服务器将Cookie的value和HttpSession对象绑定到session列表中
5. 服务器将Cookie完整发送给浏览器客户端
6. 浏览器客户端将Cookie保存到缓存中
7. 只要浏览器不关闭,Cookie就不会消失
8. 当再次发送请求的时候,会自动提交缓存中当的Cookie
9. 服务器接收到Cookie,验证该Cookie的name是否是jsessionid,然后获取该Cookie的value
10. 通过Cookie的value去session列表中检索对应的HttpSession对象
需要知道的是:浏览器关闭之后,服务器不会销毁session对象
HTTP协议是一种无连接/无状态的协议
当一段时间后,用户没有再访问session对象,此时session对象超时,web服务器会自动回收session对象
在SpringBoot中,session默认存储30分钟
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout = Duration.ofMinutes(30);
配置
server:
servlet:
session:
timeout: 30m
自动登录:过滤器识别cookie存的用户信息
自动登录过滤器:RememberMeAuthenticationFilter
当用户没有登录时,会读取request里的cookie来进行自动登录认证
解析cookie变成token
读取request里的cookie后遍历cookie,判断有没有一个名字为“remember-me”的cookie,若有则取出
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
看代码吧
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
private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
@Override
public synchronized PersistentRememberMeToken getTokenForSeries(String seriesId) {
return this.seriesTokens.get(seriesId);
}
一种是jdbc的磁盘储存:JdbcTokenRepositoryImpl
@Override
public PersistentRememberMeToken getTokenForSeries(String seriesId) {
return getJdbcTemplate().queryForObject(this.tokensBySeriesSql, this::createRememberMeToken, seriesId);
}
token获取用户信息并进行认证
“解析”完cookie变成cookieTokens后,利用这个token获取用户信息并进行校验
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)
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
return getUserDetailsService().loadUserByUsername(token.getUsername());
用户认证(验证账号是否正常)
上文讲到获取token拿到username后,根据username拿到了user信息(UserDetails)
判断用户是否被锁定、账户过期等,若存在这些情况就抛出异常
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实例。
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;
}
至此自动登录的认证完成