Oauth2系列7:授权码和访问令牌的颁发流程是怎样实现的?

传送门

Oauth2系列1:初识Oauth2

Oauth2系列2:授权码模式

Oauth2系列3:接入前准备

Oauth2系列4:密码模式

Oauth2系列5:客户端凭据模式

Oauth2系列6:隐式模式

在前面几节,完整的介绍了Oauth2的授权码,密码,客户端凭据及隐藏模式。其中最后一种隐式模式,是最不安全的一种。而授权码模式是最经典、最完备、最为常用的授权流程了,在任何场景下,都是首选!所以这里实现一个简易的授权码流程,麻雀虽小五脏俱全,会展示授权码流程涉及的整个步骤

授权服务的工作流程

Oauth2系列2:授权码模式里,提到了标准授权码流程,有4个角色,分别是资源拥有者,客户端,授权服务及受保护资源。而其中的授权服务,是整个授权码流程的核心,它负责令牌的管理,包括令牌的颁发,刷新,撤销,甚至访问管理。

用一个用例图来表示一下整个生命周期环节如下

  • 图上刻意简化了资源拥有者,受保护资源,因为不是这里的重点,只突出了客户端及授权服务
  • 从图中看出,颁发一个令牌的细节还是挺多的,而这种复杂性也带来了安全性,任何设计背后都是成本的考量,是一种安全性与易用性的平衡及取舍
  • 这里把应用的注册,授权服务放到了一块,具体的设计要根据实际的专题进行设计,因为一般的开放平台做授权管理跟单一系统区别在于,开放平台的资源并不它自己,是其它应用系统托管过来的

应用注册

对于应用的注册,一般是平台级的行为,不会单独开放接口给客户端注册。

一般流程是,用户到登录到平台上,提交应用的注册信息。等审核通过之后,平台会颁发对应的client_id,client_secret信息,用于客户端做身份认别。

所以这里也就不做接口的开发,只定义一个表结构如下

 各个平台的主要逻辑字段基本都大同小异,其它只是根据平台业务或多或少的定制化

注册完后,开放平台就会给客户端 app_id 和 app_secret 等信息,以方便后面授权时的各种身份校验。同时,注册的时候,客户端软件也会请求受保护资源的可访问范围。这个权限范围,就是 scope

颁发授权码 code

颁发授权码code是服务端提供的服务,先定义此接口,让接入客户端调用,用于让用户跳转到统一授权页面进行确认:

URL:https://xxx.com/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK_URL&scope=read

类型: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协议实现

https://account.geekbang.org/account/oauth/authorize?remember=1&type=wechat&is_bind=0&redirect_url=https%3A%2F%2Faccount.geekbang.org%2Fthirdlogin%3Fremember%3D1%26type%3Dwechat%26is_bind%3D0%26platform%3Dtime%26redirect%3Dhttps%253A%252F%252Ftime.geekbang.org%252F

点击之后会跳转到如下的登录页面 

用户微信扫码之后,会在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授权码
statestate参数,用来防止csrf,这里不过多介绍,后面安全单独讨论

当拿着授权码 code 来请求的时候,授权服务需要为之生成最终的请求访问令牌。这个过程主要包括验证客户端是否存在、验证 code 值是否合法和生成 access_token 值这三大步。

注意,用code换取token这一步,这里授权服务的接口出于安全性的考虑,一般要求是后面交互,即客户端用HTTP请求直接请求授权服务,甚至可能有些还会使用HTTPS协议:

 URL:https://b.com/oauth/token

类型:POST

入参:

参数是否必须说明
client_id应用唯一标识
client_secret应用身份密钥,client_secret参数是保密的,因此只能在后端发请求
grant_typeAUTHORIZATION_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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值