Introduce revokation mechanism

This commit is contained in:
ErickSkrauch 2019-12-10 01:38:09 +03:00
parent ba7fad84a0
commit 016a193263
11 changed files with 103 additions and 20 deletions

View File

@ -36,7 +36,7 @@ class Component extends BaseComponent {
public $privateKeyPass; public $privateKeyPass;
/** /**
* @var string|\Defuse\Crypto\Key * @var string
*/ */
public $encryptionKey; public $encryptionKey;

View File

@ -5,6 +5,8 @@ namespace api\components\User;
use common\models\Account; use common\models\Account;
use common\models\AccountSession; use common\models\AccountSession;
use common\models\OauthClient;
use Webmozart\Assert\Assert;
use yii\web\User as YiiUserComponent; use yii\web\User as YiiUserComponent;
/** /**
@ -78,6 +80,15 @@ class Component extends YiiUserComponent {
} }
if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) { if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) {
/** @var \common\models\OauthSession|null $minecraftSession */
$minecraftSession = $account->getSessions()
->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])
->one();
if ($minecraftSession !== null) {
$minecraftSession->revoked_at = time();
Assert::true($minecraftSession->save());
}
foreach ($account->minecraftAccessKeys as $minecraftAccessKey) { foreach ($account->minecraftAccessKeys as $minecraftAccessKey) {
$minecraftAccessKey->delete(); $minecraftAccessKey->delete();
} }

View File

@ -6,6 +6,8 @@ namespace api\components\User;
use api\components\Tokens\TokenReader; use api\components\Tokens\TokenReader;
use Carbon\Carbon; use Carbon\Carbon;
use common\models\Account; use common\models\Account;
use common\models\OauthClient;
use common\models\OauthSession;
use Exception; use Exception;
use Lcobucci\JWT\Token; use Lcobucci\JWT\Token;
use Lcobucci\JWT\ValidationData; use Lcobucci\JWT\ValidationData;
@ -50,9 +52,25 @@ class JwtIdentity implements IdentityInterface {
throw new UnauthorizedHttpException('Incorrect token'); throw new UnauthorizedHttpException('Incorrect token');
} }
$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 ($tokenReader->getClientId() !== null && self::isRevoked($accountId, $tokenReader->getClientId(), $iat)) {
throw new UnauthorizedHttpException('Token has been revoked');
}
return new self($token); 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 { public function getToken(): Token {
return $this->token; return $this->token;
} }

View File

@ -85,6 +85,7 @@ class AuthenticationForm extends ApiForm {
$account = $loginForm->getAccount(); $account = $loginForm->getAccount();
$token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken);
$dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken);
// TODO: issue session in the oauth_sessions
Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in.");

View File

@ -88,6 +88,7 @@ class ComponentTest extends TestCase {
$component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS);
$this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertEmpty($account->getMinecraftAccessKeys()->all());
$this->assertNotEmpty($account->getSessions()->all()); $this->assertNotEmpty($account->getSessions()->all());
// TODO: write test about invalidating new minecraft access tokens based on JWT
// All sessions should be removed except the current one // All sessions should be removed except the current one
$component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION);

View File

