写在前面

在《自动踢掉登录用户》一文中,出于简单考率,我们将用户信息保存在内存中,但是在实际工作中都是将用户信息保存早数据库中。看到这里,小伙伴是不是觉得只需将数据库保存用户替换为内存保存用户,完全没必要新开一篇文章,是的通常都是可以直接这么操作,但是这里仅仅这么操作是不行的,因此此处有必要单独进行介绍。

项目初始化

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

第一步,使用IDEA创建一个名为kickoffuser-jpa的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!";
}
}

第六步,在static目录下新建一个login.html文件,里面的代码如下所示:

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<style>
body {
background: #353f42;
}

* {
padding: 0;
margin: 0;
}

.main {
margin: 0 auto;
padding-left: 25px;
padding-right: 25px;
padding-top: 15px;
width: 350px;
height: 350px;
background: #FFFFFF;
/*以下css用于让登录表单垂直居中在界面,可删除*/
position: absolute;
top: 50%;
left: 50%;
margin-top: -175px;
margin-left: -175px;
}

.title {
width: 100%;
height: 40px;
line-height: 40px;
}

.title span {
font-size: 18px;
color: #353f42;
}

.title-msg {
width: 100%;
height: 64px;
line-height: 64px;
}

.title:hover {
cursor: default;
}

.title-msg:hover {
cursor: default;
}

.title-msg span {
font-size: 12px;
color: #707472;
}

.input-content {
width: 100%;
height: 120px;
}

.input-content input {
width: 330px;
height: 40px;
border: 1px solid #dad9d6;
background: #ffffff;
padding-left: 10px;
padding-right: 10px;
}

.enter-btn {
width: 350px;
height: 40px;
color: #fff;
background: #0bc5de;
line-height: 40px;
text-align: center;
border: 0px;
}

.foor {
width: 100%;
height: auto;
color: #9b9c98;
font-size: 12px;
margin-top: 20px;
}

.enter-btn:hover {
cursor: pointer;
background: #1db5c9;
}

.foor div:hover {
cursor: pointer;
color: #484847;
font-weight: 600;
}

.left {
float: left;
}

.right {
float: right;
}
</style>
<div class="main">
<div class="title">
<span>密码登录</span>
</div>

<div class="title-msg">
<span>请输入登录账户和密码</span>
</div>

<form class="login-form" method="post" novalidate action="/goLogin">
<!--输入框-->
<div class="input-content">
<!--autoFocus-->
<div>
<input type="text" autocomplete="off"
placeholder="用户名" name="username" required/>
</div>

<div style="margin-top: 16px">
<input type="password"
autocomplete="off" placeholder="登录密码" name="password" required maxlength="32"/>
</div>
</div>

<!--登入按钮-->
<div style="text-align: center">
<button type="submit" class="enter-btn">登录</button>
</div>

<div class="foor">
<div class="left"><span>忘记密码 ?</span></div>

<div class="right"><span>注册账户</span></div>
</div>
</form>

</div>
</body>
</html>

第七步,新建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
@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);
super.configure(auth);
}

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

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("username")
.passwordParameter("password")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{
httpServletResponse.setContentType("text/html;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write("注销登录成功!");
out.flush();
out.close();
}).and().sessionManagement().maximumSessions(1);
http.csrf().disable();
}
}

第八步,数据库和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: root
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

第九步,启动项目进行测试。打开Chrome浏览器,访问/hello接口,页面跳到登录页面,输入用户名和密码后点击登录,之后页面显示/hello接口信息。之后打开另一个Edge浏览器,访问/hello接口,之后输入用户信息完成登录,并显示/hello接口信息,接着回到Chrome浏览器,刷新之前的页面,发现页面依旧是显示/hello接口信息,并没有显示之前的踢掉用户信息提示,即登录成功不会踢掉已经登录的用户。

问题分析

在解决这个问题之前,我们需要明白SpringSecurity是如何保存用户对象及Session信息的。

在SpringSecurity中,与Session存储相关的接口为SessionRegistry,查看一下该接口的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface SessionRegistry {
List<Object> getAllPrincipals();

List<SessionInformation> getAllSessions(Object var1, boolean var2);

SessionInformation getSessionInformation(String var1);

void refreshLastRequest(String var1);

void registerNewSession(String var1, Object var2);

void removeSessionInformation(String var1);
}

可以看到里面有6个方法,第一个getAllPrincipals方法表示获取所有的用户信息;第二个getAllSessions方法表示获取所有的Session信息;第三个getSessionInformation方法表示获取某个session信息;第四个refreshLastRequest方法表示获取刷新最近的请求;第五个registerNewSession方法表示将新生成的session进行保存;第六个removeSessionInformation方法表示删除某个session信息;

该接口只有一个实现类SessionRegistryImpl,用于对会话信息进行统一管理,查看一下该类的源码:

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
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);
private final ConcurrentMap<Object, Set<String>> principals;
private final Map<String, SessionInformation> sessionIds;

public SessionRegistryImpl() {
this.principals = new ConcurrentHashMap();
this.sessionIds = new ConcurrentHashMap();
}

