写在前面
在前面对登录流程进行了较为细致的学习之后,接下来实现一个常用的功能—自动登录。请注意本篇新建了一个工程auto-login,不再使用之前的代码。
使用场景
以常用的QQ邮箱为例,如下所示界面就是支持自动登录:
不仅仅是这里举例的QQ邮箱,很多网站都有这个功能。对于用户来说,每次登录都需要输出用户名和密码不仅增加了登录难度,降低用户体验,更重要的是账号被盗的风险也随之提升。
自动登录,说白了就是用户在登录成功后,那么在接下来的一段时间里,就算发生了诸如用户关闭浏览器、服务器宕机重启等行为时,此时用户依旧可以保持之前的登录状态,而不用重新登录。SpringSecurity对于自动登录提供了简易的配置方式,开发者通过简单的一些配置就能实现较为复杂的自动登录功能。
工程初始化
第一步,使用IDEA创建一个名为auto-login
的SpringBoot工程,之后选择添加Web和SpringSecurity依赖。
第二步,在application.yml
配置文件中新增如下配置信息,用于自定义登录用户名和密码:
1 2 3 4 5
| spring: security: user: name: "envythink" password: "1234"
|
第三步,新建controller包,并在里面新建一个HelloController
类,简单起见里面只提供一个/hello
接口,如下所示:
1 2 3 4 5 6 7
| @RestController public class HelloController { @GetMapping("/hello") public String hello(){ return "Hello,World!"; } }
|
第四步,新建config包,并在里面新建一个MySecurityConfig
类,注意这个类需要继承WebSecurityConfigurerAdapter
类,并实现其中的configure(HttpSecurity http)
方法,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12
| @Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .csrf().disable(); } }
|
第五步,启动项目进行测试。访问http://localhost:8080/hello
链接,可以看到首先会跳转到登录页面:
之后输入第二步中配置的用户名和密码,点击登录,之后页面就跳转到http://localhost:8080/hello
链接界面,进而显示/hello
接口的信息:
上面是一个非常简单的SpringSecurity使用的例子,接下来就在此基础上实现“自动登录”功能。
代码实现
“自动登录”在SpringSecurity中被称为“记住我”,想实现“记住我”这个功能,开发者只需在SpringSecurity Config配置类中的configure(HttpSecurity http)
方法中添加.rememberMe()
配置方法即可,注意添加的位置:
只需添加一个方法就能实现“自动登录”,接下来进行验证。重启项目,之后继续访问http://localhost:8080/hello
链接,可以看到此时登录页面就出现了“自动登录”这一提示框:
输入之前配置的用户名和密码,并勾选“记住我”选框,点击登录可以发现出错了:
1
| java.lang.IllegalStateException: UserDetailsService is required.
|
原因在于用户未提供一个UserDetailsService实例,因此抛出异常。
提供一个UserDetailsService实例
出于简单考虑,这里就不使用数据库,而是直接将查询用户信息的逻辑固定化。
第一步,新建一个entity包,并在里面新建一个User实体类,注意它需要实现UserDetails
接口,并实现其中的抽象方法。简单起见里面只提供两个属性,除了返回用户名和密码,其余方法都设置为true,这样便于后续测试,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| public class User implements UserDetails { private String username;
private String password;
public User() { }
public User(String username, String password) { this.username = username; this.password = password; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; }
@Override public String getPassword() { return password; }
@Override public String getUsername() { return username; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
|
第二步,新建一个service包,并在里面新建一个MyUserDetailsService
类,注意它需要实现UserDetailsService
接口,并实现其中的抽象方法:
1 2 3 4 5 6 7 8 9 10 11
| @Service public class MyUserDetailsService implements UserDetailsService {
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { if(!"envythink".equals(username)){ return null; } return new User("envythink","1234"); } }
|
第三步,新建一个config包,并在里面新建一个MySecurityConfig
类,注意它需要继承WebSecurityConfigurerAdapter
类,并实现其中的抽象方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailsService myUserDetailsService;
@Bean PasswordEncoder passwordEncoder(){ return NoOpPasswordEncoder.getInstance(); }
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailsService); }
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .rememberMe() .and() .csrf().disable(); } }
|
重启项目
在完成上述步骤后,接下来就重新启动项目,继续访问http://localhost:8080/hello
链接,可以看到此时登录页面依旧出现了“自动登录”这一提示框:
输入之前配置的用户名和密码,并勾选“记住我”选框,点击登录可以发现现在系统是登录成功了:
可以看到此处的登录表单中有三个输入控件,且name属性分别为username、password和remember-me,也就是说当开发者需要自定义登录页面,那么此时控件的name属性必须与前述三者保持一致。
登录成功后,页面会自动跳转到/hello
接口,同时我们发现此时系统在访问/hello
接口的时候,是携带了Cookie信息,如下所示:
放大一下,可以看到里面有一个以remember-me
为Key的键值对,而这个键值对就保存了用户信息,这个后续会详解:
接下来我们关闭浏览器,之后再次重新打开浏览器。正常来说,当用户关闭浏览器,再次访问/hello
接口时,系统会要求用户重新登录的,但是如果使用了“记住我”这一功能,那么此时用户就可以访问/hello
接口,而不用重新登录,这也就说明我们设置的RememberMe功能生效了,即用户下次就可以自动登录。
流程梳理
接下来将对上述“自动登录”功能的流程进行梳理,首先来看之前提到的Cookie,里面有一个以remember-me
为Key的键值对,信息如下:
1
| remember-me=ZW52eXRoaW5rOjE2MTEyOTQ3OTgwMDI6NGFlZTYxYzMwNzFmMTg1NDJiOWQwY2Y4NzE1Yjk1ZjI
|
可以看到这里的Value是一串使用Base64加密的字符串,开发者可以使用一些在线的解码工具来解码,当然了也可以自己提供一个方法来进行转码。在项目测试类中新建如下方法:
1 2 3 4 5 6 7 8 9
| @SpringBootTest class AutoLoginApplicationTests {
@Test void contextLoads() { String s = new String(Base64.getDecoder().decode("ZW52eXRoaW5rOjE2MTEyOTQ3OTgwMDI6NGFlZTYxYzMwNzFmMTg1NDJiOWQwY2Y4NzE1Yjk1ZjI")); System.out.println(s); } }
|
之后执行该方法,可以看到控制台输出以下信息:
1
| envythink:1611294798002:4aee61c3071f18542b9d0cf8715b95f2
|
可以看到这个Base64字符串被:
号分隔为3部分,第一部分是用户名,这个就是之前输入的;第二部分非常像一个时间戳,通过时间戳转换工具可以知道,它的确是时间戳,且值为当前登录时间+2周的时间;第三部分是使用MD5哈希函数计算出来的值,也就是说它是一个加密值,它的明文格式如下所示:
1
| username + ":" +tokenExpiryTime + ":" + password + ":" + key
|
最后的key是盐值,用于防止令牌被修改,这些在前面学习Shiro框架的时候笔者都已经介绍过,因此这里就不再详细介绍。
因此上面“自动登录”的完整流程是:用户访问/hello
接口,系统自动跳转到登录页面,用户输入用户名和密码,并勾选“记住我”之后,系统验证登录成功,页面将Remember-Me键值对添加到Cookie中,并携带Cookie自动跳转到/hello
接口页面。之后用户关闭浏览器,并重新打开,再去访问/hello
接口,此时会携带Cookie中的Remember-Me键值对到服务端,之后服务端会将其进行计算,进而得到用户名和过期时间,之后再根据用户名来查询用户密码,接着通过md5哈希函数来计算得到哈希值,然后再将计算出的哈希值和浏览器传递来的哈希值进行对比,进而确认此令牌是否有效。
源码分析
接下来将对上述“自动登录”功能进行源码分析,主要涉及到两个过程:一个是remember-me
令牌生成过程;另一个是remember-me
令牌解析过程。
remember-me
令牌生成过程
remember-me
令牌的生成主要在TokenBasedRememberMeServices#onLoginSuccess
方法内进行的,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = this.retrieveUserName(successfulAuthentication); String password = this.retrievePassword(successfulAuthentication); if (!StringUtils.hasLength(username)) { this.logger.debug("Unable to retrieve username"); } else { if (!StringUtils.hasLength(password)) { UserDetails user = this.getUserDetailsService().loadUserByUsername(username); password = user.getPassword(); if (!StringUtils.hasLength(password)) { this.logger.debug("Unable to obtain password for user: " + username); return; } }
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis(); expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime); String signatureValue = this.makeTokenSignature(expiryTime, username, password); this.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) + "'"); }
} }
|
分析一下上述方法的执行逻辑:
(1)通过retrieveUserName()
和retrievePassword()
方法从登录成功的successfulAuthentication
对象中取出用户和密码。先查看一下retrieveUserName()
方法的源码,如下所示:
1 2 3
| protected String retrieveUserName(Authentication authentication) { return this.isInstanceOfUserDetails(authentication) ? ((UserDetails)authentication.getPrincipal()).getUsername() : authentication.getPrincipal().toString(); }
|
可以看到它需要传入一个Authentication
对象,之后调用isInstanceOfUserDetails()
方法来判断这个对象是否是UserDetails
类的实例(这里肯定是的,因为我们自定义的User实体类就是继承于UserDetails
类)。查看一下这个isInstanceOfUserDetails()
方法的源码:
1 2 3
| private boolean isInstanceOfUserDetails(Authentication authentication) { return authentication.getPrincipal() instanceof UserDetails; }
|
准确说是判断Authentication#getPrincipal()
方法得到的对象是否是UserDetails
类的实例,是就返回true,否则就是false。之后retrieveUserName()
方法就根据Authentication#getPrincipal()
方法的返回结果来进行取值。
(2)之后判断用户名和密码是否存在,请注意,由于登录成功后,密码可能会被擦除,因此如果(1)中没有获取到用户密码,那么就需要再次从UserDetailsService
对象中根据用户名来获取用户密码。
(3)调用calculateLoginLifetime()
方法来获取令牌的有效期,前面说过令牌默认有效期就是两周(这里的时间戳1209600就是两周)。
(4)调用System.currentTimeMillis()
方法来获取当前系统的时间戳,之后在此基础上加上(3)中得到的令牌有效期,其实就是在当前时间添加两周时间。
(5)调用makeTokenSignature()
方法来计算哈希值,查看一下该方法的源码:
1 2 3 4 5 6 7 8 9 10
| protected String makeTokenSignature(long tokenExpiryTime, String username, String password) { String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey();
try { MessageDigest digest = MessageDigest.getInstance("MD5"); return new String(Hex.encode(digest.digest(data.getBytes()))); } catch (NoSuchAlgorithmException var7) { throw new IllegalStateException("No MD5 algorithm available!"); } }
|
可以看到它通过构建一个包含用户名、令牌有效期、密码和盐的字符串,之后计算该字符串的哈希值。请注意,如果开发者没有设置这个盐Key,那么它默认使用的是RememberMeConfigurer#getKey()
方法。我尝试在TokenBasedRememberMeServices
类中查找getKey()
方法,但是并没有发现,于是在该类的父类及其其他实现类中进行查询,依旧还是没有,最终通过查阅文档发现它调用的居然是RememberMeConfigurer#getKey()
方法:
1 2 3 4 5 6 7 8 9 10
| private String getKey() { if (this.key == null) { if (this.rememberMeServices instanceof AbstractRememberMeServices) { this.key = ((AbstractRememberMeServices)this.rememberMeServices).getKey(); } else { this.key = UUID.randomUUID().toString(); } } return this.key; }
|
可以看到它其实是一串UUID字符串。请注意,由于这里使用的UUID字符串,而它是一个随机字符串,那么就会有一个问题,每次重启服务端这个Key就会变化,这样肯定会导致之前生成的所有remember-me
自动登录令牌失效,因此开发者应当指定Key的值。
开发者只需在MySecurityConfig#configure(HttpSecurity http)
中添加一个.key()
方法,注意添加的位置:
这样用户配置了Key,那么即使用户关闭浏览器和重启服务器,此时访问/hello
接口也是不需要登录的。
(6)回到onLoginSuccess()
方法中,可以看到它调用setCookie()
方法将用户名、令牌过期时间和(5)中得到的哈希值作为参数传入:
1
| this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}
|
以上就是remember-me
令牌生成的过程,接下来结合之前《详解登录流程》一文来梳理整个登录流程,这里简单列举该过程中涉及到的类,如下所示:
1 2 3 4
| AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication -> AbstractRememberMeServices#loginSuccess -> TokenBasedRememberMeServices#onLoginSuccess
|
remember-me
令牌解析过程
接下来分析当用户关闭浏览器,之后打开再次访问/hello
接口,或者开发者重启服务器时,用户“自动登录”这一流程。
前面提到过SpringSecurity中的一系列功能都是通过一个过滤器链来实现的,此处的RememberMe功能亦是如此。那么问题来了,用的哪个过滤器链呢?前面登录使用的是UsernamePasswordAuthenticationFilter
类,类比一下,RememberMe功能是不是使用了RememberMeAuthenticationFilter
?找一下,可以发现确实存在这个类,而且就是这个类提供的过滤器链,因为里面也有doFilter方法,查看一下该doFilter方法的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() != null) { this.logger.debug(LogMessage.of(() -> { return "SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'"; })); chain.doFilter(request, response); } else { Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null) { try { rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth); SecurityContextHolder.getContext().setAuthentication(rememberMeAuth); this.onSuccessfulAuthentication(request, response, rememberMeAuth); this.logger.debug(LogMessage.of(() -> { return "SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'"; })); 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 var6) { this.logger.debug(LogMessage.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '%s'; invalidating remember-me token", rememberMeAuth), var6); this.rememberMeServices.loginFail(request, response); this.onUnsuccessfulAuthentication(request, response, var6); } }
chain.doFilter(request, response); } }
|
分析一下上述方法的执行逻辑:
(1)第一行代码非常熟悉,前面多次提到开发者可以从中获取用户的登录信息:
1
| SecurityContextHolder.getContext().getAuthentication()
|
如果开发者无法从SecurityContextHolder
中获取登录信息,那么就需要调用rememberMeServices.autoLogin()
方法进行用户登录,查看一下该方法的源码,注意该方法实现类为AbstractRememberMeServices
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { String rememberMeCookie = this.extractRememberMeCookie(request); if (rememberMeCookie == null) { return null; } else { this.logger.debug("Remember-me cookie detected"); if (rememberMeCookie.length() == 0) { this.logger.debug("Cookie was empty"); this.cancelCookie(request, response); return null; } else { try { String[] cookieTokens = this.decodeCookie(rememberMeCookie); UserDetails user = this.processAutoLoginCookie(cookieTokens, request, response); this.userDetailsChecker.check(user); this.logger.debug("Remember-me cookie accepted"); return this.createSuccessfulAuthentication(request, user); } catch (CookieTheftException var6) { this.cancelCookie(request, response); throw var6; } catch (UsernameNotFoundException var7) { this.logger.debug("Remember-me login was valid but corresponding user not found.", var7); } catch (InvalidCookieException var8) { this.logger.debug("Invalid remember-me cookie: " + var8.getMessage()); } catch (AccountStatusException var9) { this.logger.debug("Invalid UserDetails: " + var9.getMessage()); } catch (RememberMeAuthenticationException var10) { this.logger.debug(var10.getMessage()); }
this.cancelCookie(request, response); return null; } } }
|
这个方法的逻辑比较简单,因此就不做细致分析。它主要是解析Cookie,并对Cookie信息进行解码,之后再调用processAutoLoginCookie()
方法来进行校验,查看一下这个processAutoLoginCookie()
方法的源码,注意该方法实现类为TokenBasedRememberMeServices
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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) + "'"); } else { long tokenExpiryTime = this.getTokenExpiryTime(cookieTokens); if (this.isTokenExpired(tokenExpiryTime)) { throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')"); } else { UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]); Assert.notNull(userDetails, () -> { return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation"; }); String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword()); if (!equals(expectedTokenSignature, cookieTokens[2])) { throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'"); } else { return userDetails; } } } }
|
这个方法的逻辑也很简单,获取用户名和令牌过期时间,之后通过用户名来查询用户密码,接着通过调用makeTokenSignature()
方法来计算此时的哈希值。
回到之前的autoLogin()
方法中,此时会将processAutoLoginCookie()
方法中返回的哈希值与浏览器中传递来的哈希值进行对比,来判断这个令牌是否有效,进而确认登录是否有效。
文章小结
通过上面的学习,我们对RememberMe的实现原理有了一个清晰的认识,可以发现该功能的核心就是将令牌(登录信息)存储在Cookie中。这样即使服务器重启、浏览器关闭后再次打开,只要令牌没有过期,那么用户就能访问到数据。
不过使用Cookie还是有一定的风险,因为它存储在本地客户端中,假设某个不法分子获取了用户令牌,那么就可能产生大的问题。
不过这样就自相矛盾了,为了提升用户体验,我们使用了“自我登录”功能,但是又引发了安全问题,不同情况有不同的取舍,是选择用户体验?还是安全性?这个需要结合实际情况,但是我们可以通过技术迭代,最大限度地将安全风险降低到最小。那么如何降低风险呢?这些都将在下一篇《持久化令牌》一文中进行学习。