Skip to content

Commit

Permalink
Merge pull request #1082 from rochamarcelo/feature/account-lockout-po…
Browse files Browse the repository at this point in the history
…licy

Feature/account lockout policy
  • Loading branch information
steinkel authored Mar 29, 2024
2 parents bbba20a + 2b2fa3e commit 4ae200f
Show file tree
Hide file tree
Showing 15 changed files with 867 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ composer.lock
.php_cs*
/coverage
.phpunit.result.cache
/config/Migrations/schema-dump-default.lock
35 changes: 35 additions & 0 deletions config/Migrations/20240328135459_CreateFailedPasswordAttempts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateFailedPasswordAttempts 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(): void
{
$table = $this->table('failed_password_attempts', ['id' => false, 'primary_key' => ['id']]);
$table->addColumn('id', 'uuid', [
'null' => false,
]);
$table->addColumn('user_id', 'uuid', [
'default' => null,
'null' => false,
]);
$table->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
]);
$table->addForeignKey('user_id', 'users', 'id', [
'delete' => 'CASCADE',
'update' => 'CASCADE',
]);
$table->create();
}
}
24 changes: 24 additions & 0 deletions config/Migrations/20240328215332_AddLockoutTimeToUsers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class AddLockoutTimeToUsers 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(): void
{
$table = $this->table('users');
$table->addColumn('lockout_time', 'datetime', [
'default' => null,
'null' => true,
]);
$table->update();
}
}
Binary file removed config/Migrations/schema-dump-default.lock
Binary file not shown.
190 changes: 190 additions & 0 deletions src/Identifier/PasswordLockout/LockoutHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);

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

namespace CakeDC\Users\Identifier\PasswordLockout;

use Cake\Core\InstanceConfigTrait;
use Cake\I18n\DateTime;
use Cake\ORM\Locator\LocatorAwareTrait;
use Cake\ORM\Query\SelectQuery;
use CakeDC\Users\Model\Entity\FailedPasswordAttempt;

class LockoutHandler implements LockoutHandlerInterface
{
use InstanceConfigTrait;
use LocatorAwareTrait;

/**
* Default configuration.
*
* @var array{timeWindowInSeconds: int, lockoutTimeInSeconds: int, numberOfAttemptsFail:int}
*/
protected array $_defaultConfig = [
'timeWindowInSeconds' => 5 * 60,
'lockoutTimeInSeconds' => 5 * 60,
'numberOfAttemptsFail' => 6,
'failedPasswordAttemptsModel' => 'CakeDC/Users.FailedPasswordAttempts',
'userLockoutField' => 'lockout_time',
'usersModel' => 'Users',
];

/**
* Constructor
*
* @param array $config Configuration
*/
public function __construct(array $config = [])
{
$this->setConfig($config);
}

/**
* @param \ArrayAccess|array $identity
* @return bool
*/
public function isUnlocked(\ArrayAccess|array $identity): bool
{
if (!isset($identity['id'])) {
return false;
}
$lockoutField = $this->getConfig('userLockoutField');
$userLockoutTime = $identity[$lockoutField] ?? null;
if ($userLockoutTime) {
if (!$this->checkLockoutTime($userLockoutTime)) {//Still locked?
return false;
}
}
$timeWindow = $this->getTimeWindow();
$attemptsCount = $this->getAttemptsCount($identity['id'], $timeWindow);
$numberOfAttemptsFail = $this->getNumberOfAttemptsFail();
if ($numberOfAttemptsFail > $attemptsCount) {
return true;
}
$lastAttempt = $this->getLastAttempt($identity['id'], $timeWindow);
$this->getTableLocator()
->get($this->getConfig('usersModel'))
->updateAll([$lockoutField => $lastAttempt->created], ['id' => $identity['id']]);

return $this->checkLockoutTime($lastAttempt->created);
}

/**
* @param string|int $id User's id
* @return void
*/
public function newFail(string|int $id): void
{
$timeWindow = $this->getTimeWindow();
$Table = $this->getTable();
$entity = $Table->newEntity(['user_id' => $id]);
$Table->saveOrFail($entity);
$Table->deleteAll($Table->query()->newExpr()->lt('created', $timeWindow));
}

/**
* @return \Cake\ORM\Table
*/
protected function getTable(): \Cake\ORM\Table
{
return $this->getTableLocator()->get('CakeDC/Users.FailedPasswordAttempts');
}

/**
* @param string|int $id
* @param \Cake\I18n\DateTime $timeWindow
* @return int
*/
protected function getAttemptsCount(string|int $id, DateTime $timeWindow): int
{
return $this->getAttemptsQuery($id, $timeWindow)->count();
}

/**
* @param string|int $id
* @param \Cake\I18n\DateTime $timeWindow
* @return \CakeDC\Users\Model\Entity\FailedPasswordAttempt
*/
protected function getLastAttempt(int|string $id, DateTime $timeWindow): FailedPasswordAttempt
{
/**
* @var \CakeDC\Users\Model\Entity\FailedPasswordAttempt $attempt
*/
$attempt = $this->getAttemptsQuery($id, $timeWindow)->first();

return $attempt;
}

/**
* @param string|int $id
* @param \Cake\I18n\DateTime $timeWindow
* @return \Cake\ORM\Query\SelectQuery
*/
protected function getAttemptsQuery(int|string $id, DateTime $timeWindow): SelectQuery
{
$query = $this->getTable()->find();

return $query
->where([
'user_id' => $id,
$query->newExpr()->gte('created', $timeWindow),
])
->orderByDesc('created');
}

/**
* @return \Cake\I18n\DateTime
*/
protected function getTimeWindow(): DateTime
{
$timeWindow = $this->getConfig('timeWindowInSeconds');
if (is_int($timeWindow) && $timeWindow >= 60) {
return (new DateTime())->subSeconds($timeWindow);
}

throw new \UnexpectedValueException(__d('cake_d_c/users', 'Config "timeWindowInSeconds" must be integer greater than 60'));
}

/**
* @return int
*/
protected function getNumberOfAttemptsFail(): int
{
$number = $this->getConfig('numberOfAttemptsFail');
if (is_int($number) && $number >= 1) {
return $number;
}
throw new \UnexpectedValueException(__d('cake_d_c/users', 'Config "numberOfAttemptsFail" must be integer greater or equal 0'));
}

/**
* @return int
*/
protected function getLockoutTime(): int
{
$lockTime = $this->getConfig('lockoutTimeInSeconds');
if (is_int($lockTime) && $lockTime >= 60) {
return $lockTime;
}

throw new \UnexpectedValueException(__d('cake_d_c/users', 'Config "lockoutTimeInSeconds" must be integer greater than 60'));
}

/**
* @param \Cake\I18n\DateTime $dateTime
* @return bool
*/
protected function checkLockoutTime(DateTime $dateTime): bool
{
return $dateTime->addSeconds($this->getLockoutTime())->isPast();
}
}
19 changes: 19 additions & 0 deletions src/Identifier/PasswordLockout/LockoutHandlerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);

