重写默认配置

通过之前的学习,我们已经知道了第一个项目的默认配置,接下来我们就来尝试替换它们。在本节中,我们将介绍如何配置UserDetailsService和PasswordEncoder.这两个组件参与身份验证的处理,大多数应用程序都会根据其 需求对它们进行自定义,但是关于这两个组件的细节我们仍然是放到后面细讲,目前我们只需了解如何插入自定义实现。

重写UserDetailsService组件

前文里我们就提到过,UserDetailsService我们可以选择创建自己的实现,或者使用Spring Security提供的预定义实现。但是本节并不打算详细介绍前两者,而是使用Spring Security提供的一个名为InMemoryUserDetailsManager的实现。通过这个示例,我们将了解如何将这类对象插入架构中。

紧接着之前第一个Spring Security的程序示例,我们对其进行修改,让其改成拥有自己管理的凭据并将其用于身份验证。所以在此我们并不会实现UserDeatilsService,但需要使用Spring Security提供的实现类。

InMemoryUserDetailsManager虽说这个实现稍稍超出了UserDetailsService本身,但目前我们只从UserDetailsService角度看待它,这个实现会将凭据存储在内存中,然后同之前,Spring Security可以使用这些凭据对请求进行身份验证。但是InMemoryUserDetailsManager并不适用于生产环境下使用,只适用于示例或概念证明。

如下,我们建一个config包,在包下面创建ProjectConfig类,并使用@Bean将我们自己配置的UserDetailsService添加到Spring的上下文中。

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



@Bean
public UserDetailsService userDetailsService(){


InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
return userDetailsService;
}
}

此时启动程序,可以发现之前放在控制台的密码此时已经没有了,这意味着Spring boot对我们关于UserDetailsService的预配置项已失效,所以我们可以发现我们此时并不能访问Controller,原因有如下两点:

  • 目前我们还没有任何用户
  • 还没有PasswordEncoder

在上文中,我们知道身份验证不仅依赖UserDetailsService,还同时依赖PasswordEmcoder

让我们逐步来解决这个问题。我们需要

1、 至少创建一个具有一组凭据(用户名和密码)的用户;
2、 添加要由UserDetailsService实现管理的用户;
3、 定义一个PasswordEncoder类型的bean,应用程序可以使用它验证为UserDetailsService存储和管理的用户所指定的密码;
首先,我们使用一个预定义的构建器创建UserDetails类型的对象。在构建实例时,必须提供用户名、密码和至少一个权限。权限是该用户被允许执行的操作,为此可以使用任意字符串。然后通过InMemoryUserDetailsManager添加这组凭据,代码如下:

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



@Bean
public UserDetailsService userDetailsService(){


InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
//User类用于创建代表用户的对象的构造器实现,该类由SpringSecurity提供,并非我们自己创建
UserDetails user = User.withUsername("mbw")
.password("123456")
.authorities("read")
.build();
userDetailsService.createUser(user);
return userDetailsService;
}
}

如上代码,我们必须为用户名、密码、并且至少为权限提供一个值。但这仍然不足以让我们调用接口,我们还需要声明一个PasswordEncoder.

在使用之前默认的UserDetailsService时,PasswordEncoder也会自动配置,因为我们重写了UserDetailsService,所以还必须声明一个PasswordEncoder.如果没配置就去调用接口,我们将在控制台看到一个异常,而客户端将返回401和一个空的响应体。

所以我们可以在配置类添加一个PasswordEncoder bean去解决这个问题,完整代码如下:

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
package com.mbw.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig {



@Bean
public UserDetailsService userDetailsService(){


InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
UserDetails user = User.withUsername("mbw")
.password("123456")
.authorities("read")
.build();
userDetailsService.createUser(user);
return userDetailsService;
}

@Bean
public PasswordEncoder passwordEncoder(){


return NoOpPasswordEncoder.getInstance();
}
}

关于PasswordEncoder的实现,我们后面会详细介绍,这个使用的NoOpPasswordEncoder实例会将密码视为普通文本。它不会对密码进行加密或者哈希操作。为了进行匹配,它只会使用String类的底层equals(Object o)方法来比较字符串,因此我们同样在生产环境不适合使用这类PasswordEncoder.
现在,我们使用名为mbw,密码为123456的用户凭据访问接口:

重写端点授权配置

有了新的用户管理,现在就可以讨论端点的身份验证方法和配置。我们现在指定,在使用默认配置时,所有端点都会假定有一个由应用程序管理的有效用户。此外,在默认情况下,应用程序会使用HTTP Basic身份验证作为授权方法,但我们可以轻松的重写该配置。

其实之前我们也提到过,,HTTP Basic身份验证并不适用于大多数应用程序架构,有时我们希望修改它以适配我们的应用程序。同样,也并非应用程序的所有端点都需要被保护,对于那些需要保护的端点,可能需要选择不同的授权规则。要进行这样的更改,首先需要扩展WebSecurityConfigurerAdapter类。扩展这个类使得我们可以重写configure(HttpSecurity http)方法。如下代码将继续使用之前的配置类ProjectConfig,我们在其基础上使其继承WebSecurityConfigurerAdapter类,然后我们就可以使用HttpSecurity对象的不同方法去更改配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated(); //所有请求都需要身份验证
}
}

如以上代码的配置其实与之前的默认的授权行为是相同的,大家可以再次调用端点,查看它的行为是否和一开始一样(这里我们没有重写UserDetailsService和PasswordEncoder,所以凭据是默认的user,启动程序也可在控制台再次看到之前的password),但是我们只需稍作修改,就可以使所有端点都可以被访问,而不需要凭据,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().permitAll(); //所有请求都不需要身份验证
}
}

现在,调用/hello端点不在需要凭据,配置中的permitAll()调用以及anyRequest()方法会使所有端点都可以访问并且无需凭据。我们可以重启程序,并且在postman中Authorization设置为no Auth,调用/hello接口看是否还需要认证:

然后我们在该配置类尝试配置UserDetailsService和PasswordEncoder,在之前的配置方案,我们通过在Spring上下文中添加新的实现作为bean来重写UserDetailsService和PasswordEncoder.现在来看另一种为它们配置的方法,我们可以通过configure(AuthenticationManagerBuilder auth)方法设置它们,仍然需要从WebSecurityConfigurerAdapter类重写此方法,并使用类型为AuthenticationManagerBuilder的参数来设置它们,代码如下:

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
package com.mbw.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated(); //所有请求都需要身份验证
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


//声明UserDetailsService,以便将用户存储在内存中
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
//定义具有所有详情的用户
UserDetails user = User.withUsername("mbw")
.password("12345")
.authorities("read")
.build();
//添加该用户以便让UserDetailsService对其进行管理
userDetailsService.createUser(user);
auth.userDetailsService(userDetailsService)
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}

可以看到和之前配置UserDetailsService和PasswordEncoder其实差不多,只不过我们在这儿是通过重写方法完成的。我们还从AuthenticationManagerBuilder处调用了userDetailsService()方法来注册userDetailsService实例,此外,还调用了passwordEncoder0方法来注册PasswordEncoder。在SpringSecurity中,我们推荐以上面代码的这种方式进行配置,而不是像之前那样将UserDetailsService和PasswordEncoder这两个bean配置到Spring上下文然后在配置请求是否需要认证的配置方法(全写在一个配置类),这种配置方式也被称为混合配置,我们并不推荐,因为这样会使我们的代码不干净且不易于理解。但是我们可以使用职责分离方式将它们分在两个配置类中,这种配置方式同样也推荐:

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
package com.mbw.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserManagementConfig{



@Bean
public UserDetailsService userDetailsService(){


InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
UserDetails user = User.withUsername("mbw")
.password("123456")
.authorities("read")
.build();
userDetailsService.createUser(user);
return userDetailsService;
}

@Bean
public PasswordEncoder passwordEncoder(){


return NoOpPasswordEncoder.getInstance();
}
}
package com.mbw.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {


@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated(); //所有请求都需要身份验证
}
}

我们通过定义两个配置类实现职责分离,增加代码的可读性,一个用于用户和密码的管理,一个用于接口授权管理。但是在这种情况下,不能让两个类都扩展WebSecurityConfigurerAdapter,如果这样做,依赖注入将失败,可以通过使用@Order注解设置注入的优先级来解决依赖注入的问题。但是,从功能上讲,这是行不通的,因为配置会相互排除而不是合并

重写AuthenticationProvider实现

通过上面的学习,我们已经了解了UserDetailsService和PasswordEncoder在Spring Security架构中的用途以及配置它们的方法,下面我们将介绍委托给上面这两个组件的组件进行自定义,即AuthenticationProvider。还是之前那张图:

红框所示展示了AuthenticationProvider,它实现了身份验证逻辑并且委托给UserDetailsService和PasswordEncoder以便进行用户和密码管理。那么下面我们将进一步深入探讨身份验证和授权架构,以便了解如何使用AuthenticationProvider实现自定义身份验证逻辑。

在学习如何重写AuthenticationProvider之前,我们仍然建议你遵循Spring Security架构中设计的职能,就是上面那种图,此架构与细粒度的职能是松耦合的。这种设计是使Spring Security变得灵活且易于集成到应用程序中的原因之一。不过,我们可以更改其设计,但是必须谨慎使用它们,因为有可能会使解决方案复杂化。例如,我们可以选择不再需要UserDetailsService或PasswordEncoder的方式重写默认的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
package com.mbw.provider;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {


@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {


return null;
}

@Override
public boolean supports(Class<?> aClass) {


return false;
}
}

authenticate(Authentication authentication)方法表示所有用于身份验证的逻辑,因此我们将像下面代码那样添加一个实现,对于supports(),就目前而言,建议你将其实现视为理所当然,对于当前示例,它不是必需的,实现代码如下:

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
package com.mbw.provider;

import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
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.stereotype.Component;

import java.util.Arrays;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {


@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {


//getName()方法被Authentication从Principal接口处继承
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
//这个条件通常会调用UserDetailsService和PasswordEncoder用来调试用户名和密码
if("mbw".equals(username) && "12345".equals(password)){


return new UsernamePasswordAuthenticationToken(username,password, Arrays.asList());
}else {


throw new AuthenticationCredentialsNotFoundException("Error in authentication!");
}
}

@Override
public boolean supports(Class<?> authenticationType) {


return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authenticationType);
}
}

如你所见,在此处if-else子句的条件替换了UserDetailsService和PasswordEncoder的职能。这里不需要使用这两个bean,但如果使用用户和密码进行身份验证,则强烈建议将它们的管理逻辑分开,即使在重写身份验证实现时,也要像Spring Security架构所设计的那样应用它。

你可能会发现,通过实现我们自己的AuthenticationProvider来替换身份验证逻辑是很有用的,如果默认实现不能完全满足应用程序的需求,则可以选择实现自定义身份验证逻辑。

然后我们可以在ProjectConfig配置类中注册我们刚刚配置的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
package com.mbw.config;

import com.mbw.provider.CustomAuthenticationProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {


@Autowired
private CustomAuthenticationProvider authenticationProvider;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated(); //所有请求都需要身份验证
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.authenticationProvider(authenticationProvider);
}
}

现在可以调用该端点,只有由认证逻辑定义的被识别用户—mbw,并且使用密码12345才能访问该端点:

管理用户

本章将带你详细理解我们之前处理的首个示例中所遇到的一个基本角色–UserDetailsService,除了UserDetailsService,我们还将讨论

  • UserDetails,它会描述Spring Security的用户
  • GrantedAuthority,它允许我们定义用户可以执行的操作
  • 如何通过MybatisPlus结合UserDetailsService进行用户认证

在之前的学习中,我们已经对UserDetails和PasswordEncoder在身份验证过程中的角色有大致的了解。但是其中仅讨论了如何插入所定义的实例,而不是使用SpringBoot配置的默认实例。另外还有更多细节要讨论:

  • Spring Security提供了哪些实现以及如何使用它们
  • 如何为契约定义一个自定义实现以及何时需要这样做
  • 真实应用程序中使用的实现接口的方式
  • 使用这些接口的最佳实践

1.在Spring Security中实现身份验证

上面的架构想必大家已经非常熟悉了,这个阴影框就是我们这次首先要处理的组件:UserDetailsService和PasswordEncoder。这两个组件主要关注流程的部分,通常将其称为“用户管理部分”。在本章中,UserDetailsService和PasswordEncoder是直接处理用户详细信息及其凭据的组件。目前这一章我们主要详细讨论UserDetailsService.
作为用户管理的一部分,我们使用了UserDetailsService和UserDetailsManager(UserDetailsService的扩展)接口。UserDetailsService只负责按用户名检索用户

1
2
3
4
5
public interface UserDetailsService {


UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

此操作是框架完成身份验证所需的唯一操作。UserDetailsManager则在其基础上添加了对用户添加、修改、删除的行为,这是大多数应用程序中必需的功能,虽然我们完全也可以自己定义,UserDetailsManager接口扩展UserDetailsService接口是体现接口分离原则的一个很好的例子,分离接口可以提供更好的灵活性,因为框架不会强迫我们在应用程序不需要的时候实现行为,如果应用程序只需要验证用户,那么实现UserDetailsService契约就足以覆盖所需功能。而如果需要真正管理用户,我们就可以在其基础上自己扩展管理用户的功能亦或者直接实现UserDetailsManager的接口。

但是如果要管理用户,首先我们需要用户这样一个去描述它的接口(契约),Spring Security给我们提供了UserDetails,我们必须实现它以便使用框架理解的方式(UserDetailsService只识别它)去描述用户。而用户又拥有一组权限,Spring Security使用GrantedAuthority表示用户所具有的权限,下图就是用户管理部分组件之间的关系:

理解Spring Security架构中这些对象之间的联系以及实现它们的方法,可以为我们处理应用程序时提供广泛的选项以供选择。

那么下面我们就从如何描述用户开始讲解

2.描述用户

2.1.1、UserDetails接口

要与用户打交道,首先需要了解如何在应用程序中定义用户的原型,对于Spring Security,用户定义应该遵循UserDetails契约。UserDetails契约代表着Spring Security所理解的用户。描述用户的类必须实现该接口。

如下是UserDetails接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface UserDetails extends Serializable {


/**
* 将应用程序用户的权限返回成一个GrantedAuthority实例集合
*/
Collection<? extends GrantedAuthority> getAuthorities();

/**
* 下面2个方法会返回用户凭据
*/
String getPassword();
String getUsername();

/**
* 出于不同的原因,这4个方法会启用或禁用账户
*/
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}

总结一下,getUsername()和getPassword()方法会返回用户名和密码。应用程序在身体验证过程中将使用这些值,并且这些值是与本契约中身份验证相关的唯一详情。其他的5个方法都与授权用户访问应用程序资源有关。例如getAuthorities()返回授予用户的权限组。

此外,正如UserDetails所示,用户可以