@ -7,6 +7,8 @@ use api\components\User\JwtIdentity;
use api\tests\unit\TestCase; use api\tests\unit\TestCase;
use Carbon\Carbon; use Carbon\Carbon;
use common\tests\fixtures\AccountFixture; use common\tests\fixtures\AccountFixture;
use common\tests\fixtures\OauthClientFixture;
use common\tests\fixtures\OauthSessionFixture;
use yii\web\UnauthorizedHttpException; use yii\web\UnauthorizedHttpException;
class JwtIdentityTest extends TestCase { class JwtIdentityTest extends TestCase {
@ -14,6 +16,8 @@ class JwtIdentityTest extends TestCase {
public function _fixtures(): array { public function _fixtures(): array {
return [ return [
'accounts' => AccountFixture::class, 'accounts' => AccountFixture::class,
'oauthClients' => OauthClientFixture::class,
'oauthSessions' => OauthSessionFixture::class,
]; ];
} }
@ -46,6 +50,14 @@ class JwtIdentityTest extends TestCase {
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTc3NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ._6hj6XUSmSLibgT9ZE1Pokf4oI9r-d6tEc1z2J-fBlr1710Qiso5yNcXqb3Z_xy7Qtemyq8jOlOZA8DvmkVBrg', 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTc3NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ._6hj6XUSmSLibgT9ZE1Pokf4oI9r-d6tEc1z2J-fBlr1710Qiso5yNcXqb3Z_xy7Qtemyq8jOlOZA8DvmkVBrg',
'Incorrect token', 'Incorrect token',
]; ];
yield 'revoked by oauth client' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudF9pbmZvLG1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEiLCJhdWQiOiJjbGllbnR8dGxhdW5jaGVyIn0.YzUzvnREEoQPu8CvU6WLdysUU0bC_xzigQPs2LK1su38uysSYgSbPzNOZYkQnvcmVLehHY-ON44x-oA8Os-9ZA',
'Token has been revoked',
];
yield 'revoked by unauthorized minecraft launcher' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUL1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YrVVg1dWRQVVFsK04xY3BBZlJBL2ErZW1BZz09IiwiaWF0IjoxNTY0NjEwNTAwLCJzdWIiOiJlbHl8MSJ9.mxFgf4M1QSG4_Zd3sGoJUx9L9_XbjHd4T8-CWIVzmSPp2_9OHjq-CIFEwSwlfoz3QGN7NV0TpC8-PfRvjd93eQ',
'Token has been revoked',
];
yield 'invalid signature' => [ yield 'invalid signature' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw', 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw',
'Incorrect token', 'Incorrect token',

View File

@ -14,33 +14,33 @@ use const common\LATEST_RULES_VERSION;
/** /**
* Fields: * Fields:
* @property integer $id * @property int $id
* @property string $uuid * @property string $uuid
* @property string $username * @property string $username
* @property string $email * @property string $email
* @property string $password_hash * @property string $password_hash
* @property integer $password_hash_strategy * @property int $password_hash_strategy
* @property string $lang * @property string $lang
* @property integer $status * @property int $status
* @property integer $rules_agreement_version * @property int $rules_agreement_version
* @property string $registration_ip * @property string $registration_ip
* @property string $otp_secret * @property string $otp_secret
* @property integer $is_otp_enabled * @property int $is_otp_enabled
* @property integer $created_at * @property int $created_at
* @property integer $updated_at * @property int $updated_at
* @property integer $password_changed_at * @property int $password_changed_at
* *
* Getters-setters: * Getters-setters:
* @property-write string $password plain user's password * @property-write string $password plain user's password
* @property-read string $profileLink link to the user's Ely.by profile * @property-read string $profileLink link to the user's Ely.by profile
* *
* Relations: * Relations:
* @property EmailActivation[] $emailActivations * @property-read EmailActivation[] $emailActivations
* @property OauthSession[] $oauthSessions * @property-read OauthSession[] $oauthSessions
* @property OauthClient[] $oauthClients * @property-read OauthClient[] $oauthClients
* @property UsernameHistory[] $usernameHistory * @property-read UsernameHistory[] $usernameHistory
* @property AccountSession[] $sessions * @property-read AccountSession[] $sessions
* @property MinecraftAccessKey[] $minecraftAccessKeys * @property-read MinecraftAccessKey[] $minecraftAccessKeys
* *
* Behaviors: * Behaviors:
* @mixin TimestampBehavior * @mixin TimestampBehavior

View File

@ -31,6 +31,12 @@ class OauthClient extends ActiveRecord {
public const TYPE_APPLICATION = 'application'; public const TYPE_APPLICATION = 'application';
public const TYPE_MINECRAFT_SERVER = 'minecraft-server'; public const TYPE_MINECRAFT_SERVER = 'minecraft-server';
public const TYPE_MINECRAFT_GAME_LAUNCHER = 'minecraft-game-launcher';
/**
* Abstract oauth_client, used to
*/
public const UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER = 'unauthorized_minecraft_game_launcher';
public static function tableName(): string { public static function tableName(): string {
return 'oauth_clients'; return 'oauth_clients';

View File

@ -12,9 +12,10 @@ use yii\db\ActiveRecord;
* Fields: * Fields:
* @property int $account_id * @property int $account_id
* @property string $client_id * @property string $client_id
* @property int $legacy_id * @property int|null $legacy_id
* @property array $scopes * @property array $scopes
* @property integer $created_at * @property int $created_at
* @property int|null $revoked_at
* *
* Relations: * Relations:
* @property-read OauthClient $client * @property-read OauthClient $client
@ -58,6 +59,7 @@ class OauthSession extends ActiveRecord {
* @return array of refresh tokens (ids) * @return array of refresh tokens (ids)
*/ */
public function getLegacyRefreshTokens(): array { public function getLegacyRefreshTokens(): array {
// TODO: it seems that this method isn't used anywhere
if ($this->legacy_id === null) { if ($this->legacy_id === null) {
return []; return [];
} }

View File

@ -6,6 +6,23 @@ return [
'legacy_id' => 1, 'legacy_id' => 1,
'scopes' => null, 'scopes' => null,
'created_at' => 1479944472, 'created_at' => 1479944472,
'revoked_at' => null,
],
'revoked-tlauncher' => [
'account_id' => 1,
'client_id' => 'tlauncher',
'legacy_id' => null,
'scopes' => null,
'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(),
'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(),
],
'revoked-minecraft-game-launchers' => [
'account_id' => 1,
'client_id' => common\models\OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER,
'legacy_id' => null,
'scopes' => null,
'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(),
'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(),
], ],
'banned-account-session' => [ 'banned-account-session' => [
'account_id' => 10, 'account_id' => 10,
@ -13,6 +30,7 @@ return [
'legacy_id' => 2, 'legacy_id' => 2,
'scopes' => null, 'scopes' => null,
'created_at' => 1481421663, 'created_at' => 1481421663,
'revoked_at' => null,
], ],
'deleted-client-session' => [ 'deleted-client-session' => [
'account_id' => 1, 'account_id' => 1,
@ -20,6 +38,7 @@ return [
'legacy_id' => 3, 'legacy_id' => 3,
'scopes' => null, 'scopes' => null,
'created_at' => 1519510065, 'created_at' => 1519510065,
'revoked_at' => null,
], ],
'actual-deleted-client-session' => [ 'actual-deleted-client-session' => [
'account_id' => 2, 'account_id' => 2,
@ -27,5 +46,6 @@ return [
'legacy_id' => 4, 'legacy_id' => 4,
'scopes' => null, 'scopes' => null,
'created_at' => 1519511568, 'created_at' => 1519511568,
'revoked_at' => null,
], ],
]; ];

View File

@ -34,9 +34,21 @@ class m190914_181236_rework_oauth_related_tables extends Migration {
$this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE');
$this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
$this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`'); $this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`');
$this->addColumn('oauth_sessions', 'revoked_at', $this->integer(11)->unsigned() . ' AFTER `created_at`');
$this->insert('oauth_clients', [
'id' => 'unauthorized_minecraft_game_launcher',
'secret' => 'there_is_no_secret',
'type' => 'minecraft-game-launcher',
'name' => 'Unauthorized Minecraft game launcher',
'created_at' => time(),
]);
} }
public function safeDown() { public function safeDown() {
$this->delete('oauth_clients', ['id' => 'unauthorized_minecraft_game_launcher']);
$this->dropColumn('oauth_sessions', 'revoked_at');
$this->dropColumn('oauth_sessions', 'scopes'); $this->dropColumn('oauth_sessions', 'scopes');
$this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions');
$this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions');