HelloWood

Spring Security 中实现 Remember Me 记住密码功能

2018-01-01

在 Spring Boot 应用中使用 Spring Security 并实现 Remember Me 记住密码功能,实现自动登录


前置条件:在 Spring Boot 应用中已正确配置 Spring Security

##在页面添加记住密码的复选框

1
<input type="checkbox" name="remember-me"/> Remember me

##在 Security Config 配置文件中启用记住密码功能(验证信息存放在内存中)

  • SecurityConfig
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
48
49
50
51
52
53
54
import cn.com.hellowood.springsecurity.security.CustomAuthenticationProvider;
import cn.com.hellowood.springsecurity.security.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdater;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;

@Autowired
private CustomUserDetailsService userDetailsService;

@Override
protected void configure(HttpSecurity http) throws Exception {
// 任何用户都可以访问以下URI
http.authorizeRequests()
.antMatchers("/", "/login", "/login-error", "/css/**", "/index")
.permitAll();

// 其他URI均需要权限校验
http.authorizeRequests()
.anyRequest()
.authenticated();

// 只需要以下配置即可启用记住密码
http.authorizeRequests()
.and()
.rememberMe();

http.formLogin()
.loginPage("/login")
.usernameParameter("username")
.passwordParameter("password")
.successForwardUrl("/user/index")
.failureUrl("/login-error");
}

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
// 为了使用用户名密码校验实现了AuthenticationProvider和UserDetailsService类
auth.authenticationProvider(customAuthenticationProvider);
try {
auth.userDetailsService(userDetailsService);
} catch (Exception e) {
e.printStackTrace();
}
}
}

这样就可以使用记住密码了,选择记住密码登录后会在本地保存 Cookie,下次登录的时候通过 Cookie 校验用户信息;用户登录的信息保存在内存中,当内存断电或被清除之后该 Cookie 即使在有效期内也无法登录。


通过数据库存放校验信息实现记住密码登录

###创建保存校验信息的表(表名和字段值必须为以下内容)

1
2
3
4
5
6
7
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) NOT NULL PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

配置 Security Config

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import cn.com.hellowood.springsecurity.security.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;

import javax.sql.DataSource;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;

@Autowired
private CustomUserDetailsService userDetailsService;

// 数据源是为了JdbcRememberMeImpl实例而注入的,如果不设置数据源会在登陆的时候抛空指针异常
@Autowired
@Qualifier("dataSource")
DataSource dataSource;

@Override
protected void configure(HttpSecurity http) throws Exception {
// 任何用户都可以访问以下URI
http.authorizeRequests()
.antMatchers("/", "/login", "/login-error", "/css/**", "/index")
.permitAll();

// 其他URI需要权限验证
http.authorizeRequests()
.anyRequest()
.authenticated();

// 当通过JDBC方式记住密码时必须设置 key,key 可以为任意非空(null 或 "")字符串,但必须和 RememberMeService 构造参数的
// key 一致,否则会导致通过记住密码登录失败
http.authorizeRequests()
.and()
.rememberMe()
.rememberMeServices(rememberMeServices())
.key("INTERNAL_SECRET_KEY");

// 当登录成功后会被重定向到 /user/index, 所以 loginPage 和 loginProcessingUrl 相同
http.formLogin()
.loginPage("/login")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.successForwardUrl("/user/index")
.failureUrl("/login-error");
}


/**
* 返回 RememberMeServices 实例
*
* @return the remember me services
*/
@Bean
public RememberMeServices rememberMeServices() {
JdbcTokenRepositoryImpl rememberMeTokenRepository = new JdbcTokenRepositoryImpl();
// 此处需要设置数据源,否则无法从数据库查询验证信息
rememberMeTokenRepository.setDataSource(dataSource);

// 此处的 key 可以为任意非空值(null 或 ""),单必须和起前面
// rememberMeServices(RememberMeServices rememberMeServices).key(key)的值相同
PersistentTokenBasedRememberMeServices rememberMeServices =
new PersistentTokenBasedRememberMeServices("INTERNAL_SECRET_KEY", userDetailsService, rememberMeTokenRepository);

// 该参数不是必须的,默认值为 "remember-me", 但如果设置必须和页面复选框的 name 一致
rememberMeServices.setParameter("remember-me");
return rememberMeServices;
}

/**
* Configure global.
*
* @param auth the auth
* @throws Exception the exception
*/
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
// 为了使用用户名密码校验实现了AuthenticationProvider和UserDetailsService类
auth.authenticationProvider(customAuthenticationProvider);
try {
auth.userDetailsService(userDetailsService);
} catch (Exception e) {
logger.error("Set userDetailService failed, {}", e.getMessage());
e.printStackTrace();
}
}
}

这样就可以实现将校验信息保存在数据库中,下次通过记住密码登录后再次访问页面会根据 Cookie 查找该用户的登录信息,如果登录信息有效则登录成功, 否则重定向到登录页面


  • 自定义实现 AuthenticationProvider 接口
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
48
49
50
51
52
53
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
* The type Custom authentication provider.
*
* @author HelloWood
*/
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

private final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
private CustomUserDetailsService userDetailsService;

/**
* Validate user info is correct form database
*
* @param authentication
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();

logger.info("start validate user {} login", username);
// 通过用户名和密码校验,如果校验不通过会抛出 AuthenticationException
userDetailsService.loadUserByUsernameAndPassword(username, password);
Authentication auth = new UsernamePasswordAuthenticationToken(username, password, grantedAuthorities);
return auth;
}


@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}

}
  • 自定义实现 UserDetailsService 接口
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

import cn.com.hellowood.springsecurity.mapper.UserMapper;
import cn.com.hellowood.springsecurity.model.UserModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;
import java.util.ArrayList;

/**
* The type Custom user details service.
*
* @author HelloWood
*/
@Service("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {

private Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
private UserMapper userMapper;

@Autowired
private HttpSession session;

/**
* 通过用户名和密码加载用户信息并校验
*
* @param username the username
* @param password the password
* @return the user model
* @throws AuthenticationException the authentication exception
*/
public UserModel loadUserByUsernameAndPassword(String username, String password) throws AuthenticationException {
logger.info("user {} is login by username and password", username);
UserModel user = userMapper.getUserByUsernameAndPassword(username, password);
validateUser(username, user);
return user;
}


/**
* 通过用户名加载用户信息,重写该方法用于记住密码后通过 Cookie 登录
*
* @param username
* @param user
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("user {} is login by remember me cookie", username);
UserModel user = userMapper.getUserByUsername(username);
validateUser(username, user);
return new User(user.getUsername(), user.getPassword(), new ArrayList<GrantedAuthority>());
}

/**
* 校验用户信息并将用户信息放在 Session 中
*
* @param username
* @param user
*/
private void validateUser(String username, UserModel user) {
if (user == null) {
logger.error("user {} login failed, username or password is wrong", username);
throw new BadCredentialsException("Username or password is not correct");
} else if (!user.getEnabled()) {
logger.error("user {} login failed, this account had expired", username);
throw new AccountExpiredException("Account had expired");
}
// TODO There should add more logic to determine locked, expired and others status

logger.info("user {} login success", username);
// 当用户信息有效时放入 Session 中
session.setAttribute("user", user);
}
}