Laravel Passport——OAuth2 API 認證系統原始碼解析(上)

leoyang發表於2018-01-10

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...

在 Laravel 中,實現基於傳統表單的登陸和授權已經非常簡單,但是如何滿足 API 場景下的授權需求呢?在 API 場景裡通常通過令牌來實現使用者授權,而非維護請求之間的 Session 狀態。在 Laravel 專案中使用 Passport 可以輕而易舉地實現 API 授權認證,Passport 可以在幾分鐘之內為你的應用程式提供完整的 OAuth2 服務端實現。

首先我們可以先了解一下 OAuth2 : 理解OAuth 2.0

可以看出來,OAuth2 的授權模式分為 4 種,相應的 Passport 的授權模式也是 4 中。下面,我們就會逐一進行原始碼分析。

 

Passport 服務的註冊啟動

class PassportServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->registerAuthorizationServer();

        $this->registerResourceServer();

        $this->registerGuard();
    }
}

我們知道 OAuth2 大致由 客戶客戶端認證伺服器資源伺服器 等構成。 在這裡,我們扮演著 認證伺服器資源伺服器 的角色。

認證伺服器註冊

protected function registerAuthorizationServer()
{
    $this->app->singleton(AuthorizationServer::class, function () {
        return tap($this->makeAuthorizationServer(), function ($server) {
            $server->enableGrantType(
                $this->makeAuthCodeGrant(), Passport::tokensExpireIn()
            );

            $server->enableGrantType(
                $this->makeRefreshTokenGrant(), Passport::tokensExpireIn()
            );

            $server->enableGrantType(
                $this->makePasswordGrant(), Passport::tokensExpireIn()
            );

            $server->enableGrantType(
                new PersonalAccessGrant, new DateInterval('P1Y')
            );

            $server->enableGrantType(
                new ClientCredentialsGrant, Passport::tokensExpireIn()
            );

            if (Passport::$implicitGrantEnabled) {
                $server->enableGrantType(
                    $this->makeImplicitGrant(), Passport::tokensExpireIn()
                );
            }
        });
    });
}

AuthorizationServer 認證伺服器是 League OAuth2 server 的一個類,是 League 關於 OAuth2 的實現類。這個認證伺服器類需要 5 個引數,分別代表 客戶端token 令牌scope 作用範圍加密私鑰加密 key

class AuthorizationServer implements EmitterAwareInterface
{
    public function __construct(
        ClientRepositoryInterface $clientRepository,
        AccessTokenRepositoryInterface $accessTokenRepository,
        ScopeRepositoryInterface $scopeRepository,
        $privateKey,
        $encryptionKey,
        ResponseTypeInterface $responseType = null
    ) {
        $this->clientRepository = $clientRepository;
        $this->accessTokenRepository = $accessTokenRepository;
        $this->scopeRepository = $scopeRepository;

        if ($privateKey instanceof CryptKey === false) {
            $privateKey = new CryptKey($privateKey);
        }
        $this->privateKey = $privateKey;
        $this->encryptionKey = $encryptionKey;
        $this->responseType = $responseType;
    }
}

這些不同的 Repository 均是各個介面類,這些類規定了各個部分的功能。Passport 實現了上述幾個介面類:

public function makeAuthorizationServer()
{
    return new AuthorizationServer(
        $this->app->make(Bridge\ClientRepository::class),
        $this->app->make(Bridge\AccessTokenRepository::class),
        $this->app->make(Bridge\ScopeRepository::class),
        $this->makeCryptKey('oauth-private.key'),
        app('encrypter')->getKey()
    );
}

protected function makeCryptKey($key)
{
    return new CryptKey(
        'file://'.Passport::keyPath($key),
        null,
        false
    );
}

oauth-private.key 這個私鑰由 php artisan passport:keys 命令生成。encrypter 的加密 key.env 檔案的 key 屬性。

構建認證伺服器之後,還要對認證伺服器註冊授權方式。 Passport 的授權方式有傳統的 OAuth2 : 授權碼模式密碼模式隱性模式客戶端模式,還有 重新整理令牌模式個人授權模式 等。

protected function makeAuthCodeGrant()
{
    return tap($this->buildAuthCodeGrant(), function ($grant) {
        $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn());
    });
}

protected function buildAuthCodeGrant()
{
    return new AuthCodeGrant(
        $this->app->make(Bridge\AuthCodeRepository::class),
        $this->app->make(Bridge\RefreshTokenRepository::class),
        new DateInterval('PT10M')
    );
}

資源伺服器註冊

