diff --git a/Docs/Documentation/Code2F.md b/Docs/Documentation/Code2F.md new file mode 100644 index 000000000..538931d29 --- /dev/null +++ b/Docs/Documentation/Code2F.md @@ -0,0 +1,91 @@ +Code2F (OTP authentication with phone / email) +============= + +The plugin offers an easy way to integrate U2F in the users login flow +of your application. + +How does it work +---------------- +When the user log-in, he is requested to enter an OTP code sent to an email or phone. The code is requested based on the authentication checker selected. + +Enabling +-------- + +First if you want to use OTP authentication with phone and you want to use builtin TwilioTransport, you need to require twilio/sdk using composer: + +``` +composer require twilio/sdk:@stable +``` + +Then add this in your config/users.php file: + +```php + 'Code2f.enabled' => true, +``` + +Disabling +--------- +You can disable it by adding this in your config/users.php file: + +```php + 'Code2f.enabled' => false, +``` + +Configuration +------------- +To configure Code2f you need to set a checker class (see Checkers) +```php +'Code2f' => [ + 'enabled' => true, + 'checker' => \CakeDC\Auth\Authentication\DefaultCode2fTimeBasedAuthenticationChecker::class, + 'type' => \CakeDC\Auth\Authentication\Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE, + 'config' => 'sms', + 'message' => '{0} is your {1} verification code', + 'maxSeconds' => 300, + 'maxTries' => 3, + 'daysBeforeVerifyAgain' => 15 +], +``` +* `enabled`: Indicates if Code2f is enabled or not. +* `checker`: See Checkers below. +* `type`: Sets the recipient type. Can be `phone` or `email`. +* `config`: Email config to use. It does not matter if type is `phone` or `email` since we are using Email and Transports structure to deliver emails and SMS. (See Email Config) +* `message`: Message to be sent. It will be passed to translation function (__()). `{0}` is replaced with `code` generated and {1} is replaced with `App.name`. +* `maxSeconds`: Validity of the code. +* `maxTries`: Max tries before generating a new code. +* `daysBeforeVerifyAgain`: If using `Code2fTimeBasedAuthenticationChecker` this config sets the days before asking for OTP authentication again. + +Email Config +------------ +If you are using `email` type and you have an email config and transport configured you don't need to do anything else. + +On the other hand if you are using `phone` type you will need a new email config and transport. For the example we use the built-in TwilioTransport that gives you SMS support out-of-the-box. + +For `email` type it defaults to `default`. For `phone` it defaults to `sms`. + +For Email Config you need to include custom `from` key, so it sets `from` parameter correctly. For Transport Config you need to set the `phonePattern` key to allow mailer to set the correct pattern for phones and avoid the exception thrown by default behavior. + +```php +'sms' => [ + 'transport' => 'twilio', + 'from' => '+19876543210', //Complete phone number from your Twilio account +] +``` + +```php +'twilio' => [ + 'className' => \CakeDC\Users\Mailer\Transport\TwilioTransport::class, + 'phonePattern' => '/^\+[1-9]\d{1,14}$/i' //Phone pattern compatible with Twilio phone numbers +] +``` + +While `phonePattern` is required, `from` may not be since it depends on the actual transport. Remember you can always implement new transport to send SMS taking `TwilioTransport` as a reference. + +Checkers +------------- +We include three different checkers in our `cakedc/auth` plugin: + +* \CakeDC\Auth\Authentication\DefaultCode2fAuthenticationChecker: Default authentication checker requires user to enter a new OTP code on each login. +* \CakeDC\Auth\Authentication\Code2fFingerprintAuthenticationChecker: Fingerprint authentication checker requires user to enter a new OTP code when fingerprint changes (fingerprint is calculated from user-agent header and ip) +* \CakeDC\Auth\Authentication\Code2fTimeBasedAuthenticationChecker: Time Based authentication checker requires user to enter a new OTP code after `daysBeforeVerifyAgain` days has passed since last validation. + diff --git a/Docs/Documentation/Events.md b/Docs/Documentation/Events.md index 61fcee79d..f3f544cb5 100644 --- a/Docs/Documentation/Events.md +++ b/Docs/Documentation/Events.md @@ -12,6 +12,7 @@ The events in this plugin follow these conventions `.table('users') + ->addColumn('phone', 'string', [ + 'null' => true, + 'default' => null, + 'length' => 256 + ]) + ->addColumn('phone_verified', 'datetime', [ + 'null' => true, + 'default' => null, + ]) + ->update(); + $this->table('otp_codes') + ->addColumn('user_id', 'uuid', [ + 'null' => false, + ]) + ->addColumn('code', 'string', [ + 'length' => 255, + 'null' => false, + 'default' => null + ]) + ->addColumn('tries', 'integer', [ + 'null' => false, + 'default' => 0 + ]) + ->addColumn('validated', 'datetime', [ + 'null' => true, + 'default' => null, + ]) + ->addColumn('created', 'datetime', [ + 'null' => false, + 'default' => null, + ]) + ->addForeignKey('user_id', 'users', 'id', array('delete' => 'CASCADE', 'update' => 'CASCADE')) + ->create(); + } +} diff --git a/config/users.php b/config/users.php index 23dfeb9d1..38c0f4ee4 100644 --- a/config/users.php +++ b/config/users.php @@ -140,6 +140,17 @@ 'enabled' => false, 'checker' => \CakeDC\Auth\Authentication\DefaultU2fAuthenticationChecker::class, ], + 'Code2f' => [ + 'enabled' => false, + 'checker' => \CakeDC\Auth\Authentication\DefaultCode2fAuthenticationChecker::class, + 'type' => \CakeDC\Auth\Authentication\Code2fAuthenticationCheckerInterface::CODE2F_TYPE_EMAIL, + 'config' => 'default', + 'subject' => '{0}: Your verification code', //Valid for type EMAIL. {0} will be replaced by App.name + 'message' => '{0} is your {1} verification code', //{0} will be replaced by code, {1} by App.name + 'maxSeconds' => 300, + 'maxTries' => 3, + 'daysBeforeVerifyAgain' => 15, + ], 'Webauthn2fa' => [ 'enabled' => false, 'appName' => null,//App must set a valid name here diff --git a/src/Controller/Traits/Code2fTrait.php b/src/Controller/Traits/Code2fTrait.php new file mode 100644 index 000000000..2daa9d7fb --- /dev/null +++ b/src/Controller/Traits/Code2fTrait.php @@ -0,0 +1,219 @@ +getCode2fData(); + if (!$data['valid']) { + return $this->redirectWithQuery([ + 'action' => 'login', + ]); + } + if (!$data['registration']) { + return $this->redirectWithQuery([ + 'action' => 'code2fRegister', + ]); + } + + return $this->redirectWithQuery([ + 'action' => 'code2fAuthenticate', + ]); + } + + /** + * Show Code2f register start step + * + * @return \Cake\Http\Response|null + */ + public function code2fRegister() + { + $data = $this->getCode2fData(); + if (!$data['valid']) { + return $this->redirectWithQuery([ + 'action' => 'login', + ]); + } + $field = Configure::read('Code2f.type'); + $this->set('field', $field); + if ($this->getRequest()->is(['post', 'put'])) { + + $value = $this->getRequest()->getData($field); + + $validated = true; + if ($data['field'] === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE) { + $config = Mailer::getConfig(Configure::read('Code2f.config', 'sms')); + $phonePattern = TransportFactory::get($config['transport'])->getConfig('phonePattern'); + if (!$phonePattern) { + throw new \UnexpectedValueException(__d('cake_d_c/users', 'You must define `phonePattern` in your transport ({0}) config.', $config)); + } + if (!preg_match($phonePattern, $value)) { + $this->Flash->error(__d('cake_d_c/users', 'Invalid phone number: Format must be {0}}', $phonePattern)); + $validated = false; + } + } + + if ($validated) { + $data['user'][$field] = $value; + $user = $this->getUsersTable()->saveOrFail($data['user'], ['checkRules' => false]); + $this->getRequest()->getSession()->write(AuthenticationService::CODE2F_SESSION_KEY, $user); + $data['registration'] = true; + } + } + if ($data['registration']) { + return $this->redirectWithQuery([ + 'action' => 'code2fAuthenticate', + ]); + } + $this->viewBuilder()->setLayout('CakeDC/Users.login'); + } + + /** + * Show code2f authenticate start step + * + * @return \Cake\Http\Response|null + */ + public function code2fAuthenticate() + { + $data = $this->getCode2fData(); + if (!$data['valid']) { + return $this->redirectWithQuery(Configure::read('Auth.AuthenticationComponent.loginAction')); + } + if (!$data['registration']) { + return $this->redirectWithQuery([ + 'action' => 'code2fRegister', + ]); + } + /** @var OtpCodesTable $OtpCodes */ + $OtpCodes = TableRegistry::getTableLocator()->get('CakeDC/Users.OtpCodes'); + $resend = $this->getRequest()->is(['post', 'put']) && $this->getRequest()->getQuery('resend'); + if ($this->getRequest()->is(['post', 'put']) && !$resend) { + try { + $result = $OtpCodes->validateCode2f($data['user']['id'], $this->getRequest()->getData('code')); + if (!$result) { + $this->Flash->error(__d('cake_d_c/users', 'The code entered is not valid, please try again or resend code.')); + } + $this->dispatchEvent(Plugin::EVENT_AFTER_CODE2F_VERIFIED, ['user' => $data['user']]); + $this->request->getSession()->delete(AuthenticationService::CODE2F_SESSION_KEY); + $this->request->getSession()->write(TwoFactorAuthenticator::USER_SESSION_KEY, $data['user']); + return $this->redirectWithQuery(Configure::read('Auth.AuthenticationComponent.loginAction')); + } catch (TokenExpiredException | \InvalidArgumentException $e) { + $this->Flash->error($e->getMessage()); + } + } else { + try { + $fingerprint = $this->getRequest()->getSession()->consume('Code2f.fingerprint'); + $OtpCodes->sendCode2f($data['user']['id'], $fingerprint, $resend); + } catch (\UnexpectedValueException | RecordNotFoundException | \OverflowException $e) { + $this->Flash->error($e->getMessage()); + } + if ($resend) { + $query = $this->getRequest()->getQueryParams(); + unset($query['resend']); + $this->setRequest($this->getRequest()->withQueryParams($query)); + return $this->redirectWithQuery(['action' => 'code2fAuthenticate']); + } + } + $this->set($data); + $this->viewBuilder()->setLayout('CakeDC/Users.login'); + } + + /** + * Get essential Code2f data + * + * @return array + */ + protected function getCode2fData() + { + $data = [ + 'valid' => false, + 'user' => null, + 'registration' => null, + 'field' => null + ]; + $user = $this->getRequest()->getSession()->read(AuthenticationService::CODE2F_SESSION_KEY); + if (!isset($user['id'])) { + return $data; + } + $entity = $this->getUsersTable()->get($user['id']); + $data['user'] = $user; + $data['valid'] = $this->getCode2fAuthenticationChecker()->isEnabled(); + + $type = Configure::read('Code2f.type'); + $data['field'] = $type; + $data['registration'] = !empty($entity[$type]) && ( + ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE && $entity->phone) || + ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_EMAIL && $entity->email) + ); + $data['verified'] = !empty($entity[$type]) && ( + ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE && $entity->phone_verified) || + ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_EMAIL && $entity->active) + ); + $data['masked'] = ''; + if ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE && $entity->phone) { + $data['masked'] = substr($entity->phone, 0, 3) . '******' . substr($entity->phone, -3); + } elseif ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_EMAIL && $entity->email) { + $data['masked'] = preg_replace_callback( + '/^(.)(.*?)([^@]?)(?=@[^@]+$)/u', + function ($m) { + return $m[1] + . str_repeat("*", max(4, mb_strlen($m[2], 'UTF-8'))) + . ($m[3] ?: $m[1]); + }, + $entity->email + ); + } + return $data; + } + + /** + * Get the configured Code2f authentication checker + * + * @return \CakeDC\Auth\Authentication\Code2fAuthenticationCheckerInterface + */ + protected function getCode2fAuthenticationChecker() + { + return (new Code2fAuthenticationCheckerFactory())->build(); + } +} diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 096bbc9c1..d3cd9f6e0 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -13,6 +13,7 @@ namespace CakeDC\Users\Controller; +use CakeDC\Users\Controller\Traits\Code2fTrait; use CakeDC\Users\Controller\Traits\LinkSocialTrait; use CakeDC\Users\Controller\Traits\LoginTrait; use CakeDC\Users\Controller\Traits\OneTimePasswordVerifyTrait; @@ -42,6 +43,7 @@ class UsersController extends AppController use SocialTrait; use U2fTrait; use Webauthn2faTrait; + use Code2fTrait; /** * Initialize diff --git a/src/Loader/AuthenticationServiceLoader.php b/src/Loader/AuthenticationServiceLoader.php index 5ef982860..0008d2ea8 100644 --- a/src/Loader/AuthenticationServiceLoader.php +++ b/src/Loader/AuthenticationServiceLoader.php @@ -85,6 +85,7 @@ protected function loadTwoFactorAuthenticator($service) if ( Configure::read('OneTimePasswordAuthenticator.login') !== false || Configure::read('U2f.enabled') !== false + || Configure::read('Code2f.enabled') !== false ) { $service->loadAuthenticator('CakeDC/Auth.TwoFactor', [ 'skipTwoFactorVerify' => true, diff --git a/src/Loader/MiddlewareQueueLoader.php b/src/Loader/MiddlewareQueueLoader.php index cb140d87c..54e943792 100644 --- a/src/Loader/MiddlewareQueueLoader.php +++ b/src/Loader/MiddlewareQueueLoader.php @@ -98,6 +98,7 @@ protected function load2faMiddleware(MiddlewareQueue $middlewareQueue) if ( Configure::read('OneTimePasswordAuthenticator.login') !== false || Configure::read('U2f.enabled') !== false + || Configure::read('Code2f.enabled') !== false ) { $middlewareQueue->add(TwoFactorMiddleware::class); } diff --git a/src/Mailer/SMSMailer.php b/src/Mailer/SMSMailer.php new file mode 100644 index 000000000..b488aaaac --- /dev/null +++ b/src/Mailer/SMSMailer.php @@ -0,0 +1,51 @@ +getConfig('phonePattern'); + if (!$phonePattern) { + throw new \UnexpectedValueException(__d('cake_d_c/users', 'You must define `phonePattern` in your transport ({0}) config.', $config)); + } + } + $this->setEmailPattern($phonePattern); + $this->setProfile($config); + $this->setEmailFormat('text'); + } + + public function otp(EntityInterface $user, $code) + { + $this->setTo($user->phone); + $this->deliver(__d('cake_d_c/users', Configure::read('Code2f.message'), $code, Configure::read('App.name'))); + } + +} diff --git a/src/Mailer/Transport/TwilioTransport.php b/src/Mailer/Transport/TwilioTransport.php new file mode 100644 index 000000000..ee9f8b7c9 --- /dev/null +++ b/src/Mailer/Transport/TwilioTransport.php @@ -0,0 +1,71 @@ + '/^\+[1-9]\d{1,14}$/i' + ]; + + /** + * @throws \Twilio\Exceptions\ConfigurationException + * @throws \Twilio\Exceptions\TwilioException + */ + public function send(Message $message): array + { + $to = $message->getTo(); + $recipients = collection($to); + + $recipients->each(function ($recipient) { + if (!preg_match($this->getConfig('phonePattern'), $recipient)) { + throw new \InvalidArgumentException(__d('cake_d_c/users', 'Invalid Recipient {0}: Format must be {1}', $recipient, $this->getConfig('phonePattern'))); + } + }); + + $responses = []; + $client = $this->_getClient(); + foreach ($recipients as $recipient) { + $content = [ + 'from' => $message->getFrom(), + 'body' => $message->getBodyText() + ]; + if (Configure::read('debug')) { + $responses[] = $content; + Log::debug(print_r($content, true)); + continue; + } + $responses[] = $client->messages->create( + $recipient, + $content + ); + }; + return $responses; + } + + /** + * @throws \Twilio\Exceptions\ConfigurationException + */ + protected function _getClient(): Client + { + $sid = $this->getConfig('sid'); + $token = $this->getConfig('token'); + return new Client($sid, $token); + } +} diff --git a/src/Mailer/UsersMailer.php b/src/Mailer/UsersMailer.php index 2e4070e96..51f1f26cf 100644 --- a/src/Mailer/UsersMailer.php +++ b/src/Mailer/UsersMailer.php @@ -12,6 +12,7 @@ */ namespace CakeDC\Users\Mailer; +use Cake\Core\Configure; use Cake\Datasource\EntityInterface; use Cake\Mailer\Mailer; use Cake\Mailer\Message; @@ -112,4 +113,19 @@ protected function socialAccountValidation(EntityInterface $user, EntityInterfac ->viewBuilder() ->setTemplate('CakeDC/Users.socialAccountValidation'); } + + /** + * Send otp code to the user + * + * @param \Cake\Datasource\EntityInterface $user User entity + * @return void + */ + public function otp(EntityInterface $user, $code) + { + $this->setTo($user->email); + $this->setEmailFormat(Message::MESSAGE_BOTH); + $this->setSubject(__d('cake_d_c/users', Configure::read('Code2f.subject'), Configure::read('App.name'))); + $this->deliver(__d('cake_d_c/users', Configure::read('Code2f.message'), $code, $user->email)); + } + } diff --git a/src/Model/Entity/OtpCode.php b/src/Model/Entity/OtpCode.php new file mode 100644 index 000000000..59c588d5d --- /dev/null +++ b/src/Model/Entity/OtpCode.php @@ -0,0 +1,38 @@ + true, + 'code' => true, + 'tries' => true, + 'validated' => true, + 'fingerprint' => true, + 'created' => true, + ]; +} diff --git a/src/Model/Table/OtpCodesTable.php b/src/Model/Table/OtpCodesTable.php new file mode 100644 index 000000000..3e32db49e --- /dev/null +++ b/src/Model/Table/OtpCodesTable.php @@ -0,0 +1,205 @@ +setTable('otp_codes'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + + $this->belongsTo('Users', [ + 'foreignKey' => 'user_id', + 'joinType' => 'INNER', + 'className' => 'CakeDC/Users.Users', + ]); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->integer('id') + ->allowEmptyString('id', null, 'create'); + + $validator + ->scalar('code') + ->maxLength('code', 255) + ->requirePresence('code', 'create') + ->notEmptyString('code'); + + return $validator; + } + + /** + * Returns a rules checker object that will be used for validating + * application integrity. + * + * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. + * @return \Cake\ORM\RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->existsIn('user_id', 'Users'), ['errorField' => 'user_id']); + + return $rules; + } + + public function sendCode2f($userId, $fingerprint = null, $resend = false) + { + $user = $this->Users->get($userId); + $new = false; + try { + if ($otpCode = $this->_getCurrent($userId)) { + if (!$resend) return $otpCode; + } else { + $new = true; + $otpCode = $this->_generateCode($userId, $fingerprint); + } + } catch (\Exception $e) { + Log::error($e->getMessage()); + throw new \UnexpectedValueException(__d('cake_d_c/users', 'An error has occurred generating code. Please try again.')); + } + + if (!$otpCode) { + throw new RecordNotFoundException(__d('cake_d_c/users', 'Verification code could not be generated')); + } + if ($resend && !$new && (new FrozenTime())->diffInSeconds($otpCode->created) < 60) { + throw new \OverflowException(__d('cake_d_c/users', 'You need to wait at least 60 seconds to request a new code')); + } + $type = Configure::read('Code2f.type'); + try { + if ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE) { + (new SMSMailer(Configure::read('Code2f.config', 'sms')))->otp($user, $otpCode->code); + } elseif ($type === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_EMAIL) { + (new UsersMailer(Configure::read('Code2f.config', 'default')))->otp($user, $otpCode->code); + } + } catch (\Exception $e) { + Log::error($e->getMessage()); + throw new \UnexpectedValueException(__d('cake_d_c/users', 'An error has occurred sending code. Please try again.')); + } + + return $otpCode; + } + + /** + * @param $userId + * @return OtpCode + * @throws \Exception + */ + protected function _generateCode($userId, $fingerprint = null) { + $key = random_int(0, 999999); + $code = str_pad((string)$key, 6, '0', STR_PAD_LEFT); + $otpCode = $this->newEntity([ + 'code' => $code, + 'fingerprint' => $fingerprint, + 'user_id' => $userId + ]); + + return $this->save($otpCode); + + } + + protected function _getCurrent($userId) + { + /** @var OtpCode $otpCode */ + $otpCode = $this->find()->where(['user_id' => $userId, 'validated IS' => null])->orderDesc('created')->first(); + if (!$otpCode) { + return false; + } + + if ((new FrozenTime())->diffInSeconds($otpCode->created) > Configure::read('Code2f.maxSeconds') || + $otpCode->tries >= Configure::read('Code2f.maxTries')) { + return false; + } + return $otpCode; + } + + /** + * @param $userId + * @param $code + * @return \CakeDC\Users\Model\Entity\OtpCode|false + */ + public function validateCode2f($userId, $code): bool + { + $user = $this->Users->get($userId); + $otpCode = $this->_getCurrent($userId); + if (!$otpCode) { + throw new TokenExpiredException(__d('cake_d_c/users', 'Verification code is expired or already validated. Please request a new one.')); + } + if ($otpCode->code !== $code) { + $otpCode->tries += 1; + $this->save($otpCode); + throw new \InvalidArgumentException(__d('cake_d_c/users', 'Verification code is not valid. Please try again or request a new one.')); + } + if (Configure::read('Code2f.type') === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE && !$user->phone_verified) { + $user->phone_verified = new FrozenTime(); + $this->Users->save($user); + } + + $otpCode->validated = new FrozenTime(); + return (bool)$this->save($otpCode); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index b39c99565..3f9eb24f6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -42,6 +42,7 @@ class Plugin extends BasePlugin public const EVENT_ON_EXPIRED_TOKEN = 'Users.Global.onExpiredToken'; public const EVENT_AFTER_RESEND_TOKEN_VALIDATION = 'Users.Global.afterResendTokenValidation'; public const EVENT_AFTER_EMAIL_TOKEN_VALIDATION = 'Users.Global.afterEmailTokenValidation'; + public const EVENT_AFTER_CODE2F_VERIFIED = 'Users.Global.afterCode2fVerified'; /** * @inheritDoc diff --git a/src/Utility/UsersUrl.php b/src/Utility/UsersUrl.php index f4c2f9458..a35d9f430 100644 --- a/src/Utility/UsersUrl.php +++ b/src/Utility/UsersUrl.php @@ -123,6 +123,7 @@ private static function getDefaultConfigUrls() return [ 'Users.Profile.route' => static::actionUrl('profile'), 'OneTimePasswordAuthenticator.verifyAction' => static::actionUrl('verify'), + 'Code2f.verifyAction' => static::actionUrl('code2f'), 'U2f.startAction' => static::actionUrl('u2f'), 'Webauthn2fa.startAction' => static::actionUrl('webauthn2fa'), 'Auth.AuthenticationComponent.loginAction' => $loginAction, diff --git a/templates/Users/code2f_authenticate.php b/templates/Users/code2f_authenticate.php new file mode 100644 index 000000000..a883668ae --- /dev/null +++ b/templates/Users/code2f_authenticate.php @@ -0,0 +1,29 @@ +
+
+
+
+ Form->create() ?> + + Flash->render('auth') ?> + Flash->render() ?> +
+ +