public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals, Map<String, SessionInformation> sessionIds) {
this.principals = principals;
this.sessionIds = sessionIds;
}

public List<Object> getAllPrincipals() {
return new ArrayList(this.principals.keySet());
}

public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
//代码省略
}

public SessionInformation getSessionInformation(String sessionId) {
//代码省略
}

public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
if (this.getSessionInformation(sessionId) != null) {
this.removeSessionInformation(sessionId);
}

if (this.logger.isDebugEnabled()) {
this.logger.debug("Registering session " + sessionId + ", for principal " + principal);
}

this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet();
}

((Set)sessionsUsedByPrincipal).add(sessionId);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
}

return (Set)sessionsUsedByPrincipal;
});
}

public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = this.getSessionInformation(sessionId);
if (info != null) {
if (this.logger.isTraceEnabled()) {
this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
}

this.sessionIds.remove(sessionId);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
}

sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
}

sessionsUsedByPrincipal = null;
}

if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
}

return sessionsUsedByPrincipal;
});
}
}
}

由于此处仅仅是关注session信息的存储,因此将其他与之关系不大的方法的实现逻辑给删除了。接下来对上述代码进行一个较为简单的介绍:
(1)首先定义了一个principals对象,该对象是一个ConcurrentMap类型,这是一个支持并发访问的map集合,集合中的Key为用户的主体(principal),通常来说principal就是用户对象,这一点可以从getAllPrincipals()方法的源码中得到验证:

1
2
3
public List<Object> getAllPrincipals() {
return new ArrayList(this.principals.keySet());
}

集合的Value则是一个set集合,这个集合中保存了该用户的所有sessionid,这一点可以从getAllSessions()方法的源码中得到验证:

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
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
if (sessionsUsedByPrincipal == null) {
return Collections.emptyList();
} else {
List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
Iterator var5 = sessionsUsedByPrincipal.iterator();

while(true) {
SessionInformation sessionInformation;
do {
do {
if (!var5.hasNext()) {
return list;
}

String sessionId = (String)var5.next();
sessionInformation = this.getSessionInformation(sessionId);
} while(sessionInformation == null);
} while(!includeExpiredSessions && sessionInformation.isExpired());

list.add(sessionInformation);
}
}
}

(2)之后定义一个sessionIds对象,Key为sessionId,Value为SessionInformation类型,这一点可以从registerNewSession()方法内如下代码得到验证:

1
this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));

(3)当有新的session需要添加时,会调用registerNewSession()方法,该方法里面有如下核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet();
}

((Set)sessionsUsedByPrincipal).add(sessionId);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + principal + "' : " + sessionsUsedByPrincipal);
}

return (Set)sessionsUsedByPrincipal;
});

调用principals.compute()方法往principals这个ConcurrentMap类型对象中新增session信息,注意key就是principal。
(4)当用户注销登录,则调用removeSessionInformation()方法来将session信息进行移除,核心代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
this.sessionIds.remove(sessionId);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing session " + sessionId + " from principal's set of registered sessions");
}

sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Removing principal " + info.getPrincipal() + " from registry");
}

sessionsUsedByPrincipal = null;
}

if (this.logger.isTraceEnabled()) {
this.logger.trace("Sessions used by '" + info.getPrincipal() + "' : " + sessionsUsedByPrincipal);
}
return sessionsUsedByPrincipal;
});

可以看到它会调用sessionIds.remove()方法来移除sessionid,之后调用principals.computeIfPresent()方法来删除session信息。

通过上述分析,我们就知道这个ConcurrentMap集合principals,它的Key是principal对象,既然使用对象做Key,那么就需要重写equals和hashCode方法,否则第一次存完数据,后期就无法找到它。关于这一部分内容则是Java集合相关的内容,笔者在后续会有一套笔记介绍这些内容。

你可能觉得好像我们之前在使用基于内存来保存用户时,好像也没有重写equals和hashCode方法,真的是这样么,查看一下当时的User类,注意使用的是自带的User类,包所在位置为org.springframework.security.core.userdetails。可以看到其实该类默认已经重写了equals和hashCode方法:

所以我们就知道了,使用基于内存保存用户方式之所以没出错,是因为它默认重写了equals和hashCode方法,但是此处我们自定义的用户并没有重写这两个方法,因此就无法实现既定功能。

问题解决

修改用户自定义的User类,在里面重写equals和hashCode方法,可以仿照系统提供的User类中的那两个方法:

1
2
3
4
5
6
7
8
9
@Override
public boolean equals(Object rhs) {
return rhs instanceof User ? this.username.equals(((User)rhs).username) : false;
}

@Override
public int hashCode() {
return this.username.hashCode();
}

当然用户也可以根据自己的逻辑来进行实现,如下所示的实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(username, user.username);
}

@Override
public int hashCode() {
return Objects.hash(username);
}

在完成上述配置以后,重启项目,再次测试多端登录,你就会发现可以踢掉已经登录的用户。当然了本篇不仅仅只限于使用Spring Data JPA的情况,当开发者使用了Mybatis等其他ORM框架时,也只需和本文一样,重写ConcurrentMap集合principals对象的Key类型的equalshashCode方法。

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