자동 로그인은 소프트웨어를 개발할 때 매우 일반적인 기능입니다. 예를 들어 QQ 사서함에 로그인합니다.
많은 웹 사이트에서 로그인 할 때 유사한 옵션이 표시됩니다. 결국 사용자가 항상 사용자 이름과 비밀번호를 입력하도록하는 것은 매우 번거로운 일입니다.
자동 로그인 기능은 사용자가 성공적으로 로그인 한 후 일정 시간 내에 사용자가 브라우저를 닫았다가 다시 열거 나 서버를 다시 시작하면 사용자가 다시 로그인 할 필요가 없으며 사용자가 여전히 인터페이스 데이터에 직접 액세스 할 수 있음을 의미합니다.
공통 기능으로 Spring Security도 해당 지원을 제공해야하며,이 기사에서는 Spring Security에서이 기능을 구현하는 방법을 살펴 보겠습니다.
기사 디렉토리
1. Remember-me 가입
구성 편의를 위해 두 가지 종속성을 추가하기 만하면됩니다.
구성 클래스에 다음 코드를 추가합니다.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("yolo")
.password("123").roles("admin");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.and()
.csrf().disable();
}
}
여기에 .rememberMe()
캔을 추가하면 자동 로그인 기능이 성공적으로 추가되었음을 알 수 있습니다.
다음으로 테스트 인터페이스를 무작위로 추가합니다.
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "Hello Yolo !!!";
}
}
이때 모든 사람들은 기본 로그인 페이지에 나를 기억하는 옵션이 하나 더 있다는 것을 알았습니다. 사용자 이름과 암호를 입력하고 기억하기 상자를 선택한 다음 로그인 버튼을 클릭하여 로그인 작업을 수행합니다.
보시다시피 로그인 데이터에는 사용자 이름과 비밀번호 외에도 기억하기가 있습니다.이를 보여주는 이유는 로그인 페이지를 사용자 정의해야하는 경우 모든 사람에게 알리기 위해서입니다. RememberMe 옵션의 키는 어떻습니까? 쓰다.
로그인이 성공하면 자동으로 hello 인터페이스로 이동합니다. 시스템이 hello 인터페이스에 액세스 할 때 쿠키는 다음을 전달합니다.
여기에 remember-me
구현 된 핵심이 하나 더 언급되었습니다 remember-me
. 이에 대해 설명하겠습니다. 테스트 결과를 살펴 보겠습니다.
接下来,我们关闭浏览器,再重新打开浏览器。正常情况下,浏览器关闭再重新打开,如果需要再次访问 hello 接口,就需要我们重新登录了。但是此时,我们再去访问 hello 接口,发现不用重新登录了,直接就能访问到,这就说明我们的 RememberMe 配置生效了(即下次自动登录功能生效了)。
二、原理分析
按理说,浏览器关闭再重新打开,就要重新登录,现在竟然不用等了,那么这个功能到底是怎么实现的呢?
首先我们来分析一下 cookie 中多出来的这个 remember-me
,这个值一看就是一个 Base64 转码后的字符串,我们可以使用网上的一些在线工具来解码,可以自己简单写两行代码来解码:
@Test
void contextLoads() {
String s = new String(
Base64.getDecoder().decode("eW9sbzoxNjAxNDczNTY2NTA1OjlmMGY5YjBjOTAzYmNjYmU3ZjMwYWM0NjVlZjEzNmQ5"));
System.out.println("s = " + s);
}
执行这段代码,输出结果如下:
s = yolo:1601473566505:9f0f9b0c903bccbe7f30ac465ef136d9
可以看到,这段 Base64 字符串实际上用 :
隔开,分成了三部分:
(1)第一段是用户名,这个无需质疑。
(2)第二段看起来是一个时间戳,我们通过在线工具或者 Java 代码解析后发现,这是一个两周后的数据。
(3)第三段我就不卖关子了,这是使用 MD5 散列函数算出来的值,他的明文格式是
username + ":" + tokenExpiryTime + ":" + password + ":" + key
,最后的 key 是一个散列盐值,可以用来防治令牌被修改。
了解到 cookie 中 remember-me
的含义之后,那么我们对于记住我的登录流程也就很容易猜到了了。
在浏览器关闭后,并重新打开之后,用户再去访问 hello 接口,此时会携带着 cookie 中的 remember-me
到服务端,服务到拿到值之后,可以方便的计算出用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。
流程就是这么个流程,接下来我们通过分析源码来验证一下这个流程对不对。
三、源码分析
接下来,我们通过源码来验证一下我们上面说的对不对。
这里主要从两个方面来介绍,一个是 remember-me 这个令牌生成的过程,另一个则是它解析的过程。
1. 生成
生成的核心处理方法在:TokenBasedRememberMeServices#onLoginSuccess:
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
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);
}
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
(1)首先从登录成功的 Authentication 中提取出用户名/密码。
(2)由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。
(3)再接下来去获取令牌的有效期,令牌有效期默认就是两周。
(4)再接下来调用makeTokenSignature
方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在RememberMeConfigurer#getKey
方法中进行设置的,它的值是一个 UUID 字符串。
(5)最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。
关于第四点,我这里再说一下。
由于我们自己没有设置 key,key 默认值是一个 UUID 字符串,这样会带来一个问题,就是如果服务端重启,这个 key 会变,这样就导致之前派发出去的所有 remember-me
自动登录令牌失效,所以,我们可以指定这个 key。指定方式如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("yolo")
.and()
.csrf().disable();
}
如果自己配置了 key,即使服务端重启,即使浏览器打开再关闭,也依然能够访问到 hello 接口
这是 remember-me 令牌生成的过程。至于是如何走到 onLoginSuccess 方法的,这里可以给大家稍微提醒一下思路:
AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication -> AbstractRememberMeServices#loginSuccess -> TokenBasedRememberMeServices#onLoginSuccess。
2. 解析
那么当用户关掉并打开浏览器之后,重新访问 /hello
接口,此时的认证流程又是怎么样的呢?
我们之前说过,Spring Security 中的一系列功能都是通过一个过滤器链实现的,RememberMe
这个功能当然也不例外。
Spring Security 中提供了 RememberMeAuthenticationFilter
类专门用来做相关的事情,我们来看下 RememberMeAuthenticationFilter
的 doFilter
方法:
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) {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.eventPublisher != null) {
eventPublisher
.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext()
.getAuthentication(), this.getClass()));
}
if (successHandler != null) {
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
}
}
chain.doFilter(request, response);
}
else {
chain.doFilter(request, response);
}
}
这个方法最关键的地方在于,如果从 SecurityContextHolder
中无法获取到当前登录用户实例,那么就调用 rememberMeServices.autoLogin
逻辑进行登录,我们来看下这个方法:
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 {
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) {
throw cte;
}
cancelCookie(request, response);
return null;
}
可以看到,这里就是提取出 cookie
信息,并对 cookie
信息进行解码,解码之后,再调用 processAutoLoginCookie
方法去做校验,processAutoLoginCookie
方法的代码我就不贴了,核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
四、总结
看了上面的文章,大家可能已经发现,如果我们开启了 RememberMe
功能,最最核心的东西就是放在 cookie
中的令牌了,这个令牌突破了 session
的限制,即使服务器重启、即使浏览器关闭又重新打开,只要这个令牌没有过期,就能访问到数据。
一旦令牌丢失,别人就可以拿着这个令牌随意登录我们的系统了,这是一个非常危险的操作。
但是实际上这是一段悖论,为了提高用户体验(少登录),我们的系统不可避免的引出了一些安全问题,不过我们可以通过技术将安全风险降低到最小