diff --git a/api/config/main.php b/api/config/main.php index 8066d9c..72b020b 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -9,7 +9,7 @@ $params = array_merge( return [ 'id' => 'accounts-site-api', 'basePath' => dirname(__DIR__), - 'bootstrap' => ['log'], + 'bootstrap' => ['log', 'authserver'], 'controllerNamespace' => 'api\controllers', 'params' => $params, 'components' => [ @@ -48,4 +48,10 @@ return [ 'grantTypes' => ['authorization_code'], ], ], + 'modules' => [ + 'authserver' => [ + 'class' => \api\modules\authserver\Module::class, + 'baseDomain' => $params['authserverDomain'], + ], + ], ]; diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index f7c0ce9..485bbb3 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -52,6 +52,7 @@ class LoginForm extends ApiForm { } public function validateActivity($attribute) { + // TODO: проверить, не заблокирован ли аккаунт if (!$this->hasErrors()) { $account = $this->getAccount(); if ($account->status !== Account::STATUS_ACTIVE) { diff --git a/api/modules/authserver/Module.php b/api/modules/authserver/Module.php new file mode 100644 index 0000000..747a616 --- /dev/null +++ b/api/modules/authserver/Module.php @@ -0,0 +1,34 @@ +baseDomain === null) { + throw new InvalidConfigException('base domain must be specified'); + } + } + + /** + * @param \yii\base\Application $app the application currently running + */ + public function bootstrap($app) { + $app->getUrlManager()->addRules([ + $this->baseDomain . '/' . $this->id . '/auth/' => $this->id . '/authentication/', + ], false); + } + +} diff --git a/api/modules/authserver/controllers/AuthenticationController.php b/api/modules/authserver/controllers/AuthenticationController.php new file mode 100644 index 0000000..d0385e8 --- /dev/null +++ b/api/modules/authserver/controllers/AuthenticationController.php @@ -0,0 +1,54 @@ +loadByPost(); + + return $model->authenticate()->getResponseData(true); + } + + public function refreshAction() { + $model = new models\RefreshTokenForm(); + $model->loadByPost(); + + return $model->refresh()->getResponseData(false); + } + + public function validateAction() { + $model = new models\ValidateForm(); + $model->loadByPost(); + $model->validateToken(); + // В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение, + // которое обработает ErrorHandler + } + + public function signoutAction() { + $model = new models\SignoutForm(); + $model->loadByPost(); + $model->signout(); + // В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение, + // которое обработает ErrorHandler + } + + public function invalidateAction() { + $model = new models\InvalidateForm(); + $model->loadByPost(); + $model->invalidateToken(); + // В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение, + // которое обработает ErrorHandler + } + +} diff --git a/api/modules/authserver/controllers/IndexController.php b/api/modules/authserver/controllers/IndexController.php new file mode 100644 index 0000000..d81d8f0 --- /dev/null +++ b/api/modules/authserver/controllers/IndexController.php @@ -0,0 +1,15 @@ +response + ->setStatusCode(404, 'Not Found') + ->setContent('Page not found. Check our documentation site.');*/ + } + +} diff --git a/api/modules/authserver/exceptions/AuthserverException.php b/api/modules/authserver/exceptions/AuthserverException.php new file mode 100644 index 0000000..698883b --- /dev/null +++ b/api/modules/authserver/exceptions/AuthserverException.php @@ -0,0 +1,8 @@ +minecraftAccessKey = $minecraftAccessKey; + } + + public function getMinecraftAccessKey() : MinecraftAccessKey { + return $this->minecraftAccessKey; + } + + public function getResponseData(bool $includeAvailableProfiles = false) : array { + $accessKey = $this->minecraftAccessKey; + $account = $accessKey->account; + + $result = [ + 'accessToken' => $accessKey->access_token, + 'clientToken' => $accessKey->client_token, + 'selectedProfile' => [ + 'id' => $account->uuid, + 'name' => $account->username, + 'legacy' => false, + ], + ]; + + if ($includeAvailableProfiles) { + // Сами моянги ещё ничего не придумали с этими availableProfiles + $availableProfiles[0] = $result['selectedProfile']; + $result['availableProfiles'] = $availableProfiles; + } + + return $result; + } + +} diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php new file mode 100644 index 0000000..b54765b --- /dev/null +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -0,0 +1,74 @@ +validate(); + + Yii::info("Trying to authenticate user by login = '{$this->username}'.", 'legacy-authentication'); + + $loginForm = new LoginForm(); + $loginForm->login = $this->username; + $loginForm->password = $this->password; + if (!$loginForm->validate()) { + $errors = $loginForm->getFirstErrors(); + if (isset($errors['login'])) { + Yii::error("Cannot find user by login = '{$this->username}", 'legacy-authentication'); + } elseif (isset($errors['password'])) { + Yii::error("User with login = '{$this->username}' passed wrong password.", 'legacy-authentication'); + } + + // На старом сервере авторизации использовалось поле nickname, а не username, так что сохраняем эту логику + $attribute = $loginForm->getLoginAttribute(); + if ($attribute === 'username') { + $attribute = 'nickname'; + } + + // TODO: если аккаунт заблокирован, то возвращалось сообщение return "This account has been suspended." + // TODO: эта логика дублируется с логикой в SignoutForm + + throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); + } + + $account = $loginForm->getAccount(); + + /** @var MinecraftAccessKey|null $accessTokenModel */ + $accessTokenModel = MinecraftAccessKey::findOne(['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(); + } + + $dataModel = new AuthenticateData($accessTokenModel); + + Yii::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in.", 'legacy-authentication'); + + return $dataModel; + } + +} diff --git a/api/modules/authserver/models/Form.php b/api/modules/authserver/models/Form.php new file mode 100644 index 0000000..0ac01b9 --- /dev/null +++ b/api/modules/authserver/models/Form.php @@ -0,0 +1,23 @@ +load(Yii::$app->request->get()); + } + + public function loadByPost() { + $data = Yii::$app->request->post(); + // TODO: проверить, парсит ли Yii2 raw body и что он делает, если там неспаршенный json + /*if (empty($data)) { + $data = $request->getJsonRawBody(true); + }*/ + + return $this->load($data); + } + +} diff --git a/api/modules/authserver/models/InvalidateForm.php b/api/modules/authserver/models/InvalidateForm.php new file mode 100644 index 0000000..7d0d7d0 --- /dev/null +++ b/api/modules/authserver/models/InvalidateForm.php @@ -0,0 +1,37 @@ +validate(); + + $token = MinecraftAccessKey::findOne([ + 'access_token' => $this->accessToken, + 'client_token' => $this->clientToken, + ]); + + if ($token !== null) { + $token->delete(); + } + + return true; + } + +} diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php new file mode 100644 index 0000000..7258b80 --- /dev/null +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -0,0 +1,45 @@ +validate(); + + /** @var MinecraftAccessKey|null $accessToken */ + $accessToken = MinecraftAccessKey::findOne([ + 'access_token' => $this->accessToken, + 'client_token' => $this->clientToken, + ]); + if ($accessToken === null) { + throw new ForbiddenOperationException('Invalid token.'); + } + + $accessToken->refreshPrimaryKeyValue(); + $accessToken->update(); + + $dataModel = new AuthenticateData($accessToken); + + return $dataModel; + } + +} diff --git a/api/modules/authserver/models/SignoutForm.php b/api/modules/authserver/models/SignoutForm.php new file mode 100644 index 0000000..64355e5 --- /dev/null +++ b/api/modules/authserver/models/SignoutForm.php @@ -0,0 +1,51 @@ +validate(); + + $loginForm = new LoginForm(); + $loginForm->login = $this->username; + $loginForm->password = $this->password; + if (!$loginForm->validate()) { + // На старом сервере авторизации использовалось поле nickname, а не username, так что сохраняем эту логику + $attribute = $loginForm->getLoginAttribute(); + if ($attribute === 'username') { + $attribute = 'nickname'; + } + + 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(); + + return true; + } + +} diff --git a/api/modules/authserver/models/ValidateForm.php b/api/modules/authserver/models/ValidateForm.php new file mode 100644 index 0000000..ec279eb --- /dev/null +++ b/api/modules/authserver/models/ValidateForm.php @@ -0,0 +1,35 @@ +validate(); + + /** @var MinecraftAccessKey|null $result */ + $result = MinecraftAccessKey::findOne($this->accessToken); + if ($result === null) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if (!$result->isActual()) { + $result->delete(); + throw new ForbiddenOperationException('Token expired.'); + } + + return true; + } + +} diff --git a/api/modules/authserver/validators/RequiredValidator.php b/api/modules/authserver/validators/RequiredValidator.php new file mode 100644 index 0000000..fe25c3c --- /dev/null +++ b/api/modules/authserver/validators/RequiredValidator.php @@ -0,0 +1,25 @@ +owner; - if ($owner->getPrimaryKey() === null) { - do { - $key = $this->generateValue(); - } while ($this->isValueExists($key)); - - $owner->{$this->getPrimaryKeyName()} = $key; + if ($this->owner->getPrimaryKey() === null) { + $this->refreshPrimaryKeyValue(); } return true; } + public function refreshPrimaryKeyValue() { + do { + $key = $this->generateValue(); + } while ($this->isValueExists($key)); + + $this->owner->{$this->getPrimaryKeyName()} = $key; + } + protected function generateValue() : string { return (string)call_user_func($this->value); } diff --git a/common/models/Account.php b/common/models/Account.php index f528d72..4227e9d 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -43,6 +43,7 @@ use const common\LATEST_RULES_VERSION; class Account extends ActiveRecord { const STATUS_DELETED = -10; + const STATUS_BANNED = -1; const STATUS_REGISTERED = 0; const STATUS_ACTIVE = 10; diff --git a/common/models/MinecraftAccessKey.php b/common/models/MinecraftAccessKey.php new file mode 100644 index 0000000..ea9f59c --- /dev/null +++ b/common/models/MinecraftAccessKey.php @@ -0,0 +1,60 @@ + TimestampBehavior::class, + ], + [ + 'class' => PrimaryKeyValueBehavior::class, + 'value' => function() { + return Uuid::uuid4()->toString(); + }, + ], + ]; + } + + public function getAccount() : ActiveQuery { + return $this->hasOne(Account::class, ['id' => 'account_id']); + } + + public function isActual() : bool { + return $this->timestamp + self::LIFETIME >= time(); + } + +} diff --git a/console/migrations/m160819_211139_minecraft_access_tokens.php b/console/migrations/m160819_211139_minecraft_access_tokens.php new file mode 100644 index 0000000..8756c47 --- /dev/null +++ b/console/migrations/m160819_211139_minecraft_access_tokens.php @@ -0,0 +1,25 @@ +createTable('{{%minecraft_access_keys}}', [ + 'access_token' => $this->string(36)->notNull(), + 'client_token' => $this->string(36)->notNull(), + 'account_id' => $this->db->getTableSchema('{{%accounts}}')->getColumn('id')->dbType . ' NOT NULL', + 'created_at' => $this->integer()->unsigned()->notNull(), + 'updated_at' => $this->integer()->unsigned()->notNull(), + ]); + + $this->addPrimaryKey('access_token', '{{%minecraft_access_keys}}', 'access_token'); + $this->addForeignKey('FK_minecraft_access_token_to_account', '{{%minecraft_access_keys}}', 'account_id', '{{%accounts}}', 'id', 'CASCADE', 'CASCADE'); + $this->createIndex('client_token', '{{%minecraft_access_keys}}', 'client_token', true); + } + + public function safeDown() { + $this->dropTable('{{%minecraft_access_keys}}'); + } + +} diff --git a/environments/dev/api/config/params-local.php b/environments/dev/api/config/params-local.php index 07dfeb8..597a2dc 100644 --- a/environments/dev/api/config/params-local.php +++ b/environments/dev/api/config/params-local.php @@ -1,4 +1,5 @@ 'some-long-secret-key', + 'authserverDomain' => 'http://authserver.ely.by.local', ]; diff --git a/environments/docker/api/config/params-local.php b/environments/docker/api/config/params-local.php index 07dfeb8..4c73d0d 100644 --- a/environments/docker/api/config/params-local.php +++ b/environments/docker/api/config/params-local.php @@ -1,4 +1,5 @@ 'some-long-secret-key', + 'authserverDomain' => 'https://authserver.ely.by', ]; diff --git a/environments/prod/api/config/params-local.php b/environments/prod/api/config/params-local.php index 07dfeb8..4c73d0d 100644 --- a/environments/prod/api/config/params-local.php +++ b/environments/prod/api/config/params-local.php @@ -1,4 +1,5 @@ 'some-long-secret-key', + 'authserverDomain' => 'https://authserver.ely.by', ]; diff --git a/tests/codeception/common/unit/behaviors/PrimaryKeyValueBehaviorTest.php b/tests/codeception/common/unit/behaviors/PrimaryKeyValueBehaviorTest.php index 7852ac5..d68831b 100644 --- a/tests/codeception/common/unit/behaviors/PrimaryKeyValueBehaviorTest.php +++ b/tests/codeception/common/unit/behaviors/PrimaryKeyValueBehaviorTest.php @@ -9,7 +9,7 @@ use yii\db\ActiveRecord; class PrimaryKeyValueBehaviorTest extends TestCase { use Specify; - public function testSetPrimaryKeyValue() { + public function testRefreshPrimaryKeyValue() { $this->specify('method should generate value for primary key field on call', function() { $model = new DummyModel(); /** @var PrimaryKeyValueBehavior|\PHPUnit_Framework_MockObject_MockObject $behavior */