Spring Security学习笔记

学习时间:2024年7月17日

1 入门项目

  • pom依赖:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
  • 控制器
1
2
3
4
5
6
7
8
9
@RestController
public class HelloController {

@GetMapping("/hello")
public String hello() {
return "Hello, 来跟 学习 Spring Security吧!";
}

}

把项目启动起来后,在浏览器中对Web接口进行访问,会发现接口是无法直接访问的。在访问接口之前会自动跳转到/login地址,进入到一个登录界面。这是因为Spring Boot中”约定大约配置”的规则,只要添加了Spring Security的依赖包,就会自动开启安全限制,在访问Web接口之前会进行安全拦截。只有输入了用户名和密码,才能访问项目中的Web接口。

image-20240717140155388

默认用户名为user,密码为随机uuid

1
2
3
4
5
6
7
8
9
10
11
public class ReactiveUserDetailsServiceAutoConfiguration {
private String getOrDeducePassword(User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword())); // 打印生成密码
}

return encoder == null && !PASSWORD_ALGORITHM_PATTERN.matcher(password).matches() ? "{noop}" + password : password;
}
// ....
}

密码生成机制:

1
2
3
4
5
6
7
8
9
10
11
public class SecurityProperties {
// ...
public static class User {
private String name = "user"; // 默认用户名
private String password = UUID.randomUUID().toString(); // 默认密码
private List<String> roles = new ArrayList();
private boolean passwordGenerated = true;
// ....
}
// ...
}
  • 配置默认账户和密码
1
2
3
4
5
spring:
security:
user:
name: zym
password: zym123

2 认证

认证: 所谓的认证,就是用来判断系统中是否存在某用户,并判断该用户的身份是否合法的过程,解决的其实是用户登录的问题。认证的存在,是为了保护系统中的隐私数据与资源,只有合法的用户才可以访问系统中的资源。

在Spring Security中,常见的认证方式可以分为HTTP层面和表单层面,常见的认证方式如下:

  1. HTTP基本认证;
  2. Form表单认证
  3. HTTP摘要认证;

2.1 HTTP基本认证

2.1.1 概述

在Spring Security 4.x版本中,默认采用的登录方式是Http基本认证(或者Basic认证),该方式会弹出一个对话框,要求用户输入用户名和密码。在每次进行基本认证请求时,都会在Authorization请求头中利用Base64用户:密码 字符串进行编码。这种方式并不安全,并不适合在Web项目中使用,但它是一些现代主流认证的基础,而且在Spring Security的OAuth中,内部认证的默认方式就是用的Http基本认证。

  • 认证步骤:
  1. 客户端首先发起一个未携带认证信息的请求;
  2. 然后服务器端返回一个401 Unauthorized的响应信息,并在WWW-Authentication头部中说明认证形式:当进行HTTP基本认证时,WWW-Authentication会被设置为Basic realm=“被保护的页面”
  3. 接下来客户端会收到这个401 Unauthorized响应信息,并弹出一个对话框,询问用户名和密码。当用户输入后,客户端会将用户名和密码使用冒号进行拼接并用Base64编码,然后将其放入到请求的Authorization头部并发送给服务器;
  4. 最后服务器端对客户端发来的信息进行解码得到用户名和密码,并对该信息进行校验判断是否正确,最终给客户端返回响应内容。
  • 缺点

HTTP基本认证是一种无状态的认证方式,与表单认证相比,HTTP基本认证是一种基于HTTP层面的认证方式,无法携带Session信息,也就无法实现Remember-Me功能。另外,用户名和密码在传递时仅做了一次简单的Base64编码,几乎等同于以明文传输,极易被进行密码窃听和重放攻击。所以在实际开发中,很少会使用这种认证方式来进行安全校验。

2.1.2 代码实现

新建配置类SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 对任意请求都进行认证
.anyRequest()
.authenticated()
.and()
// 开启http基本认证
.httpBasic();
}
}

在SecurityConfig类上添加@EnableWebSecurity注解后,会自动被Spring发现并注册。


注:http.authorizeRequests()首先对url进行匹配,然后进行权限控制。支持链式调用。

url匹配规则

  • anyRequest():表示匹配所有的url请求
1
2
3
http.authorizeRequests()
// 匹配所有的请求,并且所有请求都需要登录认证
.anyRequest().authenticated();
  • antMatcher():传递一个ant表达式参数,表示匹配所有满足ant表达式的请求。
    • ?:匹配一个字符
    • *:匹配0个或多个字符
    • **:匹配0个或多个目录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http.authorizeRequests()
// 允许登录页面匿名访问
.antMatchers("/showLogin", "/errPage")
.anonymous()
// 所有的静态资源允许匿名访问
.antMatchers(
"/css/**",
"/js/**",
"/images/**",
"/fonts/**",
"/favicon.ico")
.anonymous()
// 其他所有的请求都需要登录认证
.anyRequest()
.authenticated();

权限控制方法

  • permitAll():表示所匹配的URL任何人都允许访问
  • anonymous():表示可以匿名访问匹配的URL。
  • denyAll():表示所匹配的URL都不允许被访问。
  • authenticated():表示所匹配的URL都需要被认证才能访问
  • rememberMe():允许通过remember-me登录的用户访问
  • hasRole():如果有参数,参数表示角色,则其角色可以访问

2.1.3 认证流程

当第一次调用接口时,查看响应头,可以看到WWW-Authenticate认证信息:

1
WWW-Authenticate:Basic realm="Realm"

image-20240717142917951

其中:

  • WWW-Authenticate: 表示服务器告知浏览器进行代理认证工作。
  • Basic: 表示认证类型为Basic认证。
  • realm=”Realm”: 表示认证域名为Realm域。

根据401和以上响应头信息,浏览器会弹出一个对话框,要求输入用户名/密码,Basic认证会将其拼接成 “用户名:密码” 格式,中间是一个冒号,并利用Base64编码成加密字符串xxx;然后在请求头中附加Authorization: Basic xxx,发送给后台认证;后台需要利用Base64来进行解码xxx,得到用户名和密码,再校验 用户名:密码 信息。

  • 如果认证错误,浏览器会保持弹框;
  • 如果认证成功,浏览器会缓存有效的Base64编码,在之后的请求中,浏览器都会在请求头中添加该有效编码。

image-20240717143346116

2.1.4 认证的销毁

在成功认证之后,Basic认证会把Authorization认证信息缓存在浏览器中一段时间,之后每次请求接口时都会自动带上,所以直到 用户关闭浏览器才会销毁认证信息,也就是说我们无法在服务端进行有效的注销。

2.2 Form表单认证

2.2.1 默认配置

在之前的章节中,创建的第一个Spring Security项目中实现的效果,其实就是表单认证。每次在访问某个Web接口之前,都会重定向到一个Security自带的login登录页面上,这个登录页面,就是表单认证的效果。

下图显示,hello请求被重定向到login

image-20240717144124904

在默认的表单认证配置中,自动配置了一些url和页面:

  • /login(get): get请求时会跳转到这个页面,只要我们访问任意一个需要认证的请求时,都会跳转到这个登录界面。
  • /login(post): post请求时会触发这个接口,在登录页面点击登录时,默认的登录页面表单中的action就是关联这个login接口。
  • /login?error: 当用户名或密码错误时,会跳转到该页面。
  • /: 登录成功后,默认跳转到该页面,如果配置了index.html页面,则 ”/“ 会重定向到index.html页面,当然这个页面要由我们自己实现。
  • /logout: 注销页面。
  • /login?logout: 注销成功后跳转到的页面。

2.2.2 自定义配置

  • 配置类
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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 用来定义哪些请求需要忽略安全控制,哪些请求必须接受安全控制;还可以在合适的时候清除SecurityContext以避免内存泄漏,
* 同时也可以用来定义请求防火墙和请求拒绝处理器,另外我们开启Spring Security Debug模式也是这里配置的
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/js/**", "/css/**", "/images/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 对任意请求都进行认证
.anyRequest()
.authenticated()
.and()
// 开启表单认证
.formLogin()
// 加载自定义的登录页面地址
.loginPage("/myLogin.html")
.permitAll()
.and()
.csrf()
.disable();
}
}
  • myLogin.html页面,注意form表单中action的值,请暂时先写成”/myLogin.html“。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="en">
<body>
<div class="login">
<h2>Access Form</h2>
<div class="login-top">
<h1>登录验证</h1>
<form action="/myLogin.html" method="post">
<input type="text" name="username" placeholder="username" />
<input type="password" name="password" placeholder="password" />
<div class="forgot">
<a href="#">忘记密码</a>
<input type="submit" value="登录" >
</div>
</form>
</div>
<div class="login-bottom">
<h3>新用户 <a href="#">注 册</a></h3>
</div>
</div>
</body>
</html>

访问接口,被重定向到/myLogin.html

image-20240717145910792

2.2.3 细化配置

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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 用来定义哪些请求需要忽略安全控制,哪些请求必须接受安全控制;还可以在合适的时候清除SecurityContext以避免内存泄漏,
* 同时也可以用来定义请求防火墙和请求拒绝处理器,另外我们开启Spring Security Debug模式也是这里配置的
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/js/**", "/css/**", "/images/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
//用and来表示配置过滤器结束,以便进行下一个过滤器的创建和配置
.and()
//设置表单登录,创建UsernamePasswordAuthenticationFilter
.formLogin()
.loginPage("/myLogin.html")
.permitAll()
//指登录成功后,是否始终跳转到登录成功url。它默认为false
.defaultSuccessUrl("/index.html",true)
//post登录接口,登录验证由系统实现
.loginProcessingUrl("/login")
//用户密码错误跳转接口
.failureUrl("/error.html")
//要认证的用户参数名,默认username
.usernameParameter("zym")
//要认证的密码参数名,默认password
.passwordParameter("zym123")
.and()
//配置注销
.logout()
//注销接口
.logoutUrl("/logout")
//注销成功后跳转到的接口
.logoutSuccessUrl("/myLogin.html")
.permitAll()
//删除自定义的cookie
.deleteCookies("myCookie")
.and()
//注意:需禁用crsf防护功能,否则登录不成功
.csrf()
.disable();
}
}

2.3 HTTP摘要认证

暂略

2.4 前后端分离方案

2.4.1 概述

在之前的章节讲解表单认证时,处理登录成功时,跳转到某个页面的API是如下两个方法:

  • defaultSuccessUrl
  • successForwardUrl

处理登录失败时,跳转页面的API是如下两个方法:

  • failureUrl()
  • failureForwardUrl()

上面的方法,无论是认证成功还是认证失败,都是在前后端不分离时的处理方案,直接从Java后端跳转到某个页面上。在前后端分离模式下,既然后端没有页面,页面都在前端,那就可以考虑使用JSON来进行信息交互了,我们把认证成功或认证失败的信息,以JSON的格式传递给前端,由前端来决定到底该往哪个页面跳转就好了。

2.4.2 认证成功

① API
  • successHandler():successHandler方法的参数是一个 AuthenticationSuccessHandler 对象,这个对象中我们要实现的方法是 onAuthenticationSuccess
  • onAuthenticationSuccess():方法中有三个参数,分别是:
    • HttpServletRequest: 利用该参数我们可以实现服务端的跳转;
    • HttpServletResponse: 利用该参数我们可以做客户端的跳转,也可以返回 JSON 数据;
    • Authentication: 这个参数则保存了我们刚刚登录成功的用户信息。
② 实现
  • 定义SecurityAuthenticationSuccessHandler类:该类需要实现SavedRequestAwareAuthenticationSuccessHandler接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 处理登录成功时的业务逻辑
*/
public class SecurityAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