類似的, ResourceServer 也是 League 的資源伺服器類:

protected function registerResourceServer()
{
    $this->app->singleton(ResourceServer::class, function () {
        return new ResourceServer(
            $this->app->make(Bridge\AccessTokenRepository::class),
            $this->makeCryptKey('oauth-public.key')
        );
    });
}

guard 註冊

當我們已經構建好 Passport 服務之後,我們只要利用中介軟體 Auth:api 就可以利用 Passport 驗證 api 的合法性。具體的原理是 中介軟體 Auth 的引數 api 是指定 guard 的名稱,例如 webapi,如果呼叫的是 apiguard 那麼就會建立相應的 passport 驅動器:

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],

passportguard 驅動器就是這個 TokenGuard:

protected function registerGuard()
{
    Auth::extend('passport', function ($app, $name, array $config) {
        return tap($this->makeGuard($config), function ($guard) {
            $this->app->refresh('request', $guard, 'setRequest');
        });
    });
}

protected function makeGuard(array $config)
{
    return new RequestGuard(function ($request) use ($config) {
        return (new TokenGuard(
            $this->app->make(ResourceServer::class),
            Auth::createUserProvider($config['provider']),
            $this->app->make(TokenRepository::class),
            $this->app->make(ClientRepository::class),
            $this->app->make('encrypter')
        ))->user($request);
    }, $this->app['request']);
}

 

授權碼模式

授權碼模式大概分為 5 個步驟:

  • 第三方 向我們的伺服器申請建立客戶端。
  • 使用者開啟客戶端以後,客戶端會跳轉到我們的網站授權頁面要求使用者給予授權。
  • 使用者同意給予客戶端授權,我們將會返回 授權碼
  • 客戶端使用上一步獲得的授權,向認證伺服器申請令牌。
  • 客戶端使用令牌,向資源伺服器申請獲取資源。

為何授權碼模式需要如此設定步驟可以檢視:Why is there an “Authorization Code” flow in OAuth2 when “Implicit” flow works so well?OAuth2疑問解答

建立客戶端

在建立客戶端這一步驟,第三方需要提供客戶端名稱與客戶端的 redirect

const data = {
    name: 'Client Name',
    redirect: 'http://example.com/callback'
};

axios.post('/oauth/clients', data)
    .then(response => {
        console.log(response.data);
    })
    .catch (response => {
        // List errors on response...
    });

我們在建立成功之後,會返回此客戶端的 ID 和金鑰。這兩個東西十分重要,是後面幾個步驟必要的引數。

public function forClients()
{
    $this->router->group(['middleware' => ['web', 'auth']], function ($router) {
        $router->get('/clients', [
            'uses' => 'ClientController@forUser',
        ]);

        $router->post('/clients', [
            'uses' => 'ClientController@store',
        ]);

        $router->put('/clients/{client_id}', [
            'uses' => 'ClientController@update',
        ]);

        $router->delete('/clients/{client_id}', [
            'uses' => 'ClientController@destroy',
        ]);
    });
}

public function store(Request $request)
{
    $this->validation->make($request->all(), [
        'name' => 'required|max:255',
        'redirect' => 'required|url',
    ])->validate();

    return $this->clients->create(
        $request->user()->getKey(), $request->name, $request->redirect
    )->makeVisible('secret');
}

public function create($userId, $name, $redirect, $personalAccess = false, $password = false)
{
    $client = (new Client)->forceFill([
        'user_id' => $userId,
        'name' => $name,
        'secret' => str_random(40),
        'redirect' => $redirect,
        'personal_access_client' => $personalAccess,
        'password_client' => $password,
        'revoked' => false,
    ]);

    $client->save();

    return $client;
}

跳轉授權頁面

客戶端建立之後,開發者會使用此客戶端的 ID 和金鑰來請求授權程式碼,並從應用程式訪問令牌。首先,接入應用的使用者向你應用程式的 /oauth/authorize 路由發出重定向請求,

Route::get('/redirect', function () {
    $query = http_build_query([
        'client_id' => 'client-id',
        'redirect_uri' => 'http://example.com/callback',
        'response_type' => 'code',
        'scope' => '',
    ]);

    return redirect('http://your-app.com/oauth/authorize?'.$query);
});

這個連結會訪問我們的授權路由,我們的伺服器會驗證上面的四個引數,考察是否存在這個第三方客戶端,如果驗證通過,將會渲染出我們的授權頁面。

