OAuth2.0

资源链接

官方文档
官方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

image.png

如果您授权一台机器访问资源并且您不需要用户的许可来访问所述资源,您应该选择 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


    image.png

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,引导用户登录


image.png

登录成功之后引导用户授权


image.png

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

  • code : Authorization code
  • state : 上面传的state,可验证是否相同
image.png
第二步

第三方发送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 : 访问令牌过期时可刷新
image.png

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 : 访问令牌过期时可刷新
image.png

数据表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;
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容