背景

在企业发展初期,企业使用的系统很少,通常一个或者两个,每个系统都有自己的登录模块,运营人员每天用自己的账号登录,很方便。
但随着企业的发展,用到的系统随之增多,运营人员在操作不同的系统时,需要多次登录,而且每个系统的账号都不一样,这对于运营人员
来说,很不方便。于是,就想到是不是可以在一个系统登录,其他系统就不用登录了呢?这就是单点登录要解决的问题。

单点登录英文全称Single Sign On,简称就是SSO。它的解释是:在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。

image

如图所示,图中有4个系统,分别是Application1、Application2、Application3、和SSO。Application1、Application2、Application3没有登录模块,而SSO只有登录模块,没有其他的业务模块,当Application1、Application2、Application3需要登录时,将跳到SSO系统,SSO系统完成登录,其他的应用系统也就随之登录了。这完全符合我们对单点登录(SSO)的定义。

技术实现

在说单点登录(SSO)的技术实现之前,我们先说一说普通的登录认证机制。
image

如上图所示,我们在浏览器(Browser)中访问一个应用,这个应用需要登录,我们填写完用户名和密码后,完成登录认证。这时,我们在这个用户的session中标记登录状态为yes(已登录),同时在浏览器(Browser)中写入Cookie,这个Cookie是这个用户的唯一标识。下次我们再访问这个应用的时候,请求中会带上这个Cookie,服务端会根据这个Cookie找到对应的session,通过session来判断这个用户是否登录。如果不做特殊配置,这个Cookie的名字叫做jsessionid,值在服务端(server)是唯一的。

同域下的单点登录

一个企业一般情况下只有一个域名,通过二级域名区分不同的系统。比如我们有个域名叫做:a.com,同时有两个业务系统分别为:app1.a.com和app2.a.com。我们要做单点登录(SSO),需要一个登录系统,叫做:sso.a.com。

我们只要在sso.a.com登录,app1.a.com和app2.a.com就也登录了。通过上面的登陆认证机制,我们可以知道,在sso.a.com中登录了,其实是在sso.a.com的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.a.com下写入了Cookie。那么我们怎么才能让app1.a.com和app2.a.com登录呢?这里有两个问题:

  • Cookie是不能跨域的,我们Cookie的domain属性是sso.a.com,在给app1.a.com和app2.a.com发送请求是带不上的。

  • sso、app1和app2是不同的应用,它们的session存在自己的应用内,是不共享的。

image

那么我们如何解决这两个问题呢?针对第一个问题,sso登录以后,可以将Cookie的域设置为顶域,即.a.com,这样所有子域的系统都可以访问到顶域的Cookie。我们在设置Cookie时,只能设置顶域和自己的域,不能设置其他的域。比如:我们不能在自己的系统中给baidu.com的域设置Cookie。

Cookie的问题解决了,我们再来看看session的问题。我们在sso系统登录了,这时再访问app1,Cookie也带到了app1的服务端(Server),app1的服务端怎么找到这个Cookie对应的Session呢?这里就要把3个系统的Session共享,如图所示。共享Session的解决方案有很多,例如:Spring-Session。这样第2个问题也解决了。

同域下的单点登录就实现了,但这还不是真正的单点登录。

不同域下的单点登录

同域下的单点登录是巧用了Cookie顶域的特性。如果是不同域呢?不同域之间Cookie是不共享的,怎么办?

这里我们就要说一说CAS流程了,这个流程是单点登录的标准流程。
cas_flow_diagram

上图是CAS官网上的标准流程,具体流程如下:

  1. 用户访问app系统,app系统是需要登录的,但用户现在没有登录。

  2. 跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。

  3. 用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。

  4. SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app系统,同时将ST作为参数传递给app系统。

  5. app系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。

  6. 验证通过后,app系统将登录状态写入session并设置app域下的Cookie。

至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。

  1. 用户访问app2系统,app2系统没有登录,跳转到SSO。

  2. 由于SSO已经登录了,不需要重新登录认证。

  3. SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。

  4. app2拿到ST,后台访问SSO,验证ST是否有效。

  5. 验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。

这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。

有的同学问我,SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。他想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗?

其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。

总结

单点登录(SSO)的所有流程都介绍完了,原理大家都清楚了。总结一下单点登录要做的事情:

  • 单点登录(SSO系统)是保障各业务系统的用户资源的安全 。

  • 各个业务系统获得的信息是,这个用户能不能访问我的资源。

  • 单点登录,资源都在各个业务系统这边,不在SSO那一方。 用户在给SSO服务器提供了用户名密码后,作为业务系统并不知道这件事。 SSO随便给业务系统一个ST,那么业务系统是不能确定这个ST是用户伪造的,还是真的有效,所以要拿着这个ST去SSO服务器再问一下,这个用户给我的ST是否有效,是有效的我才能让这个用户访问。

工厂方法模式

工厂方法模式是一种创建型设计模式, 其在父类中提供一个创建对象的方法, 允许子类决定实例化对象的类型

定义Button接口
1
2
3
4
5
public interface Button {
void render();
void onClick(Runnable f);

}
WindowButton实现类
1
2
3
4
5
6
7
8
9
10
11
12
public class WindowsButton implements Button{

@Override
public void render() {
// 根据 WINDOW 样式渲染按钮
}

@Override
public void onClick(Runnable f) {
// 绑定本地操作系统点击事件
}
}
HTMLButton实现类
1
2
3
4
5
6
7
8
9
10
11
12
public class HTMLButton implements Button{

@Override
public void render() {
// 根据 HTMl 样式渲染按钮
}

@Override
public void onClick(Runnable f) {
// 绑定网络浏览器点击事件
}
}
声明工厂抽象类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 创建者类声明的工厂方法必须返回一个产品类的对象。创建者的子类通常会提供该方法的实现。
abstract class Dialog {
// 创建者还可提供一些工厂方法的默认实现。
abstract Button createButton();

// 请注意,创建者的主要职责并非是创建产品。其中通常会包含一些核心业务
// 逻辑,这些逻辑依赖于由工厂方法返回的产品对象。子类可通过重写工厂方
// 法并使其返回不同类型的产品来间接修改业务逻辑。
void render() {
// 调用工厂方法创建一个产品对象。
Button okButton = createButton();
// 现在使用产品。
okButton.onClick(this::closeDialog);
okButton.render();
}
private void closeDialog() {

}
}
WebDialog实现抽象工厂
1
2
3
4
5
6
public class WebDialog extends Dialog{
@Override
Button createButton() {
return new HTMLButton();
}
}
WindowDialog实现抽象工厂
1
2
3
4
5
6
public class WindowsDialog extends Dialog{
@Override
Button createButton() {
return new WindowsButton();
}
}
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
public class Application {
private Dialog dialog;

// 程序根据当前配置或环境设定选择创建者的类型。
void initialize() {
// 假设readApplicationConfigFile()返回Config类型的对象,Config类包含OS属性。
Config config = readApplicationConfigFile();
if (config.getOS().equals("Windows")) {
dialog = new WindowsDialog();
} else if (config.getOS().equals("Web")) {
dialog = new WebDialog();
} else {
System.out.println(config.getOS());
}
}
// 当前客户端代码会与具体创建者的实例进行交互,但是必须通过其基本接口
// 进行。只要客户端通过基本接口与创建者进行交互,你就可将任何创建者子
// 类传递给客户端。
void main() {
this.initialize();
dialog.render();
}

// 假设的Config类
private static class Config {
private String OS;

public String getOS() {
return OS;
}

public Config() {
this.OS = System.getProperty("os.name");
}
}
// 假设的读取配置文件方法
private Config readApplicationConfigFile() {
// 读取配置文件并返回Config对象的逻辑
return new Config();
}

public static void main(String[] args) {
Application application = new Application();
application.main();
}
}