  • 使账户过期
  • 锁定账户
  • 使凭据过期
  • 禁用账户
    如果选择在应用程序的逻辑中实现这些对用户的约束限制,则需要重写以下方法:isAccountNonExpired(),isAccountNonLocked(),isCredentialsNonExpired(),isEnabled(),这样那些需要被启用的就会返回true.有些人可能会觉得这些方法的取名从编码的干净性和可维护性角度看并不明智,例如,isAccountNonExpired()看起来想一个双重否定,乍一看可能会造成混淆,但是注意仔细从结果的角度去分析这四个方法,它们的命名使它们在授权应该失败的情况下都返回false,否则返回true.这是正确的做法,因为人脑更倾向于把false和负面联系在一起。另外,如果不需要在应用程序中实现这些功能,那么只需要让这4个方法返回true即可。

2.1.2、GrantedAuthority

正如前一小节说的,授予用户的操作被称为权限。要描述Spring Security的权限,可以使用GrantedAuthority接口,它表示授予用户的权限。用户可以没有任何权限,也可以拥有任意数量的权限,通常他们至少有一个权限。下面是GrantedAuthority定义的实现:

1
2
3
4
5
public interface GrantedAuthority extends Serializable {


String getAuthority();
}

要创建一个权限,只需要为该权限找到一个名称即可,我们后面会实现getAuthority()方法,以便以String形式返回权限名,GrantedAuthority接口只有一个抽象方法,我们后面将会通过lambada表达式的例子实现它亦或者通过它的实现类SimpleGrantedAuthority创建权限实例:
SimpleGrantedAuthority类提供了一种创建GrantedAuthority类型的不可变实例的方法,在构建实例时需要提供权限名称。下面的代码包含了实现GrantedAuthority的两个示例:

1
2
GrantedAuthority g1 = ()->"READ";
GrantedAuthority g2 =new SimpleGrantedAuthority("READ");

2.1.3、UserDetails的最小化实现

本节将编写UserDetails的最小化实现。我们从一个基本实现开始,其中每个方法都返回一个静态值。我们后面会将其改为实际场景中更容易使用的版本,并且允许使用多个不同的用户实例。既然我们之前已经了解了UserDetails和GrantedAuthority接口,就可以为应用程序编写最简单的用户定义。

我们使用一个名为DummyUser的类实现一个用户的最小描述,这里主要就是为了展示如何实现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
51
52
53
54
55
56
57
58
59
60
61
package com.mbw.pojo;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
public class DummyUser implements UserDetails {


@Override
public Collection<? extends GrantedAuthority> getAuthorities() {


ArrayList<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(()->"READ");
return grantedAuthorities;
}

@Override
public String getPassword() {


return "12345";
}

@Override
public String getUsername() {


return "mbw";
}

@Override
public boolean isAccountNonExpired() {


return true;
}

@Override
public boolean isAccountNonLocked() {


return true;
}

@Override
public boolean isCredentialsNonExpired() {


return true;
}

@Override
public boolean isEnabled() {


return true;
}
}

如上面代码。我实现了UserDetails接口,并且需要实现它的所有方法。这里是getUsername()和getPassword()的实现。在本示例中,这些方法仅为每个属性返回一个固定值。

接着我们实现了getAuthorities方法,返回了一个只有一个权限的集合。

最后,必须为UserDeatils接口的最后4个方法添加一个实现。对于DummyUser类,它们总是返回true,这意味着用户总是永远可以活动和可用的。

当然,这种最小化实现的所有实例永远都在只代表着同一个用户–mbw。只是理解UserDetails的良好开端,但在实际应用程序中并不会这样做。对于一个真实的应用程序而言,我们应该创建一个类,用于生成可以代表不同用户的实例。那么后面我们就将结合mybatisplus去构建一个可以持久化用户的实例,在学习如何使用UserDetailsService管理用户之前,我们先将User和Authority类结合Mybatisplus建好。

2.1.4、结合Mybatisplus实现UserDetails和GrantedAuthority

首先我们建造spring_security数据库,然后在里面建造三张表:user用户表,authority权限表,user_authority用户-权限中间关系表,建表语句如下:

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
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

--

--------
-- Table structure for authorities
--

--------
DROP TABLE IF EXISTS authorities;
CREATE TABLE authorities (
id bigint(20) NOT NULL,
authorityName varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of authorities
--

--------
INSERT INTO authorities VALUES (2970710450550485029, 'read');
INSERT INTO authorities VALUES (2971247443118264330, 'write');

--

--------
-- Table structure for user_authority
--

--------
DROP TABLE IF EXISTS user_authority;
CREATE TABLE user_authority (
id bigint(20) NOT NULL,
userId bigint(20) NOT NULL COMMENT '用户Id',
authorityId bigint(20) NOT NULL COMMENT '权限Id',
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of user_authority
--

--------
INSERT INTO user_authority VALUES (2971669583307083780, 2999623538338967560, 2970710450550485029);
INSERT INTO user_authority VALUES (2972001087342129510, 2999623538338967560, 2971247443118264330);

--

--------
-- Table structure for users
--

--------
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id bigint(20) NOT NULL,
username varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
password varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
mobile varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
email varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
enabled tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of users
--

--------
INSERT INTO users VALUES (2999623538338967560, 'mbw', '123456', '18170075966', '1443792751@qq.com', 1);

SET FOREIGN_KEY_CHECKS = 1;

然后我们在pom和yaml中加入相关依赖和配置:
父项目pom:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.mbw</groupId>
<artifactId>spring_security_parent</artifactId>
<version>1.0-SNAPSHOT</version>
<modules>
<module>spring_security_simple_web01</module>
</modules>
<packaging>pom</packaging>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<lombok.version>1.18.20</lombok.version>
<hutool.version>5.5.8</hutool.version>
<mybatis-plus.version>3.4.2</mybatis-plus.version>
<mysql.version>5.1.47</mysql.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.3.0.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
</project>

该项目依赖如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring_security_parent</artifactId>
<groupId>com.mbw</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>spring_security_simple_web01</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>

yaml配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
port: 9090
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: your username
password: your password
url: jdbc:mysql://127.0.0.1:3306/spring_security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml,classpath:/META-INF/modeler-mybatis-mappings/*.xml
typeAliasesPackage: com.mbw.pojo
global-config:
banner: false
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

然后就是重头戏,也是我们这个阶段的最后一件事,建立实体类r:
首先在pojo中建立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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serializable;
import java.util.Set;

@TableName("users")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable, UserDetails {


@TableId(type=IdType.ASSIGN_ID)
private Long id;
@TableField("username")
private String username;
@TableField("mobile")
private String mobile;
@TableField("password")
private String password;
@TableField("email")
private String email;
@TableField("enabled")
private Boolean enabled;
@TableField(exist = false)
private Set<Authority> authorities;

@Override
public boolean isAccountNonExpired() {


return true;
}

@Override
public boolean isAccountNonLocked() {


return true;
}

@Override
public boolean isCredentialsNonExpired() {


return true;
}

@Override
public boolean isEnabled() {


return this.enabled;
}

}

在这个类中我们除了几个和凭据相关的属性,还有一个set封装的权限集合属性表示用户拥有的所有权限,但要注意这里需要使用@TableField标注它是表中不存在的字段。然后实现UserDetails的相关方法即可。

然后就是Authority类,我们要让它实现GrantedAuthority接口,这样才能让前面的User类语法通过,因为UserDetails的getAuthorities需要返回泛型是GrantedAuthority的实现类才行,属性只需id和authorityName即可,然后是是西安getAuthority()只需返回authorityName即可,代码如下:

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
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;

@TableName("authorities")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Authority implements GrantedAuthority {


@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String authorityName;

@Override
public String getAuthority() {


return authorityName;
}
}

然后就是中间表,这个没什么好说的,主要是到时候UserDetailsService需要通过它去连接User和Authority,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@TableName("user_authority")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserAuthority {


@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
private Long authorityId;
}

建造好了后,我们就可以继续后面的学习了

3 通过Spring Security管理用户

3.1、UserDetailsService

在之前的学习中,我们曾经通过框架图了解了身份验证过程委托用户管理的特定组件:UserDetailsService实例,我们甚至定义了一个UserDetailsService用来重写Spring Boot提供的默认实现。

那么这节我们将尝试实现UserDetailsService类的各种方法,但是此处我们遍不再使用UserDetailsManager接口去添加更多行为,而是直接借助Mybatisplus结合UserDetailsService去管理用户。

首先在理解如何以及为何执行它之前,必须先理解其契约。现在详细介绍UserDetailsService以及如何使用该组件的实现。UserDetailsService接口只包含一个方法,如下所示:

1
2
3
4
5
6
package org.springframework.security.core.userdetails;
public interface UserDetailsService {


UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

身份验证实现调用loadUserByUsername(String username)方法通过指定的用户名获取用户的详细信息。用户名当然会被视作唯一的此方法返回的用户是UserDetails契约的实现。如果用户名不存在,则该方法将抛出一个UsernameNotFoundException异常。该异常是一个RuntimeException,UsernameNotFoundException异常直接继承AuthenticationException类型,它是与身份验证过程相关的所有异常的父类。Authentication进一步继承了RuntimeException类。

如上图,AuthenticationProvider是实现身份验证逻辑并使用UserDetailsService加载关于用户的详细信息的组件。为了按照用户名查找用户,它会调用loadUserByUsername(String username)方法

那么我们下面理解了UserDetailsService是根据用户名查找UserDetails的实现的,那么我们就按照该方法去实现UserDetailsService重写该方法查找我们的User:
首先我们需要写一个Mapper然后写一个三表联查语句通过我们的username查询我们的user及user相关的authority:
UserMapper.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.mbw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mbw.pojo.User;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserMapper extends BaseMapper<User> {


List<User> queryUserByUsername(String username);
}

在resource下建立Mapper/UserMapper.xml
UserMapper.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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mbw.mapper.UserMapper">
<resultMap id="queryUserMap" type="com.mbw.pojo.User" autoMapping="true">
<id column="id" property="id"/>
<collection property="authorities" ofType="com.mbw.pojo.Authority" autoMapping="true" columnPrefix="a_">
<id column="id" property="id"/>
<result column="authorityName" property="authorityName"/>
</collection>
</resultMap>
<select id="queryUserByUsername" resultMap="queryUserMap">
SELECT u.*,
a.id AS a_id,
a.authorityName AS a_authorityName
from users u
LEFT JOIN user_authority ua
ON u.id = ua.userId
LEFT JOIN authorities a
ON a.id = ua.authorityId
WHERE u.username ={username}
AND u.enabled != 0
</select>
</mapper>

这样我们就写好了根据用户名查询user及对应权限的sql,这样我们就可以重写我们的loadUserByUsername(String username)方法了。我们建立service包然后建立MybatisUserDetailsService并让它实现UserDetailsService,重写我们的loadUserByUsername(String username)方法,记住需要在该类上面标注@Service注解让其交给Spring管理

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
package com.mbw.service;

import com.mbw.mapper.UserMapper;
import com.mbw.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.List;

@Service
public class MybatisUserDetailsService implements UserDetailsService {



@Autowired
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {


List<User> users = userMapper.queryUserByUsername(username);
return users.stream().findFirst().orElseThrow(()->new UsernameNotFoundException("User Not Found"));
}
}

我们在方法内部调用userMapper中我们刚刚写的queryUserByUsername(String username)方法,由于我们这儿并没有对username进行唯一校验,所以有可能查询出多个用户,所以我们这边通过stream流返回查找到的第一个user,如果没找到,则抛出异常。

然后在我们的配置类配置我们的UserDetailsService
ProjectConfig.class

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
package com.mbw.config;

import com.mbw.service.MybatisUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
@Configuration
@RequiredArgsConstructor
public class ProjectConfig extends WebSecurityConfigurerAdapter {



@Autowired
private MybatisUserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.authorizeRequests()
.anyRequest().authenticated(); //所有请求都需要身份验证
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(userDetailsService)
.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
}

然后测试我们的/hello接口
我们使用数据库中的mbw和密码123456进行basic auth认证请求接口:

得到200Ok的返回结果,回到控制台查看日志,可以看到user被成功查询出:

我们可以自己将查询出的用户信息打印出来:

1
User(id=2999623538338967560, username=mbw, mobile=18170075966, password=123456, email=1443792751@qq.com, enabled=true, authorities=[Authority(id=2971247443118264330, authorityName=write), Authority(id=2970710450550485029, authorityName=read)])

然后我们可以再数据库手动再增加一个别的用户,看是否同样也能登陆成功,发现同样也可以登陆成功,这样我们就成功掌握如何通过userDetailsService管理用户。

最后,我提一嘴UserDetailsManager
其实此接口并没有什么,我们刚刚写的MybatisUserDetailsService同样也可以做到,那么它到底做了什么呢,其实它继承了UserDetailsService,在其基础上添加了更多方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.springframework.security.provisioning;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserDetailsManager extends UserDetailsService {


void createUser(UserDetails user);
void updateUser(UserDetails user);
void deleteUser(String username);
hangePassword(String oldPassword, String newPassword);
userExists(String username);
}

即在其基础上能够添加新用户或删除现有用户,但是我们的MYbatisPlus同样也可以做到这些功能,可以直接调用,所以这里我们就不对其过多讲解,其中UserDetailsManager最著名的实现当然就是JdbcUserDetailsManager,通过JDBC直接连接到数据库,并且也天生给我们提供了很多sql直接实现刚才的那些对用户进行管理的方法。但是我自认为没有mybatisplus好用。所以也不进行过多讲解。

密码处理

通过之前的学习我们已经了解了通过UserDetails接口以及使用其实现的多种方式,.但如前面文章所述,不同参与者会在身份验证和授权过程中对用户的表示进行管理,其中还介绍了一些参与者是具有默认配置的,例如UserDetailsService和PasswordEncoder。现在我们知道可以对默认配置进行重写,之前已经对UserDetailsService的相关配置进行重写,接下来我们将继续分析PasswordEncoder。

PasswordEncoder的定义

一般而言,系统并不以明文形式管理密码,印此密码通常要经过某种转换,这使得读取和窃取密码变得较为困难。对于这一职责,Spring Security定义了一个单独的契约—PasswordEncoder。实现这个契约是为了告知Spring Security如何验证用户的密码。在身份验证过程中,PasswordEncoder会判定密码是否有效。每个系统都会存储以某种方式编码过的密码。最好把密码哈希话存储起来,这样别人就不会读到明文密码了。

PasswordEncoder还可以对密码进行编码,接口声明的encode()和matches()方法实际上是其职责的定义。这两个方法都是同一契约的一部分,因为它们彼此紧密相连。应用程序对密码进行编码的方式与验证密码的方式相关,它们应该由同一个PasswordEncoder统一进行管理

1
2
3
4
5
6
7
8
9
10
11
public interface PasswordEncoder {


String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {


return false;
}
}

该接口定义了两个抽象方法,其中一个具有默认实现。在处理PasswordEncoder实现时,最常见的是抽象的encode()和matches()方法。

encode(CharSequence rawPassword)方法的目的是返回所提供字符串的转换。就Spring Security功能而言,它用于为指定密码提供加密或哈希化。之后可以使用matches(CharSequence rawPassword, String encodedPassword)方法检查已编码的字符串是否与原密码匹配。可以在身份验证过程中使用matches()方法根据一组已知凭据来检验所提供的密码。第三个方法被称为upgradeEncoding(String encodedPassword) ,在接口中默认设置为false。如果重写它以返回true,那么为了获得更好的安全性,将重新对已编码的密码进行编码

某些情况下,对已编码的密码进行编码会使从结果中获得明文密码变的更难,我个人不推荐这种晦涩的编码方式。

实现passwordEncoder契约

可以看到,matches()和encode()这两个方法具有很强的关联性。如果重写他们,应该确保它们始终在功能方面有所对应:由encode()方法返回的字符串应该始终可以使用同一个PasswordEncoder的matches()进行验证。了解如何实现PasswordEncoder之后,就可以选择应用程序为身份验证过程管理器的方式了。最直截了当的实现是一个以普通文本形式处理密码的密码编码器。也就是说,它不对密码进行任何编码。

用明文管理密码正式NoOpPasswordEncoder的实例所做的工作。之前我们使用过,如果要自己写一个,它将类似于下面的代码:

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
package com.mbw.password_encoder;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class PlainTextPasswordEncoder implements PasswordEncoder {


@Override
public String encode(CharSequence rawPassword) {


//并没有变更密码,而是原样返回
return rawPassword.toString();
}

@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {


//检查两个字符是否相等
return rawPassword.equals(encodedPassword);
}
}

这样的话编码的结果总是与原密码相同。因此,要检查他们是否匹配,只需要使用equals()对两个字符串进行比较即可。下面的代码则是PasswordEncoder的一个使用SHA-512的哈希算法的简单实现:

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
package com.mbw.password_encoder;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

@Component
public class Sha512PasswordEncoder implements PasswordEncoder {


@Override
public String encode(CharSequence rawPassword) {


return hashWithSHA512(rawPassword.toString());
}

private String hashWithSHA512(String input) {


StringBuilder result = new StringBuilder();
try{


MessageDigest md = MessageDigest.getInstance("SHA-512");
byte[] digested = md.digest(input.getBytes());
for (byte b : digested) {


result.append(Integer.toHexString(0xFF & b));
}
}catch (NoSuchAlgorithmException e){


throw new RuntimeException("Bad algorithm");
}
return result.toString();
}

@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {


String hashPassword = encode(rawPassword);
return encodedPassword.equals(hashPassword);
}
}

从PasswordEncoder提供的实现中选择

  • NoOpPasswordEncoder:不编码密码,而保持明文。我们仅将此实现用于示例。因为它不会对密码进行哈希化,所以永远不要在真实场景中使用它
  • StandardPasswordEncoder:使用SHA-256对密码进行哈希化。这个实现现在已经不推荐了,不应该在新的实现中使用它。不建议使用它的原因是,它使用了一种目前看来不够强大的哈希算法,但我们可能仍然会在现有的应用程序中发现这种实现。
  • Pbkdf2PasswordEncoder:使用基于密码的密钥派生函数2(PBKDF2)
  • BCryptPasswordEncoder:使用bcrypt强哈希函数对密码进行编码
  • SCryptPasswordEncoder:使用scrypt强哈希函数对密码进行编码
    让我们通过一些示例了解如何创建这些类型的PasswordEncoder实现的实例。NoOpPasswordEncoder被设计成了一个单例。不能直接从类外部调用它的构造函数,但是可以使用NoOpPasswordEncoder.getInstance()方法获得类的实例,例如我们在配置类如果想配置该PasswordEncoder,可以使用如下写法:
1
2
3
4
5
6
@Bean
public PasswordEncoder passwordEncoder() {


return NoOpPasswordEncoder.getInstance();
}

在这儿在演示一种更为优秀的选项:BCryptPasswordEncoder,它使用bcrypt强哈希函数对密码进行编码。可以通过调用无参构造函数来实例化它。不过也可以选择指定一个强度系数来表示编码过程中使用的对数轮数(log rounds,即 logarithmic rounds)。此外,还可以更改用于编码的SecureRandom实例。

1
2
3
4
PasswordEncoder bCryptPasswordEncoder1 = new BCryptPasswordEncoder();
PasswordEncoder bCryptPasswordEncoder2 = new BCryptPasswordEncoder(4);
SecureRandom s = SecureRandom.getInstanceStrong();
BCryptPasswordEncoder bCryptPasswordEncoder3 = new BCryptPasswordEncoder(4, s);

我们提供的对数轮数的值会影响哈希操作使用的迭代次数。这里使用的迭代次数为2^log rounds。对于迭代次数计算,对数轮数的值只能是4~31.
在某些应用程序中,我们可能会发现使用各种密码编码器都很有用,并且会根据特定的配置进行选择。从实际情况看,在生产环境应用程序中使用DelegatingPasswordEncoder的常见场景是当编码算法从应用程序的特定版本开始更改的时候。假设有人在当前使用的算法中发现了一个漏洞,而我们想为新注册的用户更改该算法,但又不想更改现有凭据的算法。所以最终会有多种哈希算法。我们要如何应对这种情况?虽然并非应对此场景的唯一方法,但一个好的选择是使用DelegatingPasswordEncoder对象。

DelegatingPasswordEncoder是PasswordEncoder接口的一个实现,这个实现不是实现它自己的编码算法,而是委托给同一契约的另一个实现。其哈希值以一个前缀作为开头,该前缀表明了用于定义该哈希值的算法。DelegatingPasswordEncoder会根据密码的前缀委托给PasswordEncoder的正确实现。

这听起来好像很复杂,不过后面我将通过代码以及接口演示就可以看出它其实非常简单。下图展示了PasswordEncoder实例之间的关系:

在上图中,DelegatingPasswordEncoder会为前缀{noop}注册一个NoOpPasswordEncoder,为前缀{bcrypt}注册一个BCryptPasswordEncoder,并且为前缀{scrypt}注册一个SCryptPasswordEncoder,如果密码具有前缀{noop},则DelegatingPasswordEncoder会将该操作转发给NoOpPasswordEncoder实现。

那么使用其他的PasswordEncoder实现均同理。

DelegatingPasswordEncoder具有一个它可以委托的PasswordEncoder实现的列表。DelegatingPasswordEncoder会将每一个实例存储在一个映射中。NoOpPasswordEncoder被分配的键是noop,而BCryptPasswordEncoder实现被分配的键是bcrypt.然后根据前缀将实现委托给对应的passwordEncoder实现

接下来我们就通过代码演示如何定义DelegatingPasswordEncoder.首先创建所需的PasswordEncoder实现的实例集合,然后将这些实例放在一个DelegatingPasswordEncoder中,如下代码:
ProjectConfig注册DelegatingPasswordEncoder,并将默认值委托给BCryptPasswordEncoder

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
package com.mbw.config;

import com.mbw.password_encoder.PlainTextPasswordEncoder;
import com.mbw.password_encoder.Sha512PasswordEncoder;
import com.mbw.service.MybatisUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class ProjectConfig extends WebSecurityConfigurerAdapter {



@Autowired
private MybatisUserDetailsService userDetailsService;
@Autowired
private PlainTextPasswordEncoder passwordEncoder;
@Autowired
private Sha512PasswordEncoder sha512PasswordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.csrf().disable().authorizeRequests()
.antMatchers("/create").permitAll()
.anyRequest().authenticated(); //所有请求都需要身份验证
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt",encoders);
}
}

接着在MybatisUserDetailsService我们需要写一个注册接口,并且将刚刚配置的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
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
package com.mbw.service;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mbw.mapper.AuthorityMapper;
import com.mbw.mapper.UserAuthorityMapper;
import com.mbw.mapper.UserMapper;
import com.mbw.password_encoder.Sha512PasswordEncoder;
import com.mbw.pojo.Authority;
import com.mbw.pojo.User;
import com.mbw.pojo.UserAuthority;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Slf4j
public class MybatisUserDetailsService extends ServiceImpl<UserMapper, User> implements UserDetailsService {



@Autowired
private UserMapper userMapper;
@Autowired
private AuthorityMapper authorityMapper;
@Autowired
private UserAuthorityMapper userAuthorityMapper;
@Autowired
private PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {


List<User> users = userMapper.queryUserByUsername(username);
return users.stream().findFirst().orElseThrow(()->new UsernameNotFoundException("User Not Found"));
}

@Override
@Transactional
public boolean save(User user) {


try {


String passwordNotEncode = user.getPassword();
String passwordEncoded = passwordEncoder.encode(passwordNotEncode);
user.setPassword(passwordEncoded);
userMapper.insert(user);
Set<Authority> authorities = user.getAuthorities();
Set<Long> authorityIds = authorities.stream().map(Authority::getId).collect(Collectors.toSet());
authorityIds.forEach(id -> {


Authority authority = authorityMapper.selectById(id);
if(authority != null){


Long userId = user.getId();
UserAuthority userAuthority = new UserAuthority();
userAuthority.setUserId(userId);
userAuthority.setAuthorityId(id);
userAuthorityMapper.insert(userAuthority);
}
});
return true;
} catch (Exception e) {


log.error(e.getMessage(),e);
return false;
}
}
}

那么这样我们测试一下注册接口:

发现注册进去的新用户密码前缀会给我们自动带上{bcrypt},说明DelegatingPasswordEncoder配置时生效的,那么encode方法生效,match自然也会生效,我们试试登录:

登陆成功!
为方便起见,Spring Security提供了一种方法创建一个DelegatingPasswordEncoder,它有一个映射指向PasswordEncoder提供的所有标准实现。PasswordEncoderFactories类提供了一个createDelegatingPasswordEncoder()的静态方法该方法会返回使用bcrypt作为默认编码器的DelegatingPasswordEncoder的实现

1
2
3
4
5
6
@Bean
public PasswordEncoder passwordEncoder() {


return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

效果等价于上面的配置代码,大家可以去试试。

AuthenticationProvider

在之前的学习中了解了userDeatailsService和passwordEncoder,在本章我们将介绍身份验证流程的其余部分,首先我们要讨论如何实现AuthenticationProvider接口,在身份验证成功之后,将探讨SpringContext接口以及Spring Security管理它的方式。

在企业级应用程序中,你可能会发现自己处于这样一种状况:基于用户名和密码的身份验证的默认实现并不适用。另外,当涉及身份验证时,应用程序可能需要实现多个场景。例如,我们可能希望用户能够通过使用在SMS消息中接收到的或由特定应用程序显示的验证码来证明自己的身份。或者,也可能需要实现某些身份验证场景,其中用户必须提供存储在文件中的某种密钥。我们甚至可能需要某些使用用户指纹的表示来实现身份验证逻辑。框架的目的是要足够灵活,以便允许我们实现这些所需场景中的任何一个。

框架通常会提供一组最常用的实现,但它必然不能涵盖所有可能的选项。就SpringSecurity而言,可以使用AuthenticationProvider接口来定义任何自定义的身份验证逻辑。

在身份验证期间表示请求

身份验证(Authentication)也是处理过程中涉及的其中一个必要接口的名称。Authentication接口表示身份验证请求事件,并且会保存请求访问应用程序的实体的详细信息可以在身份验证过程期间和之后使用与身份验证请求事件相关的信息。请求访问应用程序的用户被称为主体(principal),如果你有兴趣可以打印下认证后的authentication对象,你会发现Principal里封装的就是我们的UserDetails,这意味着我们可以对此进行强转化,方便后面的职责分离。如果你曾经使用过java Security API ,就会知道,在Java Security API中,名为Principai的接口表示相同的概念。Spring Security的Authentication接口扩展了这个契约。

Spring Security中的Authentication契约不仅代表了一个主体还添加了关于身份验证过程是否完成的信息以及权限集合。实际上,这个契约是为了扩展Java Security API的Principal契约而设计的,这在与其他框架和应用程序实现的兼容性方面是一个加分项。Authentication接口代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public interface Authentication extends Principal, Serializable {



Collection<? extends GrantedAuthority> getAuthorities();


Object getCredentials();


Object getDetails();


Object getPrincipal();
boolean isAuthenticated();


void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

目前需要了解的几个方法如下:

  • isAuthenticated():如果身份验证技术,则返回true;如果身份验证过程仍在进行,则返回false.
  • getCredentials():返回身份验证过程中使用的密码或任何密钥。
  • getAuthorities():返回身份验证请求的授权集合。

实现自定义身份验证逻辑

Spring Security中的AuthenticationProvider负责身份验证逻辑。AuthenticationProvider接口的默认实现会将查找系统用户的职责委托给UserDetailsService。它还使用PasswordEncoder在身份验证过程中进行密码管理。其中AuthenticationProvider接口diamagnetic如下:

1
2
3
4
5
6
7
8
9
public interface AuthenticationProvider {



Authentication authenticate(Authentication authentication)
throws AuthenticationException;

boolean supports(Class<?> authentication);
}

AuthenticationProvider的职责是与Authentication接口紧密耦合一起的。authenticate()方法会接收一个Authentication对象作为参数并返回一个Authentication对象需要实现authenticate()方法来定义身份验证逻辑。可以通过以下3个要点总结如何实现authenticate()方法:

  • 如果身份验证失败,则该方法应该抛出AuthenticationException异常
  • 如果该方法接收到的身份验证对象不被AuthenticationProvider实现所支持,那么该方法应该返回null
  • 该方法应该返回一个Authentication实例,该实例表示一个完全通过了身份验证的对象。对于这个实例,isAuthenticated()方法会返回true,并且它包含关于已验证实体的所有必要细节。通常,应用程序还会从实例中移除密码等敏感数据。因为保留这些数据可能会将它暴露给不希望看到的人。

该接口的另一个方法是supports(Class<?> authentication)如果当前的AuthenticationProvider支持作为Authentication对象而提供的类型,则可以实现此方法以返回true。注意,即使该方法对一个对象返回true,authenticate()方法仍然有可能通过返回null来拒绝请求。Spring Security这样的设计是较为灵活的,使得我们可以实现一个AuthenticationProvider,它可以根据请求的详细信息来拒绝身份验证请求,而不仅仅是根据请求的类型来判断。

应用自定义身份验证逻辑

  • 声明一个实现AuthenticationProvider接口的类
  • 确定新的AuthenticationProvider支持哪种类型的Authentication对象:
  • 重写supports(Class<?>authentication)方法以指定所定义的AuthenticationProvider支持哪种类型的身份验证。
  • 重写 authenticate(Authentication authentication)方法以实现身份验证逻辑。
  • 在Spring Security中注册新的AuthenticationProvider实现的一个 实例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {



//...

@Override
public boolean supports(Class<?> authenticationType) {


return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}

上述代码定义了一个实现AuthenticationProvider接口的新的类。其中使用了@Component来标记这个类,以便在Spring管理的上下文中使用其他类型的实例。然后,我们必须决定这个AuthenticationProvider支持哪种类型的Authentication接口实现。这取决于我们希望将哪种类型作为authenticate()方法的参数来提供。

如果不在身份验证级别做任何定制修改,那么UsernamePasswordAuthenticationToken类就会默认定义其类型。这个类是Authentication接口的实现,它表示一个使用用户名和密码的标准身份验证请求。

通过这个定义,就可以让AuthenticationProvider支持特定类型的密钥。一旦指定了AuthenticationProvider的作用域,就可以通过重写authenticate()方法来实现身份验证逻辑。

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
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {



@Autowired
private MybatisUserDetailsService userDetailsService;

@Autowired
private PasswordEncoder passwordEncoder;

@Override
public Authentication authenticate(Authentication authentication) {


String username = authentication.getName();
String password = authentication.getCredentials().toString();

UserDetails u = userDetailsService.loadUserByUsername(username);
if (passwordEncoder.matches(password, u.getPassword())) {


//如果密码匹配,则返回Authentication接口的实现以及必要的详细信息
return new UsernamePasswordAuthenticationToken(username, password, u.getAuthorities());
} else {

//密码不匹配,抛出异常
throw new BadCredentialsException("Something went wrong!");
}
}

@Override
public boolean supports(Class<?> authenticationType) {


return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}

上述代码的逻辑很简单。可以使用UserDetailsService实现来获得UserDetails。如果用户不存在,则loadUserByUsername()方法应该抛出AuthenticationException异常。在本示例中,身份验证过程停止了,而HTTP过滤器将响应状态设置为HTTP 401 Unauthorized。如果用户名存在,则可以从上下文中使用PasswordEncoder的matches()方法进一步检查用户的密码。如果密码不匹配,同样抛出AuthenticationException异常。如果密码正确,则AuthenticationProvider会返回一个标记为”authenticated”的身份验证实例,其中包含有关请求的详细信息。从代码中我们可以清晰的感受到AuthenticationProvider将验证委托给UserDetailsService和PasswordEncoder,上述代码的流程图如下:

要插入AuthenticationProvider的新实现,需要在项目的配置类中重写WebSecurityConfigurerAdapter类的configure(AuthenticationManagerBuilder auth)方法。如下所示:

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
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {



@Autowired
private AuthenticationProvider authenticationProvider;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.csrf().disable().authorizeRequests()
.antMatchers("/create").permitAll()
.anyRequest().authenticated(); //所有请求都需要身份验证
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt",encoders);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) {


auth.authenticationProvider(authenticationProvider);
}
}

至此,现在已经成功地自定义了AuthenticationProvider的实现。接下来就可以在需要的地方为应用程序定制身份验证逻辑了。

SecurityContext 的获取和保持

本节我们将讨论安全上下文,我们将分析它是如何工作的、如何从其中访问数据,以及应用程序如何在具有不同的与线程有关的场景中管理它。一般来说,我们为了让后续程序能够使用验证通过人员的信息,都会使用到它,比如编写一个SecurityUtils用来获取用户信息是经常用到的,那么学习它后,你就可以使用安全上下文存储关于已验证用户的详细信息了。

SecurityContext接口

我们上文学习了AuthenticationProvider对身份验证的整个流程,一旦AuthenticationManager成功完成身份验证,它将为请求的其余部分存储Authentication实例,这个实例就被称为安全上下文

Spring Security的安全上下文是由SpringContext接口描述的。接口代码如下:

1
2
3
4
5
6
public interface SecurityContext extends Serializable {


Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}

从接口定义中观察到,SecurityContext的主要职责是存储身份验证对象。但是SecurityContext本身是如何被管理的呢?Spring Security提供了3中策略来管理Spring Context,其中都用到了一个对象来扮演管理器的角色。该对象被命名为SecurityContextHolder.

  • MODE_THREADLOCAL——允许每个线程在安全上下文中存储自己的详细信息。在每个请求一个线程的Web应用程序中,这是一种常见的方法,因为每个请求都有一个单独的线程。但若是该接口是需要异步的,那么此种策略便不再适用
  • MODE_INHERITABLETHREADLOCAL——类似于MODE_THREADLOCAL,但还会指示Spring Security在异步方法的情况下将安全上下文复制到下一个线程。这样,就可以说运行@Async方法的新线程继承了该安全上下文
  • MODE_GLOBAL——使应用程序的所有线程看到相同的安全上下文实例

除了Spring Security提供的这3种管理安全上下文策略外,我们还将讨论当Spring不知道我们自己的线程会发生什么的情况时,我们需要显式地将详细信息从安全上下文复制到新线程.Spring Security不能自动管理不在Spring上下文中的对象,但是它为此提供了一些很好用的实用工具类。

将策略用于安全上下文

首先我们将学习通过MODE_THREADLOCAL策略管理安全上下文,这个策略也是Spring Security用于管理安全上下文的默认策略。使用这种策略,Spring Security就可以使用ThreadLocal管理上下文。我们都知道ThreadLocal确保应用程序每个线程只能看到自己线程中的ThreadLocal中的数据,其他线程是访问不到的。

作为管理安全上下文的默认策略,此过程不需要显式配置。在身份验证过程结束后,只要在需要的地方使用静态getContext()方法从持有者请求安全上下文即可。从安全上下文中,可以进一步获得Authentication对象,该对象存储着有关已验证实体的详细信息。例如下面代码我们将通过一个接口获取已认证用户的用户名:

1
2
3
4
5
6
7
8
@GetMapping("/hello")
public String hello(){


SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
return "Hello," + authentication.getName() + "!";
}

当使用正确的用户调用端点时,响应体会包含用户名。例如:

使用管理安全上下文的默认策略很容易,在很多情况下,这种策略也足够使用了。MODE_THREADLOCAL提供了为每个线程隔离安全上下文的能力,它是安全上下文更容易理解和管理。但是有些情况下,这并不适用。

如果必须处理每个请求的多个线程,情况就会变得更加复杂。看看如果让端点异步化会发生什么。即执行该方法的线程将不再是服务该请求的线程:

1
2
3
4
5
6
7
8
9
@GetMapping("/hello")
@Async
public String hello(){


SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
return "Hello," + authentication.getName() + "!";
}

为了启用@Async注解的功能,我们得在我们的配置类或者启动类上加上@EnableAsync注解它,如下所示:

1
2
3
4
5
6
7
@Configuration
@RequiredArgsConstructor
@EnableAsync
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter{


}

当然我们对于这三种策略,也可以使用Spring自带的线程池,在这里不做过多展示。

对于上面的程序,如果再次运行,我们会出现NPE异常,也就是如下代码出现异常:

1
String username = context.getAuthentication().getName()

这是因为该方法现在在另一个不继承安全上下文的线程上执行。因此,Authorization对象为null,最终导致NPR异常。在这种情况下,我们就可以使用MODE_INHERITABLETHREADLOCAL策略来解决这个问题。我们可以通过调用SecurityContextHolder.setStrategyName()方法或使用系统属性 spring.security.strategy来设置这个策略,框架就会知道要将请求的原始线程的详情复制到异步方法新创建的线程。

1
2
3
4
5
6
@Bean
public InitializingBean initializingBean(){


return ()-> SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}

如上代码,我们使用IntializingBean设置了SecurityContextHolder模式,调用端点时,我们将看到安全上下文已被Spring正确地传播到下一线程。此时,Authentication不再为null了。

不过,只有在框架本身创建线程时(例如我们之前使用的@Async),这种策略才有效,如果是通过代码直接创建线程,那么即使使用MODE_INHERITABLETHREADLOCAL策略,也会遇到同样的问题。这是因为,框架并不识别代码所创建的线程。后面我们会有对应的解决方案。

而第三种策略MODE_GLOBAL则是由应用程序的所有线程共享安全上下文,这种策略并不建议使用,因为它不符合程序的总体情况,它只适用于独立应用程序。

DelegatingSecurityContext(Runn/Call)able转发安全上下文

在学习这个之前,记得先把配置类中之前配置的策略给注释,否则会像作者一样陷入自己骗自己的场景,找了2小时bug

1
2
3
4
5
6
//    @Bean
// public InitializingBean initializingBean(){


// return ()-> SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
// }

之前的学习中我们已经了解了3种管理安全上下文的策略,但是他们有自己的弊端,就是框架只会确保为请求的线程提供安全上下文,并且该安全上下文仅可由该线程访问。但是框架并不关心新创建的线程(例如,异步方法所创建的线程)。所以我们必须为安全上下文的管理显式地设置另一种模式,但是仍然有一个疑问:当代码在框架不知道的情况下启动新线程时会发生什么?有时我们将这些线程称为自管理线程,因为管理它们是我们自己,而不是框架。

SpringContextHolder的无指定策略为我们提供了一个自管理线程的解决方案。在这种情况下,我们需要处理安全上下文转播。用于此目的的一种解决方案是使用DelegatingSecurityContext(Runn/Call)able装饰想要在单独线程上执行的任务通过类的名字不难猜出它扩展了Runnable/Callable,区别只是一个不存在返回值一个存在返回值。这两个类都代表异步执行的任务,就像其他任何Runnable和Callable一样,它们会确保为执行任务的线程复制到当前安全上下文。如下图,DelegatingSecurityContextCallable被设计成Callable对象的装饰器。在构建此类对象时,需要提供应用程序异步执行的可调用任务,并将安全上下文复制到新线程,然后执行任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@GetMapping("/giao")
public String giao() throws ExecutionException, InterruptedException {


//创建Callable任务,并将其作为任务在单独线程上执行
Callable<String> task = () ->{


SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
ExecutorService e = Executors.newCachedThreadPool();
try{


DelegatingSecurityContextCallable<String> contextTask = new DelegatingSecurityContextCallable<>(task);
return "giao, " + e.submit(contextTask).get() + "!";
}finally {


e.shutdown();
}
}

从代码中可以看到DelegatingSecurityContextCallable装饰了任务,它会将安全上下文提供给新线程。

现在调用端点,可以看到Spring将安全上下文传播到执行任务的线程:

DelegatingSecurityContextExecuorService转发安全上下文

之前我们已经学习了DelegatingSecurityContext(Runn/Call)able,这些类可以装饰异步执行的任务,并负责从安全上下文复制详细信息。不过还有第二个选项处理安全上下文,这就是从线程池而不是任务本身管理传播,这个处理方案就是DelegatingSecurityContextExecuorService,它的实现装饰了ExecutorService。DelegatingSecurityContextExecuorService还负责安全上下文的传播,如下图:

可以看到DelegatingSecurityContextExecuorService装饰了ExecutorService,并在提交任务之前将安全上下文详细信息传播给下一线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GetMapping("/miao")
public String miao() throws ExecutionException, InterruptedException {


Callable<String> task = () ->{


SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
ExecutorService e = Executors.newCachedThreadPool();
//通过DelegatingSecurityContextExecutorService装饰线程池
e = new DelegatingSecurityContextExecutorService(e);
try{


//在提交任务前DelegatingSecurityContextExecutorService会将安全上下文传播到执行此任务的线程
return "miao, " + e.submit(task).get() + "!";
}finally {


e.shutdown();
}
}

其实SpringSecurity提供了很多这样的对象管理安全上下文,具体如下表:

描述
DelegatingSecurityContextExecutor 实现Executor接口,并被设计用来装饰Executor对象,使其具有将安全上下文转发给由其线程池创建的线程的能力
DelegatingSecurityContextExecutorService 实现ExecutorService接口,并被设计用来装饰ExecutorService对象,使其具有将安全上下文转发给由其线程池创建的线程的能力
DelegatingSecurityContextScheduledExecutorService 实现ScheduledExecutorService接口,并被设计用来装饰ScheduledExecutorService对象,使其具有将安全上下文转发给由其线程池创建的线程的能力
DelegatingSecurityContextRunnable 实现Runnable接口,表示在另一个线程上执行而不返回响应的任务。除了常规Runnable所承担的职责之外,它还能够传播安全上下文,以便在新线程上使用
DelegatingSecurityContextCallable 实现Callable接口,表示在另一个线程上执行并最终返回响应的任务。除了常规Callable所承担的职责之外,它还能够传播安全上下文,以便在新线程上使用

可是细心的朋友们可能会发现,对于之前3种策略我们能使用Spring自带的线程池,但是对于DelegatingSecurityContextXXX,可以发现Spring自带的ThreadPoolTaskExecutor很难被“装饰”,就算是
最基础的DelegatingSecurityContextExecutor,你会发现没有一个合适的类去承载它,虽说ThreadPoolTaskExecutor也实现了Executor,也可以使用Executor去接收DelegatingSecurityContextExecutor装饰的ThreadPoolTaskExecutor,但是Executor并没有submit这些更好用的方法

1
? = new DelegatingSecurityContextExecutor(new ThreadPoolTaskExecutor();

但是我们平时工作用的做多的反而是Spring自带的线程池,我现在就是想用ThreadPoolTaskExecutor,那么该怎么解决呢?
我们就需要用到spring提供的TaskDecorator来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (this.taskDecorator != null) {


executor = new ThreadPoolExecutor(
this.corePoolSize, this.maxPoolSize, this.keepAliveSeconds, TimeUnit.SECONDS,
queue, threadFactory, rejectedExecutionHandler) {


@Override
public void execute(Runnable command) {


Runnable decorated = taskDecorator.decorate(command);
if (decorated != command) {


decoratedTaskMap.put(decorated, command);
}
super.execute(decorated);
}
};
}

TaskDecorator使用了装饰器模式,在初始化线程池的时候复写了线程池的execute方法

所以我们可以新建一个TaskDecorator类,复写decorate方法,设置安全上下文,这样就可以通过ThreadPoolTaskExecutor将安全上下文信息共享给其他线程。代码如下:

我们创建一个配置类,并且配置类中通过内部类形式配置好TaskDecorator,并将安全上下文放进去,记得最后一定要clearContext!

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
package com.mbw.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class ThreadPoolTaskConfig {


/** 核心线程数(默认线程数) */
private static final int CORE_POOL_SIZE = 20;
/** 最大线程数 */
private static final int MAX_POOL_SIZE = 100;
/** 允许线程空闲时间(单位:默认为秒) */
private static final int KEEP_ALIVE_TIME = 10;
/** 缓冲队列大小 */
private static final int QUEUE_CAPACITY = 200;
/** 线程池名前缀 */
private static final String THREAD_NAME_PREFIX = "mbw-Async-";
@Bean("taskExecutor") // bean的名称,默认为首字母小写的方法名
public ThreadPoolTaskExecutor taskExecutor(){


ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(QUEUE_CAPACITY);
executor.setKeepAliveSeconds(KEEP_ALIVE_TIME);
executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
executor.setTaskDecorator(runnable -> {


SecurityContext securityContext = SecurityContextHolder.getContext();
return () -> {


try {


SecurityContextHolder.setContext(securityContext);
runnable.run();
} finally {


SecurityContextHolder.clearContext();
}
};
});

// 线程池对拒绝任务的处理策略
// CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化
executor.initialize();
return executor;
}
}

然后我们直接使用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Resource
private ThreadPoolTaskExecutor taskExecutor;

@GetMapping("/miao")
public String miao() throws ExecutionException, InterruptedException {


Callable<String> task = () ->{


SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
try{


//在提交任务前DelegatingSecurityContextExecutorService会将安全上下文传播到执行此任务的线程
return "miao, " + taskExecutor.submit(task).get() + "!";
}finally {


taskExecutor.shutdown();
}
}

发现同样可以拿到信息

HttpBasic 和 登录表单

到目前为止,我们只使用了HTTP Nasic作为身份验证方法,它的身份验证方法很简单,我们前面的例子也拿他用于示例和演示,是一个非常不错的选择。但是出于同样的原因,它可能并不适合我们需要实现的所有现实场景。

本节将介绍与HTTP Basic相关的更多配置。此外,还将探究一种名为FormLogin的新身份验证方法。

使用和配置HTTP Basic

HTTP Basic身份验证提供的默认值就非常够用了。但是在更复杂的应用程序中,你可能会发现需要自定义其中一些设置。例如,我们可能想为身份验证过程失败的情况实现特定的逻辑。

首先我们来看一下如何设置HTTP Basic:
我们在我们的配置类通过扩展configure()设置HTTP Basic身份验证

1
2
3
4
5
6
7
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.httpBasic();
}

就这样几行代码就可以开启HTTP Basic身份验证,但是我们可以在其基础上对其追加配置,我们可以通过使用Customizer自定义失败身份验证的响应。如果在身份验证失败的情况下,系统的客户期望响应中有特定的内容,就需要这样做。我们可能需要添加或删除一个或多个头信息。或者可以使用一些逻辑来过滤主体信息,以确保应用程序不会向客户端公开任何敏感数据。

为了自定义失败身份验证的响应,可以实现AuthenticationEntryPoint。它的commence()方法会接收HttpServletRequest\HttpServletResponse和导致身份验证失败的AuthenticationException。如下代码我们就展示了如何实现AuthenticationEntryPoint的方法,该方法会向响应添加一个头信息,并将HTTP状态设置为401 Unauthorized.

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
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class CustomEntryPoint implements AuthenticationEntryPoint {


@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {


response.addHeader("message","LiuQing I'm your father");
response.sendError(HttpStatus.UNAUTHORIZED.value());

}
}

在身份验证失败时,AuthenticationEntryPoint接口的名称并没有反映其使用情况,这有点含糊不清。在Spring Security架构中,它由名称为ExceptionTranslationManager的组件直接使用,该组件会处理过滤链中抛出的任何AccessDeniedException和AuthenticationException异常。可以将ExceptionTranslationManager看作Java异常和HTTP响应之间的桥梁

然后可以使用配置类中的HTTP Basic方法注册CustomEntryPoint.如下代码展示了自定义入口点的配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@RequiredArgsConstructor
@EnableAsync
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


private final CustomEntryPoint customEntryPoint;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic(c->c.authenticationEntryPoint(customEntryPoint));
}

你会发现这种配置方式当你请求失败导致401时,例如密码错误,由于HttpBasic会识别你的错误方式导致抛出两次异常,第一个是因为你的错误请求原因抛出的异常,例如密码错误就是org.springframework.security.authentication.BadCredentialsException: Bad credentials

然后再次抛出认证异常,因为我们没有认证通过所以访问不了资源。

org.springframework.security.authentication.InsufficientAuthenticationException: Full authentication is required to access this resource

该异常和上面的BadCredentialsException均实现了AuthenticationException,ExceptionTranslationManager均会处理这两个异常,所以你会发现请求头被重复添加了两次:

这样显然是不对的,所以我们可以使用另一种处理方式,既然HTTP Basic会连续抛出两次异常,那我就不再Http Basic里配置,我将其抽出作为认证异常的统一处理,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@RequiredArgsConstructor
@EnableAsync
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


private final CustomEntryPoint customEntryPoint;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
http.exceptionHandling().authenticationEntryPoint(customEntryPoint).and()
.authorizeRequests()
.anyRequest().authenticated();
}

再次运行,你会发现只抛出了org.springframework.security.authentication.InsufficientAuthenticationException

然后被ExceptionTranslationManager处理,entryPoint当然也就只处理了一次,所以最后只添加了一次请求头。

当然这只是作者的解决方式,这种解决方法算是有点跑题,毕竟我们初衷是为了在Http Basic内部配置,如果大家有更好的解决方法可以在评论区分享。

使用基于表单的登录实现身份验证

在开发Web应用程序时,我们可能希望提供一个对用户友好的登陆表单,用户可以在其中输入他们的凭据。同样,我们可能希望通过身份验证的用户能够在登录后浏览Web页面并能够注销。对于小型Web应用程序,可以利用基于表单的登陆方法,但是对于需要水平可伸缩的大型应用程序而言,使用服务器端会话管理安全上下文是不可取的,这个在我们后面学习OAuth时会详细讨论这方面内容。

回到正题,我们先看下下图通过表单登录的主要流程图:

在使用Spring Security自己最基础的表单登录之前,记得先将之前配置过的entryPoint的代码给注释下,否则打不开Spring Security为我们提供的登录页面。目前登录路径我们先不由我们自己决定,我们先使用Spring Security为我们提供的页面。要将身份验证方法更改为基于表单的登录,可以在配置类的configure(HttpSecurity http)方法而非httpBasic()中,调用HttpSecurity参数的formLogin()方法。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {


//开启formlogin也可同时开启httpBasic身份验证
http.httpBasic();
http.exceptionHandling().authenticationEntryPoint(customEntryPoint).and()
.formLogin()
.and()
.authorizeRequests()
.anyRequest().authenticated();
}

启动应用程序并且现在访问我们一个接口,会发现它将我们重定向到一个登陆页面

只要没注册UserDetailsService,就可以使用所提供的默认凭据进行登录。即之前提到过的user和那串控制台的UUID。

然后我们可以访问/logout路径,则SpringSecurity会将我们重定向到注销页面

尝试在未登录的情况下访问路径后,用户将被自动重定向到登录页面。成功登录后,应用程序会将用户重定向回他们最初试图访问的路径。如果该路径不存在,应用程序将显示一个默认错误页面,该页面是error.html。

formLogin()方法会返回类型为FormLoginConfigurer< HttpSecurity>的对象,该对象允许我们进行自定义。例如,可以通过调用defaultSuccessUrl()方法来实现这一点,如下代码:
首先我们定义一个Controller,注意注解不是@RestController,而是@Controller,因为我们需要重定向到一个页面。

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


@GetMapping("/home")
public String home(){


return "home";
}
}

然后准备该主页:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Welcome</h1>
</body>
</html>

然后在配置类中配置登陆成功自动重定向的成功页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
 @Override
protected void configure(HttpSecurity http) throws Exception {


//开启formlogin也可同时开启httpBasic身份验证
http.httpBasic();
http.exceptionHandling().authenticationEntryPoint(customEntryPoint).and()
.formLogin()
.defaultSuccessUrl("/home",true)
.and()
.authorizeRequests()
.anyRequest().authenticated();
}

然后我们访问localhost:9090/login,登陆成功后发现自动重定向到home.html:

如果需要就此进行更深入的处理,可以使用AuthenticationSuccessHandler和AuthenticationFailureHandler对象所提供的更详细的自定义方法。这两个接口允许实现一个对象,通过该对象可以应用为身份验证而执行的逻辑。

如果希望自定义成功身份验证的逻辑,则可以自定义AuthenticationSuccessHandler.onAuthenticationSuccess()方法会接收servlet请求、servlet响应和Authentication对象作为参数。这一点在后面学习jwt我们常常会在这个类对我们的token作相关处理,所以这个类是很重要的。具有非常大的灵活性,但是我们目前只是举一个例子:
例如下面该类认证成功后验证用户所拥有的权限是否有read权限,然后做出相应的接口调用

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
@Component
@Slf4j
public class CommonLoginSuccessHandler implements AuthenticationSuccessHandler {


@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {


Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Optional<? extends GrantedAuthority> auth = authorities.stream().filter(a -> a.getAuthority().equals("read")).findFirst();
if(auth.isPresent()){


log.info("您有足够的权限访问此资源");
response.sendRedirect("/user/hello");
}else {


log.info("您没有足够的权限访问此资源");
response.sendRedirect("/user/error");
}

}
}

然后既然有AuthenticationSuccessHandler,自然有对应的AuthenticationFailureHandler,对于这个Handler,我们也仍然是简单的打印一句话然后增加一个请求头:

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
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;

@Component
@Slf4j
public class CommonLoginFailureHandler implements AuthenticationFailureHandler {


@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {


log.warn("认证失败");
response.setHeader("failed", LocalDateTime.now().toString());

}
}

然后将它们通过@Component交由Spring管理并在配置类注入并配置它们

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
@Configuration
@RequiredArgsConstructor
@EnableAsync
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


private final UserDetailsServiceImpl commonUserDetailServiceImpl;
private final CustomEntryPoint customEntryPoint;
private final CommonLoginSuccessHandler successHandler;
private final CommonLoginFailureHandler failureHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {



http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and()
.exceptionHandling().authenticationEntryPoint(customEntryPoint).and()
.httpBasic().and()
.authorizeRequests()
// 登录、验证码允许匿名访问
.anyRequest().authenticated();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(commonUserDetailServiceImpl)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}

然后分别尝试认证成功和认证失败:

首先认证成功有read权限:

然后是认证成功但是没有read权限:

最后是认证失败:

打开F12我们可以看到我们追加的回应头

日志中也有认证失败:

那么至此basic和formLogin我们差不多有了一个大概的认识,后面我们会在前后端分离中去深入了解。这一节动手的比较多,大家可以自己实践一下,碰到bug可以自己先尝试解决。细心的朋友可能发现我的userDetailsService换了个类,这是我为后面的权限做了一个全新的模块,我想的是将业务的User和UserDetailsService给分离开来,我们在实际开发更多的场景也是如此,所以从下一章开始我将会重新带着大家布置一个新模块。

权限的配置

市面上很多教程都说Spring Security是一个解决身份验证、授权管理的安全框架,截至目前,我们只讨论了身份验证,正如之前介绍过的,身份验证是应用程序标识资源的调用者的过程。前面的学习中我们并没有实现任何决定是否批准请求的规则。其中仅关注了系统是否知道该用户,即productConfig中的anyRequest,permitAll/authenticated.在大多数应用程序中,并非系统识别出的所有用户都能访问系统中的每个资源,这就牵扯出授权,授权是系统决定已识别的客户端是否有权限访问所请求得资源得过程

在Spring Security中,一旦应用程序结束身份验证流程,它就会将请求委托给一个授权过滤器。该过滤器会根据所配置的授权规则来允许或拒绝请求

这里将按照以下步骤讨论授权得所有必要细节。

  • 了解权限是什么,并基于用户的权限对所有端点应用访问规则。
  • 了解如何按角色对权限进行分组,以及如何基于用户的角色应用授权规则。

另外的,本节将涉及到有关RBAC的管理以及职责分离,所以我将带着大家重新写一个项目。当然关于RBAC的写法我浏览了很多项目,其实有非常多的写法,只要类似user管理role,role管理权限这样的基本都能算RBAC,但是考虑到SpringSecurity将role和authority归并到一起,只是通过ROLE_去区分角色和权限(这些后面都会再次提及,目前保留一个概念即可),所以我将使用自己理解的一种RBAC的管理去编码,大家也可以去gitee找到适合自己的方法去写,条条大路通罗马。

基于权限和角色限制访问

1、基于权限

这里将介绍授权和角色的概念。可以使用它们保护应用程序得所有端点。其中不同的用户具有不同的角色,不同的角色具有不用的权限,但是角色和权限均视为用户所拥有的权利,根据用户拥有的权利,他们只能执行特定的操作。应用程序会将权利作为权限和角色来提供

我们在之前的学习中对于单独的Authority类实现了GrantedAuthority接口,但是并没有详细去使用,只是实现了它的getAuthority()方法,将权限名属性赋予这个get方法。那么从现在我们需要研究它的用途,下图展示了UserDetails契约和GrantedAuthority接口之间的关系。

我们可以从图中了解到权限就是用户可以使用系统资源执行的操作。一个权限具有一个名称,对象的getAuthority()行为会将该名称作为String返回。在定义自定义授权规则时,可以使用权限的名称。授权规则通常是“Jane被允许删除(delete)产品记录”或“John被允许读取(read)文档记录”。在这种情况下,删除和读取就是被授予的权限

1
2
3
4
5
public interface GrantedAuthority extends Serializable {


String getAuthority();
}

UserDetails是Spring Security中描述用户的接口,它有一个GrantedAuthority实例的集合,如上图所示。可以允许用户拥有一个或多个权限。getAuthorities()方法会返回GrantedAuthority()实例的集合。可以在如下代码中查看这个方法。之所以实现这个方法,是为了让它返回授予用户的所有权限。身份验证结束之后,权限就会已登录用户的详细信息的一部分,应用程序可以使用它授予权限。

1
2
3
4
5
public interface UserDetails extends Serializable {


Collection<? extends GrantedAuthority> getAuthorities();
}

那么通常呢,我们将这个权限定义为Authority,这里需要和后面的role区分开,但是在实际开发中,我们更多的看见的一般是菜单,菜单其实类似于我们的Authority,只是结构变为更加复杂的树形结构,也更便于区分,但是意义和Authority是一样的

2、基于角色

角色是表示用户做什么的另一种方式。SpringSecurity将权限视为对其应用限制的细粒度权利。角色就像是用户的徽章。它们为用户提供一组操作的权利。有些应用程序总是为特定用户提供相同的权限组

为角色提供的名称与为权限提供的名称类似,这取决于我们自己。与权限相比,可以认为角色是细粒度的。无论如何,在后台,角色都是使用Spring Security中的相同接口表示的,即GrantedAuthority。在定义角色时,其名称应该以ROLE_前缀开头。在实现层面,这个前缀表明了角色和权限之间的区别

如上图,如果在某个应用程序中,用户要么只有读写权限,要么拥有所有权限(crud),在这种情况下,我们就可以为其创建两个用户去分别管理它所对应的权限组,而一个用户可以同时拥有一个或多个角色,一个角色也对应一个或多个权限,这种架构我们就称为RBAC管理架构。

那么下面我们就开始进行新项目的编码并且对关于Spring Security中的角色权限的相关接口进行更加细致的学习。

项目搭建

我们建造一个新的maven项目,maven依赖如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring_security_parent</artifactId>
<groupId>com.mbw</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>spring_security_simple_web02</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.56</version>
</dependency>
<!--为yml自定义属性自动生成提示-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
</dependencies>
</project>

然后yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
port: 9090
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: 123456
url: jdbc:mysql://127.0.0.1:3306/spring_security?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
mybatis-plus:
mapper-locations: classpath:/mapper/**/*.xml,classpath:/META-INF/modeler-mybatis-mappings/*.xml
typeAliasesPackage: com.mbw.pojo
global-config:
banner: false
configuration:
map-underscore-to-camel-case: false
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

接着我们需要创建users,roles,authorities,以及中间表,user_role,role_authority

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
-- 

--------
-- Table structure for users
--

--------
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id bigint(20) NOT NULL,
username varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
password varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
mobile varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
email varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
enabled tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of users
--

--------
INSERT INTO users VALUES (1580099436639481858, '张飞', '{bcrypt}$2a$10$QQ9JPjhBcX1XVwhlAmYy5.57Im4c6tGYZh./cGZwS2LqIFCjv4Qke', '18576345294', '1485924969@qq.com', 1);
INSERT INTO users VALUES (1583381616086003713, '刘备', '{bcrypt}$2a$10$PhvJ9iXj/IQw35mHA.c1TevBXiZ7toWafQfOIGITfv8vM9m75Sbay', '18170075966', '1485924969@qq.com', 1);

--

--------
DROP TABLE IF EXISTS roles;
CREATE TABLE roles (
id bigint(20) NOT NULL,
roleName varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of roles
--

--------
INSERT INTO roles VALUES (1580101763882618881, '管理员');
INSERT INTO roles VALUES (2259225272127586304, 'ADMIN');
INSERT INTO roles VALUES (2299388013789372417, 'USER');

DROP TABLE IF EXISTS authorities;
CREATE TABLE authorities (
id bigint(20) NOT NULL,
authorityName varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of authorities
--

--------
INSERT INTO authorities VALUES (2970710450550485029, 'read');
INSERT INTO authorities VALUES (2971247443118264330, 'write');

DROP TABLE IF EXISTS user_role;
CREATE TABLE user_role (
id bigint(20) NOT NULL,
roleId bigint(20) NULL DEFAULT NULL,
userId bigint(20) NULL DEFAULT NULL,
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of user_role
--

--------
INSERT INTO user_role VALUES (1580099436769505282, 2299388013789372417, 1580099436639481858);
INSERT INTO user_role VALUES (1583381616245387266, 1580101763882618881, 1583381616086003713);
INSERT INTO user_role VALUES (1583381616354439170, 2299388013789372417, 1583381616086003713);

DROP TABLE IF EXISTS role_authority;
CREATE TABLE role_authority (
id bigint(20) NOT NULL,
roleId bigint(20) NOT NULL COMMENT '角色Id',
authorityId bigint(20) NOT NULL COMMENT '权限Id',
PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

--

--------
-- Records of role_authority
--

--------
INSERT INTO role_authority VALUES (1580101764591456258, 1580101763882618881, 2970710450550485029);
INSERT INTO role_authority VALUES (1580101764771811329, 2299388013789372417, 2970710450550485029);
INSERT INTO role_authority VALUES (1580118642791585029, 1580101763882618881, 2971247443118264330);

然后创建实体类,这里我打算使用职责分离对我们实际开发中的User和SpringSecurity识别的User即UserDetails进行分离,也就是说我们的UserDetails可以封装我们的User类,但是我们的User和UserDetails实际上是没有关联的,User就是User.那么Role,Authority同理,他们也和GrantedAuthority没有关系,而GrantedAuthority届时会封装Authority类中的authorityName和ROLE_(Role类中的roleName)。

那么这边我们创建分离后的User,Role,Authority以及他们的中间关系实体类:

User.java

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
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;

import java.io.Serializable;
import java.util.Set;

@TableName("users")
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class User implements Serializable {


@TableId(type=IdType.ASSIGN_ID)
private Long id;
@TableField("username")
private String username;
@TableField("mobile")
private String mobile;
@TableField("password")
private String password;
@TableField("email")
private String email;
@TableField("enabled")
private Boolean enabled;
@TableField(exist = false)
private Set<Role> roles;
/**
* 图片验证码
*/
@TableField(exist = false)
private String captcha;

/**
* uuid
*/
@TableField(exist = false)
private String uuid;
}

Role.java

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
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Set;

@TableName("roles")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Role {


@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField("roleName")
private String roleName;
@TableField(exist = false)
private Set<Authority> authorities;
}

Authority.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@TableName("authorities")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Authority {


@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField("authorityName")
private String authorityName;
}

UserRole.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@TableName("user_role")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRole {


@TableId(type= IdType.ASSIGN_ID)
private Long id;
@TableField("userId")
private Long userId;
@TableField("roleId")
private Long roleId;
}

RoleAuthority.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.mbw.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@TableName("role_authority")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RoleAuthority {


@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField("roleId")
private Long roleId;
@TableField("authorityId")
private Long authorityId;
}

然后是mapper
UserMapper.java
主要是检查用户名/手机号是否唯一方法以及通过用户名查询用户方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.mbw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mbw.pojo.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {


User queryUserByUsername(String username);
User checkUsernameUnique(String userName);
User checkPhoneUnique(String phone);
}

UserMapper.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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mbw.mapper.UserMapper">
<resultMap id="queryUserMap" type="com.mbw.pojo.User" autoMapping="true">
<id column="id" property="id"/>
<collection property="roles" ofType="com.mbw.pojo.Role" autoMapping="true" columnPrefix="r_">
<id column="id" property="id"/>
<result column="roleName" property="roleName"/>
</collection>
</resultMap>
<select id="queryUserByUsername" resultMap="queryUserMap">
SELECT u.*,
r.id AS r_id,
r.roleName AS r_roleName
from users u
LEFT JOIN user_role ur
ON u.id = ur.userId
LEFT JOIN roles r
ON r.id = ur.roleId
WHERE u.username ={username}
AND u.enabled != 0
</select>
<select id="checkUsernameUnique" resultType="com.mbw.pojo.User">
select u.id,u.username from users u where u.username ={username} limit 1
</select>
<select id="checkPhoneUnique" resultType="com.mbw.pojo.User">
select u.id,u.username from users u where u.mobile ={mobile} limit 1
</select>
</mapper>

RoleMapper.java

主要有查询全部权限以及根据用户名获取所有权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.mbw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mbw.pojo.Role;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

@Mapper
public interface RoleMapper extends BaseMapper<Role> {


List<Role> queryAllRoleByRoleName();
/**
* 根据用户名获取角色
*
* @param username
* @return List<SysRole>
*/
List<Role> loadRolesByUsername(@Param("username") String username);

}

RoleMapper.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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mbw.mapper.RoleMapper">
<resultMap id="queryRoleMap" type="com.mbw.pojo.Role">
<id property="id" column="id"/>
<result property="roleName" column="roleName"/>
<collection property="authorities" ofType="com.mbw.pojo.Authority" autoMapping="true" columnPrefix="a_">
<id property="id" column="id"/>
<result property="authorityName" column="authorityName"/>
</collection>
</resultMap>
<select id="queryAllRoleByRoleName" resultType="com.mbw.pojo.Role">
SELECT r.*,
a.id AS a_id,
a.authorityName AS a_authorityName
FROM roles r
LEFT JOIN role_authority ra ON r.id = ra.roleId
LEFT JOIN authority a ON a.id = ra.authorityId
</select>
<select id="loadRolesByUsername" resultType="com.mbw.pojo.Role">
select r.*
from roles r,
user_role ur,
users u where r.id = ur.roleId and u.id = ur.userId
and u.username ={username}
</select>
</mapper>

AuthorityMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.mbw.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mbw.pojo.Authority;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Set;

@Mapper
public interface AuthorityMapper extends BaseMapper<Authority> {


/**
* 通过角色名称list查询菜单权限
*/
List<Authority> loadPermissionByRoleCode(@Param("roleInfos") Set<String> roleInfos);
}

AuthorityMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mbw.mapper.AuthorityMapper">
<resultMap type="com.mbw.pojo.Authority" id="SysMenuMap">
<result property="id" column="id" />
<result property="authorityName" column="authorityName" />
</resultMap>
<select id="loadPermissionByRoleCode" resultMap="SysMenuMap">
select
a.id,a.authorityName
from authorities a
left join role_authority ra on a.id = ra.authorityId
left join roles r on r.id = ra.roleId
where r.roleName in
<foreach collection="roleInfos" item="roleInfo" open="(" separator="," close=")">
{roleInfo}
</foreach>
</select>
</mapper>

中间关系实体类的mapper没有什么方法,大家自己写一下,实现下BaseMapper即可,这里不做过多展示。

然后是Service,这里同样也体现了职责分离,即UserService和UserDetailsService,意义同之前的User和UserDetails
UserService.java

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
package com.mbw.service;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mbw.common.utils.Result;
import com.mbw.common.utils.UserConstants;
import com.mbw.mapper.RoleMapper;
import com.mbw.mapper.UserMapper;
import com.mbw.mapper.UserRoleMapper;
import com.mbw.pojo.Role;
import com.mbw.pojo.User;
import com.mbw.pojo.UserRole;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.stream.Stream;

@Service
public class UserService extends ServiceImpl<UserMapper, User> {



@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Autowired
private PasswordEncoder passwordEncoder;

public User getUserByName(String userName) {


return userMapper.queryUserByUsername(userName);
}

public String checkPhoneUnique(User user) {


Long userId = ObjectUtil.isEmpty(user.getId()) ? -1: user.getId();
User info = userMapper.checkPhoneUnique(user.getMobile());
if (ObjectUtil.isNotEmpty(info) && !info.getId().equals(userId))
{


return UserConstants.USER_PHONE_NOT_UNIQUE;
}
return UserConstants.USER_PHONE_UNIQUE;
}

public String checkUserNameUnique(User user) {


Long userId = ObjectUtil.isEmpty(user.getId()) ? -1: user.getId();
User info = userMapper.checkUsernameUnique(user.getUsername());
if (ObjectUtil.isNotEmpty(info) && !info.getId().equals(userId))
{


return UserConstants.USER_NAME_NOT_UNIQUE;
}
return UserConstants.USER_NAME_UNIQUE;
}

public Result createUser(User user) {


Set<Role> roles = user.getRoles();
if(CollUtil.isNotEmpty(roles)){


String passwordNotEncode = user.getPassword();
String passwordEncode = passwordEncoder.encode(passwordNotEncode);
user.setPassword(passwordEncode);
userMapper.insert(user);
Stream<Long> roleIds = roles.stream().map(Role::getId);
roleIds.forEach(roleId->{


Role role = roleMapper.selectById(roleId);
if(role != null){


Long userId = user.getId();
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(roleId);
userRoleMapper.insert(userRole);
}
});
return Result.ok().message("添加成功");
}

return Result.error().message("添加失败");
}
}

RoleService.java

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
package com.mbw.service;

import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mbw.mapper.AuthorityMapper;
import com.mbw.mapper.RoleAuthorityMapper;
import com.mbw.mapper.RoleMapper;
import com.mbw.pojo.Authority;
import com.mbw.pojo.Role;
import com.mbw.pojo.RoleAuthority;
import org.apache.ibatis.annotations.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;
import java.util.stream.Stream;

@Service
public class RoleService extends ServiceImpl<RoleMapper, Role> {


@Autowired
private RoleMapper roleMapper;
@Autowired
private AuthorityMapper authorityMapper;
@Autowired
private RoleAuthorityMapper roleAuthorityMapper;

public List<Role> queryAllRoleByRoleName(){


return roleMapper.queryAllRoleByRoleName();
}

public void saveRole(Role role){


Set<Authority> authorities = role.getAuthorities();
if(CollUtil.isNotEmpty(authorities)){


Stream<Long> authorityIds = authorities.stream().map(Authority::getId);
roleMapper.insert(role);
authorityIds.forEach(authorityId->{


Authority authority = authorityMapper.selectById(authorityId);
if(authority != null){


RoleAuthority roleAuthority = new RoleAuthority();
roleAuthority.setRoleId(role.getId());
roleAuthority.setAuthorityId(authorityId);
roleAuthorityMapper.insert(roleAuthority);
}
});
}
}

public List<Role> loadRolesByUsername(String username){


return roleMapper.loadRolesByUsername(username);
}

}

然后是controller
UserController.java

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
package com.mbw.controller;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.mbw.common.utils.Result;
import com.mbw.common.utils.UserConstants;
import com.mbw.pojo.User;
import com.mbw.security.utils.SecurityUtil;
import com.mbw.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.concurrent.DelegatingSecurityContextCallable;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.Objects;
import java.util.concurrent.*;

@RestController
@RequestMapping("/user")
public class UserController {


@Autowired
private UserService userService;

@PostMapping("/create")
public Result createUser(@RequestBody User user){


if (UserConstants.USER_PHONE_NOT_UNIQUE.equals(userService.checkPhoneUnique(user))){


return Result.error().message("手机号已存在");
}
if (UserConstants.USER_NAME_NOT_UNIQUE.equals(userService.checkUserNameUnique(user))){


return Result.error().message("用户名已存在");
}
return userService.createUser(user);
}

@GetMapping("/hello")
public String hello(){


SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
return "Hello," + authentication.getName() + "!";
}

@GetMapping("/error")
public String error(){


return "403 error";
}
}

这里需要用到一个UserConstants常量类以及一个结果类Result:
UserConstants.java

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
package com.mbw.common.utils;
/**
* 用户常量信息
*/
public class UserConstants {


/**
* 岗位名称是否唯一的返回结果码
*/
public final static String JOB_NAME_UNIQUE = "0";
public final static String JOB_NAME_NOT_UNIQUE = "1";

/**
* 用户名名称是否唯一的返回结果码
*/
public final static String USER_NAME_UNIQUE = "0";
public final static String USER_NAME_NOT_UNIQUE = "1";

/**
* 部门名称是否唯一的返回结果码
*/
public final static String DEPT_NAME_UNIQUE = "0";
public final static String DEPT_NAME_NOT_UNIQUE = "1";

/**
* 手机号码是否唯一的返回结果
*/
public final static String USER_PHONE_UNIQUE = "0";
public final static String USER_PHONE_NOT_UNIQUE = "1";

/**
* 是否唯一的返回结果
*/
public final static String UNIQUE = "0";
public final static String NOT_UNIQUE = "1";

/**
* 部门停用状态
*/
public static final String DEPT_DISABLE = "0";

/**
* 部门正常状态
*/
public static final String DEPT_NORMAL = "1";

/**
* 全部数据权限
*/
public static final String DATA_SCOPE_ALL = "1";

/**
* 自定数据权限
*/
public static final String DATA_SCOPE_CUSTOM = "2";

/**
* 部门数据权限
*/
public static final String DATA_SCOPE_DEPT = "3";

/**
* 部门及以下数据权限
*/
public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";

/**
* 仅本人数据权限
*/
public static final String DATA_SCOPE_SELF = "5";

/**
* 数据权限过滤关键字
*/
public static final String DATA_SCOPE = "dataScope";
public static final String LOGIN_TYPE_JSON = "JSON";
public static final String TOKEN_PREFIX = "Bearer ";

public static final String TOKEN_REDIS_KEY = "login_token_key:";

public static final String CAPTCHA_CODE_KEY = "captcha_code_key:";

public static final String TOKEN_KEY = "token_key";

public static final String UNKNOWN_IP = "XX XX";

public static final String APPLICATION_JSON_UTF8_VALUE = "application/json;charset=UTF-8";
}

Result.java

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
package com.mbw.common.utils;

import lombok.Data;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
* 统一返回结果的类
*/
@Data
public class Result<T> implements Serializable {



private Boolean success;

private Integer code;

private String msg;

private Long count;

private List<T> data = new ArrayList<T>();

private String jwt;
/**
* 把构造方法私有
*/
private Result() {

}


/**
* 成功静态方法
* @return
*/
public static Result ok() {


Result r = new Result();
r.setSuccess(true);
r.setCode(ResultCode.SUCCESS);
r.setMsg("成功");
return r;
}
/**
* 失败静态方法
* @return
*/
public static Result error() {


Result r = new Result();
r.setSuccess(false);
r.setCode(ResultCode.ERROR);
r.setMsg("失败");
return r;
}

public static Result judge(int n,String msg){


return n > 0 ? ok().message(msg + "成功") : error().message(msg +"失败");
}

public Result success(Boolean success){


this.setSuccess(success);
return this;
}

public Result message(String message){


this.setMsg(message);
return this;
}

public Result code(Integer code){


this.setCode(code);
return this;
}
public Result data(List<T> list){


this.data.addAll(list);
return this;
}
public Result count(Long count){


this.count = count;
return this;
}
public Result jwt(String jwt){


this.jwt = jwt;
return this;
}
}

RoleController.java

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
package com.mbw.controller;

import com.mbw.pojo.Role;
import com.mbw.service.RoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/role")
public class RoleController {


@Autowired
private RoleService roleService;

@PostMapping("/create")
public void createRole(@RequestBody Role role){


roleService.saveRole(role);
}
}

HomeController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.mbw.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {


@GetMapping("/home")
public String home(){


return "home";
}

@GetMapping("/error")
public String error(){


return "error";
}
}

上面的准备工作做完后,就可以开始Security层面的编码工作了
首先我们建造Security识别的用户JwtUserDto类,将这个类实现UserDtails接口。

JwtUserDto.java

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
package com.mbw.security.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.mbw.pojo.Role;
import com.mbw.pojo.User;
import lombok.Data;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;

@Data
@ToString
public class JwtUserDto implements UserDetails {



/**
* 用户唯一标识
*/
private String token;

/**
* 登陆时间
*/
private Long loginTime;

/**
* 过期时间
*/
private Long expireTime;
private User user;
private Set<Role> roleInfo;
/**
* 用户权限的集合
*/
@JsonIgnore
private List<String> authorityNames;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {


ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorityNames.forEach(authorityName->authorities.add(new SimpleGrantedAuthority(authorityName)));
return authorities;
}

@Override
public String getPassword() {


return user.getPassword();
}

@Override
public String getUsername() {


return user.getUsername();
}

@Override
public boolean isAccountNonExpired() {


return true;
}

@Override
public boolean isAccountNonLocked() {


return true;
}

@Override
public boolean isCredentialsNonExpired() {


return true;
}

@Override
public boolean isEnabled() {


return user.getEnabled();
}
public JwtUserDto(User user, Set<Role> roleInfo, List<String> authorityNames) {


this.user = user;
this.roleInfo = roleInfo;
this.authorityNames = authorityNames;
}
}

首先token,loginTime,expireTime可以先不管
我们可以看到我们的User类,然后通过Set封装Role代表用户所拥有的所有角色,List封装了authorityNames代表SpringSecurity识别的所有权限名,所以这里泛型是String,注意这里是SpringSecurity识别的所有权限名,所以既有权限名,也有带着ROLE_前缀的角色名,并且通过一个构造方法通过他们构造出一个UserDetails.
然后就是一定要实现的getAuthorities(),这里我的想法是既然我将authorityNames这个属性代表了所有的角色名和权限名,那么我完全可以通过该属性去实现该方法,通过Stream流的方式将authorityNames的每个权限名封装进new出来的SimpleGrantedAuthority,那我们都知道SimpleGrantedAuthority是Authority的实现类。所以该方法自然能够通过。

那么看到这儿你可能会有疑问,怎么将Authority类的authorityName和Role的roleName联系起来并放进这个AuthorityNames属性呢,这个其实很简单,我们可以通过UserDetailsService搞定它:
UserDetailsServiceImpl.java

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
package com.mbw.security.service;

import cn.hutool.core.util.StrUtil;
import com.mbw.mapper.AuthorityMapper;
import com.mbw.pojo.Authority;
import com.mbw.pojo.Role;
import com.mbw.pojo.User;
import com.mbw.security.dto.JwtUserDto;
import com.mbw.service.RoleService;
import com.mbw.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {


@Autowired
private UserService userService;

@Autowired
private RoleService roleService;

@Autowired
private AuthorityMapper authorityMapper;

@Override
public JwtUserDto loadUserByUsername(String username) throws UsernameNotFoundException {


// 根据用户名获取用户
User user = userService.getUserByName(username);
if (user == null ){


throw new BadCredentialsException("用户名或密码错误");
}
List<Role> roles = roleService.loadRolesByUsername(username);
Set<String> roleInfos = roles.stream().map(Role::getRoleName).collect(Collectors.toSet());
List<Authority> authorities = authorityMapper.loadPermissionByRoleCode(roleInfos);
List<String> authorityNames = authorities.stream().map(Authority::getAuthorityName).filter(StrUtil::isNotEmpty).collect(Collectors.toList());
authorityNames.addAll(roleInfos.stream().map(roleName->"ROLE_"+roleName).collect(Collectors.toList()));
return new JwtUserDto(user, new HashSet<>(roles), authorityNames);
}
}

可以看到我们之前Mapper定义的通过用户名,角色名查询角色,权限的接口就有了用处,我们查出了角色名后通过Stream流的方式将roleName加上ROLE_前缀后加入到authorityNames集合中,这样就完成了之前的问题。我们目前只要登陆就可以获取到他的角色名,权限名。

然后我们在写上我们之前学习过的SuccessHandler和FailtureHandler
CommonLoginSuccessHandler.java
这里我只打印了一下获取的所有权限名

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
package com.mbw.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
public class CommonLoginSuccessHandler implements AuthenticationSuccessHandler {


@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {


log.info(authentication.getAuthorities().toString());

}
}

CommonLoginFailureHandler.java
CommonLoginFailureHandler逻辑和之前学习的一致

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
package com.mbw.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;

@Component
@Slf4j
public class CommonLoginFailureHandler implements AuthenticationFailureHandler {


@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {


log.warn("认证失败");
response.setHeader("failed", LocalDateTime.now().toString());

}
}

然后写完配置类即可:
SpringSecurityConfig.java

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
package com.mbw.security.config;

import com.mbw.handler.CommonLoginFailureHandler;
import com.mbw.handler.CommonLoginSuccessHandler;
import com.mbw.security.service.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;

@Configuration
@RequiredArgsConstructor
@EnableAsync
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


private final UserDetailsServiceImpl commonUserDetailServiceImpl;
private final CommonLoginSuccessHandler successHandler;
private final CommonLoginFailureHandler failureHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {



http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
.anyRequest().authenticated();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(commonUserDetailServiceImpl)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
}

然后写完启动类测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.mbw;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@EnableWebSecurity
@SpringBootApplication
public class SecurityApplication {


public static void main(String[] args) {


SpringApplication.run(SecurityApplication.class,args);
}
}

打开项目,访问个接口/user/hello,被重定向到登录页面,我们登录张飞这个用户,张飞这个用户的角色是User,只拥有读权限:
登陆后看日志,发现权限确实是这样:

至此我们就完成了项目搭建,并且也完成了RBAC的管理。将User和UserDetails分离开来,并且按照SpringSecurity的规则将role和Authority封装到一起,那么下一节我们在该项目架构上将学习授权的详细内容。记住,这个架构大家可以看作实际开发的超级简化版,该架构在未来还有非常多需要改进的地方,但是希望大家能够吸收该架构的一个基础的意识,当然大家有更好的意见也可以在评论区进行分享。

最后呢,我们对我们的认证成功的类进行下改进
CommonLoginSuccessHandler.java

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
package com.mbw.handler;

import com.mbw.security.dto.JwtUserDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.Optional;

@Component
@Slf4j
public class CommonLoginSuccessHandler implements AuthenticationSuccessHandler {


@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {


Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Optional<String> auth = authorities.stream().map(GrantedAuthority::getAuthority).filter(a -> a.equals("read")).findFirst();
log.info("auth:{}",authorities);
if(auth.isPresent()){


log.info("您有足够的权限访问此资源");
response.sendRedirect("/user/hello");
}else {


log.info("您没有足够的权限访问此资源");
response.sendRedirect("/user/error");
}

}
}

关于前端页面,大家随便写写就行,主要将名字定义为home.html和error.html即可,那么这节就先到这里,代码内容比较多,希望大家好好理解

在配置类中对请求进行权限限制

2.1、使用hasAuthority()和hasAnyAuthority()

记住,这一小节,我们是在配置类中进行配置,如果没有配置特定路径,是不能局部到接口上的,等后面的学习我们可以通过注解实现在接口级别上完成权限验证,那这里我们先通过配置类的方式实现权限控制。

我们在上一节中创建数据库的时候就已经创建了两个用户:刘备和张飞,其中刘备的角色是管理员和USER,拥有读写权限,张飞只是USER,只拥有读权限。

那么我们现在在配置类要求除了/login,/user/create以外其他所有请求都要write权限,该怎么去实现呢?
很简单,我们可以通过hasAuthority()和hasAnyAuthority()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(HttpSecurity http) throws Exception {



http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
.anyRequest().hasAuthority("write");
}

我们可以将允许用户使用的权限名称作为hasAuthority()方法的参数来提供。应用程序首先需要对请求进行身份验证,然后根据用户的权限决定是否允许调用

然后通过张飞登录,首先张飞登录成功后会先通过successHandler去验证他的权限,发现有read权限,转至/user/hello.而这个接口又被我们配置类中写的hasAuthority拦截检查是否有write权限访问,然后发现张飞并没有write权限,所以报error,重定向springSecurity默认错误接口/error,即我们写的error.html:

我们从开发者工具也可看到张飞在请求/user/hello接口时由于没有足够的权限报了403 Forbidden.
可以通过类似方式使用hasAnyAuthority()方法。该方法具有参数varargs:这样,它就可以接收多个权限名称。如果用户拥有作为方法参数提供的至少一个权限,应用程序就会允许其请求

例如我们将上例的hasAuthority(“write”)改为hasAnyAuthority(“write”,“read”),这样,张飞的请求就将被接受:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(HttpSecurity http) throws Exception {


http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
// 登录、验证码允许匿名访问
.anyRequest().hasAnyAuthority("read","write");
}

2.2、使用access表达式

要根据用户权限指定访问权限,可以在实践中应用的第三种方法是access()方法。不过,access()方法更为通用。它会接收一个指定授权条件的Spring表达式(SpEL)作为参数。这种方法很强大,并且它不仅只适用于权限方面。然而与此同时增加了阅读和理解难度,出于这个原因,建议将它作为最后考虑选项,即仅当不能应用前面介绍的hasAuthority()和hasAnyAuthority()才使用它。

那么下面我们展示access方法配置端点访问,这里大家留意将之前的hasAuthority()和hasAnyAuthority()进行对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected void configure(HttpSecurity http) throws Exception {



http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
// 登录、验证码允许匿名访问
.anyRequest().access("hasAuthority('write')");
}

上面代码其实等价于之前的

1
hasAuthority('write')

我们会发现如果将access()方法用于简单的需求,就会将语法变得复杂,这时还不如使用之前讲的两个方法。但是access()并非一无是处,我们现在在db中给authorities表增加2种权限,update和delete,然后将四个权限全部赋予ADMIN这个角色,然后增加一个用户我们取名叫关羽,给他赋予ADMIN角色。

然后看下面的配置代码,我们假设要求拥有读权限但是同时不能让有删除权限的用户访问资源,这时通过普通的hasAuthority或者hasAnyAuthority()很难解决,我们可以通过access表达式解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected void configure(HttpSecurity http) throws Exception {


//声明用户必须拥有读权限而不是删除权限
String expression = "hasAuthority('read') and !hasAuthority('delete')";
http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
.anyRequest().access(expression);
}

这时我们登录关羽,关羽有所有权限,但因为他有delete权限,所以不能访问资源,被转发至默认错误页面:

基于用户角色限制所有端点的访问

我们在上篇文章已经介绍了角色,知道了角色提供的名称与为权限提供的名称类似,这取决于我们自己。与权限相比,可以认为角色是细粒度的。无论如何,在后台,角色都是使用Spring Security中的相同接口表示的,即GrantedAuthority。在定义角色时,其名称应该以ROLE_前缀开头。在实现层面,这个前缀表明了角色和权限之间的区别。

先看下面的配置代码,我们仍然使用hasAuthority():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
.anyRequest().hasAuthority("ROLE_USER");
}

这个时候登录张飞:

大家应该已经猜到答案了,就是上面说到的Spring Security将角色和权限均视作GrantedAuthority的成分,所以经过之前的文章,我们将ROLE_前缀的角色也放到了UserDetails的List中,所以我们可以通过hasAuthority(“ROLE_USER”)将ROLE_USER也通过类似权限控制的方式进行验证,但若是我换成如下代码:

1
.anyRequest().hasAuthority("USER")

此时再登录张飞:

因为你ROLE_USER视为一种权限,而不是角色,那么你USER并不是一个权限,自然就识别不了,db都没有这个权限,张飞自然肯定没有,所以403了。

那么毕竟我们想更好的区分角色和权限,该怎么办呢,SpringSecurity给我们提供了相关接口,它们就是hasRole()和hasAnyRole(),但是它们的使用方法相较于hasAuthority()等方法不同的是:
我并没有加ROLE_前缀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http.csrf().disable()
.logout()
.and()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/login","/user/create").permitAll()
.anyRequest().hasRole("USER");
}

此时登录张飞:

发现访问资源成功,我们发现hasRole()方法现在会指定允许访问端点的角色,并且参数没有ROLE_前缀。

我们可以通过源码找到答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public ExpressionInterceptUrlRegistry hasRole(String role) {


return access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
}
private static String hasRole(String role) {


Assert.notNull(role, "role cannot be null");
if (role.startsWith("ROLE_")) {


throw new IllegalArgumentException(
"role should not start with 'ROLE_' since it is automatically inserted. Got '"
+ role + "'");
}
return "hasRole('ROLE_" + role + "')";
}

可以看到,hasRole 的处理逻辑和 hasAuthority 似乎一模一样,不同的是,hasRole 这里会自动给传入的字符串加上 ROLE_ 前缀,所以我们的GrantedAuthority中的角色应该也加入ROLE_前缀,亦或者从db中查出来的角色一开始就应该带上该前缀,都是可以的。

但是要确保这样的设计,roles()方法提供的参数不能包含ROLE_前缀,否则会抛出异常:

然后就是对于角色,access表达式同样也支持role()的相关方法,这一点和authority()是类似的,这里不做赘述。

限制对所有端点的访问

可以使用permitAll()方法允许对所有请求的访问。denyAll()方法正好与permitAll()方法相反。即拒绝所有的请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {



//...

@Override
protected void configure(HttpSecurity http) throws Exception {


http.httpBasic();
//使用denyAll()限制对每一个人的访问
http.authorizeRequests().anyRequest().denyAll();
}
}

实际上,该方法使用频率要小于其他方法,但是有的场合使得它成为必要的方法。下面来看2种场景

①假设有一个端点作为路径(path)变量来接收电子邮件地址。这里需要的是允许具有地址以.com结尾的变量值的请求。我们不希望应用程序接收电子邮件地址的任何其他格式。对于这个需求,可以使用一个正则表达式对匹配规则的请i去进行分组(后面会讲),然后使用denyAll()方法只是应用程序拒绝所有的这些请求:

还可以设想一个如下图的应用程序,一些微服务实现了应用程序的用例,可以通过调用不同路径上可用的端点来访问这些用例。但是为了调用端点,客户端需要请求另一个被称为网关的服务。假设限制有个应用程序有2个网关分别称为网关A和网关B,如果客户端像访问/products/**路径,则请求网关A。但是对于/articles/**路径,客户端必须请求网关B。每个网关服务都被设计为拒绝所有对这些服务不支持的其他路径的请i去。这个简化的场景可以帮助我们理解denyAll()方法,在实际开发中,可以在更复杂的架构种发现类似的场景:

权限应用到端点

之前讲解了如何基于权限和角色配置访问。但是其中只应用了针对所有端点的配置。本章将介绍如何对特定的请求分组应用授权约束。在生产环境的应用程序中,不太可能对所有请求应用相同的规则。其中将具有只有某些特定用户才能调用的端点,而其他端点则可能每个用户都可以访问。根据业务需求,每个接口都有自己的自定义授权配置。

要选择应用授权配置的请求,可以使用匹配器方法。Spring Security提供了3种类型的匹配方法。

  • MVC匹配器:将MVC表达式用于路径以便选择端点。
  • Ant匹配器:将Ant表达式用于路径以便选择端点。
  • regex匹配器:将正则表达式(regex)用于路径以便选择端点。

使用MVC匹配器方法选择端点

2.1、对单个请求无请求方法的匹配

首先看一个简单的示例,我们要创建一个暴露两个端点的应用程序,这两个端点是/hello和/xiao。我们希望确保只有ADMIN角色的用户才能调用/hello端点。类似地,只有USER角色的用户才能调用/xiao端点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.mbw.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {


@RequestMapping("/hello")
public String hello(){


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

return "纳西妲我抽爆!";}

}

然后我们就着上次搭建好的程序以及数据继续我们的学习,为了让指定接口具有特定的角色才能调用,我们需要使用mvcMatchers()方法,下面代码就是配置类中使用的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers("/hello").hasRole("ADMIN")
.mvcMatchers("/xiao").hasRole("USER");
}

我们接下来通过张飞这个用户认证通过后访问/xiao这个端点,发现可以访问通过,因为张飞角色只有USER。

但是张飞并没有ADMIN这个角色,我们访问/hello端点,会发现报403,说明配置此时生效。

但是我们拿关羽访问该端点,关羽是三个角色都有,所以可以访问该端点

然后我们现在仍然是刚才的配置,我们新加上一个端点:

1
2
3
4
5
6
7
8
@GetMapping("/giao")
public String giao(){


Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String name = authentication.getName();
return "giao,"+name;
}

这时你不去登陆直接访问该接口,你会发现你能请求的通:

ps:anonymousUser是因为未经任何认证,而接口又被放行,所以安全上下文没有任何认证用户信息,所以显示anonymousUser匿名用户。

因为我们的配置中只有

那么对于其他的请求,如果没有额外配置,默认情况下任何人任何权限都可以访问它,等同于permitAll().在这种情况下,Spring Security不会进行身份验证,不过你如果硬要进行身份验证也是可以的,框架也会对其进行评估,例如你输入正确的凭据,框架会显示正确的回复,但是凭据不正确,框架也会返回对应的错误,这个我们通过basic登录展示:

可以看到仍然会显示张飞的名字,说明框架会理会该请求并进行评估,但若是没有提供身份凭据,你也可以访问没做限制的请求而已。当然若你提供错误的身份凭据,框架理所当然会返回401:

那么回到MVC匹配器上,我们之前调用的是mvcMatchers(String… patterns)这个方法,这意味着我们不仅可以像刚才一样对单一请求匹配某个授权规则,我们还可以指定多个端点同时使用相同的授权规则

2.2、对单个请求有请求方法的匹配

当然,有时我们还需要指定HTTP方法,而不仅仅是路径,这就要用到MVC匹配器的另一种方法:
mvcMatchers(HttpMethod method,String… patterns),它允许制定要应用限制的请求方法和路径,这一点对同一路径不同请求方法的端点会非常好用,例如我们现在增加两个路径相同但是所需请求方法不同的接口:

1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("/a")
public String postEndpointA(){


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


return "a";
}

然后假设现在需要让post方法的/a请求需要经过认证,而get的不用。我们可以这样配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers(HttpMethod.POST,"/a").authenticated()
.mvcMatchers(HttpMethod.GET,"/a").permitAll();
}

我们现在来到Postman通过get方法调用/a并且使用No Auth的方式请求,发现无需认证也可以请求

但若是改为post方法的/a,如果还是no Auth,则报401:

现在认证通过后再访问,发现可以访问:

2.3、对多个路径的匹配

有时我们的controller会在类上加入@RequestMapping给该类的所有接口加上统一前缀路径,此时假设我们对这个类的所有接口需要假设只有ADMIN角色访问,我们总不能真的把一个个路径用逗号隔开写上去,太麻烦了。 Spring MVC从Ant中借用了路径匹配语法,这使得我们可以使用**操作符去匹配以某一路径开始的所有路径请求。

我们新建一个controller

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
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class TestController {



@GetMapping("/xiao")
public String xiao(){

return "纳西妲我抽爆!";}
@GetMapping("/giao")
public String giao(){


Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String name = authentication.getName();
return "giao,"+name;
}
@GetMapping("/a")
public String getEndpointA(){


return "a";
}
@GetMapping("/a/b")
public String getEndpointAB(){


return "ab";
}
}

我们现在假设对该controller下所有的接口要求只允许有ADMIN角色的才能访问,我们可以使用如下匹配方法:

1
.mvcMatchers("/test/xiao","/test/giao","/test/a","/test/a/b").hasRole("ADMIN")

但是这样太过于累赘,万一接口很多呢?我们可以通过MVC路径匹配表达式中的**解决

1
.mvcMatchers("/test/**").hasRole("ADMIN")

你会发现带有/test路径的所有接口必须要有ADMIN权限才能访问,若没有则报403

而拿关羽则可以通过:

如前面的示例所示,操作符可以指向任意数量的路径名。可以像上一个实例中所做的那样使用它,以便可以用具有已知前缀的路径来匹配请求。还可以在路径中间使用它指向任意数量的路径名,或者指向以特定模式(比如/a//c)结束的路径。因此,/a//c不仅可以匹配/a/b/c,还可以匹配/a/b/c/d/b/c和/a/b/c/d/c等等,如果你想匹配一个路径名,那么可以使用单个。例如,a//c将匹配/a/b/c和/a/d/c等等。而不是/a/b/d/c。

2.4、当端点路径中带有路径变量的匹配

我们在get请求通常会使用路径变量,所以实际上为这类请求应用授权规则会非常有用。甚至可以应用指向路径变量值的规则。并且当实际运用的时候,搭配之前讲过的denyAll()会使用途和扩展性非常高。

例如下面这个带有路径变量的端点

1
2
3
4
5
6
@GetMapping("/product/{code}")
public String productCode(@PathVariable String code){


return code;
}

假设我们只让该请求路径变量仅包含数字时候才接受调用,其他所有请求均不能通过请求。

当使用带有正则表达式的参数表达式时,请确保在参数名称、冒号(:)和正则表达式之间没有空格,如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers("/test/product/{code:^[0-9]*$}").permitAll()
.anyRequest().denyAll()
}

此时调用端点,假设code=1234a,不符合全部都是数字,报401:

然后再次调用端点,code=12345,发现调用通过:

关于使用MVC匹配器进行路径匹配的通用表达式如下表:

表达式 描述
/a 仅匹配路径/a
/a/* 操作符*会替换一个路径名。在这种情况下,它将匹配/a/b或/a/c,而不是/a/b/c
/a/** 操作符**会替换多个路径名。在这种情况下,/a以及/a/b和/a/b/c都是这个表达式的匹配项
/a/{param} 这个表达式适用于具有给定路径参数的路径/a
/a/{param:regex} 只有当参数的值与给定正则表达式匹配时,此表达式才应用于具有给定路径参数的路径/a

使用Ant匹配器选择用于授权的请求

使用Ant匹配器的3种方法如下:

  • antMatchers(HttpMethod method,String patterns):允许指定应用限制的HTTP方法和指向路径的Ant模式。如果希望对同一组路径的不同HTTP方法应用不同的限制,则此方法非常有用。
  • antMatchers(String patterns):如果只需要应用基于路径的授权限制,则这一方法使用起来更加简单。这些限制会自动适用于任何HTTP方法。
  • antMatchers(HttpMethod method):他等同于antMatchers(httpMethod,“/**”),允许特定的HTTP方法,而不考虑路径。

MVC匹配器与Ant匹配器哪个好用?

MVC匹配器指的是Spring应用程序如何理解将请求与控制器相匹配。有时多个路径可以被Spring解析为匹配相同的操作

例如:如果在路径之后添加一个/,那么指向相同操作的任何路径(例如/hello)都可以由Spring解析。在这种情况下,/hello和/hello/会调用相同的方法。如果使用MVC匹配器并且为/hello路径配置安全性,则它会自动使用相同的规则保护/hello/路径/。这会产生巨大的影响!开发人员如果不知道这一点,并且使用Ant匹配器,则可能会在毫不知情的情况下让路径不受保护

下面以一个示例说明。

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



@RequestMapping("/hello")
public String hello(){


return "hello world";
}
}

3.1、使用MVC匹配器的配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers( "/hello").authenticated();
//.antMatchers( "/hello").authenticated();
}

使用http://localhost:9090/hello并且不认证测试:

使用http://localhost:9090/hello/并且不认证测试:发现仍然401,这说明之前的结论是正确的,由于/hello和/hello/指向相同的操作,均可以由Spring解析,所以MVC匹配器同样也会保护/hello/这个路径

3.2、使用Ant匹配器的配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
//.mvcMatchers( "/hello").authenticated();
.antMatchers( "/hello").authenticated();
}

不进行身份验证访问http://localhost:9090/hello

不进行身份验证访问http://localhost:8080/hello/

注意,此时居然访问成功了,所以Ant匹配器的粒度比较细,会出现我们不期待的结果。还需要为该路径再配置限制,所以平时使用MVC匹配器就可以了。

实际上,Ant匹配器会为模式精确地应用给定的Ant表达式,但它无法触及Spring MVC的精细功能。在本示例中,/hello不会作为Ant表达式应用于/hello路径。如果还想保护/hello/路径,则必须单独添加它,或者编写一个匹配它的Ant表达式

使用正则表达式匹配器选择用于授权的请求

可以使用正则表达式表示字符串的任何格式,因此它们提供了无限的可能性。但缺点是难以阅读,即使应用于简单的场景也是如此。

实现正则表达式匹配器的两种方法如下:

  • regexMatchers(HttpMethod method,String regex):同时指定应用限制的HTTP方法和指向路径的正则表达式。如果希望对同一组路径的不同HTTP方法应用不同的限制,则此方法非常有用。
  • regexMatchers(String regex):如果只需要应用基于路径的授权限制,该方法使用起来会更加简单。这些限制将会自动适用于任何HTTP方法。

以一个简单的示例说明:

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



@GetMapping("/video/{country}/{language}")
public String video(@PathVariable String country,
@PathVariable String language) {


return "Video allowed for " + country + " " + language;
}
}

当需要编写更复杂的规则,最终指向更多路径模式和多个路径变量值时,编写一个正则表达式匹配器的方式会更加容易。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.regexMatchers(".*/(us|uk|ca)+/(en|fr).*").authenticated()
//配置用户需要具有ADMIN角色才能访问的其他路径
.anyRequest().hasRole("ADMIN");
}

该配置限制了国家只能要求us/uk/ca,语言只能是en/fr,所以此时通过US国家和en语言可以调用,但若是FR国家和fr语言则不能调用,我们拿张飞进行测试:

由于张飞不具有ADMIN用户,且/video/FR/fr并不被正则匹配器所匹配,所以被拦截并报403.

正则表达式是功能强大的工具,可以使用它们指向任何指定需求的路径。但是由于正则表达式难以阅读,并且可能变得很长,因此它们是我们的最后选择。只有当MVC和Ant表达式不能为所面临的问题提供解决方案时,才使用它们

实现过滤器

通过之前的学习,我们已经可以通过SpringSecurity完成一个差不多的RBAC管理框架,下面我们要学习的只是在应用层面不断加深去拓展可以应用的点,例如本次的过滤器,我们将学习基础的SpringSecurity的过滤器链,然后我会通过redis+短信验证码的这样一个拓展带大家学习如何将一个新的认证方式加入到SpringSecurity中,学习了过滤器,你就可以实现这一点。

过滤器概述

在Spring Security中,HTTP过滤器会委托应用于HTTP请求的不同职责。在之前学习中我们也经常提到过过滤器,不知道大家是否还记得Spring Security的那个框架图,大家可以发现最顶层就是一个个的过滤器,例如提到过的身份验证过滤器,它将身份验证职责委托给身份验证管理器。又比如授权过滤器会负责授权配置等等。通常HTTP过滤器会管理必须应用于请求的每个职责。过滤器形成了职责链。过滤器会接受请求、执行其逻辑,并最终将请求委托给链中的下一个过滤器

在SpringSecurity架构中实现过滤器

3.1、过滤器链

首先是Spring Security默认的过滤器链,其中数字就代表过滤器链的执行顺序

其中大家可能对UsernamePasswordAuthenticationFilter非常熟悉,它就是我们的认证过滤器:
默认匹配URL为/login且必须为POST请求。

Spring Security架构中的过滤器是典型的HTTP过滤器。可以通过javax.servlet包实现Filter接口来创建过滤器。对于其他任何HTTP过滤器,需要重写doFilter()方法来实现其逻辑。此方法会接收ServletRequest、ServletResponse和FilterChain作为参数。

  • ServletRequest:表示HTTP请求。使用ServletRequest对象检索关于请求的详细信息。
  • ServletResponse:表示HTTP响应。使用ServletResponse对象在将响应发送回客户但或顺着过滤器链更进一步执行之前修改该响应。
  • FilterChain:表示过滤器链。使用FilterChain对象将请求转发给链中的下一个过滤器。

过滤器链表示过滤器的集合,这些过滤器会按照已经定义的顺序执行操作。Spring Security提供了一些过滤器实现和它们的预定义执行顺序。

不需要了解所有过滤器,因为可能不会从代码中直接接触到它们,但是我们需要链接过滤器链是如何工作的,并了解其中的一些实现

3.2、在过滤器链中现有过滤器之前添加过滤器

考虑一个简单的场景,我们希望确保任何请求都有一个名为Request-id的头信息。假设应用程序使用这个头信息跟踪请求,并且这个头信息是必须的。同时,我们希望应用程序执行身份验证之前验证这些假设。身份验证过程可能涉及查询数据库或其他消耗资源的操作,如果请求的格式无效,则我们不希望应用程序执行这些操作。那么应该怎么做呢?要解决当前的这个需求,只需两个步骤,最终的过滤器链如下图。

实现该过滤器。创建一个RequestValidationFilter类,用于检查请求中是否存在所需的头信息。

将该过滤器添加到过滤器链。要在配置类中完成此处理,需要重写configure()方法

那么首先我们自定义一个过滤器并实现doFilter(),然后检查Request-id头信息是否存在,如果存在则放行,不存在则返回400错误。

那么代码如下:

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
package com.mbw.security.filter;

import cn.hutool.core.text.CharSequenceUtil;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class RequestValidationFilter implements Filter {


@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {


HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestId = httpRequest.getHeader("Request-id");
if(CharSequenceUtil.isBlank(requestId)){


httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
filterChain.doFilter(request,response);
}
}

然后在身份验证之前配置自定义过滤器,我们在配置类重写configure()方法,通过addFilterBefore()完成该操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   @Override
protected void configure(HttpSecurity http) throws Exception {


http
.addFilterBefore(requestValidationFilter,BasicAuthenticationFilter.class)
.and()
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.anyRequest().hasRole("ADMIN");
}

那么这样我们就成功的将自定义的过滤器加在了BasicAuthenticationFilter之前,也就是通过basic认证之前会先经过我们这个自定义的过滤器。

那么现在测试一下:
首先不带上Request-id这个请求头,报400

然后带上该请求头,请求通过:

3.3、在过滤器链中已有的过滤器之后添加过滤器

假设必须在身份验证过程之后执行一些逻辑。这方面的例子包括,在某些身份验证事件发生后通知不同的系统,或者只是为了达成日志记录和跟踪目的。

对于本示例而言,需要通过在身份验证过滤器之后添加一个过滤器来记录所有成功的身份验证事件。我们认为通过身份验证过滤器的是一个成功的身份验证事件,并且希望记录它

那么我们首先定义一个通过basic认证后打印一条requestId请求头的日志的过滤器:

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
package com.mbw.security.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Component
@Slf4j
public class AuthenticationLoggingFilter implements Filter {


@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {


HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestId = httpRequest.getHeader("Request-id");
log.info("Successfully authenticated request with id:{}" , requestId);
filterChain.doFilter(request,response);
}
}

老样子,同之前的类似代码将这个过滤器加在BasicAuthenticationFilter之后,这次我们通过addFilterAfter()完成该操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   @Override
protected void configure(HttpSecurity http) throws Exception {


http
.addFilterBefore(requestValidationFilter,BasicAuthenticationFilter.class)
.addFilterAfter(authenticationLoggingFilter,BasicAuthenticationFilter.class)
.and()
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.anyRequest().hasRole("ADMIN");
}

然后测试,我们通过认证后,可以看到控制台日志打印了一句:

你会发现执行了两次
这是因为我们将过滤器交给Spring管理,这样会让它自动加入到servlet的filter chain中,而spring security的config配置中又把filter注册到了spring security的容器中,因此在调用BasicAuthenticationFilter鉴权之前和鉴权之后先后会各执行一次。

那怎么解决呢,有两种
①不交给Spring管理,直接通过new的方式
这种简单粗暴,也很简单运用。但是有一个缺点,实际开发中可能其他地方也会使用该过滤器,这样就可能造成不便。

②通过加一个flag标记确保过滤器只运行一次,通过以下代码解决:
在进入该过滤器前,先检查是否已经有该标记,如果有,直接放行。

如果没有,将这个标记set进request,这样就可以有效防止重复执行:

1
2
3
4
5
6
7
if (httpRequest.getAttribute(FILTER_APPLIED) != null) {


chain.doFilter(httpRequest, httpResponse);
return;
}
  httpRequest.setAttribute(FILTER_APPLIED, Boolean.TRUE);

我们在AuthenticationLoggingFilter应用:

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
package com.mbw.security.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Component
@Slf4j
public class AuthenticationLoggingFilter implements Filter {


private static final String FILTER_APPLIED = "__spring_security_myAuthenticationTokenGenericFilter_filterApplied";
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {



HttpServletRequest httpRequest = (HttpServletRequest) request;
if (httpRequest.getAttribute(FILTER_APPLIED) != null) {


filterChain.doFilter(request, response);
return;
}
httpRequest.setAttribute(FILTER_APPLIED, Boolean.TRUE);
String requestId = httpRequest.getHeader("Request-id");
log.info("Successfully authenticated request with id:{}" , requestId);
filterChain.doFilter(request,response);
}
}

可以看到只执行了一次

当然其实还有第三种方法,这个我们后面会介绍

3.4、在过滤器链中另一个过滤器的位置添加一个过滤器

这个其实实战中应用的比较少,往往是我们不打算使用Spring的过滤器,通过自定义取而代之Spring的过滤器。

例如假设不打算使用HTTP Basic身份验证流程,而是要实现一些不同的处理。相较于使用用户名和密码作为应用程序对用户进行身份验证的输入凭据,这里需要应用另一种方法。可能会遇到的一些场景示例是:

  • 基于用户身份验证的静态头信息值得标识。
  • 使用对称密钥对身份验证请求进行签名。
  • 在身份验证过程中使用一次性密码(OTP)。

接下来实现一个示例来展示如何应用自定义过滤器。为了保持示例得相关性和直观性,要将重点放在配置上,并考虑实现一个简单的身份验证逻辑。在我们得场景中,有一个静态得密钥值,它对所有的请求都是相同的。要进行身份验证,用户必须在Authorization头信息中添加正确的静态密钥值。

首先要实现名为StaticKeyAuthenticationFilter的过滤器类。这个类从属性文件中读取静态密钥的值,并验证Authorization头信息的值是否与该值相等。如果值相同,则过滤器会将请求转发给过滤器链中的下一个组件。如果不相等,则过滤器会将值401 Unauthorized设置为响应的HTTP状态,而不转发过滤器链中的请求。

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
package com.mbw.security.filter;

import org.springframework.beans.factory.annotation.Value;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class StaticKeyAuthenticationFilter implements Filter {


@Value("${authorization.key}")
private String authorizationKey;
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {


HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String authentication = httpRequest.getHeader("Authorization");
if (authorizationKey.equals(authentication)) {


filterChain.doFilter(request, response);
} else {


httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}

一旦定义了过滤器,就可以使用addFilterAt()方法将其添加到过滤器链中BasicAuthenticationFilter类所在的位置。但请记住,在指定位置添加过滤器时,Spring Security并不会指定它是该位置上的唯一过滤器。可以在链中的相同位置添加更多的过滤器。在这种情况下,Spring Security不会保证这些操作的执行顺序。

提示:
建议不要在过滤器链的同一位置添加多个过滤器。当在同一位置添加更多的过滤器时,它们的使用顺序将不会被定义。有一个明确的调用过滤器的顺序是有意义的。有一个已知的顺序可以使应用程序更易于理解和维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void configure(HttpSecurity http) throws Exception {


http
.addFilterBefore(requestValidationFilter,BasicAuthenticationFilter.class)
.addFilterAt(staticKeyAuthenticationFilter,BasicAuthenticationFilter.class)
.addFilterAfter(authenticationLoggingFilter,BasicAuthenticationFilter.class)
.and()
.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failureHandler)
.and().httpBasic().and()
.authorizeRequests()
.antMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.anyRequest().hasRole("ADMIN");
}

此时测试,首先在authorization头输入错误的信息,发现401:

然后输入和配置文件相同的值:

你会发现响应虽然不同,但仍然是401,这是因为我们没有配置userDetailsService,甚至根本不存在用户的概念,我们只是相当于验证一个值,而一般场景绝对不会这么简单,常常需要一个userDetailsService。不过如果你访问一个不需要认证的接口,那还是可以的,例如下面这个获取验证码的接口:

Spring Security提供的Filter的实现

Spring Security提供了一些实现Filter接口的抽象类,可以为它们扩展过滤器定义。在扩展这些类时,他们还有助于为实现添加功能。例如,可以扩展GenericFilterBean类,它允许我们在合适的位置使用web.xml描述文件中所定义的初始化参数。一个扩展了GenericFilterBean的更有用的类是OncePerRequestFilter.在向链中添加过滤器时,框架并不会保证每个请求只调用它一次。不过,顾名思义,OncePerRequestFilter实现了确保每个请求只执行过滤器的doFilter()方法一次的逻辑。

还记得之前我们一个被重复执行的过滤器吗,我们讲了两种方法,现在我们使用第三种试试:

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
package com.mbw.security.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
public class AuthenticationLoggingFilter extends OncePerRequestFilter {



@Override
protected void doFilterInternal(HttpServletRequest httpRequest, HttpServletResponse httpResponse, FilterChain filterChain) throws ServletException, IOException {


String requestId = httpRequest.getHeader("Request-id");
log.info("Successfully authenticated request with id:{}" , requestId);
filterChain.doFilter(httpRequest,httpResponse);
}
}

然后删掉我们之前写的替换BasicAuthenticationFilter的StaticKeyAuthenticationFilter相关代码,当然,你也可以继续用这个验证,只是我觉得怪怪的,想用basic验证而已:

认证成功后,会到控制台发现只执行了一次日志,即只执行了一次doFilter():

那么关于OncePerRequestFilter类的简要经验,它们会很有用。

  • 它只支持HTTP请求,但这实际上也是我们一直使用的。它的有点是对类型进行强制转换,并且可以直接接收HttpServletRequest和HttpServletResponse请求。记住,使用Filter接口,就必须对请求和响应进行强制转换。
  • 可以实现判定是否应用过滤器的逻辑。即使将过滤器添加到链中,我们也可能判定它不适用于某些请求。可以通过重写shouldNotFilter(HttpServletRequest)方法设置这一点。默认情况下,过滤器适用于所有请求。
  • 默认情况下,OncePerRequestFilter不适用于异步请求或错误分发请求。可以通过重写shouldNotFilterAsyncDispatch()和shouldNotFilterErrorDispatch()方法来更改此行为。

过滤器案例-短信验证码登录

学习完Spring Security中的过滤器后,我们就可以整合新的认证方式并且让Spring Security帮我们完成认证的整个过程,那么这一章我们将通过加入短信验证码认证的例子带大家巩固之前的学习。

当然由于本人并没有开通短信业务,所以这里通过生成4位随机数并配合redis完成相关业务,大家如果集成了想尝试完全可以平替,主要学习的是原理和流程。

流程讲解

首先我们需要了解spring security的基本工作流程

  • 当登录请求进来时,会在UsernamePasswordAuthenticationFilter 里构建一个没有权限的
    Authentication
  • 然后把Authentication 交给 AuthenticationManager 进行身份验证管理
  • 而AuthenticationManager 本身不做验证 ,会交给 AuthenticationProvider 进行验证
  • AuthenticationProvider 会调用 UserDetailsService 对用户信息进行校验
  • 我们可以自定义自己的类来 实现UserDetailsService 接口
  • 就可以根据自己的业务需要进行验证用户信息 , 可以从数据库进行用户账号密码校验
  • 也可以 在redis 中用手机号和code 进行校验
  • UserDetailsService 校验成功后 会返回 UserDetails类,里面存放着用户祥细信息
  • 一般我们会重新写一个自定义的类来继承 UserDetails ,方便数据转换。
  • 验证成功后 会重新构造 Authentication 把 UserDetails 传进去,并把认证 改为 true super.setAuthenticated(true)
  • 验证成功后来到 AuthenticationSuccessHandler 验证成功处理器 ,在里面可以返回数据给前端

之后我们就可以模仿上面的密码登录流程完成短信验证认证方法,流程如下:

根据上图 我们要重写 SmsAuthenticationFilter、SmsAuthenticationProvider、UserDetailsService、UserDetails,来模拟用户密码登录,其中UserDetails可以用之前代码中的JwtUserDto,之后还需要重写Authentication对象—SmsCodeAuthenticationToken

在写一些配置类来启用我们的短信业务流程 SmsSecurityConfigurerAdapter SecurityConfig extends WebSecurityConfigurerAdapter

代码实现

首先由于我们涉及到redis,依赖中需要加入redis依赖:

1
2
3
4
5
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

之后在yaml中加入redis相关配置

1
2
3
4
5
6
7
8
spring:
redis配置
redis:
host: 127.0.0.1
password: 123456
port: 6379
security:
loginType: json

RedisConfig.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@EnableCaching
@Configuration
public class RedisConfig {



private static final int CACHE_EXPIRE = 60;

@Bean
@ConditionalOnMissingBean(RedisTemplate.class)
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {


RedisTemplate template = new RedisTemplate();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new FastJson2JsonRedisSerializer(Object.class));
template.afterPropertiesSet();
return template;
}
}

其中对Redis的序列化和反序列化运用fastJson重写
FastJson2JsonRedisSerializer.class

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
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.lang.Nullable;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
* 使用fastjson重写redis序列化和反序列化
*/
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {



public static final Charset DEFAULT_CHARSET;

private Class<T> clazz;

static {


DEFAULT_CHARSET = StandardCharsets.UTF_8;
/**
* 开启fastjson autotype功能(不开启,造成EntityWrapper<T>中的T无法正常解析)
*/
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}

public FastJson2JsonRedisSerializer(Class<T> clazz) {


super();
this.clazz = clazz;
}

/**
* 反序列化
*/
@Override
public T deserialize(@Nullable byte[] bytes) {


if (bytes == null || bytes.length == 0) {


return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}

/**
* 序列化
*/
@Override
public byte[] serialize(@Nullable Object t) {


if (t == null) {


return new byte[0];
}
/**
* SerializerFeature.WriteClassName 这个很关键,
* 这样序列化后的json中就会包含这个类的全称 ==> "@type": "com.mbw.security.dto.JwtUserDto",
* 在反序列化的时候,就可以直接转换成JwtUserDto对象了
*/
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
}

然后就是获取验证码接口
我们先在service写一个类似方法通过RandomUtil生成一个四位数的验证码,然后将验证码存入redis,并设置5分钟的过期时间,但是此时按照正常开发是应该将这个验证码通过短信形式发送给请求者手机上的,但是由于本人没有申请手机短信业务,这里就通过日志的形式代替这一步:
SmsCodeSendService.class

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
import com.mbw.common.utils.UserConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@Slf4j
public class SmsCodeSendService {


@Autowired
private StringRedisTemplate stringRedisTemplate;
public boolean sendSmsCode(String mobile, String code) {


//因为这里是示例所以就没有真正的使用第三方发送短信平台。
String sendCode = String.format("你好你的验证码%s,请勿泄露他人。", code);
log.info("向手机号" + mobile + "发送的短信为:" + sendCode);
//存入redis,以手机号_SMS_key为key,值是验证码,5分钟过期
stringRedisTemplate.opsForValue().set(mobile + "_" + UserConstants.SMS_REDIS_KEY, code, Duration.ofMinutes(5));
return true;
}
}

然后controller调用service发送验证码的服务

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
package com.mbw.controller;

import cn.hutool.core.util.RandomUtil;
import com.mbw.common.utils.Result;
import com.mbw.mapper.UserMapper;
import com.mbw.pojo.User;
import com.mbw.service.SmsCodeSendService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@Slf4j
@RestController
public class SmsController {


@Resource
private UserMapper userDetailsMapper;
@Resource
private SmsCodeSendService smsCodeSendService;

@RequestMapping("/smsCode")
public Result getSmsCaptcha(@RequestParam String mobile) {


User user = userDetailsMapper.findByMobile(mobile);
if (user == null) {


return Result.error().message("你输入的手机号未注册");
}
smsCodeSendService.sendSmsCode(mobile, RandomUtil.randomNumbers(4));
return Result.ok().message("发送验证码成功");
}
}

然后就是认证相关业务的实现
首先是自定义的验证码验证过滤器
想象一下,前端调用获取验证码接口,然后输入获取的验证码,点击登陆后,我们最应该做的是什么,是不是应该验证你的验证码的正确性,然后再做其他认证步骤。如果验证码都错了,就应该给它立即返回认证异常,所以关于验证这个逻辑,我们将它抽取成一个过滤器实现,并将它放在认证服务过滤器之前。即在认证之前先验证手机验证码的正确性:
SmsCodeValidateFilter.class

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
package com.mbw.security.filter;

import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.mbw.common.utils.UserConstants;
import com.mbw.handler.CommonLoginFailureHandler;
import com.mbw.mapper.UserMapper;
import com.mbw.pojo.User;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {



@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private CommonLoginFailureHandler commonLoginFailureHandler;
@Resource
private UserMapper userMapper;

@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {


if (CharSequenceUtil.equals("/smslogin", httpServletRequest.getRequestURI()) &&
CharSequenceUtil.equalsIgnoreCase("post", httpServletRequest.getMethod())) {


try {


validated(new ServletWebRequest(httpServletRequest));
} catch (SessionAuthenticationException e) {


//直接抛出相关异常
commonLoginFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}

private void validated(ServletWebRequest request) {


String code = request.getRequest().getParameter("smsCode");
String mobile = request.getRequest().getParameter("mobile");
String smsCode = stringRedisTemplate.opsForValue().get(mobile + "_" + UserConstants.SMS_REDIS_KEY);
if (StrUtil.isEmpty(code)) {


throw new SessionAuthenticationException("验证码不能为空");
}

if (StrUtil.isEmpty(mobile)) {


throw new SessionAuthenticationException("手机号不能为空");
}

if (StrUtil.isEmpty(smsCode)) {


throw new SessionAuthenticationException("验证码不存在");
}
Long expire = stringRedisTemplate.getExpire(mobile + "_" + UserConstants.SMS_REDIS_KEY);
if (expire <= 0) {


//如果已过期,redis会删除key,此时getExpire返回-2,key已自动被redis删除
throw new SessionAuthenticationException("验证码已过期");
}
if (!StrUtil.equals(code, smsCode)) {


throw new SessionAuthenticationException("验证码不匹配");
}
User user = userMapper.findByMobile(mobile);
if (ObjectUtil.isNull(user)) {


throw new SessionAuthenticationException("该手机号未注册");
}
//验证完成后redis内部将Key删除
stringRedisTemplate.delete(mobile + "_" + UserConstants.SMS_REDIS_KEY);

}
}

那么对于上面过滤器,拦截的登陆路径是由前后端沟通好,比如这里专门用于手机短信验证码登陆的/smslogin.我们这里是将手机短信登陆全权交给SpringSecurity去管理,所以采用的流程均和Spring Security有关;你也可以通过写controller接口的形式并且和SpringSecurity适配后让前端调用controller的接口,都是可以的。

然后就是我们的Authentication对象,我们这边给它命名为SmsCodeAuthenticationToken,主要就是模仿UsernamePasswordAuthenticationToken去写。这个对象的Principal之前说过存放的是认证信息,但是对于Authentication对象来说,它是存在两种状态分别对应一个参数的构造和3个参数的构造,这两个状态就是认证前和认证后。要记住认证前存放的是凭证,例如我是那用户名密码登录,那Principal就是用户名,我拿手机号验证码登陆,那Principal存放的就是手机号。而认证后存放的就是认证的相关信息。

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
package com.mbw.security.authentication;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

//仿造UsernamePasswordAuthenticationToken写一个手机验证码登陆的Token
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {


private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
//存放认证信息,认证前存放的是手机号,认证之后UserDetails
private final Object principal;

//认证前
public SmsCodeAuthenticationToken(Object principal) {


super((Collection) null);
this.principal = principal;
this.setAuthenticated(false);
}

//认证后
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {


super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}

@Override
public Object getCredentials() {


return null;
}

@Override
public Object getPrincipal() {


return this.principal;
}

public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {


if (isAuthenticated) {


throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
} else {


super.setAuthenticated(false);
}
}

public void eraseCredentials() {


super.eraseCredentials();
}
}

写完Authentication对象后,接着就是提供认证服务的AuthenticationProvider,我们知道它是将认证委托给UserDetailsService(这里并没有用到PasswordEncoder),但是相关的逻辑我们仍然需要重写,我们将这个类命名为SmsCodeAuthenticationProvider。之后我们配置这个类的时候会将它的userDetailsService设置为我们重写的userDetailsService.
通过该类,我们也可以看出认证前先取出AuthenticationToken的Principal(由于还没认证此时是手机号),将其作为参数委托给userDetailsService做相关认证。认证如果失败抛出异常,成功则调用AuthenticationToken的三个参数的方法,将userDetails作为Principal还有authorities等注入进AuthenticationToken并且返回。

SmsCodeAuthenticationProvider.class

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 com.mbw.security.authentication.SmsCodeAuthenticationToken;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

public class SmsCodeAuthenticationProvider implements AuthenticationProvider {



private UserDetailsService userDetailsService;

public UserDetailsService getUserDetailsService() {


return userDetailsService;
}

public void setUserDetailsService(UserDetailsService userDetailsService) {


this.userDetailsService = userDetailsService;
}

//重写两个方法--authenticate()和supports()
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {


//认证之前
SmsCodeAuthenticationToken token = (SmsCodeAuthenticationToken) authentication;
//认证之前Principal存放的是手机号
UserDetails userDetails = userDetailsService.loadUserByUsername((String) token.getPrincipal());
if (userDetails==null){


throw new InternalAuthenticationServiceException("无法根据手机号获取用户信息");
}
//认证之后,此时将获取的userDetails存放进Principal
SmsCodeAuthenticationToken smsCodeAuthenticationToken = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
smsCodeAuthenticationToken.setDetails(token.getDetails());
return smsCodeAuthenticationToken;
}

@Override
public boolean supports(Class<?> authentication) {


return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}

然后就是执行认证业务的核心类–UserDetailsService,我们需要和之前重写的隔离开,重新写一个SmsUserDetailsService专门供给我们之前重写的SmsCodeAuthenticationProvider使用
SmsUserDetailsService逻辑还是类似,就是重写loadUserByUsername(),只是这里的username不再是username,而是mobile,而类里的其中getUserByMobile()这里就不做过多展示,就是通过mobile找用户的sql。

SmsUserDetailsService.class

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
import cn.hutool.core.util.StrUtil;
import com.mbw.mapper.AuthorityMapper;
import com.mbw.pojo.Authority;
import com.mbw.pojo.Role;
import com.mbw.pojo.User;
import com.mbw.security.dto.JwtUserDto;
import com.mbw.service.RoleService;
import com.mbw.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class SmsUserDetailsService implements UserDetailsService {



@Autowired
private UserService userService;

@Autowired
private RoleService roleService;

@Autowired
private AuthorityMapper authorityMapper;
@Override
public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {


// 根据手机号获取用户
User user = userService.getUserByMobile(mobile);
if (user == null){


throw new UsernameNotFoundException("该手机号对应的用户不存在");
}
List<Role> roles = roleService.loadRolesByUsername(user.getUsername());
Set<String> roleInfos = roles.stream().map(Role::getRoleName).collect(Collectors.toSet());
List<Authority> authorities = authorityMapper.loadPermissionByRoleCode(roleInfos);
List<String> authorityNames = authorities.stream().map(Authority::getAuthorityName).filter(StrUtil::isNotEmpty).collect(Collectors.toList());
authorityNames.addAll(roleInfos.stream().map(roleName->"ROLE_"+roleName).collect(Collectors.toList()));
return new JwtUserDto(user, new HashSet<>(roles), authorityNames);
}
}

然后就是我们的短信认证过滤器SmsCodeAuthenticationFilter。这里我们仿造UsernamePasswordAuthenticationFilter写一个就好。

首先是构造方法一定要有,我们在构造方法规定只对/smslogin这个post请求才会生效。而逻辑简单来说就是从request中获取手机号然后调用AuthenticationToken中的一个参数的方法构造出,并将其委托给AuthenticationProvider完成相关认证业务:

SmsCodeAuthenticationFilter.class

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
import com.mbw.security.authentication.SmsCodeAuthenticationToken;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

//仿造UsernamePasswordAuthenticationFilter写一个专门为手机短信验证登陆的过滤器
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


//报错:参数错误
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
private boolean postOnly = true;

//必须要构造器,构造器参数为请求路径和请求方法
public SmsCodeAuthenticationFilter() {


super(new AntPathRequestMatcher("/smslogin", "POST"));
}

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {


if (this.postOnly && !request.getMethod().equals("POST")) {


throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {


//从request中获取Mobile
String mobile = this.obtainMobile(request);
if (mobile == null) {


mobile = "";
}
mobile = mobile.trim();
//此时调用的是第一个参数的authentication,即认证前,principal存放的是mobile
SmsCodeAuthenticationToken authentication = new SmsCodeAuthenticationToken(mobile);
setDetails(request, authentication);
//认证
return this.getAuthenticationManager().authenticate(authentication);
}
}

//获取参数
@Nullable
protected String obtainMobile(HttpServletRequest request) {


return request.getParameter(this.mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {


authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}

然后我们专门写一个配置类用来配置短信业务相关的认证业务,这里就涉及到过滤器的顺序,以及组件之间的关系,如果大家对一开始讲的流程熟悉的话,想必理解这个配置类不难,其中successHandler和failtureHandler大家用之前写的就好,或者自己随便写些啥业务也是可以的。

SmsCodeSecurityConfig.class

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
import com.mbw.handler.CommonLoginFailureHandler;
import com.mbw.handler.CommonLoginSuccessHandler;
import com.mbw.security.filter.SmsCodeAuthenticationFilter;
import com.mbw.security.filter.SmsCodeValidateFilter;
import com.mbw.security.service.SmsCodeAuthenticationProvider;
import com.mbw.security.service.SmsUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
public class SmsCodeSecurityConfig
extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {


@Autowired
private SmsUserDetailsService userDetailsService;
@Autowired
private CommonLoginSuccessHandler successHandler;
@Autowired
private CommonLoginFailureHandler failureHandler;
@Resource
private SmsCodeValidateFilter smsCodeValidateFilter;
@Override
public void configure(HttpSecurity http) {


SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(failureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
http.authenticationProvider(smsCodeAuthenticationProvider).
addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

但是这个配置类我们还没有配置进我们的主配置类,我们可以通过http.apply(C configurer)解决该问题,配置如下:
我们可以看到这样就可以完美地将手机验证和用户名密码完美地结合。其中这个captchaCodeFilter这个是图形验证码过滤器,因为我的代码密码登录还需要输入个验证码,所以需要加入相关业务,没有的朋友可以直接不加,我们这次主要业务是将短信业务融入进来。

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
import com.mbw.handler.CommonLoginFailureHandler;
import com.mbw.handler.CommonLoginSuccessHandler;
import com.mbw.handler.MyLogoutSuccessHandler;
import com.mbw.security.filter.CaptchaCodeFilter;
import com.mbw.security.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import javax.annotation.Resource;
import java.util.HashMap;

@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


@Autowired
private UserDetailsServiceImpl commonUserDetailServiceImpl;
@Autowired
private CommonLoginSuccessHandler successHandler;
@Autowired
private CommonLoginFailureHandler failureHandler;
@Autowired
private MyLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private CaptchaCodeFilter captchaCodeFilter;
@Resource
private SmsCodeSecurityConfig smsCodeSecurityConfig;

@Override
protected void configure(HttpSecurity http) throws Exception {


http.cors().and()
.addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutSuccessHandler(logoutSuccessHandler)
.and()
.rememberMe()
//默认都为remember-me
.rememberMeParameter("remeber-me")
//cookieName一般设置复杂一些,迷惑别人(不容易看出)
.rememberMeCookieName("remeber-me")
//过期时间
.tokenValiditySeconds(24 * 60 * 60 * 2)
.and()
.csrf().disable()
.formLogin()
.loginPage("/toLogin") //用户没有权限就跳转到这个页面
.loginProcessingUrl("/login")//登录跳转页面,表单中的action
.usernameParameter("uname")
.passwordParameter("upassword")//传递的属性
.successHandler(successHandler)
.failureHandler(failureHandler)
.and()
.apply(smsCodeSecurityConfig)
.and().httpBasic().and()
.authorizeRequests()
.mvcMatchers("/home","/toLogin","/login", "/user/create", "/kaptcha", "/smsCode", "/smslogin").permitAll()
.mvcMatchers("/test/a/*").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1).
maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new CustomExpiredSessionStrategy());
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {


auth.userDetailsService(commonUserDetailServiceImpl)
.passwordEncoder(passwordEncoder());
}

@Bean
public PasswordEncoder passwordEncoder() {


HashMap<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}

//跨域配置
@Bean
CorsConfigurationSource corsConfigurationSource() {


CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*");
configuration.addAllowedMethod("*");
configuration.addAllowedHeader("*");
configuration.applyPermitDefaultValues();
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

@Override
public void configure(WebSecurity web) {


//将项目中的静态资源路径开放出来
//为什么要抽出来?上面的规则都需要通过过滤器校验,这个不需要
web.ignoring().antMatchers("/css/**", "/fonts/**", "/js/**", "/templates/**", "/static/**");
}

关于loginPage的配置我是专门写了一个接口去跳转到Login页面,由于笔者用了thymeleaf,只能通过接口访问templates下的静态文件,我也不知道为什么放在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
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<meta charset="UTF-8">
<title>登录</title>
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
</head>
<script>
function flushCode() {


// 每次刷新的时候获取当前时间,防止浏览器缓存刷新失败
var time = new Date();
document.getElementById("kaptcha").src = "/kaptcha?time=" + time;
}
</script>
<body>
<h1>图片验证码登录</h1>
<form method="post" >
<span>用户名</span><input type="text" name="username" id="username"/> <br>
<span>密码</span><input type="password" name="password" id="password"/> <br>
<span>验证码</span><input type="text" name="captchaCode" id="captchaCode"/> <br>
<img alt="验证码" id="kaptcha" name="kaptcha" src="/kaptcha" >
<a href="#" onclick="flushCode();">看不清?</a></br>
<label><input type="checkbox" id="remeber-me">记住我</label>
<input type="button" onclick="login()" value="登陆">
</form>
<script type="text/javascript">
function login() {


var name=$("#username").val();
var password=$("#password").val();
var kaptcha=$("#captchaCode").val();
var remeberme=$("#remeber-me").is(":checked");
if (name===""||password===""){


alert("用户密码不能为空");
return;
}
$.ajax({


type: "POST",
url: "/login",
data:{


"uname": name,
"upassword": password,
"kaptcha":kaptcha,
"remeber-me":remeberme
},
success: function (json) {


if (json.success){


location.href='/user/hello';
}else {


alert(json.msg);
location.href='/login.html';
}
},
error:function () {



}
});
}
</script>
<h1>短信登录</h1>
<form method="post" action="/smslogin">
<span>电话号码</span><input type="text" name="mobile" id="mobile"/> <br>
<span>验证码</span><input type="text" name="smsCode" id="smsCode"/>
<input type="button" onclick="getSmsCode()" value="获取"></br>
<input type="button" onclick="smslogin()" value="登陆">
</form>
<script type="text/javascript">
function smslogin() {


var smsCode=$("#smsCode").val();
var mobile=$("#mobile").val();
$.ajax({


type: "POST",
url: "/smslogin",
data:{


"smsCode": smsCode,
"mobile":mobile
},
success: function (json) {


if (json.success){


alert(json.msg);
location.href='/user/hello';
}else {


alert(json.msg);
location.href='/login.html';
}
},
error:function () {



}
});
}
function getSmsCode() {


var mobile=$("#mobile").val();
$.ajax({


type: "get",
url: "/smsCode",
data:{


"mobile": mobile
},
success: function (json) {


alert(json.msg);
},
error: function (json) {


alert(json.msg);
}
});
}
</script>
</body>
</html>

演示:

点击获取,就可以在后台控制台和redis钟看到验证码:

输验证码点击登陆:

这样就完成短信验证认证服务