Spring Security —— RememberMe

Posted _瞳孔

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security —— RememberMe相关的知识,希望对你有一定的参考价值。

一:简介

Remember Me 即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。

具体的实现思路就是通过Cookie来记录当前用户身份。当用户登录成功之后,会通过算法将用户信息、时间戳等进行加密,加密完成后,通过响应头带回前端存储在cookie中,当浏览器会话过期之后,如果再次访问该网站,会自动将Cookie中的信息发送给服务器,服务器对Cookie中的信息进行校验分析,进而确定出用户的身份,Cookie中所保存的用户信息也是有时效的,例如三天、一周等。

如果不做额外配置,那么,服务端session过期时间默认为30分钟,也就是说即使用户认证成功了,但30分钟没有向后端发送请求,那么,30分钟后认证也会失效,那时候再请求接口会直接要求重新认证,可以在yml中配置服务端会话保存时间,单位为分钟

server:
  servlet:
    session:
      timeout: 1

二:基本使用

开启RememberMe功能很简单,直接加一个rememberMe()方法就行

    @Override
    protected void configure(HttpSecurity http) throws Exception 
        http.authorizeRequests()
                .mvcMatchers("/hello1").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler(new CustomAuthenticationSuccessHandler())
                .failureHandler(new CustomAuthenticationFailureHandler())
                .and()
                .logout()
                .logoutSuccessHandler(new CustomLogoutSuccessHandler())
                .and()
                .rememberMe();
    

开启之后,我们重新打开默认登录页面,会发现,页面多了一个复选框,勾选后即可实现RememberMe功能

三:原理分析

开启RememberMe后,RememberMeAuthenticationFilter过滤器就会被激活,我们可以看看这个过滤器的doFilter方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException 
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
		// 查看是否有SecurityContextHolder中是否有认证信息
		if (SecurityContextHolder.getContext().getAuthentication() == null) 
			// 没有认证信息,因此尝试rememberMe认证,这也是核心方法
			Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
					response);

			if (rememberMeAuth != null) 
				// rememberMeAuth不为null则表示自动登录成功,现在需要对key进行校验
				try 
					rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

					// 认证走到这一步就说明成功了,因为失败会抛异常
					// 将身份信息存储到SecurityContextHolder
					SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
					
					// 发布认证成功事件
					onSuccessfulAuthentication(request, response, rememberMeAuth);

					if (logger.isDebugEnabled()) 
						logger.debug("SecurityContextHolder populated with remember-me token: '"
								+ SecurityContextHolder.getContext().getAuthentication()
								+ "'");
					

					// Fire event
					if (this.eventPublisher != null) 
						eventPublisher
								.publishEvent(new InteractiveAuthenticationSuccessEvent(
										SecurityContextHolder.getContext()
												.getAuthentication(), this.getClass()));
					

					if (successHandler != null) 
						successHandler.onAuthenticationSuccess(request, response,
								rememberMeAuth);

						return;
					

				
				catch (AuthenticationException authenticationException) 
					if (logger.isDebugEnabled()) 
						logger.debug(
								"SecurityContextHolder not populated with remember-me token, as "
										+ "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
										+ rememberMeAuth
										+ "'; invalidating remember-me token",
								authenticationException);
					
					
					// 登录失败,使用该方法处理失败回调
					rememberMeServices.loginFail(request, response);

					// 发布登录失败事件
					onUnsuccessfulAuthentication(request, response,
							authenticationException);
				
			
			// 过滤器放行
			chain.doFilter(request, response);
		
		else 
		    // 如果SecurityContextHolder中有认证信息,说明已经认证过了,则打印日志并直接放行
			if (logger.isDebugEnabled()) 
				logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			

			chain.doFilter(request, response);
		
	

