Implementation of the backend for the OAuth2 clients management

This commit is contained in:
ErickSkrauch
2018-02-28 01:27:35 +03:00
parent ddec87e3a9
commit 673429e577
55 changed files with 1810 additions and 65 deletions

View File

@@ -0,0 +1,10 @@
<?php
namespace api\modules\oauth;
use yii\base\Module as BaseModule;
class Module extends BaseModule {
public $id = 'oauth';
}

View File

@@ -0,0 +1,61 @@
<?php
namespace api\modules\oauth\controllers;
use api\controllers\Controller;
use api\modules\oauth\models\OauthProcess;
use common\rbac\Permissions as P;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
class AuthorizationController extends Controller {
public function behaviors(): array {
return ArrayHelper::merge(Controller::behaviors(), [
'authenticator' => [
'only' => ['complete'],
],
'access' => [
'class' => AccessControl::class,
'only' => ['complete'],
'rules' => [
[
'allow' => true,
'actions' => ['complete'],
'roles' => [P::COMPLETE_OAUTH_FLOW],
'roleParams' => function() {
return [
'accountId' => Yii::$app->user->identity->getAccount()->id,
];
},
],
],
],
]);
}
public function verbs(): array {
return [
'validate' => ['GET'],
'complete' => ['POST'],
'token' => ['POST'],
];
}
public function actionValidate(): array {
return $this->createOauthProcess()->validate();
}
public function actionComplete(): array {
return $this->createOauthProcess()->complete();
}
public function actionToken(): array {
return $this->createOauthProcess()->getToken();
}
private function createOauthProcess(): OauthProcess {
return new OauthProcess(Yii::$app->oauth->authServer);
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace api\modules\oauth\controllers;
use api\controllers\Controller;
use api\exceptions\ThisShouldNotHappenException;
use api\modules\oauth\exceptions\UnsupportedOauthClientType;
use api\modules\oauth\models\OauthClientForm;
use api\modules\oauth\models\OauthClientFormFactory;
use api\modules\oauth\models\OauthClientTypeForm;
use common\models\Account;
use common\models\OauthClient;
use common\rbac\Permissions as P;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
use yii\web\NotFoundHttpException;
class ClientsController extends Controller {
public function behaviors(): array {
return ArrayHelper::merge(parent::behaviors(), [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'actions' => ['create'],
'allow' => true,
'permissions' => [P::CREATE_OAUTH_CLIENTS],
],
[
'actions' => ['update', 'delete', 'reset'],
'allow' => true,
'permissions' => [P::MANAGE_OAUTH_CLIENTS],
'roleParams' => function() {
return [
'clientId' => Yii::$app->request->get('clientId'),
];
},
],
[
'actions' => ['get'],
'allow' => true,
'permissions' => [P::VIEW_OAUTH_CLIENTS],
'roleParams' => function() {
return [
'clientId' => Yii::$app->request->get('clientId'),
];
},
],
[
'actions' => ['get-per-account'],
'allow' => true,
'permissions' => [P::VIEW_OAUTH_CLIENTS],
'roleParams' => function() {
return [
'accountId' => Yii::$app->request->get('accountId'),
];
},
],
],
],
]);
}
public function actionGet(string $clientId): array {
return $this->formatClient($this->findOauthClient($clientId));
}
public function actionCreate(string $type): array {
$account = Yii::$app->user->identity->getAccount();
if ($account === null) {
throw new ThisShouldNotHappenException('This form should not to be executed without associated account');
}
$client = new OauthClient();
$client->account_id = $account->id;
$client->type = $type;
$requestModel = $this->createForm($client);
$requestModel->load(Yii::$app->request->post());
$form = new OauthClientForm($client);
if (!$form->save($requestModel)) {
return [
'success' => false,
'errors' => $requestModel->getValidationErrors(),
];
}
return [
'success' => true,
'data' => $this->formatClient($client),
];
}
public function actionUpdate(string $clientId): array {
$client = $this->findOauthClient($clientId);
$requestModel = $this->createForm($client);
$requestModel->load(Yii::$app->request->post());
$form = new OauthClientForm($client);
if (!$form->save($requestModel)) {
return [
'success' => false,
'errors' => $requestModel->getValidationErrors(),
];
}
return [
'success' => true,
'data' => $this->formatClient($client),
];
}
public function actionDelete(string $clientId): array {
$client = $this->findOauthClient($clientId);
(new OauthClientForm($client))->delete();
return [
'success' => true,
];
}
public function actionReset(string $clientId, string $regenerateSecret = null): array {
$client = $this->findOauthClient($clientId);
$form = new OauthClientForm($client);
$form->reset($regenerateSecret !== null);
return [
'success' => true,
'data' => $this->formatClient($client),
];
}
public function actionGetPerAccount(int $accountId): array {
/** @var Account|null $account */
$account = Account::findOne(['id' => $accountId]);
if ($account === null) {
throw new NotFoundHttpException();
}
$clients = $account->oauthClients;
$result = array_map(function(OauthClient $client) {
return $this->formatClient($client);
}, $clients);
return $result;
}
private function formatClient(OauthClient $client): array {
$result = [
'clientId' => $client->id,
'clientSecret' => $client->secret,
'type' => $client->type,
'name' => $client->name,
'websiteUrl' => $client->website_url,
'createdAt' => $client->created_at,
'countUsers' => (int)$client->getSessions()->count(),
];
switch ($client->type) {
case OauthClient::TYPE_APPLICATION:
$result['description'] = $client->description;
$result['redirectUri'] = $client->redirect_uri;
break;
case OauthClient::TYPE_MINECRAFT_SERVER:
$result['minecraftServerIp'] = $client->minecraft_server_ip;
break;
}
return $result;
}
private function createForm(OauthClient $client): OauthClientTypeForm {
try {
$model = OauthClientFormFactory::create($client);
} catch (UnsupportedOauthClientType $e) {
Yii::warning('Someone tried use ' . $client->type . ' type of oauth form.');
throw new NotFoundHttpException(null, 0, $e);
}
return $model;
}
private function findOauthClient(string $clientId): OauthClient {
/** @var OauthClient|null $client */
$client = OauthClient::findOne($clientId);
if ($client === null) {
throw new NotFoundHttpException();
}
return $client;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace api\modules\oauth\controllers;
use api\controllers\Controller;
use api\modules\oauth\models\IdentityInfo;
use common\rbac\Permissions as P;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
class IdentityController extends Controller {
public function behaviors(): array {
return ArrayHelper::merge(Controller::behaviors(), [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'actions' => ['index'],
'allow' => true,
'roles' => [P::OBTAIN_ACCOUNT_INFO],
'roleParams' => function() {
/** @noinspection NullPointerExceptionInspection */
return [
'accountId' => Yii::$app->user->getIdentity()->getAccount()->id,
];
},
],
],
],
]);
}
public function actionIndex(): array {
/** @noinspection NullPointerExceptionInspection */
return (new IdentityInfo(Yii::$app->user->getIdentity()->getAccount()))->info();
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace api\modules\oauth\exceptions;
use yii\base\Exception;
class InvalidOauthClientState extends Exception implements OauthException {
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace api\modules\oauth\exceptions;
interface OauthException {
}

View File

@@ -0,0 +1,23 @@
<?php
namespace api\modules\oauth\exceptions;
use Throwable;
use yii\base\Exception;
class UnsupportedOauthClientType extends Exception implements OauthException {
/**
* @var string
*/
private $type;
public function __construct(string $type, int $code = 0, Throwable $previous = null) {
parent::__construct('Unsupported oauth client type', $code, $previous);
$this->type = $type;
}
public function getType(): string {
return $this->type;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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;
}

View 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;
}
}

View File

@@ -85,7 +85,7 @@ class RateLimiter extends \yii\filters\RateLimiter {
}
if ($this->server === null) {
/** @var OauthClient $server */
/** @var OauthClient|null $server */
$this->server = OauthClient::findOne($serverId);
// TODO: убедится, что это сервер
if ($this->server === null) {