/**
* Authentication:携带登录的用户名及角色等信息
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
//直接输出json格式的响应信息
Object principal = authentication.getPrincipal();
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
//以json格式对外输出身份信息
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
}
}
  • 配置类:在SecurityConfig配置类中,调用successHandler()方法,把前面定义的SecurityAuthenticationSuccessHandler类关联进来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
// 认证成功时的处理器
.successHandler(new SecurityAuthenticationSuccessHandler())
.and()
.csrf()
.disable();
}
}

登录成功后,后端向前端发送json:

image-20240717154134514

2.4.3 认证失败

  • 定义类SecurityAuthenticationFailureHandler,实现ExceptionMappingAuthenticationFailureHandler接口,来专门处理认证失败时的返回结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 处理登录失败时的业务逻辑
*/
public class SecurityAuthenticationFailureHandler extends ExceptionMappingAuthenticationFailureHandler {
/**
* AuthenticationException:异常信息
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
//直接输出json格式的响应信息
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(exception.getMessage());
out.flush();
out.close();
}
}
  • 配置类:调用failureHandler()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
// 认证成功时的处理器
.successHandler(new SecurityAuthenticationSuccessHandler())
// 认证失败时的处理器
.failureHandler(new SecurityAuthenticationFailureHandler())
.and()
.csrf()
.disable();
}
}

登录失败:

image-20240717154621341

2.4.4 退出登录

  • 定义一个SecurityLogoutSuccessHandler类,实现LogoutSuccessHandler接口,在这里负责输出退出登录时的JSON结果。
1
2
3
4
5
6
7
8
9
10
public class SecurityLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write("注销成功");
out.flush();
out.close();
}
}
  • 配置类:调用logoutSuccessHandler()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
// 认证成功时的处理器
.successHandler(new SecurityAuthenticationSuccessHandler())
// 认证失败时的处理器
.failureHandler(new SecurityAuthenticationFailureHandler())
.and()
.logout()
//退出登录
.logoutSuccessHandler(new SecurityLogoutSuccessHandler())
.and()
.csrf()
.disable();
}

2.4.5 未认证

如果用户没有登录,就访问一个需要认证后才能访问的页面。这个时候,我们不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,前端收到提示之后,再自行决定页面跳转。因为在前后端分离时,后端没有页面,未认证时也没办法直接重定向到登录页面。

  • 定义一个SecurityAuthenticationEntryPoint类,实现AuthenticationEntryPoint接口,在这里负责输出未认证时的JSON结果。
1
2
3
4
5
6
7
8
9
10
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write("尚未登录,请先登录");
out.flush();
out.close();
}
}
  • 配置类:调用authenticationEntryPoint()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
// 认证成功时的处理器
.successHandler(new SecurityAuthenticationSuccessHandler())
// 认证失败时的处理器
.failureHandler(new SecurityAuthenticationFailureHandler())
.and()
.logout()
.logoutSuccessHandler(new SecurityLogoutSuccessHandler())
.and()
.csrf()
.disable()
.exceptionHandling()
.authenticationEntryPoint(new SecurityAuthenticationEntryPoint());
}

3 授权

所谓授权,比如说某个用户想要访问某个资源(接口、页面、功能等),我们应该先去检查该用户是否具备对应的权限,如果具备就允许访问,如果不具备,则不允许访问。

在Spring Security中,授权粒度有如下几种:

  • 支持基于 URL 的请求授权
  • 基于方法访问的授权
  • 基于对象访问的授权

授权实现方式:

  • 基于内存模型实现授权
  • 基于默认数据库模型实现授权
  • 基于自定义数据库模型实现授权

3.1 基于内存模型

3.2 基于默认数据库模型

3.3 基于自定义数据库模型

3.3.1 接口

Spring Security 支持MySQL、Oracle等多种不同的数据源,这些不同的数据源最终都由 UserDetailsService这个接口的子类来负责进行操作。

Spring Security还提供了另一个UserDetailsService的实现子类,也就是JdbcUserDetailsManager。利用JdbcUserDetailsManager可以帮助我们以JDBC的方式对接数据库进行增删改查等操作。它内部设定了一个默认的数据库模型,只要遵从这个模型,我们就可以很方便的实现在数据库中创建用户名和密码、角色等信息,但是灵活性不足。


  • UserDetailsService接口的源码:
1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

从源码中可以看出,可以利用loadUserByUsername()方法,根据用户名查询出对应的UserDetails信息。

  • UserDetails接口源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

String getPassword();

String getUsername();

boolean isAccountNonExpired();

boolean isAccountNonLocked();

boolean isCredentialsNonExpired();

boolean isEnabled();
}

从UserDetails的源码中我们了解到,UserDetails其实就是一个包含了User信息的类,其中包含了用户名、密码、角色及账号状态等信息。

3.3.2 代码实现

① 测试接口
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/admin")
public class AdminController {
@GetMapping("/hello")
public String hello() {
return "hello, admin";
}
}
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/hello")
public String hello() {
return "hello, user";
}
}
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/visitor")
public class VisitorController {
@GetMapping("/hello")
public String hello() {
return "hello, visitor";
}
}

授权规则:

  • /visitor/hello 任何人都可以访问;
  • /admin/hello 具有 admin 角色的人才能访问;
  • /user/hello 具有 user 角色的人才能访问;
  • 所有 user 角色能够访问的接口资源,admin 角色也都能够访问。
② 准备数据库
1
2
3
4
5
6
7
8
CREATE TABLE users (id bigint(20) NOT NULL AUTO_INCREMENT,
username varchar(50) NOT NULL,
password varchar(60) NOT NULL,
enable tinyint(4) NOT NULL DEFAULT 1,
roles text character set utf8,
PRIMARY KEY (id),
KEY username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

image-20240717164213440

③ User实体类

创建一个User实体类,这个用户实体类需要实现 UserDetails 接口,并实现接口中的方法。

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
@Data
public class User implements UserDetails {

private Long id;

private String username;

private String password;

private String roles;

private boolean enable;

private List<GrantedAuthority> authorities;

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

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

@Override
public String getUsername() {
return this.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;
}
}
  • accountNonExpired、accountNonLocked、credentialsNonExpired、enabled 这四个属性分别用来描述用户的状态,表示账户是否没有过期、账户是否没有被锁定、密码是否没有过期、以及账户是否可用;
  • roles 属性表示用户的角色;
  • getAuthorities 方法返回用户的角色信息,一个用户可能会有多个角色,所以这里返回值是一个集合类型,我们在这个方法中把自己的 Role 角色稍微转化一下即可。
④ Mapper接口
1
2
3
4
5
@Repository
public interface UserMapper {
@Select("SELECT * FROM users WHERE username=#{username}")
User findByUserName(@Param("username") String username);
}
⑤ 实现UserDetailsService接口

在service层,定义一个UserDetailsService子类,实现UserDetailsService接口,然后实现该接口中的loadUserByUsername()方法。这个方法的参数是用户在登录的时候传入的用户名,然后根据用户名去查询用户信息(查出来之后,系统会自动进行密码比对)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class MyUserDetailsService implements UserDetailsService {
@Resource
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库尝试读取该用户
User user = userMapper.findByUserName(username);

// 用户不存在,抛出异常
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}

// 将数据库形式的roles解析为UserDetails的权限集
// AuthorityUtils.commaSeparatedStringToAuthorityList是Spring Security
// 提供的用于将逗号隔开的权限集字符串切割成可用权限对象列表的方法
// 当然也可以自己实现,如用分号来隔开等,参考generateAuthorities
user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
return 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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Resource
private MyUserDetailsService userDetailsService;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 访问/admin/**需要ADMIN角色
.antMatchers("/admin/**")
.hasRole("ADMIN")
// 访问/user/**需要USER角色
.antMatchers("/user/**")
.hasRole("USER")
// 访问/visitor/**准予放行
.antMatchers("/visitor/**")
.permitAll()
// 其他请求需要授权
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//关联UserDetailsService对象
auth.userDetailsService(userDetailsService)
//暂时不对密码进行加密配置
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}

4 验证码

暂略

5 自动登录和注销

5.1 实现方案

自动登录是将用户的登录信息保存在用户浏览器的cookie中,当用户下次访问时,自动实现校验并建立登录状态的一种机制

所以基于上面的原理,Spring Security 就为我们提供了两种比较好的实现自动登录的方案:

  • 基于散列加密算法机制:加密用户必要的登录信息,并生成令牌来实现自动登录,利用TokenBasedRememberMeServices类来实现。
  • 基于数据库等持久化数据存储机制:生成持久化令牌来实现自动登录,利用PersistentTokenBasedRememberMeServices来实现。

5.2 基于散列加密算法

5.2.1 代码实现

① 配置加密令牌key

创建一个application.yml文件,在其中添加数据库配置,以及一个用来加密令牌的key字符串,字符串的值随便自定义就行

1
2
3
4
5
6
7
8
spring:
datasource:
url: jdbc:mysql://localhost:3306/db-security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT
username: XXX
password: XXX
security:
remember-me:
key: zym
② 配置类
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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

// 注入key
@Value("${spring.security.remember-me.key}")
private String rememberKey;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**")
.hasRole("ADMIN")
.antMatchers("/user/**")
.hasRole("USER")
.antMatchers("/visitor/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
// 认证成功时的处理器
.successHandler(new SecurityAuthenticationSuccessHandler())
// 认证失败时的处理器
.failureHandler(new SecurityAuthenticationFailureHandler())
.and()
// 开启“记住我”
.rememberMe()
.userDetailsService(userDetailsService)
.key(rememberKey)
.and()
.csrf()
.disable();
}

@Resource
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//关联UserDetailsService对象
auth.userDetailsService(userDetailsService)
//暂时不对密码进行加密配置
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}

请求结果:

image-20240718141535919

5.2.2 cookies加密原理

首次登录时:

1
2
// 对各字段进行散列加密
hashInfo=md5Hex(username +":"+expirationTime +":"password+":"+key)

将该hashInfo作为cookies存储在浏览器。

再次登录时:

1
2
//利用base64进行解码
rememberCookie=base64(username+":"+expirationrime+":"+hashInfo)

步骤:

  • 首先后端根据登陆成功的用户的用户名,密码和过期时间等信息散列生成cookie,发回并保存在浏览器中;
  • 在浏览器关闭并重新打开之后,用户再去访问 /user/hello 接口时,此时浏览器就会携带remember-me这个cookie到服务端;
  • 服务器端拿到cookie之后,利用Base64进行解码,计算出用户名和过期时间,再根据用户名查询到用户密码;
  • 最后还要通过 MD5 散列函数计算出散列值,并将计算出的散列值和浏览器传递来的散列值进行对比,以此确认这个令牌是否有效。

5.2.3 自动登录原理

① 令牌生成

令牌生成的核心处理方法定义在:TokenBasedRememberMeServices#onLoginSuccess

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
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
//从认证对象中获取用户名
String username = retrieveUserName(successfulAuthentication);
//从认证对象中获取密码
String password = retrievePassword(successfulAuthentication);

......

if (!StringUtils.hasLength(password)) {
//根据用户名查询出对应的用户
UserDetails user = getUserDetailsService().loadUserByUsername(username);
//获取到用户身上的密码
password = user.getPassword();
}

//获取登录过期时间,默认是2周
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);

//生成remember-me签名信息
String signatureValue = makeTokenSignature(expiryTime, username, password);

//保存cookie
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
}

protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
  1. 首先从登录成功的 Authentication 对象中提取出用户名/密码;
  2. 由于登录成功之后,密码可能被擦除了,所以如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码;
  3. 接下来获取令牌的有效期,令牌有效期默认是两周;
  4. 再接下来调用 makeTokenSignature()方法 去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。但是如果服务端重启,这个默认的 key 是会变的,这样就导致之前派发出去的所有 remember-me 自动登录令牌失效,所以我们可以指定这个 key。
  5. 最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中并随response返回。
② 令牌解析

核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值。最后再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。

5.3 基于持久化令牌

在持久化令牌方案中,最核心的是series和token两个值,这两个值都是用MD5散列计算生成的随机字符串。不同的是,series仅在用户使用密码重新登录时更新,而 token 会在每一个新的session会话中都重新生成

持久化令牌方案 避免了散列加密方案中,一个令牌可以同时在多端登录的问题,这是因为每个session会话都会引发token的更新,即每个token仅支持单实例登录

5.3.1 代码实现

① 数据表

创建一张persistent_logins表,用来存储我们自动登录时生成的持久化令牌信息,该表SQL脚本如下:

1
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)

在该表中,series是主键,可以根据series进行令牌信息的查询等操作。

② 配置类
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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Value("${spring.security.remember-me.key}")
private String rememberKey;

@Autowired
private DataSource dataSource;

@Override
protected void configure(HttpSecurity http) throws Exception {
//配置数据源
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);

http.authorizeRequests()
.antMatchers("/admin/**")
.hasRole("ADMIN")
.antMatchers("/user/**")
.hasRole("USER")
.antMatchers("/visitor/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
.and()
// 开启“记住我”
.rememberMe()
.userDetailsService(userDetailsService)
.key(rememberKey)
// 持久化令牌方案
.tokenRepository(tokenRepository)
// 设置令牌有效期,为7天有效期
.tokenValiditySeconds(60 * 60 * 24 * 7)
.and()
.csrf()
.disable();
}

@Resource
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//关联UserDetailsService对象
auth.userDetailsService(userDetailsService)
//暂时不对密码进行加密配置
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}

这时候,我们只需要在登录页面中输入 用户名和密码,勾选“记住我”功能之后,Spring Security就会生成一个持久化令牌,在这个令牌中就保存了当前登陆的用户信息,该令牌信息会被自动持久化存储到persistent_logins表中。

image-20240718150819642

5.3.2 两种方式的对比

散列加密方案和持久化令牌方案,这两种方案都是把信息存储在cookie中,所以都有被盗取用户身份信息的可能性,当然持久化令牌方案的安全性更高一些。但是如果要你追求最安全的方式,那就尽量不要实现自动登录功能,所以我们要在用户体验和提高安全性之间选择平衡点。

如果我们一定要实现自动登录功能,可以限制以cookie身份登录时的部分执行权限**,比如在修改密码、修改邮箱(防止找回密码)、查看隐私信息(如完整的手机号码、银行卡号等)时,我们可以进一步校验用户的登录密码,或者设置独立密码来做二次校验,以提高安全性。

5.4 注销

  • 配置类
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
http.authorizeRequests()
.antMatchers("/admin/**")
.hasRole("ADMIN")
.antMatchers("/user/**")
.hasRole("USER")
.antMatchers("/app/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
.and()
//开启记住我功能
.rememberMe()
.userDetailsService(userDetailsService)
//1.散列加密方案
.key(rememberKey)
//2.持久化令牌方案
.tokenRepository(tokenRepository)
//7天有效期
.tokenValiditySeconds(60 * 60 * 24 * 7)
.and()
//配置退出登录功能
.logout()
//关联自己的退出登录接口
.logoutUrl("/user/logout")
//注销成功,重定向到该路径下
.logoutSuccessUrl("/login")
//使得session失效
.invalidateHttpSession(true)
//清除认证信息
.clearAuthentication(true)
//删除指定的cookie
.deleteCookies("cookie01","cookie02")
.and()
.csrf()
.disable();
  • 自定义退出接口

如果我们想自己编写退出登录时的业务逻辑,也可以在UserController中定义一个“/logout”接口,处理退出登录功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/user")
public class UserController {

@GetMapping("hello")
public String hello() {
return "hello, user";
}

@RequestMapping("/logout")
public void logout(HttpSession session){
session.invalidate();
System.out.println("logout执行了...");
}

}

6 密码加密

6.1 简介

在一般的项目里,常用的就是信息摘要算法,也可以被称为散列加密函数,或者称为散列算法、哈希函数。这是一种可以从任何数据中创建数字“指纹”的方法,常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm)等

Spring Security提供了多种密码加密算法,但官方推荐使用的是BCryptPasswordEncoder方案。

6.2 代码实现

  • register接口
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
@RestController
@RequestMapping("/user")
public class UserController {

@Resource
private PasswordEncoder passwordEncoder;

@Resource
private UserService userService;

@GetMapping("/hello")
public String hello() {
return "hello, user";
}

@GetMapping("/register")
public User registerUser(@RequestBody User user){
user.setEnable(true);
user.setRoles("USER");
//对密码进行加密
user.setPassword(passwordEncoder.encode(user.getPassword()));
userService.save(user);
return user;
}
}
  • 配置类:放行/user/register接口,注入PasswordEncoder
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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Value("${spring.security.remember-me.key}")
private String rememberKey;

@Autowired
private DataSource dataSource;

@Override
protected void configure(HttpSecurity http) throws Exception {
//配置数据源
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);

http.authorizeRequests()
.antMatchers("/admin/**")
.hasRole("ADMIN")
// 放行register接口
.antMatchers("/user/register")
.permitAll()
.antMatchers("/user/**")
.hasRole("USER")
.antMatchers("/visitor/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll()
.and()
// 开启“记住我”
.rememberMe()
.userDetailsService(userDetailsService)
.key(rememberKey)
// 持久化令牌方案
.tokenRepository(tokenRepository)
// 设置令牌有效期,为7天有效期
.tokenValiditySeconds(60 * 60 * 24 * 7)
.and()
.csrf()
.disable();
}

@Resource
private MyUserDetailsService userDetailsService;

@Bean
public PasswordEncoder passwordEncoder() {
//使用默认的BCryptPasswordEncoder加密方案
return new BCryptPasswordEncoder();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//关联UserDetailsService对象
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}

测试接口:

image-20240718170818507

7 权限控制

在Spring Security 中,对接口的拦截或放行,有四种常见的权限控制方式:

  • 利用Ant表达式实现权限控制;(已学)
  • 利用授权注解结合SpEl表达式实现权限控制;
  • 利用过滤器注解实现权限控制;
  • 利用动态权限实现权限控制。

7.1 授权注解结合SpEl表达式

常用的授权注解有3个:

  • @PreAuthorize:方法执行前进行权限检查;
  • @PostAuthorize:方法执行后进行权限检查;
  • @Secured:类似于 @PreAuthorize。

代码实现

首先需要利用@EnableGlobalMethodSecurity注解开启授权注解功能,代码如下:

1
2
3
4
5
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ...
// ...
}

然后在具体的接口方法上利用授权注解进行权限控制,代码如下:

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
@RestController
public class UserController {
@Secured({"ROLE_USER"})
//@PreAuthorize("principal.username.equals('user')")
@GetMapping("/user/hello")
public String helloUser() {
return "hello, user";
}

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/hello")
public String helloAdmin() {
return "hello, admin";
}

@PreAuthorize("#age>100")
@GetMapping("/age")
public String getAge(@RequestParam("age") Integer age) {
return String.valueOf(age);
}

@GetMapping("/visitor/hello")
public String helloVisitor() {
return "hello, visitor";
}
}

7.2 过滤器注解

在Spring Security中还提供了另外的两个注解,即@PreFilter和@PostFilter这两个注解可以对集合类型的参数或返回值进行过滤。使用@PreFilter和@PostFilter时,Spring Security将移除对应表达式结果为false的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@RestController
public class FilterController {

/**
* 只返回结果中id为偶数的user元素。
* filterObject是@PreFilter@PostFilter中的一个内置表达式,表示集合中的当前对象。
*/
@PostFilter("filterObject.id%2==0")
@GetMapping("/users")
public List<User> getAllUser() {
List<User> users = new ArrayList<>();
for (int i = 0; i < 10; i++) {
users.add(new User(i, "zym-" + i));
}
return users;
}

}

