Merge branch 'develop'

This commit is contained in:
ErickSkrauch
2017-03-07 01:45:28 +03:00
13 changed files with 214 additions and 34 deletions

View File

@@ -27,6 +27,11 @@ use yii\web\User as YiiUserComponent;
*/
class Component extends YiiUserComponent {
const TERMINATE_MINECRAFT_SESSIONS = 1;
const TERMINATE_SITE_SESSIONS = 2;
const DO_NOT_TERMINATE_CURRENT_SESSION = 4;
const TERMINATE_ALL = self::TERMINATE_MINECRAFT_SESSIONS | self::TERMINATE_SITE_SESSIONS;
public $enableSession = false;
public $loginUrl = null;
@@ -184,6 +189,24 @@ class Component extends YiiUserComponent {
return AccountSession::findOne($sessionId->getValue());
}
public function terminateSessions(int $mode = self::TERMINATE_ALL | self::DO_NOT_TERMINATE_CURRENT_SESSION): void {
$identity = $this->getIdentity();
$activeSession = ($mode & self::DO_NOT_TERMINATE_CURRENT_SESSION) ? $this->getActiveSession() : null;
if ($mode & self::TERMINATE_SITE_SESSIONS) {
foreach ($identity->sessions as $session) {
if ($activeSession === null || $activeSession->id !== $session->id) {
$session->delete();
}
}
}
if ($mode & self::TERMINATE_MINECRAFT_SESSIONS) {
foreach ($identity->minecraftAccessKeys as $minecraftAccessKey) {
$minecraftAccessKey->delete();
}
}
}
public function getAlgorithm() : AlgorithmInterface {
return new Hs256($this->secret);
}

View File

@@ -51,6 +51,7 @@ class ForgotPasswordForm extends ApiForm {
}
$validator = new TotpValidator(['account' => $account]);
$validator->window = 1;
$validator->validateAttribute($this, $attribute);
}

View File

@@ -69,6 +69,7 @@ class LoginForm extends ApiForm {
}
$validator = new TotpValidator(['account' => $account]);
$validator->window = 1;
$validator->validateAttribute($this, $attribute);
}

View File

