练习代码同步到github:SpringSecuritySamples/auto-login
顾名思义,就是用户登录成功以后,在一段时间里浏览器保存用户的登录状态,如果用户关闭了浏览器再重新打开,或者重启了服务器,都不需要用户再次输入密码进行登录,就可以直接自动登录直接访问接口。
只要在Security-Config中添加一句代码
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()//自动登录功能
.and()
.csrf().disable();
}
启动项目后,就多了一个Remember me的选项。
勾选上登录,查看cookie
Cookie: Idea-5cccbd0c=05b8c6b5-e5de-4a5c-8f0a-0d354b3058b5; JSESSIONID=438EBD5C451B195DF99FE38AE9D5CC9D; remember-me=YmFkY2F0OjE2MTg0NzE2Nzc4Mzc6Njc4ZmFkZGNkOWE3YzAwZmM5YWIxODMzMjk5NzE1ODY
根据经验判断remember-me后面这段是base64编码,解码得到
badcat:1618471677837:678faddcd9a7c00fc9ab183329971586
username + ":" + tokenExpiryTime + ":" + password + ":" + key
,key是一个散列盐值,每次服务器重启都会变。具体含义都是从对应源码TokenBasedRememberMeServices中得出的
@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())));
}
知道了具体含义,我们就知道必须要自己设置一个key,不然每次重启服务器后之前的令牌都会失效。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("badcat")
.and()
.csrf().disable();
}
测试重启服务器后,仍可以直接访问到/hello。
Spring Security 的功能大多是通过一个过滤器链实现的,此处的原理也是通过RememberMeAuthenticationFilter 类来实现,暂且不表。
之前的自动登录就是生成了一种令牌来实现功能,在此基础上提高安全性,增加校验参数,就是持久化令牌。但用户是感受不到二者的区别的。
这里持久化令牌的处理类是PersistentTokenBasedRememberMeServices,其中用来保存的是 PersistentRememberMeToken
public class PersistentRememberMeToken {
private final String username;
private final String series;//只有当用户在使用用户名/密码登录时,才会生成或者更新
private final String tokenValue;//只要有新的会话,就会重新生成,防止一个用户多端登录
private final Date date;//上次登录的时间
//省略其他
}
令牌信息需要我们准备一张表来存储,可以自定义或者使用默认的。
系统默认提供的数据库模型是JdbcTokenRepositoryImpl,根据结构创建数据表
CREATE TABLE `persistent_logins` (
`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
然后改写SecurityConfig代码
@Autowired
DataSource dataSource;
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("javaboy")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
新的cookie里remember-me的值
remember-me=JTJGU3JTcHVQd2JPN0ZSbkdJRkw2dmN3JTNEJTNEOiUyQkViRmFSMVNmS2k2cHg0MkFJMEtUUSUzRCUzRA
base64解码得到
%2FSrSpuPwbO7FRnGIFL6vcw%3D%3D:%2BEbFaR1SfKi6px42AI0KTQ%3D%3D
再url解码后
/SrSpuPwbO7FRnGIFL6vcw==:+EbFaR1SfKi6px42AI0KTQ==
刚好对应保存到数据库的数据
这一通解码让人想念起做ctf题目的时候……
万一令牌被人盗用,后果还是很严重的,虽然不能完全避免,但是我们可以想办法将损失降低。
二次校验就是如果使用了自动登录,只能进行一些普通操作,如果涉及到一些敏感操作的话,需要跳转回登录页面,重新输入密码来二次确认身份。
准备三个接口。
@RestController
public class HelloController{
@GetMapping("/hello")
public String hello(){
//任何类型的认证登录后都可以访问
return "hello";
}
@GetMapping("/admin")
public String admin(){
//非自动登录可以访问
return "admin";
}
@GetMapping("/rememberme")
public String rememberme(){
//只有自动登录可以访问
return "rememberme";
}
}
修改SecurityConfig
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/rememberme").rememberMe()
.antMatchers("/admin").fullyAuthenticated()//不包括自动登录
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("badcat")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
测试后符合预期。