其余略

8 跨域问题

8.1 简介

在同源策略中,域名、协议、端口都要相同。

在前后端分离的项目中,需要解决跨域问题,常用的手段:

  • Java后端进行跨域解决CORS
  • 使用AJAX的JSONP
  • 使用jQuery的JSONP插件
  • document.domain + iframe 跨域解决方案
  • window.name + iframe 跨域解决方案
  • location.hash + iframe 跨域解决方案
  • postMessage跨域解决方案
  • WebSocket协议跨域解决方案
  • node代理跨域解决方案
  • nginx代理跨域解决方案

8.2 代码实现

8.2.1 未引入ss的跨域解决方案

在SpringBoot环境中,创建一个端口号为8080的web项目,注意这个web项目没有引入Spring Security的依赖包。然后在其中创建一个IndexController,定义两个测试接口以便被ajax进行跨域访问。

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

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

@PostMapping("/hello")
public String hello2() {
return "post hello";
}
}

定义一个index.html页面,利用axios跨域访问8080项目中的web接口。

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.26.0/axios.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<!-- <h2 class="page-header">基本使用</h2> -->
<button class="btn btn-primary">发送GET请求</button>
<button class="btn btn-warning">发送POST请求</button>
</div>
<script>
// 获取按钮
const btns = document.querySelectorAll("button");

