Spring REST 教程(二)
原文:Spring REST
五、错误处理
在本章中,我们将讨论以下内容:
-
在 REST API 中处理错误
-
设计有意义的错误响应
-
验证 API 输入
-
外部化错误消息
对于程序员来说,错误处理是最重要的话题之一,但也是容易被忽视的话题。尽管我们怀着良好的意图开发软件,但事情确实会出错,我们必须准备好优雅地处理和交流这些错误。对于使用 REST API 的开发人员来说,通信方面尤其重要。设计良好的错误响应允许开发人员理解问题,并帮助他们正确使用 API。此外,良好的错误处理允许 API 开发人员记录有助于他们调试问题的信息。
快速轮询错误处理
在我们的 QuickPoll 应用中,考虑用户试图检索不存在的投票的场景。图 5-1 显示了邮递员请求一个 id 为 100 的不存在的轮询。
图 5-1
请求一个不存在的投票
收到请求后,QuickPoll 应用中的PollController使用PollRepository来检索投票。由于 id 为 100 的 poll 不存在,PollRepository的findById方法返回一个空选项,PollController向客户端发送一个空正文,如图 5-2 所示。
图 5-2
对不存在的投票的响应
Note
在本章中,我们将继续使用我们在上一章中构建的 QuickPoll 应用。代码也可以在下载的源代码的Chapter5\starter文件夹下找到。完成的解决方案可以在Chapter5\final文件夹下找到。由于我们在本章的一些清单中省略了 getter/setter 方法和导入,请参考final文件夹下的代码以获得完整的清单。Chapter5文件夹还包含一个导出的 Postman 集合,该集合包含与本章相关的 REST API 请求。
这种当前的实现是欺骗性的,因为客户端接收到状态码 200。相反,应该返回状态代码 404,表明请求的资源不存在。为了实现这个正确的行为,我们将在com.apress.controller.PollController的getPoll方法中验证投票 id,对于不存在的投票,抛出一个com.apress.exception.ResourceNotFoundException异常。清单 5-1 显示了修改后的getPoll实现。
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
Optional<Poll> poll = pollRepository.findById(pollId);
if(!poll.isPresent()) {
throw new ResourceNotFoundException("Poll with id " + pollId + " not found");
}
return new ResponseEntity<>(poll.get(), HttpStatus.OK);
}
Listing 5-1getPoll Implementation
ResourceNotFoundException是一个自定义异常,其实现如清单 5-2 所示。请注意,在类级别声明了一个@ResponseStatus注释。注释指示 Spring MVC,当抛出ResourceNotFoundException时,应该在响应中使用HttpStatus NOT_FOUND (404 代码)。
package com.apress.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ResourceNotFoundException() {}
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
Listing 5-2ResourceNotFoundException Implementation
完成这些修改后,启动 QuickPoll 应用,并运行 Postman 请求,以进行 ID 为 100 的轮询。PollController返回正确的状态码,如图 5-3 所示。
图 5-3
对不存在的投票的新响应
除了 GET 之外,PUT、DELETE 和 PATCH 等其他 HTTP 方法也作用于现有的轮询资源。因此,我们需要在相应的方法中执行相同的轮询 ID 验证,以便向客户端返回正确的状态代码。清单 5-3 显示了封装到PollController的verifyPoll方法中的轮询 id 验证逻辑,以及修改后的getPoll、updatePoll和deletePoll方法。
protected Poll verifyPoll(Long pollId) throws ResourceNotFoundException {
Optional<Poll> poll = pollRepository.findById(pollId);
if(!poll.isPresent()) {
throw new ResourceNotFoundException("Poll with id " + pollId + " not found");
}
return poll.get();
}
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
return new ResponseEntity<>(verifyPoll(pollId), HttpStatus.OK);
}
@PutMapping("/polls/{pollId}")
public ResponseEntity<?> updatePoll(@RequestBody Poll poll, @PathVariable Long pollId) {
verifyPoll(pollId);
pollRepository.save(poll);
return new ResponseEntity<>(HttpStatus.OK);
}
@DeleteMapping("/polls/{pollId}")
public ResponseEntity<?> deletePoll(@PathVariable Long pollId) {
pollRepository.deleteById(pollId);
pollRepository.delete(pollId);
return new ResponseEntity<>(HttpStatus.OK);
}
Listing 5-3Updated PollController
错误响应
HTTP 状态代码在 REST APIs 中扮演着重要的角色。API 开发者应该努力返回指示请求状态的正确代码。此外,在响应正文中提供关于错误的有用的、细粒度的详细信息也是一种很好的做法。这些细节将使 API 消费者能够轻松地解决问题,并帮助他们恢复。如图 5-3 所示,Spring Boot 遵循这一惯例,并在错误响应主体中包含以下详细信息:
-
时间戳—错误发生的时间,以毫秒为单位。
-
status—与错误相关联的 HTTP 状态代码;这部分是多余的,因为它与响应状态代码相同。
-
错误—与状态代码相关联的描述。
-
exception-导致此错误的异常类的完全限定路径。
-
消息—提供有关错误的更多详细信息的消息。
-
路径-导致异常的 URI。
这些细节是由 Spring Boot 框架生成的。在非引导 Spring MVC 应用中,这个特性不是现成可用的。在本节中,我们将使用通用的 Spring MVC 组件为 QuickPoll 应用实现一个类似的错误响应,以便它在引导和非引导环境中都可以工作。在我们深入研究这个实现之前,让我们来看看两个流行的应用的错误响应细节:GitHub 和 Twilio。图 5-4 显示了 GitHub 对包含无效输入的请求的错误响应细节。message 属性给出了错误的简单描述,error 属性列出了输入无效的字段。在本例中,客户端的请求缺少问题资源的标题字段。
图 5-4
GitHub 错误响应
Twilio 提供了一个 API,允许开发人员以编程方式打电话、发送文本和接收文本。图 5-5 显示了缺少“收件人”电话号码的 POST 调用的错误响应。“状态”和“消息”字段类似于 Spring Boot 回复中的字段。代码字段包含一个数字代码,可用于查找有关异常的更多信息。more_info 字段包含错误代码文档的 URL。收到该错误时,Twilio API 消费者可以导航到 https ://www.twilio.com/docs/errors/21201 并获得更多信息来解决该错误。
图 5-5
Twilio 错误响应
很明显,对于错误没有一个标准的响应格式。由 API 和框架实现者决定发送给客户机的细节。然而,标准化响应格式的尝试已经开始,一个被称为 HTTP APIs 问题细节( http://tools.ietf.org/html/draft-nottingham-http-problem-06 )的 IETF 规范正在获得关注。受“HTTP APIs 的问题细节”规范的启发,清单 5-4 展示了我们将在 QuickPoll 应用中实现的错误响应格式。
{
"title" : "",
"status" : "",
"detail" : ",
"timestamp" : "",
"developerMessage: "",
"errors": {}
}
Listing 5-4QuickPoll Error Response Format
以下是快速轮询错误响应中字段的简要描述:
-
Title—title字段提供错误情况的简短标题。例如,作为输入验证结果的错误将具有标题“验证失败”同样,“内部服务器错误”将用于内部服务器错误。 -
Status—status字段包含当前请求的 HTTP 状态代码。尽管在响应体中包含状态代码是多余的,但它允许 API 客户端在一个地方查找进行故障排除所需的所有信息。 -
Detail—detail字段包含错误的简短描述。该字段中的信息通常是人类可读的,并且可以呈现给最终用户。 -
Timestamp—错误发生的时间,以毫秒为单位。 -
developerMessage—developerMessage包含与开发人员相关的异常类名或堆栈跟踪等信息。 -
错误-错误字段用于报告字段验证错误。
既然我们已经定义了错误响应,我们就可以修改 QuickPoll 应用了。我们首先创建响应细节的 Java 表示,如清单 5-5 所示。如您所见,ErrorDetail类缺少错误字段。我们将在下一节中添加该功能。
package com.apress.dto.error;
public class ErrorDetail {
private String title;
private int status;
private String detail;
private long timeStamp;
private String developerMessage;
// Getters and Setters omitted for brevity
}
Listing 5-5Error Response Details Representation
错误处理是一个横切关注点。我们需要一个应用范围的策略,以相同的方式处理所有的错误,并将相关的细节写入响应体。正如我们在第二章中讨论的,用@ControllerAdvice标注的类可以用来实现这样的横切关注点。清单 5-6 显示了带有恰当命名的handleResourceNotFoundException方法的RestExceptionHandler类。多亏了@ExceptionHandler注释,每当控制器抛出ResourceNotFoundException时,Spring MVC 就会调用RestExceptionHandler的handleResourceNotFoundException方法。在这个方法中,我们创建了一个ErrorDetail的实例,并用错误信息填充它。
package com.apress.handler;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import com.apress.dto.error.ErrorDetail;
import com.apress.exception.ResourceNotFoundException;
@ControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException rnfe, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.NOT_FOUND.value());
errorDetail.setTitle("Resource Not Found");
errorDetail.setDetail(rnfe.getMessage());
errorDetail.setDeveloperMessage(rnfe.getClass().getName());
return new ResponseEntity<>(errorDetail, null, HttpStatus.NOT_FOUND);
}
}
Listing 5-6RestExceptionHandler Implementation
为了验证我们新创建的处理程序是否按预期工作,重新启动 QuickPoll 应用,并向 id 为 100 的不存在的 Poll 提交一个 Postman 请求。您应该会看到如图 5-6 所示的错误响应。
图 5-6
ResourceNotFoundException 错误响应
输入字段验证
正如一句著名的谚语所说,“垃圾进来,垃圾出去”;输入字段验证应该是每个应用中的另一个重点领域。考虑这样一个场景:一个客户端请求创建一个新的投票,但是请求中不包含投票问题。图 5-7 显示了一个缺少问题的邮递员请求和相应的响应。确保在触发 Postman 请求之前将 Content-Type 头设置为“application/json”。从响应中,您可以看到投票仍然被创建。创建缺少问题的投票可能会导致数据不一致和其他错误。
图 5-7
创建带有缺失问题的投票
Spring MVC 提供了两个选项来验证用户输入。在第一个选项中,我们创建了一个实现了org.springframework.validation.Validator接口的验证器。然后,我们将这个验证器注入到控制器中,并手动调用验证器的 validate 方法来执行验证。第二种选择是使用 JSR 303 验证,这是一种旨在简化应用任何层中的字段验证的 API。考虑到框架的简单性和声明性,我们将在本书中使用 JSR 303 验证框架。
您可以在 https://beanvalidation.org/1.0/spec 了解更多 JSR 303。
JSR 303 和 JSR 349 定义了 Bean 验证 API 的规范(分别是版本 1.0 和 1.1)。它们通过一组标准化的验证约束为 JavaBean 验证提供了一个元数据模型。使用这个 API,您可以用诸如@NotNull和@Email这样的验证约束来注释域对象属性。实现框架在运行时强制执行这些约束。在本书中,我们将使用 Hibernate Validator,这是一个流行的 JSR 303/349 实现框架。表 5-1 显示了 Bean 验证 API 提供的一些现成的验证约束。此外,还可以定义自己的自定义约束。
表 5-1
Bean 验证 API 约束
|限制
|
描述
|
| --- | --- |
| NotNull | 注释字段不能有空值。 |
| Null | 注释字段必须为空。 |
| Max | 带注释的字段值必须是小于或等于注释中指定的数字的整数值。 |
| Min | 带注释的字段值必须是大于或等于注释中指定的数字的整数值。 |
| Past | 带注释的字段必须是过去的日期。 |
| Future | 带注释的字段必须是未来的日期。 |
| Size | 注释字段必须与注释中指定的最小和最大边界相匹配。对于作为集合的字段,集合的大小与边界相匹配。对于字符串字段,字符串的长度根据边界进行验证。 |
| Pattern | 带注释的字段必须与注释中指定的正则表达式匹配。 |
为了给 QuickPoll 添加验证功能,我们从注释 Poll 类开始,如清单 5-7 所示。因为我们希望确保每个投票都有一个问题,所以我们用一个@NotEmpty注释对问题字段进行了注释。javax.validation.constraints.NotEmpty注释不是 JSR 303/349 API 的一部分。相反,它是 Hibernate 验证器的一部分;它确保输入字符串不为空,并且其长度大于零。此外,为了简化投票体验,我们将限制每个投票包含不少于两个且不超过六个选项。
@Entity
public class Poll {
@Id
@GeneratedValue
@Column(name="POLL_ID")
private Long id;
@Column(name="QUESTION")
@NotEmpty
private String question;
@OneToMany(cascade=CascadeType.ALL)
@JoinColumn(name="POLL_ID")
@OrderBy
@Size(min=2, max = 6)
private Set<Option> options;
// Getters and Setters removed for brevity
}
Listing 5-7Poll Class Annotated with JSR 303 Annotations
我们现在将注意力转移到com.apress.controller.PollController上,并向createPoll方法的Poll参数添加一个@Valid注释,如清单 5-8 所示。@Valid注释指示 Spring 在绑定用户提交的数据后执行数据验证。Spring 将实际的验证委托给一个注册的验证器。随着 Spring Boot 将 JSR 303/JSR 349 和 Hibernate 验证器 jar 添加到类路径中,JSR 303/JSR 349 被自动启用,并将用于执行验证。
@GetMapping(value="/polls")
public ResponseEntity<?> createPoll(@Valid @RequestBody Poll poll) {
poll = pollRepository.save(poll);
// Set the location header for the newly created resource
HttpHeaders responseHeaders = new HttpHeaders();
URI newPollUri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(poll.getId())
.toUri();
responseHeaders.setLocation(newPollUri);
return new ResponseEntity<>(null, responseHeaders, HttpStatus.CREATED);
}
Listing 5-8PollController Annotated with @Valid Annotations
像图 5-7 中一样,用一个遗漏的问题重复邮递员请求,你会看到操作失败,错误代码为 400,如图 5-8 所示。从错误响应中,注意 Spring MVC 完成了对输入的验证。在没有找到必需的问题字段时,它抛出了一个MethodArgumentNotValidException异常。
图 5-8
遗漏问题导致错误
尽管 Spring Boot 的错误消息很有帮助,但是为了与我们在清单 5-4 中设计的快速轮询错误响应保持一致,我们将修改RestExceptionHandler,以便我们可以拦截MethodArgumentNotValidException异常并返回适当的ErrorDetail实例。当我们设计 QuickPoll 错误响应时,我们设计了一个错误字段来保存我们的验证错误。一个字段可能有一个或多个相关联的验证错误。例如,在我们的投票示例中,缺少问题字段将导致“字段不能为空”验证错误。同样,空电子邮件地址可能会导致“字段不能为空”和“字段不是格式良好的电子邮件”验证错误。请记住这些验证约束,清单 5-9 显示了一个完整的错误响应和验证错误示例。error对象包含一个无序的键值错误实例集合。error 键表示存在验证错误的资源馈送的名称。错误值是表示验证错误详细信息的数组。从清单 5-9 中,我们可以看到字段 1 包含一个验证错误,字段 2 与两个验证错误相关联。每个验证错误本身都由表示违反的约束的代码和包含人类可读错误表示的消息组成。
{
"title" : "",
"status" : "",
"detail" : ",
"timestamp" : "",
"path" : "",
"developerMessage: "",
"errors": {
"field1" : [ {
"code" : "NotNull",
message" : "Field1 may not be null"
} ],
"field2" : [ {
"code" : "NotNull",
"message" : "Field2 may not be null"
},
{
"code" : "Email",
"message" : "Field2 is not a well formed email"
}]
}
}
Listing 5-9Validation Error Format
为了在 Java 代码中表示新添加的验证错误特性,我们创建了一个新的com.apress.dto.error.ValidationError类。清单 5-10 显示了ValidationError类和更新后的ErrorDetail类。为了生成清单 5-9 中所示的错误响应格式,ErrorDetail类中的错误字段被定义为一个Map,它接受String实例作为键,接受ValidationError实例列表作为值。
package com.apress.dto.error;
public class ValidationError {
private String code;
private String message;
// Getters and Setters removed for brevity
}
public class ErrorDetail {
private String title;
private int status;
private String detail;
private long timeStamp;
private String path;
private String developerMessage;
private Map<String, List<ValidationError>> errors = new HashMap<String, List<ValidationError>>();
// Getters and setters removed for brevity
}
Listing 5-10ValidationError and Updated ErrorDetail Classes
下一步是通过添加一个拦截和处理MethodArgumentNotValidException异常的方法来修改RestExceptionHandler。清单 5-11 显示了RestExceptionHandler中的handleValidationError方法实现。我们通过创建一个ErrorDetail的实例并填充它来开始方法实现。然后,我们使用传入的异常参数来获取所有字段错误,并遍历列表。我们为每个字段错误创建了一个ValidationError实例,并用代码和消息信息填充它。
@ControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationError(MethodArgumentNotValidException manve, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
// Populate errorDetail instance
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.BAD_REQUEST.value());
String requestPath = (String) request.getAttribute("javax.servlet.error.request_uri");
if(requestPath == null) {
requestPath = request.getRequestURI();
}
errorDetail.setTitle("Validation Failed");
errorDetail.setDetail("Input validation failed");
errorDetail.setDeveloperMessage(manve.getClass().getName());
// Create ValidationError instances
List<FieldError> fieldErrors = manve.getBindingResult().getFieldErrors();
for(FieldError fe : fieldErrors) {
List<ValidationError> validationErrorList = errorDetail.getErrors().get(fe.getField());
if(validationErrorList == null) {
validationErrorList = new ArrayList<ValidationError>();
errorDetail.getErrors().put(fe.getField(), validationErrorList);
}
ValidationError validationError = new ValidationError();
validationError.setCode(fe.getCode());
validationError.setMessage(fe.getDefaultMessage());
validationErrorList.add(validationError);
}
return new ResponseEntity<>(errorDetail, null, HttpStatus. BAD_REQUEST);
}
/** handleResourceNotFoundException method removed **/
}
Listing 5-11handleValidationError Implementation
实施完成后,重新启动 QuickPoll 应用并提交一个缺少问题的投票。这将产生一个带有我们自定义错误响应的状态代码 400,如图 5-9 所示。
图 5-9
验证错误响应
外部化错误消息
我们已经在输入验证方面取得了相当大的进展,并为客户提供了描述性的错误消息,可以帮助他们进行故障排除并从这些错误中恢复过来。然而,实际的验证错误消息可能不是非常具有描述性,API 开发人员可能想要更改它。如果他们能够从外部属性文件中提取该消息,那就更好了。属性文件方法不仅简化了 Java 代码,还使交换消息变得容易,而无需修改代码。它还为未来的国际化/本地化需求奠定了基础。为此,在src\main\resources文件夹下创建一个 messages.properties 文件,并添加以下两条消息:
NotEmpty.poll.question=Question is a required field
Size.poll.options=Options must be greater than {2} and less than {1}
如你所见,我们对消息的每个键都遵循了惯例<<Constraint_Name>>.model_name.field_Name。model_name表示用户提交的数据绑定到的 Spring MVC 模型对象的名称。该名称通常使用@ModelAttribute注释来提供。在缺少此注释的情况下,模型名称是使用参数的非限定类名派生的。PollController的createPoll方法将一个com.apress.domain.Poll实例作为其模型对象。因此,在这种情况下,模型名称将被派生为 poll 。如果控制器将com.apress.domain.SomeObject的一个实例作为其参数,那么派生的模型名称将是 someObject 。重要的是要记住,Spring 不会使用方法参数的名称作为模型名称。
下一步是从文件中读取属性,并在创建ValidationError实例时使用它们。我们通过在RestExceptionHandler类中注入一个MessageSource的实例来实现。Spring 的MessageSource提供了一个容易解析消息的抽象。清单 5-12 显示了handleValidationError修改后的源代码。注意,我们使用MessageResource's getMessage方法来检索消息。
@ControllerAdvice
public class RestExceptionHandler {
@Inject
private MessageSource messageSource;
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public @ResponseBody ErrorDetail handleValidationError(MethodArgumentNotValidException manve, HttpServletRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
// Populate errorDetail instance
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(HttpStatus.BAD_REQUEST.value());
String requestPath = (String) request.getAttribute("javax.servlet.error.request_uri");
if(requestPath == null) {
requestPath = request.getRequestURI();
}
errorDetail.setTitle("Validation Failed");
errorDetail.setDetail("Input validation failed");
errorDetail.setDeveloperMessage(manve.getClass().getName());
// Create ValidationError instances
List<FieldError> fieldErrors = manve.getBindingResult().getFieldErrors();
for(FieldError fe : fieldErrors) {
List<ValidationError> validationErrorList = errorDetail.getErrors().get(fe.getField());
if(validationErrorList == null) {
validationErrorList = new ArrayList<ValidationError>();
errorDetail.getErrors().put(fe.getField(), validationErrorList);
}
ValidationError validationError = new ValidationError();
validationError.setCode(fe.getCode());
validationError.setMessage(messageSource.getMessage(fe, null));
validationErrorList.add(validationError);
}
return errorDetail;
}
}
Listing 5-12Reading Messages from Properties File
重新启动 QuickPoll 应用并提交一个缺少问题的投票将导致新的验证错误消息,如图 5-10 所示。
图 5-10
新验证错误消息
改进 RestExceptionHandler
默认情况下,Spring MVC 通过抛出一组标准异常来处理错误场景,比如无法读取格式错误的请求或者找不到所需的请求参数。然而,Spring MVC 不会将这些标准的异常细节写到响应体中。为了保持 QuickPoll 客户端的一致性,Spring MVC 标准异常也以相同的方式处理,并且我们返回相同的错误响应格式,这一点很重要。一种简单的方法是在 RestExceptionHandler 中为每个异常创建一个处理程序方法。更简单的方法是让RestExceptionHandler类扩展 Spring 的ResponseEntityExceptionHandler。ResponseEntityExceptionHandler类包含一组受保护的方法,这些方法处理标准异常并返回包含错误细节的ResponseEntity实例。
扩展ResponseEntityExceptionHandler类允许我们覆盖与异常相关联的受保护方法,并返回一个ErrorDetail实例。清单 5-13 显示了一个修改过的RestExceptionHandler,它覆盖了handleHttpMessageNotReadable方法。方法实现遵循我们之前使用的相同模式——创建并填充一个ErrorDetail的实例。因为ResponseEntityExceptionHandler已经附带了一个MethodArgumentNotValidException的处理程序方法,我们已经将handleValidationError方法代码移动到一个被覆盖的handleMethodArgumentNotValid方法中。
@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex, HttpHeaders headers,
HttpStatus status, WebRequest request) {
ErrorDetail errorDetail = new ErrorDetail();
errorDetail.setTimeStamp(new Date().getTime());
errorDetail.setStatus(status.value());
errorDetail.setTitle("Message Not Readable");
errorDetail.setDetail(ex.getMessage());
errorDetail.setDeveloperMessage(ex.getClass().getName());
return handleExceptionInternal(ex, errorDetail, headers, status, request);
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNot
ValidException manve, HttpHeaders headers, HttpStatus status, WebRequest request) {
// implementation removed for brevity
return handleExceptionInternal(manve, errorDetail, headers, status, request);
}
}
Listing 5-13RestExceptionHandler Handling Malformed Messages
让我们通过使用 Postman 提交一个不可读的消息(比如从 JSON 请求体中删除一个“,”来快速验证我们的实现。您应该会看到如图 5-11 所示的响应。
图 5-11
不可读消息错误
摘要
在这一章中,我们为基于 Spring MVC 的 REST 应用设计并实现了一个错误响应格式。我们还研究了验证用户输入和返回对 API 消费者有意义的错误消息。在下一章,我们将看看使用 Swagger 框架记录 REST 服务的策略。
六、日志记录 REST 服务
在本章中,我们将讨论以下内容:
-
招摇的基础
-
将 Swagger 用于 API 文档
-
定制 Swagger
文档是任何项目的一个重要方面。对于企业和开源项目来说尤其如此,在这些项目中,许多人协作来构建项目。在这一章中,我们将看看 Swagger,一个简化 REST API 文档的工具。
记录 REST API 以供消费者使用和交互是一项困难的任务,因为没有真正建立的标准。组织在历史上依赖手动编辑的文档向客户公开 REST 合同。对于基于 SOAP 的 web 服务,WSDL 充当客户端的契约,并提供操作和相关请求/响应有效负载的详细描述。WADL,即 Web 应用描述语言,规范试图填补 REST web 服务世界中的这一空白,但它并没有被广泛采用。近年来,描述 REST 服务的元数据标准的数量有所增长,比如 Swagger、Apiary 和 iODocs。它们中的大多数都是出于记录 API 的需要,从而扩大了 API 的应用范围。
时髦的
Swagger ( http://swagger.io )是创建交互式 REST API 文档的规范和框架。它使文档与 REST 服务的任何更改保持同步。它还提供了一组用于生成 API 客户端代码的工具和 SDK 生成器。Swagger 最初是由 Wordnik 在 2010 年初开发的,目前由 SmartBear 软件提供支持。
Swagger 是一个与语言无关的规范,其实现可用于多种语言,如 Java、Scala 和 PHP。在 https://github.com/springfox/springfox 可以找到规格的完整描述。该规范由两种文件类型组成——一个资源列表文件和一组描述 REST API 和可用操作的 API 声明文件。
名为“api-docs”的资源列表文件是描述 api 的根文档。它包含关于 API 的一般信息,例如 API 版本、标题、描述和许可证。顾名思义,资源列表文件还包含应用中所有可用的 API 资源。清单 6-1 显示了一个假设的 REST API 的示例资源清单文件。注意,Swagger 使用 JSON 作为它的描述语言。从清单 6-1 中的APIs数组可以看到,资源清单文件声明了两个 API 资源,分别是products和orders。URIs /default/products和/default/orders允许您访问资源的 API 声明文件。Swagger 允许对其资源进行分组;默认情况下,所有资源都归入default组,因此在 URI 中是“/default”组。info对象包含与 API 相关的联系和许可信息。
{
"apiVersion": "1.0",
"swaggerVersion": "1.2"
"apis": [
{
"description": "Endpoint for Product management",
"path": "/default/products"
},
{
"description": "Endpoint for Order management",
"path": "/default/orders"
}
],
"authorizations": { },
"info" : {
"contact": "contact@test.com",
"description": "Api for an ecommerce application",
"license": "Apache 2.0",
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.html",
"termsOfServiceUrl": "Api terms of service",
"title": "ECommerce App"
}
}
Listing 6-1Sample Resource File
API 声明文件描述了资源以及 API 操作和请求/响应表示。清单 6-2 显示了用于product资源的示例 API 声明文件,将在 URI /default/products上提供。basePath字段提供了服务于 API 的根 URI。resourcePath指定了相对于basePath的资源路径。在这种情况下,我们指定产品的 REST API 可以在http://server:port/products访问。APIs字段包含描述 API 操作的 API 对象。清单 6-2 描述了一个名为createProduct的 API 操作及其相关的 HTTP 方法、消费/产生的消息的媒体类型以及 API 响应。models字段包含任何与资源相关的模型对象。清单 6-2 显示了一个与product资源相关联的product模型对象。
{
"apiVersion": "1.0",
"swaggerVersion": "1.2"
"basePath": "/",
"resourcePath": "/products",
"apis": [
{
"description": "createProduct",
"operations": [
{
"method": "POST",
"produces": [ "application/json" ],
"consumes": [ "application/json" ],
"parameters": [ { "allowMultiple": false} ],
"responseMessages": [
{
"code": 200,
"message": null,
"responseModel": "object"
}
]
}
],
"path": "/products"
}
],
"models": {
"Product": {
"description": "",
"id": "Product",
"properties": { }
}
}
}
Listing 6-2Sample Products API Declaration File at /default/products
Note
在我们假设的例子中,Swagger 期望产品资源的 API 声明文件驻留在“/default/products”URI。这不应该与访问产品资源的实际 REST API 位置混淆。在本例中,声明文件表明在http://server:port/products URI 可以访问产品资源。
整合 Swagger
集成 Swagger 包括创建“api-docs”资源列表文件和一组描述 API 资源的 API 声明文件。有几个 Swagger 和社区拥有的项目集成了现有的源代码并自动生成这些文件,而不是手工编码这些文件。springfox-boot-starter就是这样一个框架,它简化了 Swagger 与基于 Spring MVC 的项目的集成。我们通过在pom.xml文件中添加清单 6-3 所示的springfox-boot-starter Maven 依赖项,开始 Swagger 与 QuickPoll 应用的集成。
Note
我们继续我们的传统,建立在我们在前几章中对 QuickPoll 应用所做的工作之上。您也可以使用下载源代码的Chapter6\starter文件夹中的 starter 项目。完成的解决方案可以在Chapter6\final文件夹下找到。
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
Listing 6-3Springfox-Boot-Starter Dependency
下一步,我们必须定义如清单 6-4 所示的 bean Docket。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.paths(PathSelectors.any())
.build();
}
}
Listing 6-4Define Docket Bean
有了这个最小配置,运行 QuickPoll 应用并启动 URI http://localhost:8080/v3/api-docs。您应该会看到如图 6-1 所示的资源列表文件。
图 6-1
快速轮询资源列表文件
Swagger UI
资源列表和 API 声明文件是理解 REST API 的宝贵资源。Swagger UI 是 Swagger 的一个子项目,它获取这些文件并自动生成一个愉快、直观的界面来与 API 交互。使用这个接口,技术人员和非技术人员都可以通过提交请求来测试 REST 服务,并查看这些服务如何响应。Swagger UI 是使用 HTML、CSS 和 JavaScript 构建的,没有任何其他外部依赖。它可以托管在任何服务器环境中,甚至可以从您的本地机器上运行。
springfox-boot-starter 已经包含了使用来自http://localhost:8080/v3/api-docs的 JSON 的 Swagger UI 的工作,并在可读的 UI 中解析 JSON,如图 6-2 所示。
图 6-2
QuickPoll Swagger UI
没有一些修改,我们准备推出 Swagger UI。运行快速投票应用并导航至 URL http://localhost:8080/swagger-ui.html。你应该会看到 QuickPoll Swagger UI,如图 6-2 所示。
使用 UI,您应该能够执行诸如创建投票和读取所有投票之类的操作。
定制 Swagger
在前面的章节中,您已经看到,通过最少的配置,我们能够使用 Swagger 创建交互式文档。此外,当我们对服务进行更改时,该文档会自动更新。然而,您会注意到开箱即用,标题和 API 描述并不十分直观。此外,诸如“服务条款”、“联系开发人员”等 URL 也不起作用。当您探索 UI 时,诸如 Poll 和 Vote 之类的响应类在 Swagger UI 中是不可见的,用户不得不猜测操作的返回类型。
Swagger Springfox 提供了一个名为 Docket 的便捷构建器,用于定制和配置 Swagger。Docket 提供了方便的方法和合理的缺省值,但是它本身使用 ApiInfo 类来执行实际的配置。我们通过在 QuickPoll 应用中的 com.apress 包下创建一个 SwaggerConfig 类来开始我们的 Swagger 定制。用清单 6-5 的内容填充新创建的类。
package com.apress;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.Collections;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket api() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.basePackage("com.apress.controller"))
.paths(PathSelectors.any())
.build()
.apiInfo(apiInfo());
}
private ApiInfo apiInfo() {
return new ApiInfo(
"QuickPoll REST API",
"QuickPoll Api for creating and managing polls",
"http://example.com/terms-of-service",
"Terms of service",
new Contact("Maxim Bartkov", "www.example.com", "info@example.com"),
"MIT License", "http://opensource.org/licenses/MIT", Collections.emptyList());
}
}
Listing 6-5Custom Swagger Implementation
SwaggerConfig类用@Configuration标注,表明它包含一个或多个 Spring bean 配置。因为Docket依赖于框架的SpringSwaggerConfig,我们注入一个SpringSwaggerConfig的实例供以后使用。SpringSwaggerConfig是一个 Spring 管理的 bean,它在 Spring 的组件扫描 JAR 文件期间被实例化。
configureSwagger方法包含了我们的 Swagger 配置的内容。该方法用@Bean注释,向 Spring 表明返回值是一个 Spring bean,需要在一个BeanFactory中注册。Swagger Springfox 框架拾取这个 bean 并定制 Swagger。我们通过创建一个 SwaggerSpringMvcPlugin 实例来开始方法实现。然后,使用ApiInfoBuilder,我们创建一个ApiInfo对象,包含与 QuickPoll 应用相关的标题、描述、联系人和许可信息。最后,我们将创建的apiInfo和apiVersion信息传递给Docket实例并返回它。
Note
有可能有多种方法产生Docketbean。每个Docket将产生一个单独的资源列表。这在相同的 Spring MVC 应用服务于多个 API 或相同 API 的多个版本的情况下非常有用。
添加了新的SwaggerConfig类后,运行 QuickPoll 应用并导航到http://localhost:8080/swagger-ui.html.你会看到我们的用户界面中反映的变化,如图 6-3 所示。
图 6-3
更新了快速投票界面
从图 6-3 中,您会注意到除了三个 QuickPoll REST 端点之外,还有一个 Spring Boot 的“/error”端点。因为这个端点实际上没有任何用途,所以让我们在 API 文档中隐藏它。为此,我们将使用Docket类的便捷的includePattern方法。includePattern方法允许我们指定哪些请求映射应该包含在资源列表中。清单 6-6 显示了SwaggerConfig的configureSwagger方法的更新部分。默认情况下,paths方法采用正则表达式,在我们的例子中,我们明确列出了我们想要包含的所有三个端点。
docket
.apiInfo(apiInfo)
.paths(PathSelectors.regex("/polls/*.*|/votes/*.*|/computeresult/*.*"));
Listing 6-6ConfigureSwagger Method with IncludePatterns
重新运行 QuickPoll 应用,您将看到 Spring Boot 错误控制器不再出现在文档中。
配置控制器
Swagger Core 提供了一组注释,使得定制控制器文档变得容易。在本节中,我们将定制PollController,但是同样的原则也适用于其他 REST 控制器。Chapter6\final中下载的代码具有所有控制器的完整定制。
我们首先用清单 6-7 所示的@Api注释来注释PollContoller。@Api注释将一个类标记为 Swagger 资源。Swagger 扫描用@Api注释的类,读取生成资源列表和 API 声明文件所需的元数据。在这里,我们表示与PollController相关的文档将在/polls举行。记得开箱即用,Swagger 使用类名并生成 URI poll-controller ( http://localhost:8080/swagger-ui/index.html#!/poll-controller)来托管文档。随着我们的改变,PollController Swagger 文档可以在http://localhost:8080/swagger-ui.html#!/polls获得。使用@Api注释,我们还提供了与我们的 Poll API 相关的描述。
import io.swagger.annotations.Api;
@RestController
@Api(value = "polls", description = "Poll API")
public class PollController {
// Implementation removed for brevity
}
Listing 6-7@Api Annotation in Action
运行 QuickPoll 应用,在http://localhost:8080/swagger-ui/index.html导航到 Swagger UI 时,您会注意到更新后的 URI 路径和描述,如图 6-4 所示。
图 6-4
更新的轮询端点
现在我们将继续使用@ApiOperation注释定制 API 操作。这个注释允许我们定制操作信息,比如名称、描述和响应。清单 6-8 显示了应用于createPoll、getPoll和getAllPolls方法的@ApiOperation。我们使用value属性来提供操作的简要描述。Swagger 建议将该字段限制为 120 个字符。“注释”字段可用于提供有关操作的更多描述性信息。
import io.swagger.annotations.ApiOperation;
@ApiOperation(value = "Creates a new Poll", notes="The newly created poll Id will be sent in the location response header", response = Void.class)
@PostMapping("/polls")
public ResponseEntity<Void> createPoll(@Valid @RequestBody Poll poll) {
.......
}
@ApiOperation(value = "Retrieves a Poll associated with the pollId", response=Poll.class)
@GetMapping("/polls/{pollId}")
public ResponseEntity<?> getPoll(@PathVariable Long pollId) {
.........
}
@ApiOperation(value = "Retrieves all the polls", response=Poll.class, responseContainer="List")
@GetMapping("/polls")
public ResponseEntity<Iterable<Poll>> getAllPolls() {
..........
}
Listing 6-8@ApiOperation Annotated Methods
成功完成的createPoll方法向客户端发送一个空的主体和一个状态代码 201。然而,因为我们返回的是一个ResponseEntity,Swagger 无法计算出正确的响应模型。我们使用ApiOperation的response属性并将其设置为Void.class来解决这个问题。我们还将方法返回类型从ResponseEntity<?>更改为ResponseEntity<Void>,以使我们的意图更加清晰。
getPoll方法返回与传入的pollId参数相关联的轮询。因此,我们将ApiOperation的response属性设置为Poll.class。因为getAllPolls方法返回了Poll实例的集合,所以我们使用了responseContainer属性并将其值设置为List。
添加这些注释后,重新运行并启动 QuickPoll 应用的 Swagger UI,以验证描述、响应模型和注释部分是否已更改。例如,单击“Poll API”旁边的“polls”链接来展开 PollController 的操作。然后单击 GET 旁边的“/polls/{pollId}”链接,查看与getPoll方法相关联的响应模型。图 6-5 显示了更新后的响应模型。
图 6-5
GetPoll 方法的更新模型
我们之前使用的@ApiOperation允许我们指定操作的默认返回类型。正如我们在整本书中看到的,定义良好的 API 使用额外的状态代码,Swagger 提供了@ApiResponse注释来配置代码和相关的响应体。清单 6-9 显示了用@ApiResponse标注的状态代码 201 和 500 的 createPoll 方法。Swagger 要求我们将所有的@ApiResponse注释放在一个包装器@ApiResponse注释中。对于状态代码 201,我们添加了说明,指出如何检索新创建的投票 ID。通过状态代码 500,我们已经表明响应主体将包含一个ErrorDetail实例。
import com.wordnik.swagger.annotations.ApiResponse;
import com.wordnik.swagger.annotations.ApiResponses;
@ApiOperation(value = "Creates a new Poll", notes="The newly created poll Id will be sent in the location response header", response = Void.class)
@ApiResponses(value = {@ApiResponse(code=201, message="Poll Created Successfully", response=Void.class),
@ApiResponse(code=500, message="Error creating Poll", response=ErrorDetail.class) } )
@PostMapping("/polls")
public ResponseEntity<Void> createPoll(@Valid @RequestBody Poll poll) {
// Content removed for brevity
}
Listing 6-9@ApiResponse Annotations
运行 QuickPoll 应用并导航到 Swagger UI。单击“Poll API”旁边的“polls”链接,展开 PollController 的操作。然后单击 POST 旁边的“/polls”链接,查看更新的 notes 和ErrorDetail模型模式。图 6-6 显示了预期的输出。
图 6-6
修改的响应消息
快速浏览图 6-6 可以看到,我们得到的响应比配置的消息要多。这是因为 Swagger out of the box 为每个 HTTP 方法添加了一组默认响应消息。可以使用清单 6-10 所示的Docket类中的useDefaultResponseMessages方法来禁用这种行为。
public class SwaggerConfig {
@Bean
public Docket configureSwagger() {
// Content removed
docket.useDefaultResponseMessages(false);
return docket;
}
}
Listing 6-10Ignore Default Response Messages
运行 QuickPoll 应用并重复这些步骤,查看与“/polls”URI 上的 POST 操作相关的响应消息。如图 6-7 所示,不再显示默认响应消息。
图 6-7
更新的响应消息
除了我们看到的配置选项之外,Swagger 还提供了以下注释来配置模型对象:
-
@ApiModel-允许改变模型名称或提供相关模型描述的注释 -
@ApiModelProperty—可用于提供属性描述和允许值列表并指示是否需要的注释
摘要
文档在理解和使用 REST API 的过程中扮演着重要的角色。在这一章中,我们回顾了 Swagger 的基础知识,并将其与 QuickPoll 应用集成,以生成交互式文档。我们还研究了如何定制 Swagger 来满足我们特定应用的需求。
在下一章中,我们将着眼于 REST API 的版本控制技术以及分页和排序功能的实现。
七、版本控制、分页和排序
在本章中,我们将讨论以下内容:
-
REST 服务的版本控制策略
-
添加分页功能
-
添加排序功能
我们都熟悉这句著名的谚语“生活中唯一不变的是变化。”这适用于软件开发。在这一章中,我们将把 API 版本化作为处理这种变化的一种方式。此外,处理大型数据集可能会有问题,尤其是在涉及移动客户端时。大型数据集还会导致服务器过载和性能问题。为了处理这个问题,我们将使用分页和排序技术,并以可管理的块发送数据。
版本控制
随着用户需求和技术的变化,无论我们的设计是如何计划的,我们最终都会改变我们的代码。这将涉及通过添加、更新、有时删除属性来对 REST 资源进行更改。虽然 API 的关键——读取、创建、更新和删除一个或多个资源——保持不变,但这可能会导致表示发生如此剧烈的变化,以至于可能会破坏任何现有的消费者。类似地,对功能的更改,如保护我们的服务和要求身份验证或授权,会破坏现有的消费者。这样的重大变化通常需要新版本的 API。
在本章中,我们将在 QuickPoll API 中添加分页和排序功能。正如您将在后面的小节中看到的,这种变化将导致为一些 GET HTTP 方法返回的表示发生变化。在我们对 QuickPoll API 进行版本化以处理分页和排序之前,让我们来看看一些版本化的方法。
版本控制方法
对 REST API 进行版本控制有四种流行的方法:
-
URI 版本控制
-
URI 参数版本化
-
接受标题版本控制
-
自定义标题版本
这些方法都不是灵丹妙药,每一种都有其优点和缺点。在这一节中,我们将研究这些方法以及一些使用它们的真实世界的公共 API。
URI 版本控制
在这种方法中,版本信息成为 URI 的一部分。例如,http://api.example.org/v1/users和http://api.example.org/v2/users代表一个应用 API 的两个不同版本。这里我们使用v符号来表示版本,跟在v后面的数字1和2表示第一个和第二个 API 版本。
URI 版本控制是最常用的方法之一,被主要的公共 API 使用,如 Twitter、LinkedIn、Yahoo 和 SalesForce。以下是一些例子:
-
SalesForce:
http://na1.salesforce.com/services/data/v26.0 -
Twilio:
https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls
如你所见,LinkedIn、Yahoo 和 SalesForce 使用了v符号。除了主要版本,SalesForce 还使用次要版本作为其 URI 版本的一部分。相比之下,Twilio 采用了一种独特的方法,在 URI 中使用时间戳来区分其版本。
将版本作为 URI 的一部分非常有吸引力,因为版本信息就在 URI。它还简化了 API 开发和测试。人们可以通过 web 浏览器轻松浏览和使用不同版本的 REST 服务。相反,这可能会使客户的生活变得困难。例如,考虑一个客户机在其数据库中存储对用户资源的引用。在切换到新版本时,这些引用会过时,客户必须进行大量数据库更新,以将引用升级到新版本。
URI 参数版本化
这类似于我们刚刚看到的 URI 版本控制,只是版本信息被指定为 URI 请求参数。例如,URI http://api.example.org/users?v=2使用版本参数v来表示 API 的第二个版本。version 参数通常是可选的,API 的默认版本将继续处理没有 version 参数的请求。通常,默认版本是 API 的最新版本。
尽管不如其他版本化策略流行,但是一些主要的公共 API(如 Netf lix)已经使用了这种策略。URI 参数版本化与 URI 版本化具有相同的缺点。另一个缺点是,一些代理不缓存带有 URI 参数的资源,导致额外的网络流量。
接受标题版本控制
这种版本控制方法使用Accept头来传递版本信息。因为标头包含版本信息,所以对于 API 的多个版本,将只有一个 URI。
到目前为止,我们已经使用了标准的媒体类型,如"application/json"作为Accept头的一部分,来表示客户端期望的内容类型。为了传递额外的版本信息,我们需要一个自定义的媒体类型。创建自定媒体类型时,以下约定很受欢迎:
vnd.product_name.version+ suffix
vnd是自定义媒体类型的起点,表示供应商。产品或生产商名称是产品的名称,用于区分这种媒体类型和其他自定义产品媒体类型。版本部分用字符串表示,如v1或v2或v3。最后,后缀用于指定媒体类型的结构。例如,+json后缀表示遵循为媒体类型"application/json."建立的准则的结构,RFC 6389 ( https://tools.ietf.org/html/rfc6839 )给出了标准化前缀的完整列表,例如+xml、+json和+zip。例如,使用这种方法,客户端可以发送一个application/vnd.quickpoll.v2+json accept 头来请求 API 的第二个版本。
Accept头文件版本控制方法变得越来越流行,因为它允许在不影响整个 API 的情况下对单个资源进行细粒度的版本控制。这种方法会使浏览器测试变得更加困难,因为我们必须精心制作Accept头。GitHub 是一个流行的公共 API,它使用了这种Accept头策略。对于不包含任何Accept头的请求,GitHub 使用最新版本的 API 来满足请求。
自定义标题版本
定制头版本化方法类似于Accept头版本化方法,除了使用定制头而不是Accept头。微软 Azure 采用了这种方法,并使用了自定义标题x-ms-version。例如,为了在撰写本书时获得 Azure 的最新版本,您的请求需要包括一个自定义标题:
x-ms-version: 2021-09-14
这种方法与Accept头文件方法有相同的优点和缺点。因为 HTTP 规范提供了一种通过Accept头实现这一点的标准方法,所以定制头的方法还没有被广泛采用。
弃用 API
当您发布一个 API 的新版本时,维护旧版本变得很麻烦,并可能导致维护噩梦。要维护的版本数量及其寿命取决于 API 用户群,但是强烈建议至少维护一个旧版本。
不再维护的 API 版本需要被弃用并最终退役。重要的是要记住,弃用是为了表明 API 仍然可用,但将来将不复存在。API 用户应该得到大量关于弃用的通知,这样他们就可以迁移到新版本。
快速轮询版本控制
在本书中,我们将使用 URI 版本化方法来对 QuickPoll REST API 进行版本化。
实现和维护不同版本的 API 可能很困难,因为它通常会使代码变得复杂。我们希望确保一个版本的代码中的更改不会影响其他版本的代码。为了提高可维护性,我们希望确保尽可能避免代码重复。以下是组织代码以支持多个 API 版本的两种方法:
-
完整的代码复制——在这种方法中,您复制整个代码库,并为每个版本维护并行的代码路径。流行的 API builder Apigility 采用这种方法,并为每个新版本克隆整个代码库。这种方法使得不影响其他版本的代码更改变得容易。它还可以轻松切换后端数据存储。这也将允许每个版本成为一个独立的可部署的工件。尽管这种方法提供了很大的灵活性,但是我们将复制整个代码库。
-
特定于版本的代码复制——在这种方法中,我们只复制特定于每个版本的代码。每个版本都可以有自己的一组控制器和请求/响应 DTO 对象,但会重用大多数公共服务和后端层。对于较小的应用,这种方法可以很好地工作,因为特定于版本的代码可以简单地分成不同的包。对重用代码进行更改时必须小心,因为它可能会影响多个版本。
Spring MVC 使得使用 URI 版本管理方法来管理 QuickPoll 应用变得很容易。考虑到版本控制在管理变更中起着至关重要的作用,我们在开发周期中尽早进行版本控制是非常重要的。因此,我们将为目前为止开发的所有 QuickPoll 服务分配一个版本(v1))。为了支持多个版本,我们将遵循第二种方法,创建一组单独的控制器。
Note
在本章中,我们将继续建立在前几章中对 QuickPoll 应用所做的工作。或者,您可以使用下载源代码的Chapter7\starter文件夹中的一个 starter 项目。完成的解决方案可以在Chapter7\final文件夹下找到。有关包含 getter/setter 和附加导入的完整列表,请参考此解决方案。下载的Chapter7文件夹还包含一个导出的 Postman 集合,其中包含与本章相关的 REST API 请求。
我们通过创建两个包com.apress.v1.controller和com.apress.v2.controller来开始版本化过程。将所有控制器从com.apress.controller包装中移到com.apress.v1.controller。对于新的v1包中的每个控制器,添加一个类级别的@RequestMapping ("/v1")注释。因为我们将有多个版本的控制器,所以我们需要给每个控制器指定唯一的组件名。我们将遵循将版本号附加到非限定类名的惯例来派生我们的组件名。使用这个约定,v1 PollController将有一个组件名pollControllerV1。
清单 7-1 显示了经过这些修改的PollController类的部分。注意,组件名是作为一个值提供给@RestController注释的。类似地,将组件名 voteControllerV1 分配给v1 VoteController,将computeResultControllerV1分配给v1 ComputeResultController。
package com.apress.v1.controller;
import org.springframework.web.bind.annotation.RequestMapping;
@RestController("pollControllerV1")
@RequestMapping("/v1")
@Api(value = "polls", description = "Poll API")
public class PollController {
}
Listing 7-1Version 1 of the Poll Controller
Note
尽管VoteController和ComputeResultControler的行为和代码在不同版本中不会改变,但我们复制代码是为了保持简单。在真实的场景中,将代码重构为可重用的模块,或者使用继承来避免代码重复。
有了类级别的@RequestMapping注释,v1 PollController中的所有 URIs 都变成了相对于"/v1/."的,重启 QuickPoll 应用,并使用 Postman 验证您可以在新的http://localhost:8080/v1/polls端点创建一个新的 Poll。
为了创建 API 的第二个版本,将所有控制器从v1包复制到v2包。将类级别的RequestMapping值从"/v1/"更改为"/v2/",组件名称后缀从"V1"更改为"V2."。清单 7-2 显示了PollController的V2版本的修改部分。因为v2 PollController是v1 PollController的副本,我们从清单 7-2 中省略了PollController类的实现。
@RestController("pollControllerV2")
@RequestMapping("/v2")
@Api(value = "polls", description = "Poll API")
public class PollController {
// Code copied from the v1 Poll Controller
}
Listing 7-2Version 2 of the Poll Controller
一旦您完成了对三个控制器的修改,重新启动 QuickPoll 应用,并使用 Postman 验证您可以使用http://localhost:8080/v2/polls端点创建一个新的轮询。类似地,通过访问http://localhost:8080/v2/votes和http://localhost:8080/v2/computeresult端点,验证您可以访问VoteController和ComputeResultController端点。
SwaggerConfig
我们所做的版本更改需要更改我们的 Swagger 配置,以便我们可以使用 UI 来测试两个 REST API 版本并与之交互。清单 7-3 展示了重构后的com.apress.SwaggerConfig类。正如上一章所讨论的,一个springfox.documentation.spring.web.plugins.Docket实例代表一个 Swagger 组。因此,重构后的SwaggerConfig类包含两个方法,每个方法返回一个代表 API 组的Docket实例。另外,请注意,我们已经将 API 信息提取到它自己的方法中,并使用它来配置Docket的两个实例。
package com.apress;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.Collections;
@Configuration
public class SwaggerConfiguration {
@Bean
public Docket apiV1() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.regex("/v1/*.*"))
.build()
.apiInfo(apiInfo("v1"))
.groupName("v1")
.useDefaultResponseMessages(false);
}
@Bean
public Docket apiV2() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.regex("/v2/*.*"))
.build()
.apiInfo(apiInfo("v2"))
.groupName("v2")
.useDefaultResponseMessages(false);
}
private ApiInfo apiInfo(String version) {
return new ApiInfo(
"QuickPoll REST API",
"QuickPoll Api for creating and managing polls",
version,
"Terms of service",
new Contact("Maxim Bartkov", "www.linkedin.com/in/bartkov-maxim", "maxgalayoutop@gmail.com"),
"MIT License", "http://opensource.org/licenses/MIT", Collections.emptyList());
}
}
Listing 7-3Refactored SwaggerConfig Class
使用这个新重构的SwaggerConfig,重新启动 QuickPoll 应用,并在http://localhost:8080/swagger-ui/index.html在 web 浏览器中启动 Swagger UI。UI 启动后,在 Swagger UI 的输入框中添加请求参数?group=v2 to the http://localhost:8080/v2/api-docs URI,并点击 Explore。你应该看到如图 7-1 所示的v2版本的 API 并与之交互。
图 7-1
QuickPoll 2.0 版本的 Swagger UI
这就结束了版本化 QuickPoll 应用所需的配置,并为在本章的最后两节中添加分页和排序支持做好了准备。
页码
REST APIs 被各种各样的客户端使用,从桌面应用到 Web,再到移动设备。因此,在设计能够返回大量数据集的 REST API 时,出于带宽和性能原因,限制返回的数据量是很重要的。在移动客户端使用 API 的情况下,带宽问题变得更加重要。限制数据可以极大地提高服务器从数据存储中更快地检索数据的能力,以及客户机处理数据和呈现 UI 的能力。通过将数据分割成离散的页面或分页数据,REST 服务允许客户端以可管理的块滚动和访问整个数据集。
在我们开始在 QuickPoll 应用中实现分页之前,让我们先来看看四种不同的分页样式:页码分页、限制偏移量分页、基于光标的分页和基于时间的分页。
页码分页
在这种分页方式中,客户端指定包含所需数据的页码。例如,一个客户机想要我们假设的博客服务的第 3 页中的所有博客文章,可以使用下面的 GET 方法:
http://blog.example.com/posts?page=3
这个场景中的 REST 服务将通过一组 posts 进行响应。返回的帖子数量取决于服务中设置的默认页面大小。客户端可以通过传入页面大小参数来覆盖默认页面大小:
http://blog.example.com/posts?page=3&size=20
GitHub 的 REST 服务使用这种分页风格。默认情况下,页面大小设置为 30,但可以使用per_page参数覆盖:
https://api.github.com/user/repos?page=2&per_page=100
限制偏移量分页
在这种分页风格中,客户端使用两个参数:limit 和 offset 来检索它们需要的数据。limit 参数指示要返回的最大元素数,offset 参数指示返回数据的起始点。例如,要检索从项目编号 31 开始的 10 篇博客文章,客户端可以使用以下请求:
http://blog.example.com/posts?limit=10&offset=30
基于光标的分页
在这种分页风格中,客户机利用一个指针或一个光标来浏览数据集。游标是服务生成的随机字符串,充当数据集中某项的标记。为了理解这种风格,考虑一个客户机发出以下请求来获取博客帖子:
http://blog.example.com/posts
收到请求后,服务将发送类似以下内容的数据:
{
"data" : [
... Blog data
],
"cursors" : {
"prev" : null,
"next" : "123asdf456iamcur"
}
}
该响应包含一组博客,代表整个数据集的一个子集。作为响应一部分的cursors包含一个prev字段,可用于检索之前的数据子集。然而,因为这是初始子集,所以prev字段值为空。客户端可以使用next字段中的光标值,通过以下请求获得下一个数据子集:
http://api.example.com/posts?cursor=123asdf456iamcur
在收到这个请求时,服务将发送数据以及prev和next光标字段。Twitter 和脸书等应用使用这种分页风格来处理数据频繁变化的实时数据集(tweets 和 posts)。生成的游标通常不会永久存在,应该仅用于短期分页目的。
基于时间的分页
在这种分页风格中,客户端指定一个时间范围来检索他们感兴趣的数据。脸书支持这种分页风格,并要求将时间指定为 Unix 时间戳。这是脸书的两个请求示例:
https://graph.facebook.com/me/feed?limit=25&until=1364587774
https://graph.facebook.com/me/feed?limit=25&since=1364849754
两个示例都使用 limit 参数来指示要返回的最大项数。until参数指定时间范围的结束,而since参数指定时间范围的开始。
分页数据
前面几节中的所有分页样式都只返回数据的子集。因此,除了提供请求的数据之外,服务传递特定于分页的信息也变得很重要,比如记录总数或总页数或当前页码和页面大小。以下示例显示了带有分页信息的响应正文:
{
"data": [
... Blog Data
],
"totalPages": 9,
"currentPageNumber": 2,
"pageSize": 10,
"totalRecords": 90
}
客户端可以使用分页信息来评估当前状态,并构造 URL 来获取下一个或上一个数据集。服务采用的另一项技术是在一个特殊的Link头中包含分页信息。Link报头被定义为 RFC 5988 ( http://tools.ietf.org/html/rfc5988 )的一部分。它通常包含一组现成的向前和向后滚动的链接。GitHub 使用这种方法;下面是一个Link头值的例子:
Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next", <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"
快速投票分页
为了在 QuickPoll 应用中支持大型投票数据集,我们将实现页码分页样式,并将在响应正文中包含分页信息。
我们从配置 QuickPoll 应用开始实现,在引导过程中将虚拟轮询数据加载到数据库中。这将使我们能够测试我们的轮询和排序代码。为此,将下载的章节代码中的import.sql文件复制到src\main\resources文件夹中。import.sql文件包含用于创建测试投票的 DML 语句。Hibernate 开箱即用加载类路径下的import.sql文件,并执行其中的所有 SQL 语句。重启 QuickPoll 应用,在 Postman 中导航到http://localhost:8080/v2/polls;它应该列出所有加载的测试轮询。
Spring Data JPA 和 Spring MVC 提供了对页码分页样式的现成支持,使得我们的 QuickPoll 分页实现变得容易。Spring Data JPA 中分页(和排序)功能的核心是清单 7-4 中所示的org.springframework.data.repository.PagingAndSortingRepository接口。
public interface PagingAndSortingRepository<T, ID extends Serializable> extends CrudRepository<T, ID> {
Page<T> findAll(Pageable pageable);
Iterable<T> findAll(Sort sort);
}
Listing 7-4Spring Data JPA’s Paging and Sorting Repository
PagingAndSortingRepository接口扩展了我们目前在 QuickPoll 应用中使用的CrudRepository接口。此外,它还添加了两个 finder 方法,返回与所提供的分页和排序标准相匹配的实体。负责分页的findAll方法使用一个Pageable实例来读取页面大小和页码等信息。此外,它还需要排序信息,这一点我们将在本章的后面部分详细介绍。这个findAll方法返回一个包含数据子集和以下信息的Page实例:
-
元素总数-结果集中的元素总数
-
元素数量—返回子集中的元素数量
-
大小—每页中元素的最大数量
-
总页数-结果集中的总页数
-
number—返回当前页码
-
last—指示它是否是最后一个数据子集的标志
-
first—指示它是否是第一个数据子集的标志
-
排序—返回用于排序的参数(如果有)
在 QuickPoll 中实现分页的下一步是让我们的PollRepository扩展PagingAndSortingRepository而不是当前的CrudRepository。清单 7-5 展示了新的PollRepository实现。因为PagingAndSortingRepository扩展了CrudRepository,我们 API 的第一个版本所需的所有功能都保持不变。
package com.apress.repository;
import org.springframework.data.repository.PagingAndSortingRepository;
import com.apress.domain.Poll;
public interface PollRepository extends PagingAndSortingRepository<Poll, Long> {
}
Listing 7-5PollRepository Implementation
将存储库更改为使用PagingAndSortingRepository就结束了分页所需的后端实现。我们现在继续重构 V2 PollController,以便它使用新的分页查找器方法。清单 7-6 展示了 V2 com.apress.v2.controller.PollController的重构后的getAllPolls方法。注意,我们已经将Pageable参数添加到了getAllPolls方法中。在"/polls,"上收到 GET 请求时,Spring MVC 检查请求参数,构造一个Pageable实例,并将其传递给getAllPolls方法。通常,传入的实例属于类型PageRequest。然后将Pageable参数传递给新的 finder 方法,分页数据作为响应的一部分被返回。
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@RequestMapping(value="/polls", method=RequestMethod.GET)
@ApiOperation(value = "Retrieves all the polls", response=Poll.class, responseContainer="List")
public ResponseEntity<Page<Poll>> getAllPolls(Pageable pageable) {
Page<Poll> allPolls = pollRepository.findAll(pageable);
return new ResponseEntity<>(allPolls, HttpStatus.OK);
}
Listing 7-6GetAllPolls Method with Paging Functionality
快速轮询分页实现到此结束。重启 QuickPoll 应用,并使用 Postman 提交一个 GET 请求to http://localhost:8080/v2/polls?page=0&size=2。响应应该包含两个带有分页相关元数据的轮询实例。图 7-2 显示了请求以及响应的元数据部分。
图 7-2
分页结果以及分页元数据
Note
Spring Data JPA 使用基于零索引的分页方法。因此,第一页的页码从 0 开始,而不是从 1 开始。
更改默认页面大小
Spring MVC 使用一个org.springframework.data.web.PageableHandlerMethodArgumentResolver从请求参数中提取分页信息,并将Pageable实例注入控制器方法。开箱即用的,PageableHandlerMethodArgumentResolver类将默认页面大小设置为 20。因此,如果您在http://localhost:8080/v2/polls上执行 GET 请求,响应将包括 20 次轮询。虽然 20 是一个很好的默认页面大小,但有时您可能希望在应用中全局更改它。为此,您需要用您选择的设置创建并注册一个新的PageableHandlerMethodArgumentResolver实例。
需要更改默认 MVC 行为的 Spring Boot 应用需要创建类型为org.springframework.web.servlet.config.annotation.WebMvcConfigurer的类,并使用其回调方法进行定制。清单 7-7 显示了在com.apress包中新创建的QuickPollMvcConfigAdapter类,配置为将默认页面大小设置为 5。这里我们使用的是WebMvcConfigurer's addArgumentResolvers回调方法。我们通过创建一个PageableHandlerMethodArgumentResolver的实例来开始方法实现。setFallbackPageable方法,顾名思义,是 Spring MVC 在请求参数中找不到分页信息时使用的方法。我们创建一个默认页面大小为 5 的PageRequest实例,并将其传递给setFallbackPageable方法。然后,我们使用传入的argumentResolvers参数向 Spring 注册我们的PageableHandlerMethodArgumentResolver实例。
package com.apress;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class QuickPollMvcConfigAdapter implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
PageableHandlerMethodArgumentResolver phmar = new PageableHandlerMethodArgumentResolver();
// Set the default size to 5
phmar.setFallbackPageable(PageRequest.of(0, 5));
argumentResolvers.add(phmar);
}
}
Listing 7-7Code to Change Default Page Size to 5
重新启动 QuickPoll 应用,并使用 Postman 对http://localhost:8080/v2/polls执行 GET 请求。您将会注意到,现在的回复只包括五个投票。相关的分页元数据如清单 7-8 所示。
{
..... Omitted Poll Data ......
"totalPages": 4,
"totalElements": 20,
"last": false,
"size": 5,
"number": 0,
"sort": null,
"numberOfElements": 5,
"first": true
}
Listing 7-8Paging Metadata for Default Page Size 5
问我问题
排序允许 REST 客户端决定数据集中项目的排列顺序。支持排序的 REST 服务允许客户端提交带有用于排序的属性的参数。例如,客户端可以提交以下请求,根据博客帖子的创建日期和标题对其进行排序:
http://blog.example.com/posts?sort=createdDate,title
升序排序或降序排序
REST 服务还允许客户端指定两种排序方向之一:升序或降序。由于这方面没有固定的标准,以下示例展示了指定排序方向的常用方法:
http://blog.example.com/posts?sortByDesc=createdDate&sortByAsc=title
http://blog.example.com/posts?sort=createdDate,desc&sort=title,asc
http://blog.example.com/posts?sort=-createdDate,title
在所有这些例子中,我们按照博客文章创建日期的降序来检索它们。然后,创建日期相同的帖子会根据标题进行排序:
-
在第一种方法中,
sort参数清楚地指定了方向应该是上升还是下降。 -
在第二种方法中,我们对两个方向使用了相同的参数名。但是,参数值说明了排序方向。
-
最后一种方法使用“
-”符号来表示任何以“-”为前缀的属性都应该按降序排序。没有以“-为前缀的属性将按升序排序。
快速投票排序
考虑到排序通常与分页一起使用,Spring Data JPA 的PagingAndSortingRepository和Pageable实现被设计为从头开始处理和服务排序请求。因此,我们不需要任何显式的排序实现。
为了测试排序功能,使用 Postman 向http://localhost:8080/v2/polls/?sort=question提交一个 GET 请求。您应该会看到响应,其中民意调查按照问题文本的升序排序,并带有排序元数据。图 7-3 显示了邮递员请求以及分类元数据。
图 7-3
排序元数据
为了对具有不同排序方向的多个字段进行排序,Spring MVC 要求您遵循上一节中讨论的第二种方法。以下请求按升序问题值和降序id值排序:
http://localhost:8080/v2/polls/?sort=question,asc&sort=id,desc
摘要
在本章中,我们回顾了 REST API 版本控制的不同策略。然后,我们使用 URL 版本控制方法在 QuickPoll 中实现了版本控制。我们还回顾了使用分页和排序技术处理大型数据集的不同方法。最后,我们使用 Spring Data 的现成功能来实现页码分页样式。在下一章,我们将回顾确保 REST 服务的策略。
八、安全
在本章中,我们将讨论以下内容:
-
保障休息服务的策略
-
OAuth 2.0
-
Spring 安全框架的基础
-
实施快速轮询安全性
要求安全性的传统 web 应用通常使用用户名/密码进行身份验证。REST 服务带来了有趣的安全问题,因为它们可以被各种客户端使用,比如浏览器和移动设备。它们也可以被其他服务使用,这种机器对机器的通信可能没有任何人机交互。客户端代表用户消费 REST 服务也并不罕见。在本章中,我们将探索在使用 REST 服务时可以使用的不同的认证/授权方法。然后,我们将研究使用这些方法来保护我们的 QuickPoll 应用。
保障休息服务
我们首先调查六种用于保护 REST 服务的流行方法:
-
基于会话的安全性
-
HTTP 基本身份验证
-
摘要认证
-
基于证书的安全性
-
扩展验证
-
OAuth
基于会话的安全性
基于会话的安全模型依赖于服务器端会话来跨请求保持用户的身份。在典型的 web 应用中,当用户试图访问受保护的资源时,会出现一个登录页面。认证成功后,服务器将登录用户的信息存储在 HTTP 会话中。在后续请求中,将查询会话以检索用户信息,并用于执行授权检查。如果用户没有适当的授权,他们的请求将被拒绝。图 8-1 是这种方法的图示。
图 8-1
基于会话的安全流
像 Spring Security 这样的框架提供了使用这种安全模型开发应用的所有必要的管道。这种方法对于向现有 Spring Web 应用添加 REST 服务的开发人员来说非常有吸引力。REST 服务将从会话中检索用户身份,以执行授权检查并相应地提供资源。然而,这种方法违反了无状态 REST 约束。此外,因为服务器保存客户端的状态,所以这种方法是不可伸缩的。理想情况下,客户端应该保存状态,而服务器应该是无状态的。
HTTP 基本身份验证
当涉及到人工交互时,可以使用登录表单来获取用户名和密码。然而,当我们的服务与其他服务对话时,这可能是不可能的。HTTP 基本身份验证提供了一种机制,允许客户端使用交互和非交互方式发送身份验证信息。
在这种方法中,当客户端向受保护的资源发出请求时,服务器会发送一个 401“未授权”响应代码和一个“WWW-Authenticate”头。标头的“Basic”部分表示我们将使用基本身份验证,而“realm”部分表示服务器上受保护的空间:
GET /protected_resource
401 Unauthorized
WWW-Authenticate: Basic realm="Example Realm"
收到响应后,客户端用分号将用户名和密码连接起来,Base64 编码连接起来的字符串。然后,它使用标准的Authorization头将信息发送到服务器:
GET /protected_resource
Authorization: Basic bHxpY26U5lkjfdk
服务器解码提交的信息并验证提交的凭证。验证成功后,服务器完成请求。整个流程如图 8-2 所示。
图 8-2
HTTP 基本认证流程
因为客户端在每个请求中都包含了身份验证信息,所以服务器变成了无状态的。重要的是要记住,客户端只是对信息进行编码,而不是加密。因此,在非 SSL/TLS 连接上,有可能进行中间人攻击并窃取密码。
摘要认证
摘要式身份验证方法类似于前面讨论的基本身份验证模型,只是用户凭据是加密发送的。客户端提交对受保护资源的请求,服务器用 401“未授权”响应代码和 WWW-Authenticate 头进行响应。下面是一个服务器响应的示例:
GET /protected_resource
401 Unauthorized
WWW-Authenticate: Digest realm="Example Realm", nonce="P35kl89sdfghERT10Asdfnbvc", qop="auth"
注意,WWW-Authenticate指定了摘要认证模式以及服务器生成的随机数和 qop。随机数是用于加密目的的任意令牌。qop 或“保护质量”指令可以包含两个值— "auth"或"auth-int":
-
qop 值
"auth"指示摘要用于认证目的。 -
值
"auth-int"表示摘要将用于认证和请求完整性。
在接收到请求时,如果 qop 值被设置为"auth,",则客户端使用以下公式生成摘要:
hash_value_1 = MD5(username:realm:password)
has_value_2 = MD5(request_method:request_uri)
digest = MD5(hash_value_1:nonce:hash_value_2)
如果 qop 值设置为"auth-int,",客户端通过包含请求体来计算摘要:
hash_value_1 = MD5(username:realm:password)
has_value_2 = MD5(request_method:request_uri:MD5(request_body))
digest = MD5(hash_value_1:nonce:hash_value_2)
默认情况下,MD5 算法用于计算哈希值。摘要包含在Authorization报头中,并发送给服务器。收到请求后,服务器计算摘要并验证用户的身份。验证成功后,服务器完成请求。该方法的完整流程如图 8-3 所示。
图 8-3
摘要认证流程
摘要式身份验证方法比基本身份验证更安全,因为密码绝不会以明文形式发送。但是,在非 SSL/TLS 通信中,窥探器仍然可以检索摘要并重放请求。解决这个问题的一个方法是将服务器生成的随机数限制为只能一次性使用。此外,因为服务器必须生成用于验证的摘要,所以它需要能够访问密码的纯文本版本。因此,它不能采用更安全的单向加密算法,如 bcrypt,并且更容易受到服务器端的攻击。
基于证书的安全性
基于证书的安全模型依靠证书来验证一方的身份。在基于 SSL/TLS 的通信中,客户端(如浏览器)通常使用证书来验证服务器的身份,以确保服务器是它所声称的那样。此模型可以扩展到执行相互身份验证,其中服务器可以请求客户端证书作为 SSL/TLS 握手的一部分,并验证客户端的身份。
在这种方法中,在接收到对受保护资源的请求时,服务器将其证书提供给客户端。客户端确保可信的证书颁发机构(CA)颁发了服务器的证书,并将其证书发送给服务器。服务器验证客户端的证书,如果验证成功,将授予对受保护资源的访问权限。该流程如图 8-4 所示。
图 8-4
基于证书的安全流程
基于证书的安全模型消除了发送共享机密的需要,使其比用户名/密码模型更安全。然而,证书的部署和维护可能非常昂贵,通常用于大型系统。
扩展验证
随着 REST APIs 的流行,使用这些 API 的第三方应用的数量也显著增加。这些应用需要用户名和密码来与 REST 服务交互,并代表用户执行操作。这带来了巨大的安全问题,因为第三方应用现在可以访问用户名和密码。第三方应用中的安全漏洞可能会危及用户信息。此外,如果用户更改了他的凭证,他需要记得去更新所有这些第三方应用。最后,这种机制不允许用户撤销对第三方应用的授权。在这种情况下,撤销的唯一选择是更改他的密码。
扩展验证和 OAuth 方案提供了一种代表用户访问受保护资源而无需存储密码的机制。在这种方法中,客户端应用通常使用登录表单向用户请求用户名和密码。然后,客户端将用户名和密码发送给服务器。服务器接收用户的凭证并验证它们。验证成功后,将向客户端返回一个令牌。客户端丢弃用户名和密码信息,并将令牌存储在本地。当访问用户的受保护资源时,客户端会在请求中包含令牌。这通常是使用定制的 HTTP 头(如X-Auth-Token)来完成的。令牌的寿命取决于实现的服务。令牌可以一直保留,直到服务器将其撤销,或者令牌可以在指定的时间段内过期。该流程如图 8-5 所示。
图 8-5
扩展验证安全流程
Twitter 之类的应用允许第三方应用使用扩展验证方案访问它们的 REST API。然而,即使有了扩展验证,第三方应用也需要捕获用户名和密码,这就留下了误用的可能性。考虑到 XAuth 的简单性,当同一个组织同时开发客户端和 REST API 时,它可能是一个不错的选择。
OAuth 2.0
开放授权(OAuth)是一个框架,用于代表用户访问受保护的资源,而无需存储密码。OAuth 协议于 2007 年首次推出,并被 2010 年推出的 OAuth 2.0 所取代。在本书中,我们将回顾 OAuth 2.0 和一般原则。
OAuth 2.0 定义了以下四个角色:
-
资源所有者—资源所有者是希望授予其部分帐户或资源访问权限的用户。例如,资源所有者可以是 Twitter 或脸书用户。
-
客户端—客户端是希望访问用户资源的应用。这可能是一个第三方应用,如 Klout (
https://klout.com/)想要访问用户的 Twitter 帐户。 -
授权服务器—授权服务器验证用户的身份,并向客户端授予令牌以访问用户的资源。
-
资源服务器—资源服务器托管受保护的用户资源。例如,这将是 Twitter API 来访问推文和时间线等等。
图 8-6 描述了这四个角色之间的相互作用。OAuth 2.0 要求这些交互在 SSL 上进行。
图 8-6
OAuth 2.0 安全流程
在客户端参与图 8-6 所示的“OAuth 舞蹈”之前,它必须向授权服务器注册。对于大多数公共 API,如脸书和 Twitter,这涉及到填写申请表和提供有关客户端的信息,如应用名称、基本域和网站。注册成功后,客户端将收到一个客户端 ID 和一个客户端密码。客户端 ID 用于唯一标识客户端,并且是公开可用的。这些客户端凭证在 OAuth 交互中起着重要的作用,我们稍后将对此进行讨论。
OAuth 交互始于用户表达对使用第三方应用“客户机”的兴趣。客户端代表用户请求访问受保护资源的授权,并将用户/资源所有者重定向到授权服务器。客户端可以将用户重定向到的 URI 示例如下所示:
https://oauth2.example.com/authorize?client_id=CLIENT_ID&response_type=auth_code&call_back=CALL_BACK_URI&scope=read,tweet
任何生产 OAuth 2.0 交互都必须使用 HTTPS,因此,URI 以 https 开始。CLIENT_ID用于向授权服务器提供客户端的身份。scope 参数提供了客户端需要的一组逗号分隔的作用域/角色。
收到请求后,授权服务器通常会通过登录表单向用户提供身份验证质询。用户提供他的用户名和密码。成功验证用户凭证后,授权服务器使用CALL_BACK_URI参数将用户重定向到客户端应用。授权服务器还将授权代码附加到CALL_BACK_URI参数值上。下面是授权服务器可能生成的 URL 示例:
https://mycoolclient.com/code_callback?auth_code=6F99A74F2D066A267D6D838F88
然后,客户端使用授权码向授权服务器请求访问令牌。为此,客户端通常会在 URI 上执行 HTTP POST,如下所示:
https://oauth2.example.com/access_token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&
auth_code=6F99A74F2D066A267D6D838F88
如您所见,客户端在请求中提供了其凭证。授权服务器验证客户端的身份和授权码。验证成功后,它返回一个访问令牌。以下是 JSON 格式的响应示例:
{"access_token"="f292c6912e7710c8"}
收到访问令牌后,客户机将向资源服务器请求受保护的资源,并传递它获得的访问令牌。资源服务器验证访问令牌并为受保护的资源提供服务。
OAuth 客户端配置文件
OAuth 2.0 的优势之一是它支持各种客户端配置文件,如“web 应用”、“本机应用”和“用户代理/浏览器应用”。前面讨论的授权代码流(通常称为授权许可类型)适用于具有基于 web 的用户界面和服务器端后端的“web 应用”客户端。这允许客户端将授权代码存储在安全的后端,并在将来的交互中重用它。其他客户端配置文件有自己的流程,这些流程决定了四个 OAuth 2.0 参与者之间的交互。
纯基于 JavaScript 的应用或原生应用不能安全地存储授权码。因此,对于这样的客户端,来自授权服务器的回调不包含授权代码。取而代之的是,隐式授权类型方法被采用,并且访问令牌被直接移交给客户端,然后用于请求受保护的资源。属于此客户端配置文件的应用没有客户端密码,只是使用客户端 ID 进行标识。
OAuth 2.0 还支持授权流,称为密码授权类型,类似于上一节讨论的扩展验证。在这个流程中,用户直接向客户端应用提供他的凭证。他永远不会被重定向到授权服务器。客户端将这些凭证传递给授权服务器,并接收用于请求受保护资源的访问令牌。
OAuth 1.0 引入了几个实现复杂性,尤其是在用客户端凭证签署请求的加密要求方面。OAuth 2.0 简化了这一点,消除了签名,并要求所有交互都使用 HTTPS。然而,由于 OAuth 2 的许多特性是可选的,该规范导致了不可互操作的实现。
刷新令牌与访问令牌
访问令牌的生命周期可能是有限的,客户端应该为令牌不再工作的可能性做好准备。为了防止资源所有者重复认证,OAuth 2.0 规范提供了刷新令牌的概念。当授权服务器生成访问令牌时,它可以选择性地发布刷新令牌。客户端存储该刷新令牌,并且当访问令牌到期时,它联系授权服务器以获得一组新的访问令牌以及刷新令牌。规范允许为授权和密码授予类型的流生成刷新令牌。考虑到“隐式授权类型”缺乏安全性,刷新令牌被禁止用于这种客户端配置文件。
Spring 安全性概述
为了在 QuickPoll 应用中实现安全性,我们将使用另一个流行的 Spring 子项目,即 Spring Security。在我们继续实现之前,让我们了解一下 Spring Security 和组成框架的不同组件。
Spring Security,以前称为 Acegi Security,是一个保护基于 Java 的应用的框架。它提供了对各种身份验证系统的现成集成,如 LDAP、Kerberos、OpenID、OAuth 等等。通过最少的配置,它可以很容易地扩展到任何定制的身份验证和授权系统。该框架还实现了安全最佳实践,并内置了一些功能来防范诸如 CSRF、跨站点请求伪造、会话修复等攻击。
Spring Security 提供了一个一致的安全模型,可以用来保护 web URLs 和 Java 方法。下面列出了 Spring 安全认证/授权过程中涉及的高级步骤以及涉及的组件:
-
这个过程从用户请求一个受 Spring 保护的 web 应用上的受保护资源开始。
-
请求通过一系列被称为“过滤器链”的 Spring 安全过滤器,这些过滤器识别服务请求的
org.springframework.security.web.AuthenticationEntryPoint。AuthenticationEntryPoint将用一个认证请求来响应客户端。例如,这可以通过向用户发送登录页面来实现。 -
在从用户接收到诸如用户名/密码的认证信息时,创建一个
org.springframework.security.core.Authentication对象。清单 8-1 中显示了Authentication接口,它的实现在 Spring 安全性中扮演着双重角色。它们代表身份验证请求的令牌或身份验证成功完成后完全通过身份验证的主体。isAuthenticated方法可以用来确定一个Authentication实例所扮演的当前角色。在用户名/密码认证的情况下,getPrincipal方法返回用户名,getCredentials返回密码。getUserDetails方法包含 IP 地址等附加信息。 -
作为下一步,认证请求令牌被呈现给
org.springframework.security.authentication.AuthenticationManager。清单 8-2 中的AuthenticationManager,包含一个 authenticate 方法,该方法接受一个认证请求令牌并返回一个完全填充的Authentication实例。Spring 提供了一个名为ProviderManager的AuthenticationManager的现成实现。
public interface Authentication extends Principal, Serializable {
Object getPrincipal();
Object getCredentials();
Object getDetails();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
Listing 8-1Authentication API
- 为了执行认证,
ProviderManager需要将提交的用户信息与后端用户存储(如 LDAP 或数据库)进行比较。ProviderManager将这一职责委托给一系列org.springframework.security.authentication.AuthenticationProvider。这些AuthenticationProvider使用一个org.springframework.security.core.userdetails.UserDetailsService从后端存储中检索用户信息。清单 8-3 展示了用户详细信息服务 API。
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
Listing 8-2AuthenticationManager API
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
Listing 8-3UserDetailsService API
JdbcDaoImpl和LdapUserDetailService等UserDetailsService的实现将使用传入的用户名来检索用户信息。这些实现还将创建一组代表用户在系统中的角色/权限的GrantedAuthority实例。
-
AuthenticationProvider将提交的凭证与后端系统中的信息进行比较,在成功验证后,org.springframework.security.core.userdetails.UserDetails对象用于构建一个完全填充的Authentication实例。 -
然后将
Authentication实例放入一个org.springframework.security.core.context.SecurityContextHolder中。顾名思义,SecurityContextHolder,只是将登录用户的上下文与当前执行的线程相关联,以便在用户请求或操作中随时可用。在基于 web 的应用中,登录用户的上下文通常存储在用户的 HTTP 会话中。 -
然后,Spring Security 使用一个
org.springframework.security.access.intercept.AbstractSecurityInterceptor及其实现org.springframework.security.web.access.intercept.FilterSecurityInterceptor和org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor来执行授权检查。FilterSecurityInterceptor用于基于 URL 的授权,MethodSecurityInterceptor用于方法调用授权。 -
AbstractSecurityInterceptor依靠安全配置和一组org.springframework.security.access.AccessDecisionManager来决定用户是否被授权。授权成功后,用户就可以访问受保护的资源。
Note
为了简单起见,我在这些步骤中有意省略了一些 Spring 安全类。关于 Spring 安全和认证/授权步骤的完整回顾,请参考Pro Spring Security(a press,2019)。
现在,您已经对 Spring Security 的认证/授权流程及其一些组件有了基本的了解,让我们看看如何将 Spring Security 集成到我们的 QuickPoll 应用中。
保护快速投票
我们将在 QuickPoll 应用中实现安全性,以满足以下两个要求:
-
注册用户可以创建和访问投票。这使我们能够跟踪帐户、使用情况等等。
-
只有具有管理员权限的用户才能删除投票
Note
在本章中,我们将继续建立在前几章中对 QuickPoll 应用所做的工作。或者,您可以在下载的源代码的Chapter8\starter文件夹中使用一个 starter 项目。在本章中,我们将使用基本认证来保护快速轮询。然后我们将为 QuickPoll 添加 OAuth 2.0 支持。因此,Chapter8\final文件夹包含两个文件夹:quick-poll-ch8-final-basic-auth和quick-poll-ch8-final。quick-poll-ch8-final-basic-auth包含在 QuickPoll 中添加了基本认证的解决方案。quick-poll-ch8-final包含添加了基本认证和 OAuth 2.0 的完整解决方案。我们知道并非所有项目都需要 OAuth 2.0 支持。因此,将最终的解决方案分成两个项目可以让您检查和使用您需要的特性/代码。请参考final文件夹下的解决方案,获取包含 getter/setter 和附加导入的完整列表。下载的Chapter8文件夹还包含一个导出的 Postman 集合,其中包含与本章相关的 REST API 请求。
通过要求用户认证,我们将彻底改变 QuickPoll 应用的行为。为了允许现有用户继续使用我们的 QuickPoll 应用,我们将创建一个新版本(v3)的 API 来实现这些更改。为此,在src\main\java下创建一个新的com.apress.v3.controller包,并从com.apress.v2.controller包中复制控制器。对于新复制的控制器,将RequestMapping从/v2/更改为/v3/,并将控制器名称前缀从v2更改为v3以反映 API 版本 3。我们通过将清单 8-4 中所示的 Spring Security starter 依赖项添加到 QuickPoll 项目的pom.xml文件中来开始实现。这将把所有与 Spring 安全相关的 JAR 文件引入到项目中。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Listing 8-4Spring Starter POM
当在类路径中看到 Spring Security 时,Spring Boot 添加了默认的安全配置,该配置使用 HTTP 基本认证来保护所有的 HTTP 端点。启动 QuickPoll 应用,并使用 Postman 向http://localhost:8080/v3/polls提交一个 GET 请求。Postman 显示一个验证窗口,提示您输入用户名和密码,如图 8-7 所示。
图 8-7
邮递员中的基本认证窗口
Spring Boot 的默认安全配置是带有用户名 user 的单个用户。Spring Boot 为用户生成一个随机密码,并在应用启动时在INFO日志级别打印出来。在您的控制台/日志文件中,您应该会看到如下所示的条目:
Using default security password: 554cc6c2-67e1-4f1e-8c5b-096609e2d0b1
将在控制台中找到的用户名和密码输入 Postmaster 登录窗口,然后点击登录。Spring Security 将验证输入的凭证,并允许完成请求。
卷曲
到目前为止,我们一直使用 Postman 来测试我们的 QuickPoll 应用。在本章中,我们将结合 Postman 使用一个名为 cURL 的命令行工具。cURL 是一个流行的开源工具,用于与服务器交互和使用 URL 语法传输数据。它安装在大多数操作系统发行版中。如果您的系统上没有 cURL,请按照 http://curl.haxx.se/download.html 中的说明下载并在您的机器上安装 cURL。有关在 Windows 机器上安装 cURL 的说明,请参考附录 A。
要使用 cURL 测试我们的 QuickPoll 基本身份验证,请在命令行运行以下命令:
curl -vu user:554cc6c2-67e1-4f1e-8c5b-096609e2d0b1 http://localhost:8080/v3/polls
在这个命令中,–v选项请求 cURL 在调试模式(详细)下运行。–u选项允许我们指定基本认证所需的用户名和密码。在 http://curl.haxx.se/docs/manual.html 可以获得卷曲选项的完整列表。
用户基础设施设置
虽然 Spring Boot 已经大大简化了 Spring 安全集成,但是我们希望定制安全行为,以便它使用应用用户而不是 Spring Boot 的通用用户。我们还想对 v3 PollController应用安全性,让其他端点匿名访问。在我们研究定制 Spring 安全性之前,让我们设置一下创建/更新 QuickPoll 应用用户所需的基础设施。
我们首先创建一个如清单 8-5 所示的User域对象来表示快速轮询用户。User类包含了username、password、firstname和lastname等属性。它还包含一个布尔标志,用于指示用户是否具有管理权限。作为安全最佳实践,我们用@JsonIgnore注释了password字段。因此,密码字段将不包含在user的表示中,从而阻止客户端访问密码值。因为“用户”是 Oracle 等数据库中的一个关键字,所以我们使用了@Table注释为与这个User实体对应的表命名为“用户”。
package com.apress.domain;
import javax.persistence.Table;
import org.hibernate.annotations.Type;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.hibernate.annotations.Type;
import javax.validation.constraints.NotEmpty;
@Entity
@Table(name="USERS")
public class User {
@Id
@GeneratedValue
@Column(name="USER_ID")
private Long id;
@Column(name="USERNAME")
@NotEmpty
private String username;
@Column(name="PASSWORD")
@NotEmpty
@JsonIgnore
private String password;
@Column(name="FIRST_NAME")
@NotEmpty
private String firstName;
@Column(name="LAST_NAME")
@NotEmpty
private String lastName;
@Column(name="ADMIN", columnDefinition="char(3)")
@Type(type="yes_no")
@NotEmpty
private boolean admin;
// Getters and Setters omitted for brevity
}
Listing 8-5User Class
我们将在数据库中存储 QuickPoll 用户,因此需要一个UserRepository来对用户实体执行 CRUD 操作。清单 8-6 显示了在com.apress.repository包下创建的UserRepository接口。除了CrudRepository提供的查找器方法外,UserRepository还包含一个名为findByUsername的自定义查找器方法。Spring Data JPA 将提供一个运行时实现,以便findByUsername方法检索与传入的用户名参数相关联的用户。
package com.apress.repository;
import org.springframework.data.repository.CrudRepository;
import com.apress.domain.User;
public interface UserRepository extends CrudRepository<User, Long> {
public User findByUsername(String username);
}
Listing 8-6UserRepository Interface
QuickPoll 等应用通常有一个允许新用户注册的界面。为了简化本书,我们生成了一些测试用户,如清单 8-7 所示。将这些 SQL 语句复制到 QuickPoll 项目的 src\main\resources 文件夹下的import.sql文件的末尾。当应用被引导时,Hibernate 会将这些测试用户加载到“users”表中,并使它们可供应用使用。
insert into users (user_id, username, password, first_name, last_name, admin) values
(1, 'mickey', '$2a$10$kSqU.ek5pDRMMK21tHJlceS1xOc9Kna4F0DD2ZwQH/LAzH0ML0p6.', 'Mickey', 'Mouse', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(2, 'minnie', '$2a$10$MnHcLn.XdLx.iMntXsmdgeO1B4wAW1E5GOy/VrLUmr4aAzabXnGFq', 'Minnie', 'Mouse', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(3, 'donald', '$2a$10$0UCBI04PCXiK0pF/9kI7.uAXiHNQeeHdkv9NhA1/xgmRpfd4qxRMG', 'Donald', 'Duck', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(4, 'daisy', '$2a$10$aNoR88g5b5TzSKb7mQ1nQOkyEwfHVQOxHY0HX7irI8qWINvLDWRyS', 'Daisy', 'Duck', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(5, 'clarabelle', '$2a$10$cuTJd2ayEwXfsPdoF5/hde6gzsPx/gEiv8LZsjPN9VPoN5XVR8cKW', 'Clarabelle', 'Cow', 'no');
insert into users (user_id, username, password, first_name, last_name, admin) values
(6, 'admin', '$2a$10$JQOfG5Tqnf97SbGcKsalz.XpDQbXi1APOf2SHPVW27bWNioi9nI8y', 'Super', 'Admin', 'yes');
Listing 8-7Test User Data
注意,生成的测试用户的密码不是纯文本的。遵循良好的安全实践,我使用 BCrypt ( http://en.wikipedia.org/wiki/Bcrypt )自适应散列函数加密了密码值。表 8-1 显示了这些测试用户及其密码的纯文本版本。
表 8-1
测试用户信息
|用户名
|
密码
|
是管理员
|
| --- | --- | --- |
| Mickey | 奶酪 | 不 |
| Minnie | Red01 | 不 |
| Donald | 骗人的 | 不 |
| Daisy | 夸克 2 | 不 |
| Clarabelle | 牛叫声 | 不 |
| Admin | 管理 | 是 |
UserDetailsService 实现
在 Spring Security introduction 一节中,我们了解到,UserDetailsService通常用于检索user信息,在身份验证过程中会与用户提交的凭证进行比较。清单 8-8 显示了我们 QuickPoll 应用的UserDetailsService实现。
package com.apress.security;
import javax.inject.Inject;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import com.apress.domain.User;
import com.apress.repository.UserRepository;
@Component
public class QuickPollUserDetailsService implements UserDetailsService {
@Inject
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if(user == null) {
throw new UsernameNotFoundException(String.format("User with the username %s doesn't exist", username));
}
// Create a granted authority based on user's role.
// Can't pass null authorities to user. Hence initialize with an
empty arraylist
List<GrantedAuthority> authorities = new ArrayList<>();
if(user.isAdmin()) {
authorities = AuthorityUtils.createAuthorityList("ROLE_ADMIN");
}
// Create a UserDetails object from the data
UserDetails userDetails = new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
return userDetails;
}
}
Listing 8-8UserDetailsService Implementation for QuickPoll
QuickPollUserDetailsService类利用UserRepository从数据库中检索User信息。然后,它检查检索到的用户是否具有管理权限,并构造一个 admin GrantedAuthority,即ROLE_ADMIN。Spring 安全基础设施期望loadUserByUsername方法返回类型UserDetails的实例。因此,QuickPollUserDetailsService类创建了o.s.s.c.u.User实例,并用从数据库中检索到的数据填充它。o.s.s.c.u.User是UserDetails接口的具体实现。如果QuickPollUserDetailsService在数据库中找不到传入用户名的用户,它将抛出一个UsernameNotFoundException异常。
自定义 Spring 安全性
定制 Spring Security 的默认行为包括创建一个用@EnableWebSecurity注释的配置类。这个配置类通常扩展了提供帮助方法的org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurer类,以简化我们的安全配置。清单 8-9 显示了SecurityConfig类,它将包含 QuickPoll 应用的安全相关配置。
package com.apress;
import javax.inject.Inject;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Inject
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}
}
Listing 8-9Security Configuration for QuickPoll
SecurityConfig类声明了一个userDetailsService属性,该属性在运行时被注入了一个QuickPollUserDetailsService实例。它还覆盖了一个超类的configure方法,该方法将一个AuthenticationManagerBuilder作为参数。AuthenticationManagerBuilder是一个助手类,它实现了 Builder 模式,提供了一种组装AuthenticationManager的简单方法。在我们的方法实现中,我们使用AuthenticationManagerBuilder来添加UserDetailsService实例。因为我们已经使用 BCrypt 算法加密了存储在数据库中的密码,所以我们提供了一个BCryptPasswordEncoder的实例。authentication manager 框架将使用密码编码器来比较用户提供的普通字符串和数据库中存储的加密哈希。
配置就绪后,重新启动 QuickPoll 应用,并在命令行运行以下命令:
curl -u mickey:cheese http://localhost:8080/v2/polls
如果您在没有–u选项和用户名/密码数据的情况下运行该命令,您将收到来自服务器的 403 错误,如下所示:
{"timestamp":1429998300969,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/v2/polls"}
保护 URI
上一节中介绍的SecurityConfig类通过配置 HTTP 基本认证来使用 QuickPoll 用户,使我们更进一步。但是,这种配置保护所有端点,并且需要身份验证才能访问资源。为了实现我们只保护 v3 轮询 API 的需求,我们将覆盖另一个WebSecurityConfigurer的config方法。清单 8-10 显示了需要添加到SecurtyConfig类中的配置方法实现。
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/v1/**", "/v2/**", "/swagger-ui/**", "/api-docs/**").permitAll()
.antMatchers("/v3/polls/ **").authenticated()
.and()
.httpBasic()
.realmName("Quick Poll")
.and()
.csrf()
.disable();
}
Listing 8-10New Config Method in SecurityConfig
清单 8-10 中传递给 config 方法的HttpSecurity参数允许我们指定 URI 应该是安全的还是不安全的。我们通过请求 Spring Security 不创建 HTTP 会话并且不在会话中存储登录用户的SecurityContext来开始方法实现。这是通过使用SessionCreationPolicy.STATELESS创建策略实现的。然后我们使用antMatchers来提供我们不希望 Spring 安全保护的蚂蚁风格的 URI 表达式。使用permitAll方法,我们指定 API 版本 1 和 2 以及 Swagger UI 应该是匿名可用的。下一个antMatchers和authenticated方法指定 Spring Security 应该只允许经过认证的用户访问 V3 Polls API。最后,我们启用 HTTP 基本身份验证,并将领域名称设置为“快速轮询”重新启动 QuickPoll 应用,应该只提示您对/v3/polls资源进行身份验证。
Note
跨站点请求伪造或 CSRF ( http://en.wikipedia.org/wiki/Cross-site_request_forgery )是一种安全漏洞,恶意网站通过这种漏洞迫使最终用户在他们当前已通过身份验证的不同网站上执行不需要的命令。默认情况下,Spring Security 启用 CSRF 保护,并强烈建议用户通过浏览器提交请求时使用它。对于非浏览器客户端使用的服务,可以禁用 CSRF。通过实现自定义的RequestMatcher,可以仅对某些 URL 或 HTTP 方法禁用 CSRF。
为了保持本书的简单和易管理,我们禁用了 CSRF 保护。
我们的最后一项安全要求是确保只有具有管理权限的用户才能删除投票。为了实现这个授权需求,我们将在deletePoll方法上应用 Spring Security 的方法级安全性。Spring 的方法级安全性可以使用恰当命名的org.springframework.security.config.annotation.method.configuration. EnableGlobalMethodSecurity注释来启用。清单 8-11 显示了添加到SecurityConfig类的注释。
package com.apress;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebMvcConfigurer {
// Content removed for brevity
}
Listing 8-11EnableGlobalMethodSecurity Annotation Added
Spring Security 支持丰富的类和方法级授权注释,以及基于标准的 JSR 250 注释安全性。EnableGlobalMethodSecurity中的prePostEnabled标志请求 Spring Security 启用执行方法调用前后授权检查的注释。下一步是用清单 8-12 所示的@PreAuthorize注释来注释 v3 PollController的deletePoll方法。
import org.springframework.security.access.prepost.PreAuthorize;
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public ResponseEntity<Void> deletePoll(@PathVariable Long pollId) {
// Code removed for brevity
}
Listing 8-12PreAuthorize Annotation Added
@PreAuthorize注释决定是否可以调用deletePoll方法。Spring Security 通过评估作为注释值传入的 Spring-EL 表达式来做出这个决定。在这种情况下,hasAuthority检查登录用户是否拥有“ROLE_ADMIN”权限。重启应用,并使用 Postman 在端点http://localhost:8080/v3/polls/12上执行删除。当提示输入凭证时,输入用户名 mickey 和密码 cheese,然后点击 Log In。图 8-8 显示了请求和相关输入。
图 8-8
删除未经授权用户的投票
由于用户 mickey 没有管理权限,您将看到来自服务的未授权响应,如图 8-9 所示。
图 8-9
未经授权的删除响应
现在让我们使用具有管理权限的管理员用户重试这个请求。在 Postman 中,点击基本认证选项卡,输入证书 admin/admin,点击“刷新标题”,如图 8-10 所示。在提交请求时,您应该看到 ID 为 12 的投票资源被删除了。
图 8-10
邮递员中的基本身份验证管理凭证
要使用 cURL 删除投票,请运行以下命令:
curl -i -u admin:admin -X DELETE http://localhost:3/v3/polls/13
前面提到的命令删除了一个 ID 为 13 的轮询资源。–i选项请求 curl 输出响应头。–X选项允许我们指定 HTTP 方法名。在我们的例子中,我们指定了 DELETE HTTP 方法。该结果的输出如清单 8-13 所示。
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sat, 25 Apr 2015 21:50:35 GMT
Listing 8-13Output of cURL Delete
摘要
安全性是任何企业应用的一个重要方面。在本章中,我们回顾了保护 REST 服务的策略。我们还深入研究了 OAuth 2,并回顾了它的不同组件。然后,我们使用 Spring Security 在 QuickPoll 应用中实现基本的身份验证。在下一章,我们将使用 Spring 的 RestTemplate 来构建 REST 客户端。我们还将使用 Spring MVC 测试框架在 REST 控制器上执行单元和集成测试。