Spring Security是如何储存认证用户信息的

22 篇文章 1 订阅
2 篇文章 0 订阅

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());

结束请求时源码

这段很简略

  1. 从holder中拿回context
  2. holder清除context
  3. 将context放回repo
  4. 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;
	}

至此自动登录的认证完成

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值