// 发送GET请求
btns[0].onclick = function() {
axios({
// 请求类型
method: "GET",
// 请求URL
url: "http://localhost:8080/hello"
}).then(response => {
console.log(response);
});
};

// 发送POST请求
btns[1].onclick = function() {
axios({
// 请求类型
method: "POST",
// 请求URL
url: "http://localhost:8080/hello"
}).then(response => {
console.log(response);
});
};
</script>
</body>
</html>

然后分别执行get与post请求,这时候就可以在浏览器的控制台上看到产生了CORS跨域问题,出现了CORS error状态,在请求头中出现了Referer Policy: strict-origin-when-cross-origin

image-20240718204626268

image-20240718204658975

解决

  • 方式1:在接口方法上利用@CrossOrigin注解解决跨域问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class IndexController {

@CrossOrigin(value = "http://127.0.0.1:5500/")
@GetMapping("/hello")
public String hello() {
return "get hello";
}

@CrossOrigin(value = "http://127.0.0.1:5500/")
@PostMapping("/hello")
public String hello2() {
return "post hello";
}
}
  • 方式2:通过实现WebMvcConfigurer接口来解决跨域问题
1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://127.0.0.1:5500/")
.allowedMethods("*")
.allowedHeaders("*");
}

}

然后成功请求:

