mirror of
https://github.com/elyby/accounts.git
synced 2024-12-26 15:10:22 +05:30
119a0f8078
Add mock responses related to the Device Code grant.
388 lines
14 KiB
PHP
388 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace api\modules\oauth\models;
|
|
|
|
use api\rbac\Permissions as P;
|
|
use common\components\OAuth2\Entities\UserEntity;
|
|
use common\components\OAuth2\Events\RequestedRefreshToken;
|
|
use common\models\Account;
|
|
use common\models\OauthClient;
|
|
use common\models\OauthSession;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use League\OAuth2\Server\AuthorizationServer;
|
|
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
|
use League\OAuth2\Server\Exception\OAuthServerException;
|
|
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Webmozart\Assert\Assert;
|
|
use Yii;
|
|
|
|
final readonly class OauthProcess {
|
|
|
|
private const array INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [
|
|
P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info',
|
|
P::OBTAIN_ACCOUNT_EMAIL => 'account_email',
|
|
];
|
|
|
|
public function __construct(
|
|
private AuthorizationServer $server,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* A request that should check the passed OAuth2 authorization params and build a response
|
|
* for our frontend application.
|
|
*
|
|
* The input data is the standard GET parameters list according to the OAuth2 standard:
|
|
* $_GET = [
|
|
* client_id,
|
|
* redirect_uri,
|
|
* response_type,
|
|
* scope,
|
|
* state,
|
|
* ];
|
|
*
|
|
* In addition, you can pass the description value to override the application's description.
|
|
*
|
|
* @return array<mixed>
|
|
*/
|
|
public function validate(ServerRequestInterface $request): array {
|
|
try {
|
|
$authRequest = $this->server->validateAuthorizationRequest($request);
|
|
$client = $authRequest->getClient();
|
|
/** @var OauthClient $clientModel */
|
|
$clientModel = $this->findClient($client->getIdentifier());
|
|
$response = $this->buildSuccessResponse($request, $clientModel, $authRequest->getScopes());
|
|
} catch (OAuthServerException $e) {
|
|
$response = $this->buildCompleteErrorResponse($e);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* This method generates authorization_code and a link
|
|
* for the user's further redirect to the client's site.
|
|
*
|
|
* The input data are the same parameters that were necessary for validation request:
|
|
* $_GET = [
|
|
* client_id,
|
|
* redirect_uri,
|
|
* response_type,
|
|
* scope,
|
|
* state,
|
|
* ];
|
|
*
|
|
* Also, the accept field, which shows that the user has clicked on the "Accept" button.
|
|
* If the field is present, it will be interpreted as any value resulting in false positives.
|
|
* Otherwise, the value will be interpreted as "true".
|
|
*
|
|
* @return array<mixed>
|
|
*/
|
|
public function complete(ServerRequestInterface $request): array {
|
|
try {
|
|
Yii::$app->statsd->inc('oauth.complete.attempt');
|
|
|
|
$authRequest = $this->server->validateAuthorizationRequest($request);
|
|
/** @var Account $account */
|
|
$account = Yii::$app->user->identity->getAccount();
|
|
/** @var OauthClient $client */
|
|
$client = $this->findClient($authRequest->getClient()->getIdentifier());
|
|
|
|
$canBeAutoApproved = $this->canBeAutoApproved($account, $client, $authRequest);
|
|
$acceptParam = ((array)$request->getParsedBody())['accept'] ?? null;
|
|
if ($acceptParam === null && !$canBeAutoApproved) {
|
|
Yii::$app->statsd->inc('oauth.complete.approve_required');
|
|
throw $this->createAcceptRequiredException();
|
|
}
|
|
|
|
// At this point if the $acceptParam is an empty, then the application can be auto approved
|
|
$approved = $acceptParam === null || in_array($acceptParam, [1, '1', true, 'true'], true);
|
|
if ($approved) {
|
|
$this->storeOauthSession($account, $client, $authRequest);
|
|
}
|
|
|
|
$authRequest->setUser(new UserEntity($account->id));
|
|
$authRequest->setAuthorizationApproved($approved);
|
|
$response = $this->server->completeAuthorizationRequest($authRequest, new Response(200));
|
|
|
|
$result = [
|
|
'success' => true,
|
|
];
|
|
if ($response->hasHeader('Location')) {
|
|
$result['redirectUri'] = $response->getHeaderLine('Location');
|
|
}
|
|
|
|
Yii::$app->statsd->inc('oauth.complete.success');
|
|
} catch (OAuthServerException $e) {
|
|
if ($e->getErrorType() === 'accept_required') {
|
|
// TODO: revoke access if there previously was an oauth session?
|
|
Yii::$app->statsd->inc('oauth.complete.fail');
|
|
}
|
|
|
|
$result = $this->buildCompleteErrorResponse($e);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* device_code: string,
|
|
* user_code: string,
|
|
* verification_uri: string,
|
|
* interval: int,
|
|
* expires_in: int,
|
|
* }|array{
|
|
* error: string,
|
|
* message: string,
|
|
* }
|
|
*/
|
|
public function deviceCode(ServerRequestInterface $request): array {
|
|
try {
|
|
$response = $this->server->respondToDeviceAuthorizationRequest($request, new Response());
|
|
} catch (OAuthServerException $e) {
|
|
Yii::$app->response->statusCode = $e->getHttpStatusCode();
|
|
return $this->buildIssueErrorResponse($e);
|
|
}
|
|
|
|
Yii::$app->statsd->inc('oauth.deviceCode.initialize');
|
|
|
|
return json_decode((string)$response->getBody(), true);
|
|
}
|
|
|
|
/**
|
|
* The method is executed by the application server to which auth_token or refresh_token was given.
|
|
*
|
|
* Input data is a standard list of POST parameters according to the OAuth2 standard:
|
|
* $_POST = [
|
|
* client_id,
|
|
* client_secret,
|
|
* redirect_uri,
|
|
* code,
|
|
* grant_type,
|
|
* ]
|
|
* for request with grant_type = authentication_code:
|
|
* $_POST = [
|
|
* client_id,
|
|
* client_secret,
|
|
* refresh_token,
|
|
* grant_type,
|
|
* ]
|
|
*
|
|
* @return array<mixed>
|
|
*/
|
|
public function getToken(ServerRequestInterface $request): array {
|
|
$params = (array)$request->getParsedBody();
|
|
$clientId = $params['client_id'] ?? '';
|
|
$grantType = $params['grant_type'] ?? 'null';
|
|
try {
|
|
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
|
|
|
|
$shouldIssueRefreshToken = false;
|
|
$this->server->getEmitter()->subscribeOnceTo(RequestedRefreshToken::class, function() use (&$shouldIssueRefreshToken): void {
|
|
$shouldIssueRefreshToken = true;
|
|
});
|
|
|
|
$response = $this->server->respondToAccessTokenRequest($request, new Response(200));
|
|
/** @noinspection JsonEncodingApiUsageInspection at this point json error is not possible */
|
|
$result = json_decode((string)$response->getBody(), true);
|
|
if ($shouldIssueRefreshToken) {
|
|
// Set the refresh_token field to keep compatibility with the old clients,
|
|
// which will be broken in case when refresh_token field will be missing
|
|
$result['refresh_token'] = $result['access_token'];
|
|
}
|
|
|
|
if (($result['expires_in'] ?? 0) <= 0) {
|
|
if ($shouldIssueRefreshToken || $grantType === 'refresh_token') {
|
|
// Since some of our clients use this field to understand how long the token will live,
|
|
// we have to give it some value. The tokens with zero lifetime don't expire
|
|
// but in order not to break the clients storing the value as integer on 32-bit systems,
|
|
// let's calculate the value based on the unsigned maximum for this type
|
|
$result['expires_in'] = 2 ** 31 - time();
|
|
} else {
|
|
unset($result['expires_in']);
|
|
}
|
|
}
|
|
|
|
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
|
|
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
|
|
} catch (OAuthServerException $e) {
|
|
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail");
|
|
Yii::$app->response->statusCode = $e->getHttpStatusCode();
|
|
|
|
$result = $this->buildIssueErrorResponse($e);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function findClient(string $clientId): ?OauthClient {
|
|
return OauthClient::findOne(['id' => $clientId]);
|
|
}
|
|
|
|
/**
|
|
* The method checks whether the current user can be automatically authorized for the specified client
|
|
* without requesting access to the necessary list of scopes
|
|
*/
|
|
private function canBeAutoApproved(Account $account, OauthClient $client, AuthorizationRequestInterface $request): bool {
|
|
if ($client->is_trusted) {
|
|
return true;
|
|
}
|
|
|
|
$session = $this->findOauthSession($account, $client);
|
|
if ($session === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($session->isRevoked()) {
|
|
return false;
|
|
}
|
|
|
|
return empty(array_diff($this->getScopesList($request), $session->getScopes()));
|
|
}
|
|
|
|
private function storeOauthSession(Account $account, OauthClient $client, AuthorizationRequestInterface $request): void {
|
|
$session = $this->findOauthSession($account, $client);
|
|
if ($session === null) {
|
|
$session = new OauthSession();
|
|
$session->account_id = $account->id;
|
|
$session->client_id = $client->id;
|
|
}
|
|
|
|
$session->scopes = array_unique(array_merge($session->getScopes(), $this->getScopesList($request)));
|
|
$session->last_used_at = time();
|
|
|
|
Assert::true($session->save());
|
|
}
|
|
|
|
/**
|
|
* @param ScopeEntityInterface[] $scopes
|
|
*
|
|
* @return array<mixed>
|
|
*/
|
|
private function buildSuccessResponse(ServerRequestInterface $request, OauthClient $client, array $scopes): array {
|
|
return [
|
|
'success' => true,
|
|
// We return only those keys which are related to the OAuth2 standard parameters
|
|
'oAuth' => array_intersect_key($request->getQueryParams(), array_flip([
|
|
'client_id',
|
|
'redirect_uri',
|
|
'response_type',
|
|
'scope',
|
|
'state',
|
|
'user_code',
|
|
])),
|
|
'client' => [
|
|
'id' => $client->id,
|
|
'name' => $client->name,
|
|
'description' => $request->getQueryParams()['description'] ?? $client->description,
|
|
],
|
|
'session' => [
|
|
'scopes' => $this->buildScopesArray($scopes),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param ScopeEntityInterface[] $scopes
|
|
* @return string[]
|
|
*/
|
|
private function buildScopesArray(array $scopes): array {
|
|
$result = [];
|
|
foreach ($scopes as $scope) {
|
|
$result[] = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope->getIdentifier()] ?? $scope->getIdentifier();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* success: false,
|
|
* error: string,
|
|
* parameter: string|null,
|
|
* statusCode: int,
|
|
* redirectUri?: string,
|
|
* }
|
|
*/
|
|
private function buildCompleteErrorResponse(OAuthServerException $e): array {
|
|
$hint = $e->getPayload()['hint'] ?? '';
|
|
$parameter = null;
|
|
if (preg_match('/the `(\w+)` scope/', $hint, $matches)) {
|
|
$parameter = $matches[1];
|
|
}
|
|
|
|
if ($parameter === null && $hint === 'user_code') {
|
|
$parameter = $hint;
|
|
}
|
|
|
|
$response = [
|
|
'success' => false,
|
|
'error' => $e->getErrorType(),
|
|
'parameter' => $parameter,
|
|
'statusCode' => $e->getHttpStatusCode(),
|
|
];
|
|
|
|
if ($e->hasRedirect()) {
|
|
$response['redirectUri'] = $e->getRedirectUri() . http_build_query($e->getPayload());
|
|
}
|
|
|
|
if ($e->getHttpStatusCode() !== 200) {
|
|
Yii::$app->response->setStatusCode($e->getHttpStatusCode());
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Raw error messages aren't very informative for the end user, as they don't contain
|
|
* information about the parameter that caused the error.
|
|
* This method is intended to build a more understandable description.
|
|
*
|
|
* Part of the existing texts are the legacy from the previous implementation.
|
|
*
|
|
* @return array{
|
|
* error: string,
|
|
* message: string,
|
|
* }
|
|
*/
|
|
private function buildIssueErrorResponse(OAuthServerException $e): array {
|
|
$errorType = $e->getErrorType();
|
|
$message = $e->getMessage();
|
|
$hint = $e->getHint();
|
|
switch ($hint) {
|
|
case 'Invalid redirect URI':
|
|
$errorType = 'invalid_client';
|
|
$message = 'Client authentication failed.';
|
|
break;
|
|
case 'Cannot decrypt the authorization code':
|
|
$message .= ' Check the "code" parameter.';
|
|
break;
|
|
}
|
|
|
|
return [
|
|
'error' => $errorType,
|
|
'message' => $message,
|
|
];
|
|
}
|
|
|
|
private function createAcceptRequiredException(): OAuthServerException {
|
|
return new OAuthServerException('Client must accept authentication request.', 0, 'accept_required', 401);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function getScopesList(AuthorizationRequestInterface $request): array {
|
|
return array_values(array_map(fn(ScopeEntityInterface $scope): string => $scope->getIdentifier(), $request->getScopes()));
|
|
}
|
|
|
|
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
|
private function findOauthSession(Account $account, OauthClient $client): ?OauthSession {
|
|
return $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one();
|
|
}
|
|
|
|
}
|