diff --git a/.gitignore b/.gitignore index f696b2eed..b4faf0fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ composer.lock .php_cs* /coverage .phpunit.result.cache +/config/Migrations/schema-dump-default.lock diff --git a/config/Migrations/20240328135459_CreateFailedPasswordAttempts.php b/config/Migrations/20240328135459_CreateFailedPasswordAttempts.php new file mode 100644 index 000000000..d6fe743c0 --- /dev/null +++ b/config/Migrations/20240328135459_CreateFailedPasswordAttempts.php @@ -0,0 +1,35 @@ +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(); + } +} diff --git a/config/Migrations/20240328215332_AddLockoutTimeToUsers.php b/config/Migrations/20240328215332_AddLockoutTimeToUsers.php new file mode 100644 index 000000000..4055ebb5d --- /dev/null +++ b/config/Migrations/20240328215332_AddLockoutTimeToUsers.php @@ -0,0 +1,24 @@ +table('users'); + $table->addColumn('lockout_time', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->update(); + } +} diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock deleted file mode 100644 index 9c437b3c1..000000000 Binary files a/config/Migrations/schema-dump-default.lock and /dev/null differ diff --git a/src/Identifier/PasswordLockout/LockoutHandler.php b/src/Identifier/PasswordLockout/LockoutHandler.php new file mode 100644 index 000000000..4896ef9a7 --- /dev/null +++ b/src/Identifier/PasswordLockout/LockoutHandler.php @@ -0,0 +1,190 @@ + 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(); + } +} diff --git a/src/Identifier/PasswordLockout/LockoutHandlerInterface.php b/src/Identifier/PasswordLockout/LockoutHandlerInterface.php new file mode 100644 index 000000000..d524adca1 --- /dev/null +++ b/src/Identifier/PasswordLockout/LockoutHandlerInterface.php @@ -0,0 +1,19 @@ +_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); + } +} diff --git a/src/Model/Entity/FailedPasswordAttempt.php b/src/Model/Entity/FailedPasswordAttempt.php new file mode 100644 index 000000000..437f060bc --- /dev/null +++ b/src/Model/Entity/FailedPasswordAttempt.php @@ -0,0 +1,33 @@ + + */ + protected array $_accessible = [ + 'user_id' => true, + 'created' => true, + 'user' => true, + ]; +} diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php index d286c2632..cd454f375 100644 --- a/src/Model/Entity/User.php +++ b/src/Model/Entity/User.php @@ -32,6 +32,7 @@ * @property array|string $additional_data * @property \CakeDC\Users\Model\Entity\SocialAccount[] $social_accounts * @property string $password + * @property \Cake\I18n\DateTime $lockout_time */ class User extends Entity { diff --git a/src/Model/Table/FailedPasswordAttemptsTable.php b/src/Model/Table/FailedPasswordAttemptsTable.php new file mode 100644 index 000000000..911247d77 --- /dev/null +++ b/src/Model/Table/FailedPasswordAttemptsTable.php @@ -0,0 +1,82 @@ + newEntities(array $data, array $options = []) + * @method \CakeDC\Users\Model\Entity\FailedPasswordAttempt get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \CakeDC\Users\Model\Entity\FailedPasswordAttempt findOrCreate($search, ?callable $callback = null, array $options = []) + * @method \CakeDC\Users\Model\Entity\FailedPasswordAttempt patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method array<\CakeDC\Users\Model\Entity\FailedPasswordAttempt> patchEntities(iterable $entities, array $data, array $options = []) + * @method \CakeDC\Users\Model\Entity\FailedPasswordAttempt|false save(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method \CakeDC\Users\Model\Entity\FailedPasswordAttempt saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method iterable<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|\Cake\Datasource\ResultSetInterface<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|false saveMany(iterable $entities, array $options = []) + * @method iterable<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|\Cake\Datasource\ResultSetInterface<\CakeDC\Users\Model\Entity\FailedPasswordAttempt> saveManyOrFail(iterable $entities, array $options = []) + * @method iterable<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|\Cake\Datasource\ResultSetInterface<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|false deleteMany(iterable $entities, array $options = []) + * @method iterable<\CakeDC\Users\Model\Entity\FailedPasswordAttempt>|\Cake\Datasource\ResultSetInterface<\CakeDC\Users\Model\Entity\FailedPasswordAttempt> deleteManyOrFail(iterable $entities, array $options = []) + * @mixin \Cake\ORM\Behavior\TimestampBehavior + */ +class FailedPasswordAttemptsTable extends Table +{ + /** + * Initialize method + * + * @param array $config The configuration for the Table. + * @return void + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('failed_password_attempts'); + $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 + ->uuid('user_id') + ->notEmptyString('user_id'); + + 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; + } +} diff --git a/tests/Fixture/FailedPasswordAttemptsFixture.php b/tests/Fixture/FailedPasswordAttemptsFixture.php new file mode 100644 index 000000000..f609682ec --- /dev/null +++ b/tests/Fixture/FailedPasswordAttemptsFixture.php @@ -0,0 +1,79 @@ +records = [ + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c701', + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'created' => date('Y-m-d H:i:s', strtotime('-20 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c702', + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'created' => date('Y-m-d H:i:s', strtotime('-4 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c703', + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'created' => date('Y-m-d H:i:s', strtotime('-4 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c704', + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'created' => date('Y-m-d H:i:s', strtotime('-3 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c705', + 'user_id' => '00000000-0000-0000-0000-000000000002', + 'created' => date('Y-m-d H:i:s', strtotime('-2 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c800', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-4 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c801', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-4 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c802', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-4 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c803', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-3 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c804', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-3 minutes')), + ], + [ + 'id' => '79cdd7a7-0f34-49dd-a691-21444f94c805', + 'user_id' => '00000000-0000-0000-0000-000000000004', + 'created' => date('Y-m-d H:i:s', strtotime('-2 minutes')), + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Identifier/PasswordLockout/LockoutHandlerTest.php b/tests/TestCase/Identifier/PasswordLockout/LockoutHandlerTest.php new file mode 100644 index 000000000..45c87ef8b --- /dev/null +++ b/tests/TestCase/Identifier/PasswordLockout/LockoutHandlerTest.php @@ -0,0 +1,136 @@ +get('CakeDC/Users.FailedPasswordAttempts'); + $id = '00000000-0000-0000-0000-000000000002'; + $handler = new LockoutHandler(); + $currentCount = 5; + $attemptsBefore = $AttemptsTable->find() + ->where(['user_id' => $id]) + ->orderByAsc('created') + ->all() + ->toArray(); + //First time will remove old records and still add a new one + $handler->newFail($id); + $this->assertSame($currentCount, count($attemptsBefore)); + $this->assertFalse($AttemptsTable->exists(['id' => $attemptsBefore[0]->id])); + + //Now only add a new one because there is nothing to remove + $handler = new LockoutHandler(); + $handler->newFail($id); + $attemptsAfterSecond = $AttemptsTable->find()->where(['user_id' => $id])->count(); + $this->assertSame($currentCount + 1, $attemptsAfterSecond); + } + + /** + * @return void + */ + public function testIsUnlockedYes() + { + $handler = new LockoutHandler(); + $UsersTable = TableRegistry::getTableLocator()->get('Users'); + $actual = $handler->isUnlocked($UsersTable->get('00000000-0000-0000-0000-000000000002')); + $this->assertTrue($actual); + } + + /** + * @return void + */ + public function testIsUnlockedNotSavedLockoutAndLastFailureMax() + { + $userId = '00000000-0000-0000-0000-000000000004'; + $UsersTable = TableRegistry::getTableLocator()->get('Users'); + $userBefore = $UsersTable->get($userId); + $this->assertNull($userBefore->lockout_time); + $handler = new LockoutHandler(); + $actual = $handler->isUnlocked($UsersTable->get($userId)); + $this->assertFalse($actual); + $userAfter = $UsersTable->get($userId); + $this->assertInstanceOf(DateTime::class, $userAfter->lockout_time); + } + + /** + * @return void + */ + public function testIsUnlockedSaveLockoutAndCompleted() + { + $handler = new LockoutHandler([ + 'numberOfAttemptsFail' => 7, + ]); + $UsersTable = TableRegistry::getTableLocator()->get('Users'); + $userId = '00000000-0000-0000-0000-000000000004'; + $UsersTable->updateAll(['lockout_time' => new DateTime('-6 minutes')], ['id' => $userId]); + $userBefore = $UsersTable->get($userId); + $this->assertInstanceOf(DateTime::class, $userBefore->lockout_time); + + $actual = $handler->isUnlocked($UsersTable->get($userId)); + $this->assertTrue($actual); + } + + /** + * @return void + */ + public function testIsUnlockedSaveLockoutAndNotCompleted() + { + $handler = new LockoutHandler([ + 'numberOfAttemptsFail' => 7, + ]); + $userId = '00000000-0000-0000-0000-000000000004'; + $UsersTable = TableRegistry::getTableLocator()->get('Users'); + $UsersTable->updateAll(['lockout_time' => new DateTime('-4 minutes')], ['id' => $userId]); + $userBefore = $UsersTable->get($userId); + $this->assertInstanceOf(DateTime::class, $userBefore->lockout_time); + + $actual = $handler->isUnlocked($UsersTable->get($userId)); + $this->assertFalse($actual); + $userAfter = $UsersTable->get($userId); + $this->assertInstanceOf(DateTime::class, $userAfter->lockout_time); + $this->assertEquals($userBefore->lockout_time, $userAfter->lockout_time); + } + + /** + * @return void + */ + public function testIsUnlockedWithoutIdButNotEmpty() + { + $handler = new LockoutHandler(); + $user = [ + 'username' => 'user-2', + 'email' => 'user-2@test.com', + ]; + $actual = $handler->isUnlocked($user); + $this->assertFalse($actual); + } + + /** + * @return void + */ + public function testIsUnlockedWithoutIdAndEmpty() + { + $handler = new LockoutHandler(); + $actual = $handler->isUnlocked([]); + $this->assertFalse($actual); + } +} diff --git a/tests/TestCase/Identifier/PasswordLockoutIdentifierTest.php b/tests/TestCase/Identifier/PasswordLockoutIdentifierTest.php new file mode 100644 index 000000000..c2b713112 --- /dev/null +++ b/tests/TestCase/Identifier/PasswordLockoutIdentifierTest.php @@ -0,0 +1,152 @@ +get('CakeDC/Users.FailedPasswordAttempts'); + $Table = TableRegistry::getTableLocator()->get('CakeDC/Users.Users'); + $user = $Table->get('00000000-0000-0000-0000-000000000002'); + $currentCount = 5; + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + $user->password = $password; + $Table->saveOrFail($user); + $identifier = new PasswordLockoutIdentifier(); + $identity = $identifier->identify([ + PasswordIdentifier::CREDENTIAL_USERNAME => $user->username, + PasswordIdentifier::CREDENTIAL_PASSWORD => $password, + ]); + $this->assertInstanceOf(EntityInterface::class, $identity); + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + } + + /** + * Test identify method with password and not locked + * + * @return void + */ + public function testIdentifyValidPasswordButReachedMaxAttemptsAndLockTimeNotCompleted() + { + $password = Security::randomString(); + $AttemptsTable = TableRegistry::getTableLocator()->get('CakeDC/Users.FailedPasswordAttempts'); + $Table = TableRegistry::getTableLocator()->get('CakeDC/Users.Users'); + $user = $Table->get('00000000-0000-0000-0000-000000000004'); + $currentCount = 6; + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + $user->password = $password; + $Table->saveOrFail($user); + $identifier = new PasswordLockoutIdentifier(); + $identity = $identifier->identify([ + PasswordIdentifier::CREDENTIAL_USERNAME => $user->username, + PasswordIdentifier::CREDENTIAL_PASSWORD => $password, + ]); + $this->assertNull($identity); + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + } + + /** + * Test identify method with password and not locked + * + * @return void + */ + public function testIdentifyValidPasswordButReachedMaxAttemptsAndLockTimeAlreadyCompleted() + { + $password = Security::randomString(); + $AttemptsTable = TableRegistry::getTableLocator()->get('CakeDC/Users.FailedPasswordAttempts'); + $Table = TableRegistry::getTableLocator()->get('CakeDC/Users.Users'); + $user = $Table->get('00000000-0000-0000-0000-000000000004'); + $currentCount = 6; + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + $user->password = $password; + $Table->saveOrFail($user); + $identifier = new PasswordLockoutIdentifier([ + 'lockoutHandler' => [ + 'lockoutTimeInSeconds' => 60, + ], + ]); + $identity = $identifier->identify([ + PasswordIdentifier::CREDENTIAL_USERNAME => $user->username, + PasswordIdentifier::CREDENTIAL_PASSWORD => $password, + ]); + $this->assertInstanceOf(EntityInterface::class, $identity); + $this->assertSame($currentCount, $AttemptsTable->find()->where(['user_id' => $user->id])->count()); + } + + /** + * Test identify method with password and not locked + * + * @return void + */ + public function testIdentifyInValidPasswordClearOldFailures() + { + $password = Security::randomString(); + $AttemptsTable = TableRegistry::getTableLocator()->get('CakeDC/Users.FailedPasswordAttempts'); + $Table = TableRegistry::getTableLocator()->get('CakeDC/Users.Users'); + $user = $Table->get('00000000-0000-0000-0000-000000000002'); + $currentCount = 5; + $attemptsBefore = $AttemptsTable->find() + ->where(['user_id' => $user->id]) + ->orderByAsc('created') + ->all() + ->toArray(); + $this->assertSame($currentCount, count($attemptsBefore)); + $wrongPassword = Security::randomString(); + $this->assertNotEquals($wrongPassword, $password); + $user->password = $password; + $Table->saveOrFail($user); + $identifier = new PasswordLockoutIdentifier(); + $identity = $identifier->identify([ + PasswordIdentifier::CREDENTIAL_USERNAME => $user->username, + PasswordIdentifier::CREDENTIAL_PASSWORD => $wrongPassword,//wrong password + ]); + //First call remove the first failed_password_attempt because is out of the window range and adds a new one + $this->assertNull($identity); + $this->assertFalse($AttemptsTable->exists(['id' => $attemptsBefore[0]->id])); + $attemptsAfter = $AttemptsTable->find()->where(['user_id' => $user->id])->orderByAsc('created')->all()->toArray(); + $this->assertSame($currentCount, count($attemptsAfter)); + + $identity = $identifier->identify([ + PasswordIdentifier::CREDENTIAL_USERNAME => $user->username, + PasswordIdentifier::CREDENTIAL_PASSWORD => $wrongPassword,//wrong password + ]); + //On second call there is no record out of the window range only adds a new one + $this->assertNull($identity); + $this->assertTrue($AttemptsTable->exists(['id' => $attemptsAfter[0]->id])); + $attemptsAfterSecond = $AttemptsTable->find()->where(['user_id' => $user->id])->count(); + $this->assertSame($currentCount + 1, $attemptsAfterSecond); + } +} diff --git a/tests/TestCase/Model/Table/UsersTableTest.php b/tests/TestCase/Model/Table/UsersTableTest.php index b9995544f..4d731996c 100644 --- a/tests/TestCase/Model/Table/UsersTableTest.php +++ b/tests/TestCase/Model/Table/UsersTableTest.php @@ -20,6 +20,7 @@ use Cake\TestSuite\TestCase; use CakeDC\Users\Exception\AccountNotActiveException; use CakeDC\Users\Model\Table\SocialAccountsTable; +use CakeDC\Users\Model\Table\UsersTable; /** * Users\Model\Table\UsersTable Test Case @@ -36,6 +37,9 @@ class UsersTableTest extends TestCase 'plugin.CakeDC/Users.SocialAccounts', ]; + protected UsersTable $Users; + protected string $fullBaseBackup; + /** * setUp method * diff --git a/tests/schema.php b/tests/schema.php index 18db1b7c5..b9c6ac078 100644 --- a/tests/schema.php +++ b/tests/schema.php @@ -71,6 +71,7 @@ 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 'additional_data' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 'last_login' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'lockout_time' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], ], 'constraints' => [ 'primary' => [ @@ -92,4 +93,20 @@ 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], ], ], + [ + 'table' => 'failed_password_attempts', + 'columns' => [ + 'id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], + 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + ], + 'constraints' => [ + 'primary' => [ + 'type' => 'primary', + 'columns' => [ + 'id', + ], + ], + ], + ], ];