写在前面

在前一篇《自动踢掉登录用户》一文中,我们采用的是前后端不分离模式,但是在前后端分离盛行的当下,有必要对此模式下的自动踢掉用户进行学习。同时在前一文中,用户信息都是配置在内存中,而实际工作中都是将其放入数据库中,因此需要切换数据源为数据库。需要注意的是,在使用SpringSecurity中的Session做并发处理时,直接将内存中的用户切换为数据库中的用户,也就是将内存源切换为数据库源是会出现问题的,接下来就来细说这个问题。

前后端分离项目实例化

考虑到此处主要学习如何在前后端分离模式下,实现自动踢掉登录用户,因此就只是单纯的将用户存储在数据库中,而不进行任何的权限控制。同时此处登录使用JSON格式。

第一步,使用IDEA创建一个名为kickoffuser-json的SpringBoot工程,并在其pom.xml依赖文件中添加如下依赖:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

第二步,新建entity包,并在其中创建用户类User,里面代码如下所示:

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
@Data
@Entity(name = "t_user")
public class User implements UserDetails {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// @Getter(value = AccessLevel.NONE)
private String username;

// @Getter(value = AccessLevel.NONE)
private String password;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}

@Override
public String getPassword() {
return password;
}

@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

第三步,新建repository包,并在里面新建一个UserRepository接口,里面的代码如下:

1
2
3
public interface UserRepository extends JpaRepository<User,Long> {
User findUserByUsername(String username);
}

第四步,新建service包,并在里面新建一个MyUserDetailService类,里面的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class MyUserDetailService implements UserDetailsService {
@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user= userRepository.findUserByUsername(username);
if(user==null){
throw new UsernameNotFoundException("用户不存在");
}
return user;
}
}

第五步,新建controller包,并在里面新建一个HelloController类,里面的代码如下:

1
2
3
4
5
6
7
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello,world!";
}
}

第六步,新建filter包,并在里面新建一个EnvyLoginFilter类,里面的代码如下:

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
@Component
public class EnvyLoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if(!request.getMethod().equals("POST")){
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}else{
//判断请求类型
if(request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) ||request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)){
//说明此时使用的JSON方式
Map<String,String> loginData = new HashMap<>();
try{
loginData = new ObjectMapper().readValue(request.getInputStream(),Map.class);
}catch (IOException e){
e.printStackTrace();
}
String username =loginData.get(getUsernameParameter());
String password =loginData.get(getPasswordParameter());

username = username != null ?username.trim():"";
password = password != null ?password:"";

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password);
this.setDetails(request,authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}else {
//说明此时使用的是Key/Value键值对方式
return super.attemptAuthentication(request,response);
}
}
}
}

第七步,新建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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailService myUserDetailService;

@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailService);
}

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**","/login.html");
}

@Bean
public EnvyLoginFilter envyLoginFilter() throws Exception {
EnvyLoginFilter envyLoginFilter = new EnvyLoginFilter();
//设置登录成功时的逻辑
envyLoginFilter.setAuthenticationSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
Map<String,Object> map = new HashMap<>();
User user = (User) authentication.getPrincipal();
user.setPassword(null);
map.put("status",200);
map.put("user",user);
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
});

envyLoginFilter.setAuthenticationFailureHandler((httpServletRequest,httpServletResponse,exception)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
Map<String,Object> map = new HashMap<>();
map.put("status",401);
if (exception instanceof LockedException) {
map.put("msg", "账户被锁定,请联系管理员!");
} else if (exception instanceof BadCredentialsException) {
map.put("msg", "账户名或密码输入错误,请重新输入!");
} else if (exception instanceof DisabledException) {
map.put("msg", "账户被禁用,请联系管理员!");
} else if (exception instanceof AccountExpiredException) {
map.put("msg", "账户已过期,请联系管理员!");
} else if (exception instanceof CredentialsExpiredException) {
map.put("msg", "密码已过期,请联系管理员!");
}
else {
map.put("msg", "登录失败!");
}
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
});
envyLoginFilter.setAuthenticationManager(authenticationManagerBean());
envyLoginFilter.setFilterProcessesUrl("/goLogin");
return envyLoginFilter;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.permitAll()
.and()
.csrf().disable();
http.addFilterAt(envyLoginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}

