SpringSecurity学习笔记(八)RememberMe功能

参考视频,不良人

什么是RememberMe?

RememberMe 是一种服务器端的行为。传统的登录方式基于 Session会话,一旦用户的会话超时过期,就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。

原本的交互流程是,用户登录了之后会将用户的信息保存在服务端的session中,并且返回客户端一个jsessionid作为一个标识,默认过期时间是30分钟,30min没有进行任何操作,时间到了之后就会过期。也可以在application.yml自定义过期时间

server:
  servlet:
    session:
      timeout: 1

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

SS中RememberMe的使用

SS中开启
在这里插入图片描述
这个时候如果我们没有自定义登录界面的话,内部的默认的登录界面会出现一个RememberMe的下标
在这里插入图片描述
如果我们自定义了登录界面那么就需要编写rememberme,这里看一下别人的源码
在这里插入图片描述
这里有一个小技巧,它这里先自定义了一个常量然后把常量赋值给变量,这也我们就既可以自己设置值,不设置的话也会有默认值,比较灵活。
这里面有一个方法

private void initDefaultLoginFilter(H http) {
    
    
		DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
				.getSharedObject(DefaultLoginPageGeneratingFilter.class);
		if (loginPageGeneratingFilter != null) {
    
    
			loginPageGeneratingFilter.setRememberMeParameter(getRememberMeParameter());
		}
	}

看一下DefaultLoginPageGeneratingFilter这个类

String contextPath = request.getContextPath();
		if (this.formLoginEnabled) {
    
    
			sb.append("      <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n"
					+ "        <h2 class=\"form-signin-heading\">Please sign in</h2>\n"
					+ createError(loginError, errorMsg)
					+ createLogoutSuccess(logoutSuccess)
					+ "        <p>\n"
					+ "          <label for=\"username\" class=\"sr-only\">Username</label>\n"
					+ "          <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
					+ "        </p>\n"
					+ "        <p>\n"
					+ "          <label for=\"password\" class=\"sr-only\">Password</label>\n"
					+ "          <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n"
					+ "        </p>\n"
					+ createRememberMe(this.rememberMeParameter)
					+ renderHiddenInputs(request)
					+ "        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
					+ "      </form>\n");
		}

这里面拼接了这部分代码
所以我在自己的登录界面里面加上

        <input type='checkbox' name='remember-me'>记住我
        <br>

即可。

RememberMe的实现原理

开启了记住我的功能之后,在密码验证的过程中,会进入UsernamePasswordAuthenticationFilter的这个方法:

public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException {
    
    
		if (postOnly && !request.getMethod().equals("POST")) {
    
    
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}

		String username = obtainUsername(request);
		String password = obtainPassword(request);

		if (username == null) {
    
    
			username = "";
		}

		if (password == null) {
    
    
			password = "";
		}

		username = username.trim();

		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
				username, password);

		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);

		return this.getAuthenticationManager().authenticate(authRequest);
	}

这个方法是在它的父类AbstractAuthenticationProcessingFilter中调用的

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
    
    

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		if (!requiresAuthentication(request, response)) {
    
    
			chain.doFilter(request, response);

			return;
		}

		if (logger.isDebugEnabled()) {
    
    
			logger.debug("Request is to process authentication");
		}

		Authentication authResult;

		try {
    
    
		//这里调用前面的方法认证之后就是一个具有完整的用户信息的对象
			authResult = attemptAuthentication(request, response);
			if (authResult == null) {
    
    
				// return immediately as subclass has indicated that it hasn't completed
				// authentication
				return;
			}
			sessionStrategy.onAuthentication(authResult, request, response);
		}
		catch (InternalAuthenticationServiceException failed) {
    
    
			logger.error(
					"An internal error occurred while trying to authenticate the user.",
					failed);
			unsuccessfulAuthentication(request, response, failed);

			return;
		}
		catch (AuthenticationException failed) {
    
    
			// Authentication failed
			unsuccessfulAuthentication(request, response, failed);

			return;
		}

		// Authentication success
		if (continueChainBeforeSuccessfulAuthentication) {
    
    
			chain.doFilter(request, response);
		}

		successfulAuthentication(request, response, chain, authResult);
	}

在认证成功之后调用,可以看到这里是将用户的信息保存在SecurityContextHolder中。

