mirror of
				https://github.com/elyby/accounts.git
				synced 2025-05-31 14:11:46 +05:30 
			
		
		
		
	Merge branch 'two_factor_auth' into develop
This commit is contained in:
		| @@ -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/ | ||||
|   | ||||
| @@ -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/ | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
							
								
								
									
										75
									
								
								api/controllers/TwoFactorAuthController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								api/controllers/TwoFactorAuthController.php
									
									
									
									
									
										Normal 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, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
							
								
								
									
										138
									
								
								api/models/profile/TwoFactorAuthForm.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								api/models/profile/TwoFactorAuthForm.php
									
									
									
									
									
										Normal 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'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										51
									
								
								api/validators/TotpValidator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								api/validators/TotpValidator.php
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										116
									
								
								common/components/Qr/ElyDecorator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								common/components/Qr/ElyDecorator.php
									
									
									
									
									
										Normal 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)); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										6
									
								
								common/components/Qr/resources/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								common/components/Qr/resources/logo.svg
									
									
									
									
									
										Normal 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 | 
| @@ -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'; | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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": "*", | ||||
|   | ||||
							
								
								
									
										17
									
								
								console/migrations/m170118_224937_account_otp_secrets.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								console/migrations/m170118_224937_account_otp_secrets.php
									
									
									
									
									
										Normal 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'); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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, | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
							
								
								
									
										31
									
								
								tests/codeception/api/_pages/TwoFactorAuthRoute.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								tests/codeception/api/_pages/TwoFactorAuthRoute.php
									
									
									
									
									
										Normal 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, | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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([ | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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'); | ||||
|     } | ||||
|   | ||||
| @@ -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'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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'); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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, | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										61
									
								
								tests/codeception/api/functional/TwoFactorAuthEnableCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								tests/codeception/api/functional/TwoFactorAuthEnableCest.php
									
									
									
									
									
										Normal 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, | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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, | ||||
|         ]); | ||||
|  | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|         ]); | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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([ | ||||
|   | ||||
| @@ -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' | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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()); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										35
									
								
								tests/codeception/api/unit/validators/TotpValidatorTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								tests/codeception/api/unit/validators/TotpValidatorTest.php
									
									
									
									
									
										Normal 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); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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, | ||||
|     ], | ||||
| ]; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user