传送门
在前面几节,完整的介绍了Oauth2的授权码,密码,客户端凭据及隐藏模式。其中最后一种隐式模式,是最不安全的一种。而授权码模式是最经典、最完备、最为常用的授权流程了,在任何场景下,都是首选!所以这里实现一个简易的授权码流程,麻雀虽小五脏俱全,会展示授权码流程涉及的整个步骤
授权服务的工作流程
在Oauth2系列2:授权码模式里,提到了标准授权码流程,有4个角色,分别是资源拥有者,客户端,授权服务及受保护资源。而其中的授权服务,是整个授权码流程的核心,它负责令牌的管理,包括令牌的颁发,刷新,撤销,甚至访问管理。
用一个用例图来表示一下整个生命周期环节如下
- 图上刻意简化了资源拥有者,受保护资源,因为不是这里的重点,只突出了客户端及授权服务
- 从图中看出,颁发一个令牌的细节还是挺多的,而这种复杂性也带来了安全性,任何设计背后都是成本的考量,是一种安全性与易用性的平衡及取舍
- 这里把应用的注册,授权服务放到了一块,具体的设计要根据实际的专题进行设计,因为一般的开放平台做授权管理跟单一系统区别在于,开放平台的资源并不它自己,是其它应用系统托管过来的
应用注册
对于应用的注册,一般是平台级的行为,不会单独开放接口给客户端注册。
一般流程是,用户到登录到平台上,提交应用的注册信息。等审核通过之后,平台会颁发对应的client_id,client_secret信息,用于客户端做身份认别。
所以这里也就不做接口的开发,只定义一个表结构如下
各个平台的主要逻辑字段基本都大同小异,其它只是根据平台业务或多或少的定制化
注册完后,开放平台就会给客户端 app_id 和 app_secret 等信息,以方便后面授权时的各种身份校验。同时,注册的时候,客户端软件也会请求受保护资源的可访问范围。这个权限范围,就是 scope
颁发授权码 code
颁发授权码code是服务端提供的服务,先定义此接口,让接入客户端调用,用于让用户跳转到统一授权页面进行确认:
类型:GET
入参:
参数 是否必须 说明 response_type 是 固定为"code",表示要求返回授权码 client_id 是 应用唯一标识 redirect_uri 是 接受或拒绝请求后的跳转网址 scope 否 表示要求的授权范围(这里是只读)
具体代码示例:
@RestController
@RequestMapping("/auth")
public class OauthController
{
@GetMapping("/authorize")
public void authorize(@RequestParam("response_type") String responseType, @RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri, String scope)
{
}
}
在这个过程中,从上图中可以看到,授权服务需要完成两部分工作,分别是准备工作和生成授权码 code。其中的准备工作包括下面3个步骤:
准备工作1-验证基本信息
验证基本信息,包括对客户端身份合法性和回调地址合法性的校验,还有response_type类型,必须固定为"code"
在 Web 浏览器环境下,颁发 code 的整个请求过程,都是浏览器通过前端通信来完成,这就意味着所有信息都有被冒充的风险。因此,授权服务必须对客户端的存在性做判断,就是判断client_id是否注册过。同样,回调地址也是可以被伪造的。比如,不法分子将其伪装成钓鱼页面,或者是带有恶意攻击性的软件下载页面(CSRF攻击)。因此从安全上考虑,授权服务需要对回调地址redirect_uri做基本的校验。
直接实现通过上面的authorize方法来表示一下:
@RestController
@RequestMapping("/auth")
public class OauthController
{
private static final Map<String, String> basicAppMap = new HashMap<>(16);
static
{
basicAppMap.put("clientId", "209e33db-154b-4fda-a93d-16f124413da2");
basicAppMap.put("redirectUri", "www.baidu.com");
basicAppMap.put("scope", "sex,age,nickName");
}
@GetMapping("/authorize")
public void authorize(@RequestParam("response_type") String responseType, @RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri, String scope)
{
// 基本信息检验
preCheck(responseType, clientId, redirectUri);
}
private void preCheck(String responseType, String clientId, String redirectUri)
{
if (!"code".equals(responseType)) {
throw new RuntimeException("response_type must be code");
}
if (!basicAppMap.get("clientId").equals(clientId)) {
throw new RuntimeException("client_id is wrong");
}
if (!basicAppMap.get("redirectUri").equals(redirectUri)) {
throw new RuntimeException("redirectUri is wrong");
}
}
准备工作2-验证权限范围
这里说的权限验证,到底是验证什么权限呢?在一些开放平台的设计中,系统可以托管用户私有的资源:如微信的用户账号名称,头像,姓别等,甚至获取一些平台的公共资源信息。这些资源是平台可以定制开放的,这个在应用注册时就可以生成,放到scope字段里面(复杂一点的,不一定是这么设计),那么应用在获取code时,可以选择获取的权限列表,通过传入的scope参数指定。接着上面的方法实现:
private void preCheckScope(String scope) {
List<String> scopeList = Arrays.asList(basicAppMap.get("scope").split(","));
boolean exist = scopeList.stream().anyMatch(s -> s.equals(scope));
if (!exist) {
throw new RuntimeException("权限申请超出规定范围");
}
}
准备工作3-生成授权页面
@GetMapping("/authorize")
public void authorize(@RequestParam("response_type") String responseType, @RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri, String scope)
{
// 基本信息检验
preCheck(responseType, clientId, redirectUri);
// 权限申请检验
preCheckScope(scope);
// todo 重定向到授权页面
}
授权请求页面就是授权服务上的页面,比如常见的第三方登录,以极客时间为例子
选择微信登录,浏览器抓包可以看到背后的GET请求,这就是微信第三方登录的oauth2协议实现
点击之后会跳转到如下的登录页面
用户微信扫码之后,会在APP上面展示授权页面,上面有用户的头像等信息,让用户确认是否授权
到此,生成授权码code的准备工作就完成了,接下来就是生成code的流程了
所以,这里就不做前端页面献丑了
后面就是真正的生成授权码code了,这里要有一个新接口,用于用户在界面确定授权之后的提交。
对于微信这种,是由微信提供的,会确定当前登录用户的账号,即userId(当前用户账号,这个需要由授权服务确定,理论上是用户先在授权APP上面登录,第三方客户端才能借助授权服务获取)。此接口这里可以先定义
URL:https://xxx.com/oauth/confirm
类型:POST
入参:
参数 是否必须 说明 scope 否 表示要求的授权范围(这里是只读)
具体代码示例:
@PostMapping("/confirm")
public void confirm(String scope) {
}
重点关注授权码流程,也包括3个步骤:
用户输入的权限检验
这里讨论的用户输入检验,指的是用户在授权页面选择的权限,相当于用户输入,理论上也是不能超过注册时指定的范围的,这个检验同上(不过这里的权限scope理论上可以是让用户多选的,不仅仅是检验一个包含关系)
private void CheckScope(String scope) {
List<String> scopeList = Arrays.asList(basicAppMap.get("scope").split(","));
boolean exist = scopeList.stream().anyMatch(s -> s.equals(scope));
if (!exist) {
throw new RuntimeException("权限申请超出规定范围");
}
}
生成授权码
在授权服务中,需要将生成的授权码 code 值与 client_id、userId 进行关系映射。也就是说,一个授权码 code,表示某一个用户给某一个第三方软件进行授权,比如给微信授权登录极客时。同时,需要将 code 值和这种映射关系保存起来,以便在生成访问令牌 access_token 时使用。
对于code的还安全方面的一些要求,比如一次使用有效(防止泄漏),设置有效期(默认5分钟)等。
@PostMapping("/confirm")
public void confirm(String scope, HttpServletResponse response) {
// 生成code
String code = generateCode(basicAppMap.get("clientId"), basicAppMap.get("userId"), scope);
}
private String generateCode(String clientId, String userId, String scope) {
CodeModel codeModel = new CodeModel();
String code = UUID.randomUUID().toString();
codeModel.setClientId(clientId);
codeModel.setUserId(userId);
codeModel.setScope(scope);
codeModel.setCode(code);
// todo 存储code信息,比如redis中,同时设置code有效期
return code;
}
public class CodeModel {
/** 用户ID */
private String userId;
/** 应用ID */
private String clientId;
/** 授权范围 */
private String scope;
/** 授权码 */
private String code;
}
重定向到客户端
生成授权码 code 值之后,授权服务需要将该 code 值告知第三方软件。颁发授权码 code 是通过前端通信完成的,因此这里采用重定向的方式(依赖浏览器实现),也就是HTTP中的302。这一步的重定向,也是属于第二次重定向(第一次是上面的authorize接口,客户端调用之后,会重定向到统一授权页面),最终会回调到上面的客户端提供的redirect_uri,让客户端接收code授权码。
@PostMapping("/confirm")
public void confirm(String scope, HttpServletResponse response) throws IOException {
// 生成code
String code = generateCode(basicAppMap.get("clientId"), basicAppMap.get("userId"), scope);
basicAppMap.put("code", code);
// 重定向到业务系统
response.sendRedirect(basicAppMap.get("redirectUri") + "?code=" + code);
}
颁发访问令牌
前面授权服务器生成授权码code之后,会重定向到客户端提供的redirect_uri,一般就是客户端实现一个接口,用来接收code,可以类似定义:
URL:https://businesscom/oauth/code
类型:GET
入参:
参数 是否必须 说明 code 是 授权码 state 否 state参数,用来防止csrf,这里不过多介绍,后面安全单独讨论
当拿着授权码 code 来请求的时候,授权服务需要为之生成最终的请求访问令牌。这个过程主要包括验证客户端是否存在、验证 code 值是否合法和生成 access_token 值这三大步。
注意,用code换取token这一步,这里授权服务的接口出于安全性的考虑,一般要求是后面交互,即客户端用HTTP请求直接请求授权服务,甚至可能有些还会使用HTTPS协议:
类型:POST
入参:
参数 是否必须 说明 client_id 是 应用唯一标识 client_secret 是 应用身份密钥, client_secret
参数是保密的,因此只能在后端发请求grant_type 是 AUTHORIZATION_CODE
,表示采用的授权方式是授权码code 是 授权码
验证客户端
验证客户端信息,包括对客户端身份合法性校验,还有response_type类型,必须固定为"authorization_code"。
而这里的客户端身份除了验证client_id以外,还要验证client_secret,以此来确保安全性:即在后端交互中,虽然code有可能被截取(因为是通过浏览器交互的,会暴露到浏览器中),但是client_secret只有真正的客户端才拥有。所以如果code+client_id被截取,也是不能获取到真正的访问令牌的
@PostMapping("/token")
public TokenModel getToken(@RequestBody GetTokenRequest getTokenRequest) {
// 获取令牌前置检验
preGetTokenCheck(getTokenRequest);
TokenModel tokenModel = new TokenModel();
return tokenModel;
}
private void preGetTokenCheck(GetTokenRequest getTokenRequest) {
if ("authorization_code".equals(getTokenRequest.getGrantType())) {
throw new RuntimeException("grant_type is wrong");
}
if (!basicAppMap.get("clientId").equals(getTokenRequest.getClientId())) {
throw new RuntimeException("client_id is wrong");
}
if (!basicAppMap.get("client_secret").equals(getTokenRequest.getClientSecret())) {
throw new RuntimeException("client_secret is wrong");
}
}
验证授权码
验证授权码 code 值是否合法:如果不合法,就进行提示,如果合法,要记住要删除该授权码code,以达到只使用一次的效果
@PostMapping("/token")
public TokenModel getToken(@RequestBody GetTokenRequest getTokenRequest) {
// 获取令牌前置检验
preGetTokenCheck(getTokenRequest);
checkCode(getTokenRequest.getCode());
TokenModel tokenModel = new TokenModel();
return tokenModel;
}
private void checkCode(String code) {
if (!basicAppMap.get("code").equals(code)) {
throw new RuntimeException("code is wrong");
}
// 使用之后立即删除
basicAppMap.remove("code");
}
生成访问令牌
最后一步就是生成访问令牌 access_token 值。
在回顾一下令牌的结构:
{
"access_token":"ACCESS_TOKEN",
"expires_in":7200,
"refresh_token":"REFRESH_TOKEN",
"scope":"SCOPE"
}
按照上面的格式,来生成对应的访问令牌:
@PostMapping("/token")
public TokenModel getToken(@RequestBody GetTokenRequest getTokenRequest) {
// 获取令牌前置检验
preGetTokenCheck(getTokenRequest);
// 检验授权码
checkCode(getTokenRequest.getCode());
// 生成t访问令牌
TokenModel tokenModel = generateToken();
return tokenModel;
}
private TokenModel generateToken() {
// 获取code信息,比如从redis
// CodeModel codeModel = getCode;
TokenModel tokenModel = new TokenModel();
tokenModel.setAccessToken(UUID.randomUUID().toString());
tokenModel.setExpiresIn(3600);
tokenModel.setRefreshToken(UUID.randomUUID().toString());
// tokenModel.setScope(codeModel.getScope);
return tokenModel;
}
到此,就是整个授权码的完整流程,其中代码只是接近伪代码,简单表述了整个过程,可以在这里获取参考kobe_t/kobe_t