第八步,数据库和JPA配置。修改application.yml配置文件,在里面新增如下配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: envy123
url: jdbc:mysql:///kickuser?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
jpa:
database: mysql
database-platform: mysql
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
main:
allow-bean-definition-overriding: true

第九步,启动项目进行测试。请注意,由于此处EnvyLoginFilter实例仅仅配置了使用JSON格式登录成功和失败时的处理逻辑,因此当开发者使用FORM表单登录提交时的成功与否均无法处理,这一点很容易出错,需要开发者引起高度注意。

可以看到初始化项目仅仅是实现了使用数据库保存用户,同时用户采用JSON格式登录这些功能,并未实现本篇介绍的自动踢掉登录用户这一功能,那么接下来就在此基础上实现本篇功能。

问题分析

通过前一篇《JPA+自动踢掉登录用户》一文的学习,我们知道在前后端不分离模式下,采用数据库保存用户,那么需要将自定义的User类重写equalshashCode方法,之后按照之前的方式配置即可。

但是你会发现上述配置似乎是无效的,原因在于此处我们采用的是前后端分离模式下的,基于数据库保存用户的认证,同时使用自定义的过滤器替代了默认的UsernamePasswordAuthenticationFilter,这样导致前一篇所介绍的基于session的配置都失效了。因此所有相关的配置我们应当在自定义的EnvyLoginFilter中进行配置,包括SessionAuthenticationStrategy等亦是如此。

可能需要修改甚至增加一些代码逻辑,但是这对于理解SpringSecurity是有一定帮助的。

问题解决

接下来就在本篇第一部分“前后端分离项目初始化”基础上对代码进行修改和增加,以实现自动踢掉登录用户这一功能。

第一步,重写自定义用户类User的equals()hashCode()方法:

1
2
3
4
5
6
7
8
9
10
@Data
@Entity(name = "t_user")
public class User implements UserDetails {
......
public boolean equals(Object rhs) {
return rhs instanceof User ? this.username.equals(((User)rhs).username) : false;
}

public int hashCode() {
return this.username.hashCode();

第二步,新建bean包,并在里面新建一个响应状态类ResponseBean,里面的代码如下所示:

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
public class ResponseBean {
private Integer status;
private String message;
private Object object;

private ResponseBean() {
}

private ResponseBean(Integer status, String message, Object object) {
this.status = status;
this.message = message;
this.object = object;
}

public static ResponseBean build(){
return new ResponseBean();
}

//成功,只有信息
public static ResponseBean ok(String message){
return new ResponseBean(200,message,null);
}

//成功,有信息和数据
public static ResponseBean ok(String message,Object object){
return new ResponseBean(200,message,object);
}

//失败,只有信息
public static ResponseBean error(String message){
return new ResponseBean(500,message,null);
}

//失败,有信息和数据
public static ResponseBean error(String message,Object object){
return new ResponseBean(500,message,object);
}

public Integer getStatus() {
return status;
}

public ResponseBean setStatus(Integer status) {
this.status = status;
return this;
}

public String getMessage() {
return message;
}

public ResponseBean setMessage(String message) {
this.message = message;
return this;
}

public Object getObject() {
return object;
}

public ResponseBean setObject(Object object) {
this.object = object;
return this;
}
}

第三步,回到自定义的SecurityConfig类中。前面说过我们需要提供一个SessionRegistryImpl的实例,之后由于处理session并发的是ConcurrentSessionControlAuthenticationStrategy,因此我们还需要提供一个ConcurrentSessionControlAuthenticationStrategy实例,然后设置它的处理并发数。

首先进行第一步,在SecurityConfig类中提供一个SessionRegistryImpl实例:

1
2
3
4
@Bean
SessionRegistryImpl sessionRegistry(){
return new SessionRegistryImpl();
}

之后在SecurityConfig#envyLoginFilter()方法中提供一个ConcurrentSessionControlAuthenticationStrategy实例,并进行对应设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Bean
public EnvyLoginFilter envyLoginFilter() throws Exception {
EnvyLoginFilter envyLoginFilter = new EnvyLoginFilter();
//设置登录成功时的逻辑
envyLoginFilter.setAuthenticationSuccessHandler(
//逻辑省略
)

//设置登录失败时的逻辑
envyLoginFilter.setAuthenticationFailureHandler(
//逻辑省略
)

envyLoginFilter.setAuthenticationManager(authenticationManagerBean());
envyLoginFilter.setFilterProcessesUrl("/goLogin");
ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
sessionStrategy.setMaximumSessions(1);
envyLoginFilter.setSessionAuthenticationStrategy(sessionStrategy);
return envyLoginFilter;
}

可以看到这里我们传入一个SessionRegistryImpl对象来实例化ConcurrentSessionControlAuthenticationStrategy对象,之后设置session并发数为1,最后再将其配置给自定义的envyLoginFilter对象。

可能你会好奇为什么需要配置这些,而在前一篇文章中什么都没做,原因在于使用默认的类,系统都自动配置好了,而自定义类并没有这些功能,因此需要开发者自己来手动配置。

还没有完,第四步,处理session时还需要使用到一个关键的名为ConcurrentSessionFilter的过滤器,本来这个过滤器开发者是不用修改的,但是这个过滤器中使用到了SessionRegistryImpl,而我们已经自定义了SessionRegistryImpl,因此就需要重新配置该过滤器。修改SecurityConfig#configure(HttpSecurity http)方法中的代码为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.permitAll()
.and()
.csrf().disable();
http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(),event->{
HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=utf-8");
response.setStatus(401);
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(ResponseBean.error("您已在另一台设备登录,本次登录已下线!")));
out.flush();
out.close();
}),ConcurrentSessionFilter.class);

