diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index 7b077ef..5d352e5 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace api\components\OAuth2\Entities; use api\components\OAuth2\Repositories\PublicScopeRepository; -use api\components\Tokens\TokensFactory; use DateTimeImmutable; use League\OAuth2\Server\CryptKeyInterface; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; +use Yii; class AccessTokenEntity implements AccessTokenEntityInterface { use EntityTrait; @@ -31,7 +31,7 @@ class AccessTokenEntity implements AccessTokenEntityInterface { return $scope->getIdentifier() !== PublicScopeRepository::OFFLINE_ACCESS; }); - $token = TokensFactory::createForOAuthClient($this); + $token = Yii::$app->tokensFactory->createForOAuthClient($this); $this->scopes = $scopes; diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index ec19bbf..b7bfca4 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace api\components\Tokens; use Carbon\Carbon; +use Defuse\Crypto\Crypto; use Exception; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; @@ -35,6 +36,11 @@ class Component extends BaseComponent { */ public $privateKeyPass; + /** + * @var string|\Defuse\Crypto\Key + */ + public $encryptionKey; + /** * @var AlgorithmsManager|null */ @@ -45,6 +51,7 @@ class Component extends BaseComponent { Assert::notEmpty($this->hmacKey, 'hmacKey must be set'); Assert::notEmpty($this->privateKeyPath, 'privateKeyPath must be set'); Assert::notEmpty($this->publicKeyPath, 'publicKeyPath must be set'); + Assert::notEmpty($this->encryptionKey, 'encryptionKey must be set'); } public function create(array $payloads = [], array $headers = []): Token { @@ -53,11 +60,11 @@ class Component extends BaseComponent { ->issuedAt($now->getTimestamp()) ->expiresAt($now->addHour()->getTimestamp()); foreach ($payloads as $claim => $value) { - $builder->withClaim($claim, $value); + $builder->withClaim($claim, $this->prepareValue($value)); } foreach ($headers as $claim => $value) { - $builder->withHeader($claim, $value); + $builder->withHeader($claim, $this->prepareValue($value)); } /** @noinspection PhpUnhandledExceptionInspection */ @@ -85,6 +92,10 @@ class Component extends BaseComponent { } } + public function decryptValue(string $encryptedValue): string { + return Crypto::decryptWithPassword($encryptedValue, $this->encryptionKey); + } + private function getAlgorithmManager(): AlgorithmsManager { if ($this->algorithmManager === null) { $this->algorithmManager = new AlgorithmsManager([ @@ -100,4 +111,12 @@ class Component extends BaseComponent { return $this->algorithmManager; } + private function prepareValue($value) { + if ($value instanceof EncryptedValue) { + return Crypto::encryptWithPassword($value->getValue(), $this->encryptionKey); + } + + return $value; + } + } diff --git a/api/components/Tokens/EncryptedValue.php b/api/components/Tokens/EncryptedValue.php new file mode 100644 index 0000000..83866c1 --- /dev/null +++ b/api/components/Tokens/EncryptedValue.php @@ -0,0 +1,21 @@ +value = $value; + } + + public function getValue(): string { + return $this->value; + } + +} diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php index 2af8142..4c29339 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace api\components\Tokens; +use api\rbac\Permissions as P; +use api\rbac\Roles as R; use Carbon\Carbon; use common\models\Account; use common\models\AccountSession; @@ -10,16 +12,17 @@ use Lcobucci\JWT\Token; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use Yii; +use yii\base\Component; -class TokensFactory { +class TokensFactory extends Component { public const SUB_ACCOUNT_PREFIX = 'ely|'; public const AUD_CLIENT_PREFIX = 'client|'; - public static function createForAccount(Account $account, AccountSession $session = null): Token { + public function createForWebAccount(Account $account, AccountSession $session = null): Token { $payloads = [ - 'ely-scopes' => 'accounts_web_user', - 'sub' => self::buildSub($account->id), + 'ely-scopes' => R::ACCOUNTS_WEB_USER, + 'sub' => $this->buildSub($account->id), ]; if ($session === null) { // If we don't remember a session, the token should live longer @@ -32,26 +35,39 @@ class TokensFactory { return Yii::$app->tokens->create($payloads); } - public static function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { + public function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { $payloads = [ - 'aud' => self::buildAud($accessToken->getClient()->getIdentifier()), - 'ely-scopes' => implode(',', array_map(static function(ScopeEntityInterface $scope): string { + 'aud' => $this->buildAud($accessToken->getClient()->getIdentifier()), + 'ely-scopes' => $this->joinScopes(array_map(static function(ScopeEntityInterface $scope): string { return $scope->getIdentifier(); }, $accessToken->getScopes())), 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), ]; if ($accessToken->getUserIdentifier() !== null) { - $payloads['sub'] = self::buildSub($accessToken->getUserIdentifier()); + $payloads['sub'] = $this->buildSub($accessToken->getUserIdentifier()); } return Yii::$app->tokens->create($payloads); } - private static function buildSub(int $accountId): string { + public function createForMinecraftAccount(Account $account, string $clientToken): Token { + return Yii::$app->tokens->create([ + 'ely-scopes' => $this->joinScopes([P::MINECRAFT_SERVER_SESSION]), + 'ely-client-token' => new EncryptedValue($clientToken), + 'sub' => $this->buildSub($account->id), + 'exp' => Carbon::now()->addDays(2)->getTimestamp(), + ]); + } + + private function joinScopes(array $scopes): string { + return implode(',', $scopes); + } + + private function buildSub(int $accountId): string { return self::SUB_ACCOUNT_PREFIX . $accountId; } - private static function buildAud(string $clientId): string { + private function buildAud(string $clientId): string { return self::AUD_CLIENT_PREFIX . $clientId; } diff --git a/api/config/config-test.php b/api/config/config-test.php index af8f598..a99c96d 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -9,6 +9,7 @@ return [ 'privateKeyPath' => codecept_data_dir('certs/private.pem'), 'privateKeyPass' => null, 'publicKeyPath' => codecept_data_dir('certs/public.pem'), + 'encryptionKey' => 'mock-encryption-key', ], 'reCaptcha' => [ 'public' => 'public-key', diff --git a/api/config/config.php b/api/config/config.php index 420a73b..beb193d 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -21,6 +21,10 @@ return [ 'privateKeyPath' => getenv('JWT_PRIVATE_KEY_PATH') ?: __DIR__ . '/../../data/certs/private.pem', 'privateKeyPass' => getenv('JWT_PRIVATE_KEY_PASS') ?: null, 'publicKeyPath' => getenv('JWT_PUBLIC_KEY_PATH') ?: __DIR__ . '/../../data/certs/public.pem', + 'encryptionKey' => getenv('JWT_ENCRYPTION_KEY'), + ], + 'tokensFactory' => [ + 'class' => api\components\Tokens\TokensFactory::class, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index 60c6608..e283fd6 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\models\Account; @@ -48,7 +47,7 @@ class ConfirmEmailForm extends ApiForm { $session->generateRefreshToken(); Assert::true($session->save(), 'Cannot save account session model'); - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $transaction->commit(); diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 455bfa4..7dc91fc 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\traits\AccountFinder; use api\validators\TotpValidator; @@ -121,7 +120,7 @@ class LoginForm extends ApiForm { Assert::true($session->save(), 'Cannot save account session model'); } - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $transaction->commit(); diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 957c1ec..9fd99fb 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\helpers\Error as E; @@ -56,7 +55,7 @@ class RecoverPasswordForm extends ApiForm { Assert::true($account->save(), 'Unable activate user account.'); - $token = TokensFactory::createForAccount($account); + $token = Yii::$app->tokensFactory->createForWebAccount($account); $transaction->commit(); diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php index 92cd72c..fb4fd38 100644 --- a/api/models/authentication/RefreshTokenForm.php +++ b/api/models/authentication/RefreshTokenForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use common\helpers\Error as E; use common\models\AccountSession; @@ -47,7 +46,7 @@ class RefreshTokenForm extends ApiForm { $transaction = Yii::$app->db->beginTransaction(); - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $session->setIp(Yii::$app->request->userIP); $session->touch('last_refreshed_at'); diff --git a/api/models/base/ApiForm.php b/api/models/base/ApiForm.php index 1987bf2..70e0f05 100644 --- a/api/models/base/ApiForm.php +++ b/api/models/base/ApiForm.php @@ -1,11 +1,13 @@ ['POST'], 'refresh' => ['POST'], @@ -24,21 +26,35 @@ class AuthenticationController extends Controller { ]; } - public function actionAuthenticate() { + /** + * @return array + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionAuthenticate(): array { $model = new models\AuthenticationForm(); $model->load(Yii::$app->request->post()); return $model->authenticate()->getResponseData(true); } - public function actionRefresh() { + /** + * @return array + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionRefresh(): array { $model = new models\RefreshTokenForm(); $model->load(Yii::$app->request->post()); return $model->refresh()->getResponseData(false); } - public function actionValidate() { + /** + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionValidate(): void { $model = new models\ValidateForm(); $model->load(Yii::$app->request->post()); $model->validateToken(); @@ -46,7 +62,11 @@ class AuthenticationController extends Controller { // In case of an error, an exception is thrown which will be processed by ErrorHandler } - public function actionSignout() { + /** + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionSignout(): void { $model = new models\SignoutForm(); $model->load(Yii::$app->request->post()); $model->signout(); @@ -54,7 +74,10 @@ class AuthenticationController extends Controller { // In case of an error, an exception is thrown which will be processed by ErrorHandler } - public function actionInvalidate() { + /** + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionInvalidate(): void { $model = new models\InvalidateForm(); $model->load(Yii::$app->request->post()); $model->invalidateToken(); diff --git a/api/modules/authserver/models/AuthenticateData.php b/api/modules/authserver/models/AuthenticateData.php index 5efc34a..f9f9426 100644 --- a/api/modules/authserver/models/AuthenticateData.php +++ b/api/modules/authserver/models/AuthenticateData.php @@ -1,39 +1,47 @@ minecraftAccessKey = $minecraftAccessKey; - } + /** + * @var Token + */ + private $accessToken; - public function getMinecraftAccessKey(): MinecraftAccessKey { - return $this->minecraftAccessKey; + /** + * @var string + */ + private $clientToken; + + public function __construct(Account $account, string $accessToken, string $clientToken) { + $this->account = $account; + $this->accessToken = $accessToken; + $this->clientToken = $clientToken; } public function getResponseData(bool $includeAvailableProfiles = false): array { - $accessKey = $this->minecraftAccessKey; - $account = $accessKey->account; - $result = [ - 'accessToken' => $accessKey->access_token, - 'clientToken' => $accessKey->client_token, + 'accessToken' => $this->accessToken, + 'clientToken' => $this->clientToken, 'selectedProfile' => [ - 'id' => $account->uuid, - 'name' => $account->username, + 'id' => $this->account->uuid, + 'name' => $this->account->username, 'legacy' => false, ], ]; if ($includeAvailableProfiles) { - // The Moiangs themselves haven't come up with anything yet with these availableProfiles + // The Mojang themselves haven't come up with anything yet with these availableProfiles $availableProfiles[0] = $result['selectedProfile']; $result['availableProfiles'] = $availableProfiles; } diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 4accba6..1792a0a 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -1,4 +1,6 @@ validate(); Authserver::info("Trying to authenticate user by login = '{$this->username}'."); - $loginForm = $this->createLoginForm(); + $loginForm = new LoginForm(); $loginForm->login = $this->username; $loginForm->password = $this->password; if (!$loginForm->validate()) { @@ -68,37 +81,14 @@ class AuthenticationForm extends ApiForm { throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); } + /** @var Account $account */ $account = $loginForm->getAccount(); - $accessTokenModel = $this->createMinecraftAccessToken($account); - $dataModel = new AuthenticateData($accessTokenModel); + $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); + $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); return $dataModel; } - protected function createMinecraftAccessToken(Account $account): MinecraftAccessKey { - /** @var MinecraftAccessKey|null $accessTokenModel */ - $accessTokenModel = MinecraftAccessKey::findOne([ - 'account_id' => $account->id, - 'client_token' => $this->clientToken, - ]); - - if ($accessTokenModel === null) { - $accessTokenModel = new MinecraftAccessKey(); - $accessTokenModel->client_token = $this->clientToken; - $accessTokenModel->account_id = $account->id; - $accessTokenModel->insert(); - } else { - $accessTokenModel->refreshPrimaryKeyValue(); - $accessTokenModel->update(); - } - - return $accessTokenModel; - } - - protected function createLoginForm(): LoginForm { - return new LoginForm(); - } - } diff --git a/api/modules/authserver/models/InvalidateForm.php b/api/modules/authserver/models/InvalidateForm.php index d270c7f..de029e7 100644 --- a/api/modules/authserver/models/InvalidateForm.php +++ b/api/modules/authserver/models/InvalidateForm.php @@ -1,17 +1,24 @@ validate(); - $token = MinecraftAccessKey::findOne([ - 'access_token' => $this->accessToken, - 'client_token' => $this->clientToken, - ]); - - if ($token !== null) { - $token->delete(); - } + // We're can't invalidate access token because it's not stored in our database return true; } diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index 282cc18..92eb6a5 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -1,48 +1,71 @@ false], ]; } /** * @return AuthenticateData - * @throws \api\modules\authserver\exceptions\AuthserverException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException */ - public function refresh() { + public function refresh(): AuthenticateData { $this->validate(); - /** @var MinecraftAccessKey|null $accessToken */ - $accessToken = MinecraftAccessKey::findOne([ - 'access_token' => $this->accessToken, - 'client_token' => $this->clientToken, - ]); - if ($accessToken === null) { - throw new ForbiddenOperationException('Invalid token.'); + if (mb_strlen($this->accessToken) === 36) { + /** @var MinecraftAccessKey $token */ + $token = MinecraftAccessKey::findOne([ + 'access_token' => $this->accessToken, + 'client_token' => $this->clientToken, + ]); + $account = $token->account; + } else { + $token = Yii::$app->tokens->parse($this->accessToken); + + $encodedClientToken = $token->getClaim('ely-client-token'); + $clientToken = Yii::$app->tokens->decryptValue($encodedClientToken); + if ($clientToken !== $this->clientToken) { + throw new ForbiddenOperationException('Invalid token.'); + } + + $accountClaim = $token->getClaim('sub'); + $accountId = (int)explode('|', $accountClaim)[1]; + $account = Account::findOne(['id' => $accountId]); } - if ($accessToken->account->status === Account::STATUS_BANNED) { + if ($account === null || $account->status === Account::STATUS_BANNED) { throw new ForbiddenOperationException('This account has been suspended.'); } - $accessToken->refreshPrimaryKeyValue(); - $accessToken->update(); + $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); - return new AuthenticateData($accessToken); + return new AuthenticateData($account, (string)$token, $this->clientToken); } } diff --git a/api/modules/authserver/models/SignoutForm.php b/api/modules/authserver/models/SignoutForm.php index 8765b02..f2a7ef3 100644 --- a/api/modules/authserver/models/SignoutForm.php +++ b/api/modules/authserver/models/SignoutForm.php @@ -1,4 +1,6 @@ validate(); @@ -44,16 +55,7 @@ class SignoutForm extends ApiForm { throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); } - $account = $loginForm->getAccount(); - - /** @noinspection SqlResolve */ - Yii::$app->db->createCommand(' - DELETE - FROM ' . MinecraftAccessKey::tableName() . ' - WHERE account_id = :userId - ', [ - 'userId' => $account->id, - ])->execute(); + // We're unable to invalidate access tokens because they aren't stored in our database return true; } diff --git a/api/modules/authserver/models/ValidateForm.php b/api/modules/authserver/models/ValidateForm.php index bff97ae..7bf9fe6 100644 --- a/api/modules/authserver/models/ValidateForm.php +++ b/api/modules/authserver/models/ValidateForm.php @@ -1,35 +1,33 @@ validate(); - - /** @var MinecraftAccessKey|null $result */ - $result = MinecraftAccessKey::findOne($this->accessToken); - if ($result === null) { - throw new ForbiddenOperationException('Invalid token.'); - } - - if ($result->isExpired()) { - throw new ForbiddenOperationException('Token expired.'); - } - - return true; + return $this->validate(); } } diff --git a/api/modules/authserver/validators/AccessTokenValidator.php b/api/modules/authserver/validators/AccessTokenValidator.php new file mode 100644 index 0000000..535193d --- /dev/null +++ b/api/modules/authserver/validators/AccessTokenValidator.php @@ -0,0 +1,69 @@ +validateLegacyToken($value); + } + + try { + $token = Yii::$app->tokens->parse($value); + } catch (Exception $e) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if (!Yii::$app->tokens->verify($token)) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if ($this->verifyExpiration && !$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) { + throw new ForbiddenOperationException('Token expired.'); + } + + return null; + } + + /** + * @param string $value + * + * @return array|null + * @throws ForbiddenOperationException + */ + private function validateLegacyToken(string $value): ?array { + /** @var MinecraftAccessKey|null $result */ + $result = MinecraftAccessKey::findOne(['access_token' => $value]); + if ($result === null) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if ($this->verifyExpiration && $result->isExpired()) { + throw new ForbiddenOperationException('Token expired.'); + } + + return null; + } + +} diff --git a/api/modules/authserver/validators/ClientTokenValidator.php b/api/modules/authserver/validators/ClientTokenValidator.php index 21c336b..49c1380 100644 --- a/api/modules/authserver/validators/ClientTokenValidator.php +++ b/api/modules/authserver/validators/ClientTokenValidator.php @@ -1,20 +1,23 @@ 255) { throw new IllegalArgumentException('clientToken is too long.'); } diff --git a/api/modules/authserver/validators/RequiredValidator.php b/api/modules/authserver/validators/RequiredValidator.php index 4d38f37..ab22de7 100644 --- a/api/modules/authserver/validators/RequiredValidator.php +++ b/api/modules/authserver/validators/RequiredValidator.php @@ -1,4 +1,6 @@ getActor()->sendPOST('/api/authserver/authentication/authenticate', $params); - } - - public function refresh($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/refresh', $params); - } - - public function validate($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/validate', $params); - } - - public function invalidate($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/invalidate', $params); - } - - public function signout($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/signout', $params); - } - -} diff --git a/api/tests/_support/FunctionalTester.php b/api/tests/_support/FunctionalTester.php index 86bef30..74a35ad 100644 --- a/api/tests/_support/FunctionalTester.php +++ b/api/tests/_support/FunctionalTester.php @@ -3,7 +3,6 @@ declare(strict_types=1); namespace api\tests; -use api\components\Tokens\TokensFactory; use api\tests\_generated\FunctionalTesterActions; use Codeception\Actor; use common\models\Account; @@ -20,7 +19,7 @@ class FunctionalTester extends Actor { throw new InvalidArgumentException("Cannot find account with username \"{$asUsername}\""); } - $token = TokensFactory::createForAccount($account); + $token = Yii::$app->tokensFactory->createForWebAccount($account); $this->amBearerAuthenticated((string)$token); return $account->id; diff --git a/api/tests/functional/_steps/AuthserverSteps.php b/api/tests/functional/_steps/AuthserverSteps.php index 97cb5bf..d656417 100644 --- a/api/tests/functional/_steps/AuthserverSteps.php +++ b/api/tests/functional/_steps/AuthserverSteps.php @@ -3,16 +3,14 @@ declare(strict_types=1); namespace api\tests\functional\_steps; -use api\tests\_pages\AuthserverRoute; use api\tests\FunctionalTester; use Ramsey\Uuid\Uuid; class AuthserverSteps extends FunctionalTester { - public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') { - $route = new AuthserverRoute($this); + public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0'): array { $clientToken = Uuid::uuid4()->toString(); - $route->authenticate([ + $this->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => $asUsername, 'password' => $password, 'clientToken' => $clientToken, diff --git a/api/tests/functional/authserver/AuthorizationCest.php b/api/tests/functional/authserver/AuthorizationCest.php index f64cd25..0d4f652 100644 --- a/api/tests/functional/authserver/AuthorizationCest.php +++ b/api/tests/functional/authserver/AuthorizationCest.php @@ -1,48 +1,38 @@ route = new AuthserverRoute($I); - } - - public function byName(FunctionalTester $I) { + public function byFormParamsPostRequest(FunctionalTester $I, Example $example) { $I->wantTo('authenticate by username and password'); - $this->route->authenticate([ - 'username' => 'admin', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/authenticate', [ + 'username' => $example['login'], + 'password' => $example['password'], 'clientToken' => Uuid::uuid4()->toString(), ]); $this->testSuccessResponse($I); } - public function byEmail(FunctionalTester $I) { - $I->wantTo('authenticate by email and password'); - $this->route->authenticate([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => Uuid::uuid4()->toString(), - ]); - - $this->testSuccessResponse($I); - } - - public function byNamePassedViaPOSTBody(FunctionalTester $I) { + /** + * @example {"login": "admin", "password": "password_0"} + * @example {"login": "admin@ely.by", "password": "password_0"} + */ + public function byJsonPostRequest(FunctionalTester $I, Example $example) { $I->wantTo('authenticate by username and password sent via post body'); - $this->route->authenticate(json_encode([ - 'username' => 'admin', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/authenticate', json_encode([ + 'username' => $example['login'], + 'password' => $example['password'], 'clientToken' => Uuid::uuid4()->toString(), ])); @@ -51,7 +41,7 @@ class AuthorizationCest { public function byEmailWithEnabledTwoFactorAuth(FunctionalTester $I) { $I->wantTo('get valid error by authenticate account with enabled two factor auth'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'otp@gmail.com', 'password' => 'password_0', 'clientToken' => Uuid::uuid4()->toString(), @@ -64,30 +54,9 @@ class AuthorizationCest { ]); } - public function byEmailWithParamsAsJsonInPostBody(FunctionalTester $I) { - $I->wantTo('authenticate by email and password, passing values as serialized string in post body'); - $this->route->authenticate(json_encode([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => Uuid::uuid4()->toString(), - ])); - - $this->testSuccessResponse($I); - } - - public function longClientToken(FunctionalTester $I) { - $I->wantTo('send non uuid clientToken, but less then 255 characters'); - $this->route->authenticate([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => str_pad('', 255, 'x'), - ]); - $this->testSuccessResponse($I); - } - public function tooLongClientToken(FunctionalTester $I) { $I->wantTo('send non uuid clientToken with more then 255 characters length'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'admin@ely.by', 'password' => 'password_0', 'clientToken' => str_pad('', 256, 'x'), @@ -102,7 +71,7 @@ class AuthorizationCest { public function wrongArguments(FunctionalTester $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -115,7 +84,7 @@ class AuthorizationCest { public function wrongNicknameAndPassword(FunctionalTester $I) { $I->wantTo('authenticate by username and password with wrong data'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'nonexistent_user', 'password' => 'nonexistent_password', 'clientToken' => Uuid::uuid4()->toString(), @@ -130,7 +99,7 @@ class AuthorizationCest { public function bannedAccount(FunctionalTester $I) { $I->wantTo('authenticate in suspended account'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'Banned', 'password' => 'password_0', 'clientToken' => Uuid::uuid4()->toString(), diff --git a/api/tests/functional/authserver/InvalidateCest.php b/api/tests/functional/authserver/InvalidateCest.php index ad1506a..3426af8 100644 --- a/api/tests/functional/authserver/InvalidateCest.php +++ b/api/tests/functional/authserver/InvalidateCest.php @@ -3,25 +3,15 @@ declare(strict_types=1); namespace api\tests\functional\authserver; -use api\tests\_pages\AuthserverRoute; use api\tests\functional\_steps\AuthserverSteps; use Ramsey\Uuid\Uuid; class InvalidateCest { - /** - * @var AuthserverRoute - */ - private $route; - - public function _before(AuthserverSteps $I) { - $this->route = new AuthserverRoute($I); - } - public function invalidate(AuthserverSteps $I) { $I->wantTo('invalidate my token'); [$accessToken, $clientToken] = $I->amAuthenticated(); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'accessToken' => $accessToken, 'clientToken' => $clientToken, ]); @@ -31,7 +21,7 @@ class InvalidateCest { public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -44,7 +34,7 @@ class InvalidateCest { public function wrongAccessTokenOrClientToken(AuthserverSteps $I) { $I->wantTo('invalidate by wrong client and access token'); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'accessToken' => Uuid::uuid4()->toString(), 'clientToken' => Uuid::uuid4()->toString(), ]); diff --git a/api/tests/functional/authserver/RefreshCest.php b/api/tests/functional/authserver/RefreshCest.php index 9877bc6..5245732 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -1,42 +1,49 @@ route = new AuthserverRoute($I); - } - public function refresh(AuthserverSteps $I) { - $I->wantTo('refresh my accessToken'); + $I->wantTo('refresh accessToken'); [$accessToken, $clientToken] = $I->amAuthenticated(); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => $accessToken, 'clientToken' => $clientToken, ]); + $this->assertSuccessResponse($I); + } - $I->seeResponseCodeIs(200); - $I->seeResponseIsJson(); - $I->canSeeResponseJsonMatchesJsonPath('$.accessToken'); - $I->canSeeResponseJsonMatchesJsonPath('$.clientToken'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy'); - $I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles'); + public function refreshLegacyAccessToken(AuthserverSteps $I) { + $I->wantTo('refresh legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42', + 'clientToken' => '6f380440-0c05-47bd-b7c6-d011f1b5308f', + ]); + $this->assertSuccessResponse($I); + } + + /** + * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTY1MjM1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJkZWY1MDIwMDE2ZTEzMTBmMzM2YzVjYWQzZDdiMTJmYjcyNmVhYzdlYjgyOGUzMzg1MzBhMmFmODdkZTJhMjRiMTVmNzAxNWQ1MjU1MjhiNGZiMjgzMTgxOTA2ODhlMWE4Njk5MjAwMzBlMTQyZmQ5ZWM5ODBlZDkzMWI1Mzc2MzgyMTliMjVjMjI1MjQyYzdmMjgzMjE0NjcyNDg3ZDQ4MTYxYjMwMGU1MGIzYWJlMTYwYjVkMmE4ZWMyMzMwMGJhMGNlMTg3MzYyYTgyMjJiYjQ4OTU0MzM4MDJiNTBlZDBhYzFhMWUwZDk3NDgxNDciLCJzdWIiOiJlbHl8MSJ9.PuM-8rzj4qtD9l0lUANSIWC8yjJe8ifarOYsAjc3r4iYFt0P6za-gzJEPncDC80oCXsYVlJHtrEypcsB9wJFSg", "clientToken": "d1b1162c-3d73-4b35-b64f-7bf68bd0e853"} + * @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"} + */ + public function refreshExpiredToken(AuthserverSteps $I, Example $example) { + $I->wantTo('refresh legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => $example['accessToken'], + 'clientToken' => $example['clientToken'], + ]); + $this->assertSuccessResponse($I); } public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -49,7 +56,7 @@ class RefreshCest { public function wrongAccessToken(AuthserverSteps $I) { $I->wantTo('get error on wrong access or client tokens'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => Uuid::uuid4()->toString(), 'clientToken' => Uuid::uuid4()->toString(), ]); @@ -63,7 +70,7 @@ class RefreshCest { public function refreshTokenFromBannedUser(AuthserverSteps $I) { $I->wantTo('refresh token from suspended account'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => '918ecb41-616c-40ee-a7d2-0b0ef0d0d732', 'clientToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', ]); @@ -74,4 +81,15 @@ class RefreshCest { ]); } + private function assertSuccessResponse(AuthserverSteps $I) { + $I->seeResponseCodeIs(200); + $I->seeResponseIsJson(); + $I->canSeeResponseJsonMatchesJsonPath('$.accessToken'); + $I->canSeeResponseJsonMatchesJsonPath('$.clientToken'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy'); + $I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles'); + } + } diff --git a/api/tests/functional/authserver/SignoutCest.php b/api/tests/functional/authserver/SignoutCest.php index f15b671..ba1d9a2 100644 --- a/api/tests/functional/authserver/SignoutCest.php +++ b/api/tests/functional/authserver/SignoutCest.php @@ -1,35 +1,22 @@ route = new AuthserverRoute($I); - } - - public function byName(AuthserverSteps $I) { + public function signout(AuthserverSteps $I, Example $example) { $I->wantTo('signout by nickname and password'); - $this->route->signout([ - 'username' => 'admin', - 'password' => 'password_0', - ]); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseEquals(''); - } - - public function byEmail(AuthserverSteps $I) { - $I->wantTo('signout by email and password'); - $this->route->signout([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/signout', [ + 'username' => $example['login'], + 'password' => $example['password'], ]); $I->canSeeResponseCodeIs(200); $I->canSeeResponseEquals(''); @@ -37,7 +24,7 @@ class SignoutCest { public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -50,7 +37,7 @@ class SignoutCest { public function wrongNicknameAndPassword(AuthserverSteps $I) { $I->wantTo('signout by nickname and password with wrong data'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'username' => 'nonexistent_user', 'password' => 'nonexistent_password', ]); @@ -64,7 +51,7 @@ class SignoutCest { public function bannedAccount(AuthserverSteps $I) { $I->wantTo('signout from banned account'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'username' => 'Banned', 'password' => 'password_0', ]); diff --git a/api/tests/functional/authserver/ValidateCest.php b/api/tests/functional/authserver/ValidateCest.php index b31160c..e374257 100644 --- a/api/tests/functional/authserver/ValidateCest.php +++ b/api/tests/functional/authserver/ValidateCest.php @@ -1,34 +1,35 @@ route = new AuthserverRoute($I); - } - public function validate(AuthserverSteps $I) { $I->wantTo('validate my accessToken'); [$accessToken] = $I->amAuthenticated(); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'accessToken' => $accessToken, ]); $I->seeResponseCodeIs(200); $I->canSeeResponseEquals(''); } + public function validateLegacyToken(AuthserverSteps $I) { + $I->wantTo('validate my legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42', + ]); + $I->seeResponseCodeIs(200); + $I->canSeeResponseEquals(''); + } + public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -41,7 +42,7 @@ class ValidateCest { public function wrongAccessToken(AuthserverSteps $I) { $I->wantTo('get error on wrong accessToken'); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'accessToken' => Uuid::uuid4()->toString(), ]); $I->canSeeResponseCodeIs(401); @@ -54,9 +55,21 @@ class ValidateCest { public function expiredAccessToken(AuthserverSteps $I) { $I->wantTo('get error on expired accessToken'); - $this->route->validate([ - // Knowingly expired token from the dump - 'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTQ3OTU1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJyZW1vdmVkIiwic3ViIjoiZWx5fDEifQ.xDMs5B48nH6p3a1k3WoZKtW4zoNHGGaLD1OGTFte-sUJb2fNMR65LuuBW8DzqO2odgco2xX660zqbhB-tp2OsA', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Token expired.', + ]); + } + + public function expiredLegacyAccessToken(AuthserverSteps $I) { + $I->wantTo('get error on expired legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', // Already expired token from the fixtures ]); $I->canSeeResponseCodeIs(401); $I->canSeeResponseIsJson(); diff --git a/api/tests/unit/components/Tokens/TokensFactoryTest.php b/api/tests/unit/components/Tokens/TokensFactoryTest.php index cd54c00..7dc353b 100644 --- a/api/tests/unit/components/Tokens/TokensFactoryTest.php +++ b/api/tests/unit/components/Tokens/TokensFactoryTest.php @@ -14,7 +14,9 @@ class TokensFactoryTest extends TestCase { $account = new Account(); $account->id = 1; - $token = TokensFactory::createForAccount($account); + $factory = new TokensFactory(); + + $token = $factory->createForWebAccount($account); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $token->getClaim('exp'), 2); $this->assertSame('ely|1', $token->getClaim('sub')); @@ -24,7 +26,7 @@ class TokensFactoryTest extends TestCase { $session = new AccountSession(); $session->id = 2; - $token = TokensFactory::createForAccount($account, $session); + $token = $factory->createForWebAccount($account, $session); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); $this->assertSame('ely|1', $token->getClaim('sub')); diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index 35e2db2..d6ebf7e 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -79,7 +79,7 @@ class AuthenticationFormTest extends TestCase { $result = $authForm->authenticate(); $this->assertInstanceOf(AuthenticateData::class, $result); - $this->assertSame($minecraftAccessKey->access_token, $result->getMinecraftAccessKey()->access_token); + $this->assertSame($minecraftAccessKey->access_token, $result->getToken()->access_token); } public function testCreateMinecraftAccessToken() { diff --git a/autocompletion.php b/autocompletion.php index e07fc51..8e62e76 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -24,7 +24,6 @@ class Yii extends \yii\BaseYii { * @property \mito\sentry\Component $sentry * @property \common\components\StatsD $statsd * @property \yii\queue\Queue $queue - * @property \api\components\Tokens\Component $tokens */ abstract class BaseApplication extends yii\base\Application { } @@ -33,9 +32,11 @@ abstract class BaseApplication extends yii\base\Application { * Class WebApplication * Include only Web application related components here * - * @property \api\components\User\Component $user User component. - * @property \api\components\ReCaptcha\Component $reCaptcha - * @property \api\components\OAuth2\Component $oauth + * @property \api\components\User\Component $user + * @property \api\components\ReCaptcha\Component $reCaptcha + * @property \api\components\OAuth2\Component $oauth + * @property \api\components\Tokens\Component $tokens + * @property \api\components\Tokens\TokensFactory $tokensFactory * * @method \api\components\User\Component getUser() */ diff --git a/composer.json b/composer.json index d2451b9..e0e5efa 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ext-pdo": "*", "ext-simplexml": "*", "bacon/bacon-qr-code": "^1.0", + "defuse/php-encryption": "^2.2", "domnikl/statsd": "^2.6", "ely/mojang-api": "^0.2.0", "ely/yii2-tempmail-validator": "^2.0", diff --git a/composer.lock b/composer.lock index d5c2a2b..e96b55c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ee1d380684b79ffabf92f115d7de4a8", + "content-hash": "10af6b999939a9f213664883387184ed", "packages": [ { "name": "bacon/bacon-qr-code",