OAuth2[实战系列]

资源链接

官方文档
官方GITHUB
本文GITHUB DEMO

术语

  1. Resource owner 资源拥有者,比如微信用户,拥有头像,手机号,微信唯一标识等资源,可以授权给第三方应用程序权限
  2. Client 第三方应用程序,比如服务号开发商
  3. Authorization server 授权服务器,比如微信官方的服务器,成功鉴权之后发放Access token
  4. Resource server 资源服务器,比如头像,手机,微信朋友关系等,使用Access token可以访问受保护资源
  5. Access token 用于访问受保护资源的令牌
  6. Authorization code 用户授权Client代表他们访问受保护资源时生成的中间令牌。Client收到此令牌并将其交换为Access token
  7. Grant 获得Access token的方式
  8. Scope 许可
  9. JWT JSON Web Token,一种token技术

要求

  • 为了防止中间人攻击,授权服务器必须使用TLS证书
  • PHP >=7.2
  • openssl,json 扩展

安装

  • 安装composer包
    composer require league/oauth2-server
  • 生成公私钥
    openssl genrsa -out private.key 2048
  • 从私钥提取公钥
    openssl rsa -in private.key -pubout -out public.key
  • 也可以生成带密码的私钥
    openssl genrsa -aes128 -passout pass:_passphrase_ -out private.key 2048
  • 对应的从私钥提取公钥
    openssl rsa -in private.key -passin pass:_passphrase_ -pubout -out public.key
  • 生成对称加密key
    用于加密Authorization Code 和 Refresh code
    php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'

怎么选择Grant

OAuth2[实战系列]
如果您授权一台机器访问资源并且您不需要用户的许可来访问所述资源,您应该选择 Client credentials
如果您需要获得资源所有者允许才能访问资源,则需要判断一下第三方用户的类型
第三方是否有能力安全的存储自己与用户的凭据将取决于客户端应该使用哪种授权。
如果第三方是个有自己服务器的web应用,则用Authorization code
如果第三方是个单页应用,或者移动APP,则用带PKCE扩展的Authorization code
Password Grant和Implicit Grant已经不再被官方推荐,完全可以被Authorization code取代,这里就不延伸讨论

Client credentials grant

第三方发送POST请求给授权服务器

  • grant_type : client_credentials
  • client_id : 第三方ID
  • client_secret : 第三方Secret
  • scope : 权限范围,多个以空格隔开
    授权服务器返回一下信息
  • token_type : Bearer
  • expires_in : token过期时间
  • access_token : 访问令牌,是一个授权服务器加密过的JWT

OAuth2[实战系列]

Authorization code grant

第一步

第三方构建url把资源拥有者(用户)重定向到授权服务器,并带上以下get参数

  • response_type : code
  • client_id : 第三方ID
  • redirect_uri : 第三方回调地址(可选)
  • scope : 范围
  • state : CSRF Token(可选),但是高度推荐

构建url:

http://auth.cc/index/auth/authorize?response_type=code&client_id=wx123456789&redirect_uri=https%3A%2F%2Fwww.baidu.com&scope=basic&state=34

用户打开构建url,引导用户登录
OAuth2[实战系列]

登录成功之后引导用户授权
OAuth2[实战系列]

用户同意授权,重定向到第三方的redirect_uri并带上以下get参数

  • code : Authorization code
  • state : 上面传的state,可验证是否相同

OAuth2[实战系列]

第二步

第三方发送POST请求给授权服务器

  • grant_type : authorization_code
  • client_id : 第三方ID
  • client_secret : 第三方Secret
  • redirect_uri : 第三方回调地址
  • code : 第一步返回的Authorization code
    授权服务器返回一下信息
  • token_type : Bearer
  • expires_in : token过期时间
  • access_token : 访问令牌,是一个授权服务器加密过的JWT
  • refresh_token : 访问令牌过期时可刷新

OAuth2[实战系列]

Refresh token grant

第三方发送POST请求给授权服务器

  • grant_type : refresh_token
  • client_id : 第三方ID
  • client_secret : 第三方Secret
  • scope : 范围
  • refresh_token : 刷新令牌
    授权服务器返回一下信息
  • token_type : Bearer
  • expires_in : token过期时间
  • access_token : 访问令牌,是一个授权服务器加密过的JWT
  • refresh_token : 访问令牌过期时可刷新

OAuth2[实战系列]

数据表DDL