+ +

Html->tag('i', $masked)) ?>

+ Form->control('code', ['required' => true,'label' => false, 'placeholder' => __d('cake_d_c/users', 'Verification Code')]) ?> +
+
+
+ + Form->postLink(__d('cake_d_c/users', 'Resend Code'), ['?' => ['resend' => true]], ['block' => 'resendCode']); ?> +
+ +
+ Form->button(__d('cake_d_c/users', ' Verify'), ['class' => 'btn btn-primary', 'escapeTitle' => false]); ?> + Form->end() ?> +
+
+
+
+fetch('resendCode') ?> diff --git a/templates/Users/code2f_register.php b/templates/Users/code2f_register.php new file mode 100644 index 000000000..e9f372e7a --- /dev/null +++ b/templates/Users/code2f_register.php @@ -0,0 +1,17 @@ +
+
+
+
+ Form->create() ?> + + Flash->render('auth') ?> + Flash->render() ?> +
+ Form->control($field, ['required' => true, 'label' => false, 'placeholder' => __d('cake_d_c/users', 'Enter {0}', $field)]) ?> +
+ Form->button(__d('cake_d_c/users', ' Register {0}', $field), ['class' => 'btn btn-primary', 'escapeTitle' => false]); ?> + Form->end() ?> +
+
+
+
diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index d48f6dc55..b6d8adc03 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -72,6 +72,8 @@ public function init(): void ], ], 'last_login' => '2015-06-24 17:33:54', + 'phone' => '+13055555551', + 'phone_verified' => '2015-06-24 17:33:54' ], [ 'id' => '00000000-0000-0000-0000-000000000002', @@ -94,6 +96,8 @@ public function init(): void 'created' => '2015-06-24 17:33:54', 'modified' => '2015-06-24 17:33:54', 'last_login' => '2015-06-24 17:33:54', + 'phone' => '+13055555552', + 'phone_verified' => null ], [ 'id' => '00000000-0000-0000-0000-000000000003',