本篇文章来看下利用Spring Security实现自动登录功能,并且简单了解其内部细节。
Spring Security自动登录实现本质其实也是利用cookie,并且对于cookie的数据可以用数据库保存(不是直接保存cookie字符串)。
首先来看下没有持久化cookie的实现,其实很简单,只要在页面增加一个自动登录的复选框,然后在SecurityConfig类增加对应的配置即可。
(1)login.html
<form action="/doLogin" method="post">
<input name="username"/>
<input name="password" type="password"/>
<input name="remember-me" type="checkbox"/>记住我
<button type="submit">登录button>
form>
PS:“记住我”复选框的name属性Spring Security默认为remember-me,当然这个可以自行设定。
(2)SecurityConfig.java
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService loginUserDetailsService;
@Autowired
DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // CSRF(跨站请求伪造)禁用,默认开启,会检测请求中是否包含令牌,没有则拒绝并返回403
.authorizeRequests()
// 对静态资源放行
.mvcMatchers(HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js")
.permitAll()
// 指定可匿名访问的路径
.mvcMatchers("xxx","zzz/yyy").anonymous()
.anyRequest().authenticated()// 除了上面其他都必须鉴权
.and()
.formLogin().loginPage("/loginPage")// 未认证时访问跳转登录页面
.loginProcessingUrl("/doLogin")// 表单登录url设置,默认/login
// 登录成功跳转
.defaultSuccessUrl("/main",true)
.permitAll()
.and()
.logout().permitAll()
.and()
.rememberMe() // 自动登录;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(loginUserDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder(){
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
};
}
}
那这里只需要增加上rememberMe()即可,运行看效果。
可以看到登录请求成功后就会返回cookie,然后跳转到登陆成功main就会携带上cookie,cookie的默认有效时长为14天。
当关掉浏览器再打开然后直接访问/main时,就会直接打开了。
首先再介绍几个其他配置,可以自己设置cookie的名字,form表单checkbox的name属性名,还有有效时长,当然还有其他设置项,这里不做说明。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // CSRF(跨站请求伪造)禁用,默认开启,会检测请求中是否包含令牌,没有则拒绝并返回403
.authorizeRequests()
// 对静态资源放行
.mvcMatchers(HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js")
.permitAll()
// 指定可匿名访问的路径
.mvcMatchers("xxx","zzz/yyy").anonymous()
.anyRequest().authenticated()// 除了上面其他都必须鉴权
.and()
.formLogin().loginPage("/loginPage")// 未认证时访问跳转登录页面
.loginProcessingUrl("/doLogin")// 表单登录url设置,默认/login
.defaultSuccessUrl("/main").permitAll() // 登录成功跳转url
.and()
.logout().permitAll()
.and()
.rememberMe() // 自动登录
.rememberMeCookieName("autoLoginCookie") // cookie的名字,默认remember-me
.rememberMeParameter("autoLogin") // 对应表单checkbox的name属性
.tokenValiditySeconds(60); // 设置有效时长,单位秒
}
再来看下cookie的内容,这个一看就是到是BASE64加密串,所以拿在线解密网站解密下:
可以清楚看到包含3个部分:
1、用户名
2、这个其实就是失效日期对应的时间戳
3、签名
具体内容在TokenBasedRememberMeServices类的onLoginSuccess方法可以查看:
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 {
// 省略...
// 获取有效时长
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// 生成上图中cookie解密后2对应的时间戳
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
// 生成3对应的签名(MD5加密)
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
// 生成cookie,添加到esponse
this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response);
// 省略...
}
}
所以这种方式cookie里面包含了用户名、密码,相对来说不是太安全。
自动登录的过程可在RememberMeAuthenticationFilter这个过滤器中看到,RememberMeAuthenticationFilter是在UsernamePasswordAuthenticationFilter后执行的,这个顺序可在官网过滤器部分看到。
public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
// 省略
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
// 如果拦截请求后会获取Authentication对象,没有认证时为null
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 在autoLogin中处理自动登录
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
// 省略
} else {
// 省略
}
}
// 省略
}
autoLogin方法是在TokenBasedRememberMeServices的父类AbstractRememberMeServices中(rememberMeServices对象为TokenBasedRememberMeServices),源码有删减只看核心部分:
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
// 获取请求中的cookie
String rememberMeCookie = this.extractRememberMeCookie(request);
// 解析cookie,也就是解析成上面的所说的3部分
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
// 这里面就是在进行校验
user = this.processAutoLoginCookie(cookieTokens, request, response);
}
}
this.processAutoLoginCookie就表示调用TokenBasedRememberMeServices中的processAutoLoginCookie方法,同样源码有删减只看核心部分:
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
// 调用UserDetailsService的loadUserByUsername,通过用户名获取数据库用户
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
// 利用数据库用户数据生成签名,此前登录时onLoginSuccess方法也调用此方法生成的签名
String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
// 与cookie中携带的签名比对,一样则验证成功否则失败
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
} else {
return userDetails;
}
}
总结一下自动登录过程如下图所示,主要是从RememberMeAuthenticationFilter开始(图中两个过滤器中间还有其他过滤器)
在配置属性时可能还会看到有博客里面提到key()这个方法,key方法中设置的值到底有何用处?
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.....
.rememberMe() // 自动登录
.key("my_key");
}
解决这个问题还得看下上面提到过的在生成cookie中签名的方法,TokenBasedRememberMeServices的makeTokenSignature方法:
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey();
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException var8) {
throw new IllegalStateException("No MD5 algorithm available!");
}
return new String(Hex.encode(digest.digest(data.getBytes())));
}
可以看到第一行这里,生成签名的时候除了用户名、过期时间、密码外还有一个key,这个key是作为防止令牌串改,默认情况下我们没有手动配置key的时候是系统启动时会默认生成,后续登录请求时这个可以不会改变。
但是有种情况当用户登录一次后浏览器已经存储了cookie,并且在有效期内的某个时间服务器重启了,那么这个时候用户浏览器中的cookie就失效了,得重新登录了。如果说我们自己指定了一个固定的key,那么在cookie有效期内服务器重启也不会有影响了。