@@ -66,15 +66,7 @@ class ChangePasswordForm extends ApiForm {
$account->setPassword($this->newPassword);
if ($this->logoutAll) {
/** @var \api\components\User\Component $userComponent */
$userComponent = Yii::$app->user;
$sessions = $account->sessions;
$activeSession = $userComponent->getActiveSession();
foreach ($sessions as $session) {
if (!$activeSession || $activeSession->id !== $session->id) {
$session->delete();
}
}
Yii::$app->user->terminateSessions();
}
if (!$account->save()) {

View File

@@ -14,6 +14,7 @@ use common\components\Qr\ElyDecorator;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use Yii;
use yii\base\ErrorException;
class TwoFactorAuthForm extends ApiForm {
@@ -23,6 +24,8 @@ class TwoFactorAuthForm extends ApiForm {
public $token;
public $timestamp;
public $password;
/**
@@ -38,10 +41,16 @@ class TwoFactorAuthForm extends ApiForm {
public function rules() {
$bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE];
return [
['timestamp', 'integer', 'on' => [self::SCENARIO_ACTIVATE]],
['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE],
['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE],
['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $bothScenarios],
['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $bothScenarios],
['token', TotpValidator::class, 'on' => $bothScenarios,
'account' => $this->account,
'timestamp' => function() {
return $this->timestamp;
},
],
['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios],
];
}
@@ -65,12 +74,18 @@ class TwoFactorAuthForm extends ApiForm {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->account;
$account->is_otp_enabled = true;
if (!$account->save()) {
throw new ErrorException('Cannot enable otp for account');
}
Yii::$app->user->terminateSessions();
$transaction->commit();
return true;
}
@@ -128,8 +143,18 @@ class TwoFactorAuthForm extends ApiForm {
return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H);
}
protected function setOtpSecret(): void {
$this->account->otp_secret = trim(Base32::encode(random_bytes(32)), '=');
/**
* otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов,
* которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая
* строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить
* ему такую длину, чтобы 160% его длины было равно запрошенному значению
*
* @param int $length
* @throws ErrorException
*/
protected function setOtpSecret(int $length = 24): void {
$randomBytesLength = ceil($length / 1.6);
$this->account->otp_secret = substr(trim(Base32::encode(random_bytes($randomBytesLength)), '='), 0, $length);
if (!$this->account->save()) {
throw new ErrorException('Cannot set account otp_secret');
}

View File

@@ -35,7 +35,10 @@ class AuthenticationForm extends Form {
$loginForm->password = $this->password;
if (!$loginForm->validate()) {
$errors = $loginForm->getFirstErrors();
if (isset($errors['login'])) {
if (isset($errors['token'])) {
Authserver::error("User with login = '{$this->username}' protected by two factor auth.");
throw new ForbiddenOperationException('Account protected with two factor auth.');
} elseif (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.');

View File

@@ -19,9 +19,17 @@ class TotpValidator extends Validator {
* @var int|null Задаёт окно, в промежуток которого будет проверяться код.
* Позволяет избежать ситуации, когда пользователь ввёл код в последнюю секунду
* его существования и пока шёл запрос, тот протух.
* Значение задаётся в +- кодах, а не секундах.
*/
public $window;
/**
* @var int|callable|null Позволяет задать точное время, относительно которого будет
* выполняться проверка. Это может быть собственно время или функция, возвращающая значение.
* Если не задано, то будет использовано текущее время.
*/
public $timestamp;
public $skipOnEmpty = false;
public function init() {
@@ -41,11 +49,24 @@ class TotpValidator extends Validator {
protected function validateValue($value) {
$totp = new TOTP(null, $this->account->otp_secret);
if (!$totp->verify((string)$value, null, $this->window)) {
if (!$totp->verify((string)$value, $this->getTimestamp(), $this->window)) {
return [E::OTP_TOKEN_INCORRECT, []];
}
return null;
}
private function getTimestamp(): ?int {
$timestamp = $this->timestamp;
if (is_callable($timestamp)) {
$timestamp = call_user_func($this->timestamp);
}
if ($timestamp === null) {
return null;
}
return (int)$timestamp;
}
}

View File

@@ -1,6 +1,6 @@
<?php
return [
'version' => '1.1.6',
'version' => '1.1.7',
'vendorPath' => dirname(dirname(__DIR__)) . '/vendor',
'components' => [
'cache' => [

View File

@@ -38,6 +38,21 @@ class AuthorizationCest {
$this->testSuccessResponse($I);
}
public function byEmailWithEnabledTwoFactorAuth(FunctionalTester $I) {
$I->wantTo('get valid error by authenticate account with enabled two factor auth');
$this->route->authenticate([
'username' => 'otp@gmail.com',
'password' => 'password_0',
'clientToken' => Uuid::uuid4()->toString(),
]);
$I->canSeeResponseCodeIs(401);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'error' => 'ForbiddenOperationException',
'errorMessage' => 'Account protected with two factor auth.',
]);
}
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([

View File

@@ -15,6 +15,7 @@ use tests\codeception\api\unit\TestCase;
use tests\codeception\common\_support\ProtectedCaller;
use tests\codeception\common\fixtures\AccountFixture;
use tests\codeception\common\fixtures\AccountSessionFixture;
use tests\codeception\common\fixtures\MinecraftAccessKeyFixture;
use Yii;
use yii\web\Request;
@@ -36,6 +37,7 @@ class ComponentTest extends TestCase {
return [
'accounts' => AccountFixture::class,
'sessions' => AccountSessionFixture::class,
'minecraftSessions' => MinecraftAccessKeyFixture::class,
];
}
@@ -166,6 +168,43 @@ class ComponentTest extends TestCase {
});
}
public function testTerminateSessions() {
/** @var AccountSession $session */
$session = AccountSession::findOne($this->tester->grabFixture('sessions', 'admin2')['id']);
/** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */
$component = $this->getMockBuilder(Component::class)
->setMethods(['getActiveSession'])
->setConstructorArgs([$this->getComponentArguments()])
->getMock();
$component
->expects($this->exactly(1))
->method('getActiveSession')
->willReturn($session);
/** @var AccountIdentity $identity */
$identity = AccountIdentity::findOne($this->tester->grabFixture('accounts', 'admin')['id']);
$component->login($identity, true);
$component->terminateSessions(0);
$this->assertNotEmpty($identity->getMinecraftAccessKeys()->all());
$this->assertNotEmpty($identity->getSessions()->all());
$component->terminateSessions(Component::TERMINATE_MINECRAFT_SESSIONS);
$this->assertEmpty($identity->getMinecraftAccessKeys()->all());
$this->assertNotEmpty($identity->getSessions()->all());
$component->terminateSessions(Component::TERMINATE_SITE_SESSIONS | Component::DO_NOT_TERMINATE_CURRENT_SESSION);
$sessions = $identity->getSessions()->all();
$this->assertEquals(1, count($sessions));
$this->assertTrue($sessions[0]->id === $session->id);
$component->terminateSessions(Component::TERMINATE_ALL);
$this->assertEmpty($identity->getSessions()->all());
$this->assertEmpty($identity->getMinecraftAccessKeys()->all());
}
public function testSerializeToken() {
$this->specify('get string, contained jwt token', function() {
$token = new Token();

View File

@@ -97,7 +97,7 @@ class ChangePasswordFormTest extends TestCase {
public function testChangePasswordWithLogout() {
/** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */
$component = $this->getMockBuilder(Component::class)
->setMethods(['getActiveSession'])
->setMethods(['getActiveSession', 'terminateSessions'])
->setConstructorArgs([[
'identityClass' => AccountIdentity::class,
'enableSession' => false,
@@ -114,25 +114,22 @@ class ChangePasswordFormTest extends TestCase {
->method('getActiveSession')
->will($this->returnValue($session));
$component
->expects($this->once())
->method('terminateSessions');
Yii::$app->set('user', $component);
$this->specify('change password with removing all session, except current', function() use ($session) {
/** @var Account $account */
$account = Account::findOne($this->tester->grabFixture('accounts', 'admin')['id']);
/** @var Account $account */
$account = $this->tester->grabFixture('accounts', 'admin');
$model = new ChangePasswordForm($account, [
'password' => 'password_0',
'newPassword' => 'my-new-password',
'newRePassword' => 'my-new-password',
'logoutAll' => true,
]);
$model = new ChangePasswordForm($account, [
'password' => 'password_0',
'newPassword' => 'my-new-password',
'newRePassword' => 'my-new-password',
'logoutAll' => true,
]);
expect($model->changePassword())->true();
/** @var AccountSession[] $sessions */
$sessions = $account->getSessions()->all();
expect(count($sessions))->equals(1);
expect($sessions[0]->id)->equals($session->id);
});
$this->assertTrue($model->changePassword());
}
}

View File

@@ -1,13 +1,18 @@
<?php
namespace tests\codeception\api\unit\models\profile;
use api\components\User\Component;
use api\models\AccountIdentity;
use api\models\profile\TwoFactorAuthForm;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use tests\codeception\api\unit\TestCase;
use tests\codeception\common\_support\ProtectedCaller;
use Yii;
class TwoFactorAuthFormTest extends TestCase {
use ProtectedCaller;
public function testGetCredentials() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
@@ -67,6 +72,23 @@ class TwoFactorAuthFormTest extends TestCase {
}
public function testActivate() {
/** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */
$component = $this->getMockBuilder(Component::class)
->setMethods(['terminateSessions'])
->setConstructorArgs([[
'identityClass' => AccountIdentity::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',
]])
->getMock();
$component
->expects($this->once())
->method('terminateSessions');
Yii::$app->set('user', $component);
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
@@ -162,4 +184,23 @@ class TwoFactorAuthFormTest extends TestCase {
$this->assertEquals('Ely.by', $totp->getIssuer());
}
public function testSetOtpSecret() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
->getMock();
$account->expects($this->exactly(2))
->method('save')
->willReturn(true);
$model = new TwoFactorAuthForm($account);
$this->callProtected($model, 'setOtpSecret');
$this->assertEquals(24, strlen($model->getAccount()->otp_secret));
$model = new TwoFactorAuthForm($account);
$this->callProtected($model, 'setOtpSecret', 25);
$this->assertEquals(25, strlen($model->getAccount()->otp_secret));
}
}

View File

@@ -14,7 +14,7 @@ class TotpValidatorTest extends TestCase {
public function testValidateValue() {
$account = new Account();
$account->otp_secret = 'some secret';
$controlTotp = new TOTP(null, 'some secret');
$controlTotp = new TOTP(null, $account->otp_secret);
$validator = new TotpValidator(['account' => $account]);
@@ -27,9 +27,31 @@ class TotpValidatorTest extends TestCase {
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31));
$this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result);
$validator->window = 60;
$validator->window = 2;
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31));
$this->assertNull($result);
$at = time() - 400;
$validator->timestamp = $at;
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
$this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result);
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at));
$this->assertNull($result);
$at = function() {
return null;
};
$validator->timestamp = $at;
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
$this->assertNull($result);
$at = function() {
return time() - 700;
};
$validator->timestamp = $at;
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at()));
$this->assertNull($result);
}
}