public function forAuthorization()
{
    $this->router->group(['middleware' => ['web', 'auth']], function ($router) {
        $router->get('/authorize', [
            'uses' => 'AuthorizationController@authorize',
        ]);

        $router->post('/authorize', [
            'uses' => 'ApproveAuthorizationController@approve',
        ]);

        $router->delete('/authorize', [
            'uses' => 'DenyAuthorizationController@deny',
        ]);
    });
}

public function authorize(ServerRequestInterface $psrRequest,
                          Request $request,
                          ClientRepository $clients,
                          TokenRepository $tokens)
{
    return $this->withErrorHandling(function () use ($psrRequest, $request, $clients, $tokens) {
        $authRequest = $this->server->validateAuthorizationRequest($psrRequest);

        $scopes = $this->parseScopes($authRequest);

        $token = $tokens->findValidToken(
            $user = $request->user(),
            $client = $clients->find($authRequest->getClient()->getIdentifier())
        );

        if ($token && $token->scopes === collect($scopes)->pluck('id')->all()) {
            return $this->approveRequest($authRequest, $user);
        }

        $request->session()->put('authRequest', $authRequest);

        return $this->response->view('passport::authorize', [
            'client' => $client,
            'user' => $user,
            'scopes' => $scopes,
            'request' => $request,
        ]);
    });
}

這裡最關鍵的就是 validateAuthorizationRequest 這個函式:

public function validateAuthorizationRequest(ServerRequestInterface $request)
{
    foreach ($this->enabledGrantTypes as $grantType) {
        if ($grantType->canRespondToAuthorizationRequest($request)) {
            return $grantType->validateAuthorizationRequest($request);
        }
    }

    throw OAuthServerException::unsupportedGrantType();
}

canRespondToAuthorizationRequest 用於驗證授權模式與引數的 response_type 是否符合。如果確認授權模式正確,那麼接下來就會繼續驗證以下幾項:

  • 客戶端 id
  • redirect 重定向地址
  • scopes 授權範圍
  • state 客戶端狀態

客戶端

public function validateAuthorizationRequest(ServerRequestInterface $request)
{
    $clientId = $this->getQueryStringParameter(
        'client_id',
        $request,
        $this->getServerParameter('PHP_AUTH_USER', $request)
    );
    if (is_null($clientId)) {
        throw OAuthServerException::invalidRequest('client_id');
    }

    $client = $this->clientRepository->getClientEntity(
        $clientId,
        $this->getIdentifier(),
        null,
        false
    );

    if ($client instanceof ClientEntityInterface === false) {
        $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
        throw OAuthServerException::invalidClient();
    }

    ...
}

public function getIdentifier()
{
    return 'authorization_code';
}

客戶端的驗證主要是利用請求中的引數 client_id,我們會從表 oauth_clients 的表中按照 client_id 來取出資料庫記錄:

public function getClientEntity($clientIdentifier, $grantType,
                                    $clientSecret = null, $mustValidateSecret = true)
{
    $record = $this->clients->findActive($clientIdentifier);

    if (! $record || ! $this->handlesGrant($record, $grantType)) {
        return;
    }

    $client = new Client(
        $clientIdentifier, $record->name, $record->redirect
    );

    if ($mustValidateSecret &&
        ! hash_equals($record->secret, (string) $clientSecret)) {
        return;
    }

    return $client;
}

public function findActive($id)
{
    $client = $this->find($id);

    return $client && ! $client->revoked ? $client : null;
}

protected function handlesGrant($record, $grantType)
{
    switch ($grantType) {
        case 'authorization_code':
            return ! $record->firstParty();
        case 'personal_access':
            return $record->personal_access_client;
        case 'password':
            return $record->password_client;
        default:
            return true;
    }
}

在表 oauth_clients 中還有兩個欄位 personal_accesspassword,對於授權碼模式來說這兩個欄位都要求為 0。

重定向地址

public function validateAuthorizationRequest(ServerRequestInterface $request)
{
    ...

    $redirectUri = $this->getQueryStringParameter('redirect_uri', $request);
    if ($redirectUri !== null) {
        if (
            is_string($client->getRedirectUri())
            && (strcmp($client->getRedirectUri(), $redirectUri) !== 0)
        ) {
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
            throw OAuthServerException::invalidClient();
        } elseif (
            is_array($client->getRedirectUri())
            && in_array($redirectUri, $client->getRedirectUri()) === false
        ) {
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
            throw OAuthServerException::invalidClient();
        }
    } elseif (is_array($client->getRedirectUri()) && count($client->getRedirectUri()) !== 1
        || empty($client->getRedirectUri())
    ) {
        $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
        throw OAuthServerException::invalidClient();
    }

    ...

}

這部分驗證引數中的 redirect_uri 是否與資料庫中的重定向地址是否一致。

授權作用域

授權作用域可以讓 API 客戶端在請求賬戶授權時請求特定的許可權。例如,如果你正在構建電子商務應用程式,並不是所有接入的 API 應用都需要下訂單的功能。你可以讓接入的 API 應用只被允許授權訪問訂單發貨狀態。換句話說,作用域允許應用程式的使用者限制第三方應用程式執行的操作。

你可以在 AuthServiceProvider 的 boot 方法中使用 Passport::tokensCan 方法來定義 API 的作用域。tokensCan 方法接受一個作用域名稱、描述的陣列作為引數。作用域描述將會在授權確認頁中直接展示給使用者,你可以將其定義為任何你需要的內容:

Passport::tokensCan([
    'place-orders' => 'Place orders',
    'check-status' => 'Check order status',
]);

public static function tokensCan(array $scopes)
{
    static::$scopes = $scopes;
}

驗證授權作用域的時候,只是在 Passport 中驗證是否存在該授權作用域:

public function validateAuthorizationRequest(ServerRequestInterface $request)
{
    ...

    $scopes = $this->validateScopes(
        $this->getQueryStringParameter('scope', $request, $this->defaultScope),
        is_array($client->getRedirectUri())
            ? $client->getRedirectUri()[0]
            : $client->getRedirectUri()
    );

    ...
}

public function validateScopes($scopes, $redirectUri = null)
{
    $scopesList = array_filter(explode(self::SCOPE_DELIMITER_STRING, trim($scopes)), function ($scope) {
        return !empty($scope);
    });

    $validScopes = [];

    foreach ($scopesList as $scopeItem) {
        $scope = $this->scopeRepository->getScopeEntityByIdentifier($scopeItem);

        if ($scope instanceof ScopeEntityInterface === false) {
            throw OAuthServerException::invalidScope($scopeItem, $redirectUri);
        }

        $validScopes[] = $scope;
    }

    return $validScopes;
}

public function getScopeEntityByIdentifier($identifier)
{
    if (Passport::hasScope($identifier)) {
        return new Scope($identifier);
    }
}

state

這個欄位用於防止 csrf 攻擊的,具體可以檢視 :移花接木:針對OAuth2的CSRF攻擊

public function validateAuthorizationRequest(ServerRequestInterface $request)
{
    $stateParameter = $this->getQueryStringParameter('state', $request);

        $authorizationRequest = new AuthorizationRequest();
        $authorizationRequest->setGrantTypeId($this->getIdentifier());
        $authorizationRequest->setClient($client);
        $authorizationRequest->setRedirectUri($redirectUri);
        $authorizationRequest->setState($stateParameter);
        $authorizationRequest->setScopes($scopes);
}

驗證結束後,接下來就會驗證當前使用者是否已經授權過,如果已經授權過,那麼就會直接返回授權碼,否則就會渲染授權頁面:

public function authorize(ServerRequestInterface $psrRequest,
                              Request $request,
                              ClientRepository $clients,
                              TokenRepository $tokens)
{
    return $this->withErrorHandling(function () use ($psrRequest, $request, $clients, $tokens) {
        $authRequest = $this->server->validateAuthorizationRequest($psrRequest);

        $scopes = $this->parseScopes($authRequest);

        $token = $tokens->findValidToken(
            $user = $request->user(),
            $client = $clients->find($authRequest->getClient()->getIdentifier())
        );

        if ($token && $token->scopes === collect($scopes)->pluck('id')->all()) {
            return $this->approveRequest($authRequest, $user);
        }

        $request->session()->put('authRequest', $authRequest);

        return $this->response->view('passport::authorize', [
            'client' => $client,
            'user' => $user,
            'scopes' => $scopes,
            'request' => $request,
        ]);
    });
}

驗證使用者的是否授權首先是檢視授權作用域是否與資料庫保持一致。由於授權作用域與 token 相互關聯,並非與客戶端相互關聯,所以 scopes 沒有在 oauth_clients 表中,而是在 oauth_access_tokens 這個表中。

protected function parseScopes($authRequest)
{
    return Passport::scopesFor(
        collect($authRequest->getScopes())->map(function ($scope) {
            return $scope->getIdentifier();
        })->all()
    );
}

public static function scopesFor(array $ids)
{
    return collect($ids)->map(function ($id) {
        if (isset(static::$scopes[$id])) {
            return new Scope($id, static::$scopes[$id]);
        }

        return;
    })->filter()->values()->all();
}

可以看到,作用域的 identifier 就是 Scopeid