protected void successfulAuthentication(HttpServletRequest request,
			HttpServletResponse response, FilterChain chain, Authentication authResult)
			throws IOException, ServletException {
    
    

		if (logger.isDebugEnabled()) {
    
    
			logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
					+ authResult);
		}

		SecurityContextHolder.getContext().setAuthentication(authResult);

		rememberMeServices.loginSuccess(request, response, authResult);

		// Fire event
		if (this.eventPublisher != null) {
    
    
			eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
					authResult, this.getClass()));
		}

		successHandler.onAuthenticationSuccess(request, response, authResult);
	}

上面的代码中调用了loginSuccess

@Override
	public final void loginSuccess(HttpServletRequest request,
			HttpServletResponse response, Authentication successfulAuthentication) {
    
    

		if (!rememberMeRequested(request, parameter)) {
    
    
			logger.debug("Remember-me login not requested.");
			return;
		}

		onLoginSuccess(request, response, successfulAuthentication);
	}

它又调用了抽象方法onLoginSuccess,具体实现是TokenBasedRememberMeServices

@Override
	public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication) {
    
    

		String username = retrieveUserName(successfulAuthentication);
		String password = retrievePassword(successfulAuthentication);

		// If unable to find a username and password, just abort as
		// TokenBasedRememberMeServices is
		// unable to construct a valid token in this case.
		if (!StringUtils.hasLength(username)) {
    
    
			logger.debug("Unable to retrieve username");
			return;
		}

		if (!StringUtils.hasLength(password)) {
    
    
			UserDetails user = getUserDetailsService().loadUserByUsername(username);
			password = user.getPassword();

			if (!StringUtils.hasLength(password)) {
    
    
				logger.debug("Unable to obtain password for user: " + username);
				return;
			}
		}

		int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
		long expiryTime = System.currentTimeMillis();
		// SEC-949
		expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);

		String signatureValue = makeTokenSignature(expiryTime, username, password);

		setCookie(new String[] {
    
     username, Long.toString(expiryTime), signatureValue },
				tokenLifetime, request, response);

		if (logger.isDebugEnabled()) {
    
    
			logger.debug("Added remember-me cookie for user '" + username
					+ "', expiry: '" + new Date(expiryTime) + "'");
		}
	}

这部分代码就是生成cookie,返回给前端

protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request,
			HttpServletResponse response) {
    
    
		String cookieValue = encodeCookie(tokens);
		Cookie cookie = new Cookie(cookieName, cookieValue);
		cookie.setMaxAge(maxAge);
		cookie.setPath(getCookiePath(request));
		if (cookieDomain != null) {
    
    
			cookie.setDomain(cookieDomain);
		}
		if (maxAge < 1) {
    
    
			cookie.setVersion(1);
		}

		if (useSecureCookie == null) {
    
    
			cookie.setSecure(request.isSecure());
		}
		else {
    
    
			cookie.setSecure(useSecureCookie);
		}

		cookie.setHttpOnly(true);

		response.addCookie(cookie);
	}

后面如果会话过期,用户之前勾选了记住我功能,就会被RememberMeAuthenticationFilter捕获

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
    
    
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		if (SecurityContextHolder.getContext().getAuthentication() == null) {
    
    
			Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
					response);

			if (rememberMeAuth != null) {
    
    
				// Attempt authenticaton via AuthenticationManager
				try {
    
    
					rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

					// Store to 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 {
    
    
			if (logger.isDebugEnabled()) {
    
    
				logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			}

			chain.doFilter(request, response);
		}
	}

在autoLogin方法中就会解析cookie

