-
Notifications
You must be signed in to change notification settings - Fork 296
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
base: 11.next-cake4
Are you sure you want to change the base?
Changes from 1 commit
1327a26
8bb108a
ef3a33e
4a02570
924b926
8bc01a8
d7b4ca4
0a7eb7b
a49246e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
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(); | ||
} | ||
} |
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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
} | ||
} |
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'))); | ||
} | ||
|
||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.