image-20240718205334883

8.2.2 引入ss的跨域解决方案

为了提高网站的安全性,在上面Spring Boot项目的基础之上,添加Spring Security的依赖包,但是暂时不进行任何别的操作。

接着重启8080这个Spring Boot项目,然后在前端中再次进行跨域请求,我们会发现在引入Spring Security后,再次产生了跨域问题。

image-20240718205533771

通过实验可知,如果使用了 Spring Security,上面的跨域配置会失效,因为请求会被 Spring Security 拦截。那么在Spring Security环境中,如何解决跨域问题呢?这里我们有3种方式可以开启 Spring Security 对跨域的支持。

① 开启cors方法

在配置类中开启cors方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.permitAll()
.and()
.formLogin()
.permitAll()
.and()
.httpBasic()
.and()
//支持跨域访问
.cors()
.and()
.csrf()
.disable();
}
}
② 进行全局配置

第二种方式是去除上面的跨域配置,直接在 Spring Security 中做全局配置,如下:

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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.permitAll()
.and()
.formLogin()
.permitAll()
.and()
.httpBasic()
.and()
.cors()
.configurationSource(corsConfigurationSource())
.and()
.csrf()
.disable();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.addAllowedOriginPattern("*");
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(Duration.ofHours(1));
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

9 源码分析

9.1 整体流程

用户提交用户名和密码后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant UPAF as UsernamePassword<br/>AuthenticationFilter
participant AM as Authentication<br/>Manager
participant DAP as DaoAuthentication<br/>Provider
participant UDS as UserDetails<br/>Service
participant SCH as SecurityContext<br/>Holder