獲取已授權 token

token 的獲取主要是利用 client_iduser_id 在表 oauth_access_tokens 中查詢符合條件的 token

public function findValidToken($user, $client)
{
    return $client->tokens()
                  ->whereUserId($user->getKey())
                  ->whereRevoked(0)
                  ->where('expires_at', '>', Carbon::now())
                  ->latest('expires_at')
                  ->first();
}

public function tokens()
{
    return $this->hasMany(Token::class, 'client_id');
}

在獲取到有效的 token 之後,並且 token 的作用域符合請求引數,就會立即返回,不需要使用者的重複授權:

protected function approveRequest($authRequest, $user)
{
    $authRequest->setUser(new User($user->getKey()));

    $authRequest->setAuthorizationApproved(true);

    return $this->convertResponse(
        $this->server->completeAuthorizationRequest($authRequest, new Psr7Response)
    );
}

授權成功

使用者點選確認按鈕,授權成功之後,伺服器就會跳轉到客戶端預設的 redirecturi,並且攜帶授權碼等一系列引數

$router->post('/authorize', [
    'uses' => 'ApproveAuthorizationController@approve',
]);

public function approve(Request $request)
{
    return $this->withErrorHandling(function () use ($request) {
        $authRequest = $this->getAuthRequestFromSession($request);

        return $this->convertResponse(
            $this->server->completeAuthorizationRequest($authRequest, new Psr7Response)
        );
    });
}

completeAuthorizationRequest 是授權伺服器的重要步驟:

public function completeAuthorizationRequest(AuthorizationRequest $authRequest, ResponseInterface $response)
{
    return $this->enabledGrantTypes[$authRequest->getGrantTypeId()]
        ->completeAuthorizationRequest($authRequest)
        ->generateHttpResponse($response);
}

public function completeAuthorizationRequest(AuthorizationRequest $authorizationRequest)
{
    if ($authorizationRequest->getUser() instanceof UserEntityInterface === false) {
        throw new \LogicException('An instance of UserEntityInterface should be set on the AuthorizationRequest');
    }

    $finalRedirectUri = ($authorizationRequest->getRedirectUri() === null)
        ? is_array($authorizationRequest->getClient()->getRedirectUri())
            ? $authorizationRequest->getClient()->getRedirectUri()[0]
            : $authorizationRequest->getClient()->getRedirectUri()
        : $authorizationRequest->getRedirectUri();

    // The user approved the client, redirect them back with an auth code
    if ($authorizationRequest->isAuthorizationApproved() === true) {
        $authCode = $this->issueAuthCode(
            $this->authCodeTTL,
            $authorizationRequest->getClient(),
            $authorizationRequest->getUser()->getIdentifier(),
            $authorizationRequest->getRedirectUri(),
            $authorizationRequest->getScopes()
        );

        $payload = [
            'client_id'             => $authCode->getClient()->getIdentifier(),
            'redirect_uri'          => $authCode->getRedirectUri(),
            'auth_code_id'          => $authCode->getIdentifier(),
            'scopes'                => $authCode->getScopes(),
            'user_id'               => $authCode->getUserIdentifier(),
            'expire_time'           => (new \DateTime())->add($this->authCodeTTL)->format('U'),
            'code_challenge'        => $authorizationRequest->getCodeChallenge(),
            'code_challenge_method' => $authorizationRequest->getCodeChallengeMethod(),
        ];

        $response = new RedirectResponse();
        $response->setRedirectUri(
            $this->makeRedirectUri(
                $finalRedirectUri,
                [
                    'code'  => $this->encrypt(
                        json_encode(
                            $payload
                        )
                    ),
                    'state' => $authorizationRequest->getState(),
                ]
            )
        );

        return $response;
    }

    // The user denied the client, redirect them back with an error
    throw OAuthServerException::accessDenied(
        'The user denied the request',
        $this->makeRedirectUri(
            $finalRedirectUri,
            [
                'state' => $authorizationRequest->getState(),
            ]
        )
    );
}

這裡最重要的就是 issueAuthCode 生成授權碼:

protected function issueAuthCode(
    \DateInterval $authCodeTTL,
    ClientEntityInterface $client,
    $userIdentifier,
    $redirectUri,
    array $scopes = []
) {
    $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;

    $authCode = $this->authCodeRepository->getNewAuthCode();
    $authCode->setExpiryDateTime((new \DateTime())->add($authCodeTTL));
    $authCode->setClient($client);
    $authCode->setUserIdentifier($userIdentifier);
    $authCode->setRedirectUri($redirectUri);

    foreach ($scopes as $scope) {
        $authCode->addScope($scope);
    }

    while ($maxGenerationAttempts-- > 0) {
        $authCode->setIdentifier($this->generateUniqueIdentifier());
        try {
            $this->authCodeRepository->persistNewAuthCode($authCode);

            return $authCode;
        } catch (UniqueTokenIdentifierConstraintViolationException $e) {
            if ($maxGenerationAttempts === 0) {
                throw $e;
            }
        }
    }
}

其中 generateUniqueIdentifier 就是生成授權碼的步驟,這個授權碼也是表 oauth_auth_codesid

protected function generateUniqueIdentifier($length = 40)
{
    try {
        return bin2hex(random_bytes($length));
        // @codeCoverageIgnoreStart
    } catch (\TypeError $e) {
        throw OAuthServerException::serverError('An unexpected error has occurred');
    } catch (\Error $e) {
        throw OAuthServerException::serverError('An unexpected error has occurred');
    } catch (\Exception $e) {
        // If you get this message, the CSPRNG failed hard.
        throw OAuthServerException::serverError('Could not generate a random string');
    }
    // @codeCoverageIgnoreEnd
}

public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity)
{
    $this->database->table('oauth_auth_codes')->insert([
        'id' => $authCodeEntity->getIdentifier(),
        'user_id' => $authCodeEntity->getUserIdentifier(),
        'client_id' => $authCodeEntity->getClient()->getIdentifier(),
        'scopes' => $this->formatScopesForStorage($authCodeEntity->getScopes()),
        'revoked' => false,
        'expires_at' => $authCodeEntity->getExpiryDateTime(),
    ]);
}

授權碼轉為令牌

由於 client_id 是公開的,因此上一步授權碼的獲取理論上很容易,真正重要的是授權碼轉為令牌:

Route::get('/callback', function (Request $request) {
    $http = new GuzzleHttp\Client;

    $response = $http->post('http://your-app.com/oauth/token', [
        'form_params' => [
            'grant_type' => 'authorization_code',
            'client_id' => 'client-id',
            'client_secret' => 'client-secret',
            'redirect_uri' => 'http://example.com/callback',
            'code' => $request->code,
        ],
    ]);

    return json_decode((string) $response->getBody(), true);
});

這一步需要客戶端提供註冊時返回的密碼,

public function forAccessTokens()
{
    $this->router->post('/token', [
        'uses' => 'AccessTokenController@issueToken',
        'middleware' => 'throttle',
    ]);
}

public function issueToken(ServerRequestInterface $request)
{
    return $this->withErrorHandling(function () use ($request) {
        return $this->convertResponse(
            $this->server->respondToAccessTokenRequest($request, new Psr7Response)
        );
    });
}

這一步需要驗證的東西非常繁多,我們分部分來看:

客戶端驗證

客戶端驗證主要校驗 client_idclient_secretredirect_uri :

public function respondToAccessTokenRequest(
    ServerRequestInterface $request,
    ResponseTypeInterface $responseType,
    \DateInterval $accessTokenTTL
) {

    $client = $this->validateClient($request);

    ...

}

protected function validateClient(ServerRequestInterface $request)
{
    list($basicAuthUser, $basicAuthPassword) = $this->getBasicAuthCredentials($request);

    $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser);

    // If the client is confidential require the client secret
    $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword);

    $client = $this->clientRepository->getClientEntity(
        $clientId,
        $this->getIdentifier(),
        $clientSecret,
        true
    );

    $redirectUri = $this->getRequestParameter('redirect_uri', $request, null);

    return $client;
}

protected function getBasicAuthCredentials(ServerRequestInterface $request)
{
    if (!$request->hasHeader('Authorization')) {
        return [null, null];
    }

    $header = $request->getHeader('Authorization')[0];
    if (strpos($header, 'Basic ') !== 0) {
        return [null, null];
    }

    if (!($decoded = base64_decode(substr($header, 6)))) {
        return [null, null];
    }

    if (strpos($decoded, ':') === false) {
        return [null, null]; // HTTP Basic header without colon isn't valid
    }

    return explode(':', $decoded, 2);
}

驗證授權碼

客戶端的密碼驗證通過後,就會開始驗證授權碼,授權碼的驗證主要涉及 expire_timeclient_idauth_code_id:

public function respondToAccessTokenRequest(
    ServerRequestInterface $request,
    ResponseTypeInterface $responseType,
    \DateInterval $accessTokenTTL
) {

    ...

    $encryptedAuthCode = $this->getRequestParameter('code', $request, null);

    if ($encryptedAuthCode === null) {
        throw OAuthServerException::invalidRequest('code');
    }

    try {
        $authCodePayload = json_decode($this->decrypt($encryptedAuthCode));
        if (time() > $authCodePayload->expire_time) {
            throw OAuthServerException::invalidRequest('code', 'Authorization code has expired');
        }

        if ($this->authCodeRepository->isAuthCodeRevoked($authCodePayload->auth_code_id) === true) {
            throw OAuthServerException::invalidRequest('code', 'Authorization code has been revoked');
        }

        if ($authCodePayload->client_id !== $client->getIdentifier()) {
            throw OAuthServerException::invalidRequest('code', 'Authorization code was not issued to this client');
        }

        // The redirect URI is required in this request
        $redirectUri = $this->getRequestParameter('redirect_uri', $request, null);
        if (empty($authCodePayload->redirect_uri) === false && $redirectUri === null) {
            throw OAuthServerException::invalidRequest('redirect_uri');
        }

        if ($authCodePayload->redirect_uri !== $redirectUri) {
            throw OAuthServerException::invalidRequest('redirect_uri', 'Invalid redirect URI');
        }

    } catch (\LogicException  $e) {
        throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code');
    }
}

public function isAuthCodeRevoked($codeId)
{
    return $this->database->table('oauth_auth_codes')
                ->where('id', $codeId)->where('revoked', 1)->exists();
}

發放令牌

令牌的發放主要是 access_tokenrefresh_token,並且取消相關的授權碼:

public function respondToAccessTokenRequest(
    ServerRequestInterface $request,
    ResponseTypeInterface $responseType,
    \DateInterval $accessTokenTTL
) {

    // Issue and persist access + refresh tokens
    $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $authCodePayload->user_id, $scopes);
    $refreshToken = $this->issueRefreshToken($accessToken);

    // Inject tokens into response type
    $responseType->setAccessToken($accessToken);
    $responseType->setRefreshToken($refreshToken);

    // Revoke used auth code
    $this->authCodeRepository->revokeAuthCode($authCodePayload->auth_code_id);

    return $responseType;
}

首先需要生成 access_token,之後再對錶 oauth_access_tokens 持久化 access_token

protected function issueAccessToken(
    \DateInterval $accessTokenTTL,
    ClientEntityInterface $client,
    $userIdentifier,
    array $scopes = []
) {
    $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;

    $accessToken = $this->accessTokenRepository->getNewToken($client, $scopes, $userIdentifier);
    $accessToken->setClient($client);
    $accessToken->setUserIdentifier($userIdentifier);
    $accessToken->setExpiryDateTime((new \DateTime())->add($accessTokenTTL));

    foreach ($scopes as $scope) {
        $accessToken->addScope($scope);
    }

    while ($maxGenerationAttempts-- > 0) {
        $accessToken->setIdentifier($this->generateUniqueIdentifier());
        try {
            $this->accessTokenRepository->persistNewAccessToken($accessToken);

            return $accessToken;
        } catch (UniqueTokenIdentifierConstraintViolationException $e) {
            if ($maxGenerationAttempts === 0) {
                throw $e;
            }
        }
    }
}

public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null)
{
    return new AccessToken($userIdentifier, $scopes);
}

protected function generateUniqueIdentifier($length = 40)
{
    try {
        return bin2hex(random_bytes($length));
        // @codeCoverageIgnoreStart
    } catch (\TypeError $e) {
        throw OAuthServerException::serverError('An unexpected error has occurred');
    } catch (\Error $e) {
        throw OAuthServerException::serverError('An unexpected error has occurred');
    } catch (\Exception $e) {
        // If you get this message, the CSPRNG failed hard.
        throw OAuthServerException::serverError('Could not generate a random string');
    }
    // @codeCoverageIgnoreEnd
}

public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity)
{
    $this->tokenRepository->create([
        'id' => $accessTokenEntity->getIdentifier(),
        'user_id' => $accessTokenEntity->getUserIdentifier(),
        'client_id' => $accessTokenEntity->getClient()->getIdentifier(),
        'scopes' => $this->scopesToArray($accessTokenEntity->getScopes()),
        'revoked' => false,
        'created_at' => new DateTime,
        'updated_at' => new DateTime,
        'expires_at' => $accessTokenEntity->getExpiryDateTime(),
    ]);

    $this->events->dispatch(new AccessTokenCreated(
        $accessTokenEntity->getIdentifier(),
        $accessTokenEntity->getUserIdentifier(),
        $accessTokenEntity->getClient()->getIdentifier()
    ));
}

