在用户认证成功后,会调用AbstractAuthenticationProcessingFilter
类的successfulAuthentication()
方法
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
// 记住我功能
rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
successHandler.onAuthenticationSuccess(request, response, authResult);
}
在这个方法中rememberMeServices.loginSuccess(request, response, authResult)
便跟记住我功能有关。
RememberMeServices
接口提供了记住我功能。具体功能由其实现类实现。我们再回到上面的loginSuccess()
方法。它由AbstractRememberMeServices
实现。
public final void loginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
首先我们要通过rememberMeRequested(request, parameter)
这个方法判断我们是否选择了记住我功能。
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (alwaysRemember) {
return true;
}
// parameter值为 "remember-me"
// 获取前台传入的 "remember-me" 的值
String paramValue = request.getParameter(parameter);
// 非空判断
if (paramValue != null) {
// 获取到的值为 true,on,yes,1 中的任意一种,即表示开启记住我功能
if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
|| paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
return true;
}
}
if (logger.isDebugEnabled()) {
logger.debug("Did not send remember-me cookie (principal did not set parameter '"
+ parameter + "')");
}
return false;
}
// parameter 的值为 "remember-me"
private String parameter = DEFAULT_PARAMETER;
public static final String DEFAULT_PARAMETER = "remember-me";
要实现记住我功能,从这个方法中我们可以得出两点:
- 前台标签中的 name 属性的值必须为 “remember-me”(当然我们也可以自定义,下面会讲到)
- 前台标签的 value 属性的值必须为 true, on, yes, 1 中的一种
如果满足上述条件,继续调用onLoginSuccess()
方法,该方法具体由AbstractRememberMeServices
的子类PersistentTokenBasedRememberMeServices
实现
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
// 在数据库中保存cookie信息
tokenRepository.createNewToken(persistentToken);
// 在浏览器中保存cookie信息
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
该方法做了两件事,一是在数据库中保存cookie信息,二是在浏览器中保存cookie信息。
tokenRepository.createNewToken(persistentToken)
添加cookie信息到数据库中,具体方法由子类JdbcTokenRepositoryImpl
实现
public void createNewToken(PersistentRememberMeToken token) {
// 获取Spring中的JdbcTemplate执行update()操作
getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(),
token.getTokenValue(), token.getDate());
}
我们看一下这个insertTokenSql
public static final String DEF_INSERT_TOKEN_SQL =
"insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
所以这里就是调用JdbcTemplate
类执行update()
操作向persistent_logins
这个表中插入你的用户信息以及cookie信息。而当你下次登录时,又会调用查询语句比对数据库中的cookie信息与浏览器中的cookie信息是否一致,来完成自动登录。到这里我们已经完成了cookie的保存操作。再来看一下自动登录时怎么实现的。
我们回到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);
...
}
}
首先会先判断前面的过滤器是否进行过认证(SecurityContext中是否有认证信息,认证后的信息会保存在SecurityContext中),未进行过认证的话会调用RememberMeServices
的autoLogin()
方法。该方法具体由子类AbstractRememberMeServices
实现
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
// 从浏览器的请求域中获取cookie信息
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 {
// 获取解析后的cookie值
String[] cookieTokens = decodeCookie(rememberMeCookie);
// 进行自动登录验证
user = processAutoLoginCookie(cookieTokens, request, response);
// 检查user的用户信息是否可用
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
...
}
在该方法中又调用了 processAutoLoginCookie()
方法比对浏览器中的cookie信息和数据库中保存的cookie信息是否一致
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2
+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
// 获取数据库中保存的cookie信息
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException(
"No persistent token found for series id: " + presentedSeries);
}
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(
messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
if (logger.isDebugEnabled()) {
logger.debug("Refreshing persistent login token for user '"
+ token.getUsername() + "', series '" + token.getSeries() + "'");
}
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
try {
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception e) {
logger.error("Failed to update token: ", e);
throw new RememberMeAuthenticationException(
"Autologin failed due to data access problem");
}
// 登录用户
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
tokenRepository.getTokenForSeries(presentedSeries)
用于获取数据库中保存的cookie信息。如果比对失败则会抛出异常。比对成功,会调用tokenRepository.updateToken()
方法更新cookie信息,用于下一次比对。最后调用getUserDetailsService().loadUserByUsername(token.getUsername());
进行用户信息验证,登录用户。这里便实现了用户的自动登录。
再说一下processAutoLoginCookie()
方法中的具体步骤
- 解析前端传来的Cookie,里面包含了Token和seriesId,它会
使用seriesId查找数据库的Token
- 检查Cookie中的Token和数据库查出来的Token是否相同
- 相同的话再检查数据库中的Token是否已过期
- 如果以上都符合的话,会使用旧的用户名和series重新new一个Token,这时过期时间也重新刷新
- 然后将新的Token保存回数据库,同时添加回Cookie中
- 最后再调用
UserDetailsService
的loadUserByUsername()
方法返回UserDetails
对象完成登录
代码实现
persistent_logins表
(这张表也可以由springsecurity为我们创建,下面有讲到)
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
pom.xml
<dependencies>
<!-- SpringSecurity -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
<!-- mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
</dependencies>
application.yml
server:
port: 8080
spring:
datasource:
# 设置数据库连接池类型
type: com.alibaba.druid.pool.DruidDataSource
# 设置驱动类(因为我用的是 mysql-connector 8,所以要加 cj)
driver-class-name: com.mysql.cj.jdbc.Driver
# 记得加 serverTimezone
url: jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai
username: root
password: 990515
UserService
@Service
public class UserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("admin");
return new User("guest",
new BCryptPasswordEncoder().encode("123"),
authorities);
}
}
UserController
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/login")
public String login() {
return "登录成功";
}
}
SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
/**
* 注入加密器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 注入数据源
*/
@Autowired
private DataSource dataSource;
/**
* 注入 PersistentTokenRepository
*/
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository= new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 可以为我们自动创建表 persistent_logins (若数据库已存在该表,执行该语句则会报错)
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
/**
* 认证用户
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 拦截http请求
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 放行请求
.antMatchers("/", "/login.html").permitAll()
.anyRequest().authenticated()
.and()
// 设置登录页面
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/user/login")
.and()
// 开启记住我功能
.rememberMe()
// (可选)设置前端传递过来的 remember-me 功能的属性名
// 默认的属性名为 remember-me
.rememberMeParameter("my-remember-me")
// (可选)设置 remember-me 功能对应的 cookie 名
// 默认的 cookie 名为 remember-me
.rememberMeCookieName("my-remember-me-cookie")
// 设置 PersistentTokenRepository (将cookie信息存储到数据库中)
.tokenRepository(persistentTokenRepository())
// 设置 Cookie 的有效期为1小时
.tokenValiditySeconds(60*60)
// 设置 UserDetailsService
.userDetailsService(userDetailsService)
.and()
// 关闭csrf
.csrf().disable();
}
}
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<form action="/user/login" method="post">
用户名: <input type="text" name="username">
<br/>
密码: <input type="password" name="password">
<br/>
<input type="checkbox" name="my-remember-me"> 记住我
<br/>
<input type="submit" value="登录">
</form>
</body>
</html>