//第三方服务商表
CREATE TABLE `oauth_clients`  (
  `oauth_clients_id`  int(10) unsigned NOT NULL AUTO_INCREMENT,
  `client_id`  varchar(80) NOT NULL,
  `client_secret`  varchar(80) DEFAULT NULL,
  `redirect_uri`  varchar(2000) DEFAULT NULL,
  `grant_types`  varchar(80) DEFAULT NULL,
  `scope`  varchar(4000) DEFAULT NULL,
  `user_id`  varchar(80) DEFAULT NULL,
  PRIMARY KEY (`oauth_clients_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
//scope表
CREATE TABLE `oauth_scopes` (
    `oauth_scopes_id` int(10) unsigned NOT NULL,
    `scope` varchar(80) NOT NULL,
    `is_default` tinyint(1) DEFAULT NULL,
    PRIMARY KEY (`oauth_scopes_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心代码

<?php

namespace app\index\controller;

use auth2\Entities\UserEntity;
use auth2\Repositories\AuthCodeRepository;
use auth2\Repositories\RefreshTokenRepository;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory;
use League\OAuth2\Server\AuthorizationServer;
use auth2\Repositories\AccessTokenRepository;
use auth2\Repositories\ClientRepository;
use auth2\Repositories\ScopeRepository;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\ResourceServer;
use think\Controller;
use think\Db;
use think\Request;
use think\Response;
use think\Session;

class Auth extends Controller
{
    // 授权服务器
    private $authorizationServer;

    // 初始化授权服务器,装载Repository
    public function __construct(Request $request = null)
    {
        parent::__construct($request);

        // 以下2个Repository可以自定义实现
        $clientRepository = new ClientRepository();
        $scopeRepository = new ScopeRepository();

        // 以下3个如果不是要自定义auth code / access token 可以不用处理
        $accessTokenRepository = new AccessTokenRepository();
        $authCodeRepository = new AuthCodeRepository();
        $refreshTokenRepository = new RefreshTokenRepository();

        // 私钥
        $privateKey = ROOT_PATH . '/private.key';
        $encryptionKey = 'lxZFUEsBCJ2Yb14IF2ygAHI5N4+ZAUXXaSeeJm6+twsUmIen'; // base64_encode(random_bytes(32))

        // 实例化AuthorizationServer
        $authorizationServer = new AuthorizationServer(
            $clientRepository,
            $accessTokenRepository,
            $scopeRepository,
            $privateKey,
            $encryptionKey
        );

        // 启用 client credentials grant
        $authorizationServer->enableGrantType(
            new \League\OAuth2\Server\Grant\ClientCredentialsGrant(),
            new \DateInterval('PT2H')   // access token 有效期2个小时
        );

        // 启用 authentication code grant
        $grant = new \League\OAuth2\Server\Grant\AuthCodeGrant(
            $authCodeRepository,
            $refreshTokenRepository,
            new \DateInterval('PT10M') // authorization codes 有效期10分钟
        );
        $grant->setRefreshTokenTTL(new \DateInterval('P1M')); // refresh tokens 有效期1个月
        $authorizationServer->enableGrantType(
            $grant,
            new \DateInterval('PT2H')  // access token 有效期2个小时
        );

        // 启用 Refresh token grant
        $grant = new \League\OAuth2\Server\Grant\RefreshTokenGrant($refreshTokenRepository);
        $grant->setRefreshTokenTTL(new \DateInterval('P1M')); // refresh tokens 有效期1个月
        $authorizationServer->enableGrantType(
            $grant,
            new \DateInterval('PT2H') // // access token 有效期2个小时
        );
        $this->authorizationServer = $authorizationServer;
    }

    /**
     * 引导用户跳转登录
     */
    public function authorize()
    {
        //实例化 Psr\Http\Message\ServerRequestInterface
        $request = ServerRequestFactory::fromGlobals();
        $authRequest = $this->authorizationServer->validateAuthorizationRequest($request);
        //保存session
        Session::set('auth_request', serialize($authRequest));
        return $this->fetch('login');
    }

    /**
     * 验证登录
     */
    public function login(Request $request)
    {
        if (!$request->isPost()) {
            $this->error('错误请求');
        }
        //用户登录
        $user = Db::table('oauth_users')->where(['username' => $request->post('username'), 'password' => $request->post('password')])->find();
        if (empty($user)) {
            $this->error('密码错误');
        }
        $authRequest = unserialize(Session::get('auth_request'));
        //设置openid
        $authRequest->setUser(new UserEntity($user['openid'])); // an instance of UserEntityInterface
        Session::set('auth_request', serialize($authRequest));
        return $this->fetch('approve');
    }

    /**
     * 引导用户授权
     */
    public function approve(Request $request)
    {
        $q = $request->get();
        if (is_null($approve = $q['approve'])) {
            $this->error('错误请求');
        }
        $authRequest = unserialize(Session::get('auth_request'));
        $authRequest->setAuthorizationApproved((bool)$approve);
        $response = new \Laminas\Diactoros\Response();
        try {
            $psrResponse = $this->authorizationServer->completeAuthorizationRequest($authRequest, $response);
        } catch (OAuthServerException $e) {
            //用户拒绝授权,报错
            return convertResponsePsr2Tp($e->generateHttpResponse($response));
        }
        //用户统一授权 跳转第三方redirect_uri
        return convertResponsePsr2Tp($psrResponse);
    }


    /**
     * 获取access token
     */
    public function token(Request $request)
    {
        $request = ServerRequestFactory::fromGlobals();
        $response = new \Laminas\Diactoros\Response();
        try {
            $response = $this->authorizationServer->respondToAccessTokenRequest($request, $response);
        } catch (\League\OAuth2\Server\Exception\OAuthServerException $exception) {
            return response($exception->getMessage());
        } catch (\Exception $exception) {
            return response($exception->getMessage());
        }
        return convertResponsePsr2Tp($response);
    }

    /**
     * 刷新access token
     */
    public function refresh(Request $request){
        $request = ServerRequestFactory::fromGlobals();
        $response = new \Laminas\Diactoros\Response();
        try {
            $response = $this->authorizationServer->respondToAccessTokenRequest($request, $response);
        } catch (\League\OAuth2\Server\Exception\OAuthServerException $exception) {
            return response($exception->getHint());
        } catch (\Exception $exception) {
            return response($exception->getMessage());
        }
        return convertResponsePsr2Tp($response);
    }

    /**
     * 验证access token
     */
    public function check()
    {
        $accessTokenRepository = new AccessTokenRepository(); // instance of AccessTokenRepositoryInterface
        // 初始化资源服务器
        $server = new ResourceServer(
            $accessTokenRepository,
            ROOT_PATH . '/public.key'
        );
        $request = ServerRequestFactory::fromGlobals();
        $response = new \Laminas\Diactoros\Response();
        try {
            $request = $server->validateAuthenticatedRequest($request);
        } catch (\League\OAuth2\Server\Exception\OAuthServerException $exception) {
            return convertResponsePsr2Tp($exception->generateHttpResponse($response));
        } catch (\Exception $exception) {
            return convertResponsePsr2Tp((new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500))
                ->generateHttpResponse($response));
        }
        $attr = $request->getAttributes();
        //第三方的client_id
        $oauth_client_id = $attr['oauth_client_id'];
        //用户的openid
        $oauth_user_id = $attr['oauth_user_id'];
        //权限
        $oauth_scopes = $attr['oauth_scopes'];

        //业务逻辑
        //...
    }
}

两个需要自己实现的Repository

<?php
/**
 * @author      Alex Bilbie <hello@alexbilbie.com>
 * @copyright   Copyright (c) Alex Bilbie
 * @license     http://mit-license.org/
 *
 * @link        https://github.com/thephpleague/oauth2-server
 */

namespace auth2\Repositories;

use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use auth2\Entities\ClientEntity;
use think\Db;

class ClientRepository implements ClientRepositoryInterface
{
    /**
     * 返回第三方基本信息
     */
    public function getClientEntity($clientIdentifier)
    {
        //查询数据库
        $merchant = Db::table('oauth_clients')->where(['client_id' => $clientIdentifier])->find();
        if (empty($merchant)) {
            return false;
        }

        $client = new ClientEntity();

        $client->setIdentifier($clientIdentifier);
        $client->setName($merchant['oauth_clients_id']);
        $client->setRedirectUri($merchant['redirect_uri']);
        $client->setConfidential();

        return $client;
    }

    /**
     * 验证第三方client_id client_secret
     */
    public function validateClient($clientIdentifier, $clientSecret, $grantType)
    {
        $client = Db::table('oauth_clients')->where(['client_id' => $clientIdentifier])->find();
        // 判断第三方是否注册
        if (!$client) {
            return false;
        }
        // 验证client_secret
        if ((bool)$client['is_confidential'] === true && $clientSecret != $client['client_secret']) {
            return false;
        }
        return true;
    }
}
<?php
/**
 * @author      Alex Bilbie <hello@alexbilbie.com>
 * @copyright   Copyright (c) Alex Bilbie
 * @license     http://mit-license.org/
 *
 * @link        https://github.com/thephpleague/oauth2-server
 */

namespace auth2\Repositories;

use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use auth2\Entities\ScopeEntity;
use think\Db;

class ScopeRepository implements ScopeRepositoryInterface
{
    /**
     * 调用此方法来验证Scope
     */
    public function getScopeEntityByIdentifier($scopeIdentifier)
    {
        $count = Db::table('oauth_scopes')->where(['scope'=>$scopeIdentifier])->count();
        if (!$count) {
            return false;
        }

        $scope = new ScopeEntity();
        $scope->setIdentifier($scopeIdentifier);

        return $scope;
    }

    /**
     * 在创建访问令牌或授权代码之前调用此方法。
     * 可以在这个方法里面修改第三方的Scope
     */
    public function finalizeScopes(
        array $scopes,
        $grantType,
        ClientEntityInterface $clientEntity,
        $userIdentifier = null
    ) {
        // 这里给第三方添加一个email权限
        if ((int) $userIdentifier === 1) {
            $scope = new ScopeEntity();
            $scope->setIdentifier('email');
            $scopes[] = $scope;
        }

        return $scopes;
    }
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
遇强则强,太强另说
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 2

这个demo 例子里面,是不是还有一些 Entity 没有写上来?这个例子跑不通。

1年前 评论

他这个最关键的AccessTokenRepository没有,还是用passport吧

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!