mirror of
https://github.com/elyby/accounts.git
synced 2025-05-31 14:11:46 +05:30
Implementation of the backend for the OAuth2 clients management
This commit is contained in:
30
api/modules/oauth/models/ApplicationType.php
Normal file
30
api/modules/oauth/models/ApplicationType.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use common\helpers\Error as E;
|
||||
use common\models\OauthClient;
|
||||
use yii\helpers\ArrayHelper;
|
||||
|
||||
class ApplicationType extends BaseOauthClientType {
|
||||
|
||||
public $description;
|
||||
|
||||
public $redirectUri;
|
||||
|
||||
public function rules(): array {
|
||||
return ArrayHelper::merge(parent::rules(), [
|
||||
['redirectUri', 'required', 'message' => E::REDIRECT_URI_REQUIRED],
|
||||
['redirectUri', 'url', 'validSchemes' => ['[\w]+'], 'message' => E::REDIRECT_URI_INVALID],
|
||||
['description', 'string'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function applyToClient(OauthClient $client): void {
|
||||
parent::applyToClient($client);
|
||||
$client->description = $this->description;
|
||||
$client->redirect_uri = $this->redirectUri;
|
||||
}
|
||||
|
||||
}
|
40
api/modules/oauth/models/BaseOauthClientType.php
Normal file
40
api/modules/oauth/models/BaseOauthClientType.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\models\base\ApiForm;
|
||||
use common\helpers\Error as E;
|
||||
use common\models\OauthClient;
|
||||
|
||||
abstract class BaseOauthClientType extends ApiForm implements OauthClientTypeForm {
|
||||
|
||||
public $name;
|
||||
|
||||
public $websiteUrl;
|
||||
|
||||
public function rules(): array {
|
||||
return [
|
||||
['name', 'required', 'message' => E::NAME_REQUIRED],
|
||||
['websiteUrl', 'url', 'message' => E::WEBSITE_URL_INVALID],
|
||||
];
|
||||
}
|
||||
|
||||
public function load($data, $formName = null): bool {
|
||||
return parent::load($data, $formName);
|
||||
}
|
||||
|
||||
public function validate($attributeNames = null, $clearErrors = true): bool {
|
||||
return parent::validate($attributeNames, $clearErrors);
|
||||
}
|
||||
|
||||
public function getValidationErrors(): array {
|
||||
return $this->getFirstErrors();
|
||||
}
|
||||
|
||||
public function applyToClient(OauthClient $client): void {
|
||||
$client->name = $this->name;
|
||||
$client->website_url = $this->websiteUrl;
|
||||
}
|
||||
|
||||
}
|
30
api/modules/oauth/models/IdentityInfo.php
Normal file
30
api/modules/oauth/models/IdentityInfo.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\models\base\BaseAccountForm;
|
||||
use api\modules\accounts\models\AccountInfo;
|
||||
use common\models\Account;
|
||||
|
||||
class IdentityInfo extends BaseAccountForm {
|
||||
|
||||
private $model;
|
||||
|
||||
public function __construct(Account $account, array $config = []) {
|
||||
parent::__construct($account, $config);
|
||||
$this->model = new AccountInfo($account);
|
||||
}
|
||||
|
||||
public function info(): array {
|
||||
$response = $this->model->info();
|
||||
|
||||
$response['profileLink'] = $response['elyProfileLink'];
|
||||
unset($response['elyProfileLink']);
|
||||
$response['preferredLanguage'] = $response['lang'];
|
||||
unset($response['lang']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
26
api/modules/oauth/models/MinecraftServerType.php
Normal file
26
api/modules/oauth/models/MinecraftServerType.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use common\helpers\Error as E;
|
||||
use common\models\OauthClient;
|
||||
use common\validators\MinecraftServerAddressValidator;
|
||||
use yii\helpers\ArrayHelper;
|
||||
|
||||
class MinecraftServerType extends BaseOauthClientType {
|
||||
|
||||
public $minecraftServerIp;
|
||||
|
||||
public function rules(): array {
|
||||
return ArrayHelper::merge(parent::rules(), [
|
||||
['minecraftServerIp', MinecraftServerAddressValidator::class, 'message' => E::MINECRAFT_SERVER_IP_INVALID],
|
||||
]);
|
||||
}
|
||||
|
||||
public function applyToClient(OauthClient $client): void {
|
||||
parent::applyToClient($client);
|
||||
$client->minecraft_server_ip = $this->minecraftServerIp;
|
||||
}
|
||||
|
||||
}
|
96
api/modules/oauth/models/OauthClientForm.php
Normal file
96
api/modules/oauth/models/OauthClientForm.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\exceptions\ThisShouldNotHappenException;
|
||||
use api\modules\oauth\exceptions\InvalidOauthClientState;
|
||||
use common\models\OauthClient;
|
||||
use common\tasks\ClearOauthSessions;
|
||||
use Yii;
|
||||
use yii\helpers\Inflector;
|
||||
|
||||
class OauthClientForm {
|
||||
|
||||
/**
|
||||
* @var OauthClient
|
||||
*/
|
||||
private $client;
|
||||
|
||||
public function __construct(OauthClient $client) {
|
||||
if ($client->type === null) {
|
||||
throw new InvalidOauthClientState('client\'s type field must be set');
|
||||
}
|
||||
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
public function getClient(): OauthClient {
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function save(OauthClientTypeForm $form): bool {
|
||||
if (!$form->validate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$client = $this->getClient();
|
||||
$form->applyToClient($client);
|
||||
|
||||
if ($client->isNewRecord) {
|
||||
$baseId = $id = substr(Inflector::slug($client->name), 0, 250);
|
||||
$i = 0;
|
||||
while ($this->isClientExists($id)) {
|
||||
$id = $baseId . ++$i;
|
||||
}
|
||||
|
||||
$client->id = $id;
|
||||
$client->generateSecret();
|
||||
}
|
||||
|
||||
if (!$client->save()) {
|
||||
throw new ThisShouldNotHappenException('Cannot save oauth client');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(): bool {
|
||||
$transaction = Yii::$app->db->beginTransaction();
|
||||
|
||||
$client = $this->client;
|
||||
$client->is_deleted = true;
|
||||
if (!$client->save()) {
|
||||
throw new ThisShouldNotHappenException('Cannot update oauth client');
|
||||
}
|
||||
|
||||
Yii::$app->queue->push(ClearOauthSessions::createFromOauthClient($client));
|
||||
|
||||
$transaction->commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function reset(bool $regenerateSecret = false): bool {
|
||||
$transaction = Yii::$app->db->beginTransaction();
|
||||
|
||||
$client = $this->client;
|
||||
if ($regenerateSecret) {
|
||||
$client->generateSecret();
|
||||
if (!$client->save()) {
|
||||
throw new ThisShouldNotHappenException('Cannot update oauth client');
|
||||
}
|
||||
}
|
||||
|
||||
Yii::$app->queue->push(ClearOauthSessions::createFromOauthClient($client, time()));
|
||||
|
||||
$transaction->commit();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isClientExists(string $id): bool {
|
||||
return OauthClient::find()->andWhere(['id' => $id])->exists();
|
||||
}
|
||||
|
||||
}
|
37
api/modules/oauth/models/OauthClientFormFactory.php
Normal file
37
api/modules/oauth/models/OauthClientFormFactory.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\modules\oauth\exceptions\UnsupportedOauthClientType;
|
||||
use common\models\OauthClient;
|
||||
|
||||
class OauthClientFormFactory {
|
||||
|
||||
/**
|
||||
* @param OauthClient $client
|
||||
*
|
||||
* @return OauthClientTypeForm
|
||||
* @throws UnsupportedOauthClientType
|
||||
*/
|
||||
public static function create(OauthClient $client): OauthClientTypeForm {
|
||||
switch ($client->type) {
|
||||
case OauthClient::TYPE_APPLICATION:
|
||||
return new ApplicationType([
|
||||
'name' => $client->name,
|
||||
'websiteUrl' => $client->website_url,
|
||||
'description' => $client->description,
|
||||
'redirectUri' => $client->redirect_uri,
|
||||
]);
|
||||
case OauthClient::TYPE_MINECRAFT_SERVER:
|
||||
return new MinecraftServerType([
|
||||
'name' => $client->name,
|
||||
'websiteUrl' => $client->website_url,
|
||||
'minecraftServerIp' => $client->minecraft_server_ip,
|
||||
]);
|
||||
}
|
||||
|
||||
throw new UnsupportedOauthClientType($client->type);
|
||||
}
|
||||
|
||||
}
|
18
api/modules/oauth/models/OauthClientTypeForm.php
Normal file
18
api/modules/oauth/models/OauthClientTypeForm.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use common\models\OauthClient;
|
||||
|
||||
interface OauthClientTypeForm {
|
||||
|
||||
public function load($data): bool;
|
||||
|
||||
public function validate(): bool;
|
||||
|
||||
public function getValidationErrors(): array;
|
||||
|
||||
public function applyToClient(OauthClient $client): void;
|
||||
|
||||
}
|
271
api/modules/oauth/models/OauthProcess.php
Normal file
271
api/modules/oauth/models/OauthProcess.php
Normal file
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\components\OAuth2\Exception\AcceptRequiredException;
|
||||
use api\components\OAuth2\Exception\AccessDeniedException;
|
||||
use api\components\OAuth2\Grants\AuthCodeGrant;
|
||||
use api\components\OAuth2\Grants\AuthorizeParams;
|
||||
use common\models\Account;
|
||||
use common\models\OauthClient;
|
||||
use common\rbac\Permissions as P;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use League\OAuth2\Server\Exception\InvalidGrantException;
|
||||
use League\OAuth2\Server\Exception\OAuthException;
|
||||
use League\OAuth2\Server\Grant\GrantTypeInterface;
|
||||
use Yii;
|
||||
use yii\helpers\ArrayHelper;
|
||||
|
||||
class OauthProcess {
|
||||
|
||||
private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [
|
||||
P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info',
|
||||
P::OBTAIN_ACCOUNT_EMAIL => 'account_email',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var AuthorizationServer
|
||||
*/
|
||||
private $server;
|
||||
|
||||
public function __construct(AuthorizationServer $server) {
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрос, который должен проверить переданные параметры oAuth авторизации
|
||||
* и сформировать ответ для нашего приложения на фронте
|
||||
*
|
||||
* Входными данными является стандартный список GET параметров по стандарту oAuth:
|
||||
* $_GET = [
|
||||
* client_id,
|
||||
* redirect_uri,
|
||||
* response_type,
|
||||
* scope,
|
||||
* state,
|
||||
* ];
|
||||
*
|
||||
* Кроме того можно передать значения description для переопределения описания приложения.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function validate(): array {
|
||||
try {
|
||||
$authParams = $this->getAuthorizationCodeGrant()->checkAuthorizeParams();
|
||||
$client = $authParams->getClient();
|
||||
/** @var OauthClient $clientModel */
|
||||
$clientModel = $this->findClient($client->getId());
|
||||
$response = $this->buildSuccessResponse(
|
||||
Yii::$app->request->getQueryParams(),
|
||||
$clientModel,
|
||||
$authParams->getScopes()
|
||||
);
|
||||
} catch (OAuthException $e) {
|
||||
$response = $this->buildErrorResponse($e);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Метод выполняется генерацию авторизационного кода (authorization_code) и формирование
|
||||
* ссылки для дальнейшнешл редиректа пользователя назад на сайт клиента
|
||||
*
|
||||
* Входными данными является всё те же параметры, что были необходимы для валидации:
|
||||
* $_GET = [
|
||||
* client_id,
|
||||
* redirect_uri,
|
||||
* response_type,
|
||||
* scope,
|
||||
* state,
|
||||
* ];
|
||||
*
|
||||
* А также поле accept, которое показывает, что пользователь нажал на кнопку "Принять".
|
||||
* Если поле присутствует, то оно будет интерпретироваться как любое приводимое к false значение.
|
||||
* В ином случае, значение будет интерпретировано, как положительный исход.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function complete(): array {
|
||||
try {
|
||||
Yii::$app->statsd->inc('oauth.complete.attempt');
|
||||
$grant = $this->getAuthorizationCodeGrant();
|
||||
$authParams = $grant->checkAuthorizeParams();
|
||||
/** @var Account $account */
|
||||
$account = Yii::$app->user->identity->getAccount();
|
||||
/** @var \common\models\OauthClient $clientModel */
|
||||
$clientModel = $this->findClient($authParams->getClient()->getId());
|
||||
|
||||
if (!$this->canAutoApprove($account, $clientModel, $authParams)) {
|
||||
Yii::$app->statsd->inc('oauth.complete.approve_required');
|
||||
$isAccept = Yii::$app->request->post('accept');
|
||||
if ($isAccept === null) {
|
||||
throw new AcceptRequiredException();
|
||||
}
|
||||
|
||||
if (!$isAccept) {
|
||||
throw new AccessDeniedException($authParams->getRedirectUri());
|
||||
}
|
||||
}
|
||||
|
||||
$redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams);
|
||||
$response = [
|
||||
'success' => true,
|
||||
'redirectUri' => $redirectUri,
|
||||
];
|
||||
Yii::$app->statsd->inc('oauth.complete.success');
|
||||
} catch (OAuthException $e) {
|
||||
if (!$e instanceof AcceptRequiredException) {
|
||||
Yii::$app->statsd->inc('oauth.complete.fail');
|
||||
}
|
||||
|
||||
$response = $this->buildErrorResponse($e);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Метод выполняется сервером приложения, которому был выдан auth_token или refresh_token.
|
||||
*
|
||||
* Входными данными является стандартный список POST параметров по стандарту oAuth:
|
||||
* $_POST = [
|
||||
* client_id,
|
||||
* client_secret,
|
||||
* redirect_uri,
|
||||
* code,
|
||||
* grant_type,
|
||||
* ]
|
||||
* для запроса grant_type = authentication_code.
|
||||
* $_POST = [
|
||||
* client_id,
|
||||
* client_secret,
|
||||
* refresh_token,
|
||||
* grant_type,
|
||||
* ]
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getToken(): array {
|
||||
$grantType = Yii::$app->request->post('grant_type', 'null');
|
||||
try {
|
||||
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
|
||||
$response = $this->server->issueAccessToken();
|
||||
$clientId = Yii::$app->request->post('client_id');
|
||||
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
|
||||
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
|
||||
} catch (OAuthException $e) {
|
||||
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail");
|
||||
Yii::$app->response->statusCode = $e->httpStatusCode;
|
||||
$response = [
|
||||
'error' => $e->errorType,
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function findClient(string $clientId): ?OauthClient {
|
||||
return OauthClient::findOne($clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Метод проверяет, может ли текущий пользователь быть автоматически авторизован
|
||||
* для указанного клиента без запроса доступа к необходимому списку прав
|
||||
*
|
||||
* @param Account $account
|
||||
* @param OauthClient $client
|
||||
* @param AuthorizeParams $oauthParams
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function canAutoApprove(Account $account, OauthClient $client, AuthorizeParams $oauthParams): bool {
|
||||
if ($client->is_trusted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var \common\models\OauthSession|null $session */
|
||||
$session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one();
|
||||
if ($session !== null) {
|
||||
$existScopes = $session->getScopes()->members();
|
||||
if (empty(array_diff(array_keys($oauthParams->getScopes()), $existScopes))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $queryParams
|
||||
* @param OauthClient $client
|
||||
* @param \api\components\OAuth2\Entities\ScopeEntity[] $scopes
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function buildSuccessResponse(array $queryParams, OauthClient $client, array $scopes): array {
|
||||
return [
|
||||
'success' => true,
|
||||
// Возвращаем только те ключи, которые имеют реальное отношение к oAuth параметрам
|
||||
'oAuth' => array_intersect_key($queryParams, array_flip([
|
||||
'client_id',
|
||||
'redirect_uri',
|
||||
'response_type',
|
||||
'scope',
|
||||
'state',
|
||||
])),
|
||||
'client' => [
|
||||
'id' => $client->id,
|
||||
'name' => $client->name,
|
||||
'description' => ArrayHelper::getValue($queryParams, 'description', $client->description),
|
||||
],
|
||||
'session' => [
|
||||
'scopes' => $this->fixScopesNames(array_keys($scopes)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function fixScopesNames(array $scopes): array {
|
||||
foreach ($scopes as &$scope) {
|
||||
if (isset(self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope])) {
|
||||
$scope = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope];
|
||||
}
|
||||
}
|
||||
|
||||
return $scopes;
|
||||
}
|
||||
|
||||
private function buildErrorResponse(OAuthException $e): array {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $e->errorType,
|
||||
'parameter' => $e->parameter,
|
||||
'statusCode' => $e->httpStatusCode,
|
||||
];
|
||||
|
||||
if ($e->shouldRedirect()) {
|
||||
$response['redirectUri'] = $e->getRedirectUri();
|
||||
}
|
||||
|
||||
if ($e->httpStatusCode !== 200) {
|
||||
Yii::$app->response->setStatusCode($e->httpStatusCode);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function getGrant(string $grantType = null): GrantTypeInterface {
|
||||
return $this->server->getGrantType($grantType ?? Yii::$app->request->get('grant_type'));
|
||||
}
|
||||
|
||||
private function getAuthorizationCodeGrant(): AuthCodeGrant {
|
||||
/** @var GrantTypeInterface $grantType */
|
||||
$grantType = $this->getGrant('authorization_code');
|
||||
if (!$grantType instanceof AuthCodeGrant) {
|
||||
throw new InvalidGrantException('authorization_code grant have invalid realisation');
|
||||
}
|
||||
|
||||
return $grantType;
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user