mirror of
https://github.com/elyby/accounts.git
synced 2025-05-31 14:11:46 +05:30
Merge branch 'develop'
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
@@ -51,6 +51,7 @@ class ForgotPasswordForm extends ApiForm {
|
||||
}
|
||||
|
||||
$validator = new TotpValidator(['account' => $account]);
|
||||
$validator->window = 1;
|
||||
$validator->validateAttribute($this, $attribute);
|
||||
}
|
||||
|
||||
|
@@ -69,6 +69,7 @@ class LoginForm extends ApiForm {
|
||||
}
|
||||
|
||||
$validator = new TotpValidator(['account' => $account]);
|
||||
$validator->window = 1;
|
||||
$validator->validateAttribute($this, $attribute);
|
||||
}
|
||||
|
||||
|
@@ -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()) {
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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.');
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'version' => '1.1.6',
|
||||
'version' => '1.1.7',
|
||||
'vendorPath' => dirname(dirname(__DIR__)) . '/vendor',
|
||||
'components' => [
|
||||
'cache' => [
|
||||
|
@@ -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([
|
||||
|
@@ -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();
|
||||
|
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user