UPAF->>UPAF: 1.将请求信息封装为Authentication,<br/>实现类为UsernamePasswordAuthenticationToken
UPAF->>AM: 2.认证authenticate()
AM->>DAP: 3.委托认证authenticate()
DAP->>UDS: 4.获取用户信息<br/>loadUserByUsername()
UDS->>DAP: 5.返回UserDetails
DAP->>DAP: 6.通过passwordEncoder对比UserDetails中的密码与Authentication<br/>中的密码一致
DAP->>DAP: 7.填充Authentication,如权限信息
DAP->>UPAF: 8.返回Authentication
UPAF->>SCH: 9.通过SecurityContextHolder.getContext().setAuthentication()方法将Authentication保存至安全上下文

image-20240719104911147

9.2 基础API

Authentication,SecurityContext,SecurityContextHolder。三者关系:

SecurityContextHolder 用来保存 SecurityContext,利用SecurityContextHolder的getContext()方法可以得到SecurityContext对象;然后再通过调用 SecurityContext 对象中的getAuthentication()方法,就可以获取 Authentication 对象;最后我们利用Authentication对象,可以进一步获取已认证用户的详细信息。

9.2.1 Authentication接口

Authentication 是 spring-security-core 核心包中的接口,直接继承自 Principal 接口,而 Principal 是位于 java.security 包中,由此可知 Authentication 是 Spring Security 中的核心接口。Authentication接口中封装了用户信息,它有个很重要的子类UsernamePasswordAuthenticationToken

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

作用:在用户登录认证之前,用户名密码等信息 会被封装为一个 Authentication 的具体实现类对象。在登录认证成功之后又会生成一个信息更全面的Authentication对象,从该对象中可以得到用户的权限信息列表、密码、用户细节信息、用户身份信息、认证信息等。这个 Authentication对象会被保存在 SecurityContextHolder 所持有的 SecurityContext 中,供后续的程序进行调用,如访问权限的鉴定等。

9.2.2 SecurityContext接口

安全上下文,该类用于存储认证授权的相关信息,实际上就是存储 “当前用户“ 的账号信息和相关权限,即代表当前用户信息的 Authentication 对象会被 SecurityContext 所持有(引用),SecurityContext上下文对象则被SecurityContextHolder 所持有(引用)。

1
2
3
4
5
public interface SecurityContext extends Serializable {
Authentication getAuthentication();

void setAuthentication(Authentication authentication);
}

9.2.3 SecurityContextHolder类

① 线程安全性

由于一个请求从开始到结束都由一个线程处理,这个线程中途不会去处理其他的请求,所以在这段时间内,这个线程就相当于跟当前用户是一一对应的。SecurityContextHolder类就是用于把SecurityContext存储在当前线程中。

在Web环境下,SecurityContextHolder是利用ThreadLocal来存储SecurityContext对象的。所以SecurityContextHolder可以用来设置和获取SecurityContext对象,该类主要是给框架内部使用,我们可以利用它获取当前用户的SecurityContext对象,进而进行请求检查,和访问控制等。

因为Sevlet中的线程都是被池化复用的,一旦处理完当前的请求,这个线程可能马上就会被分配去处理其他的请求,而且也不能保证用户下次的请求会被分配到与上次相同的线程。因此在每一次请求结束后都会自动清除当前线程的ThreadLocal对象。

② 存储策略

SecurityContextHolder可以通过设置来调整3种存储策略,三种策略详情如下:

  • MODE_THREADLOCAL:表示将 SecurityContext对象 存储在当前线程中,(默认);
  • MODE_INHERITABLETHREADLOCAL:表示将 SecurityContext对象 存储在线程中,但子线程可以获取到父线程中的 SecurityContext;
  • MODE_GLOBAL:表示 SecurityContext对象内容 在所有线程中都相同。

9.3 Filter API

9.3.1 内置过滤器

Spring Security内置的过滤器:

默认加载Filter 过滤器作用
WebAsyncManagerIntegrationFilter 将 WebAsyncManager 与SpringSecurity上下文集成
SecurityContextPersistenceFilter 在处理请求之前,将安全信息加载到SecurityContextHolder中
HeaderWriterFilter 处理头信息加入响应中
CsrfFilter 处理CSRF攻击
LogoutFilter 处理注销登录
UsernamePasswordAuthenticationFilter 处理表单认证
DefaultLoginPageGeneratingFilter 配置默认登录页面
DefaultLogoutPageGeneratingFilter 配置默认注销页面
BasicAuthenticationFilter 处理 HttpBasic 登录
RequestCacheAwareFilter 处理请求缓存
SecurityContextHolderAwareRequestFilter 包装原始请求
AnoymousAuthenticationFilter 配置匿名认证
SessionManagementFilter 处理Session并发问题
ExceptionTraslationFilter 处理认证/授权中的异常

9.3.2 自定义过滤器

添加过滤器的方法(都是HttpSecurity中的),第一个参数都是要加入的过滤器,第二个参数是针对已存在过滤器的Class对象:

  • addFilterAt(filter,atFilter):这个相当于线性表的插入操作,把 filter 插入在 atFilter 的位置上。
  • addFilterBefore(filter,atFilter):这个是在 atFilter 前插入一个 filter 过滤器;
  • addFilterAfter(filter,atFilter):这个顾名思义是在 atFilter 后插入一个 filter 过滤器;

可以继承Spring Security中内置的过滤器以进行重写逻辑,另一方面可以继承Spring框架的过滤器。

例如:OncePerRequestFilter是Spring框架提供的一个过滤器,确保在一次HTTP请求期间只执行一次特定的过滤器逻辑。它继承了GenericFilterBean类,并实现了javax.servlet.Filter接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class MyFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
//执行你的过滤逻辑
System.out.println("执行过滤逻辑");
// 继续处理请求
filterChain.doFilter(httpServletRequest, httpServletResponse);

}

}

注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private MyFilter myFilter;
// ...
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// httpSecurity的配置...
// ...
// 在处理表单认证之前,添加自定义过滤器
httpSecurity.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class);
}
}

9.4 认证管理API

9.4.1 AuthenticationManager接口