从上面源码我们可以知道,核心方法是rememberMeServices.autoLogin(),它会返回一个Authentication实现类对象,并交给AuthenticationManager进行认证,AuthenticationManager认证我已经在之前的博客详细讲过,这里就不加赘述了,因此这里我们着重了解一下rememberMeServices,rememberMeServices是RememberMeServices实现类对象,我们可以看看RememberMeServices接口的源码:

public interface RememberMeServices 
	/**
	 * 每当SecurityContextHolder不包含身份验证对象,并且Spring Security希望为实现提供
	 * 使用记忆功能对请求进行身份验证的机会时就会调用此方法。Spring Security不会试图确定
	 * 浏览器是否请求了rememberMe服务或提供了有效的cookie。此类决定留待实施。如果浏览器出于
	 * 任何原因提供了未经授权的cookie,则应忽略它
	 */
	Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

	/**
	 * 每当尝试进行自动登录时,用户提供的凭据丢失或无效时调用
	 * 实现应该使HttpServletRequest中指示的所有member-me令牌无效。
	 */
	void loginFail(HttpServletRequest request, HttpServletResponse response);

	/**
	 * 自动登录成功时的回调
	 * 实现可以在HttpServletResponse中自动设置remember-me令牌,不过不建议这样做。
	 * 相反,实现通常应该寻找一个请求参数,该参数指示浏览器已提出明确的身份验证请求
	 */
	void loginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication);

该接口有如下实现类:

至于默认使用的是哪一个实现类,我们可以通过打断点调试,可以看到,我们进入了AbstractRememberMeServices抽象类

AbstractRememberMeServices抽象类的类图如下:

我们可以通过计算,看默认是由哪个子类进行实现的,可见默认是由TokenBasedRememberMeServices实现的


我们进入autoLogin()方法,以下是其具体实现:

@Override
	public final Authentication autoLogin(HttpServletRequest request,
			HttpServletResponse response) 
		// 获取请求中的rememberMeCookie,通过debug计算可以知道默认是找remember-me字段
		String rememberMeCookie = extractRememberMeCookie(request);
		// 如果没有名为remember-me(默认是这个,可以自定义)的字段,则直接认证错误
		if (rememberMeCookie == null) 
			return null;
		

		logger.debug("Remember-me cookie detected");
		
		// 如果cookie有指定字段,但指定字段没有值,也同样认证错误
		if (rememberMeCookie.length() == 0) 
			logger.debug("Cookie was empty");
			// 在响应上设置一个“cancel cookie”(maxAge = 0)以禁用持久登录。
			cancelCookie(request, response);
			return null;
		

		UserDetails user = null;

		try 
			// 对cookie进行解码,并使用“:”分隔符将其分割为字符串数组返回
			String[] cookieTokens = decodeCookie(rememberMeCookie);
			// 这是抽象方法,由子类实现,默认是实现子类是TokenBasedRememberMeServices
			// 该方法是通过验证cookie来进行身份认证的,具体处理见子类,下文会分析
			user = processAutoLoginCookie(cookieTokens, request, response);
			userDetailsChecker.check(user);

			logger.debug("Remember-me cookie accepted");

			return createSuccessfulAuthentication(request, user);
		
		catch (CookieTheftException cte) 
			cancelCookie(request, response);
			throw cte;
		
		catch (UsernameNotFoundException noUser) 
			logger.debug("Remember-me login was valid but corresponding user not found.",
					noUser);
		
		catch (InvalidCookieException invalidCookie) 
			logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
		
		catch (AccountStatusException statusInvalid) 
			logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
		
		catch (RememberMeAuthenticationException e) 
			logger.debug(e.getMessage());
		

		cancelCookie(request, response);
		return null;
	

