1. RememberMe 基本使用
RememberMe 是一种服务器端的行为。传统的登录方式基于Session会话,一旦用户的会话超时过期,就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。
具体的实现思路就是通过 Cookie 来记录当前用户身份。当用户登录成功之后,会通过一定算法,将用户信息、时间戳等进行加密,加密完成后,通过响应头带回前端存储在cookie中,当浏览器会话过期之后,如果再次访问该网站,会自动将 Cookie 中的信息发送给服务器,服务器对 Cookie中的信息进行校验分析,进而确定出用户的身份,Cookie中所保存的用户信息也是有时效的,例如三天、一周等。
配置SpringSecurity 的记住我功能:
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User
.withUsername("root")
.password("{noop}123")
.roles("admin").build());
return inMemoryUserDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启请求的权限管理
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
// 开启rememberme功能
.rememberMe()
.and()
.csrf().disable();
}
}
配置session在1分钟之后过期:
server.servlet.session.timeout=1
可以看到一旦打开了记住我功能,登录⻚面中会多出一个 RememberMe 选项:
2. RememberMe 登录流程源码分析
步骤1:AbstractAuthenticationProcessingFilter#doFilter 登录请求认证
登录请求首先被AbstractAuthenticationProcessingFilter过滤器拦截:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
// 1、调用子类的attemptAuthentication方法尝试认证
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
// 2、会话处理
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
// 3、认证成功的处理
successfulAuthentication(request, response, chain, authenticationResult);
}catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
// 4、认证失败的处理
unsuccessfulAuthentication(request, response, failed);
}catch (AuthenticationException ex) {
// 认证失败的处理
unsuccessfulAuthentication(request, response, ex);
}
}
}
步骤2:AbstractAuthenticationProcessingFilter#successfulAuthentication 认证成功处理
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication authResult) throws IOException, ServletException {
// 1、将认证成功的Authentication对象存入SecurityContextHolder
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
// 2、如果开启了记住我功能,当身份验证成功时调用
this.rememberMeServices.loginSuccess(request, response, authResult);
// 3、发布事件
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
// 4、认证成功的回调
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
}
步骤3:AbstractRememberMeServices#loginSuccess 认证成功时调用
public abstract class AbstractRememberMeServices
implements RememberMeServices, InitializingBean, LogoutHandler, MessageSourceAware {
public static final String DEFAULT_PARAMETER = "remember-me";
private String parameter = DEFAULT_PARAMETER;
@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,Authentication successfulAuthentication) {
// 检查传入请求并检查是否存在已配置的“记住我”参数。
// 如果它存在,或者如果 alwaysRemember 设置为 true,则调用 onLoginSucces。
if (!rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
/**
* 判断请求中是否存在已配置的记住我remember-me参数,或者alwaysRemember是否为true
*/
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (this.alwaysRemember) {
return true;
}
// paramValue=on
String paramValue = request.getParameter(parameter);
if (paramValue != null) {
if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
|| paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
return true;
}
}
this.logger.debug(
LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
return false;
}
}
步骤4:TokenBasedRememberMeServices#onLoginSuccess
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
// 令牌的过期时间默认值为两周
public static final int TWO_WEEKS_S = 1209600;
private int tokenValiditySeconds = TWO_WEEKS_S;
private Boolean useSecureCookie = null;
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
// 获取用户名
String username = retrieveUserName(successfulAuthentication);
// 获取密码
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
return;
}
// 如果用户密码在用户登录成功后从successfulAuthentication对象中擦除
// 则从数据库中重新加载出用户密码。
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
// 计算出令牌的过期时长,令牌默认有效期是两周。
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// 令牌的过期时间=当前系统时间+过期时长
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
// 根据令牌的过期时间、用户名以及用户密码,计算出一个签名
String signatureValue = makeTokenSignature(expiryTime, username, password);
// 调用 setCookie 方法设置 Cookie
// 第一个参数是一个数组,数组中一共包含三项:用户名、过期时间以及签名,
// 在setCookie 方法中会将数组转为字符串,并进行Base64编码后响应给前端。
setCookie(new String[] {
username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(
"Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}
}
// 从Authentication对象中获取username
protected String retrieveUserName(Authentication authentication) {
if (isInstanceOfUserDetails(authentication)) {
return ((UserDetails) authentication.getPrincipal()).getUsername();
}
return authentication.getPrincipal().toString();
}
// 判断是否是UserDetails实例
private boolean isInstanceOfUserDetails(Authentication authentication) {
return authentication.getPrincipal() instanceof UserDetails;
}
// 从Authentication对象中获取password
protected String retrievePassword(Authentication authentication) {
if (isInstanceOfUserDetails(authentication)) {
return ((UserDetails) authentication.getPrincipal()).getPassword();
}
if (authentication.getCredentials() != null) {
return authentication.getCredentials().toString();
}
return null;
}
// 计算令牌的过期时间
protected int calculateLoginLifetime(HttpServletRequest request, Authentication authentication) {
return getTokenValiditySeconds();
}
protected int getTokenValiditySeconds() {
return this.tokenValiditySeconds;
}
// 根据令牌的过期时间、用户名以及用户密码,计算出一个签名
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
// 使用MD5加密算法加密
MessageDigest digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No MD5 algorithm available!");
}
}
// 设置cookie
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response) {
String cookieValue = encodeCookie(tokens);
Cookie cookie = new Cookie(this.cookieName, cookieValue);
// 设置cookie的过期时间
cookie.setMaxAge(maxAge);
// 设置cookie的生效路径
cookie.setPath(getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
if (maxAge < 1) {
cookie.setVersion(1);
}
cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure());
cookie.setHttpOnly(true);
// 在响应对象中添加cookie
response.addCookie(cookie);
}
// 将cookieTokensp拼接成字符串,并使用Base64编码
protected String encodeCookie(String[] cookieTokens) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < cookieTokens.length; i++) {
try {
sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8.toString()));
}
catch (UnsupportedEncodingException ex) {
this.logger.error(ex.getMessage(), ex);
}
if (i < cookieTokens.length - 1) {
sb.append(DELIMITER);
}
}
String value = sb.toString();
sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes())));
while (sb.charAt(sb.length() - 1) == '=') {
sb.deleteCharAt(sb.length() - 1);
}
return sb.toString();
}
}
3. RememberMe 会话过期自动登录流程源码分析
步骤1: RememberMeAuthenticationFilter#doFilter
当在SecurityConfig 配置中开启了"记住我"功能之后,在进行认证时如果勾选了"记住我"选项,此时打开浏览器控制台,分析整个登录过程。
首先当我们登录时,在登录请求中多了一个 RememberMe 的参数:
很显然,这个参数就是告诉服务器应该开启 RememberMe 功能的。如果自定义登录⻚面开启RememberMe 功能应该多加入一个一样的请求参数就可以啦。
该请求会被 RememberMeAuthenticationFilter 进行拦截然后自动登录具体参⻅源码:
public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
// 事件发布
private ApplicationEventPublisher eventPublisher;
// 认证成功的回调接口
private AuthenticationSuccessHandler successHandler;
// 认证管理器
private AuthenticationManager authenticationManager;
// 记住我功能
private RememberMeServices rememberMeServices;
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值
// 没值的话表示用户尚未登录;调用 autoLogin 方法进行自动登录
// 有值的话说明用户已经登录在,直接放行;
if (SecurityContextHolder.getContext().getAuthentication() != null) {
chain.doFilter(request, response);
return;
}
// 调用 autoLogin 方法进行自动登录。
Authentication rememberMeAuth
= this.rememberMeServices.autoLogin(request, response);
// 登录成功
if (rememberMeAuth != null) {
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}
}
请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值,有值的话说明用户已经登录在,直接放行;没值的话表示用户尚未登录;调用 autoLogin 方法进行自动登录。
当自动登录成功后返回的rememberMeAuth 不为null 时,表示自动登录成功,此时调用 authenticate 方法对 key 进行校验,并且将登录成功的用户信息保存到SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的1oginSuccess 方法。
如果自动登录失败,则调用 remenberMeServices.loginFail方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication都是该过滤器中定义的空方法,并没有任何实现这就是RememberMeAuthenticationFilter 过滤器所做的事情,成功将RememberMeServices的服务集成进来。
步骤2:AbstractRememberMeServices#autoLogin
public abstract class AbstractRememberMeServices
implements RememberMeServices, InitializingBean, LogoutHandler, MessageSourceAware {
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
private String cookieName = SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY;
private static final String DELIMITER = ":";
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
// 1、提取请求中Cookie的value值
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
// 2、对CookieValue进行解码:["root", "1664974500735", "9e44c8368018d34..."]
String[] cookieTokens = decodeCookie(rememberMeCookie);
// 3、调用子类的方法验证 Cookie 中的令牌信息是否合法
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
// 4、校验用户信息
this.userDetailsChecker.check(user);
// 5、创建并返回认证成功的Authentication对象
return createSuccessfulAuthentication(request, user);
}catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
// ...
cancelCookie(request, response);
return null;
}
// 从请求的Cookie中获取remember-me的cookieValue值
protected String extractRememberMeCookie(HttpServletRequest request) {
// 从请求中获取所有的Cookie
Cookie[] cookies = request.getCookies();
if ((cookies == null) || (cookies.length == 0)) {
return null;
}
for (Cookie cookie : cookies) {
// 判断Cookie的名称是否为remember-me,如果是就获取cookie的value
if (this.cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
// 对cookieValue进行base64解密
protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
for (int j = 0; j < cookieValue.length() % 4; j++) {
cookieValue = cookieValue + "=";
}
String cookieAsPlainText;
try {
// cookieAsPlainText:"root:1664974500735:9e44c8368018d34940dd599e422e2e3c"
cookieAsPlainText
= new String(Base64.getDecoder().decode(cookieValue.getBytes()));
}
catch (IllegalArgumentException ex) {
throw new InvalidCookieException("Cookie token was not Base64 encoded; value was '" + cookieValue + "'");
}
// 对cookieAsPlainText使用冒号(:)分割
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;
}
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;
}
}
步骤3:TokenBasedRememberMeServices#processAutoLoginCookie
在开启记住我后如果没有加入额外配置默认实现就是由TokenBasedRememberMeServices
进行的实现。查看这个类源码中 processAutoLoginCookie 方法实现:
public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,HttpServletResponse response) {
// ["root", "1664974500735", "9e44c8368018d34..."]
// 1、如果cookieTokens数组的长度不等于3则格式错误,抛出异常
if (cookieTokens.length != 3) {
throw new InvalidCookieException(
"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
// 2、获取cookieTokens数组中index=1处的值,即token的过期时间:1664974500735
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
// 3、判断令牌是否过期,如果己经过期,则拋出异常。
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
+ "'; current time is '" + new Date() + "')");
}
// 4、根据用户名(cookieTokens数组的第1项)查询出当前用户对象
UserDetails userDetails
= getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
// 5、生成一个签名:"9e44c8368018d34940dd599e422e2e3c"
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),userDetails.getPassword());
// 6、判断cookieTokens[2]中的签名和生成的签名是否相等,如果不相等则抛出异常,相等则令牌合法
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
// 获取token的过期时间
private long getTokenExpiryTime(String[] cookieTokens) {
try {
// 获取cookieTokens数组中的第二个值,并将其转为Long类型
return new Long(cookieTokens[1]);
}
catch (NumberFormatException nfe) {
throw new InvalidCookieException(
"Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')");
}
}
protected boolean isTokenExpired(long tokenExpiryTime) {
return tokenExpiryTime < System.currentTimeMillis();
}
// 计算要放入cookie中的数字签名,默认值为 MD5 ("username:tokenExpiryTime:password:key")
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
//首先将用户名、令牌过期时间、用户密码以及 key 组成一个宇符串,中间用“:”隔开
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
// 然后通过MD5 消息摘要算法对该宇符串进行加密,将加密结果转为一个字符串返回;
return new String(Hex.encode(digest.digest(data.getBytes())));
}catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No MD5 algorithm available!");
}
}
}
4. RememberMe 原理
当用户通过用户名/密码的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名、令牌过期时间以及签名拼接成一个字符串,中间用“:” 隔开,对拼接好的字符串进行Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。
当会话过期之后,访问系统资源时会自动携带上Cookie中的令牌,服务端拿到 Cookie中的令牌后,先进行 Bae64解码,解码后分别提取出令牌中的三项数据:接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息:接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示会牌是合法令牌,自动登录成功,否则自动登录失败。
5. RememberMeServices
public interface RememberMeServices {
// 从请求中提取出需要的参数,完成自动登录功能;
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
// 登录失败的回调;
void loginFail(HttpServletRequest request, HttpServletResponse response);
// 登录成功的回调;
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
}
6. 内存令牌 PersistentTokenBasedRememberMeServices
可以看到基于TokenBasedRememberMeServices实现的记住我方式是不安全的,因为我们可以直接在浏览器中看到Base64编码后的cookie信息,那么拿着这个cookie就可以任意访问系统了:
SpringSecurity提供了一种更为安全的记住我功能,即基于PersistentTokenBasedRememberMeServices实现记住我功能。
1. 登录流程源码变化
登录流程的步骤1、步骤2、步骤3不变,变化的是步骤4,不再调用TokenBasedRememberMeServices#onLoginSuccess,而是调用PersistentTokenBasedRememberMeServices#onLoginSuccess 。
步骤1:PersistentTokenBasedRememberMeServices#onLoginSuccess
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
private PersistentTokenRepository tokenRepository
= new InMemoryTokenRepositoryImpl();
private SecureRandom random;
public static final int DEFAULT_SERIES_LENGTH = 16;
public static final int DEFAULT_TOKEN_LENGTH = 16;
private int seriesLength = DEFAULT_SERIES_LENGTH;
private int tokenLength = DEFAULT_TOKEN_LENGTH;
@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,Authentication successfulAuthentication) {
// 获取用户名
String username = successfulAuthentication.getName();
// 使用构造函数生成 PersistentRememberMeToken
PersistentRememberMeToken persistentToken
= new PersistentRememberMeToken(username,
generateSeriesData(),
generateTokenData(),
new Date());
try {
// 存储PersistentRememberMeToken到内存(HashMap)
this.tokenRepository.createNewToken(persistentToken);
// 在响应对象中添加cookie
addCookie(persistentToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to save persistent token ", ex);
}
}
protected String generateSeriesData() {
byte[] newSeries = new byte[this.seriesLength];
this.random.nextBytes(newSeries);
return new String(Base64.getEncoder().encode(newSeries));
}
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}
private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
setCookie(new String[] {
token.getSeries(), token.getTokenValue() },
getTokenValiditySeconds(),
request,response);
}
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response) {
// 对tokens使用Base64编码
String cookieValue = encodeCookie(tokens);
Cookie cookie = new Cookie(this.cookieName, cookieValue);
// cookie的最大生效时间
cookie.setMaxAge(maxAge);
// cookie的生效路径
cookie.setPath(getCookiePath(request));
if (this.cookieDomain != null) {
cookie.setDomain(this.cookieDomain);
}
if (maxAge < 1) {
cookie.setVersion(1);
}
cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure());
cookie.setHttpOnly(true);
// 在响应对象中添加cookie信息
response.addCookie(cookie);
}
private String getCookiePath(HttpServletRequest request) {
String contextPath = request.getContextPath();
return (contextPath.length() > 0) ? contextPath : "/";
}
// 将cookieTokens数组中的值拼接成字符串,然后使用Base64编码
protected String encodeCookie(String[] cookieTokens) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < cookieTokens.length; i++) {
try {
sb.append(URLEncoder.encode(cookieTokens[i], StandardCharsets.UTF_8.toString()));
}
catch (UnsupportedEncodingException ex) {
this.logger.error(ex.getMessage(), ex);
}
if (i < cookieTokens.length - 1) {
sb.append(DELIMITER);
}
}
String value = sb.toString();
sb = new StringBuilder(new String(Base64.getEncoder().encode(value.getBytes())));
while (sb.charAt(sb.length() - 1) == '=') {
sb.deleteCharAt(sb.length() - 1);
}
return sb.toString();
}
}
步骤2:PersistentRememberMeToken 源码
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) {
this.username = username;
this.series = series;
this.tokenValue = tokenValue;
this.date = date;
}
}
步骤3:InMemoryTokenRepositoryImpl#createNewToken
public class InMemoryTokenRepositoryImpl implements PersistentTokenRepository {
private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
/**
* 存储PersistentRememberMeToken到内存,也可以使用redis来存储
*/
@Override
public synchronized void createNewToken(PersistentRememberMeToken token) {
// 通过series获取当前的PersistentRememberMeToken
PersistentRememberMeToken current = this.seriesTokens.get(token.getSeries());
// 如果PersistentRememberMeToken已经存在,抛出异常
if (current != null) {
throw new DataIntegrityViolationException("Series Id '" + token.getSeries() + "' already exists!");
}
// 使用内存HashMap来存储token,当需要的时候可以直接从内存中取
// key=series,value=PersistentRememberMeToken
this.seriesTokens.put(token.getSeries(), token);
}
@Override
public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
PersistentRememberMeToken token = getTokenForSeries(series);
PersistentRememberMeToken newToken
= new PersistentRememberMeToken(token.getUsername(),
series,
tokenValue,
new Date());
// Store it, overwriting the existing one.
this.seriesTokens.put(series, newToken);
}
@Override
public synchronized PersistentRememberMeToken getTokenForSeries(String seriesId) {
return this.seriesTokens.get(seriesId);
}
@Override
public synchronized void removeUserTokens(String username) {
Iterator<String> series = this.seriesTokens.keySet().iterator();
while (series.hasNext()) {
String seriesId = series.next();
PersistentRememberMeToken token = this.seriesTokens.get(seriesId);
if (username.equals(token.getUsername())) {
series.remove();
}
}
}
}
2. 会话过期自动登录流程源码变化
登录流程的步骤1、步骤2不变,变化的是步骤3,不再调用TokenBasedRememberMeServices#processAutoLoginCookie,而是调用PersistentTokenBasedRememberMeServices#processAutoLoginCookie。
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
private PersistentTokenRepository tokenRepository
= new InMemoryTokenRepositoryImpl();
private SecureRandom random;
public static final int DEFAULT_SERIES_LENGTH = 16;
public static final int DEFAULT_TOKEN_LENGTH = 16;
private int seriesLength = DEFAULT_SERIES_LENGTH;
private int tokenLength = DEFAULT_TOKEN_LENGTH;
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,HttpServletResponse response) {
// 判断cookieTokens的长度是否为2,如果不是说明格式错误,抛出异常
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"+ Arrays.asList(cookieTokens) + "'");
}
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
// 从内存中根据presentedSeries获取PersistentRememberMeToken
PersistentRememberMeToken token
= this.tokenRepository.getTokenForSeries(presentedSeries);
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);
}
// 判断用户输入的presentedToken和内存的tokenValue是否相同,不同则抛出异常清除token存储信息
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value.
// Delete all logins for this user and throw an exception to warn them.
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
// 判断token是否过期,过期则抛出异常
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
// Token also matches, so login is valid.
// Update the token value, keeping the *same* series number.
// 生成一个信息的tokenValue,并构造PersistentRememberMeToken
PersistentRememberMeToken newToken
= new PersistentRememberMeToken(token.getUsername(),
token.getSeries(),
generateTokenData(),
new Date());
try {
// 更新内存中存储的PersistentRememberMeToken
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
// 将cookie添加到响应对象response中
addCookie(newToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to update token: ", ex);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
// 返回登录成功的用户详情信息
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}
private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
setCookie(new String[] {
token.getSeries(), token.getTokenValue() }, getTokenValiditySeconds(), request,
response);
}
}
- 不同于 TokonBasedRemornberMeServices 中的 processAutologinCookie 方法,这里cookieTokens 数组的⻓度为2,第一项是series,第二项是 token。
- 从cookieTokens数组中分到提取出 series 和 token. 然后根据 series 去内存中查询出一个 PersistentRememberMeToken对象。如果查询出来的对象为null,表示内存中并没有series对应的值,本次自动登录失败。如果查询出来的 token 和从cookieTokens 中解析出来的token不相同,说明自动登录会牌已经泄漏(恶意用户利用令牌登录后,内存中的token变了),此时移除当前用户的所有自动登录记录并抛出异常。
- 根据数据库中查询出来的结果判断令牌是否过期,如果过期就抛出异常。
- 生成一个新的 PersistentRememberMeToken 对象,用户名和series 不变,token 重新生成,date 也使用当前时间。newToken 生成后,根据 series 去修改内存中的token 和 date(即每次自动登录后都会产生新的 token 和 date)
- 调用 addCookie 方法添加 Cookie, 在addCookie 方法中,会调用到我们前面所说的setCookie 方法,但是要注意第一个数组参数中只有两项:series 和 token(即返回到前端的令牌是通过对 series 和 token 进行 Base64 编码得到的)
- 最后将根据用户名查询用户对象并返回。
3. 使用内存中令牌实现记住我功能
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User
.withUsername("root")
.password("{noop}123")
.roles("admin").build());
return inMemoryUserDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启请求的权限管理
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
// 开启rememberme功能
.rememberMe()
// 自定义 rememberMeServices
.rememberMeServices(rememberMeServices())
.and()
.csrf().disable();
}
@Bean
public RememberMeServices rememberMeServices(){
// 参数1:自定义一个令牌key,默认为uuid
// 参数2:认证数据源
// 参数3:令牌存储方式
return new PersistentTokenBasedRememberMeServices(
"key",
userDetailsService(),
new InMemoryTokenRepositoryImpl());
}
}
4. 持久化令牌
①引入依赖:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
②SpringBoot配置文件:
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8&useSSL=false&serverTimezone=Hongkong
spring.datasource.username=root
spring.datasource.password=root
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.hh.entity
logging.level.com.hh=debug
server.servlet.session.timeout=1
③配置持久化令牌:
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User
.withUsername("root")
.password("{noop}123")
.roles("admin").build());
return inMemoryUserDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启请求的权限管理
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
// 开启rememberme功能
.rememberMe()
.tokenRepository(persistentTokenRepository())
.and()
.csrf().disable();
}
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
}
启动项目并访问登录接口:后台自动生成数据库表
7. 传统Web开发自定义记住我功能
①登录页面 login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>用户登录</h1>
<form method="post" th:action="@{/doLogin}">
用户名 : <input name="uname" type="text"> <br>
密码: <input name="passwd" type="text"> <br>
<!--value 可选值默认为: true yes on 1 都可以-->
记住我: <input name="remember-me" type="checkbox" value="true"> <br>
<input type="submit" value="登录">
</form>
<h3>
<div th:text="${session.SPRING_SECURITY_LAST_EXCEPTION}"></div>
</h3>
</body>
</html>
@Controller
public class LoginController {
@GetMapping("/login.html")
public String login(){
return "login";
}
}
②登录成功跳转的 index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>系统主页</title>
</head>
<body>
<h1>欢迎进入我的主页</h1>
</body>
</html>
@Controller
public class IndexController {
@RequestMapping("/index.html")
public String index() {
System.out.println("hello index");
return "index";
}
}
③配置SpringSecurity:
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User
.withUsername("root")
.password("{noop}123")
.roles("admin").build());
return inMemoryUserDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 开启请求的权限管理
http.authorizeRequests()
.mvcMatchers("/login.html").permitAll()
.mvcMatchers("/index.html").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.usernameParameter("uname")
.passwordParameter("passwd")
.successForwardUrl("/index.html")
.failureUrl("/login.html")
.and()
// 开启rememberMe功能
.rememberMe()
.rememberMeParameter("remember-me")
.and()
.csrf().disable();
}
}
8. 前后端分离开发自定义记住我功能
①自定义过滤器替换 UsernamePasswordAuthenticationFilter
/**
* 自定义前后端分离认证 Filter
*/
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("========================================");
//1.判断是否是 post 方式请求
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
//2.判断是否是 json 格式请求类型
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
//3.从 json 数据中获取用户输入用户名和密码进行认证 {"uname":"xxx","password":"xxx","remember-me":true}
try {
Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
String rememberValue = userInfo.get(AbstractRememberMeServices.DEFAULT_PARAMETER);
if (!ObjectUtils.isEmpty(rememberValue)) {
request.setAttribute(AbstractRememberMeServices.DEFAULT_PARAMETER, rememberValue);
}
System.out.println("用户名: " + username + " 密码: " + password + " 是否记住我: " + rememberValue);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
e.printStackTrace();
}
}
return super.attemptAuthentication(request, response);
}
}
②自定义 MyPersistentTokenBasedRememberMeServices
/**
* 自定义记住我 services 实现类
*/
public class MyPersistentTokenBasedRememberMeServices extends PersistentTokenBasedRememberMeServices {
public MyPersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
super(key, userDetailsService, tokenRepository);
}
/**
* 自定义前后端分离获取 remember-me 方式
*
* @param request
* @param parameter
* @return
*/
@Override
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
String paramValue = request.getAttribute(parameter).toString();
if (paramValue != null) {
if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
|| paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
return true;
}
}
return false;
}
}
③SpringSecurity配置:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("admin").build());
return inMemoryUserDetailsManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//自定义 filter 交给工厂管理
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setFilterProcessesUrl("/doLogin");//指定认证 url
loginFilter.setUsernameParameter("uname");//指定接收json 用户名 key
loginFilter.setPasswordParameter("passwd");//指定接收 json 密码 key
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setRememberMeServices(rememberMeServices()); //设置认证成功时使用自定义rememberMeService
//认证成功处理
loginFilter.setAuthenticationSuccessHandler((req, resp, authentication) -> {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录成功");
result.put("用户信息", authentication.getPrincipal());
resp.setContentType("application/json;charset=UTF-8");
resp.setStatus(HttpStatus.OK.value());
String s = new ObjectMapper().writeValueAsString(result);
resp.getWriter().println(s);
});
//认证失败处理
loginFilter.setAuthenticationFailureHandler((req, resp, ex) -> {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "登录失败: " + ex.getMessage());
resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
resp.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
resp.getWriter().println(s);
});
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.anyRequest().authenticated()//所有请求必须认证
.and()
.formLogin()
.and()
.rememberMe() //开启记住我功能 cookie 进行实现 1.认证成功保存记住我 cookie 到客户端 2.只有 cookie 写入客户端成功才能实现自动登录功能
.rememberMeServices(rememberMeServices()) //设置自动登录使用哪个 rememberMeServices
.and()
.exceptionHandling()
.authenticationEntryPoint((req, resp, ex) -> {
resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
resp.setStatus(HttpStatus.UNAUTHORIZED.value());
resp.getWriter().println("请认证之后再去处理!");
})
.and()
.logout()
.logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", HttpMethod.DELETE.name()),
new AntPathRequestMatcher("/logout", HttpMethod.GET.name())
))
.logoutSuccessHandler((req, resp, auth) -> {
Map<String, Object> result = new HashMap<String, Object>();
result.put("msg", "注销成功");
result.put("用户信息", auth.getPrincipal());
resp.setContentType("application/json;charset=UTF-8");
resp.setStatus(HttpStatus.OK.value());
String s = new ObjectMapper().writeValueAsString(result);
resp.getWriter().println(s);
})
.and()
.csrf().disable();
// at: 用来某个 filter 替换过滤器链中哪个 filter
// before: 放在过滤器链中哪个 filter 之前
// after: 放在过滤器链中那个 filter 之后
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
@Bean
public RememberMeServices rememberMeServices() {
return new MyPersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), new InMemoryTokenRepositoryImpl());
}
}
④测试:
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
System.out.println("test ....");
return "test ok!";
}
}