AuthenticationManager的作用是校验Authentication,如果验证失败会抛出AuthenticationException异常

1
2
3
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

9.4.2 ProviderManager类

Spring Security 中 AuthenticationManager 接口的默认实现类是 ProviderManager,但它本身并不直接处理身份认证请求,它会委托给内部配置的 AuthenticationProvider (一个接口)列表providers。该列表会进行循环遍历,依次对比匹配以查看它是否可以执行身份验证,每个 Provider 验证程序在验证后,将会抛出异常或返回一个完全填充的 Authentication 对象。源码如下所示:

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
// org/springframework/security/authentication/ProviderManager.java
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
//维护一个AuthenticationProvider列表
private List<AuthenticationProvider> providers = Collections.emptyList();

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();

// 遍历providers列表,判断是否支持当前authentication对象的认证方式
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}

try {
// 执行provider的认证方式并获取返回结果
result = provider.authenticate(authentication);

if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}

// 若当前ProviderManager无法完成认证操作,且其包含父级认证器,则允许转交给父级认证器尝试进行认证
if (result == null && parent != null) {
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}


if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// 完成认证,从authentication对象中移除私密数据
((CredentialsContainer) result).eraseCredentials();
}

// 若父级AuthenticationManager认证成功,则派发AuthenticationSuccessEvent事件
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}

// 未认证成功,抛出ProviderNotFoundException异常
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}

if (parentException == null) {
prepareException(lastException, authentication);
}

throw lastException;
}
}

9.5 认证实现API

9.5.1 AuthenticationProvider接口

AuthenticationManager负责管理协调认证工作的,但并不负责认证功能的真正实现,认证功能的真正实现是由 AuthenticationManager 中引用的 AuthenticationProvider 类来完成的,通过源码我们可以看到AuthenticationManager 中引用了一个providers列表。

1
2
//维护一个AuthenticationProvider列表
private List<AuthenticationProvider> providers = Collections.emptyList();

AuthenticationProvider接口有多个实现子类。

9.5.2 DaoAuthenticationProvider类

DaoAuthenticationProvider是AuthenticationProvider接口的实现类,Spring Security中默认就是使用DaoAuthenticationProvider来实现基于数据库模型认证授权工作。

DaoAuthenticationProvider 在进行认证的时候,需要调用 UserDetailsService 对象的loadUserByUsername() 方法来获取用户信息 UserDetails,其中包括用户名、密码和所拥有的权限等。

authenticate()认证方法源码剖析

在AuthenticationProvider接口中有个authenticate()方法,但是该方法并没有默认实现。

1
2
3
4
5
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;

boolean supports(Class<?> authentication);
}

这个方法是在AuthenticationProvider的子类AbstractUserDetailsAuthenticationProvider中实现的,实现源码如下:

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
// 抽象方法
protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException;

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));

// Determine username
// 从认证对象中得到用户名
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();

boolean cacheWasUsed = true;
// 根据用户名获取缓存的用户对象
UserDetails user = this.userCache.getUserFromCache(username);

if (user == null) {
cacheWasUsed = false;

try {
// 在缓存为空的情况下,调用retrieveUser()方法,根据用户名查询用户对象。
// 其中的retrieveUser()方法是个抽象方法,由子类DaoAuthenticationProvider来实现。
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");

if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}

Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}

try {
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}

postAuthenticationChecks.check(user);

if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}

Object principalToReturn = user;

if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}

return createSuccessAuthentication(principalToReturn, authentication, user);
}

retrieveUser抽象方法由DaoAuthenticationProvider来具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)throws AuthenticationException {
prepareTimingAttackProtection();
try {
//这里调用了我们自己编写的UserDetailsService对象中的loadUserByUsername()方法!
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}catch (InternalAuthenticationServiceException ex) {
throw ex;
}catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}

9.5.3 authenticate流程

  1. 在ProviderManager类中遍历List\ providers集合,判断AuthenticationProvider类是否支持对当前对象进行认证;
  2. 如果支持,则调用AuthenticationProvider接口对象的authenticate(authentication)方法进行认证;
  3. 结合当前具体的认证实现模型,如果是基于数据库模型来进行表单认证,则执行AbstractUserDetailsAuthenticationProvider类中的authenticate(authentication)方法具体进行认证;
  4. AbstractUserDetailsAuthenticationProvider类中的authenticate(authentication)方法内部会调用retrieveUser()抽象方法加载对象;
  5. 在DaoAuthenticationProvider类中具体执行retrieveUser()方法,在该方法中调用UserDetailsService对象的loadUserByUsername(username)方法,从而实现根据用户名从数据库中查询用户信息;

9.6 实体接口API

9.6.1 UserDetails接口

UserDetails 是 Spring Security 中的核心接口,它表示用户的详细信息,这个接口涵盖了一些必要的用户信息字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface UserDetails extends Serializable {
//获取用户权限,本质上是用户的角色信息
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//判断账户是否未过期
boolean isAccountNonExpired();
//判断账户是否未锁定
boolean isAccountNonLocked();
//判断密码是否未过期
boolean isCredentialsNonExpired();
//判断账户是否可用
boolean isEnabled();
}

可以实现这个接口,然后自定义其他的属性。

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
@Data
@TableName("users")
public class User implements UserDetails {

@TableId(value = "id", type = IdType.AUTO)
private Long id;

@TableField("username")
private String username;

@TableField("password")
private String password;

@TableField("roles")
private String roles;

@TableField("enable")
private boolean enable;

private List<GrantedAuthority> authorities;

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

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

@Override
public String getUsername() {
return this.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;
}
}

9.6.2 UserDetailsService接口

UserDetailsService也是一个接口,该接口中只有一个方法loadUserByUsername(),该方法可以用来获取UserDetails对象,源码如下:

1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

同样可以实现接口,定义自己的业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class MyUserDetailsService implements UserDetailsService {

@Resource
private UserService userService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库尝试读取该用户
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
User user = userService.getOne(queryWrapper);

// 用户不存在,抛出异常
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}

// 根据用户的角色来分配权限
user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
return user;
}
}