前言

通过上一章节的讲解,相信大家已经认识了Spring Security安全框架。在我们创建的第一个演示项目中,相信大家也发现了默认表单登录的局限性。Spring Security默认提供的登录页虽然快速可用,但存在三大问题:

  • 界面风格与业务系统不匹配
  • 登录成功/失败处理逻辑固定
  • 缺乏扩展能力(如验证码、多因子认证)

本章节我们将对Spring Security默认表单进行登录定制,并深度改造处理逻辑。


改造准备

在之前的Maven项目中创建第二个子模块,命名为login-spring-security。由于需要自定义登录页,还需要引入thymeleaf模板框架:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

完整的Maven项目结构如下:

项目结构


开始登录页改造

创建自定义登录页

首先,我们需要自定义一个带验证码的登录页。在resources/templates目录下创建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
<!-- src/main/resources/templates/login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>企业级登录系统</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="w-100" style="max-width: 400px;">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center mb-4">登录</h2>
<form th:action="@{/login}" method="post">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" name="username" id="username" placeholder="请输入用户名">
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" name="password" id="password" placeholder="请输入密码">
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">登录</button>
</div>
<p class="mt-3 text-center"><a href="#">忘记密码?</a></p>
</form>
</div>
</div>
</div>
</div>
</body>
</html>

创建默认首页

添加一个默认首页index.html,显示登出按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- src/main/resources/templates/index.html -->
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>企业级登录系统</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<h1>Hello Security</h1>
<!-- 测试过程不需要关闭csrf防护 -->
<form th:action="@{/login}" method="post">
<button type="submit" class="btn btn-primary">Log Out</button>
</form>
<!-- 测试过程需要关闭csrf防护 否则404 -->
<a th:href="@{/logout}">Log Out</a>
</body>
</html>

添加Controller配置

添加Controller配置首页以及登录页:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller
public class DemoTowController {

@GetMapping("/login")
public String login() {
return "login";
}

@GetMapping("/")
public String index() {
return "index";
}
}

配置Spring Security

最后对Spring Security进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class BasicSecurityConfig {
// 配置安全策略
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // 自定义登录页路径
.permitAll() // 不需要对login认证
)
.logout(withDefaults())
.csrf(csrf -> csrf.disable()); // 关闭csrf防护
return http.build();
}
}

测试效果

测试访问默认主页,由于主页被拦截会自动跳转到login登录页:

登录页

输入正确用户名密码后,自动返回主页,点击登出按钮自动回到登录页:

首页

特别说明:

注意登录页以及主页登出,action采用@{}生成URL,Spring Security会自动帮我们生成name为 _csrf 的隐藏表单,作用于csrf防护:

CSRF隐藏字段

如果登出页是 a 连接形式,为了保证登出不会出现 404 的问题:

  1. 先关闭 csrf 防护:http.csrf(csrf -> csrf.disable())
  2. 登出按钮使用表单方式:th:action="@{/logout}"

自定义用户名密码

到这里有小伙伴又要问了,每次密码都是Spring Security自动生成的UUID,能否自定义用户名密码?答案是肯定的。Spring Security提供了在Spring Boot配置文件设置用户密码的功能:

1
2
3
4
5
6
# application.yml
spring:
security:
user:
name: admin
password: admin

登录成功/失败跳转问题

通过上述代码,可以看到登录成功后默认返回系统主页(index.html)。但业务需求可能需要跳转到别的页面,如何配置?

Spring Security配置类中 formLogin 提供了两个参数 defaultSuccessUrlfailureUrl 方便我们进行配置:

1
2
3
4
5
6
http.formLogin(form -> form
.loginPage("/login") // 自定义登录页路径
.defaultSuccessUrl("/home", true) // 登录成功后跳转路径
.failureUrl("/login?error=true") // 登录失败后跳转路径
.permitAll() // 不需要对login认证
)

自定义登出

登出配置和登录基本相同:

1
2
3
4
http.logout(logout -> logout
.logoutUrl("/logout") // 自定义登出URL
.logoutSuccessUrl("/login?logout") // 登出成功跳转
)

前后端分离适配方案

上述案例针对的是前后端在一个整体中的情况。针对现在前后端分离的项目,我们如何进行改造?需要处理以下问题:

  • 用户登录成功返回成功JSON / 失败返回对应JSON
  • 用户登出成功返回成功JSON / 失败返回对应JSON

原理说明

首先引入官方介绍图如下:

认证处理器流程

在身份认证管理器AuthenticationManager中,有两个结果:SuccessFailure,最终交给AuthenticationSuccessHandler以及AuthenticationFailureHandler处理器处理。

简单总结:

  • 登录成功调用:AuthenticationSuccessHandler
  • 登录失败调用:AuthenticationFailureHandler

自定义处理器实现

通过上面的讲解,我们只需要自定义这两个处理器即可:

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
@Configuration
public class BasicSecurityConfig {
// 配置安全策略
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/ajaxLogin").permitAll() // ajax登录页不需要认证
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // 自定义登录页路径
.successHandler(loginSuccessHandler())
.failureHandler(loginFailureHandler())
.permitAll() // 不需要对login认证
)
.logout(withDefaults())
.csrf(csrf -> csrf.disable()); // 关闭csrf防护
return http.build();
}

// 自定义登录成功处理器
@Bean
public AuthenticationSuccessHandler loginSuccessHandler() {
return (request, response, authentication) -> {
if (isAjaxRequest(request)) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"code\":200, \"message\":\"认证成功\"}");
} else {
response.sendRedirect("/");
}
};
}

// 自定义登录失败处理器
@Bean
public AuthenticationFailureHandler loginFailureHandler() {
return (request, response, exception) -> {
if (isAjaxRequest(request)) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"code\":401, \"message\":\"认证失败\"}");
} else {
response.sendRedirect("/login?error=true");
}
};
}

// 判断是否ajax请求
public boolean isAjaxRequest(HttpServletRequest request) {
String xRequestedWith = request.getHeader("X-Requested-With");
return "XMLHttpRequest".equals(xRequestedWith);
}
}

创建AJAX登录页

新增一个ajaxLogin.html,使用ajax发送请求(为了测试方便,这里简单创建一个,不使用VUE等工程了):

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
<!-- src/main/resources/templates/ajaxLogin.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>企业级登录系统</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
</head>
<body>
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="w-100" style="max-width: 400px;">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center mb-4">登录</h2>
<form>
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" name="username" id="username" placeholder="请输入用户名">
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" name="password" id="password" placeholder="请输入密码">
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">登录</button>
</div>
<p class="mt-3 text-center"><a href="#">忘记密码?</a></p>
</form>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('form').submit(function (event) {
event.preventDefault();
var username = $('#username').val();
var password = $('#password').val();
$.ajax({
type: 'POST',
url: '/login',
data: {
username: username,
password: password
},
success: function (response) {
console.log(response)
if(response.code == 200){
window.location.href = '/';
}
}
})
})
})
</script>
</body>
</html>

添加Controller路由

在Controller中追加页面展示:

1
2
3
4
@GetMapping("/ajaxLogin")
public String ajaxLogin() {
return "ajaxLogin";
}

测试效果

最后启动Spring Boot项目,访问/ajaxLogin登录页,测试输入正确和不正确的账号密码,并观察浏览器控制台输出:

AJAX登录测试


结语

本章节介绍了如何通过Spring Security实现从配置自定义登录页面、表单登录处理逻辑的配置,并简单模拟了前后端分离的适配方案。

在接下来的章节中,我们将逐步深入Spring Security的各个技术细节,带你从入门到精通,全面掌握这一安全技术的方方面面。欢迎继续关注!