写在前面

在前面对登录流程进行了较为细致的学习之后,接下来实现一个常用的功能—自动登录。请注意本篇新建了一个工程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还是有一定的风险,因为它存储在本地客户端中,假设某个不法分子获取了用户令牌,那么就可能产生大的问题。

不过这样就自相矛盾了,为了提升用户体验,我们使用了“自我登录”功能,但是又引发了安全问题,不同情况有不同的取舍,是选择用户体验?还是安全性?这个需要结合实际情况,但是我们可以通过技术迭代,最大限度地将安全风险降低到最小。那么如何降低风险呢?这些都将在下一篇《持久化令牌》一文中进行学习。