实现方式

  1. 让所有产品都遵循同一接口。 该接口必须声明对所有产品都有意义的方法。

  2. 在创建类中添加一个空的工厂方法。 该方法的返回类型必须遵循通用的产品接口。

  3. 在创建者代码中找到对于产品构造函数的所有引用。 将它们依次替换为对于工厂方法的调用, 同时将创建产品的代码移入工厂方法

  4. 如果应用中的产品类型太多, 那么为每个产品创建子类并无太大必要, 这时你也可以在子类中复用基类中的控制参数

  5. 如果代码经过上述移动后, 基础工厂方法中已经没有任何代码, 你可以将其转变为抽象类。 如果基础工厂方法中还有其他语句, 你可以将其设置为该方法的默认行为

工厂模式方法优缺点:

优点:你可以避免创建者和具体产品之间的紧密耦合

单一职责原则。 你可以将产品创建代码放在程序的单一位置, 从而使得代码更容易维护

开闭原则。 无需更改现有客户端代码, 你就可以在程序中引入新的产品类型

缺点:应用工厂方法模式需要引入许多新的子类, 代码可能会因此变得更复杂。 最好的情况是将该模式引入创建者类的现有层次结构中

一、概念

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。

在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。

这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数.

幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次,比如:

  • 订单接口, 不能多次创建订单

  • 支付接口, 重复支付同一笔订单只能扣一次钱

  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调

  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等

 

二、常见解决方案

  • 唯一索引 – 防止新增脏数据

  • token机制 – 防止页面重复提交

  • 悲观锁 – 获取数据的时候加锁(锁表或锁行)

  • 乐观锁 – 基于版本号version实现, 在更新数据那一刻校验数据

  • 分布式锁 – redis(jedis、redisson)或zookeeper实现

  • 状态机 – 状态变更, 更新数据时判断状态

 

三、本文实现方案

本文采用第2种方式实现, 即通过redis + token机制实现接口幂等性校验

为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 请求接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token,如果存在, 正常处理业务逻辑, 并从redis中删除此token, 那么, 如果是重复请求, 由于token已被删除, 则不能通过校验, 返回请勿重复操作提示,如果不存在, 说明参数不合法或者是重复请求, 返回提示即可

 

四、核心代码

依赖库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>compile</scope>
</dependency>
自定义注解
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
/**
* token业务处理,提供token创建、token验证接口
* Created by double on 2019/7/11.
*/
@Service
public class TokenServiceImpl implements TokenService {

private static final String TOKEN_NAME = "token";

@Autowired
StringRedisTemplate stringRedisTemplate;

@Override
public ServerResponse createToken() {
//通过UUID来生成token
String tokenValue = "idempotent:token:" + UUID.randomUUID().toString();
//将token放入redis中,设置有效期为60S
stringRedisTemplate.opsForValue().set(tokenValue, "0", 60, TimeUnit.SECONDS);
return ServerResponse.success(tokenValue);
}

/**
* @param request
*/
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
token = request.getParameter(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
//没有携带token,抛异常,这里的异常需要全局捕获
throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
}
}
/*先生成token,拿到token去访问需要幂等性的接口,因为第一次访问,里面有token,不执行下面方法,直接走删除token
若重复访问,之前token删除掉了,redis里面没有token返回false,!false为true,走下面方法,或者访问的是不是生成的token
也为true,走下面逻辑
*/
//token不存在,说明token已经被其他请求删除或者是非法的token
if (!stringRedisTemplate.hasKey(token)) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
boolean del = stringRedisTemplate.delete(token);
if (!del) {
//token删除失败,说明token已经被其他请求删除
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
}

}
IdempotentTokenInterceptor
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
/**
* 接口幂等性校验拦截器
* Created by double on 2019/7/11.
*/
public class IdempotentTokenInterceptor implements HandlerInterceptor {

@Autowired
private TokenService tokenService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
//幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
ApiIdempotent apiIdempotent = handlerMethod.getMethod().getAnnotation(ApiIdempotent.class);
if (apiIdempotent != null) {
tokenService.checkToken(request);
}

return true;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 幂等性测试接口
* Created by double on 2019/7/11.
*/
@RestController
@RequestMapping("/test")
public class TestController {

@Autowired
private TestService testService;

@ApiIdempotent
@PostMapping("testIdempotent")
public ServerResponse testIdempotent() {
return testService.testIdempotence();
}

}
获取token
http://localhost:8081/token

{
    "status": 0,
    "msg": "idempotent:token:80dd47ed-fa63-4b30-82fa-f3ee4fd64a50",
    "data": null
}

 

验证接口安全性
http://localhost:8081/test/testIdempotent?token=idempotent:token:b9ae797d-ed1a-4dbc-a94f-b7e45897f0f5

第一次请求

{
    "status": 0,
    "msg": "test idempotent success",
    "data": null
}

 

重复请求

{
    "status": 1,
    "msg": "请勿重复操作",
    "data": null
}

 

利用jmeter测试工具模拟50个并发请求, 获取一个新的token作为参数

设置50个线程请求一次

image-20200415170757239

设置请求IP、Path、参数等信息image-20200415170807474

查看执行结果,可以看到只有一个请求成功,其他的请求都返回错误

image-20200415170713970

image-20200415170741939

结构体上的函数

我们可以把一个方法关联在一个结构体上:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Saiyan struct {

Name string

Power int

}

func (s *Saiyan) Super() {

s.Power += 10000

}

在上面的代码中,我们可以这么理解,*Saiyan 类型是 Super 方法的接受者。然后我们可以通过下面的代码去调用 Super 方法:

1
2
3
4
5
goku := &Saiyan{"Goku", 9001}

goku.Super()

fmt.Println(goku.Power) // 将会打印出 19001

构造器

结构体没有构造器。但是,你可以创建一个返回所期望类型的实例的函数(类似于工厂):

1
2
3
4
5
6
func NewSaiyan(name string, power int) *Saiyan {
return &Saiyan{
Name: name,
Power: power,
}
}

这种模式以错误的方式惹恼了很多开发人员。一方面,这里有一点轻微的语法变化;另一方面,它确实感觉有点不那么明显。

我们的工厂不必返回一个指针;下面的形式是完全有效的

1
2
3
4
5
6
func NewSaiyan(name string, power int) Saiyan {
return Saiyan{
Name: name,
Power: power,
}
}

结构体的字段

到目前为止的例子中,Saiyan 有两个字段 Name 和 Power,其类型分别为 string 和 int。字段可以是任何类型 – 包括其他结构体类型以及目前我们还没有提及的 array,maps,interfaces 和 functions 等类型。

例如,我们可以扩展 Saiyan 的定义:

1
type Saiyan struct {   Name string   Power int   Father *Saiyan }

然后我们通过下面的方式初始化:

1
gohan := &Saiyan{   Name: "Gohan",   Power: 1000,   Father: &Saiyan {     Name: "Goku",     Power: 9001,     Father: nil,   }, }

New

尽管缺少构造器,Go 语言却有一个内置的 new 函数,使用它来分配类型所需要的内存。 new(X) 的结果与 &X{} 相同。

1
2
3
goku := new(Saiyan)
// same as
goku := &Saiyan{}

如何使用取决于你,但是你会发现大多数人更偏爱后一种写法无论是否有字段需要初始化,因为这看起来更具可读性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
goku := new(Saiyan)

goku.name = "goku"

goku.power = 9001

//vs

goku := &Saiyan {

Name: "goku",

Power: 9000,

}

无论你选择哪一种,如果你遵循上述的工厂模式,就可以保护剩余的代码而不必知道或担心内存分配细节

组合

Go 支持组合, 这是将一个结构包含进另一个结构的行为。在某些语言中,这种行为叫做 特质 或者 混合。 没有明确的组合机制的语言总是可以做到这一点。在 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
public class Person {

private String name;

public String getName() {

return this.name;

}

}

public class Saiyan {

// Saiyan 中包含着 person 对象

private Person person;

// 将请求转发到 person 中

public String getName() {

return this.person.getName();

}

...

}

这可能会非常繁琐。Person 的每个方法都需要在 Saiyan 中重复。Go 避免了这种复杂性:

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
type Person struct {

Name string

}

func (p *Person) Introduce() {

fmt.Printf("Hi, I'm %s\n", p.Name)

}

type Saiyan struct {

*Person

Power int

}

// 使用它

goku := &Saiyan{

Person: &Person{"Goku"},

Power: 9001,

}

goku.Introduce()

Saiyan 结构体有一个 Person 类型的字段。由于我们没有显式地给它一个字段名,所以我们可以隐式地访问组合类型的字段和函数。然而,Go 编译器确实给了它一个字段名 Person,下面这样完全有效:

1
2
3
4
5
6
7
8
9
goku := &Saiyan{

Person: &Person{"Goku"},

}

fmt.Println(goku.Name)

fmt.Println(goku.Person.Name)

上面两个都打印 「Goku」。

组合比继承更好吗?许多人认为它是一种更好的组织代码的方式。当使用继承的时候,你的类和超类紧密耦合在一起,你最终专注于结构而不是行为

指针 VS 值

当你写 Go 代码的时候,很自然就会去问自己 应该是值还是指向值的指针呢? 这儿有两个好消息,首先,无论我们讨论下面哪一项,答案都是一样的:

  • 局部变量赋值

  • 结构体指针

  • 函数返回值

  • 函数参数

  • 方法接收器

第二,如果你不确定,那就用指针咯。

正如我们已经看到的,传值是一个使数据不可变的好方法(函数中改变它不会反映到调用代码中)。有时,这是你想要的行为,但是通常情况下,不是这样的。

即使你不打算改变数据,也要考虑创建大型结构体副本的成本。相反,你可能有一些小的结构:

1
2
3
4
5
6
7
type Point struct {

X int

Y int

}

这种情况下,复制结构的成本能够通过直接访问 X 和 Y 来抵消,而没有其它任何间接操作。

还有,这些案例都是很微妙的,除非你迭代成千上万个这样的指针,否则你不会注意到差异

SpringBoot接口,统一实现接口封装

在以SpringBoot开发Restful接口时,统一返回方便前端进行开发和封装,以及出现时给出响应编码和信息

RESTful API接口

什么是 REST

Representational State Transfer,翻译是“表现层状态转化”。可以总结为一句话:REST 是所有 Web 应用都应该遵守的架构设计指导原则

面向资源是 REST 最明显的特征,对于同一个资源的一组不同的操作。资源是服务器上一个可命名的抽象概念,资源是以名词为核心来组织的,首先关注的是名词。REST 要求,必须通过统一的接口来对资源执行各种操作。对于每个资源只能执行一组有限的操作。

什么是 RESTful API

符合 REST 设计标准的 API,即 RESTful API。REST 架构设计,遵循的各项标准和准则,就是 HTTP 协议的表现,换句话说,HTTP 协议就是属于 REST 架构的设计模式。比如,无状态,请求-响应

为什么要统一封装接口

现在大多数项目采用前后分离的模式进行开发,统一返回方便前端进行开发和封装,以及出现时给出响应编码和信息

状态码封装

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.example.springbootvalidation.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;


import java.util.Arrays;
import java.util.Collections;
import java.util.List;


@Getter
@AllArgsConstructor
public enum ResponseStatus {
SUCCESS("200", "success"),
FAIL("500", "failed"),

HTTP_STATUS_200("200", "ok"),
HTTP_STATUS_400("400", "request error"),
HTTP_STATUS_401("401", "no authentication"),
HTTP_STATUS_403("403", "no authorities"),
HTTP_STATUS_500("500", "server error");

public static final List<ResponseStatus> HTTP_STATUS_ALL = Collections.unmodifiableList(
Arrays.asList(HTTP_STATUS_200, HTTP_STATUS_400, HTTP_STATUS_401, HTTP_STATUS_403, HTTP_STATUS_500
));
/**
* response code
*/
private final String responseCode;
/**
* description.
*/
private final String description;

}

返回内容封装

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
package com.example.springbootvalidation.entity;

import lombok.Builder;

import java.io.Serializable;

@Builder
public class ResponseResult <T>{
/**
* response timestamp.
*/
private long timestamp;

/**
* response code, 200 -> OK.
*/
private String status;

/**
* response message.
*/
private String message;

/**
* response data.
*/
private T data;
public static <T> ResponseResult<T> success() {
return success(null);
}

/**
* response success result wrapper.
*
* @param data response data
* @param <T> type of data class
* @return response result
*/
public static <T> ResponseResult<T> success(T data) {
return ResponseResult.<T>builder().data(data)
.message(ResponseStatus.SUCCESS.getDescription())
.status(ResponseStatus.SUCCESS.getResponseCode())
.timestamp(System.currentTimeMillis())
.build();
}

/**
* response error result wrapper.
*
* @param message error message
* @param <T> type of data class
* @return response result
*/
public static <T extends Serializable> ResponseResult<T> fail(String message) {
return fail(null, message);
}

/**
* response error result wrapper.
*
* @param data response data
* @param message error message
* @param <T> type of data class
* @return response result
*/
public static <T> ResponseResult<T> fail(T data, String message) {
return ResponseResult.<T>builder().data(data)
.message(message)
.status(ResponseStatus.FAIL.getResponseCode())
.timestamp(System.currentTimeMillis())
.build();
}
}

源码地址:https://github.com/Breeze1203/JavaAdvanced/tree/main/springboot-demo/spring-boot-validation

AuthenticationProvider 接口

AuthenticationProvider 接口在 Spring Security 中扮演着至关重要的角色,它是用于执行身份验证的关键组件之一。下面我将通过通俗易懂的方式解释一下 接口的作用:

想象一下,您正在访问一个需要登录才能查看的网站。当您输入用户名和密码后,系统需要验证这些凭据是否正确,以确定您是否有权限访问需登录的页面。这时候就轮到 AuthenticationProvider 上场了。

AuthenticationProvider 主要负责接收用户的认证请求,验证用户提供的凭据(比如用户名和密码),然后确定用户是否是合法用户。具体来说,AuthenticationProvider 的作用包括:

  1. 验证用户身份:AuthenticationProvider 通过提供的认证信息(例如用户名密码)验证用户的身份,检查用户是否合法。

  2. 处理认证:一旦用户提交了认证信息,Spring Security 会将认证请求交给 AuthenticationProvider 处理,以便对用户进行身份验证。

  3. 支持多种认证方式:AuthenticationProvider 可以支持多种认证方式,比如用户名密码认证、基于证书的认证、LDAP 认证等,以满足不同场景下的认证需求。

  4. 返回认证结果:一旦验证完成,AuthenticationProvider 将返回一个经过认证的 AuthenticationProvider 对象,其中包含用户的身份信息、权限信息等。

综上所述,AuthenticationProvider 是整个身份验证流程中的核心组件,负责验证用户身份,处理认证请求,并返回认证结果。通过实现自定义的 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
33
34
35
@Component
public class MyPwdAuthenticationProvider implements AuthenticationProvider {
@Autowired
private CustomerRepository customerRepository;

@Autowired
private PasswordEncoder passwordEncoder;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String pwd = authentication.getCredentials().toString();
System.out.println("username: "+username);
System.out.println("pwd: "+pwd);

List<Customer> customer = customerRepository.findByEmail(username);
if (customer.size() > 0) {
if (passwordEncoder.matches(pwd, customer.get(0).getPwd())) {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(customer.get(0).getRole()));
return new UsernamePasswordAuthenticationToken(username, pwd, authorities);
} else {
throw new BadCredentialsException("Invalid password!");
}
}else {
throw new BadCredentialsException("No user registered with this details!");
}
}

@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));

}
}

重写的authenticate方法里面的Authentication对象,包含了用户登录时的用户信息,我们可以通过这里的信息与数据库里面查询出来的信息进行比对,来进行身份验证;

重写的第二个supports方法的作用是,有一个 supports(Class<?> authentication) 方法,其作用是指定该 AuthenticationProvider 是否支持对特定类型的 Authentication 对象进行身份认证。

具体来说,这个方法的作用是判断传入的 authentication 对象的类型是否属于当前 AuthenticationProvider 支持的类型。如果 supports 方法返回 true,则表示当前的 AuthenticationProvider 接受并能够处理该类型的 Authentication 对象;如果返回 false,则表示当前的 AuthenticationProvider 不适用于这种类型的 Authentication 对象,Spring Security 将尝试寻找其他匹配的 AuthenticationProvider。

通常情况下,我们会在自定义的 AuthenticationProvider 实现类中重写这个 supports 方法,根据具体的需求来确定该 AuthenticationProvider 是否支持某种类型的 Authentication 对象。通过合理配置 supports 方法,可以确保在多个认证提供者共同工作时,每个提供者只处理自己支持的认证类型,实现更加清晰和灵活的认证流程;

前面我们已经学习了不少基础和高级数据类型,在 Go 语言里面,我们还可以通过自定义类型来表示一些特殊的数据结构和业务逻辑。

使用关键字 type 来声明:

1
type NAME TYPE

声明语法

  • 单次声明
1
type City string
  • 批量声明
1
2
3
4
5
6
7
8
9
10
11
12
13
type (
B0 = int8
B1 = int16
B2 = int32
B3 = int64
)

type (
A0 int8
A1 int16
A2 int32
A3 int64
)

简单示例

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

type City string

func main() {
city := City("上海")
fmt.Println(city)
}

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

type City string
type Age int

func main() {
city := City("北京")
fmt.Println("I live in", city + " 上海") // 字符串拼接
fmt.Println(len(city)) // len 方法

middle := Age(12)

if middle >= 12 {
fmt.Println("Middle is bigger than 12")
}
}

总结: 自定义类型的原始类型的所有操作同样适用。

函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

type Age int

func main() {
middle := Age(12)
printAge(middle)
}

func printAge(age int) {
fmt.Println("Age is", age)
}

当我们运行代码的时候会出现 ./main.go:11:10: cannot use middle (type Age) as type int in argument to printAge 的错误。

因为 printAge 方法期望的是 int 类型,但是我们传入的参数是 Age,他们虽然具有相同的值,但为不同的类型。

我们可以采用显式的类型转换( printAge(int(primary)))来修复。

不同自定义类型间的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

type Age int
type Height int

func main() {
age := Age(12)
height := Height(175)

fmt.Println(height / age)
}

当我们运行代码会出现 ./main.go:12:21: invalid operation: height / age (mismatched types Height and Age) 错误,修复方法使用显式转换:

1
fmt.Println(int(height) / int(age))

声明和初始化

当我们第一次看到变量和声明时,我们只看了内置类型,比如整数和字符串。既然现在我们要讨论结构,那么我们需要把讨论范围扩展到指针。

创建结构的值的最简单的方式是:

1
2
3
4
5
6
7
goku := Saiyan{

Name: "Goku",

Power: 9000,

}

注意: 上述结构末尾的逗号 , 是必需的。没有它的话,编译器就会报错。你将会喜欢上这种必需的一致性,尤其当你使用一个与这种风格相反的语言或格式的时候。

我们不必设置所有或哪怕一个字段。下面这些都是有效的:

1
2
3
4
5
6
7
goku := Saiyan{}

// or

goku := Saiyan{Name: "Goku"}

goku.Power = 9000

就像未赋值的变量其值默认为 0 一样,字段也是如此。

此外,你可以不写字段名,依赖字段顺序去初始化结构体 (但是为了可读性,你应该把字段名写清楚):

1
goku := Saiyan{"Goku", 9000}

以上所有的示例都是声明变量 goku 并赋值。

许多时候,我们并不想让一个变量直接关联到值,而是让它的值为一个指针,通过指针关联到值。一个指针就是内存中的一个地址;指针的值就是实际值的地址。这是间接地获取值的方式。形象地来说,指针和实际值的关系就相当于房子和指向该房子的方向之间的关系。

为什么我们想要一个指针指向值而不是直接包含该值呢?这归结为 Go 中传递参数到函数的方式:镜像复制。知道了这个,尝试理解一下下面的代码呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {

goku := Saiyan{"Power", 9000}

Super(goku)

fmt.Println(goku.Power)

}

func Super(s Saiyan) {

s.Power += 10000

}

上面程序运行的结果是 9000,而不是 19000,。为什么?因为 Super 修改了原始值 goku 的复制版本,而不是它本身,所以,Super 中的修改并不影响上层调用者。现在为了达到你的期望,我们可以传递一个指针到函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {

goku := &Saiyan{"Power", 9000}

Super(goku)

fmt.Println(goku.Power)

}

func Super(s *Saiyan) {

s.Power += 10000

}

这一次,我们修改了两处代码。第一个是使用了 & 操作符以获取值的地址(它就是 取地址 操作符)。然后,我们修改了 Super 参数期望的类型。它之前期望一个 Saiyan 类型,但是现在期望一个地址类型 Saiyan,这里 X 意思是 指向类型 X 值的指针 。很显然类型 Saiyan 和 *Saiyan 是有关系的,但是他们是不同的类型。

这里注意到我们仍然传递了一个 goku 的值的副本给 Super,但这时 goku 的值其实是一个地址。所以这个副本值也是一个与原值相等的地址,这就是我们间接传值的方式。想象一下,就像复制一个指向饭店的方向牌。你所拥有的是一个方向牌的副本,但是它仍然指向原来的饭店。

我们可以证实一下这是一个地址的副本,通过修改其指向的值(尽管这可能不是你真正想做的事情):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {

goku := &Saiyan{"Power", 9000}

Super(goku)

fmt.Println(goku.Power)

}

func Super(s *Saiyan) {

s = &Saiyan{"Gohan", 1000}

}

上面的代码,又一次地输出 9000。就像许多语言表现的那样,包括 Ruby,Python, Java 和 C#,Go 以及部分的 C#,只是让这个事实变得更明显一些。

同样很明显的是,复制一个指针比复制一个复杂的结构的消耗小多了。在 64 位的机器上面,一个指针占据 64 bit 的空间。如果我们有一个包含很多字段的结构,创建它的副本将会是一个很昂贵的操作。指针的真正价值在于能够分享它所指向的值。我们是想让 Super 修改 goku 的副本还是修改共享的 goku 值本身呢?

自定义认证(Authentication)的存储位置

默认情况下, Spring Security 在 HTTP 会话中为你存储 security context。然而,这里有几个原因,你可能想自定义:

  • 你可能想在 HttpSessionSecurityContextRepository 实例上调用单个 setter

  • 你可能想在缓存或数据库中存储 security context,以实现横向扩展。

首先,你需要创建一个 SecurityContextRepository 的实现,或者使用一个现有的实现,如 HttpSessionSecurityContextRepository

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.example.eochadmin.config;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.context.DeferredSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;

public class MySecurityContextRepository implements SecurityContextRepository {
private static final String SESSION_ATTR_NAME = "SPRING_SECURITY_CONTEXT";
/*
用来从存储中加载安全上下文,通常情况下,你会在这里实现逻辑来根据请求的信息加载和返回相应的安全上下文对象
*/
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
/*
首先获取请求中的 HttpSession 对象,如果存在会话则尝试获取名为 SPRING_SECURITY_CONTEXT 的属性,
即安全上下文对象。如果会话不存在或者属性为空,则返回 null
*/
HttpServletRequest request = requestResponseHolder.getRequest();
HttpSession session = request.getSession(false); // Do not create session if it doesn't exist
if (session != null) {
return (SecurityContext) session.getAttribute(SESSION_ATTR_NAME);
} else {
return null;
}
}

/*它的作用是通过请求对象加载延迟的安全上下文。在这里,
实现调用了 SecurityContextRepository.super.loadDeferredContext(request),这将委托给接口的默认实现来处理。
通常情况下,你可以在这里定制化实现,根据具体的需求来加载延迟的安全上下文
*/
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
return SecurityContextRepository.super.loadDeferredContext(request);
}

/*
这个方法用来保存安全上下文到存储中。在当前代码中,方法体是空的,没有实际的保存操作。通常你会在这里编写逻辑,
将给定的安全上下文对象保存到某种持久化存储中,以便后续的访问可以获取到正确的安全上下文信息
*/
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
/*
首先获取请求中的 HttpSession 对象,如果不存在会话则创建一个新的会话。
然后将安全上下文对象存储在会话的 SPRING_SECURITY_CONTEXT 属性中
*/
HttpSession session = request.getSession(true); // Create session if it doesn't exist
session.setAttribute(SESSION_ATTR_NAME, context);
}

/*
这个方法用来检查请求中是否包含有效的安全上下文。在这里的实现中,直接返回了 false,
表示不包含有效的安全上下文。通常情况下,你需要在这里实现逻辑来检查给定请求是否有相关的安全上下文信息
*/
@Override
public boolean containsContext(HttpServletRequest request) {
/*
首先获取请求中的 HttpSession 对象,如果会话存在且会话中的 SPRING_SECURITY_CONTEXT 属性不为空,
则返回 true;否则返回 false
*/
HttpSession session = request.getSession(false); // Do not create session if it doesn't exist
return session != null && session.getAttribute(SESSION_ATTR_NAME) != null;
}
}

然后你可以在 HttpSecurity 中设置它。

1
2
3
4
5
6
7
8
9
10
@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
MySecurityContextRepository repo = new MySecurityContextRepository();
http.securityContext((context) -> context.securityContextRepository(repo));
return http.build();
}
}

在上面的示例中,确实是通过 HttpSession 存储了安全上下文(即 SecurityContext 对象),而不是直接存储认证用户的详细信息。让我来进一步解释:

1. 存储的内容

HttpSession 中存储的是 SecurityContext 对象,而 SecurityContext 包含了 Authentication 对象,后者表示认证用户的详细信息(例如用户名、权限等)。

2. SecurityContext 中的 Authentication 对象

SecurityContext 是 Spring Security 中用来持有当前用户的信息的容器。

Authentication 对象包含了认证用户的详细信息,它是 Principal(主体)和 GrantedAuthority(授权信息)的封装。

3. 存储过程

- 在上述示例中,`saveContext` 方法将整个 SecurityContext 对象存储在 HttpSession 中的 SPRING_SECURITY_CONTEXT 属性中。

- 这意味着在会话中,我们可以通过 SecurityContext 对象来获取 Authentication 对象,进而获取认证用户的详细信息。

4. 认证用户信息的存储

- 认证用户的具体信息(例如用户名、权限)通常包含在 Authentication 对象中。

- Spring Security 在认证成功后,会将有效的 Authentication 对象设置到 SecurityContext 中,然后由 saveContext 方法负责将整个 SecurityContext 存储在 HttpSession

因此,虽然代码示例中直接操作的是 SecurityContext,实际上 SecurityContext 中包含了 Authentication 对象,从而间接地存储了认证用户的信息。这种设计符合了 Spring Security 的认证和授权机制,确保了安全上下文和认证信息的正确管理和使用。

手动存储 Authentication

例如,在某些情况下,你可能要手动验证用户,而不是依靠  Spring Security filter。你可以使用自定义 filter 或 Spring MVC controller 端点来做到这一点。如果你想在请求之间保存 认证,例如在 HttpSession 中,你就必须这样做

1
2
3
4
5
6
7
8
9
10
11
12
13
private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

@PostMapping("/login")
public void login(@RequestParam("username") String username,@RequestParam("password") String password, HttpServletRequest request, HttpServletResponse response) {
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
username, password);
MyAuthenticationManager authenticationManager = new MyAuthenticationManager();
Authentication authentication = authenticationManager.authenticate(token);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response);
}
  1. SecurityContextRepository 添加到 controller 中

  2. 注入 HttpServletRequestHttpServletResponse,以便能够保存 SecurityContext

  3. 使用提供的凭证创建一个未经 认证的UsernamePasswordAuthenticationToken

  4. 调用 AuthenticationManager#authenticate 来验证用户

  5. 创建一个 SecurityContext,并在其中设置 Authentication

  6. SecurityContextRepository 中保存 SecurityContext

配置无状态认证(Authentication)的持久化

有时不需要创建和维护一个 HttpSession,例如,在不同的请求中坚持认证。一些认证机制,如 HTTP Basic 是无状态的,因此,在每次请求时都会重新认证用户。

如果你不希望创建会话,你可以使用 SessionCreationPolicy.STATELESS,像这样

1
2
http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));// 配置为无状态会话

上述配置是将 SecurityContextRepository 配置 为使用 NullSecurityContextRepository,同时也是为了 防止请求被保存在会话中

配置并发会话控制

如果你希望对单个用户登录你的应用程序的能力进行限制, Spring Security 支持开箱即用,只需添加以下简单内容。首先,你需要在你的配置中添加以下 listener,以保持  Spring Security 对会话生命周期事件的更新

1
2
3
4
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
1
2
3
4
5
6
7
8
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.maximumSessions(1)
);
return http.build();
}

会话会自行过期,不需要做任何事情来确保 security context 被删除。也就是说, Spring Security 可以检测到会话过期的情况,并采取你指定的具体行动。例如,当用户用已经过期的会话发出请求时,你可能想重定向到一个特定的端点。这可以通过 HttpSecurity 中的 invalidSessionUrl 实现

1
2
3
4
5
6
7
8
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.invalidSessionUrl("/invalidSession")
);
return http.build();
}

请注意,如果你使用这种机制来检测会话超时,如果用户注销后没有关闭浏览器又重新登录,它可能会错误地报告一个错误。这是因为当你使会话失效时,session cookie 没有被清除,即使用户已经注销,也会重新提交。如果你的情况是这样,你可能想 配置注销来清除 session cookie。

定制失效会话的策略

invalidSessionUrl 是使用 SimpleRedirectInvalidSessionStrategy 实现 来设置 InvalidSessionStrategy 的方便方法。如果你想自定义行为,你可以实现 InvalidSessionStrategy 接口并使用 invalidSessionStrategy 方法进行配置

注销时清除 Session Cookies

你可以在注销时明确地删除 JESSIONID cookie,例如通过使用 logout handler 中的 Clear-Site-Data header:

1
2
3
4
5
6
7
8
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout((logout) -> logout
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
);
return http.build();
}

Copied!

这样做的好处是与容器无关,可以与任何支持 Clear-Site-Data header 的容器一起工作。

作为一种替代方法,你也可以在 logout handler 中使用以下语法:

1
2
3
4
5
6
7
8
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout(logout -> logout
.deleteCookies("JSESSIONID")
);
return http.build();
}

Copied

SecurityFilterChain主要架构图

abstractauthenticationprocessingfilter

SecurityFilterChain 是 Spring Security 中的一个重要概念,它实际上代表了一个或多个安全过滤器链(Filter Chain),用于处理 HTTP 请求的安全性

在 Spring Security 中,每个 SecurityFilterChain 对象都定义了一组安全过滤器(Security Filters),这些过滤器按顺序对请求进行处理

AbstractAuthenticationProcessingFilter

可以看到AbstractAuthenticationProcessingFilter是SecurityFilterChain当中的一个过滤器,官方的定义是AbstractAuthenticationProcessingFilter - 一个用于 认证的基本 Filter。这也让我们很好地了解了认证的高层流程以及各部分是如何协作的

可以看出这个过滤器是进行认证的,里面包含了各种过滤的方法

Authentication接口

Authentication 接口在 Spring Security中主要有两个作用。

  • 对 AuthenticationManager 的一个输入,用于提供用户为验证而提供的凭证。当在这种情况下使用时,isAuthenticated() 返回 false

