OAuth 2简介
假设我们想要为 Taco Cloud 创建一个后台管理应用。具体来讲,我们希望这个新的应用能够管理主Taco Cloud Web站点上可用的配料。
在开始编写代码实现管理应用之前,我们需要向Taco Cloud API添加一些新的端点,以支持配料的管理。程序清单8.1中的REST控制器提供了3个端点,用于列出、添加和删除配料。
package tacos.web.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import tacos.Ingredient;
import tacos.data.IngredientRepository;
@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);
}
}
非常好!现在,我们需要做的就是编写管理应用,按需调用主Taco Cloud应用上的这些端点来添加和删除配料。
但是,请稍等,这些API还没有安全限制呢。如果我们的后端应用可以发送HTTP请求来添加和删除配料,那么任何人都可以这样做。即便只是使用curl命令行客户端,其他人也可以像这样添加新的配料:
$ curl localhost:8080/ingredients \
-H"Content-type: application/json" \
-d'{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}'
他们甚至还可以使用 curl 删除已有的配料,如下所示:
$ curl localhost:8080/ingredients/GRBF -X DELETE
这个API是主应用的一部分,对整个外部世界都是可访问的。实际上,主应用用户界面的home.html就使用了GET端点。因此,很明显,我们至少需要保护POST和DELETE端点。
有种可选方案是使用HTTP Basic认证来保护“/ingredients”的端点。这可以通过为处理器方法添加@PreAuthorize来实现,如下所示:
@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);
}
端点也可以通过如下所示的安全配置来保护:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/ingredients").hasRole("ADMIN")
.antMatchers(HttpMethod.DELETE, "/ingredients/**").hasRole("ADMIN")
...
}
不管采用哪种方式,向“/ingredients”提交POST或DELETE请求时,我们会要求提交者提供具有“ROLE_ADMIN”权限的凭证。例如,使用curl时,凭证信息可以通过“-u”参数来声明,如下所示:
$ curl localhost:8080/ingredients \
-H"Content-type: application/json" \
-d'{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}' \
-u admin:l3tm31n
尽管HTTP Basic能够为我们锁定API,但是它实在是太基础了。它要求客户端和API共享用户凭证信息,这可能会导致信息重复。另外,尽管HTTP Basic凭据在请求头中使用了Base64进行编码,但如果黑客能够以某种方式拦截请求,那么凭证会很容易地被获取、解码并用于带有恶意的目的。如果发生这种情况,就需要修改密码,所有的客户端都需要进行更新和重新认证。
如果我们能够不强制要求管理员用户在每个请求上都表明自己的身份,而是让API只要求调用者提供可以证明该用户已授权访问的令牌(token),那情况又会怎样呢?这大概就像是体育比赛的门票。要进入场地观看比赛,门口的工作人员不需要关心你是谁,他们只需要知道你有一张合规的门票。你如果有门票,就可以进入场地观看比赛。
这大致就是OAuth 2授权的工作方式。客户端向授权服务器申请一个访问令牌(类似于泊车钥匙),令牌中会声明用户的权限。该令牌允许客户端以授权用户的身份与API进行交互。在任何时候,令牌都可以过期或撤销,而不需要用户修改密码。在这种情况下,客户端只需要请求一个新的访问令牌,就能以用户的身份继续行事。这个流程如图8.1所示。

OAuth 2是一个功能丰富的安全规范,提供了多种使用方式。图8.1描述的流程称作授权码授权(authorization code grant)模式。OAuth 2支持的其他流程如下。
-
隐式授权(implicit grant):与授权码授权类似,隐式授权会将用户的浏览器重定向到授权服务器,以获取用户的许可。但是,重定向回来时,不是在请求中提供授权码,而是在请求中隐式授予访问令牌。尽管这种方式是为了在浏览器中运行的JavaScript客户端设计的,但是一般不推荐使用这种方式。更好的方式是使用授权码授权。
-
用户凭证(或密码)授权(user credentials (password) grant):这种流程不会进行重定向,甚至可能不涉及Web浏览器。客户端应用会获取用户的凭证,并将它们直接替换成访问令牌。这个流程似乎很适合那些非浏览器客户端,但是现代应用通常更倾向于要求用户在浏览器中访问一个Web站点,并进行授权码授权,从而避免直接处理用户的凭证。
-
客户端凭证授权(client credentials grant):这个流程与用户凭证授权类似,只不过不是交换用户的凭证以获取访问令牌,而是客户端交换自己的凭证以获取访问令牌。但是,所授予的令牌仅限于执行一些不以用户为中心的操作,并且不能以用户的身份行事。
对我们来讲,我们将会关注通过授权码授权的方式获取JWT(即JSON web token)访问令牌。这涉及创建一些协作的应用,包括:
-
授权服务器(authorization server):授权服务器的任务是代表客户端应用获取用户的许可。如果用户许可,授权服务器就会为客户端应用提供一个访问令牌,借助该令牌就能够访问需要认证的API了。
-
资源服务器(resource server):资源服务器其实就是OAuth 2所保护的API的另一个名称。尽管资源服务器本身是API的一部分,但为了便于讨论,通常会将其作为独立的概念对待。资源服务器会限制对其资源的访问,除非请求提供了一个包含必要权限scope的合法访问令牌。对于我们来讲,第7章开始编写的Taco Cloud API可以作为资源服务器,只需要我们为其添加一些安全配置。
-
客户端应用:客户端应用是想要消费API的应用,但是它需要权限才能做到这一点。我们将会为Taco Cloud构建一个简单的管理应用,实现添加新配料的功能。
-
用户:用户是使用客户端应用的人。用户需要授予客户端应用代表他们访问资源服务器API的权限。
在授权码授权的流程中,客户端获取访问令牌时,客户端应用和授权服务器之间会有一系列的重定向。首先,它会将用户的浏览器从客户端重定向到授权服务器,要求用户授予特定的权限(或“scope”)。授权服务器要求用户登录并同意授予所要求的权限。用户许可之后,授权服务器会将浏览器重定向到客户端,并且会附带一个授权码,客户端可以据此替换一个访问令牌。客户端有了访问令牌之后,就可以在每个请求的“Authorization”头信息中传递这个令牌,从而与资源服务器API交互。
我们会将关注点放到OAuth 2的具体使用上,但是我建议你通过阅读OAuth 2规范或以下任意一本关于该主题的书来深入了解该主题:
-
OAuth 2 in Action;
-
Microservices Security in Action;
-
API Security in Action。
你还可以学习Manning推出的名为“Protecting User Data with Spring Security and OAuth2”的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的项目。这个项目被标记为“具有试验性”(experimental),并且计划最终会由社区驱动,但它是开始使用OAuth 2的绝佳起点,这样我们就无须注册任何其他的授权服务器。
在本章的剩余部分,我们将会看到如何借助Spring Security来使用OAuth 2。在此过程中,我们将创建两个新项目,分别是授权服务器项目和客户端项目,并修改现有的Taco Cloud项目,使其API成为一个资源服务器。首先使用Spring Authorization Server创建授权服务器。