Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial OTP implementation #981

Draft
wants to merge 9 commits into
base: 11.next-cake4
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Initial OTP implementation
  • Loading branch information
ajibarra committed Feb 18, 2022
commit 1327a2634b56b90de62a1c121ce97c4b0ac29d93
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"league/oauth2-instagram": "@stable",
"league/oauth2-google": "@stable",
"league/oauth2-linkedin": "@stable",
"twilio/sdk": "@stable",
"luchianenco/oauth2-amazon": "^1.1",
"google/recaptcha": "@stable",
"robthree/twofactorauth": "^1.6",
Expand All @@ -59,7 +60,8 @@
"league/oauth2-linkedin": "Provides Social Authentication with LinkedIn",
"google/recaptcha": "Provides reCAPTCHA validation for registration form",
"robthree/twofactorauth": "Provides Google Authenticator functionality",
"cakephp/authorization": "Provide authorization for users"
"cakephp/authorization": "Provide authorization for users",
"twilio/sdk": "Provide SMS OTP with Twilio"
},
"autoload": {
"psr-4": {
Expand Down
52 changes: 52 additions & 0 deletions config/Migrations/20220211110249_AddCode2fFields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class AddCode2fFields extends AbstractMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
* @return void
*/
public function change()
{
$this->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();
}
}
9 changes: 9 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@
'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',
'message' => __d('cake_d_c/users', '{0} is your {1} verification code'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the {0} and {1} will work here as expected.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed _d( since we will replace {0} with code and {1} with App Name. Added a comment in users.php.

'maxSeconds' => 300,
'maxTries' => 3
],
'Webauthn2fa' => [
'enabled' => false,
'appName' => null,//App must set a valid name here
Expand Down
199 changes: 199 additions & 0 deletions src/Controller/Traits/Code2fTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);

/**
* Copyright 2010 - 2022, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2010 - 2022, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/
namespace CakeDC\Users\Controller\Traits;

use Cake\Core\Configure;
use Cake\ORM\TableRegistry;
use CakeDC\Auth\Authentication\AuthenticationService;
use CakeDC\Auth\Authentication\Code2fAuthenticationCheckerFactory;
use CakeDC\Auth\Authentication\Code2fAuthenticationCheckerInterface;
use CakeDC\Auth\Authenticator\TwoFactorAuthenticator;
use CakeDC\Users\Model\Table\OtpCodesTable;

/**
* Class Code2fTrait
*
* @package App\Controller\Traits
* @mixin \Cake\Controller\Controller
*/
trait Code2fTrait
{
use U2fTrait {
redirectWithQuery as redirectWithQuery;
}

/**
* Code2f entry point
*
* @return \Cake\Http\Response|null
*/
public function code2f()
{
$data = $this->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);
if ($data['field'] === Code2fAuthenticationCheckerInterface::CODE2F_TYPE_PHONE && !preg_match('/^\+[1-9]\d{1,14}$/i', $value)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work with any country? if not we may need to move this so the app can extend

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am moving this to a config since it works with any country but may not work with any sms service. Defaulting on Twilio to the current one.

$this->Flash->error(__d('cake_d_c/users', 'Invalid phone number: Format must be +1234567890'));
} else {
$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->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 (\Exception $e) {
$this->Flash->error($e->getMessage());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that we will not show bad error messages like database error or expose file path?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only specific exceptions are catched now

}
} else {
try {
$OtpCodes->sendCode2f($data['user']['id'], $resend);
} catch (\Exception $e) {
$this->Flash->error($e->getMessage());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that we will not show bad error messages like database error or expose file path?

Copy link
Member Author

@ajibarra ajibarra Feb 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have encapsulated potential exceptions logging them and throwing new ones with generic messages. Additionally only specific exceptions are catched now

}
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();
}
}
2 changes: 2 additions & 0 deletions src/Controller/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,7 @@ class UsersController extends AppController
use SocialTrait;
use U2fTrait;
use Webauthn2faTrait;
use Code2fTrait;

/**
* Initialize
Expand Down
1 change: 1 addition & 0 deletions src/Loader/AuthenticationServiceLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/Loader/MiddlewareQueueLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
40 changes: 40 additions & 0 deletions src/Mailer/SMSMailer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);

/**
* Copyright 2010 - 2022, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2010 - 2022, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/
namespace CakeDC\Users\Mailer;

use Cake\Core\Configure;
use Cake\Datasource\EntityInterface;
use Cake\Mailer\Mailer;
use Cake\Mailer\Message;
use CakeDC\Users\Utility\UsersUrl;

/**
* SMS Mailer
*/
class SMSMailer extends Mailer
{
public function __construct($config = null)
{
parent::__construct();
$this->setEmailPattern('/^\+[1-9]\d{1,14}$/m');
$this->setProfile($config);
$this->setEmailFormat('text');
}

public function otp(EntityInterface $user, $code)
{
$this->setTo($user->phone);
$this->deliver(__(Configure::read('Code2f.message'), $code, Configure::read('App.name')));
}

}
Loading