将Authentication对象传递给AuthenticationManager

  • 代表当前 认证的用户。你可以从 SecurityContext 中获得当前的 Authentication(认证成功

 认证(Authentication)包含了:

  • principal: 识别用户。当用用户名/密码进行认证时,这通常是 UserDetails 的一个实例。

  • credentials: 通常是一个密码。在许多情况下,这在用户被认证后被清除,以确保它不会被泄露。

  • authorities: GrantedAuthority 实例是用户被授予的高级权限。两个例子是角色(role)和作用域(scope)

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

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

GrantedAuthority

GrantedAuthority 实例是用户被授予的高级权限。两个例子是角色(role)和作用域(scope)。

你可以从Authentication.getAuthorities()法中获得 GrantedAuthority 实例。这个方法提供了一个 GrantedAuthority 对象的集合。毫不奇怪,GrantedAuthority 是授予委托人的一种权限。这种 授权通常是 “roles”,例如 ROLE_ADMINISTRATOR 或 ROLE_HR_SUPERVISOR。这些角色后来被配置为Web 授权、方法授权和域对象授权。 Spring Security 的其他部分会解释这些授权并期望它们存在。当使用基于用户名/密码的 认证时, GrantedAuthority 实例通常由 UserDetailsService 加载。

AuthenticationManager接口

前面提到的Authentication对象作为AuthenticationManager的输入,这个类的作用是对输入的Authentication对象进行认证;

官方解释:AuthenticationManager 是定义  Spring Security 的 Filter 如何执行 认证 的API。返回的 认证是由调用 AuthenticationManager 的控制器(即 Spring Security的 Filter 实例)在 SecurityContextHolder 上设置的。如果你不与  Spring Security 的 Filter 实例集成,你可以直接设置 SecurityContextHolder,不需要使用 AuthenticationManager。

AuthenticationManager 的实现可以是任何东西,(可以定义自己的AuthenticationManager,只需实现它,并注入到spring容器,然后可在SecurityContextHolder 上设置),例如下面

ProviderManager

ProviderManager是最常用的AuthenticationManager的实现(就像我我们定义自己的AuthenticationManager,如上图)。ProviderManager 委托给一个 ListAuthenticationProvider实例(这里的意思该类里面会有一个AuthenticationProvider的集合),查看源码如图

每个 AuthenticationProvider 都有机会表明 认证应该是成功的、失败的,或者表明它不能做出决定并允许下游的 AuthenticationProvider 来决定。如果配置的 AuthenticationProvider 实例中没有一个能进行 认证,那么认证就会以 ProviderNotFoundException 而失败,这是一个特殊的 AuthenticationException,表明 ProviderManager 没有被配置为支持被传入它的 Authentication 类型(这里的意思大概是,会遍历List<AuthenticationProvider> providers,依次认证,如果当前不能作出决定进行认证,则交由给下一个AuthenticationProvider认证,如果没有一个能进行认证,则会 ProviderNotFoundException 而失败

providermanager

在实践中,每个 AuthenticationProvider 都知道如何执行特定类型的 认证。例如,一个 AuthenticationProvider 可能能够验证一个用户名/密码,而另一个可能能够验证一个 SAML 断言。这让每个 AuthenticationProvider 在支持多种类型的 认证的同时,可以做一种非常具体的认证类型,并且只暴露一个 AuthenticationManager Bean。

ProviderManager 还允许配置一个可选的父级 AuthenticationManager,在没有 AuthenticationProvider 可以执行 认证的情况下,可以参考它。父级可以是任何类型的 AuthenticationManager,但它通常是 ProviderManager 的一个实例

事实上,多个 ProviderManager 实例可能共享同一个父级 AuthenticationManager。这在有多个 SecurityFilterChain 实例的场景中有些常见,这些实例有一些共同的 认证(共享的父 AuthenticationManager),但也有不同的认证机制(不同的 ProviderManager 实例)。

providermanagers parent

默认情况下,ProviderManager 会尝试从 Authentication 对象中清除任何敏感的凭证信息,该对象由成功的 认证请求返回。这可以防止密码等信息在 HttpSession 中保留超过必要的时间。

当你使用用户对象的缓存时,这可能会导致问题,例如,在一个无状态的应用程序中提高性能。如果 Authentication 包含对缓存中的一个对象的引用(比如 UserDetails 实例),而这个对象的凭证已经被删除,那么就不可能再针对缓存的值进行 认证。如果你使用一个缓存,你需要考虑到这一点。一个明显的解决方案是,首先在缓存实现中或在创建返回的 Authentication 对象的 AuthenticationProvider 中制作一个对象的副本。另外,你可以禁用 ProviderManager 上的 eraseCredentialsAfterAuthentication 属性。

在 ProviderManager 类中,最后一个变量 eraseCredentialsAfterAuthentication 是用来指定在认证成功后是否擦除 Authentication 对象中的凭据信息的标志位

具体来说,当 eraseCredentialsAfterAuthentication 设置为 true 时,在成功进行身份验证后,Authentication 对象中的凭据信息(通常是密码)将被擦除或清除。这样做的目的是为了增加安全性,避免在系统的其他部分中意外泄露或暴露凭据信息。

在 Spring Security 中,默认情况下,ProviderManager 的 eraseCredentialsAfterAuthentication 默认为 true。这意味着一旦用户成功通过身份验证,其 Authentication 对象中的密码等敏感信息会被清除。这种做法可以防止在认证成功后密码继续存在于内存中,从而降低了安全风险

AuthenticationProvider

你可以在 ProviderManager 中注入多个 AuthenticationProvider 实例。每个 AuthenticationProvider 都执行一种特定类型的 认证。例如, DaoAuthenticationProvider 支持基于用户名/密码的 认证,而 JwtAuthenticationProvider 支持认证JWT令牌

用 AuthenticationEntryPoint 请求凭证

AuthenticationEntryPoint 用于发送一个要求客户端提供凭证的HTTP响应

有时,客户端会主动包含凭证(如用户名和密码)来请求资源。在这些情况下, Spring Security 不需要提供要求客户端提供凭证的HTTP响应,因为这些凭证已经被包括在内。

在其他情况下,客户端向他们未被 授权访问的资源发出未经 认证的请求。在这种情况下, AuthenticationEntryPoint 的实现被用来请求客户端的凭证。 AuthenticationEntryPoint 的实现可能会执行 重定向到一个登录页面,用 WWW-Authenticate 头来响应,或采取其他行动

解读:这段话的意思是关于 AuthenticationEntryPoint 在 Spring Security 中的作用和使用场景的解释。让我们逐句解释:

1. AuthenticationEntryPoint 是 Spring Security 中的一个接口,用于处理未经身份验证的请求。当客户端请求一个需要认证的资源时,但未提供认证凭据(如用户名和密码)时,`AuthenticationEntryPoint` 负责向客户端发送一个响应,要求客户端提供凭证,通常是通过 HTTP 401 状态码。

2. 有时,客户端会主动包含凭证(如用户名和密码)来请求资源。在这些情况下, Spring Security 不需要提供要求客户端提供凭证的HTTP响应,因为这些凭证已经被包括在内。

当客户端在请求中已经包含了有效的凭证(如基本认证中的用户名和密码),Spring Security 就无需再发送要求提供凭证的 HTTP 响应。这种情况下,Spring Security 可以直接使用客户端提供的凭证进行认证,而不需要触发 AuthenticationEntryPoint。

3. 在其他情况下,客户端向他们未被授权访问的资源发出未经认证的请求。在这种情况下, AuthenticationEntryPoint 的实现被用来请求客户端的凭证。

如果客户端请求的资源需要认证,但客户端未提供有效的凭证或者提供的凭证不足以授权访问该资源,Spring Security 就会使用配置的 AuthenticationEntryPoint 来处理这种情况。这时,`AuthenticationEntryPoint` 可能会执行一些操作,如重定向到登录页面、发送 WWW-Authenticate 头部以触发浏览器弹出认证对话框,或者返回其他自定义的响应来要求客户端提供正确的凭证

AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter 被用作验证用户凭证的基础 Filter。在 认证凭证之前, Spring Security 通常通过使用AuthenticationEntryPoint 来请求凭证。

接下来,AbstractAuthenticationProcessingFilter 可以对提交给它的任何 认证请求进行认证。

abstractauthenticationprocessingfilter

当用户提交他们的凭证时,AbstractAuthenticationProcessingFilter 会从 HttpServletRequest 中创建一个要认证的Authentication。创建的认证的类型取决于 AbstractAuthenticationProcessingFilter 的子类。例如,UsernamePasswordAuthenticationFilter从 HttpServletRequest 中提交的 username 和 password 创建一个 UsernamePasswordAuthenticationToken。

接下来,Authentication 被传入 AuthenticationManager,以进行 认证。

如果 认证失败,则为 Failure。

  • SecurityContextHolder 被清空。

  • RememberMeServices.loginFail 被调用。如果没有配置记住我(remember me),这就是一个无用功。请参阅 rememberme 包。

  • AuthenticationFailureHandler 被调用。参见 AuthenticationFailureHandler 接口。

如果认证成功,则为 Success。

  • SessionAuthenticationStrategy 被通知有新的登录。参见 SessionAuthenticationStrategy 接口。

  • Authentication 是在 SecurityContextHolder 上设置的。后来,如果你需要保存 SecurityContext 以便在未来的请求中自动设置,必须显式调用 SecurityContextRepository#saveContext。参见 SecurityContextHolderFilter 类。

  • RememberMeServices.loginSuccess 被调用。如果没有配置 remember me,这就是一个无用功。

  • ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent 事件。

  • AuthenticationSuccessHandler 被调用。

适配器模式是一种结构型设计模式, 它能使接口不兼容的对象能够相互合作。

适配器设计模式

问题

假如你正在开发一款股票市场监测程序, 它会从不同来源下载 XML 格式的股票数据, 然后向用户呈现出美观的图表。

在开发过程中, 你决定在程序中整合一个第三方智能分析函数库。 但是遇到了一个问题, 那就是分析函数库只兼容 JSON 格式的数据。

整合分析函数库之前的程序结构

你无法 “直接” 使用分析函数库, 因为它所需的输入数据格式与你的程序不兼容。

你可以修改程序库来支持 XML。 但是, 这可能需要修改部分依赖该程序库的现有代码。 甚至还有更糟糕的情况, 你可能根本没有程序库的源代码, 从而无法对其进行修改。

解决方案

你可以创建一个适配器。 这是一个特殊的对象, 能够转换对象接口, 使其能与其他对象进行交互。

适配器模式通过封装对象将复杂的转换过程隐藏于幕后。 被封装的对象甚至察觉不到适配器的存在。 例如, 你可以使用一个将所有数据转换为英制单位 (如英尺和英里) 的适配器封装运行于米和千米单位制中的对象。

适配器不仅可以转换不同格式的数据, 其还有助于采用不同接口的对象之间的合作。 它的运作方式如下:

  1. 适配器实现与其中一个现有对象兼容的接口。

  2. 现有对象可以使用该接口安全地调用适配器方法。

  3. 适配器方法被调用后将以另一个对象兼容的格式和顺序将请求传递给该对象。

有时你甚至可以创建一个双向适配器来实现双向转换调用。

适配器解决方案

让我们回到股票市场程序。 为了解决数据格式不兼容的问题, 你可以为分析函数库中的每个类创建将 XML 转换为 JSON 格式的适配器, 然后让客户端仅通过这些适配器来与函数库进行交流。 当某个适配器被调用时, 它会将传入的 XML 数据转换为 JSON 结构, 并将其传递给被封装分析对象的相应方法。

真实世界类比

适配器模式的示例

出国旅行前后的旅行箱。

如果你是第一次从美国到欧洲旅行, 那么在给笔记本充电时可能会大吃一惊。 不同国家的电源插头和插座标准不同。 美国插头和德国插座不匹配。 同时提供美国标准插座和欧洲标准插头的电源适配器可以解决你的难题。

适配器模式结构

对象适配器

实现时使用了构成原则: 适配器实现了其中一个对象的接口, 并对另一个对象进行封装。 所有流行的编程语言都可以实现适配器。

适配器设计模式的结构(对象适配器)
  1. 客户端 (Client) 是包含当前程序业务逻辑的类。

  2. 客户端接口 (Client Interface) 描述了其他类与客户端代码合作时必须遵循的协议。

  3. 服务 (Service) 中有一些功能类 (通常来自第三方或遗留系统)。 客户端与其接口不兼容, 因此无法直接调用其功能。

  4. 适配器 (Adapter) 是一个可以同时与客户端和服务交互的类: 它在实现客户端接口的同时封装了服务对象。 适配器接受客户端通过适配器接口发起的调用, 并将其转换为适用于被封装服务对象的调用。

  5. 客户端代码只需通过接口与适配器交互即可, 无需与具体的适配器类耦合。 因此, 你可以向程序中添加新类型的适配器而无需修改已有代码。 这在服务类的接口被更改或替换时很有用: 你无需修改客户端代码就可以创建新的适配器类。

类适配器

这一实现使用了继承机制: 适配器同时继承两个对象的接口。 请注意, 这种方式仅能在支持多重继承的编程语言中实现, 例如 C++。

适配器设计模式(类适配器)
  1. 类适配器不需要封装任何对象, 因为它同时继承了客户端和服务的行为。 适配功能在重写的方法中完成。 最后生成的适配器可替代已有的客户端类进行使用。

伪代码

下列适配器模式演示基于经典的 “方钉和圆孔” 问题。

适配器模式结构的示例

让方钉适配圆孔。

适配器假扮成一个圆钉 (Round­Peg), 其半径等于方钉 (Square­Peg) 横截面对角线的一半 (即能够容纳方钉的最小外接圆的半径)。

适配器模式适合应用场景

当你希望使用某个类, 但是其接口与其他代码不兼容时, 可以使用适配器类。

适配器模式允许你创建一个中间层类, 其可作为代码与遗留类、 第三方类或提供怪异接口的类之间的转换器。

如果您需要复用这样一些类, 他们处于同一个继承体系, 并且他们又有了额外的一些共同的方法, 但是这些共同的方法不是所有在这一继承体系中的子类所具有的共性。

你可以扩展每个子类, 将缺少的功能添加到新的子类中。 但是, 你必须在所有新子类中重复添加这些代码, 这样会使得代码有坏味道

将缺失功能添加到一个适配器类中是一种优雅得多的解决方案。 然后你可以将缺少功能的对象封装在适配器中, 从而动态地获取所需功能。 如要这一点正常运作, 目标类必须要有通用接口, 适配器的成员变量应当遵循该通用接口。 这种方式同装饰模式非常相似。

实现方式

  1. 确保至少有两个类的接口不兼容:

    • 一个无法修改 (通常是第三方、 遗留系统或者存在众多已有依赖的类) 的功能性服务类。

    • 一个或多个将受益于使用服务类的客户端类。

  2. 声明客户端接口, 描述客户端如何与服务交互。

  3. 创建遵循客户端接口的适配器类。 所有方法暂时都为空。

  4. 在适配器类中添加一个成员变量用于保存对于服务对象的引用。 通常情况下会通过构造函数对该成员变量进行初始化, 但有时在调用其方法时将该变量传递给适配器会更方便。

  5. 依次实现适配器类客户端接口的所有方法。 适配器会将实际工作委派给服务对象, 自身只负责接口或数据格式的转换。

  6. 客户端必须通过客户端接口使用适配器。 这样一来, 你就可以在不影响客户端代码的情况下修改或扩展适配器。

适配器模式优缺点

  • 单一职责原则你可以将接口或数据转换代码从程序主要业务逻辑中分离。

  • 开闭原则。 只要客户端代码通过客户端接口与适配器进行交互, 你就能在不修改现有客户端代码的情况下在程序中添加新类型的适配器。

  • 代码整体复杂度增加, 因为你需要新增一系列接口和类。 有时直接更改服务类使其与其他代码兼容会更简单。

与其他模式的关系

  • 桥接模式通常会于开发前期进行设计, 使你能够将程序的各个部分独立开来以便开发。 另一方面, 适配器模式通常在已有程序中使用, 让相互不兼容的类能很好地合作。

  • 适配器可以对已有对象的接口进行修改, 装饰模式则能在不改变对象接口的前提下强化对象功能。 此外, 装饰还支持递归组合, 适配器则无法实现。

  • 适配器能为被封装对象提供不同的接口, 代理模式能为对象提供相同的接口, 装饰则能为对象提供加强的接口。

  • 外观模式为现有对象定义了一个新接口, 适配器则会试图运用已有的接口。 适配器通常只封装一个对象, 外观通常会作用于整个对象子系统上。

  • 桥接状态模式策略模式 (在某种程度上包括适配器) 模式的接口非常相似。 实际上, 它们都基于组合模式——即将工作委派给其他对象, 不过也各自解决了不同的问题。 模式并不只是以特定方式组织代码的配方, 你还可以使用它们来和其他开发者讨论模式所解决的问题

1
2
3
4
5
6
7
8
package org.pt.design.adapterdesign;
/*
公共接口
*/
public interface AdvancedMediaPlayer{
public void playVlc(String fileName);
public void playMp4(String fileName);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.pt.design.adapterdesign;

/*
接口实现类1
*/
public class Mp4Player implements AdvancedMediaPlayer{
@Override
public void playVlc(String fileName) {

}

@Override
public void playMp4(String fileName) {
System.out.println("Playing mp4 file. Name: "+ fileName);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.pt.design.adapterdesign;
/*
接口实现类2
*/
public class VlcPlayer implements AdvancedMediaPlayer{
@Override
public void playVlc(String fileName) {
System.out.println("Playing vlc file. Name: "+ fileName);
}

@Override
public void playMp4(String fileName) {

}
}
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 org.pt.design.adapterdesign;

/*
适配器模式就是适配器类里面有一个公共的接口,在构造方法里面,根据这个接口
的不同实现类将这个接口引用指向具体类
*/

public class MediaAdapter implements MediaPlayer{
AdvancedMediaPlayer advancedMusicPlayer;
public MediaAdapter(String audioType){
if(audioType.equalsIgnoreCase("vlc") ){
advancedMusicPlayer = new VlcPlayer();
} else if (audioType.equalsIgnoreCase("mp4")){
advancedMusicPlayer = new Mp4Player();
}
}
@Override
public void play(String audioType, String fileName) {
if(audioType.equalsIgnoreCase("vlc")){
advancedMusicPlayer.playVlc(fileName);
}else if(audioType.equalsIgnoreCase("mp4")){
advancedMusicPlayer.playMp4(fileName);
}
}
}
0%