Appearance
Spring In Action 6th:创建 REST 服务
1. 编写 RESTful 控制器
简而言之,REST API 和网站并没有太大的不同。两者都涉及对 HTTP 请求的响应。但关键的区别在于,网站会用 HTML 来响应这些请求,而 REST API 通常会用像 JSON 或 XML 这样的数据导向格式来响应。
先前我们使用了 @GetMapping 和 @PostMapping 注解来获取和发送服务器数据。在定义 REST API 时,这些相同的注解仍然会派上用场。此外,Spring MVC 还支持其他几种用于各种类型 HTTP 请求的注解,如表 1.1 所示。
| 注解 | HTTP 方法 | 典型用法 |
|---|---|---|
@GetMapping | HTTP GET 请求 | 读取资源数据 |
@PostMapping | HTTP POST 请求 | 创建资源 |
@PutMapping | HTTP PUT 请求 | 更新资源 |
@PatchMapping | HTTP PATCH 请求 | 更新资源 |
@DeleteMapping | HTTP DELETE 请求 | 删除资源 |
@RequestMapping | 用于处理通用请求;HTTP 方法在方法属性中指定 |
1.1. 从服务器获取数据
我们希望 Taco Cloud 应用能够让 taco 狂热者设计自己的 taco 创作,并与他们的 taco 爱好者分享。一种实现方式是在网站上展示最近创建的 tacos 列表。
为了支持这个功能,我们需要创建一个处理 GET 请求的端点 /api/tacos,它包含一个 recent 参数,并以最近售出的 tacos 设计列表作为响应。你将创建一个新的控制器来处理这样的请求:
Java
@RestController
@RequestMapping(path = "/api/tacos", produces = "application/json")
@CrossOrigin(origins = "http://localhost:8080")
public class TacoController {
private TacoRepository tacoRepo;
public TacoController(TacoRepository tacoRepo) {
this.tacoRepo = tacoRepo;
}
@GetMapping(params = "recent")
public Iterable<Taco> recentTacos() {
PageRequest page = PageRequest.of(0, 12,
Sort.by("createdAt").descending());
return tacoRepo.findAll(page).getContent();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Java
public interface TacoRepository
extends CrudRepository<Taco, Long> {
Page<Taco> findAll(Pageable pageable);
}1
2
3
4
2
3
4
你可能会觉得这个控制器的名字有些熟悉。先前我们创建了一个名为 DesignTacoController 的类似命名的控制器,用于处理类似类型的请求。但是,那个控制器是用于在 Taco Cloud 应用中生成 HTML 结果的,而这个新的 TacoController 是一个 REST 控制器,如 @RestController 注解所示。
@RestController 注解有两个作用。首先,它是一个像 @Controller 和 @Service 那样的刻板注解,用于标记一个类以便组件扫描发现。但是,最相关的是,@RestController 注解告诉 Spring,控制器中的所有处理器方法应该将其返回值直接写入响应的主体,而不是将其放在模型中传递给视图进行渲染。
或者,你也可以像任何 Spring MVC 控制器一样,用 @Controller 注解 TacoController。但是,然后你需要在所有的处理器方法上都加上 @ResponseBody 注解,以达到同样的效果。另一个选择是返回一个 ResponseEntity 对象,我们稍后会讨论。
类级别的 @RequestMapping 注解与 recentTacos() 方法上的 @GetMapping 注解一起工作,指定 recentTacos() 方法负责处理 /api/tacos?recent 的 GET 请求。
你会注意到,@RequestMapping 注解还设置了一个 produces 属性。这指定了 TacoController 中的任何处理器方法只会处理客户端发送的包含 application/json 的 Accept 头的请求,这表明客户端只能处理 JSON 格式的响应。这种使用 produces 的方式限制了你的 API 只能产生 JSON 结果,并且它允许另一个控制器处理相同路径的请求,只要这些请求不需要 JSON 输出。
尽管将 produces 设置为 application/json 限制了你的 API 只能基于 JSON。
你会注意到,在 @RequestMapping 注解中还将 produces 属性设置为 application/json,这表示该控制器将会响应 JSON 格式的数据。举个例子,当客户端请求头中的 Accept 属性值被设置为 application/xml,由于 Accept 属性值与 produces 属性值不匹配,所以该控制器方法将不会被调用。如果客户端没有声明 Accept 请求头,那么该控制器方法仍然可以被调用,因为在这种情况下,客户端默认接受所有类型的媒体格式。但是,最好的做法是让客户端在请求头中明确声明它所接受的媒体类型。
你也可以将 produces 设置为一个字符串数组,以支持多种内容类型。例如,为了允许 XML 输出,你可以在 produces 属性中添加 text/xml,如下所示:
Java
@RequestMapping(path = "/api/tacos", produces = {"application/json", "text/xml"})你可能已注意到,TacoController 还被 @CrossOrigin 注解。对于基于 JavaScript 的用户界面(如 Angular 或 ReactJS 等框架编写的)来说,它们通常会从与 API 不同的主机和/或端口提供服务(至少目前如此),而 Web 浏览器会阻止你的客户端消费 API。这种限制可以通过在服务器响应中包含 CORS(跨源资源共享)头来克服。Spring 通过 @CrossOrigin 注解使得应用 CORS 变得简单。
在这里,@CrossOrigin 允许来自 localhost,端口 8080 的客户端访问 API。origins 属性接受一个数组,因此,你也可以指定多个值,如下所示:
Java
@CrossOrigin(origins = {"http://localhost:8080", "http://localhost.com"})recentTacos() 方法中的逻辑相当直接。它构造了一个 PageRequest 对象,指定你只想要第一个页面的 12 个结果,按照 taco 的创建日期降序排序。简而言之,你想要一打最近创建的 taco 设计。PageRequest 被传入到 TacoRepository 的 findAll() 方法的调用中,该页面的结果内容被返回给客户端。
你现在有了一个 Taco Cloud API 的起点供你的客户端使用。出于开发测试的目的,你可能还想使用像 curl 或 HTTPie 这样的命令行工具来探索 API。例如,下面的命令行展示了你可能如何使用 curl 获取最近创建的 tacos:
Bash
$ curl localhost:8080/api/tacos?recent或者,如果你更喜欢 HTTPie,可以像这样:
Bash
$ http :8080/api/tacos?recent最初,数据库将是空的,所以这些请求的结果也将是空的。我们将在一会儿看到如何处理保存 tacos 的 POST 请求。但是,在此期间,你可以添加一个 CommandLineRunner bean 来预加载一些测试数据到数据库中。下面的 CommandLineRunner bean 方法展示了你可能如何预加载一些配料和一些 tacos:
Java
@Bean
public CommandLineRunner dataLoader(
IngredientRepository repo,
UserRepository userRepo,
PasswordEncoder encoder,
TacoRepository tacoRepo) {
return args -> {
Ingredient flourTortilla = new Ingredient("FLTO", "Flour Tortilla", Type.WRAP);
Ingredient cornTortilla = new Ingredient("COTO", "Corn Tortilla", Type.WRAP);
Ingredient groundBeef = new Ingredient("GRBF", "Ground Beef", Type.PROTEIN);
Ingredient carnitas = new Ingredient("CARN", "Carnitas", Type.PROTEIN);
Ingredient tomatoes = new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES);
Ingredient lettuce = new Ingredient("LETC", "Lettuce", Type.VEGGIES);
Ingredient cheddar = new Ingredient("CHED", "Cheddar", Type.CHEESE);
Ingredient jack = new Ingredient("JACK", "Monterrey Jack", Type.CHEESE);
Ingredient salsa = new Ingredient("SLSA", "Salsa", Type.SAUCE);
Ingredient sourCream = new Ingredient("SRCR", "Sour Cream", Type.SAUCE);
repo.save(flourTortilla);
repo.save(cornTortilla);
repo.save(groundBeef);
repo.save(carnitas);
repo.save(tomatoes);
repo.save(lettuce);
repo.save(cheddar);
repo.save(jack);
repo.save(salsa);
repo.save(sourCream);
Taco taco1 = new Taco();
taco1.setName("Carnivore");
taco1.setIngredients(Arrays.asList(
flourTortilla, groundBeef, carnitas,
sourCream, salsa, cheddar));
tacoRepo.save(taco1);
Taco taco2 = new Taco();
taco2.setName("Bovine Bounty");
taco2.setIngredients(Arrays.asList(
cornTortilla, groundBeef, cheddar,
jack, sourCream));
tacoRepo.save(taco2);
Taco taco3 = new Taco();
taco3.setName("Veg-Out");
taco3.setIngredients(Arrays.asList(
flourTortilla, cornTortilla, tomatoes,
lettuce, salsa));
tacoRepo.save(taco3);
};
}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
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
现在,如果你尝试使用 curl 或 HTTPie 向最近的 /api/tacos 端点发出请求,你将得到类似于以下的响应(为了便于阅读,对响应进行了格式化):
Bash
$ curl localhost:8080/api/tacos?recent
[
{
"id": 4,
"name": "Veg-Out",
"createdAt": "2021-08-02T00:47:09.624+00:00",
"ingredients": [
{ "id": "FLTO", "name": "Flour Tortilla", "type": "WRAP" },
{ "id": "COTO", "name": "Corn Tortilla", "type": "WRAP" },
{ "id": "TMTO", "name": "Diced Tomatoes", "type": "VEGGIES" },
{ "id": "LETC", "name": "Lettuce", "type": "VEGGIES" },
{ "id": "SLSA", "name": "Salsa", "type": "SAUCE" }
]
},
{
"id": 3,
"name": "Bovine Bounty",
"createdAt": "2021-08-02T00:47:09.621+00:00",
"ingredients": [
{ "id": "COTO", "name": "Corn Tortilla", "type": "WRAP" },
{ "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" },
{ "id": "CHED", "name": "Cheddar", "type": "CHEESE" },
{ "id": "JACK", "name": "Monterrey Jack", "type": "CHEESE" },
{ "id": "SRCR", "name": "Sour Cream", "type": "SAUCE" }
]
},
{
"id": 2,
"name": "Carnivore",
"createdAt": "2021-08-02T00:47:09.520+00:00",
"ingredients": [
{ "id": "FLTO", "name": "Flour Tortilla", "type": "WRAP" },
{ "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" },
{ "id": "CARN", "name": "Carnitas", "type": "PROTEIN" },
{ "id": "SRCR", "name": "Sour Cream", "type": "SAUCE" },
{ "id": "SLSA", "name": "Salsa", "type": "SAUCE" },
{ "id": "CHED", "name": "Cheddar", "type": "CHEESE" }
]
}
]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
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
假设你想提供一个端点,通过其 ID 获取单个 taco。通过在处理器方法的路径中使用占位符变量并接受路径变量,你可以捕获 ID 并通过仓库使用它来查找 Taco 对象,如下所示:
Java
@GetMapping("/{id}")
public Optional<Taco> tacoById(@PathVariable("id") Long id) {
return tacoRepo.findById(id);
}1
2
3
4
2
3
4
由于控制器的基础路径是 /api/tacos,此控制器方法处理针对 /api/tacos/{id} 的 GET 请求,其中路径的 {id} 部分是一个占位符。请求中的实际值被赋给 id 参数,该参数通过 @PathVariable 映射到 {id} 占位符。
在 tacoById() 内部,id 参数被传递给仓库的 findById() 方法来获取 Taco。仓库的 findById() 方法返回一个 Optional<Taco>,因为可能不存在与给定 ID 匹配的 taco。Optional<Taco> 简单地从控制器方法返回。
然后,Spring 接收 Optional<Taco> 并调用其 get() 方法来生成响应。如果 ID 与任何已知的 tacos 不匹配,响应体将包含 null,并且响应的 HTTP 状态码将是 200(OK)。客户端收到了它无法使用的响应,但状态码指示一切正常。更好的方法是返回带有 HTTP 404(NOT FOUND)状态的响应。
就目前的写法而言,从 tacoById() 返回 404 状态码并不容易。但是,如果你做一些小的调整,你可以适当地设置状态码,如下所示:
Java
@GetMapping("/{id}")
public ResponseEntity<Taco> tacoById(@PathVariable("id") Long id) {
Optional<Taco> optTaco = tacoRepo.findById(id);
return optTaco.map(taco -> new ResponseEntity<>(taco, HttpStatus.OK))
.orElseGet(() -> new ResponseEntity<>(null, HttpStatus.NOT_FOUND));
}1
2
3
4
5
6
2
3
4
5
6
现在,tacoById() 不再返回一个 Taco 对象,而是返回一个 ResponseEntity<Taco>。如果找到了 taco,你将 Taco 对象包装在一个带有 OK HTTP 状态的 ResponseEntity 中(这是之前的行为)。但是,如果找不到 taco,你将 null 包装在一个带有 NOT FOUND HTTP 状态的 ResponseEntity 中,以表示客户端正在尝试获取不存在的 taco。
定义一个返回信息的端点只是开始。如果你的 API 需要从客户端接收数据呢?让我们看看你如何编写处理请求输入的控制器方法。
1.2. 向服务器发送数据
到目前为止,你的 API 能够返回最近创建的十几个 tacos。但是,这些 tacos 是如何创建的呢?
虽然你可以使用 CommandLineRunner bean 来预加载一些测试的 taco 数据,但最终,taco 数据将来自于用户在他们制作 taco 时的创作。因此,我们需要在 TacoController 中编写一个方法,处理包含 taco 设计的请求,并将它们保存到数据库中。通过在 TacoController 中添加以下的 postTaco() 方法:
Java
@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Taco postTaco(@RequestBody Taco taco) {
return tacoRepo.save(taco);
}1
2
3
4
5
2
3
4
5
因为 postTaco() 将处理一个 HTTP POST 请求,所以它用 @PostMapping 注解,而不是 @GetMapping。你在这里没有指定路径属性,所以 postTaco() 方法将处理 /api/tacos 的请求,这是在 TacoController 的类级别的 @RequestMapping 中指定的。
然而,你确实设置了 consumes 属性。consumes 属性对于请求输入就像 produces 对于请求输出一样。在这里,你使用 consumes 来表示该方法只会处理那些 Content-type 匹配 application/json 的请求。
该方法的 Taco 参数用 @RequestBody 注解,以表示请求的主体应转换为 Taco 对象并绑定到参数。这个注解很重要 —— 没有它,Spring MVC 会假设你希望将请求参数(查询参数或表单参数)绑定到 Taco 对象。但是 @RequestBody 注解确保了请求主体中的 JSON 被绑定到 Taco 对象。
一旦 postTaco() 收到了 Taco 对象,它就会将它传递给 TacoRepository 的 save() 方法。
你可能也注意到了,我用 @ResponseStatus(HttpStatus.CREATED) 注解了 postTaco() 方法。在正常情况下(当没有抛出异常时),所有的响应都会有一个 HTTP 状态码 200(OK),表示请求成功。虽然 HTTP 200 响应总是受欢迎的,但它并不总是足够描述性的。在 POST 请求的情况下,HTTP 状态码 201(CREATED)更具描述性。它告诉客户端,不仅请求成功,而且还创建了一个资源。在适当的地方使用 @ResponseStatus 以向客户端传达最具描述性和准确的 HTTP 状态码总是一个好主意。
虽然你已经使用了 @PostMapping 来创建一个新的 Taco 资源,但 POST 请求也可以用来更新资源。即便如此,POST 请求通常用于资源创建,而 PUT 和 PATCH 请求用于更新资源。让我们看看你如何使用 @PutMapping 和 @PatchMapping 更新数据。
1.3. 更新服务器上的数据
在你编写处理 HTTP PUT 或 PATCH 命令的控制器代码之前,你应该花一点时间考虑一下房间里的大象:为什么有两种不同的 HTTP 方法用于更新资源?
虽然 PUT 经常被用来更新资源数据,但它实际上是 GET 的语义对立面。GET 请求是用于将数据从服务器传输到客户端,而 PUT 请求是用于将数据从客户端发送到服务器。
从这个意义上说,PUT 实际上是用来执行全面替换操作,而不是更新操作。相比之下,HTTP PATCH 的目的是执行资源数据的补丁或部分更新。
例如,假设你想能够改变订单上的地址。我们可以通过 REST API 用一个像这样的 PUT 请求来实现这一点:
Java
@PutMapping(path = "/{orderId}", consumes = "application/json")
public TacoOrder putOrder(
@PathVariable("orderId") Long orderId,
@RequestBody TacoOrder order) {
order.setId(orderId);
return repo.save(order);
}1
2
3
4
5
6
7
2
3
4
5
6
7
这可能行得通,但它需要客户端在 PUT 请求中提交完整的订单数据。从语义上讲,PUT 意味着 “将这些数据放在这个 URL”,基本上替换了已经存在的任何数据。如果省略了订单的任何属性,那么该属性的值将被 null 覆盖。甚至订单中的 tacos 也需要与订单数据一起设置,否则它们将从订单中移除。
如果 PUT 对资源数据进行了全面替换,那么你应该如何处理只做部分更新的请求呢?这就是 HTTP PATCH 请求和 Spring 的 @PatchMapping 的用途。以下是你可能编写的一个控制器方法,用于处理订单的 PATCH 请求:
Java
@PatchMapping(path = "/{orderId}", consumes = "application/json")
public TacoOrder patchOrder(@PathVariable("orderId") Long orderId,
@RequestBody TacoOrder patch) {
TacoOrder order = repo.findById(orderId).get();
if (patch.getDeliveryName() != null) {
order.setDeliveryName(patch.getDeliveryName());
}
if (patch.getDeliveryStreet() != null) {
order.setDeliveryStreet(patch.getDeliveryStreet());
}
if (patch.getDeliveryCity() != null) {
order.setDeliveryCity(patch.getDeliveryCity());
}
if (patch.getDeliveryState() != null) {
order.setDeliveryState(patch.getDeliveryState());
}
if (patch.getDeliveryZip() != null) {
order.setDeliveryZip(patch.getDeliveryZip());
}
if (patch.getCcNumber() != null) {
order.setCcNumber(patch.getCcNumber());
}
if (patch.getCcExpiration() != null) {
order.setCcExpiration(patch.getCcExpiration());
}
if (patch.getCcCVV() != null) {
order.setCcCVV(patch.getCcCVV());
}
return repo.save(order);
}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
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
首先要注意的是,patchOrder() 方法用 @PatchMapping 注解,而不是 @PutMapping,表示它应该处理 HTTP PATCH 请求,而不是 PUT 请求。
但你无疑已经注意到,patchOrder() 方法比 putOrder() 方法涉及的内容更多。这是因为 Spring MVC 的映射注解,包括 @PatchMapping 和 @PutMapping,只指定了一个方法应该处理哪些类型的请求。这些注解并没有规定如何处理请求。即使 PATCH 在语义上意味着部分更新,你也需要在处理器方法中编写实际执行这样更新的代码。
在 putOrder() 方法的情况下,你接受了一个订单的完整数据并保存了它,符合 HTTP PUT 的语义。但为了让 patchMapping() 符合 HTTP PATCH 的语义,方法的主体需要更复杂一些。它不是完全用发送来的新数据替换订单,而是检查传入的 TacoOrder 对象的每个字段,并将任何非 null 值应用到现有的订单上。这种方法允许客户端只发送应该被改变的属性,并使服务器保留客户端未指定的任何属性的现有数据。
Note
在
patchOrder()方法中应用的 PATCH 方法有以下限制:
- 如果
null值意味着不改变,那么客户端如何指示一个字段应该设置为null呢?- 无法从集合中移除或添加一部分项目。如果客户端想要从集合中添加或移除一个条目,它必须发送完整的修改后的集合;
关于如何处理 PATCH 请求或传入数据应该是什么样的,并没有硬性规定。例如,客户端可以发送一个特定于 PATCH 的更改描述,而不是实际的域数据。
在 @PutMapping 和 @PatchMapping 中,你会注意到请求路径引用了要更改的资源。这与 @GetMapping 注解的方法处理路径的方式相同。
1.4. 删除服务器上的数据
有时候,数据可能不再需要了。在这些情况下,客户端应该能够通过 HTTP DELETE 请求请求删除一个资源。Spring MVC 的 @DeleteMapping 对于声明处理 DELETE 请求的方法非常方便。例如,假设你希望你的 API 允许删除一个订单资源:
Java
@DeleteMapping("/{orderId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteOrder(@PathVariable("orderId") Long orderId) {
try {
repo.deleteById(orderId);
} catch (EmptyResultDataAccessException e) {
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
你可能不会对 @DeleteMapping 用于指定 deleteOrder() 方法负责处理 /orders/{orderId} 的 DELETE 请求感到惊讶。它接受作为 URL 中的路径变量提供的订单 ID,并将其传递给仓库的 deleteById() 方法。如果在调用该方法时订单存在,它将被删除。如果订单不存在,将会抛出 EmptyResultDataAccessException。
我选择捕获 EmptyResultDataAccessException 并不对其进行任何处理。我在这里的想法是,如果你试图删除一个不存在的资源,结果与它在删除前存在是一样的 —— 也就是说,资源将不存在。它之前是否存在是无关紧要的。另外,我可以让 deleteOrder() 来返回一个 ResponseEntity,将主体设置为 null,HTTP 状态码设置为 NOT FOUND。
deleteOrder() 方法中唯一需要注意的另一件事是,它用 @ResponseStatus 注解,以确保响应的 HTTP 状态是 204(NO CONTENT)。对于一个不再存在的资源,没有必要将任何资源数据返回给客户端,所以 DELETE 请求的响应通常没有主体,因此,应该传达一个 HTTP 状态码,让客户端知道不要期望任何内容。
我们将在稍后讨论编写 REST 客户端代码。但现在,让我们看看另一种创建 REST API 端点的方式:基于 Spring Data 仓库的自动创建。
2. 启用后端数据服务
Spring Data 除了可以自动创建基于你在代码中定义的接口的仓库实现,Spring Data 还有另一个技巧可以帮助你为你的应用程序定义 API。
Spring Data REST 是 Spring Data 家族的另一个成员,它会自动为 Spring Data 创建的仓库创建 REST API。你只需要在你的构建中添加 Spring Data REST,你就可以为你定义的每个仓库接口获得一个带有操作的 API。
要开始使用 Spring Data REST,将以下依赖添加到你的构建中:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>1
2
3
4
2
3
4
信不信由你,这就是在一个已经使用 Spring Data 自动仓库的项目中暴露 REST API 所需要的全部内容。仅仅通过在构建中有 Spring Data REST 启动器,应用程序就获得了自动配置,使得任何由 Spring Data 创建的仓库(包括 Spring Data JPA、Spring Data Mongo 等)都能自动创建 REST API。
Spring Data REST 创建的 REST 端点至少和你自己创建的一样好(甚至可能更好)。所以在这一点上,你可以自由地进行一些拆除工作,移除你到目前为止创建的任何带有 @RestController 注解的类,然后继续前进。
要尝试 Spring Data REST 提供的端点,你可以启动应用程序并开始探索一些 URL。基于你已经为 Taco Cloud 定义的一组仓库,你应该能够对 tacos、ingredients、tacoOrders 和 users 进行 GET 请求。
例如,你可以通过对 /ingredients 发送 GET 请求来获取所有配料的列表。使用 curl,你可能会得到类似这样的结果(缩略以只显示第一个配料):
Bash
$ curl localhost:8080/ingredients
{
"_embedded": {
"ingredients": [
{
"name": "Flour Tortilla",
"type": "WRAP",
"_links": {
"self": {
"href": "http://localhost:8080/ingredients/FLTO"
},
"ingredient": {
"href": "http://localhost:8080/ingredients/FLTO"
}
}
}
...
]
},
"_links": {
"self": {
"href": "http://localhost:8080/ingredients"
},
"profile": {
"href": "http://localhost:8080/profile/ingredients"
}
}
}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
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
哇!你仅仅通过在你的构建中添加一个依赖,就不仅获得了一个用于获取配料的端点,返回的资源中还包含了超链接!这些超链接是 HATEOAS(Hypermedia as the Engine of Application State,应用状态的超媒体引擎)的实现。使用这个 API 的客户端可以(可选地)使用这些超链接作为导航 API 和执行下一个请求的指南。
Spring HATEOAS 项目为在你的 Spring MVC 控制器响应中添加超媒体链接提供了一般性的支持。但是 Spring Data REST 会自动在其生成的 API 的响应中添加这些链接。
Note
使用 HATEOAS 还是不使用 HATEOAS?
HATEOAS 的一般思想是,它使客户端能够像人类导航网站一样导航 API:通过跟随链接。而不是在客户端中编码 API 详情,并让客户端为每个请求构造 URL,客户端可以从超链接列表中按名称选择一个链接,并用它来发出下一个请求。这样,客户端不需要编码来了解 API 的结构,而可以使用 API 本身作为 API 的路线图。
另一方面,超链接确实在有效载荷中添加了少量额外的数据,并增加了一些复杂性,要求客户端知道如何使用这些超链接进行导航。出于这个原因,API 开发者经常放弃使用 HATEOAS,而客户端开发者如果在 API 中有任何超链接,通常会简单地忽略它们。
假装你是这个 API 的客户端,你也可以使用 curl 来跟该自链接,如下所示:
Bash
$ curl http://localhost:8080/ingredients/FLTO
{
"name": "Flour Tortilla",
"type": "WRAP",
"_links": {
"self": {
"href": "http://localhost:8080/ingredients/FLTO"
},
"ingredient": {
"href": "http://localhost:8080/ingredients/FLTO"
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
我们不会花太多时间深入研究 Spring Data REST 创建的每一个端点和选项。但你应该知道,它也支持对其创建的端点使用 POST、PUT 和 DELETE 方法。没错,你可以向 /ingredients 发送 POST 请求来创建一个新的成分,也可以通过 DELETE /ingredients/FLTO 来从菜单中移除面粉玉米饼。
你可能想做的一件事是为 API 设置一个基础路径,这样它的端点就会有所区别,不会与你编写的任何控制器冲突。要调整 API 的基础路径,可以设置 spring.data.rest.base-path 属性,如下所示:
YAML
spring:
data:
rest:
base-path: /data-api1
2
3
4
2
3
4
这将 Spring Data REST 端点的基础路径设置为 /data-api。虽然你可以将基础路径设置为你喜欢的任何内容,但选择 /data-api 可以确保 Spring Data REST 暴露的端点不会与任何其他控制器冲突,包括我们在本章早些时候创建的以 /api 开头的路径。
因此,/ingredients 端点现在变为 /data-api/ingredients。现在,通过请求一个 tacos 列表来试一试这个新的基础路径,如下所示:
Bash
$ curl http://localhost:8080/data-api/tacos
{
"timestamp": "2018-02-11T16:22:12.381+0000",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/api/tacos"
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
哎呀!这并没有像预期的那样工作。你有一个 Ingredient 实体和一个 IngredientRepository 接口,Spring Data REST 通过 /data-api/ingredients 端点将其暴露出来。那么,如果你有一个 Taco 实体和一个 TacoRepository 接口,为什么 Spring Data REST 不给你一个 /data-api/tacos 端点呢?
2.1. 调整资源路径和关系名称
实际上,Spring Data REST 确实为你提供了一个用于处理 tacos 的端点。但是,尽管 Spring Data REST 可以很聪明,但在如何暴露 tacos 端点的方式上,它显得稍微不那么出色。
当为 Spring Data 仓库创建端点时,Spring Data REST 试图将关联的实体类复数化。对于 Ingredient 实体,端点是 /data-api/ingredients。对于 TacoOrder 实体,它是 /data-api/tacoOrders(Spring Data REST 会自动将实体类名转换为小写,并在后面添加 “s” 来形成路径)。到目前为止,一切都很好。
但有时候,比如在 “taco” 这个词上,它会犯错误,复数化的版本并不完全正确。事实证明,Spring Data REST 将 “taco” 复数化为 “tacoes”,所以应请求 /data-api/tacoes,如下所示:
Bash
$ curl localhost:8080/data-api/tacoes
{
"_embedded": {
"tacoes": [
{
"name": "Carnivore",
"createdAt": "2018-02-11T17:01:32.999+0000",
"_links": {
"self": {
"href": "http://localhost:8080/data-api/tacoes/2"
},
"taco": {
"href": "http://localhost:8080/data-api/tacoes/2"
},
"ingredients": {
"href": "http://localhost:8080/data-api/tacoes/2/ingredients"
}
}
}
]
},
"page": {
"size": 20,
"totalElements": 3,
"totalPages": 1,
"number": 0
}
}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
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
你可能会想知道我是如何知道 “taco” 会被误复数化为 “tacoes” 的。事实上,Spring Data REST 还暴露了一个主页资源,列出了所有暴露端点的链接。只需向 API 基础路径发送 GET 请求,就可以获取如下信息:
Bash
$ curl localhost:8080/data-api
{
"_links": {
"tacoes": {
"href": "http://localhost:8080/data-api/tacoes{?page,size,sort}",
"templated": true
},
"ingredients": {
"href": "http://localhost:8080/data-api/ingredients"
},
"tacoOrders": {
"href": "http://localhost:8080/data-api/tacoOrders"
},
"users": {
"href": "http://localhost:8080/data-api/users"
},
"profile": {
"href": "http://localhost:8080/data-api/profile"
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
可以通过在 Taco 类中添加以下简单的注解,来调整关系名和路径:
Java
@Data
@Entity
@RestResource(rel = "tacos", path = "tacos")
public class Taco {
...
}1
2
3
4
5
6
2
3
4
5
6
@RestResource 注解让你可以给实体设置任何你想要的关系名和路径。在这个例子中,你将它们都设置为 “tacos”。现在,当你请求主页资源时,你会看到正确复数化的 tacos 链接,如下所示:
JSON
"tacos": {
"href": "http://localhost:8080/data-api/tacos{?page,size,sort}",
"templated": true
}1
2
3
4
2
3
4
让我们看看如何可以对 Spring Data REST 端点的结果进行排序。
2.2. 分页和排序
你可能已经注意到,主页资源中 /data-api/tacos 端点提供了可选的 page、size 和 sort 参数。默认情况下,对集合资源(如 /data-api/tacos)的请求将从第一页开始,每页返回最多 20 个项目。但你可以通过在请求中指定 page 和 size 参数来调整页面大小和显示的页面。
例如,要请求第一页的 tacos,其中页面大小为 5,你可以发出以下 GET 请求:
Bash
$ curl "localhost:8080/data-api/tacos?size=5"假设有超过五个 tacos 可供查看,你可以通过添加 page 参数来请求 tacos 的第二页,如下所示:
Bash
$ curl "localhost:8080/data-api/tacos?size=5&page=1"注意,page 参数是从 0 开始的,这意味着请求第 1 页实际上是在请求第二页。(你也会注意到,许多命令行 shell 在处理请求中的 & 符号时会出错,这就是我在前面的 curl 命令中用引号包裹整个 URL 的原因。)
sort 参数让你可以按照实体的任何属性对结果列表进行排序。例如,你需要一种方法来获取最新创建的 12 个 tacos 供 UI 显示。你可以通过指定以下分页和排序参数的组合来实现这一点:
Bash
$ curl "localhost:8080/data-api/tacos?sort=createdAt,desc&page=0&size=12"这里的 sort 参数指定了你应该按照 createdAt 属性进行降序排序(这样最新的 tacos 就会排在前面)。page 和 size 参数指定了你应该查看第一页的 12 个 tacos。这正是 UI 需要显示最近创建的 tacos 的内容。
现在,让我们换个角度,看看如何编写客户端代码来使用我们创建的 API 端点。
3. 使用 REST 服务
Spring 应用程序可以使用以下方式消费 REST API:
RestTemplate:由核心 Spring 框架提供的一个直接、同步的 REST 客户端;Traverson:由 Spring HATEOAS 提供的一个围绕 Spring 的
RestTemplate的包装器,以启用一个超链接感知、同步的 REST 客户端。灵感来自同名的 JavaScript 库;WebClient:一个响应式(reactive)、异步的 REST 客户端;
现在,我们将专注于使用 RestTemplate 创建客户端,将推迟稍后讨论 WebClient。如果你对编写超链接感知的客户端感兴趣,可以查看 Traverson 文档。
从客户端的角度来看,与 REST 资源进行交互涉及到很多事情 —— 主要是繁琐和样板代码。使用低级 HTTP 库,客户端需要创建一个客户端实例和一个请求对象,执行请求,解释响应,将响应映射到域对象,并处理可能在此过程中抛出的任何异常。而所有这些样板代码都会被重复,无论发送什么 HTTP 请求。
为了避免这种样板代码,Spring 提供了 RestTemplate。就像 JdbcTemplate 处理与 JDBC 工作的丑陋部分一样,RestTemplate 使你免于处理消费 REST 资源的繁琐。
RestTemplate 提供了 41 个用于与 REST 资源交互的方法。与其逐一介绍它提供的所有方法,不如只考虑 12 个唯一的操作,每个操作都重载以等于完整的 41 个方法集。这 12 个操作请参考表 3.1 中的描述:
| 方法 | 描述 |
|---|---|
delete(...) | 对指定 URL 上的资源执行 HTTP DELETE 请求 |
exchange(...) | 对 URL 执行指定的 HTTP 方法,返回一个 ResponseEntity,其中包含从响应体映射的对象 |
execute(...) | 对 URL 执行指定的 HTTP 方法,返回一个映射到响应体的对象 |
getForEntity(...) | 发送 HTTP GET 请求,返回一个 ResponseEntity,其中包含从响应体映射的对象 |
getForObject(...) | 发送 HTTP GET 请求,返回一个映射到响应体的对象 |
headForHeaders(...) | 发送 HTTP HEAD 请求,返回指定资源 URL 的 HTTP 请求头 |
optionsForAllow(...) | 发送 HTTP OPTIONS 请求,返回指定 URL 的 Allow 头信息 |
patchForObject(...) | 发送 HTTP PATCH 请求,返回从响应主体映射的结果对象 |
postForEntity(...) | 将数据 POST 到一个 URL,返回一个 ResponseEntity,其中包含从响应体映射而来的对象 |
postForLocation(...) | 将数据 POST 到一个 URL,返回新创建资源的 URL |
postForObject(...) | 将数据 POST 到一个 URL,返回从响应主体映射的对象 |
put(...) | 将资源数据 PUT 到指定的 URL |
RestTemplate 定义了 12 个唯一的操作,每个操作都有重载,总共提供了 41 个方法除了 TRACE 外,RestTemplate 对每个标准 HTTP 方法都有至少一个方法。此外,execute() 和 exchange() 提供了用于发送任何 HTTP 方法请求的低级别、通用方法。
表 3.1 中的大多数方法都被重载为以下三种方法形式:
- 一种接受一个
String作为 URL 规范,URL 参数在变量参数列表中指定; - 一种接受一个
String作为 URL 规范,URL 参数在Map<String, String>中指定; - 一种接受一个
java.net.URI作为 URL 规范,不支持参数化的 URL;
一旦你了解了 RestTemplate 提供的 12 个操作以及每种变体形式的工作方式,你就可以开始编写消费资源的 REST 客户端了。要使用 RestTemplate,你需要在需要的地方创建一个实例,如下所示:
Java
RestTemplate rest = new RestTemplate();或者,你可以将它声明为一个 bean,并在需要的地方注入它,如下所示:
Java
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}1
2
3
4
2
3
4
让我们通过查看支持四种主要 HTTP 方法的 RestTemplate 操作来进行调查:GET、PUT、DELETE 和 POST。我们将从 getForObject() 和 getForEntity() 开始 —— GET 方法。
3.1. GET 资源
假设你想从 Taco Cloud API 获取一个配料。为此,你可以使用 RestTemplate 的 getForObject() 方法来获取配料。例如,以下代码使用 RestTemplate 通过其 ID 获取一个 Ingredient 对象:
Java
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject("http://localhost:8080/data-api/ingredients/{id}",
Ingredient.class, ingredientId);
}1
2
3
4
2
3
4
在这里,你正在使用 getForObject() 的变体,该变体接受一个 String 类型的 URL,并使用变量列表作为 URL 变量。传递给 getForObject() 的 ingredientId 参数用于填充给定 URL 中的 {id} 占位符。虽然在这个例子中只有一个 URL 变量,但重要的是要知道,变量参数会按照它们给出的顺序分配给占位符。
getForObject() 的第二个参数是应该绑定到的类型。在这种情况下,响应数据(可能是 JSON 格式)应该被反序列化为一个将要返回的 Ingredient 对象。
或者,你可以使用一个 Map 来指定 URL 变量,如下所示:
Java
public Ingredient getIngredientById(String ingredientId) {
Map<String, String> urlVariables = new HashMap<>();
urlVariables.put("id", ingredientId);
return rest.getForObject("http://localhost:8080/data-api/ingredients/{id}",
Ingredient.class, urlVariables);
}1
2
3
4
5
6
2
3
4
5
6
在这种情况下,ingredientId 的值被映射到一个名为 id 的键。当请求被发出时,{id} 占位符将被替换为键为 id 的 map 条目。
使用 URI 参数稍微复杂一些,除了需要在调用 getForObject() 之前构造一个 URI 对象。其它代码与上述两个变体类似,如下所示:
Java
public Ingredient getIngredientById(String ingredientId) {
Map<String, String> urlVariables = new HashMap<>();
urlVariables.put("id", ingredientId);
URI url = UriComponentsBuilder
.fromHttpUrl("http://localhost:8080/data-api/ingredients/{id}")
.build(urlVariables);
return rest.getForObject(url, Ingredient.class);
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
在这里,URI 对象是从一个 String 规范定义的,并且其占位符从 Map 中的条目填充,就像 getForObject() 的前一个变体一样。getForObject() 方法是获取资源的简单方法。但是,如果客户端需要的不仅仅是有效载荷体,你可能需要考虑使用 getForEntity()。
getForEntity() 的工作方式与 getForObject() 大致相同,但是它返回的不是表示响应有效载荷的领域对象,而是包装该领域对象的 ResponseEntity 对象。ResponseEntity 提供了对额外响应细节的访问,例如响应头。
例如,假设除了配料数据外,你还想检查来自响应的 Date 头。使用 getForEntity(),这就变得直接了当,如下面的代码所示:
Java
public Ingredient getIngredientById(String ingredientId) {
ResponseEntity<Ingredient> responseEntity =
rest.getForEntity("http://localhost:8080/data-api/ingredients/{id}",
Ingredient.class, ingredientId);
log.info("Fetched time: {}",
responseEntity.getHeaders().getDate());
return responseEntity.getBody();
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
getForEntity() 方法的重载与 getForObject() 相同,因此你可以将 URL 变量作为变量列表参数提供,或者使用 URI 对象调用 getForEntity()。
3.2. PUT 资源
对于发送 HTTP PUT 请求,RestTemplate 提供了 put() 方法。所有三个重载的 put() 方法都接受一个将被序列化并发送到给定 URL 的对象。至于 URL 本身,可以指定为 URI 对象或 String。像 getForObject() 和 getForEntity() 一样,URL 变量可以使用变量参数列表或 Map 提供。
假设你想用新的 Ingredient 对象的数据替换一个配料资源。以下代码应该可以实现:
Java
public void updateIngredient(Ingredient ingredient) {
rest.put("http://localhost:8080/data-api/ingredients/{id}",
ingredient, ingredient.getId());
}1
2
3
4
2
3
4
在这里,URL 以 String 形式给出,并且有一个占位符,该占位符由给定的 Ingredient 对象的 id 属性替换。要发送的数据是 Ingredient 对象本身。put() 方法返回 void,所以你不需要处理返回值。
3.3. DELETE 资源
假设 Taco Cloud 不再提供某种配料,并希望将其完全删除。为了实现这一点,你可以调用 RestTemplate 的 delete() 方法,如下所示:
Java
public void deleteIngredient(Ingredient ingredient) {
rest.delete("http://localhost:8080/data-api/ingredients/{id}",
ingredient.getId());
}1
2
3
4
2
3
4
在这个例子中,只有 URL(以 String 形式指定)和一个 URL 变量值被给到 delete()。但是,与其他 RestTemplate 方法一样,URL 可以被指定为一个 URI 对象,或者 URL 参数也可以用一个 Map 给出。
3.4. POST 资源数据
现在假设你在 Taco Cloud 菜单中添加了一个新的配料。向 /data-api/ingredients 端点发送一个 HTTP POST 请求,并在请求体中包含配料数据,就可以实现这一点。RestTemplate 有三种发送 POST 请求的方法,每种方法都有相同的重载变体用于指定 URL。如果你想在 POST 请求后接收新创建的 Ingredient 资源,你可以像这样使用 postForObject():
Java
public Ingredient createIngredient(Ingredient ingredient) {
return rest.postForObject("http://localhost:8080/data-api/ingredients",
ingredient, Ingredient.class);
}1
2
3
4
2
3
4
这个 postForObject() 方法的变体接受一个 String 类型的 URL 规范、要发送到服务器的对象以及应该绑定到响应体的领域类型。第四个参数可以是 URL 变量值的 Map,或者是要替换到 URL 中的参数列表(虽然这里我们并没有使用该参数)。
如果你的客户端更需要新创建资源的位置,那么你可以调用 postForLocation(),如下所示:
Java
public URI createIngredient(Ingredient ingredient) {
return rest.postForLocation("http://localhost:8080/data-api/ingredients",
ingredient);
}1
2
3
4
2
3
4
注意,postForLocation() 的工作方式很像 postForObject(),只是它返回新创建资源的 URI,而不是资源对象本身。返回的 URI 是从响应的 Location 头部派生出来的。如果你偶然需要位置和响应有效载荷,你可以像这样调用 postForEntity():
Java
public Ingredient createIngredient(Ingredient ingredient) {
ResponseEntity<Ingredient> responseEntity =
rest.postForEntity("http://localhost:8080/data-api/ingredients",
ingredient,
Ingredient.class);
log.info("New resource created at {}",
responseEntity.getHeaders().getLocation());
return responseEntity.getBody();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
尽管 RestTemplate 的方法在目的上有所不同,但在使用方式上它们非常相似。这使得你能够轻松地熟练掌握 RestTemplate,并在你的客户端代码中使用它。
4. 保护 REST 服务
在分布式应用中,软件系统之间的信任至关重要。你给予客户端的信任应该限制在客户端完成其工作所必需的功能上。
保护 REST API 与保护基于浏览器的 Web 应用不同。在这一章中,我们将研究 OAuth 2,这是一个专门为 API 安全创建的授权规范。在此过程中,我们将研究 Spring Security 对 OAuth 2 的支持。但是首先,让我们看看 OAuth 2 是如何工作的。
4.1. OAuth 2 介绍
假设我们想要创建一个新的后台应用程序来管理 Taco Cloud 应用程序。更具体地说,我们希望这个新应用程序能够管理 Taco Cloud 主网站上可用的食材。在我们开始为管理应用程序编写代码之前,我们需要向 Taco Cloud API 添加一些新的端点来支持食材管理。下面的 REST 控制器提供了三个端点,用于列出、添加和删除食材。
Java
package graceful.hello.spring.web.controllers;
import graceful.hello.spring.web.data.IngredientRepository;
import graceful.hello.spring.web.modules.Ingredient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/api/ingredients", produces = "application/json")
@CrossOrigin(origins = "http://localhost:8080")
public class IngredientController {
private IngredientRepository repo;
@Autowired
public IngredientController(IngredientRepository repo) {
this.repo = repo;
}
@GetMapping
public Iterable<Ingredient> allIngredients() {
return repo.findAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Ingredient saveIngredient(@RequestBody Ingredient ingredient) {
return repo.save(ingredient);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteIngredient(@PathVariable("id") String ingredientId) {
repo.deleteById(ingredientId);
}
}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
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
太好了!现在我们需要做的就是开始开发管理应用程序,根据需要调用 Taco Cloud 主应用程序上的这些端点来添加和删除食材。但等等,这个 API 还没有安全保护。如果我们的后端应用程序可以发出 HTTP 请求来添加和删除食材,那么其他人也可以。即使使用 curl 命令行客户端,有人也可以像这样添加新的食材:
Bash
$ curl localhost:8080/api/ingredients \
-H"Content-type: application/json" \
-d'{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}'1
2
3
2
3
他们甚至可以使用 curl 来删除现有的食材,如下所示:
Bash
$ curl localhost:8080/api/ingredients/GRBF -X DELETE这个 API 是主应用程序的一部分,对全世界开放;实际上,GET 端点被主应用程序的用户界面在 home.html 中使用。因此,很明显,我们至少需要保护 POST 和 DELETE 端点。一种选择是使用 HTTP Basic 认证来保护 /api/ingredients 端点。这可以通过在处理器方法中添加 @PreAuthorize 来完成,如下所示:
Java
@PostMapping
@PreAuthorize("#{hasRole('ADMIN')}")
public Ingredient saveIngredient(@RequestBody Ingredient ingredient) {
return repo.save(ingredient);
}
@DeleteMapping("/{id}")
@PreAuthorize("#{hasRole('ADMIN')}")
public void deleteIngredient(@PathVariable("id") String ingredientId) {
repo.deleteById(ingredientId);
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
或者,可以在安全配置中保护端点,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/ingredients").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/ingredients/**").hasRole("ADMIN")
...
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Note:是否使用
ROLE_前缀Spring Security 中的权限可以有多种形式,包括角色、权限和(我们稍后将看到的)OAuth 2 范围。特别是角色,是一种带有
ROLE_前缀的特殊形式的权限。当使用直接处理角色的方法或 SpEL 表达式,如
hasRole()时,ROLE_前缀是隐含的。因此,调用hasRole("ADMIN")实际上是在检查一个名为ROLE_ADMIN的权限。在调用这些方法和函数时,你不需要显式地使用ROLE_前缀(实际上,这样做会导致ROLE_前缀重复)。其他处理权限更一般的 Spring Security 方法和函数也可以用来检查角色。但在这些情况下,你必须显式添加
ROLE_前缀。例如,如果你选择使用hasAuthority()而不是hasRole(),你需要传入ROLE_ADMIN而不是ADMIN。
无论哪种方式,向 /api/ingredients 提交 POST 或 DELETE 请求都需要提交者提供具有 ROLE_ADMIN 权限的凭据。例如,使用 curl,可以使用 -u 参数来指定凭据,如下所示:
Bash
$ curl localhost:8080/api/ingredients \
-H"Content-type: application/json" \
-d'{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}' \
-u admin:l3tm31n1
2
3
4
2
3
4
虽然 HTTP Basic 会锁定 API,但它实在是太过于基础了。它要求客户端和 API 共享用户的凭据。此外,虽然 HTTP Basic 凭据在请求的头部进行了 Base64 编码,但如果黑客以某种方式截获了请求,凭据可以很容易地被获取、解码,并用于恶意目的。
如果我们不要求管理员用户在每个请求中都进行身份验证,而是 API 只请求一些证明他们有权访问资源的令牌,那会怎样呢?这大致就像一场体育赛事的门票。要进入比赛,检票员不需要知道你是谁;他们只需要知道你有有效的门票。如果是这样,那么你就被允许进入。
这大致就是 OAuth 2 授权的工作方式。客户端从授权服务器请求一个访问令牌 —— 类似于代客泊车钥匙,这需要用户的明确许可。该令牌允许他们代表授权客户端的用户与 API 进行交互。在任何时候,令牌都可能过期或被撤销,而不需要更改用户的密码。在这种情况下,客户端只需要请求一个新的访问令牌,就可以继续代表用户行事。这个流程在图 4.1 中有所说明:

OAuth 2 是一个非常丰富的安全规范,提供了很多使用方式。图 4.1 中描述的流程被称为授权码授权。OAuth 2 规范支持的其他流程包括:
隐式授权:和授权码授权一样,隐式授权将用户的浏览器重定向到授权服务器以获取用户的同意。但当重新定向回来时,除了在请求中提供授权码外,访问令牌也在请求中。虽然最初是为在浏览器中运行的 JavaScript 客户端设计的,但这种流程现在通常不再推荐,而是更倾向于使用授权码授权;
用户凭据(或密码)授权:在这个流程中,不会发生重定向,甚至可能不涉及 Web 浏览器。相反,客户端应用程序获取用户的凭据,并直接将它们交换为访问令牌。这个流程似乎适合于非基于浏览器的客户端,但现代应用程序通常更倾向于要求用户在浏览器中访问网站并执行授权码授权,以避免处理用户的凭据;
客户端凭据授权:类似于用户凭据授权,只不过不是将用户的凭据转换为访问令牌,而是客户端自己用凭据来申请访问令牌。但是,授予的令牌的范围仅限于执行非以用户为中心的操作,不能用于代表用户;
对于我们的目标,我们将专注于使用授权代码授权流程来获取 JSON Web Token(JWT)访问令牌。这将涉及创建一些协同工作的应用程序,包括以下内容:
授权服务器:授权服务器的任务是代表客户端应用程序从用户那里获取权限。如果用户授予了权限,那么授权服务器会给客户端应用程序一个它可以用来获取对 API 的认证访问的访问令牌;
资源服务器:资源服务器只是另一个受 OAuth 2 保护的 API 的名称。尽管资源服务器是 API 本身的一部分,但为了讨论,两者通常被视为两个不同的概念。资源服务器限制对其资源的访问,除非请求提供了具有必要权限范围的有效访问令牌;
客户端应用程序:客户端应用程序是一个想要使用 API 但需要权限才能这样做的应用程序。我们将为 Taco Cloud 构建一个简单的管理应用程序,以便能够添加新的成分;
用户:这是使用客户端应用程序并授予应用程序访问资源服务器 API 权限的人;
在授权代码授权流程中,客户端应用程序和授权服务器之间会发生一系列浏览器重定向,以便客户端获取访问令牌。它开始于客户端将用户的浏览器重定向到授权服务器,请求特定的权限(或 “范围”)。然后,授权服务器要求用户登录并同意请求的权限。在用户授予了同意后,授权服务器将浏览器重定向回客户端,并附带一个客户端可以用来交换访问令牌的代码。一旦客户端拥有了访问令牌,它就可以通过在每个请求的 Authorization 头部传递它来与资源服务器 API 进行交互。
Note:尽管我们将把注意力集中在 OAuth 2 的特定用途上,但我们鼓励你通过阅读 OAuth 2 规范或阅读相关该主题的书籍来深入研究该主题。
你可能还想看一下一个名为 “使用 Spring Security 和 OAuth 2 保护用户数据” 的 liveProject。
多年来,一个名为 Spring Security for OAuth 的项目为 OAuth 1.0a 和 OAuth 2 提供了支持。它与 Spring Security 分开,但由同一团队开发。然而,近年来,Spring Security 团队已经将客户端和资源服务器组件吸收到 Spring Security 本身。
至于授权服务器,决定不将其包含在 Spring Security 中。相反,鼓励开发人员使用来自 Okta、Google 等各种供应商的授权服务器。但是,由于开发者社区的需求,Spring Security 团队启动了一个 Spring Authorization Server 项目。这个项目被标记为 “实验性” 的,并且最终将由社区驱动,但它是开始使用 OAuth 2 的好方法,无需注册其他授权服务器实现。
接下来我们将看到如何使用 Spring Security 使用 OAuth 2。在此过程中,我们将创建两个新项目,一个授权服务器项目和一个客户端项目,并修改我们现有的 Taco Cloud 项目,使其 API 充当资源服务器。我们将首先使用 Spring Authorization Server 项目创建一个授权服务器。
4.2. 创建验证服务器
授权服务器的主要任务是代表用户发出访问令牌。如前所述,我们有几种授权服务器实现可供选择,但我们将在我们的项目中使用 Spring Authorization Server。Spring Authorization Server 是实验性的,并没有实现所有的 OAuth 2 授权类型,但它确实实现了授权代码授权和客户端凭证授权。
授权服务器是一个与提供 API 的任何应用程序不同的应用程序,也与客户端不同。因此,要开始使用 Spring Authorization Server,你需要创建一个新的 Spring Boot 项目,选择(至少)Web 和 Security 启动器。对于我们的授权服务器,用户将使用 JPA 存储在关系数据库中,所以一定要添加 JPA 启动器和 H2 依赖项。另外,如果你正在使用 Lombok 来处理 Getter、Setter、构造函数等,那么也需要包含它。

Spring Authorization Server 还没有作为 Spring Initializr 的依赖项提供。因此,一旦你的项目被创建,你需要手动将 Spring Authorization Server 依赖项添加到你的构建中。例如,这是你需要在你的 pom.xml 文件中包含的 Maven 依赖项:
XML
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.4.4</version>
</dependency>1
2
3
4
5
2
3
4
5
Note
原文当中仍然使用的是实验性的版本:
XML<dependency> <groupId>org.springframework.security.experimental</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>0.1.2</version> </dependency>1
2
3
4
5此外
spring-security-oauth2-authorization-server从1.0.x开始需要 Spring 6 及以上版本,所以这里我们选择0.4.4版本。
接下来,因为我们将在我们的开发机器上运行所有这些项目,你需要确保主 Taco Cloud 应用程序和授权服务器之间没有端口冲突。将以下条目添加到项目的 application.yml 文件中,将使授权服务器在 9000 端口上可用:
YAML
server:
port: 90001
2
2
现在,让我们深入研究授权服务器将使用的基本安全配置。下面的代码清单显示了一个非常简单的 Spring Security 配置类,该类启用了基于表单的登录,并要求所有请求都进行身份验证。
Java
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
return http
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().authenticated()
)
.formLogin()
.and().build();
}
@Bean
UserDetailsService userDetailsService(UserRepository userRepo) {
return userRepo::findByUsername;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
注意,UserDetailsService bean 使用 UserRepository 来通过用户名查找用户。为了继续配置授权服务器本身,我们将跳过 UserRepository 的具体内容。
我们可以在 CommandLineRunner bean 中使用 UserRepository 来预先填充数据库,如下所示,添加一些测试用户:
Java
@Bean
public ApplicationRunner dataLoader(
UserRepository repo, PasswordEncoder encoder) {
return args -> {
repo.save(new User("habuma", encoder.encode("password"), "ROLE_ADMIN"));
repo.save(new User("tacochef", encoder.encode("password"), "ROLE_ADMIN"));
};
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
现在我们可以开始应用配置来启用授权服务器。配置授权服务器的第一步是创建一个新的配置类,该类导入了授权服务器的一些常见配置。以下是 AuthorizationServerConfig 的代码:
Java
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http
.formLogin(Customizer.withDefaults())
.build();
}
...
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Note
如果你目前
spring-security-oauth2-authorization-server使用的也是0.4.4版本,那么还需要添加AuthorizationServerSettingsbean:Java@Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); }1
2
3
4
authorizationServerSecurityFilterChain() 这个 bean 方法定义了一个 SecurityFilterChain,它为 OAuth 2 授权服务器设置了一些默认行为,并提供了一个默认的表单登录页面。@Order 注解被赋予了 Ordered.HIGHEST_PRECEDENCE,以确保如果出于某种原因声明了这种类型的其他 bean,此 bean 将优先于其他 bean。
大部分情况下,这是一个样板配置。如果你愿意,可以深入了解并自定义配置。但现在,我们只是使用默认设置。
有一个组件(客户端存储库)不是样板配置,因此不是由 OAuth2AuthorizationServerConfiguration 提供的。客户端存储库是类似于用户详细信息服务或用户存储库,不同之处在于不是维护详细信息用户,而是为请求授权需要询问的客户。它由 RegisteredClientRepository 接口定义,该接口如下所示:
Java
public interface RegisteredClientRepository {
void save(RegisteredClient registeredClient);
@Nullable
RegisteredClient findById(String id);
@Nullable
RegisteredClient findByClientId(String clientId);
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
在生产环境中,你可能会编写 RegisteredClientRepository 的自定义实现,从数据库或其他来源检索客户端详细信息。但是,Spring 授权服务器开箱即用,提供了一个适合演示和测试目的的内存实现。我们鼓励你按照你认为合适的方式实现 RegisteredClientRepository。但是对于我们的目的,我们将使用内存实现在授权服务器上注册一个客户端。请在 AuthorizationServerConfig 中添加以下 bean 方法:
Java
@Bean
public RegisteredClientRepository registeredClientRepository(
PasswordEncoder passwordEncoder) {
RegisteredClient registeredClient =
RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("taco-admin-client")
.clientSecret(passwordEncoder.encode("secret"))
.clientAuthenticationMethod(
ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri(
"http://127.0.0.1:9090/login/oauth2/code/taco-admin-client")
.scope("writeIngredients")
.scope("deleteIngredients")
.scope(OidcScopes.OPENID)
.clientSettings(
ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
如你所见,RegisteredClient 中包含了许多细节。但是,从上到下,我们的客户端定义如下:
ID:一个随机的、唯一的标识符;Client ID:类似于用户名,但不是用户,而是客户端。在这个例子中,是taco-admin-client;Client secret:类似于客户端的密码。在这里,我们使用secret作为客户端密钥;Authorization grant type:这个客户端将支持的 OAuth 2 授权类型。在这个例子中,我们启用了授权码和刷新令牌授权;Redirect URL:一个或多个注册的 URL,授权服务器在授权被授予后可以重定向到这些 URL。这增加了另一层安全性,防止某个任意应用程序接收到它可以用来交换令牌的授权码;Scope:这个客户端被允许请求的一个或多个 OAuth 2 范围。在这里我们设置了三个范围:writeIngredients、deleteIngredients以及常量OidcScopes.OPENID,它解析为openid。当我们使用授权服务器作为 Taco Cloud 管理应用程序的单点登录解决方案时,openid范围将是必需的;Client settings:在这个例子中,我们在授予请求的范围之前需要用户明确的同意。如果没有这个设置,用户登录后范围将被隐式授予;
最后,因为我们的授权服务器将会生成 JWT 令牌,这些令牌需要包含一个使用 JSON Web Key(JWK)作为签名密钥创建的签名。因此,我们需要一些 beans 来生成 JWK。在 AuthorizationServerConfig 中添加以下的 bean 方法(和私有的辅助方法)来帮助我们处理这个问题:
Java
@Bean
public JWKSource<SecurityContext> jwkSource()
throws NoSuchAlgorithmException {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsa() throws NoSuchAlgorithmException {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}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
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
这里似乎有很多事情正在进行。但是简单来说,JWKSource 创建了将用于签署令牌的 RSA 2048 位密钥对。令牌将使用私钥进行签名。然后,资源服务器可以通过从授权服务器获取公钥来验证请求中收到的令牌是否有效。我们在创建资源服务器时会更详细地讨论这个问题。
我们的授权服务器的所有部分现在都已就绪。剩下的就是启动它并试用一下。构建并运行应用程序,你应该有一个监听 9000 端口的授权服务器。
因为我们还没有客户端,所以你可以使用你的网络浏览器和 curl 命令行工具假装自己是一个客户端。首先,将你的网络浏览器指向:
Text
http://localhost:9000/oauth2/authorize?response_type=code&client_id=taco-admin-client&redirect_uri=http://127.0.0.1:9090/login/oauth2/code/taco-admin-client&scope=writeIngredients+deleteIngredients你应该会看到一个看起来像图 4.3 的登录页面:

在登录后(使用 tacochef 和 password,或者数据库中 UserRepository 下的某个用户名密码组合),你会被要求在一个看起来像图 4.4 的页面上同意请求的范围。在授予同意后,浏览器将被重定向回客户端 URL。我们还没有客户端,所以那里可能什么都没有,你会收到一个错误。但是没关系 —— 我们假装自己是客户端,所以我们将从 URL 中自己获取授权码。

查看浏览器的地址栏,你会看到 URL 有一个 code 参数。复制该参数的整个值,并在以下的 curl 命令行中用它替换 $code:
Bash
$ curl localhost:9000/oauth2/token \
-H "Content-type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "redirect_uri=http://127.0.0.1:9090/login/oauth2/code/taco-admin-client" \
-d "code=$code" \
-u taco-admin-client:secret1
2
3
4
5
6
2
3
4
5
6
在这里,我们正在将收到的授权码换成访问令牌。消息载体是 application/x-www-form-urlencoded 格式,并发送授权类型(authorization_code),重定向 URI(用于额外的安全性),以及授权码本身。如果一切顺利,那么你将收到一个 JSON 响应,看起来像这样:
JSON
{
"access_token": "eyJraW...",
"refresh_token": "iBLFkq...",
"scope": "deleteIngredients writeIngredients",
"token_type": "Bearer",
"expires_in": 300
}1
2
3
4
5
6
7
2
3
4
5
6
7
access_token 属性包含客户端可以用来向 API 发送请求的访问令牌(实际上,它比这里显示的要长得多,同样,refresh_token 在这里也被缩短了)。但是现在可以将访问令牌发送到资源服务器的请求中,以获取需要 writeIngredients 或 deleteIngredients 范围的资源的访问权限。访问令牌将在 300 秒后过期,所以如果我们要使用它,就必须快点。但是如果它过期了,那么我们可以使用刷新令牌来获取一个新的访问令牌,而不需要再次经历整个授权流程。
那么,我们如何使用访问令牌呢?我们可能会将它作为 Authorization 头的一部分发送到 Taco Cloud API 的请求中 —— 可能像这样:
Bash
$ curl localhost:8080/api/ingredients \
-H "Content-type: application/json" \
-H "Authorization: Bearer eyJraW..." \
-d '{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}'1
2
3
4
2
3
4
在这一点上,令牌对我们没有任何帮助。那是因为我们的 Taco Cloud API 还没有被启用为资源服务器。但是,即使没有实际的资源服务器和客户端 API,我们仍然可以通过复制它并粘贴到 https://jwt.io 的表单中来检查访问令牌。结果看起来会像图 4.5 那样。

如你所见,令牌被解码为三个部分:头部、负载和签名。仔细看负载,你会发现这个令牌是代表名为 tacochef 的用户发出的,令牌具有 writeIngredients 和 deleteIngredients 的范围。这正是我们要求的!
大约 5 分钟后,访问令牌将过期。你仍然可以在 https://jwt.io 的调试器中检查它,但是如果它在一个真实的请求中被给到一个 API,它将被拒绝。但是你可以在不再次通过授权码授予流程的情况下请求一个新的访问令牌。你需要做的就是使用 refresh_token 授权并传递刷新令牌作为 refresh_token 参数的值,向授权服务器发出一个新的请求。使用 curl,这样的请求看起来会像这样:
Bash
$ curl localhost:9000/oauth2/token \
-H "Content-type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=iBLFkq..." \
-u taco-admin-client:secret1
2
3
4
2
3
4
对这个请求的响应将与最初用于交换授权码以获取访问令牌的请求的响应相同,只是带有一个全新的访问令牌。
访问令牌的真正力量和目的是获得对 API 的访问权限。所以,让我们看看如何在 Taco Cloud API 上启用一个资源服务器。
4.3. 利用资源服务器保护 API
资源服务器实际上只是一个过滤器,它位于 API 前面,确保对需要授权的资源的请求包含具有所需范围的有效访问令牌。Spring Security 提供了一个 OAuth 2 资源服务器实现,你可以通过将以下依赖项添加到项目构建中,将其添加到现有的 API,如下所示:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>1
2
3
4
2
3
4
有了这个依赖,下一步就是声明对 /api/ingredients 的 POST 请求需要 writeIngredients 范围,而对 /api/ingredients 的 DELETE 请求需要 deleteIngredients 范围。以下是 SecurityConfig 类中的部分内容,展示了如何做到这一点:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/ingredients")
.hasAuthority("SCOPE_writeIngredients")
.antMatchers(HttpMethod.DELETE, "/api/ingredients")
.hasAuthority("SCOPE_deleteIngredients")
...
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
每个相应的端点都调用了 .hasAuthority() 方法来指定所需的范围。注意,这些范围前缀为 SCOPE_,表示它们应与在请求这些资源时给定的访问令牌中的 OAuth 2 范围进行匹配。
在该配置类中,我们还需要启用资源服务器,如下所示:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
...
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
这里的 oauth2ResourceServer() 方法接收一个用于配置资源服务器的 lambda。在这里,它只是启用了 JWT 令牌(而不是不透明令牌),以便资源服务器可以检查令牌的内容,找出它包含哪些安全声明。具体来说,它会查看令牌是否包含我们已经保护的两个端点的 writeIngredients 或 deleteIngredients 范围。
然而,它不会盲目地信任令牌。为了确信令牌是由受信任的授权服务器代表用户创建的,它会使用与用于创建令牌签名的私钥匹配的公钥来验证令牌的签名。我们需要配置资源服务器,让它知道在哪里获取公钥。以下属性将指定授权服务器上的 JWK 集 URL,资源服务器将从该 URL 获取公钥:
YAML
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:9000/oauth2/jwks1
2
3
4
5
6
2
3
4
5
6
现在我们的资源服务器已经准备好了!构建 Taco Cloud 应用并启动它。然后,您可以使用 curl 来试用它,如下所示:
Bash
$ curl localhost:8080/api/ingredients \
-H "Content-type: application/json" \
-d '{"id":"CRKT", "name":"Legless Crickets", "type":"PROTEIN"}'1
2
3
2
3
请求应该会以 HTTP 401 响应代码失败。这是因为我们已经配置了端点,要求该端点的 writeIngredients 范围,而我们在请求上没有提供具有该范围的有效访问令牌。
要成功请求并添加一个新的配料项,您需要使用我们在上一节中使用的流程获取访问令牌,确保我们在将浏览器定向到授权服务器时请求 writeIngredients 和 deleteIngredients 范围。然后,使用 curl 在 Authorization 头中提供访问令牌,如下所示(将 $token 替换为实际的访问令牌):
Bash
$ curl localhost:8080/api/ingredients \
-H "Content-type: application/json" \
-H "Authorization: Bearer $token" \
-d '{"id":"SHMP", "name":"Coconut Shrimp", "type":"PROTEIN"}'1
2
3
4
2
3
4
这次新的配料应该会被创建。您可以使用 curl 或您选择的 HTTP 客户端对 /api/ingredients 端点执行 GET 请求来验证,如下所示:
Bash
$ curl localhost:8080/api/ingredients
[
{
"id": "FLTO",
"name": "Flour Tortilla",
"type": "WRAP"
},
...
{
"id": "SHMP",
"name": "Coconut Shrimp",
"type": "PROTEIN"
}
]1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Coconut Shrimp 现在被包含在 /api/ingredients 端点返回的所有配料列表的末尾。
请记住,访问令牌在 5 分钟后过期。如果您让令牌过期,请求将开始再次返回 HTTP 401 响应。但是,您可以通过使用您与访问令牌一起获得的刷新令牌向授权服务器发出请求来获取新的访问令牌。
现在我们已经保护了 /api/ingredients 端点,将同样的技术应用于保护我们 API 中的其他可能敏感的端点可能是个好主意。例如,/orders 端点可能不应该对任何类型的请求开放,即使是 HTTP GET 请求,因为这会让黑客很容易地获取客户信息。
使用 curl 管理 Taco Cloud 应用对于研究和了解 OAuth 2 令牌如何允许访问资源来说非常有效。但最终,我们希望有一个真正的客户端应用程序可以用来管理配料。现在,让我们转向创建一个能够获取访问令牌并向 API 发送请求的 OAuth-enabled 客户端。
4.4. 开发客户端
在 OAuth 2 授权流程中,客户端应用程序的角色是代表用户获取访问令牌,并向资源服务器发出请求。因为我们正在使用 OAuth 2 的授权码流程,这意味着当客户端应用程序确定用户尚未进行身份验证时,它应该将用户的浏览器重定向到授权服务器以获得用户的同意。然后,当授权服务器将控制权重定向回客户端时,客户端必须将收到的授权码交换为访问令牌。
首先,客户端需要在其类路径中有 Spring Security 的 OAuth 2 客户端支持。添加启动器依赖项:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>1
2
3
4
2
3
4
这不仅为应用程序提供了我们将在稍后利用的 OAuth 2 客户端功能,而且还传递性地引入了 Spring Security 本身。这使我们能够为应用程序编写一些安全配置。以下的 SecurityFilterChain bean 设置了 Spring Security,以便所有请求都需要身份验证:
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().authenticated())
.oauth2Login(oauth2Login ->
oauth2Login.loginPage("/oauth2/authorization/taco-admin-client")
)
.oauth2Client(Customizer.withDefaults())
.build();
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
此外,这个 SecurityFilterChain bean 也启用了 OAuth 2 的客户端部分。具体来说,它在 /oauth2/authorization/taco-admin-client 路径下设置了一个登录页面。但这不是一个普通的接受用户名和密码的登录页面。相反,它接受一个授权码,将其交换为访问令牌,并使用访问令牌来确定用户的身份。换句话说,这是用户授予权限后授权服务器将重定向到的路径。
我们还需要配置有关授权服务器和我们应用程序的 OAuth 2 客户端详细信息的信息。这是在配置属性中完成的,例如在以下的 application.yml 文件中,它配置了一个名为 taco-admin-client 的客户端:
YAML
spring:
security:
oauth2:
client:
registration:
taco-admin-client:
provider: tacocloud
client-id: taco-admin-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:9090/login/oauth2/code/{registrationId}"
scope: writeIngredients,deleteIngredients,openid1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
这将一个名为 taco-admin-client 的客户端注册到 Spring Security OAuth 2 客户端。注册详情包括客户端的凭证(client-id 和 client-secret)、授权类型(authorization-grant-type)、请求的范围(scope)和重定向 URI(redirect-uri)。注意,给 redirect-uri 的值有一个占位符,它引用了客户端的注册 ID,即 taco-admin-client。因此,重定向 URI 被设置为 http://127.0.0.1:9090/login/oauth2/code/taco-admin-client,它与我们之前配置为 OAuth 2 登录的路径相同。
那么授权服务器本身呢?我们在哪里告诉客户端应该将用户的浏览器重定向到哪里?这就是 provider 属性所做的,尽管是间接的。provider 属性被设置为 tacocloud,这是对描述 tacocloud 提供者的授权服务器的一组单独配置的引用。该提供者配置在同一 application.yml 文件中配置,如下所示:
YAML
spring:
security:
oauth2:
client:
...
provider:
tacocloud:
issuer-uri: http://authserver:90001
2
3
4
5
6
7
8
2
3
4
5
6
7
8
对于提供者配置,唯一需要的属性是 issuer-uri。此属性标识授权服务器的基本 URI。在这种情况下,它指的是一个名为 authserver 的服务器主机。假设您在本地运行这些示例,这只是 localhost 的另一个别名。在大多数基于 Unix 的操作系统中,可以在您的 /etc/hosts 文件中添加以下行来实现这一点:
Text
127.0.0.1 authserver基于基本 URL,Spring Security 的 OAuth 2 客户端将假定合理的默认值作为授权 URL、令牌 URL 和其他授权服务器的具体信息。但是,如果由于某种原因您正在使用的授权服务器与这些默认值不同,您可以像这样明确配置授权详细信息:
YAML
spring:
security:
oauth2:
client:
provider:
tacocloud:
issuer-uri: http://authserver:9000
authorization-uri: http://authserver:9000/oauth2/authorize
token-uri: http://authserver:9000/oauth2/token
jwk-set-uri: http://authserver:9000/oauth2/jwks
user-info-uri: http://authserver:9000/userinfo
user-name-attribute: sub1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
我们已经看到了大部分这些 URI,比如授权、令牌和 JWK 集 URI。然而,user-info-uri 属性是新的。这个 URI 被客户端用来获取关键的用户信息,最主要的是用户的用户名。对该 URI 的请求应返回一个包含 username-attribute 指定的属性的 JSON 响应,以识别用户。但是,请注意,当使用 Spring Authorization Server 时,您不需要为该 URI 创建端点。Spring Authorization Server 将自动公开 user-info 端点。
现在,应用程序已经准备好从授权服务器进行身份验证并获取访问令牌了。不做任何其他事情,您可以启动应用程序,向该应用程序的任何 URL 发送请求,并被重定向到授权服务器进行授权。当授权服务器重定向回来时,Spring Security 的 OAuth 2 客户端库的内部工作将会交换它在重定向中收到的代码以获取访问令牌。那么,我们如何使用这个令牌呢?
假设我们有一个使用 RestTemplate 与 Taco Cloud API 交互的服务 bean。以下的 RestIngredientService 实现显示了这样一个类,它提供了两个方法:一个用于获取配料列表,另一个用于保存新的配料:
Java
public class RestIngredientService {
private final RestTemplate restTemplate;
public RestIngredientService() {
this.restTemplate = new RestTemplate();
}
public Iterable<Ingredient> findAll() {
return Arrays.asList(Optional.ofNullable(restTemplate.getForObject(
"http://localhost:8080/api/ingredients",
Ingredient[].class)).orElseGet(() -> new Ingredient[0]));
}
public Ingredient addIngredient(Ingredient ingredient) {
return restTemplate.postForObject(
"http://localhost:8080/api/ingredients",
ingredient,
Ingredient.class);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
对 /api/ingredients 端点的 HTTP GET 请求没有被保护,所以只要 Taco Cloud API 在 localhost 的 8080 端口监听,findAll() 方法应该可以正常工作。但是 addIngredient() 方法可能会因为我们已经保护了对 /api/ingredients 的 POST 请求需要 writeIngredients 范围而返回 HTTP 401 响应失败。唯一的解决办法是在请求的 Authorization 头中提交一个具有 writeIngredients 范围的访问令牌。
幸运的是,Spring Security 的 OAuth 2 客户端在完成授权码流程后应该有访问令牌。我们需要做的就是确保访问令牌最终出现在请求中。为了做到这一点,让我们改变构造函数,将一个请求拦截器附加到它创建的 RestTemplate,如下所示:
Java
public RestIngredientService(String accessToken) {
this.restTemplate = new RestTemplate();
if (accessToken != null) {
this.restTemplate
.getInterceptors()
.add((request, bytes, execution) -> {
request.getHeaders().add("Authorization", "Bearer " + accessToken);
return execution.execute(request, bytes);
});
}
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
现在,构造函数接受一个是访问令牌的 String 参数。使用这个令牌,它将一个客户端请求拦截器附加到 RestTemplate,使得每个由 RestTemplate 发出的请求都添加了 Authorization 头,头的值是 Bearer 后跟令牌值。
只剩下一个问题:访问令牌来自哪里?以下的 bean 方法就是魔法发生的地方:
Java
@Bean
@RequestScope
public RestIngredientService ingredientService(
OAuth2AuthorizedClientService clientService) {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
String accessToken = null;
if (authentication.getClass()
.isAssignableFrom(OAuth2AuthenticationToken.class)) {
OAuth2AuthenticationToken oauthToken =
(OAuth2AuthenticationToken) authentication;
String clientRegistrationId =
oauthToken.getAuthorizedClientRegistrationId();
if (clientRegistrationId.equals("taco-admin-client")) {
OAuth2AuthorizedClient client =
clientService.loadAuthorizedClient(
clientRegistrationId, oauthToken.getName());
accessToken = client.getAccessToken().getTokenValue();
}
}
return new RestIngredientService(accessToken);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
首先,注意到使用 @RequestScope 注解声明了 bean 是请求范围的。这意味着在每个请求上都会创建一个新的 bean 实例。bean 必须是请求范围的,因为它需要从 SecurityContext 中获取身份验证,而 SecurityContext 是由 Spring Security 的一个过滤器在每个请求上填充的;在应用程序启动时创建默认范围的 bean 时,没有 SecurityContext。
在返回 RestIngredientService 实例之前,bean 方法检查身份验证是否实际上是作为 OAuth2AuthenticationToken 实现的。如果是,那么这意味着它将拥有令牌。然后,它验证身份验证令牌是否为名为 taco-admin-client 的客户端。如果是,那么它就从授权客户端中提取令牌,并通过 RestIngredientService 的构造函数传递它。
有了那个令牌,RestIngredientService 在代表授权应用程序的用户向 Taco Cloud API 的端点发出请求时就不会有任何问题了。
5. 小结
REST 服务:
可以使用 Spring MVC 创建 REST 端点,其控制器遵循与面向浏览器的控制器相同的编程模型;
控制器处理方法可以用
@ResponseBody注解,或者返回ResponseEntity对象来绕过模型和视图,并直接写入响应体中的数据;@RestController注解简化了 REST 控制器,无需在处理方法上使用@ResponseBody;使用 Spring Data REST,可以自动将 Spring Data 仓库公开为 REST API;
保护 REST 服务:
OAuth 2 安全性是一种常见的保护 API 的方式,比简单的 HTTP Basic 认证更强大;
授权服务器为客户端颁发访问令牌,以便在以下情况下代表用户行事:向 API 发出请求(或在客户端令牌流的情况下代表 API);
资源服务器位于 API 前面,以验证是否存在有效的、未过期的令牌,并提供访问 API 资源所需的范围;
Spring Authorization Server 实现了 OAuth 2 授权服务器;
Spring Security 提供了创建资源服务器的支持,以及创建从授权服务器获取访问令牌并在通过资源服务器发送请求时传递这些令牌的客户端;