以下是processAutoLoginCookie()方法的源码分析

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
			HttpServletRequest request, HttpServletResponse response) 

		// rememberMeCookie分割成的字符串数组应该有三个元素,即用户名、时间戳、key
		if (cookieTokens.length != 3) 
			throw new InvalidCookieException("Cookie token did not contain 3"
					+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
		

		long tokenExpiryTime;

		try 
			// 获取cookie的有效日期,默认有效期是两周
			tokenExpiryTime = new Long(cookieTokens[1]);
		
		catch (NumberFormatException nfe) 
			throw new InvalidCookieException(
					"Cookie token[1] did not contain a valid number (contained '"
							+ cookieTokens[1] + "')");
		
		
		// 如果cookie过期则抛出异常
		if (isTokenExpired(tokenExpiryTime)) 
			throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
					+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
					+ "')");
		

		// 检查用户是否存在
		// 将查找用户信息放在检查过期时间之后,已尽量减少耗时的数据库调用。

		// 根据用户名从数据库中查找用户信息
		UserDetails userDetails = getUserDetailsService().loadUserByUsername(
				cookieTokens[0]);

		// 断言,如果用户不存在,则报错
		Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
				+ " returned null for username " + cookieTokens[0] + ". "
				+ "This is an interface contract violation");

		// 检查令牌的签名是否与数据库详细信息匹配。
		// 如果效率是一个主要问题,只需添加一个UserCache实现
		// 但回想一下,此方法通常在每个HttpSession中只调用一次,
		// 因为如果令牌有效,它将导致SecurityContextHolder填充,而如果无效,将导致删除cookie
		
		// 根据过期时间、用户名、用户密码做签名,生成令牌
		// 从makeTokenSignature源码我们可以知道,它是把下面的data做了一次md5加密
		// String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
		String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
				userDetails.getUsername(), userDetails.getPassword());

		// 如果计算的令牌跟客户端传过来的令牌不一样,则说明客户端令牌被篡改过,或者是伪造的
		if (!equals(expectedTokenSignature, cookieTokens[2])) 
			throw new InvalidCookieException("Cookie token[2] contained signature '"
					+ cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
		

		// 如果走到这一步,就说明认证通过
		return userDetails;
	

至此,RememberMe原理分析就结束了,以下是总结:当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用MD5消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串, 中间用“:” 隔开,对拼接好的字符串进行Base64编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当会话过期之后,访问系统资源时会自动携带上Cookie中的令牌,服务端拿到Cookie中的令牌
后,先进行Base64解码,解码后分别提取出令牌中的三项数据,接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息,接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示会牌是合法令牌,自动登录成功,否则自动登录失败。

四:提高安全性

上面这套RememberMe认证其实是有安全风险的,因为cookie是保存在浏览器中的,如果当前用户的浏览器被植入病毒,cookie被窃取,那么他们也是可以将这个cookie设置到自己的浏览器进行登录的,那如何提高安全性呢。

之前我们已经分析到,RememberMeServices接口是由AbstractRememberMeServices抽象类实现,而该抽象类有两个子类,即TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices,默认是采用TokenBasedRememberMeServices,而PersistentTokenBasedRememberMeServices就是进一步提高安全性而生的

我们可以看看它对登录成功后的处理:

	protected void onLoginSuccess(HttpServletRequest request,
			HttpServletResponse response, Authentication successfulAuthentication) 
		
		// 获取用户名
		String username = successfulAuthentication.getName();

		logger.debug("Creating new persistent login for user " + username);

		// 生成持久化令牌
		PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
				username, generateSeriesData(), generateTokenData(), new Date());
		try 
			tokenRepository.createNewToken(persistentToken);
			// 把生成的令牌写回cookie
			addCookie(persistentToken, request, response);
		
		catch (Exception e) 
			logger.error("Failed to save persistent token ", e);
		
	

既然登录成功后做了特殊的处理,那么我们就可以看看登录后的cookie认证有什么变化,以下是PersistentTokenBasedRememberMeServicesprocessAutoLoginCookie()方法的实现:

	protected UserDetails processAutoLoginCookie(String[] cookieTokens,
			HttpServletRequest request, HttpServletResponse response) 

		// cookie长度从3变成了2
		if (cookieTokens.length != 2) 
			throw new InvalidCookieException("Cookie token did not contain " + 2
					+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
		
		
		// cookie第一段是onLoginSuccess方法写回的信息
		final String presentedSeries = cookieTokens[0];
		final String presentedToken = cookieTokens[1];

		// 根据series去内存查询出一个PersistentRememberMeToken对象
		PersistentRememberMeToken token = tokenRepository
				.getTokenForSeries(presentedSeries);

		// 如果查询出来的对象是null,表示内存中并没有series对应的值,本次登录失败
		if (token == null) 
			// No series match, so we can't authenticate using this cookie
			throw new RememberMeAuthenticationException(
					"No persistent token found for series id: " + presentedSeries);
		

		// 如果查询出来的token和客户端cookie解析出来的token不一样
		// 则说明会话令牌泄漏(恶意用户利用令牌登录后,内存中的token变了)
		if (!presentedToken.equals(token.getTokenValue())) 
			// 移除当前用户的所有自动登录记录
			tokenRepository.removeUserTokens(token.getUsername());

			// 抛出CookieTheftException异常,即cookie泄漏异常
			throw new CookieTheftException(
					messages.getMessage(
							"PersistentTokenBasedRememberMeServices.cookieStolen",
							"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
		

		// 检查令牌是否过期
		if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
				.currentTimeMillis()) 
			throw new RememberMeAuthenticationException("Remember-me login has expired");
		

		if (logger.isDebugEnabled()) 
			logger.debug("Refreshing persistent login token for user '"
					+ token.getUsername() + "', series '" + token.getSeries() + "'");
		
		// 生成新的PersistentRememberMeToken对象,用户名和series不变,token重新生成,date使用当前时间
		PersistentRememberMeToken newToken = new PersistentRememberMeToken(
				token.getUsername(), token.getSeries(), generateTokenData(), new Date());

		try 
			// newToken生成后,根据series去修改内存中的token和date(即每次登录都会产生新的token和date)
			tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
					newToken.getDate());
			// 添加cookie
			addCookie(newToken, request, response);
		
		catch (Exception e) 
			logger.error("Failed to update token: ", e);
			throw new RememberMeAuthenticationException(
					"Autologin failed due to data access problem");
		

		return getUserDetailsService().loadUserByUsername(token.getUsername());
	

因此,在持久化令牌方案中,最核心的是series和token两个值,这两个值都是用MD5散列计算生成的随机字符串。不同的是,series仅在用户使用密码重新登录时更新,而 token 会在每一个新的session会话中都重新生成。持久化令牌方案避免了散列加密方案中,一个令牌可以同时在多端登录的问题,这是因为每个session会话都会引发token的更新,即每个token仅支持单实例登录。其次,自动登录不会导致series变更,但每次自动登录都需要同时验证 series和 token两个值,所以这样的设计会更安全。因为当该令牌还未使用过自动登录就被盗取时,系统会在非法用户验证通过后刷新 token 值,此时在合法用户的浏览器中,该token值已经失效。当合法用户使用自动登录时,由于该series对应的 token 不同,系统可以推断该令牌可能已被盗用,从而做一些处理。例如,清理该用户的所有自动登录令牌,并通知该用户可能已被盗号等。

那要如何使用这种方式呢,其实很简单,只要配置一个rememberMeServices就可以了:

    @Override
    protected void configure(HttpSecurity http) throws Exception 
        http.authorizeRequests()
                .mvcMatchers("/hello1").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()以上是关于Spring Security —— RememberMe的主要内容,如果未能解决你的问题,请参考以下文章

Spring mvc / security:从spring security中排除登录页面

Spring Security:2.4 Getting Spring Security

没有 JSP 的 Spring Security /j_spring_security_check

Spring-Security

Spring Security 登录错误:HTTP 状态 404 - /j_spring_security_check

未调用 Spring Security j_spring_security_check