Merge branch 'authserver_intergration'

This commit is contained in:
ErickSkrauch 2016-09-01 23:19:57 +03:00
commit 762fab447b
51 changed files with 1412 additions and 91 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace api\components;
use api\modules\authserver\exceptions\AuthserverException;
class ErrorHandler extends \yii\web\ErrorHandler {
public function convertExceptionToArray($exception) {
if ($exception instanceof AuthserverException) {
return [
'error' => $exception->getName(),
'errorMessage' => $exception->getMessage(),
];
}
return parent::convertExceptionToArray($exception);
}
}

View File

@ -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' => [
@ -47,5 +47,14 @@ return [
'class' => \common\components\oauth\Component::class,
'grantTypes' => ['authorization_code'],
],
'errorHandler' => [
'class' => \api\components\ErrorHandler::class,
],
],
'modules' => [
'authserver' => [
'class' => \api\modules\authserver\Module::class,
'baseDomain' => $params['authserverDomain'],
],
],
];

View File

@ -17,13 +17,10 @@ class ActiveUserRule extends AccessRule {
protected function matchCustom($action) {
$account = $this->getIdentity();
return $account->status > Account::STATUS_REGISTERED
return $account->status === Account::STATUS_ACTIVE
&& $account->isAgreedWithActualRules();
}
/**
* @return \api\models\AccountIdentity|null
*/
protected function getIdentity() {
return Yii::$app->getUser()->getIdentity();
}

View File

@ -54,7 +54,11 @@ class LoginForm extends ApiForm {
public function validateActivity($attribute) {
if (!$this->hasErrors()) {
$account = $this->getAccount();
if ($account->status !== Account::STATUS_ACTIVE) {
if ($account->status === Account::STATUS_BANNED) {
$this->addError($attribute, E::ACCOUNT_BANNED);
}
if ($account->status === Account::STATUS_REGISTERED) {
$this->addError($attribute, E::ACCOUNT_NOT_ACTIVATED);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace api\modules\authserver;
use Yii;
use yii\base\BootstrapInterface;
use yii\base\InvalidConfigException;
class Module extends \yii\base\Module implements BootstrapInterface {
public $id = 'authserver';
public $defaultRoute = 'index';
/**
* @var string базовый домен, запросы на который этот модуль должен обрабатывать
*/
public $baseDomain = 'https://authserver.ely.by';
public function init() {
parent::init();
if ($this->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/<action>' => $this->id . '/authentication/<action>',
], false);
}
public static function info($message) {
Yii::info($message, 'legacy-authserver');
}
public static function error($message) {
Yii::info($message, 'legacy-authserver');
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace api\modules\authserver\controllers;
use api\controllers\Controller;
use api\modules\authserver\models;
class AuthenticationController extends Controller {
public function behaviors() {
$behaviors = parent::behaviors();
unset($behaviors['authenticator']);
return $behaviors;
}
public function verbs() {
return [
'authenticate' => ['POST'],
'refresh' => ['POST'],
'validate' => ['POST'],
'signout' => ['POST'],
'invalidate' => ['POST'],
];
}
public function actionAuthenticate() {
$model = new models\AuthenticationForm();
$model->loadByPost();
return $model->authenticate()->getResponseData(true);
}
public function actionRefresh() {
$model = new models\RefreshTokenForm();
$model->loadByPost();
return $model->refresh()->getResponseData(false);
}
public function actionValidate() {
$model = new models\ValidateForm();
$model->loadByPost();
$model->validateToken();
// В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение,
// которое обработает ErrorHandler
}
public function actionSignout() {
$model = new models\SignoutForm();
$model->loadByPost();
$model->signout();
// В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение,
// которое обработает ErrorHandler
}
public function actionInvalidate() {
$model = new models\InvalidateForm();
$model->loadByPost();
$model->invalidateToken();
// В случае успеха ожидается пустой ответ. В случае ошибки же бросается исключение,
// которое обработает ErrorHandler
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace api\modules\authserver\controllers;
use api\controllers\Controller;
class IndexController extends Controller {
// TODO: симулировать для этого модуля обработчик 404 ошибок, как был в фалконе
public function notFoundAction() {
/*return $this->response
->setStatusCode(404, 'Not Found')
->setContent('Page not found. Check our <a href="http://docs.ely.by">documentation site</a>.');*/
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace api\modules\authserver\exceptions;
use ReflectionClass;
use yii\web\HttpException;
class AuthserverException extends HttpException {
/**
* Рефлексия быстрее, как ни странно:
* @url https://coderwall.com/p/cpxxxw/php-get-class-name-without-namespace#comment_19313
*
* @return string
*/
public function getName() {
return (new ReflectionClass($this))->getShortName();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace api\modules\authserver\exceptions;
class ForbiddenOperationException extends AuthserverException {
public function __construct($message, $code = 0, \Exception $previous = null) {
parent::__construct($status = 401, $message, $code, $previous);
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace api\modules\authserver\exceptions;
class IllegalArgumentException extends AuthserverException {
public function __construct($status = null, $message = null, $code = 0, \Exception $previous = null) {
parent::__construct(400, 'credentials can not be null.', $code, $previous);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace api\modules\authserver\models;
use common\models\MinecraftAccessKey;
class AuthenticateData {
/**
* @var MinecraftAccessKey
*/
private $minecraftAccessKey;
public function __construct(MinecraftAccessKey $minecraftAccessKey) {
$this->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;
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace api\modules\authserver\models;
use api\models\authentication\LoginForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\Module as Authserver;
use api\modules\authserver\validators\RequiredValidator;
use common\helpers\Error as E;
use common\models\Account;
use common\models\MinecraftAccessKey;
class AuthenticationForm extends Form {
public $username;
public $password;
public $clientToken;
public function rules() {
return [
[['username', 'password', 'clientToken'], RequiredValidator::class],
];
}
/**
* @return AuthenticateData
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
public function authenticate() {
$this->validate();
Authserver::info("Trying to authenticate user by login = '{$this->username}'.");
$loginForm = $this->createLoginForm();
$loginForm->login = $this->username;
$loginForm->password = $this->password;
if (!$loginForm->validate()) {
$errors = $loginForm->getFirstErrors();
if (isset($errors['login'])) {
if ($errors['login'] === E::ACCOUNT_BANNED) {
Authserver::error("User with login = '{$this->username}' is banned");
throw new ForbiddenOperationException('This account has been suspended.');
} else {
Authserver::error("Cannot find user by login = '{$this->username}'");
}
} elseif (isset($errors['password'])) {
Authserver::error("User with login = '{$this->username}' passed wrong password.");
}
// На старом сервере авторизации использовалось поле nickname, а не username, так что сохраняем эту логику
$attribute = $loginForm->getLoginAttribute();
if ($attribute === 'username') {
$attribute = 'nickname';
}
// TODO: эта логика дублируется с логикой в SignoutForm
throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password.");
}
$account = $loginForm->getAccount();
$accessTokenModel = $this->createMinecraftAccessToken($account);
$dataModel = new AuthenticateData($accessTokenModel);
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([
'client_token' => $this->clientToken,
'account_id' => $account->id,
]);
if ($accessTokenModel === null) {
$accessTokenModel = new MinecraftAccessKey();
$accessTokenModel->client_token = $this->clientToken;
$accessTokenModel->account_id = $account->id;
$accessTokenModel->insert();
} else {
$accessTokenModel->refreshPrimaryKeyValue();
}
return $accessTokenModel;
}
protected function createLoginForm() : LoginForm {
return new LoginForm();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace api\modules\authserver\models;
use Yii;
use yii\base\Model;
abstract class Form extends Model {
public function formName() {
return '';
}
public function loadByGet() {
return $this->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);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace api\modules\authserver\models;
use api\modules\authserver\validators\RequiredValidator;
use common\models\MinecraftAccessKey;
class InvalidateForm extends Form {
public $accessToken;
public $clientToken;
public function rules() {
return [
[['accessToken', 'clientToken'], RequiredValidator::class],
];
}
/**
* @return bool
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
public function invalidateToken() : bool {
$this->validate();
$token = MinecraftAccessKey::findOne([
'access_token' => $this->accessToken,
'client_token' => $this->clientToken,
]);
if ($token !== null) {
$token->delete();
}
return true;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace api\modules\authserver\models;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\RequiredValidator;
use common\models\Account;
use common\models\MinecraftAccessKey;
class RefreshTokenForm extends Form {
public $accessToken;
public $clientToken;
public function rules() {
return [
[['accessToken', 'clientToken'], RequiredValidator::class],
];
}
/**
* @return AuthenticateData
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
public function refresh() {
$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 ($accessToken->account->status === Account::STATUS_BANNED) {
throw new ForbiddenOperationException('This account has been suspended.');
}
$accessToken->refreshPrimaryKeyValue();
$accessToken->update();
$dataModel = new AuthenticateData($accessToken);
return $dataModel;
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace api\modules\authserver\models;
use api\models\authentication\LoginForm;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\RequiredValidator;
use common\helpers\Error as E;
use common\models\MinecraftAccessKey;
use Yii;
class SignoutForm extends Form {
public $username;
public $password;
public function rules() {
return [
[['username', 'password'], RequiredValidator::class],
];
}
public function signout() : bool {
$this->validate();
$loginForm = new LoginForm();
$loginForm->login = $this->username;
$loginForm->password = $this->password;
if (!$loginForm->validate()) {
$errors = $loginForm->getFirstErrors();
if (isset($errors['login']) && $errors['login'] === E::ACCOUNT_BANNED) {
// Считаем, что заблокированный может безболезненно выйти
return true;
}
// На старом сервере авторизации использовалось поле 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;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace api\modules\authserver\models;
use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\RequiredValidator;
use common\models\MinecraftAccessKey;
class ValidateForm extends Form {
public $accessToken;
public function rules() {
return [
[['accessToken'], RequiredValidator::class],
];
}
public function validateToken() : bool {
$this->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;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace api\modules\authserver\validators;
use api\modules\authserver\exceptions\IllegalArgumentException;
/**
* Для данного модуля нам не принципиально, что там за ошибка: если не хватает хотя бы одного
* параметра - тут же отправляем исключение и дело с концом
*/
class RequiredValidator extends \yii\validators\RequiredValidator {
/**
* @param string $value
* @return null
* @throws \api\modules\authserver\exceptions\AuthserverException
*/
protected function validateValue($value) {
if (parent::validateValue($value) !== null) {
throw new IllegalArgumentException();
}
return null;
}
}

View File

@ -25,18 +25,21 @@ class PrimaryKeyValueBehavior extends Behavior {
}
public function setPrimaryKeyValue() : bool {
$owner = $this->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);
}

View File

@ -11,7 +11,7 @@ class UuidAlgorithm extends DefaultAlgorithm implements KeyAlgorithmInterface {
* @inheritdoc
*/
public function generate($len = 40) : string {
return Uuid::uuid5(Uuid::NAMESPACE_DNS, parent::generate($len))->toString();
return Uuid::uuid4()->toString();
}
}

View File

@ -27,6 +27,7 @@ final class Error {
const KEY_NOT_EXISTS = 'error.key_not_exists';
const KEY_EXPIRE = 'error.key_expire';
const ACCOUNT_BANNED = 'error.account_banned';
const ACCOUNT_NOT_ACTIVATED = 'error.account_not_activated';
const ACCOUNT_ALREADY_ACTIVATED = 'error.account_already_activated';
const ACCOUNT_CANNOT_RESEND_MESSAGE = 'error.account_cannot_resend_message';

View File

@ -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;

View File

@ -0,0 +1,60 @@
<?php
namespace common\models;
use common\behaviors\PrimaryKeyValueBehavior;
use Ramsey\Uuid\Uuid;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
/**
* Это временный класс, куда мигрирует вся логика ныне существующего authserver.ely.by.
* Поскольку там допускался вход по логину и паролю, а формат хранения выданных токенов был
* иным, то на период, пока мы окончательно не мигрируем, нужно сохранить старую логику
* и структуру под неё.
*
* Поля модели:
* @property string $access_token
* @property string $client_token
* @property integer $account_id
* @property integer $created_at
* @property integer $updated_at
*
* Отношения:
* @property Account $account
*
* Поведения:
* @mixin TimestampBehavior
* @mixin PrimaryKeyValueBehavior
*/
class MinecraftAccessKey extends ActiveRecord {
const LIFETIME = 172800; // Ключ актуален в течение 2 дней
public static function tableName() {
return '{{%minecraft_access_keys}}';
}
public function behaviors() {
return [
[
'class' => 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->updated_at + self::LIFETIME >= time();
}
}

View File

@ -14,14 +14,14 @@
},
"minimum-stability": "stable",
"require": {
"php": "~7.0.6",
"php": "^7.0.6",
"yiisoft/yii2": "2.0.9",
"yiisoft/yii2-swiftmailer": "*",
"ramsey/uuid": "~3.1",
"ramsey/uuid": "^3.5.0",
"league/oauth2-server": "~4.1.5",
"yiisoft/yii2-redis": "~2.0.0",
"guzzlehttp/guzzle": "^6.0.0",
"php-amqplib/php-amqplib": "~2.6.2",
"php-amqplib/php-amqplib": "^2.6.2",
"ely/yii2-tempmail-validator": "~1.0.0",
"emarref/jwt": "~1.0.2",
"ely/amqp-controller": "^0.1.0"

View File

@ -0,0 +1,25 @@
<?php
use console\db\Migration;
class m160819_211139_minecraft_access_tokens extends Migration {
public function safeUp() {
$this->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}}');
}
}

View File

@ -14,12 +14,13 @@ services:
web:
build: ./docker/nginx
ports:
- "80:80"
links:
- app
volumes_from:
- app
environment:
- AUTHSERVER_HOST=authserver.ely.by
- PHP_LINK=app
node-dev-server:
build: ./frontend

View File

@ -1,3 +1,10 @@
FROM nginx:1.9
COPY nginx.conf /etc/nginx/nginx.conf
COPY account.ely.by.conf.template /etc/nginx/conf.d/account.ely.by.conf.template
COPY run.sh /run.sh
RUN rm /etc/nginx/conf.d/default.conf \
&& chmod a+x /run.sh
CMD ["/run.sh"]

View File

@ -0,0 +1,54 @@
server {
listen 80;
set $root_path '/var/www/html';
set $api_path '${root_path}/api/web';
set $frontend_path '${root_path}/frontend/dist';
set $authserver_host '${AUTHSERVER_HOST}';
root $root_path;
charset utf-8;
client_max_body_size 2M;
etag on;
set $request_url $request_uri;
if ($host = $authserver_host) {
set $request_url '/api/authserver${request_uri}';
}
location / {
if ($host = $authserver_host) {
rewrite ^ /api/authserver$uri last;
}
alias $frontend_path;
index index.html;
try_files $uri /index.html =404;
}
location /api {
try_files $uri $uri /api/web/index.php?$is_args$args;
}
location ~* \.php$ {
fastcgi_pass ${PHP_LINK}:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SERVER_NAME $host;
fastcgi_param REQUEST_URI $request_url;
try_files $uri =404;
}
# html файлы идут отдельно, для них будет применяться E-Tag кэширование
location ~* \.html$ {
root $frontend_path;
access_log off;
}
# Раздача статики для frontend с указанием max-кэша. Сброс будет по #hash после ребилда webpackом
location ~* ^.+\.(jpg|jpeg|gif|png|svg|js|json|css|zip|rar|eot|ttf|woff|ico) {
root $frontend_path;
expires max;
access_log off;
}
}

View File

@ -21,47 +21,5 @@ http {
sendfile on;
keepalive_timeout 10;
server {
listen 80;
set $root_path '/var/www/html';
set $api_path '${root_path}/api/web';
set $frontend_path '${root_path}/frontend/dist';
root $root_path;
charset utf-8;
client_max_body_size 2M;
etag on;
location / {
alias $frontend_path;
index index.html;
try_files $uri /index.html =404;
}
location /api {
try_files $uri /api/web/index.php?$args;
}
location ~* \.php$ {
fastcgi_pass app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SERVER_NAME $host;
}
# html файлы идут отдельно, для них будет применяться E-Tag кэширование
location ~* \.html$ {
root $frontend_path;
access_log off;
}
# Раздача статики для frontend с указанием max-кэша. Сброс будет по #hash после ребилда webpackом
location ~* ^.+\.(jpg|jpeg|gif|png|svg|js|json|css|zip|rar|eot|ttf|woff|ico) {
root $frontend_path;
expires max;
access_log off;
}
}
include /etc/nginx/conf.d/*.conf;
}

4
docker/nginx/run.sh Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
envsubst '$AUTHSERVER_HOST:$PHP_LINK' < /etc/nginx/conf.d/account.ely.by.conf.template > /etc/nginx/conf.d/default.conf
nginx -g 'daemon off;'

View File

@ -1,4 +1,5 @@
<?php
return [
'userSecret' => 'some-long-secret-key',
'authserverDomain' => 'http://authserver.ely.by.local',
];

View File

@ -1,4 +1,5 @@
<?php
return [
'userSecret' => 'some-long-secret-key',
'authserverDomain' => 'https://authserver.ely.by',
];

View File

@ -1,4 +1,5 @@
<?php
return [
'userSecret' => 'some-long-secret-key',
'authserverDomain' => 'https://authserver.ely.by',
];

View File

@ -0,0 +1,36 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* @property \tests\codeception\api\FunctionalTester $actor
*/
class AuthserverRoute extends BasePage {
public function authenticate($params) {
$this->route = ['authserver/authentication/authenticate'];
$this->actor->sendPOST($this->getUrl(), $params);
}
public function refresh($params) {
$this->route = ['authserver/authentication/refresh'];
$this->actor->sendPOST($this->getUrl(), $params);
}
public function validate($params) {
$this->route = ['authserver/authentication/validate'];
$this->actor->sendPOST($this->getUrl(), $params);
}
public function invalidate($params) {
$this->route = ['authserver/authentication/invalidate'];
$this->actor->sendPOST($this->getUrl(), $params);
}
public function signout($params) {
$this->route = ['authserver/authentication/signout'];
$this->actor->sendPOST($this->getUrl(), $params);
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* Represents contact page
* @property \tests\codeception\api\FunctionalTester $actor
*/
class ContactPage extends BasePage {
public $route = 'site/contact';
/**
* @param array $contactData
*/
public function submit(array $contactData) {
foreach ($contactData as $field => $value) {
$inputType = $field === 'body' ? 'textarea' : 'input';
$this->actor->fillField($inputType . '[name="ContactForm[' . $field . ']"]', $value);
}
$this->actor->click('contact-button');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace tests\codeception\api\functional\_steps;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\_pages\AuthserverRoute;
use tests\codeception\api\FunctionalTester;
class AuthserverSteps extends FunctionalTester {
public function amAuthenticated() {
$route = new AuthserverRoute($this);
$clientToken = Uuid::uuid4()->toString();
$route->authenticate([
'username' => 'admin',
'password' => 'password_0',
'clientToken' => $clientToken,
]);
$accessToken = $this->grabDataFromResponseByJsonPath('$.accessToken')[0];
return [$accessToken, $clientToken];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace tests\codeception\api\functional\authserver;
use tests\codeception\api\_pages\AuthserverRoute;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\FunctionalTester;
class AuthorizationCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new AuthserverRoute($I);
}
public function byName(FunctionalTester $I) {
$I->wantTo('authenticate by username and password');
$this->route->authenticate([
'username' => 'admin',
'password' => 'password_0',
'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 wrongArguments(FunctionalTester $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->authenticate([
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'IllegalArgumentException',
'errorMessage' => 'credentials can not be null.',
]);
}
public function wrongNicknameAndPassword(FunctionalTester $I) {
$I->wantTo('authenticate by username and password with wrong data');
$this->route->authenticate([
'username' => 'nonexistent_user',
'password' => 'nonexistent_password',
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid credentials. Invalid nickname or password.',
]);
}
public function bannedAccount(FunctionalTester $I) {
$I->wantTo('authenticate in suspended account');
$this->route->authenticate([
'username' => 'Banned',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'This account has been suspended.',
]);
}
private function testSuccessResponse(FunctionalTester $I) {
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->canSeeResponseJsonMatchesJsonPath('$.accessToken');
$I->canSeeResponseJsonMatchesJsonPath('$.clientToken');
$I->canSeeResponseJsonMatchesJsonPath('$.availableProfiles[0].id');
$I->canSeeResponseJsonMatchesJsonPath('$.availableProfiles[0].name');
$I->canSeeResponseJsonMatchesJsonPath('$.availableProfiles[0].legacy');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name');
$I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace tests\codeception\api\functional\authserver;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\_pages\AuthserverRoute;
use tests\codeception\api\functional\_steps\AuthserverSteps;
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');
list($accessToken, $clientToken) = $I->amAuthenticated();
$this->route->invalidate([
'accessToken' => $accessToken,
'clientToken' => $clientToken,
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->invalidate([
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'IllegalArgumentException',
'errorMessage' => 'credentials can not be null.',
]);
}
public function wrongAccessTokenOrClientToken(AuthserverSteps $I) {
$I->wantTo('invalidate by wrong client and access token');
$this->route->invalidate([
'accessToken' => Uuid::uuid4()->toString(),
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace tests\codeception\api\functional\authserver;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\_pages\AuthserverRoute;
use tests\codeception\api\functional\_steps\AuthserverSteps;
class RefreshCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function refresh(AuthserverSteps $I) {
$I->wantTo('refresh my accessToken');
list($accessToken, $clientToken) = $I->amAuthenticated();
$this->route->refresh([
'accessToken' => $accessToken,
'clientToken' => $clientToken,
]);
$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 wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->refresh([
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'IllegalArgumentException',
'errorMessage' => 'credentials can not be null.',
]);
}
public function wrongAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on wrong access or client tokens');
$this->route->refresh([
'accessToken' => Uuid::uuid4()->toString(),
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid token.',
]);
}
public function refreshTokenFromBannedUser(AuthserverSteps $I) {
$I->wantTo('refresh token from suspended account');
$this->route->refresh([
'accessToken' => '918ecb41-616c-40ee-a7d2-0b0ef0d0d732',
'clientToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'This account has been suspended.',
]);
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace tests\codeception\api\functional\authserver;
use tests\codeception\api\_pages\AuthserverRoute;
use tests\codeception\api\functional\_steps\AuthserverSteps;
class SignoutCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function byName(AuthserverSteps $I) {
$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->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->signout([
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'IllegalArgumentException',
'errorMessage' => 'credentials can not be null.',
]);
}
public function wrongNicknameAndPassword(AuthserverSteps $I) {
$I->wantTo('signout by nickname and password with wrong data');
$this->route->signout([
'username' => 'nonexistent_user',
'password' => 'nonexistent_password',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid credentials. Invalid nickname or password.',
]);
}
public function bannedAccount(AuthserverSteps $I) {
$I->wantTo('signout from banned account');
$this->route->signout([
'username' => 'Banned',
'password' => 'password_0',
]);
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace tests\codeception\api\functional\authserver;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\_pages\AuthserverRoute;
use tests\codeception\api\functional\_steps\AuthserverSteps;
class ValidateCest {
/**
* @var AuthserverRoute
*/
private $route;
public function _before(AuthserverSteps $I) {
$this->route = new AuthserverRoute($I);
}
public function validate(AuthserverSteps $I) {
$I->wantTo('validate my accessToken');
list($accessToken) = $I->amAuthenticated();
$this->route->validate([
'accessToken' => $accessToken,
]);
$I->seeResponseCodeIs(200);
$I->canSeeResponseEquals('');
}
public function wrongArguments(AuthserverSteps $I) {
$I->wantTo('get error on wrong amount of arguments');
$this->route->validate([
'key' => 'value',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'IllegalArgumentException',
'errorMessage' => 'credentials can not be null.',
]);
}
public function wrongAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on wrong accessToken');
$this->route->validate([
'accessToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Invalid token.',
]);
}
public function expiredAccessToken(AuthserverSteps $I) {
$I->wantTo('get error on expired accessToken');
$this->route->validate([
// Заведомо истёкший токен из дампа
'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Token expired.',
]);
}
}

View File

@ -18,7 +18,13 @@ class ActiveUserRuleTest extends TestCase {
$account = new AccountIdentity();
$this->specify('get false if user not finished registration', function() use (&$account) {
$account->status = 0;
$account->status = Account::STATUS_REGISTERED;
$filter = $this->getFilterMock($account);
expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->false();
});
$this->specify('get false if user has banned status', function() use (&$account) {
$account->status = Account::STATUS_BANNED;
$filter = $this->getFilterMock($account);
expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->false();
});

View File

@ -8,7 +8,6 @@ use Codeception\Specify;
use common\models\Account;
use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\fixtures\AccountFixture;
use Yii;
/**
* @property AccountFixture $accounts
@ -84,6 +83,14 @@ class LoginFormTest extends DbTestCase {
expect($model->getErrors('login'))->equals(['error.account_not_activated']);
});
$this->specify('error.account_banned if account has banned status', function () {
$model = $this->createModel([
'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]),
]);
$model->validateActivity('login');
expect($model->getErrors('login'))->equals(['error.account_banned']);
});
$this->specify('no errors if account active', function () {
$model = $this->createModel([
'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]),

View File

@ -0,0 +1,147 @@
<?php
namespace codeception\api\unit\modules\authserver\models;
use api\models\AccountIdentity;
use api\models\authentication\LoginForm;
use api\modules\authserver\models\AuthenticateData;
use api\modules\authserver\models\AuthenticationForm;
use common\models\Account;
use common\models\MinecraftAccessKey;
use Ramsey\Uuid\Uuid;
use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\_support\ProtectedCaller;
use tests\codeception\common\fixtures\AccountFixture;
use tests\codeception\common\fixtures\MinecraftAccessKeyFixture;
/**
* @property AccountFixture $accounts
* @property MinecraftAccessKeyFixture $minecraftAccessKeys
*/
class AuthenticationFormTest extends DbTestCase {
use ProtectedCaller;
public function fixtures() {
return [
'accounts' => AccountFixture::class,
'minecraftAccessKeys' => MinecraftAccessKeyFixture::class,
];
}
/**
* @expectedException \api\modules\authserver\exceptions\ForbiddenOperationException
* @expectedExceptionMessage Invalid credentials. Invalid nickname or password.
*/
public function testAuthenticateByWrongNicknamePass() {
$authForm = $this->createAuthForm();
$authForm->username = 'wrong-username';
$authForm->password = 'wrong-password';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
/**
* @expectedException \api\modules\authserver\exceptions\ForbiddenOperationException
* @expectedExceptionMessage Invalid credentials. Invalid email or password.
*/
public function testAuthenticateByWrongEmailPass() {
$authForm = $this->createAuthForm();
$authForm->username = 'wrong-email@ely.by';
$authForm->password = 'wrong-password';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
/**
* @expectedException \api\modules\authserver\exceptions\ForbiddenOperationException
* @expectedExceptionMessage This account has been suspended.
*/
public function testAuthenticateByValidCredentialsIntoBlockedAccount() {
$authForm = $this->createAuthForm(Account::STATUS_BANNED);
$authForm->username = 'dummy';
$authForm->password = 'password_0';
$authForm->clientToken = Uuid::uuid4();
$authForm->authenticate();
}
public function testAuthenticateByValidCredentials() {
$authForm = $this->createAuthForm();
$minecraftAccessKey = new MinecraftAccessKey();
$minecraftAccessKey->access_token = Uuid::uuid4();
$authForm->expects($this->once())
->method('createMinecraftAccessToken')
->will($this->returnValue($minecraftAccessKey));
$authForm->username = 'dummy';
$authForm->password = 'password_0';
$authForm->clientToken = Uuid::uuid4();
$result = $authForm->authenticate();
$this->assertInstanceOf(AuthenticateData::class, $result);
$this->assertEquals($minecraftAccessKey->access_token, $result->getMinecraftAccessKey()->access_token);
}
public function testCreateMinecraftAccessToken() {
$authForm = new AuthenticationForm();
$fixturesCount = count($this->minecraftAccessKeys->data);
$authForm->clientToken = Uuid::uuid4();
/** @var Account $account */
$account = $this->accounts->getModel('admin');
/** @var MinecraftAccessKey $result */
$result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account);
$this->assertInstanceOf(MinecraftAccessKey::class, $result);
$this->assertEquals($account->id, $result->account_id);
$this->assertEquals($authForm->clientToken, $result->client_token);
$this->assertEquals($fixturesCount + 1, MinecraftAccessKey::find()->count());
}
public function testCreateMinecraftAccessTokenWithExistsClientId() {
$authForm = new AuthenticationForm();
$fixturesCount = count($this->minecraftAccessKeys->data);
$authForm->clientToken = $this->minecraftAccessKeys['admin-token']['client_token'];
/** @var Account $account */
$account = $this->accounts->getModel('admin');
/** @var MinecraftAccessKey $result */
$result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account);
$this->assertInstanceOf(MinecraftAccessKey::class, $result);
$this->assertEquals($account->id, $result->account_id);
$this->assertEquals($authForm->clientToken, $result->client_token);
$this->assertEquals($fixturesCount, MinecraftAccessKey::find()->count());
}
private function createAuthForm($status = Account::STATUS_ACTIVE) {
/** @var LoginForm|\PHPUnit_Framework_MockObject_MockObject $loginForm */
$loginForm = $this->getMockBuilder(LoginForm::class)
->setMethods(['getAccount'])
->getMock();
$account = new AccountIdentity();
$account->username = 'dummy';
$account->email = 'dummy@ely.by';
$account->status = $status;
$account->setPassword('password_0');
$loginForm->expects($this->any())
->method('getAccount')
->will($this->returnValue($account));
/** @var AuthenticationForm|\PHPUnit_Framework_MockObject_MockObject $authForm */
$authForm = $this->getMockBuilder(AuthenticationForm::class)
->setMethods(['createLoginForm', 'createMinecraftAccessToken'])
->getMock();
$authForm->expects($this->any())
->method('createLoginForm')
->will($this->returnValue($loginForm));
return $authForm;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace codeception\api\unit\modules\authserver\validators;
use api\modules\authserver\validators\RequiredValidator;
use tests\codeception\api\unit\TestCase;
use tests\codeception\common\_support\ProtectedCaller;
class RequiredValidatorTest extends TestCase {
use ProtectedCaller;
public function testValidateValueNormal() {
$validator = new RequiredValidator();
$this->assertNull($this->callProtected($validator, 'validateValue', 'dummy'));
}
/**
* @expectedException \api\modules\authserver\exceptions\IllegalArgumentException
*/
public function testValidateValueEmpty() {
$validator = new RequiredValidator();
$this->assertNull($this->callProtected($validator, 'validateValue', ''));
}
}

View File

@ -6,6 +6,7 @@ use Codeception\TestCase;
use tests\codeception\common\fixtures\AccountFixture;
use tests\codeception\common\fixtures\AccountSessionFixture;
use tests\codeception\common\fixtures\EmailActivationFixture;
use tests\codeception\common\fixtures\MinecraftAccessKeyFixture;
use tests\codeception\common\fixtures\OauthClientFixture;
use tests\codeception\common\fixtures\OauthSessionFixture;
use tests\codeception\common\fixtures\UsernameHistoryFixture;
@ -59,6 +60,7 @@ class FixtureHelper extends Module {
'class' => OauthSessionFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/oauth-sessions.php',
],
'minecraftAccessKeys' => MinecraftAccessKeyFixture::class,
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace tests\codeception\common\fixtures;
use common\models\MinecraftAccessKey;
use yii\test\ActiveFixture;
class MinecraftAccessKeyFixture extends ActiveFixture {
public $modelClass = MinecraftAccessKey::class;
public $dataFile = '@tests/codeception/common/fixtures/data/minecraft-access-keys.php';
public $depends = [
AccountFixture::class,
];
}

View File

@ -120,4 +120,17 @@ return [
'created_at' => 1470499952,
'updated_at' => 1470499952,
],
'banned-account' => [
'id' => 10,
'uuid' => 'd2e7360e-50cf-4b9b-baa0-c4440a150795',
'username' => 'Banned',
'email' => 'banned@ely.by',
'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0
'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'lang' => 'en',
'status' => \common\models\Account::STATUS_BANNED,
'rules_agreement_version' => \common\LATEST_RULES_VERSION,
'created_at' => 1472682343,
'updated_at' => 1472682343,
],
];

View File

@ -0,0 +1,24 @@
<?php
return [
'admin-token' => [
'access_token' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42',
'client_token' => '6f380440-0c05-47bd-b7c6-d011f1b5308f',
'account_id' => 1,
'created_at' => time() - 10,
'updated_at' => time() - 10,
],
'expired-token' => [
'access_token' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
'client_token' => '47fb164a-2332-42c1-8bad-549e67bb210c',
'account_id' => 1,
'created_at' => 1472423530,
'updated_at' => 1472423530,
],
'banned-token' => [
'access_token' => '918ecb41-616c-40ee-a7d2-0b0ef0d0d732',
'client_token' => '6042634a-a1e2-4aed-866c-c661fe4e63e2',
'account_id' => 10,
'created_at' => time() - 10,
'updated_at' => time() - 10,
],
];

View File

@ -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 */

View File

@ -30,5 +30,9 @@ return [
'password' => 'tester-password',
'vhost' => '/account.ely.by/tests',
],
'security' => [
// Для тестов нам не сильно важна безопасность, а вот время прохождения тестов значительно сокращается
'passwordHashCost' => 4,
],
],
];