http.addFilterAt(envyLoginFilter(), UsernamePasswordAuthenticationFilter.class);
}

可以看到我们使用自定义的ConcurrentSessionFilter过滤器替代了系统默认的ConcurrentSessionFilter过滤器,只不过没起名字而已。同时查看这个ConcurrentSessionFilter的构造方法可知,该方法需要SessionRegistrySessionInformationExpiredStrategy这两个参数:

1
2
3
4
5
6
public ConcurrentSessionFilter(SessionRegistry sessionRegistry, SessionInformationExpiredStrategy sessionInformationExpiredStrategy) {
Assert.notNull(sessionRegistry, "sessionRegistry required");
Assert.notNull(sessionInformationExpiredStrategy, "sessionInformationExpiredStrategy cannot be null");
this.sessionRegistry = sessionRegistry;
this.sessionInformationExpiredStrategy = sessionInformationExpiredStrategy;
}

因此这里的SessionRegistry我们就使用了前面配置的SessionRegistry实例,而SessionInformationExpiredStrategy对象则是通过一个lambda表达式来创建该对象。其实这个SessionInformationExpiredStrategy对象就是处理session过期后的回调函数,也就是当用户在被另一个登录用户踢下线之后,开发者想给该用户什么下线提示,此时就可以在这个方法内完成。

你以为这就完了么?不是的,还有最后一步,需要将用户登录的session信息进行保存,因此需要回到EnvyLoginFilter#EnvyLoginFilter()方法中,修改里面的内容如下所示:

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
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if(!request.getMethod().equals("POST")){
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}else{
//判断请求类型
if(request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) ||request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)){
//说明此时使用的JSON方式
Map<String,String> loginData = new HashMap<>();
try{
loginData = new ObjectMapper().readValue(request.getInputStream(),Map.class);
}catch (IOException e){
e.printStackTrace();
}
String username =loginData.get(getUsernameParameter());
String password =loginData.get(getPasswordParameter());

username = username != null ?username.trim():"";
password = password != null ?password:"";

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password);
this.setDetails(request,authenticationToken);

User user = new User();
user.setUsername(username);
sessionRegistry.registerNewSession(request.getSession(true).getId(),user);

return this.getAuthenticationManager().authenticate(authenticationToken);
}else {
//说明此时使用的是Key/Value键值对方式
return super.attemptAuthentication(request,response);
}
}
}

其实就是新增了如下内容:

1
2
3
4
//新增内容
User user = new User();
user.setUsername(username);
sessionRegistry.registerNewSession(request.getSession(true).getId(),user);

这里我们重新实例化了一个User,并设置该name属性为当前登录用户,之后手动调用sessionRegistry.registerNewSession(request.getSession(true).getId(),user);方法来保存当前用户登录的session信息。

在完成上述配置后,接下来就可以进行多端测试,可以发现当用户被人踢下线时,系统会有您已在另一台设备登录,本次登录已下线!这一提示信息。

那么本篇关于前后端分离模式下的,基于数据库保存用户,实现自定踢掉登录用户的学习就到此为止,后续学习其他内容。