類似地,還有生成 refresh_token

protected function issueRefreshToken(AccessTokenEntityInterface $accessToken)
{
    $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;

    $refreshToken = $this->refreshTokenRepository->getNewRefreshToken();
    $refreshToken->setExpiryDateTime((new \DateTime())->add($this->refreshTokenTTL));
    $refreshToken->setAccessToken($accessToken);

    while ($maxGenerationAttempts-- > 0) {
        $refreshToken->setIdentifier($this->generateUniqueIdentifier());
        try {
            $this->refreshTokenRepository->persistNewRefreshToken($refreshToken);

            return $refreshToken;
        } catch (UniqueTokenIdentifierConstraintViolationException $e) {
            if ($maxGenerationAttempts === 0) {
                throw $e;
            }
        }
    }
}

BearerToken

為了加強安全性,根據 OAuth2 規範,access_tokenrefresh_token 需要利用 Bearer Token 的方式給出,access token 會被轉化為 JWTrefresh token 會被加密:

public function generateHttpResponse(ResponseInterface $response)
{
    $expireDateTime = $this->accessToken->getExpiryDateTime()->getTimestamp();

    $jwtAccessToken = $this->accessToken->convertToJWT($this->privateKey);

    $responseParams = [
        'token_type'   => 'Bearer',
        'expires_in'   => $expireDateTime - (new \DateTime())->getTimestamp(),
        'access_token' => (string) $jwtAccessToken,
    ];
I 
    if ($this->refreshToken instanceof RefreshTokenEntityInterface) {
        $refreshToken = $this->encrypt(
            json_encode(
                [
                    'client_id'        => $this->accessToken->getClient()->getIdentifier(),
                    'refresh_token_id' => $this->refreshToken->getIdentifier(),
                    'access_token_id'  => $this->accessToken->getIdentifier(),
                    'scopes'           => $this->accessToken->getScopes(),
                    'user_id'          => $this->accessToken->getUserIdentifier(),
                    'expire_time'      => $this->refreshToken->getExpiryDateTime()->getTimestamp(),
                ]
            )
        );

        $responseParams['refresh_token'] = $refreshToken;
    }

    $responseParams = array_merge($this->getExtraParams($this->accessToken), $responseParams);

    $response = $response
        ->withStatus(200)
        ->withHeader('pragma', 'no-cache')
        ->withHeader('cache-control', 'no-store')
        ->withHeader('content-type', 'application/json; charset=UTF-8');

    $response->getBody()->write(json_encode($responseParams));

    return $response;
}

重新整理令牌

如果你的應用程式發放了短期的訪問令牌,使用者將需要通過在發出訪問令牌時提供給他們的重新整理令牌來重新整理其訪問令牌。該申請的 url 與申請令牌的連結相同,僅僅 grant_type 不同:

$response = $http->post('http://your-app.com/oauth/token', [
    'form_params' => [
        'grant_type' => 'refresh_token',
        'refresh_token' => 'the-refresh-token',
        'client_id' => 'client-id',
        'client_secret' => 'client-secret',
        'scope' => '',
    ],
]);

return json_decode((string) $response->getBody(), true);
public function respondToAccessTokenRequest(
    ServerRequestInterface $request,
    ResponseTypeInterface $responseType,
    \DateInterval $accessTokenTTL
) {
    // Validate request
    $client = $this->validateClient($request);
    $oldRefreshToken = $this->validateOldRefreshToken($request, $client->getIdentifier());
    $scopes = $this->validateScopes($this->getRequestParameter(
        'scope',
        $request,
        implode(self::SCOPE_DELIMITER_STRING, $oldRefreshToken['scopes']))
    );

    // The OAuth spec says that a refreshed access token can have the original scopes or fewer so ensure
    // the request doesn't include any new scopes
    foreach ($scopes as $scope) {
        if (in_array($scope->getIdentifier(), $oldRefreshToken['scopes']) === false) {
            throw OAuthServerException::invalidScope($scope->getIdentifier());
        }
    }

    // Expire old tokens
    $this->accessTokenRepository->revokeAccessToken($oldRefreshToken['access_token_id']);
    $this->refreshTokenRepository->revokeRefreshToken($oldRefreshToken['refresh_token_id']);

    // Issue and persist new tokens
    $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $oldRefreshToken['user_id'], $scopes);
    $refreshToken = $this->issueRefreshToken($accessToken);

    // Inject tokens into response
    $responseType->setAccessToken($accessToken);
    $responseType->setRefreshToken($refreshToken);

    return $responseType;
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章