Remember-me认证方式指的是能在不同的会话间记录用户认证信息的功能。通常通过向客户端发送一个cookie,在以后用户访问网站时通过这个cookie中的信息来自动登录实现。spring security 已经为我们提供了必要的hooks实现这个功能,并提供了两种具体的实现。一种是cookie中token包含了所有认证所需信息,并通过hash算法来保护这个token,另一种通过数据库或者其他持久化机制来存储生成的token。两种实现方式都需要我们提供一个UserDetailsService。如果我们采取的认证方式没有用到UserDetailsService(如LDAP),除非我们在application context中创建一个UserDetailsService来适配,否则Remember-me功能将不能正确执行。
一、remember-me功能实现逻辑
- 用户首次登录认证成功后,调用RememberMeServices的loginSuccess方法,将用户凭证信息序列化成token,以某种形式存储起来
- 用户session失效后再次访问系统,会调用RememberMeServices的autoLogin方法,根据客户端传过来的cookie信息,获取之前存储的token信息,并解析出认证需要的信息,完成自动认证
二.基于简单hash的方式
1.这种方式MD5算法实现remember-me功能,核心思想是在用户认证成功后通过下面的算法生成一个token,token中包含用户名和密码等信息,并将此token缓存到客户端的cookie中。
base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" password + ":" + key)) username: 用户名 password: 密码 expirationTime: 过期日期,单位是毫秒,默认两周 key: 一个私有的key值,用来保护token,防止被伪造
通过上面的算法得到的token,如果在有效期内,并且用户名和密码没有变化的情况下就可以实现自动认证功能,过程如下
- 服务器接收到token后,利用base64对token值解码,获取到username、expirationTime、以及一个特定的hash串,hash串中包含了用户的密码信息(因为hash算法的不可逆性,这个密码是不能恢复出来的)
- 服务器判断expirationTime有没有过期,如果过期就抛出异常不再进行自动认证
- 服务器根据解析出来的username,调用UserDetailsService.loadUserByUsername方法获取用户对应的密码、权限等信息
- 将username、获取出来的password、expirationTime、key值经过相同的hash算法得到一个hash串
- 判断上一步计算出来的hash串和我们从cookie中解析出来的hash串是否一致,一致的话生成一个RememberMeAuthenticationToken,不一致抛出异常不在进行自动认证
- 接着调用authenticationManager.authenticate,传入上一步生成的token
- authenticationManager中最终会调用到RememberMeAuthenticationProvider的authenticate方法,因为在之前我们已经判断过现在的cookie中存储的认证信息对应的用户名和密码是正确的,所以这个方法只是简单的判断下RememberMeAuthenticationToken中的key值是否和RememberMeAuthenticationProvider中设定的key一致就可以了
- 如果key值一致则认证成功,并将认证后的Authentication存入SecurityContextHolder供鉴权使用
2.采用这种方式有一个潜在安全问题,就是一个缓存下来的token可以被任何用户使用,一直到设定的时效过期,并且在这个过程中用户本人并不能察觉到,在摘要认证中也有同样的问题。如果非法的使用者做了某种操作使用户发现remember-me token被泄漏了,用户可以通过修改密码来使所有缓存的token失效。另一个安全问题是我们需要把username也放到cookie中,也会造成一定的信息泄露。如果安全级别比较高,应该采用下面讲的第二种方式,将token存入到库中,否则应该禁止使用remember-me功能。
3.在spring boot中通过下面的配置启用
http .csrf() .disable() .authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .permitAll() .and() .rememberMe() .and() .logout() .logoutSuccessUrl("/login.html");
spring boot会自动给我们装配一个UserDetailsService,如果我们系统中有超过一个的UserDetailsService,我们必须明确的通过rememberMe().userDetailsService(userDetailsService)方法指定一个。
具体例子可参考 spring-security(五)java config-sample之rememberme
4.基于hash的remember-me功能策略类是TokenBasedRememberMeServices
这个类还实现了LogoutHandler接口,当在用户logout时要删除token的需求时,可以将这个类配置到LogoutFilter中。
三、采用持久化机制实现
1.这种实现方式的核心算法是首次登录成功后生成一个PersistentRememberMeToken对象,包含如下信息
- username 用户名
- series 一个16位的随机数,之后会把这个数写入客户端的cookie中,通过这个值能唯一确定一条token记录
- tokenValue 一个16位的随机数,代表着当前的token值
- date 当前系统时间,即用户访问系统认证成功的时间
接着将生成的PersistentRememberMeToken存入到PersistentTokenRepository中,spring默认给我们提供了
[list]
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
主键是series
[/list]
然后将series、tokenValue缓存到客户端的cookie中
2.当用户session过期后用户重新访问系统时通过以下过程实现认证
- 从cookie中解析出series、tokenValue
- 根据series从PersistentTokenRepository中获取到PersistentRememberMeToken
- 用获取到的PersistentRememberMeToken中的tokenValue和cookie中获取到的tokenValue进行比较看是否匹配,不匹配的话,很有可能是token泄露了,此时spring会将此用户对应的所有token都清除掉,并抛出异常自动认证结束
- 根据PersistentRememberMeToken中的last_used时间+设置的token有效期和当前系统时间相比判读是否过期,过期抛出异常自动认证结束
- 利用原来的token的series,并重新生成一个tokenValue,再构造一个PersistentRememberMeToken,利用series为key将原来的更新掉,并将last_used更新成当前系统时间
- 再将series、和新生成的tokenValue缓存到客户端的cookie中
- 接着调用UserDetailsService.loadUserByUsername方法获取用户对应的密码、权限等信息
- 用获取到的user信息创建一个RememberMeAuthenticationToken
- 接着调用authenticationManager.authenticate,传入上一步生成的token
- authenticationManager中最终会调用到RememberMeAuthenticationProvider的authenticate方法,因为在之前我们已经判断过现在的cookie中存储的认证信息对应的用户名和密码是正确的,所以这个方法只是简单的判断下RememberMeAuthenticationToken中的key值是否和RememberMeAuthenticationProvider中设定的key一致就可以了
- 如果key值一致则认证成功,并将认证后的Authentication存入SecurityContextHolder供鉴权使用
3.具体实现类PersistentTokenBasedRememberMeServices,这个类也实现了LogoutHandler,当用户执行logout时,这个类会自动将当前用户的所有PersistentRememberMeToken删除
@Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { super.logout(request, response, authentication); if (authentication != null) { tokenRepository.removeUserTokens(authentication.getName()); } }
4.由上面的分析可知采用这种形式即可以避免将用户名称返回客户端,并且只要用户点击了logout,就可以将所有缓存的PersistentRememberMeToken都删除,可以避免因为不小心忘记登出而让其他的非法使用者一直利用自己的账号信息的问题。
四、在RememberMeAuthenticationProvider中进行认证时,需要判断key值是否相同,那这个key是怎么设置的,如何和RememberMeServices中的key保持一致呢?
在spring boot的Java config中,我们通过http.rememberme()来配置remember-me功能,下面来看下这个函数
public RememberMeConfigurerrememberMe() throws Exception { return getOrApply(new RememberMeConfigurer ()); }
具体的配置在RememberMeConfigurer中
public void init(H http) throws Exception { validateInput(); String key = getKey();----------------------------------------------------(1) RememberMeServices rememberMeServices = getRememberMeServices(http, key);-(2) http.setSharedObject(RememberMeServices.class, rememberMeServices); LogoutConfigurerlogoutConfigurer = http.getConfigurer(LogoutConfigurer.class); if (logoutConfigurer != null && this.logoutHandler != null) { logoutConfigurer.addLogoutHandler(this.logoutHandler); } RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(key);----------------------------------------(3) authenticationProvider = postProcess(authenticationProvider); http.authenticationProvider(authenticationProvider); initDefaultLoginFilter(http); }
可以看出,在这个配置类里面给我们创建了RememberMeServices和RememberMeAuthenticationProvider并且用的是同一个key值