namespace CakeDC\Users\Identifier\PasswordLockout;

interface LockoutHandlerInterface
{
/**
* @param \ArrayAccess|array $identity
* @return bool
*/
public function isUnlocked(\ArrayAccess|array $identity): bool;

/**
* @param string|int $id User's id
* @return void
*/
public function newFail(string|int $id): void;
}
94 changes: 94 additions & 0 deletions src/Identifier/PasswordLockoutIdentifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);

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

namespace CakeDC\Users\Identifier;

use ArrayAccess;
use Authentication\Identifier\PasswordIdentifier;
use CakeDC\Users\Identifier\PasswordLockout\LockoutHandler;
use CakeDC\Users\Identifier\PasswordLockout\LockoutHandlerInterface;

class PasswordLockoutIdentifier extends PasswordIdentifier
{
/**
* @var \CakeDC\Users\Identifier\PasswordLockout\LockoutHandlerInterface|null
*/
protected ?LockoutHandlerInterface $lockoutHandler = null;

/**
* @inheritDoc
*/
public function __construct(array $config = [])
{
$this->_defaultConfig['lockoutHandler'] = [
'className' => LockoutHandler::class,
];

parent::__construct($config);
}

/**
* @inheritDoc
*/
protected function _checkPassword(ArrayAccess|array|null $identity, ?string $password): bool
{
if (!isset($identity['id'])) {
return false;
}
$check = parent::_checkPassword($identity, $password);
$handler = $this->getLockoutHandler();
if (!$check) {
$handler->newFail($identity['id']);

return false;
}

return $handler->isUnlocked($identity);
}

/**
* @return \CakeDC\Users\Identifier\PasswordLockout\LockoutHandlerInterface
*/
protected function getLockoutHandler(): LockoutHandlerInterface
{
if ($this->lockoutHandler !== null) {
return $this->lockoutHandler;
}
$config = $this->getConfig('lockoutHandler');
if ($config !== null) {
$this->lockoutHandler = $this->buildLockoutHandler($config);

return $this->lockoutHandler;
}
throw new \RuntimeException(__d('cake_d_c/users', 'Lockout handler has not been set.'));
}

/**
* @param array|string $config
* @return \CakeDC\Users\Identifier\PasswordLockout\LockoutHandlerInterface
*/
protected function buildLockoutHandler(array|string $config): LockoutHandlerInterface
{
if (is_string($config)) {
$config = [
'className' => $config,
];
}
if (!isset($config['className'])) {
throw new \InvalidArgumentException(__d('cake_d_c/users', 'Option `className` for lockout handler is not present.'));
}
$className = $config['className'];

return new $className($config);
}
}
Loading

0 comments on commit 4ae200f

Please sign in to comment.