diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 966601d..618ff78 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -81,7 +81,7 @@ class Component extends YiiUserComponent { if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) { /** @var \common\models\OauthSession|null $minecraftSession */ - $minecraftSession = $account->getSessions() + $minecraftSession = $account->getOauthSessions() ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) ->one(); if ($minecraftSession !== null) { diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php index 9fe214f..7917d46 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -54,23 +54,24 @@ class JwtIdentity implements IdentityInterface { $tokenReader = new TokenReader($token); $accountId = $tokenReader->getAccountId(); - $iat = $token->getClaim('iat'); - if ($tokenReader->getMinecraftClientToken() !== null && self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat)) { - throw new UnauthorizedHttpException('Token has been revoked'); - } + if ($accountId !== null) { + $iat = $token->getClaim('iat'); + if ($tokenReader->getMinecraftClientToken() !== null + && self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat) + ) { + throw new UnauthorizedHttpException('Token has been revoked'); + } - if ($tokenReader->getClientId() !== null && self::isRevoked($accountId, $tokenReader->getClientId(), $iat)) { - throw new UnauthorizedHttpException('Token has been revoked'); + if ($tokenReader->getClientId() !== null + && self::isRevoked($accountId, $tokenReader->getClientId(), $iat) + ) { + throw new UnauthorizedHttpException('Token has been revoked'); + } } return new self($token); } - private static function isRevoked(int $accountId, string $clientId, int $iat): bool { - $session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]); - return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat; - } - public function getToken(): Token { return $this->token; } @@ -100,6 +101,11 @@ class JwtIdentity implements IdentityInterface { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } + private static function isRevoked(int $accountId, string $clientId, int $iat): bool { + $session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]); + return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat; + } + // @codeCoverageIgnoreEnd private function getReader(): TokenReader { diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 8e37372..6af8e2a 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -9,8 +9,12 @@ use api\modules\authserver\exceptions\ForbiddenOperationException; use api\modules\authserver\Module as Authserver; use api\modules\authserver\validators\ClientTokenValidator; use api\modules\authserver\validators\RequiredValidator; +use api\rbac\Permissions as P; use common\helpers\Error as E; use common\models\Account; +use common\models\OauthClient; +use common\models\OauthSession; +use Webmozart\Assert\Assert; use Yii; class AuthenticationForm extends ApiForm { @@ -85,7 +89,17 @@ class AuthenticationForm extends ApiForm { $account = $loginForm->getAccount(); $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); - // TODO: issue session in the oauth_sessions + /** @var OauthSession|null $minecraftOauthSession */ + $hasMinecraftOauthSession = $account->getOauthSessions() + ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) + ->exists(); + if ($hasMinecraftOauthSession === false) { + $minecraftOauthSession = new OauthSession(); + $minecraftOauthSession->account_id = $account->id; + $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; + $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; + Assert::true($minecraftOauthSession->save()); + } Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index f9adff1..fea2410 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -10,6 +10,9 @@ use api\modules\authserver\validators\AccessTokenValidator; use api\modules\authserver\validators\RequiredValidator; use common\models\Account; use common\models\MinecraftAccessKey; +use common\models\OauthClient; +use common\models\OauthSession; +use Webmozart\Assert\Assert; use Yii; class RefreshTokenForm extends ApiForm { @@ -68,6 +71,19 @@ class RefreshTokenForm extends ApiForm { $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); + // TODO: This behavior duplicates with the AuthenticationForm. Need to find a way to avoid duplication. + /** @var OauthSession|null $minecraftOauthSession */ + $hasMinecraftOauthSession = $account->getOauthSessions() + ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) + ->exists(); + if ($hasMinecraftOauthSession === false) { + $minecraftOauthSession = new OauthSession(); + $minecraftOauthSession->account_id = $account->id; + $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; + $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; + Assert::true($minecraftOauthSession->save()); + } + return new AuthenticateData($account, (string)$token, $this->clientToken); } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 761c49d..be0dfc3 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -20,7 +20,6 @@ use Yii; class OauthProcess { - // TODO: merge this with PublicScopesRepository private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [ P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info', P::OBTAIN_ACCOUNT_EMAIL => 'account_email', @@ -325,12 +324,7 @@ class OauthProcess { } private function createAcceptRequiredException(): OAuthServerException { - return new OAuthServerException( - 'Client must accept authentication request.', - 0, - 'accept_required', - 401 - ); + return new OAuthServerException('Client must accept authentication request.', 0, 'accept_required', 401); } private function getScopesList(AuthorizationRequest $request): array { diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 04d8a53..4f5fae5 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -9,9 +9,12 @@ use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; use common\models\Account; use common\models\AccountSession; +use common\models\OauthClient; use common\tests\fixtures\AccountFixture; use common\tests\fixtures\AccountSessionFixture; use common\tests\fixtures\MinecraftAccessKeyFixture; +use common\tests\fixtures\OauthClientFixture; +use common\tests\fixtures\OauthSessionFixture; use Lcobucci\JWT\Claim\Basic; use Lcobucci\JWT\Token; @@ -32,6 +35,8 @@ class ComponentTest extends TestCase { 'accounts' => AccountFixture::class, 'sessions' => AccountSessionFixture::class, 'minecraftSessions' => MinecraftAccessKeyFixture::class, + 'oauthClients' => OauthClientFixture::class, + 'oauthSessions' => OauthSessionFixture::class, ]; } @@ -88,7 +93,7 @@ class ComponentTest extends TestCase { $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); - // TODO: write test about invalidating new minecraft access tokens based on JWT + $this->assertEqualsWithDelta(time(), $account->getOauthSessions()->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])->one()->revoked_at, 5); // All sessions should be removed except the current one $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); diff --git a/api/tests/unit/models/authentication/RefreshTokenFormTest.php b/api/tests/unit/models/authentication/RefreshTokenFormTest.php index 4d26c96..4c04a05 100644 --- a/api/tests/unit/models/authentication/RefreshTokenFormTest.php +++ b/api/tests/unit/models/authentication/RefreshTokenFormTest.php @@ -5,14 +5,12 @@ namespace codeception\api\unit\models\authentication; use api\models\authentication\RefreshTokenForm; use api\tests\unit\TestCase; -use Codeception\Specify; use common\models\AccountSession; use common\tests\fixtures\AccountSessionFixture; use Yii; use yii\web\Request; class RefreshTokenFormTest extends TestCase { - use Specify; public function _fixtures(): array { return [ @@ -21,9 +19,8 @@ class RefreshTokenFormTest extends TestCase { } public function testRenew() { - /** @var Request|\Mockery\MockInterface $request */ - $request = mock(Request::class . '[getUserIP]')->makePartial(); - $request->shouldReceive('getUserIP')->andReturn('10.1.2.3'); + $request = $this->createPartialMock(Request::class, ['getUserIP']); + $request->method('getUserIP')->willReturn('10.1.2.3'); Yii::$app->set('request', $request); $model = new RefreshTokenForm(); diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index 01c352f..778665e 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -6,7 +6,10 @@ namespace codeception\api\unit\modules\authserver\models; use api\modules\authserver\exceptions\ForbiddenOperationException; use api\modules\authserver\models\AuthenticationForm; use api\tests\unit\TestCase; +use common\models\OauthClient; +use common\models\OauthSession; use common\tests\fixtures\AccountFixture; +use common\tests\fixtures\OauthClientFixture; use Ramsey\Uuid\Uuid; class AuthenticationFormTest extends TestCase { @@ -14,6 +17,7 @@ class AuthenticationFormTest extends TestCase { public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, + 'oauthClients' => OauthClientFixture::class, ]; } @@ -28,14 +32,18 @@ class AuthenticationFormTest extends TestCase { $this->assertSame('df936908-b2e1-544d-96f8-2977ec213022', $result['selectedProfile']['id']); $this->assertSame('Admin', $result['selectedProfile']['name']); $this->assertFalse($result['selectedProfile']['legacy']); + $this->assertTrue(OauthSession::find()->andWhere([ + 'account_id' => 1, + 'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, + ])->exists()); } /** * @dataProvider getInvalidCredentialsCases */ - public function testAuthenticateByWrongNicknamePass(string $expectedFieldError, string $login, string $password) { + public function testAuthenticateByWrongNicknamePass(string $expectedExceptionMessage, string $login, string $password) { $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage("Invalid credentials. Invalid {$expectedFieldError} or password."); + $this->expectExceptionMessage($expectedExceptionMessage); $authForm = new AuthenticationForm(); $authForm->username = $login; @@ -45,19 +53,10 @@ class AuthenticationFormTest extends TestCase { } public function getInvalidCredentialsCases() { - yield ['nickname', 'wrong-username', 'wrong-password']; - yield ['email', 'wrong-email@ely.by', 'wrong-password']; - } - - public function testAuthenticateByValidCredentialsIntoBlockedAccount() { - $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage('This account has been suspended.'); - - $authForm = new AuthenticationForm(); - $authForm->username = 'Banned'; - $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4()->toString(); - $authForm->authenticate(); + yield ['Invalid credentials. Invalid nickname or password.', 'wrong-username', 'wrong-password']; + yield ['Invalid credentials. Invalid email or password.', 'wrong-email@ely.by', 'wrong-password']; + yield ['This account has been suspended.', 'Banned', 'password_0']; + yield ['Account protected with two factor auth.', 'AccountWithEnabledOtp', 'password_0']; } } diff --git a/common/tests/fixtures/data/oauth-clients.php b/common/tests/fixtures/data/oauth-clients.php index 02ec7e3..536c80d 100644 --- a/common/tests/fixtures/data/oauth-clients.php +++ b/common/tests/fixtures/data/oauth-clients.php @@ -14,6 +14,19 @@ return [ 'is_deleted' => 0, 'created_at' => 1455309271, ], + 'unauthorizedMinecraftGameLauncher' => [ + 'id' => 'unauthorized_minecraft_game_launcher', + 'secret' => 'there_is_no_secret', + 'type' => 'minecraft-game-launcher', + 'name' => 'Unauthorized Minecraft game launcher', + 'description' => '', + 'redirect_uri' => null, + 'website_url' => null, + 'minecraft_server_ip' => null, + 'is_trusted' => false, + 'is_deleted' => false, + 'created_at' => 1576003878, + ], 'tlauncher' => [ 'id' => 'tlauncher', 'secret' => 'HsX-xXzdGiz3mcsqeEvrKHF47sqiaX94',