Merge branch 'two_factor_auth' into develop

This commit is contained in:
ErickSkrauch 2017-01-24 02:00:44 +03:00
commit 55b46229db
45 changed files with 1137 additions and 99 deletions

View File

@ -1,4 +1,4 @@
FROM registry.ely.by/elyby/accounts-php:1.2.0
FROM registry.ely.by/elyby/accounts-php:1.3.0
# Вносим конфигурации для крона и воркеров
COPY docker/cron/* /etc/cron.d/

View File

@ -1,4 +1,4 @@
FROM registry.ely.by/elyby/accounts-php:1.2.0-dev
FROM registry.ely.by/elyby/accounts-php:1.3.0-dev
# Вносим конфигурации для крона и воркеров
COPY docker/cron/* /etc/cron.d/

View File

@ -7,6 +7,9 @@ return [
'/accounts/change-email/submit-new-email' => 'accounts/change-email-submit-new-email',
'/accounts/change-email/confirm-new-email' => 'accounts/change-email-confirm-new-email',
'POST /two-factor-auth' => 'two-factor-auth/activate',
'DELETE /two-factor-auth' => 'two-factor-auth/disable',
'/oauth2/v1/<action>' => 'oauth/<action>',
'/account/v1/info' => 'identity-info/index',

View File

@ -69,6 +69,7 @@ class AccountsController extends Controller {
'passwordChangedAt' => $account->password_changed_at,
'hasMojangUsernameCollision' => $account->hasMojangUsernameCollision(),
'shouldAcceptRules' => !$account->isAgreedWithActualRules(),
'isOtpEnabled' => (bool)$account->is_otp_enabled,
];
}

View File

@ -0,0 +1,75 @@
<?php
namespace api\controllers;
use api\filters\ActiveUserRule;
use api\models\profile\TwoFactorAuthForm;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
class TwoFactorAuthController extends Controller {
public $defaultAction = 'credentials';
public function behaviors() {
return ArrayHelper::merge(parent::behaviors(), [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'allow' => true,
'class' => ActiveUserRule::class,
],
],
],
]);
}
public function verbs() {
return [
'credentials' => ['GET'],
'activate' => ['POST'],
'disable' => ['DELETE'],
];
}
public function actionCredentials() {
$account = Yii::$app->user->identity;
$model = new TwoFactorAuthForm($account);
return $model->getCredentials();
}
public function actionActivate() {
$account = Yii::$app->user->identity;
$model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]);
$model->load(Yii::$app->request->post());
if (!$model->activate()) {
return [
'success' => false,
'errors' => $model->getFirstErrors(),
];
}
return [
'success' => true,
];
}
public function actionDisable() {
$account = Yii::$app->user->identity;
$model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]);
$model->load(Yii::$app->request->getBodyParams());
if (!$model->disable()) {
return [
'success' => false,
'errors' => $model->getFirstErrors(),
];
}
return [
'success' => true,
];
}
}

View File

@ -2,6 +2,7 @@
namespace api\models\authentication;
use api\models\base\ApiForm;
use api\validators\TotpValidator;
use common\helpers\Error as E;
use api\traits\AccountFinder;
use common\components\UserFriendlyRandomKey;
@ -16,11 +17,16 @@ class ForgotPasswordForm extends ApiForm {
use AccountFinder;
public $login;
public $token;
public function rules() {
return [
['login', 'required', 'message' => E::LOGIN_REQUIRED],
['login', 'validateLogin'],
['token', 'required', 'when' => function(self $model) {
return !$this->hasErrors() && $model->getAccount()->is_otp_enabled;
}, 'message' => E::OTP_TOKEN_REQUIRED],
['token', 'validateTotpToken'],
['login', 'validateActivity'],
['login', 'validateFrequency'],
];
@ -34,6 +40,20 @@ class ForgotPasswordForm extends ApiForm {
}
}
public function validateTotpToken($attribute) {
if ($this->hasErrors()) {
return;
}
$account = $this->getAccount();
if (!$account->is_otp_enabled) {
return;
}
$validator = new TotpValidator(['account' => $account]);
$validator->validateAttribute($this, $attribute);
}
public function validateActivity($attribute) {
if (!$this->hasErrors()) {
$account = $this->getAccount();

View File

@ -3,6 +3,7 @@ namespace api\models\authentication;
use api\models\AccountIdentity;
use api\models\base\ApiForm;
use api\validators\TotpValidator;
use common\helpers\Error as E;
use api\traits\AccountFinder;
use common\models\Account;
@ -16,6 +17,7 @@ class LoginForm extends ApiForm {
public $login;
public $password;
public $token;
public $rememberMe = false;
public function rules() {
@ -28,6 +30,11 @@ class LoginForm extends ApiForm {
}, 'message' => E::PASSWORD_REQUIRED],
['password', 'validatePassword'],
['token', 'required', 'when' => function(self $model) {
return !$model->hasErrors() && $model->getAccount()->is_otp_enabled;
}, 'message' => E::OTP_TOKEN_REQUIRED],
['token', 'validateTotpToken'],
['login', 'validateActivity'],
['rememberMe', 'boolean'],
@ -51,6 +58,20 @@ class LoginForm extends ApiForm {
}
}
public function validateTotpToken($attribute) {
if ($this->hasErrors()) {
return;
}
$account = $this->getAccount();
if (!$account->is_otp_enabled) {
return;
}
$validator = new TotpValidator(['account' => $account]);
$validator->validateAttribute($this, $attribute);
}
public function validateActivity($attribute) {
if (!$this->hasErrors()) {
$account = $this->getAccount();

View File

@ -0,0 +1,138 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use api\validators\TotpValidator;
use api\validators\PasswordRequiredValidator;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Encoder\Encoder;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\Svg;
use BaconQrCode\Writer;
use Base32\Base32;
use common\components\Qr\ElyDecorator;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use yii\base\ErrorException;
class TwoFactorAuthForm extends ApiForm {
const SCENARIO_ACTIVATE = 'enable';
const SCENARIO_DISABLE = 'disable';
public $token;
public $password;
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
public function rules() {
$bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE];
return [
['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],
['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios],
];
}
public function getCredentials(): array {
if (empty($this->account->otp_secret)) {
$this->setOtpSecret();
}
$provisioningUri = $this->getTotp()->getProvisioningUri();
return [
'qr' => base64_encode($this->drawQrCode($provisioningUri)),
'uri' => $provisioningUri,
'secret' => $this->account->otp_secret,
];
}
public function activate(): bool {
if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) {
return false;
}
$account = $this->account;
$account->is_otp_enabled = true;
if (!$account->save()) {
throw new ErrorException('Cannot enable otp for account');
}
return true;
}
public function disable(): bool {
if ($this->scenario !== self::SCENARIO_DISABLE || !$this->validate()) {
return false;
}
$account = $this->account;
$account->is_otp_enabled = false;
$account->otp_secret = null;
if (!$account->save()) {
throw new ErrorException('Cannot disable otp for account');
}
return true;
}
public function validateOtpDisabled($attribute) {
if ($this->account->is_otp_enabled) {
$this->addError($attribute, E::OTP_ALREADY_ENABLED);
}
}
public function validateOtpEnabled($attribute) {
if (!$this->account->is_otp_enabled) {
$this->addError($attribute, E::OTP_NOT_ENABLED);
}
}
public function getAccount(): Account {
return $this->account;
}
/**
* @return TOTP
*/
public function getTotp(): TOTP {
$totp = new TOTP($this->account->email, $this->account->otp_secret);
$totp->setIssuer('Ely.by');
return $totp;
}
public function drawQrCode(string $content): string {
$renderer = new Svg();
$renderer->setHeight(256);
$renderer->setWidth(256);
$renderer->setForegroundColor(new Rgb(32, 126, 92));
$renderer->setMargin(0);
$renderer->addDecorator(new ElyDecorator());
$writer = new Writer($renderer);
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)), '=');
if (!$this->account->save()) {
throw new ErrorException('Cannot set account otp_secret');
}
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace api\validators;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use Yii;
use yii\base\InvalidConfigException;
use yii\validators\Validator;
class TotpValidator extends Validator {
/**
* @var Account
*/
public $account;
/**
* @var int|null Задаёт окно, в промежуток которого будет проверяться код.
* Позволяет избежать ситуации, когда пользователь ввёл код в последнюю секунду
* его существования и пока шёл запрос, тот протух.
*/
public $window;
public $skipOnEmpty = false;
public function init() {
parent::init();
if ($this->account === null) {
$this->account = Yii::$app->user->identity;
}
if (!$this->account instanceof Account) {
throw new InvalidConfigException('account should be instance of ' . Account::class);
}
if (empty($this->account->otp_secret)) {
throw new InvalidConfigException('account should have not empty otp_secret');
}
}
protected function validateValue($value) {
$totp = new TOTP(null, $this->account->otp_secret);
if (!$totp->verify((string)$value, null, $this->window)) {
return [E::OTP_TOKEN_INCORRECT, []];
}
return null;
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace common\components\Qr;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Encoder\QrCode;
use BaconQrCode\Renderer\Image\Decorator\DecoratorInterface;
use BaconQrCode\Renderer\Image\RendererInterface;
use BaconQrCode\Renderer\Image\Svg;
use Imagick;
use InvalidArgumentException;
use ReflectionClass;
class ElyDecorator implements DecoratorInterface {
private const LOGO = __DIR__ . '/resources/logo.svg';
private const CORRECTION_MAP = [
ErrorCorrectionLevel::L => 7,
ErrorCorrectionLevel::M => 15,
ErrorCorrectionLevel::Q => 25,
ErrorCorrectionLevel::H => 30,
];
public function preProcess(
QrCode $qrCode,
RendererInterface $renderer,
$outputWidth,
$outputHeight,
$leftPadding,
$topPadding,
$multiple
) {
if (!$renderer instanceof Svg) {
throw new InvalidArgumentException('$renderer must by instance of ' . Svg::class);
}
$correctionLevel = self::CORRECTION_MAP[$qrCode->getErrorCorrectionLevel()->get()];
$sizeMultiplier = $correctionLevel + floor($correctionLevel / 3);
$count = $qrCode->getMatrix()->getWidth();
$countToRemoveX = floor($count * $sizeMultiplier / 100);
$countToRemoveY = floor($count * $sizeMultiplier / 100);
$startX = $leftPadding + round(($count - $countToRemoveX) / 2 * $multiple);
$startY = $topPadding + round(($count - $countToRemoveY) / 2 * $multiple);
$width = $countToRemoveX * $multiple;
$height = $countToRemoveY * $multiple;
$reflection = new ReflectionClass($renderer);
$property = $reflection->getProperty('svg');
$property->setAccessible(true);
/** @var \SimpleXMLElement $svg */
$svg = $property->getValue($renderer);
$image = $svg->addChild('image');
$image->addAttribute('x', $startX);
$image->addAttribute('y', $startY);
$image->addAttribute('width', $width);
$image->addAttribute('height', $height);
$image->addAttribute('xlink:href', $this->encodeSvgToBase64(self::LOGO));
$logo = new Imagick();
$logo->readImage(self::LOGO);
$logo->scaleImage($width, $height);
$foundedPixels = [];
foreach ($logo->getPixelIterator() as $row => $pixels) {
/** @var \ImagickPixel[] $pixels */
foreach ($pixels as $column => $pixel) {
$color = $pixel->getColorAsString();
if ($color !== 'srgb(255,255,255)') {
$foundedPixels[] = [(int)($startX + $column), (int)($startY + $row)];
}
}
}
$logo->clear();
$logo->destroy();
$padding = $multiple - 2;
if ($padding < 0) {
$padding = 1;
}
foreach ($foundedPixels as $coordinates) {
[$x, $y] = $coordinates;
$x -= $leftPadding;
$y -= $topPadding;
for ($i = $x - $padding; $i <= $x + $padding; $i++) {
for ($j = $y - $padding; $j <= $y + $padding; $j++) {
$matrixX = floor($i / $multiple);
$matrixY = floor($j / $multiple);
$qrCode->getMatrix()->set($matrixX, $matrixY, 0);
}
}
}
}
public function postProcess(
QrCode $qrCode,
RendererInterface $renderer,
$outputWidth,
$outputHeight,
$leftPadding,
$topPadding,
$multiple
) {
}
private function encodeSvgToBase64(string $filePath): string {
return 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($filePath));
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 389 365.16" style="fill:#207e5c">
<polygon points="130 161 37 161 37 257 145 257 145 285 0 285 0 19 144 19 144 47 37 47 37 133 130 133 130 161 130 161" />
<polygon points="213 285 177 285 177 0 213 0 213 285 213 285" />
<path d="M319,246.42l34-142h42l-66,227c-5.07,17.07-10.54,30.61-20,39.49S291.1,382.58,279,382.58c-4.67,0-13.89-1.65-21-3.16l4.11-27s18.89,1.78,22.69-4.37c4.54-4.26,8.71-13.46,12.21-24.48l6-23.16-58-196h41l33,142h0Z" transform="translate(-6 -17.42)" />
</svg>

After

Width:  |  Height:  |  Size: 582 B

View File

@ -54,4 +54,9 @@ final class Error {
const SUBJECT_REQUIRED = 'error.subject_required';
const MESSAGE_REQUIRED = 'error.message_required';
const OTP_TOKEN_REQUIRED = 'error.token_required';
const OTP_TOKEN_INCORRECT = 'error.token_incorrect';
const OTP_ALREADY_ENABLED = 'error.otp_already_enabled';
const OTP_NOT_ENABLED = 'error.otp_not_enabled';
}

View File

@ -20,6 +20,8 @@ use const common\LATEST_RULES_VERSION;
* @property integer $status
* @property integer $rules_agreement_version
* @property string $registration_ip
* @property string $otp_secret
* @property integer $is_otp_enabled
* @property integer $created_at
* @property integer $updated_at
* @property integer $password_changed_at

View File

@ -28,7 +28,9 @@
"ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207",
"predis/predis": "^1.0",
"mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70",
"minime/annotations": "~3.0"
"minime/annotations": "~3.0",
"spomky-labs/otphp": "^8.3",
"bacon/bacon-qr-code": "^1.0"
},
"require-dev": {
"yiisoft/yii2-codeception": "*",

View File

@ -0,0 +1,17 @@
<?php
use console\db\Migration;
class m170118_224937_account_otp_secrets extends Migration {
public function safeUp() {
$this->addColumn('{{%accounts}}', 'otp_secret', $this->string()->after('registration_ip'));
$this->addColumn('{{%accounts}}', 'is_otp_enabled', $this->boolean()->notNull()->defaultValue(false)->after('otp_secret'));
}
public function safeDown() {
$this->dropColumn('{{%accounts}}', 'otp_secret');
$this->dropColumn('{{%accounts}}', 'is_otp_enabled');
}
}

View File

@ -8,15 +8,23 @@ use yii\codeception\BasePage;
*/
class AuthenticationRoute extends BasePage {
public function login($login = '', $password = '', $rememberMe = false) {
/**
* @param string $login
* @param string $password
* @param string|bool|null $rememberMeOrToken
* @param bool $rememberMe
*/
public function login($login = '', $password = '', $rememberMeOrToken = null, $rememberMe = false) {
$this->route = ['authentication/login'];
$params = [
'login' => $login,
'password' => $password,
];
if ($rememberMe) {
if ((is_bool($rememberMeOrToken) && $rememberMeOrToken) || $rememberMe) {
$params['rememberMe'] = 1;
} elseif ($rememberMeOrToken !== null) {
$params['token'] = $rememberMeOrToken;
}
$this->actor->sendPOST($this->getUrl(), $params);
@ -27,10 +35,11 @@ class AuthenticationRoute extends BasePage {
$this->actor->sendPOST($this->getUrl());
}
public function forgotPassword($login = '') {
public function forgotPassword($login = null, $token = null) {
$this->route = ['authentication/forgot-password'];
$this->actor->sendPOST($this->getUrl(), [
'login' => $login,
'token' => $token,
]);
}

View File

@ -0,0 +1,31 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* @property \tests\codeception\api\FunctionalTester $actor
*/
class TwoFactorAuthRoute extends BasePage {
public $route = '/two-factor-auth';
public function credentials() {
$this->actor->sendGET($this->getUrl());
}
public function enable($token = null, $password = null) {
$this->actor->sendPOST($this->getUrl(), [
'token' => $token,
'password' => $password,
]);
}
public function disable($token = null, $password = null) {
$this->actor->sendDELETE($this->getUrl(), [
'token' => $token,
'password' => $password,
]);
}
}

View File

@ -1,10 +1,10 @@
<?php
namespace tests\codeception\api;
use api\components\User\LoginResult;
use api\models\authentication\LoginForm;
use api\models\AccountIdentity;
use Codeception\Actor;
use InvalidArgumentException;
use Yii;
/**
* Inherited Methods
@ -24,23 +24,15 @@ use InvalidArgumentException;
class FunctionalTester extends Actor {
use _generated\FunctionalTesterActions;
public function loggedInAsActiveAccount($login = null, $password = null) {
$form = new LoginForm();
if ($login === null && $password === null) {
$form->login = 'Admin';
$form->password = 'password_0';
} elseif ($login !== null && $password !== null) {
$form->login = $login;
$form->password = $password;
} else {
throw new InvalidArgumentException('login and password should be presented both.');
public function amAuthenticated(string $asUsername = 'admin') {
/** @var AccountIdentity $account */
$account = AccountIdentity::findOne(['username' => $asUsername]);
if ($account === null) {
throw new InvalidArgumentException("Cannot find account for username \"$asUsername\"");
}
$result = $form->login();
$this->assertInstanceOf(LoginResult::class, $result);
if ($result !== false) {
$this->amBearerAuthenticated($result->getJwt());
}
$result = Yii::$app->user->login($account);
$this->amBearerAuthenticated($result->getJwt());
}
public function notLoggedIn() {

View File

@ -16,7 +16,7 @@ class AccountsAcceptRulesCest {
}
public function testCurrent(FunctionalTester $I) {
$I->loggedInAsActiveAccount('Veleyaba', 'password_0');
$I->amAuthenticated('Veleyaba');
$this->route->acceptRules();
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();

View File

@ -17,7 +17,7 @@ class AccountsChangeEmailConfirmNewEmailCest {
public function testConfirmNewEmail(FunctionalTester $I) {
$I->wantTo('change my email and get changed value');
$I->loggedInAsActiveAccount('CrafterGameplays', 'password_0');
$I->amAuthenticated('CrafterGameplays');
$this->route->changeEmailConfirmNewEmail('H28HBDCHHAG2HGHGHS');
$I->canSeeResponseCodeIs(200);

View File

@ -17,7 +17,7 @@ class AccountsChangeEmailInitializeCest {
public function testChangeEmailInitialize(FunctionalTester $I) {
$I->wantTo('send current email confirmation');
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->changeEmailInitialize('password_0');
$I->canSeeResponseCodeIs(200);
@ -29,7 +29,7 @@ class AccountsChangeEmailInitializeCest {
public function testChangeEmailInitializeFrequencyError(FunctionalTester $I) {
$I->wantTo('see change email request frequency error');
$I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0');
$I->amAuthenticated('ILLIMUNATI');
$this->route->changeEmailInitialize('password_0');
$I->canSeeResponseContainsJson([

View File

@ -18,7 +18,7 @@ class AccountsChangeEmailSubmitNewEmailCest {
public function testSubmitNewEmail(FunctionalTester $I) {
$I->wantTo('submit new email');
$I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0');
$I->amAuthenticated('ILLIMUNATI');
$this->route->changeEmailSubmitNewEmail('H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by');
$I->canSeeResponseCodeIs(200);

View File

@ -18,7 +18,7 @@ class AccountsChangeLangCest {
public function testSubmitNewEmail(FunctionalTester $I) {
$I->wantTo('change my account language');
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->changeLang('ru');
$I->canSeeResponseCodeIs(200);

View File

@ -27,7 +27,7 @@ class AccountsChangePasswordCest {
public function testChangePassword(FunctionalTester $I) {
$I->wantTo('change my password');
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->changePassword('password_0', 'new-password', 'new-password');
$I->canSeeResponseCodeIs(200);

View File

@ -26,7 +26,7 @@ class AccountsChangeUsernameCest {
public function testChangeUsername(FunctionalTester $I) {
$I->wantTo('change my nickname');
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->changeUsername('password_0', 'bruce_wayne');
$I->canSeeResponseCodeIs(200);
@ -38,7 +38,7 @@ class AccountsChangeUsernameCest {
public function testChangeUsernameNotAvailable(FunctionalTester $I) {
$I->wantTo('see, that nickname "in use" is not available');
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->changeUsername('password_0', 'Jon');
$I->canSeeResponseCodeIs(200);

View File

@ -16,7 +16,7 @@ class AccountsCurrentCest {
}
public function testCurrent(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$this->route->current();
$I->canSeeResponseCodeIs(200);
@ -29,6 +29,7 @@ class AccountsCurrentCest {
'isActive' => true,
'hasMojangUsernameCollision' => false,
'shouldAcceptRules' => false,
'isOtpEnabled' => false,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt');
}

View File

@ -1,41 +1,87 @@
<?php
namespace codeception\api\functional;
use OTPHP\TOTP;
use tests\codeception\api\_pages\AuthenticationRoute;
use tests\codeception\api\FunctionalTester;
class ForgotPasswordCest {
public function testForgotPasswordByEmail(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
/**
* @var AuthenticationRoute
*/
private $route;
$I->wantTo('create new password recover request by passing email');
$route->forgotPassword('admin@ely.by');
public function _before(FunctionalTester $I) {
$this->route = new AuthenticationRoute($I);
}
public function testWrongInput(FunctionalTester $I) {
$I->wantTo('see reaction on invalid input');
$this->route->forgotPassword();
$I->canSeeResponseContainsJson([
'success' => true,
'success' => false,
'errors' => [
'login' => 'error.login_required',
],
]);
$I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn');
$I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency');
$this->route->forgotPassword('becauseimbatman!');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'login' => 'error.login_not_exist',
],
]);
$this->route->forgotPassword('AccountWithEnabledOtp');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_required',
],
]);
$this->route->forgotPassword('AccountWithEnabledOtp');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_required',
],
]);
$this->route->forgotPassword('AccountWithEnabledOtp', '123456');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_incorrect',
],
]);
}
public function testForgotPasswordByEmail(FunctionalTester $I) {
$I->wantTo('create new password recover request by passing email');
$this->route->forgotPassword('admin@ely.by');
$this->assertSuccessResponse($I, false);
}
public function testForgotPasswordByUsername(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
$I->wantTo('create new password recover request by passing username');
$route->forgotPassword('Admin');
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn');
$I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency');
$I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask');
$this->route->forgotPassword('Admin');
$this->assertSuccessResponse($I, true);
}
public function testForgotPasswordByAccountWithOtp(FunctionalTester $I) {
$I->wantTo('create new password recover request by passing username and otp token');
$totp = new TOTP(null, 'secret-secret-secret');
$this->route->forgotPassword('AccountWithEnabledOtp', $totp->now());
$this->assertSuccessResponse($I, true);
}
public function testDataForFrequencyError(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
$I->wantTo('get info about time to repeat recover password request');
$route->forgotPassword('Notch');
$this->route->forgotPassword('Notch');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
@ -46,4 +92,18 @@ class ForgotPasswordCest {
$I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency');
}
/**
* @param FunctionalTester $I
*/
private function assertSuccessResponse(FunctionalTester $I, bool $expectEmailMask = false): void {
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn');
$I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency');
if ($expectEmailMask) {
$I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask');
}
}
}

View File

@ -1,6 +1,7 @@
<?php
namespace tests\codeception\api;
use OTPHP\TOTP;
use tests\codeception\api\_pages\AuthenticationRoute;
class LoginCest {
@ -103,6 +104,56 @@ class LoginCest {
]);
}
public function testLoginToken(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
$I->wantTo('see token don\'t have errors if email, username or token not set');
$route->login();
$I->canSeeResponseContainsJson([
'success' => false,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors.token');
$I->wantTo('see token don\'t have errors if username not exists in database');
$route->login('non-exist-username', 'random-password');
$I->canSeeResponseContainsJson([
'success' => false,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors.token');
$I->wantTo('see token don\'t has errors if email not exists in database');
$route->login('not-exist@user.com', 'random-password');
$I->canSeeResponseContainsJson([
'success' => false,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors.token');
$I->wantTo('see token don\'t has errors if email correct, but password wrong');
$route->login('not-exist@user.com', 'random-password');
$I->canSeeResponseContainsJson([
'success' => false,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors.token');
$I->wantTo('see error.token_required if username and password correct, but account have enable otp');
$route->login('AccountWithEnabledOtp', 'password_0');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_required',
],
]);
$I->wantTo('see error.token_incorrect if username and password correct, but token wrong');
$route->login('AccountWithEnabledOtp', 'password_0', '123456');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_incorrect',
],
]);
}
public function testLoginByUsernameCorrect(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
@ -151,4 +202,16 @@ class LoginCest {
$I->canSeeAuthCredentials(true);
}
public function testLoginByAccountWithOtp(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
$I->wantTo('login into account with enabled otp');
$route->login('AccountWithEnabledOtp', 'password_0', (new TOTP(null, 'secret-secret-secret'))->now());
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
$I->canSeeAuthCredentials(false);
}
}

View File

@ -8,7 +8,7 @@ class LogoutCest {
public function testLoginEmailOrUsername(FunctionalTester $I) {
$route = new AuthenticationRoute($I);
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$route->logout();
$I->canSeeResponseContainsJson([
'success' => true,

View File

@ -51,7 +51,7 @@ class OauthAuthCodeCest {
}
public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('validate and get information with description replacement');
$this->route->validate($this->buildQueryParams(
'ely',
@ -73,13 +73,13 @@ class OauthAuthCodeCest {
}
public function testCompleteValidationAction(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('validate all oAuth params on complete request');
$this->testOauthParamsValidation($I, 'complete');
}
public function testCompleteActionOnWrongConditions(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('get accept_required if I don\'t require any scope, but this is first time request');
$this->route->complete($this->buildQueryParams(
@ -112,7 +112,7 @@ class OauthAuthCodeCest {
}
public function testCompleteActionSuccess(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('get auth code if I require some scope and pass accept field');
$this->route->complete($this->buildQueryParams(
'ely',
@ -155,7 +155,7 @@ class OauthAuthCodeCest {
}
public function testAcceptRequiredOnNewScope(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('get accept_required if I have previous successful request, but now require some new scope');
$this->route->complete($this->buildQueryParams(
'ely',
@ -179,7 +179,7 @@ class OauthAuthCodeCest {
}
public function testCompleteActionWithDismissState(FunctionalTester $I) {
$I->loggedInAsActiveAccount();
$I->amAuthenticated();
$I->wantTo('get access_denied error if I pass accept in false state');
$this->route->complete($this->buildQueryParams(
'ely',

View File

@ -0,0 +1,28 @@
<?php
namespace tests\codeception\api\functional;
use tests\codeception\api\_pages\TwoFactorAuthRoute;
use tests\codeception\api\FunctionalTester;
class TwoFactorAuthCredentialsCest {
/**
* @var TwoFactorAuthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new TwoFactorAuthRoute($I);
}
public function testGetCredentials(FunctionalTester $I) {
$I->amAuthenticated();
$this->route->credentials();
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseJsonMatchesJsonPath('$.secret');
$I->canSeeResponseJsonMatchesJsonPath('$.uri');
$I->canSeeResponseJsonMatchesJsonPath('$.qr');
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace tests\codeception\api\functional;
use OTPHP\TOTP;
use tests\codeception\api\_pages\TwoFactorAuthRoute;
use tests\codeception\api\FunctionalTester;
class TwoFactorAuthDisableCest {
/**
* @var TwoFactorAuthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new TwoFactorAuthRoute($I);
}
public function testFails(FunctionalTester $I) {
$I->amAuthenticated('AccountWithEnabledOtp');
$this->route->disable();
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_required',
'password' => 'error.password_required',
],
]);
$this->route->disable('123456', 'invalid_password');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_incorrect',
'password' => 'error.password_incorrect',
],
]);
$I->amAuthenticated('AccountWithOtpSecret');
$this->route->disable('123456', 'invalid_password');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'account' => 'error.otp_not_enabled',
],
]);
}
public function testSuccessEnable(FunctionalTester $I) {
$I->amAuthenticated('AccountWithEnabledOtp');
$totp = new TOTP(null, 'secret-secret-secret');
$this->route->disable($totp->now(), 'password_0');
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => true,
]);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace tests\codeception\api\functional;
use OTPHP\TOTP;
use tests\codeception\api\_pages\TwoFactorAuthRoute;
use tests\codeception\api\FunctionalTester;
class TwoFactorAuthEnableCest {
/**
* @var TwoFactorAuthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->route = new TwoFactorAuthRoute($I);
}
public function testFails(FunctionalTester $I) {
$I->amAuthenticated('AccountWithOtpSecret');
$this->route->enable();
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_required',
'password' => 'error.password_required',
],
]);
$this->route->enable('123456', 'invalid_password');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'token' => 'error.token_incorrect',
'password' => 'error.password_incorrect',
],
]);
$I->amAuthenticated('AccountWithEnabledOtp');
$this->route->enable('123456', 'invalid_password');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'account' => 'error.otp_already_enabled',
],
]);
}
public function testSuccessEnable(FunctionalTester $I) {
$I->amAuthenticated('AccountWithOtpSecret');
$totp = new TOTP(null, 'some otp secret value');
$this->route->enable($totp->now(), 'password_0');
$I->canSeeResponseCodeIs(200);
$I->canSeeResponseIsJson();
$I->canSeeResponseContainsJson([
'success' => true,
]);
}
}

View File

@ -7,12 +7,12 @@ use tests\codeception\api\FunctionalTester;
class AuthserverSteps extends FunctionalTester {
public function amAuthenticated() {
public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') {
$route = new AuthserverRoute($this);
$clientToken = Uuid::uuid4()->toString();
$route->authenticate([
'username' => 'admin',
'password' => 'password_0',
'username' => $asUsername,
'password' => $password,
'clientToken' => $clientToken,
]);

View File

@ -9,7 +9,7 @@ class OauthSteps extends FunctionalTester {
public function getAuthCode(array $permissions = []) {
// TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования
$this->loggedInAsActiveAccount();
$this->amAuthenticated();
$route = new OauthRoute($this);
$route->complete([
'client_id' => 'ely',

View File

@ -18,7 +18,7 @@ class InvalidateCest {
public function invalidate(AuthserverSteps $I) {
$I->wantTo('invalidate my token');
list($accessToken, $clientToken) = $I->amAuthenticated();
[$accessToken, $clientToken] = $I->amAuthenticated();
$this->route->invalidate([
'accessToken' => $accessToken,
'clientToken' => $clientToken,

View File

@ -18,7 +18,7 @@ class RefreshCest {
public function refresh(AuthserverSteps $I) {
$I->wantTo('refresh my accessToken');
list($accessToken, $clientToken) = $I->amAuthenticated();
[$accessToken, $clientToken] = $I->amAuthenticated();
$this->route->refresh([
'accessToken' => $accessToken,
'clientToken' => $clientToken,

View File

@ -18,7 +18,7 @@ class ValidateCest {
public function validate(AuthserverSteps $I) {
$I->wantTo('validate my accessToken');
list($accessToken) = $I->amAuthenticated();
[$accessToken] = $I->amAuthenticated();
$this->route->validate([
'accessToken' => $accessToken,
]);

View File

@ -21,7 +21,7 @@ class JoinCest {
public function joinByLegacyAuthserver(AuthserverSteps $I) {
$I->wantTo('join to server, using legacy authserver access token');
list($accessToken) = $I->amAuthenticated();
[$accessToken] = $I->amAuthenticated();
$this->route->join([
'accessToken' => $accessToken,
'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022',
@ -32,7 +32,7 @@ class JoinCest {
public function joinByPassJsonInPost(AuthserverSteps $I) {
$I->wantTo('join to server, passing data in body as encoded json');
list($accessToken) = $I->amAuthenticated();
[$accessToken] = $I->amAuthenticated();
$this->route->join(json_encode([
'accessToken' => $accessToken,
'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022',

View File

@ -21,7 +21,7 @@ class JoinLegacyCest {
public function joinByLegacyAuthserver(AuthserverSteps $I) {
$I->wantTo('join to server by legacy protocol, using legacy authserver access token');
list($accessToken) = $I->amAuthenticated();
[$accessToken] = $I->amAuthenticated();
$this->route->joinLegacy([
'sessionId' => $accessToken,
'user' => 'Admin',
@ -32,7 +32,7 @@ class JoinLegacyCest {
public function joinByNewSessionFormat(AuthserverSteps $I) {
$I->wantTo('join to server by legacy protocol with new launcher session format, using legacy authserver');
list($accessToken) = $I->amAuthenticated();
[$accessToken] = $I->amAuthenticated();
$this->route->joinLegacy([
'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022',
'user' => 'Admin',

View File

@ -4,6 +4,7 @@ namespace codeception\api\unit\models\authentication;
use api\models\authentication\ForgotPasswordForm;
use Codeception\Specify;
use common\models\EmailActivation;
use OTPHP\TOTP;
use tests\codeception\api\unit\TestCase;
use tests\codeception\common\fixtures\AccountFixture;
use tests\codeception\common\fixtures\EmailActivationFixture;
@ -18,7 +19,7 @@ class ForgotPasswordFormTest extends TestCase {
];
}
public function testValidateAccount() {
public function testValidateLogin() {
$this->specify('error.login_not_exist if login is invalid', function() {
$model = new ForgotPasswordForm(['login' => 'unexist']);
$model->validateLogin('login');
@ -32,6 +33,21 @@ class ForgotPasswordFormTest extends TestCase {
});
}
public function testValidateTotpToken() {
$model = new ForgotPasswordForm();
$model->login = 'AccountWithEnabledOtp';
$model->token = '123456';
$model->validateTotpToken('token');
$this->assertEquals(['error.token_incorrect'], $model->getErrors('token'));
$totp = new TOTP(null, 'secret-secret-secret');
$model = new ForgotPasswordForm();
$model->login = 'AccountWithEnabledOtp';
$model->token = $totp->now();
$model->validateTotpToken('token');
$this->assertEmpty($model->getErrors('token'));
}
public function testValidateActivity() {
$this->specify('error.account_not_activated if account is not confirmed', function() {
$model = new ForgotPasswordForm([

View File

@ -6,6 +6,7 @@ use api\models\AccountIdentity;
use api\models\authentication\LoginForm;
use Codeception\Specify;
use common\models\Account;
use OTPHP\TOTP;
use tests\codeception\api\unit\TestCase;
use tests\codeception\common\fixtures\AccountFixture;
@ -38,7 +39,7 @@ class LoginFormTest extends TestCase {
'account' => null,
]);
$model->validateLogin('login');
expect($model->getErrors('login'))->equals(['error.login_not_exist']);
$this->assertEquals(['error.login_not_exist'], $model->getErrors('login'));
});
$this->specify('no errors if login exists', function () {
@ -47,7 +48,7 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(),
]);
$model->validateLogin('login');
expect($model->getErrors('login'))->isEmpty();
$this->assertEmpty($model->getErrors('login'));
});
}
@ -58,7 +59,7 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(['password' => '12345678']),
]);
$model->validatePassword('password');
expect($model->getErrors('password'))->equals(['error.password_incorrect']);
$this->assertEquals(['error.password_incorrect'], $model->getErrors('password'));
});
$this->specify('no errors if password valid', function () {
@ -67,7 +68,35 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(['password' => '12345678']),
]);
$model->validatePassword('password');
expect($model->getErrors('password'))->isEmpty();
$this->assertEmpty($model->getErrors('password'));
});
}
public function testValidateTotpToken() {
$account = new AccountIdentity(['password' => '12345678']);
$account->password = '12345678';
$account->is_otp_enabled = true;
$account->otp_secret = 'mock secret';
$this->specify('error.token_incorrect if totp invalid', function() use ($account) {
$model = $this->createModel([
'password' => '12345678',
'token' => '321123',
'account' => $account,
]);
$model->validateTotpToken('token');
$this->assertEquals(['error.token_incorrect'], $model->getErrors('token'));
});
$totp = new TOTP(null, 'mock secret');
$this->specify('no errors if password valid', function() use ($account, $totp) {
$model = $this->createModel([
'password' => '12345678',
'token' => $totp->now(),
'account' => $account,
]);
$model->validateTotpToken('token');
$this->assertEmpty($model->getErrors('token'));
});
}
@ -77,7 +106,7 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]),
]);
$model->validateActivity('login');
expect($model->getErrors('login'))->equals(['error.account_not_activated']);
$this->assertEquals(['error.account_not_activated'], $model->getErrors('login'));
});
$this->specify('error.account_banned if account has banned status', function () {
@ -85,7 +114,7 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]),
]);
$model->validateActivity('login');
expect($model->getErrors('login'))->equals(['error.account_banned']);
$this->assertEquals(['error.account_banned'], $model->getErrors('login'));
});
$this->specify('no errors if account active', function () {
@ -93,36 +122,36 @@ class LoginFormTest extends TestCase {
'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]),
]);
$model->validateActivity('login');
expect($model->getErrors('login'))->isEmpty();
$this->assertEmpty($model->getErrors('login'));
});
}
public function testLogin() {
$this->specify('user should be able to login with correct username and password', function () {
$model = $this->createModel([
'login' => 'erickskrauch',
$model = $this->createModel([
'login' => 'erickskrauch',
'password' => '12345678',
'account' => new AccountIdentity([
'username' => 'erickskrauch',
'password' => '12345678',
'account' => new AccountIdentity([
'username' => 'erickskrauch',
'password' => '12345678',
'status' => Account::STATUS_ACTIVE,
]),
]);
expect('model should login user', $model->login())->isInstanceOf(LoginResult::class);
expect('error message should not be set', $model->errors)->isEmpty();
});
'status' => Account::STATUS_ACTIVE,
]),
]);
$this->assertInstanceOf(LoginResult::class, $model->login(), 'model should login user');
$this->assertEmpty($model->getErrors(), 'error message should not be set');
}
public function testLoginWithRehashing() {
$this->specify('user, that login using account with old pass hash strategy should update it automatically', function () {
$model = new LoginForm([
'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'],
'password' => '12345678',
]);
expect($model->login())->isInstanceOf(LoginResult::class);
expect($model->errors)->isEmpty();
expect($model->getAccount()->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2);
});
$model = new LoginForm([
'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'],
'password' => '12345678',
]);
$this->assertInstanceOf(LoginResult::class, $model->login());
$this->assertEmpty($model->getErrors());
$this->assertEquals(
Account::PASS_HASH_STRATEGY_YII2,
$model->getAccount()->password_hash_strategy,
'user, that login using account with old pass hash strategy should update it automatically'
);
}
/**

View File

@ -0,0 +1,165 @@
<?php
namespace tests\codeception\api\unit\models\profile;
use api\models\profile\TwoFactorAuthForm;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use tests\codeception\api\unit\TestCase;
class TwoFactorAuthFormTest extends TestCase {
public function testGetCredentials() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
->getMock();
$account->expects($this->once())
->method('save')
->willReturn(true);
$account->email = 'mock@email.com';
$account->otp_secret = null;
/** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */
$model = $this->getMockBuilder(TwoFactorAuthForm::class)
->setConstructorArgs([$account])
->setMethods(['drawQrCode'])
->getMock();
$model->expects($this->once())
->method('drawQrCode')
->willReturn('this is qr code, trust me');
$result = $model->getCredentials();
$this->assertTrue(is_array($result));
$this->assertArrayHasKey('qr', $result);
$this->assertArrayHasKey('uri', $result);
$this->assertArrayHasKey('secret', $result);
$this->assertNotNull($account->otp_secret);
$this->assertEquals($account->otp_secret, $result['secret']);
$this->assertEquals(base64_encode('this is qr code, trust me'), $result['qr']);
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
->getMock();
$account->expects($this->never())
->method('save');
$account->email = 'mock@email.com';
$account->otp_secret = 'some valid totp secret value';
/** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */
$model = $this->getMockBuilder(TwoFactorAuthForm::class)
->setConstructorArgs([$account])
->setMethods(['drawQrCode'])
->getMock();
$model->expects($this->once())
->method('drawQrCode')
->willReturn('this is qr code, trust me');
$result = $model->getCredentials();
$this->assertEquals('some valid totp secret value', $result['secret']);
}
public function testActivate() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
->getMock();
$account->expects($this->once())
->method('save')
->willReturn(true);
$account->is_otp_enabled = false;
$account->otp_secret = 'mock secret';
/** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */
$model = $this->getMockBuilder(TwoFactorAuthForm::class)
->setMethods(['validate'])
->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]])
->getMock();
$model->expects($this->once())
->method('validate')
->willReturn(true);
$this->assertTrue($model->activate());
$this->assertTrue($account->is_otp_enabled);
}
public function testDisable() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->getMockBuilder(Account::class)
->setMethods(['save'])
->getMock();
$account->expects($this->once())
->method('save')
->willReturn(true);
$account->is_otp_enabled = true;
$account->otp_secret = 'mock secret';
/** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */
$model = $this->getMockBuilder(TwoFactorAuthForm::class)
->setMethods(['validate'])
->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]])
->getMock();
$model->expects($this->once())
->method('validate')
->willReturn(true);
$this->assertTrue($model->disable());
$this->assertNull($account->otp_secret);
$this->assertFalse($account->is_otp_enabled);
}
public function testValidateOtpDisabled() {
$account = new Account();
$account->is_otp_enabled = true;
$model = new TwoFactorAuthForm($account);
$model->validateOtpDisabled('account');
$this->assertEquals([E::OTP_ALREADY_ENABLED], $model->getErrors('account'));
$account = new Account();
$account->is_otp_enabled = false;
$model = new TwoFactorAuthForm($account);
$model->validateOtpDisabled('account');
$this->assertEmpty($model->getErrors('account'));
}
public function testValidateOtpEnabled() {
$account = new Account();
$account->is_otp_enabled = false;
$model = new TwoFactorAuthForm($account);
$model->validateOtpEnabled('account');
$this->assertEquals([E::OTP_NOT_ENABLED], $model->getErrors('account'));
$account = new Account();
$account->is_otp_enabled = true;
$model = new TwoFactorAuthForm($account);
$model->validateOtpEnabled('account');
$this->assertEmpty($model->getErrors('account'));
}
public function testGetTotp() {
$account = new Account();
$account->otp_secret = 'mock secret';
$account->email = 'check@this.email';
$model = new TwoFactorAuthForm($account);
$totp = $model->getTotp();
$this->assertInstanceOf(TOTP::class, $totp);
$this->assertEquals('check@this.email', $totp->getLabel());
$this->assertEquals('mock secret', $totp->getSecret());
$this->assertEquals('Ely.by', $totp->getIssuer());
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace tests\codeception\api\unit\validators;
use api\validators\TotpValidator;
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;
class TotpValidatorTest extends TestCase {
use ProtectedCaller;
public function testValidateValue() {
$account = new Account();
$account->otp_secret = 'some secret';
$controlTotp = new TOTP(null, 'some secret');
$validator = new TotpValidator(['account' => $account]);
$result = $this->callProtected($validator, 'validateValue', 123456);
$this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result);
$result = $this->callProtected($validator, 'validateValue', $controlTotp->now());
$this->assertNull($result);
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31));
$this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result);
$validator->window = 60;
$result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31));
$this->assertNull($result);
}
}

View File

@ -146,4 +146,34 @@ return [
'created_at' => 1474404139,
'updated_at' => 1474404149,
],
'account-with-otp-secret' => [
'id' => 12,
'uuid' => '9e9dcd11-2322-46dc-a992-e822a422726e',
'username' => 'AccountWithOtpSecret',
'email' => 'sava-galkin@mail.ru',
'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0
'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'lang' => 'ru',
'status' => \common\models\Account::STATUS_ACTIVE,
'rules_agreement_version' => \common\LATEST_RULES_VERSION,
'otp_secret' => 'some otp secret value',
'is_otp_enabled' => false,
'created_at' => 1485124615,
'updated_at' => 1485124615,
],
'account-with-enabled-otp' => [
'id' => 13,
'uuid' => '15d0afa7-a2bb-44d3-9f31-964cbccc6043',
'username' => 'AccountWithEnabledOtp',
'email' => 'otp@gmail.com',
'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0
'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'lang' => 'ru',
'status' => \common\models\Account::STATUS_ACTIVE,
'rules_agreement_version' => \common\LATEST_RULES_VERSION,
'otp_secret' => 'secret-secret-secret',
'is_otp_enabled' => true,
'created_at' => 1485124685,
'updated_at' => 1485124685,
],
];