public final Authentication autoLogin(HttpServletRequest request,
			HttpServletResponse response) {
    
    
		String rememberMeCookie = extractRememberMeCookie(request);

		if (rememberMeCookie == null) {
    
    
			return null;
		}

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

		if (rememberMeCookie.length() == 0) {
    
    
			logger.debug("Cookie was empty");
			cancelCookie(request, response);
			return null;
		}

		UserDetails user = null;

		try {
    
    
		/**
		Decodes the cookie and splits it into a set of token strings using the ":" delimiter.
Params:
cookieValue – the value obtained from the submitted cookie
Returns:
the array of tokens.
Throws:
InvalidCookieException – if the cookie was not base64 encoded.
		*/
			String[] cookieTokens = decodeCookie(rememberMeCookie);
			
			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;
	}

在方法中进行比较签名,如果相同说明用户是认证用户。

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

		if (cookieTokens.length != 3) {
    
    
			throw new InvalidCookieException("Cookie token did not contain 3"
					+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
		}

		long tokenExpiryTime;

		try {
    
    
			tokenExpiryTime = new Long(cookieTokens[1]);
		}
		catch (NumberFormatException nfe) {
    
    
			throw new InvalidCookieException(
					"Cookie token[1] did not contain a valid number (contained '"
							+ cookieTokens[1] + "')");
		}

		if (isTokenExpired(tokenExpiryTime)) {
    
    
			throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
					+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
					+ "')");
		}

		// Check the user exists.去数据库检验用户是否存在
		// Defer lookup until after expiry time checked, to possibly avoid expensive
		// database call.

		UserDetails userDetails = getUserDetailsService().loadUserByUsername(
				cookieTokens[0]);

		Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
				+ " returned null for username " + cookieTokens[0] + ". "
				+ "This is an interface contract violation");

		// Check signature of token matches remaining details.
		// Must do this after user lookup, as we need the DAO-derived password.
		// If efficiency was a major issue, just add in a UserCache implementation,
		// but recall that this method is usually only called once per HttpSession - if
		// the token is valid,
		// it will cause SecurityContextHolder population, whilst if invalid, will cause
		// the cookie to be cancelled.
		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;
	}

完整的实现流程如下:

首先如果用户登陆的时候勾选了记住我的功能,系统就会根据用户的用户名密码以及过期时间计算一个签名,这个签名使用MD5消息摘要算法生成,不可逆。然后将用户名、过期时间、签名拼接成一个字符串,使用 分割,然后将这个编码之后的结果返回给前端,当会话过期之后浏览器会带上这个cookie中的令牌,然后进行Base64解码,解码之后提取令牌中的三个数据,如果没有过期则根据用户名查询用户的数据,然后计算签名,和令牌中的签名进行对比,如果相同,则可以成功登录。

最后记录一下自己遇到的问题:
由于我已经自定义了一个类用来实现验证码的验证逻辑,这个类继承UsernamePasswordAuthenticationFilter

package com.dongmu.filter;

import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@EqualsAndHashCode(callSuper = true)
@Data
public class KaptchaFilter extends UsernamePasswordAuthenticationFilter {
    
    

    private static final String FROM_KAPTCHA = "kaptcha";

    private String kaptchaName = FROM_KAPTCHA;


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
    
        String attribute = (String) request.getSession().getAttribute(FROM_KAPTCHA);
        if (attribute!=null&&attribute.equalsIgnoreCase(request.getParameter(getKaptchaName()))){
    
    
            return super.attemptAuthentication(request, response);
        }else {
    
    
            throw new AuthenticationException("验证码错误,请重新输入!") {
    
    
                @Override
                public String getMessage() {
    
    
                    return super.getMessage();
                }
            };
        }
    }

}

然后放入过滤器链中http.addFilterAt(kaptchaFilter(), UsernamePasswordAuthenticationFilter.class);
导致rememberMe一致不生效,经过debug源码发现在返回给前端一个cookie的时候并没有走TokenBasedRememberMeServices的实现而是NullRememberMeServices,这是个空方法导致内部没有生成cookie,因此解决方式如下:

@Bean
    protected TokenBasedRememberMeServices tokenBasedRememberMeServices(){
    
    
        return new TokenBasedRememberMeServices(UUID.randomUUID().toString(),myUserDetailService);
    }

    @Bean
//    @DependsOn("myAuthenticationHandler")
    public KaptchaFilter kaptchaFilter() throws Exception {
    
    

        //自定义UsernamePasswordAuthenticationFilter之后这些属性要重新设置
        KaptchaFilter kaptchaFilter = new KaptchaFilter();
        kaptchaFilter.setFilterProcessesUrl("/login");
        kaptchaFilter.setUsernameParameter("username");
        kaptchaFilter.setPasswordParameter("password");
        //把这个bean注入进入才能使rememberMe生效
        kaptchaFilter.setRememberMeServices(tokenBasedRememberMeServices());

        kaptchaFilter.setAuthenticationManager(authenticationManager());
        kaptchaFilter.setAuthenticationSuccessHandler(myAuthenticationHandler());
        kaptchaFilter.setAuthenticationFailureHandler(myAuthenticationHandler());

        return kaptchaFilter;
    }

这个为什么要设置成同一个我测试的时候发现如果是两个key不一样的tokenBasedRememberMeServices,生成的签名是不一样的,导致验证失败,还是需要重写登录。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_45401910/article/details/127186059