diff --git a/lib/SP/DataModel/UserPassRecoverData.php b/lib/SP/DataModel/UserPassRecoverData.php deleted file mode 100644 index b157a1e7..00000000 --- a/lib/SP/DataModel/UserPassRecoverData.php +++ /dev/null @@ -1,117 +0,0 @@ -. - */ - -namespace SP\DataModel; - -use SP\Domain\Common\Models\Model; - -/** - * Class UserPassRecoverData - * - * @package SP\DataModel - */ -class UserPassRecoverData extends Model -{ - /** - * @var int - */ - public $userId = 0; - /** - * @var string - */ - public $hash = ''; - /** - * @var int - */ - public $date = 0; - /** - * @var bool - */ - public $used = 0; - - /** - * @return int - */ - public function getUserId() - { - return (int)$this->userId; - } - - /** - * @param int $userId - */ - public function setUserId($userId) - { - $this->userId = (int)$userId; - } - - /** - * @return string - */ - public function getHash() - { - return $this->hash; - } - - /** - * @param string $hash - */ - public function setHash($hash) - { - $this->hash = $hash; - } - - /** - * @return int - */ - public function getDate() - { - return $this->date; - } - - /** - * @param int $date - */ - public function setDate($date) - { - $this->date = $date; - } - - /** - * @return boolean - */ - public function isUsed() - { - return (int)$this->used; - } - - /** - * @param boolean $used - */ - public function setUsed($used) - { - $this->used = (int)$used; - } - -} diff --git a/lib/SP/Domain/User/Models/UserPassRecover.php b/lib/SP/Domain/User/Models/UserPassRecover.php new file mode 100644 index 00000000..57ee65cc --- /dev/null +++ b/lib/SP/Domain/User/Models/UserPassRecover.php @@ -0,0 +1,60 @@ +. + */ + +namespace SP\Domain\User\Models; + +use SP\Domain\Common\Models\Model; + +/** + * Class UserPassRecover + */ +class UserPassRecover extends Model +{ + public const TABLE = 'UserPassRecover'; + + public ?int $userId = null; + public ?string $hash = null; + public ?int $date = null; + public ?bool $used = null; + + public function getUserId(): ?int + { + return $this->userId; + } + + public function getHash(): ?string + { + return $this->hash; + } + + public function getDate(): ?int + { + return $this->date; + } + + public function isUsed(): ?bool + { + return $this->used; + } +} diff --git a/lib/SP/Domain/User/Ports/UserPassRecoverRepositoryInterface.php b/lib/SP/Domain/User/Ports/UserPassRecoverRepository.php similarity index 76% rename from lib/SP/Domain/User/Ports/UserPassRecoverRepositoryInterface.php rename to lib/SP/Domain/User/Ports/UserPassRecoverRepository.php index f986889a..7c6286d4 100644 --- a/lib/SP/Domain/User/Ports/UserPassRecoverRepositoryInterface.php +++ b/lib/SP/Domain/User/Ports/UserPassRecoverRepository.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2024, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -27,20 +27,21 @@ namespace SP\Domain\User\Ports; use SP\Domain\Core\Exceptions\ConstraintException; use SP\Domain\Core\Exceptions\QueryException; use SP\Domain\Core\Exceptions\SPException; +use SP\Domain\User\Models\UserPassRecover as UserPassRecoverModel; use SP\Infrastructure\Database\QueryResult; /** * Class UserPassRecoverRepository * - * @package SP\Infrastructure\Common\Repositories\UserPassRecover + * @template T of UserPassRecoverModel */ -interface UserPassRecoverRepositoryInterface +interface UserPassRecoverRepository { /** * Checks recovery limit attempts by user's id and time * - * @param int $userId - * @param int $time + * @param int $userId + * @param int $time * * @return int * @throws ConstraintException @@ -51,20 +52,20 @@ interface UserPassRecoverRepositoryInterface /** * Adds a hash for a user's id * - * @param int $userId - * @param string $hash + * @param int $userId + * @param string $hash * - * @return int + * @return QueryResult * @throws ConstraintException * @throws QueryException */ - public function add(int $userId, string $hash): int; + public function add(int $userId, string $hash): QueryResult; /** * Toggles a hash used * - * @param string $hash - * @param int $time + * @param string $hash + * @param int $time * * @return int * @throws SPException @@ -74,12 +75,10 @@ interface UserPassRecoverRepositoryInterface /** * Comprobar el hash de recuperación de clave. * - * @param string $hash - * @param int $time + * @param string $hash + * @param int $time * - * @return QueryResult - * @throws ConstraintException - * @throws QueryException + * @return QueryResult */ public function getUserIdForHash(string $hash, int $time): QueryResult; } diff --git a/lib/SP/Domain/User/Services/UserPassRecoverService.php b/lib/SP/Domain/User/Services/UserPassRecoverService.php index 8a097f55..f55bd973 100644 --- a/lib/SP/Domain/User/Services/UserPassRecoverService.php +++ b/lib/SP/Domain/User/Services/UserPassRecoverService.php @@ -33,10 +33,9 @@ use SP\Domain\Common\Services\ServiceException; use SP\Domain\Core\Exceptions\ConstraintException; use SP\Domain\Core\Exceptions\QueryException; use SP\Domain\Core\Exceptions\SPException; -use SP\Domain\User\Ports\UserPassRecoverRepositoryInterface; +use SP\Domain\User\Ports\UserPassRecoverRepository; use SP\Domain\User\Ports\UserPassRecoverServiceInterface; use SP\Html\Html; -use SP\Infrastructure\User\Repositories\UserPassRecoverBaseRepository; use SP\Util\PasswordUtil; /** @@ -55,9 +54,9 @@ final class UserPassRecoverService extends Service implements UserPassRecoverSer */ public const MAX_PASS_RECOVER_LIMIT = 3; - protected UserPassRecoverBaseRepository $userPassRecoverRepository; + protected UserPassRecoverRepository $userPassRecoverRepository; - public function __construct(Application $application, UserPassRecoverRepositoryInterface $userPassRecoverRepository) + public function __construct(Application $application, UserPassRecoverRepository $userPassRecoverRepository) { parent::__construct($application); @@ -73,7 +72,7 @@ final class UserPassRecoverService extends Service implements UserPassRecoverSer $mailMessage->addDescription(__('In order to complete the process, please go to this URL:')); $mailMessage->addDescriptionLine(); $mailMessage->addDescription( - Html::anchorText(BootstrapBase::$WEBURI.'/index.php?r=userPassReset/reset/'.$hash) + Html::anchorText(BootstrapBase::$WEBURI . '/index.php?r=userPassReset/reset/' . $hash) ); $mailMessage->addDescriptionLine(); $mailMessage->addDescription(__('If you have not requested this action, please dismiss this message.')); diff --git a/lib/SP/Infrastructure/User/Repositories/UserPassRecover.php b/lib/SP/Infrastructure/User/Repositories/UserPassRecover.php new file mode 100644 index 00000000..fb19c02e --- /dev/null +++ b/lib/SP/Infrastructure/User/Repositories/UserPassRecover.php @@ -0,0 +1,145 @@ +. + */ + +namespace SP\Infrastructure\User\Repositories; + +use SP\Domain\Core\Exceptions\ConstraintException; +use SP\Domain\Core\Exceptions\QueryException; +use SP\Domain\Core\Exceptions\SPException; +use SP\Domain\User\Models\UserPassRecover as UserPassRecoverModel; +use SP\Domain\User\Ports\UserPassRecoverRepository; +use SP\Infrastructure\Common\Repositories\BaseRepository; +use SP\Infrastructure\Database\QueryData; +use SP\Infrastructure\Database\QueryResult; + +use function SP\__u; + +/** + * Class UserPassRecover + * + * @template T of UserPassRecoverModel + */ +final class UserPassRecover extends BaseRepository implements UserPassRecoverRepository +{ + /** + * Checks recovery limit attempts by user's id and time + * + * @param int $userId + * @param int $time + * + * @return int + */ + public function getAttemptsByUserId(int $userId, int $time): int + { + $query = $this->queryFactory + ->newSelect() + ->from(UserPassRecoverModel::TABLE) + ->cols(['date']) + ->where('userId = :userId') + ->where('used = 0') + ->where('date >= :date') + ->bindValues(['userId' => $userId, 'date' => $time]); + + return $this->db->doSelect(QueryData::build($query))->getNumRows(); + } + + /** + * Adds a hash for a user's id + * + * @param int $userId + * @param string $hash + * + * @return QueryResult + * @throws ConstraintException + * @throws QueryException + */ + public function add(int $userId, string $hash): QueryResult + { + $query = $this->queryFactory + ->newInsert() + ->into(UserPassRecoverModel::TABLE) + ->cols(['userId' => $userId, 'hash' => $hash]) + ->set('date', 'UNIX_TIMESTAMP()') + ->set('used', 0); + + $queryData = QueryData::build($query)->setOnErrorMessage(__u('Error while generating the recovering hash')); + + return $this->db->doQuery($queryData); + } + + /** + * Toggles a hash used + * + * @param string $hash + * @param int $time + * + * @return int + * @throws SPException + */ + public function toggleUsedByHash(string $hash, int $time): int + { + $query = $this->queryFactory + ->newUpdate() + ->table(UserPassRecoverModel::TABLE) + ->cols(['used' => 1]) + ->where('hash = :hash', ['hash' => $hash]) + ->where('date >= :date', ['date' => $time]) + ->where('used = 0') + ->limit(1); + + $queryData = QueryData::build($query); + $queryData->setOnErrorMessage(__u('Error while checking hash')); + + return $this->db->doQuery($queryData)->getAffectedNumRows(); + } + + /** + * Comprobar el hash de recuperación de clave. + * + * @param string $hash + * @param int $time + * + * @return QueryResult + */ + public function getUserIdForHash(string $hash, int $time): QueryResult + { + $query = $this->queryFactory + ->newSelect() + ->cols(UserPassRecoverModel::getCols()) + ->from(UserPassRecoverModel::TABLE) + ->where('hash = :hash') + ->where('used = 0') + ->where('date >= :date') + ->orderBy(['date DESC']) + ->limit(1) + ->bindValues( + [ + 'hash' => $hash, + 'date' => $time + ] + ); + + return $this->db->doSelect(QueryData::build($query)->setMapClassName(UserPassRecoverModel::class)); + } +} diff --git a/lib/SP/Infrastructure/User/Repositories/UserPassRecoverBaseRepository.php b/lib/SP/Infrastructure/User/Repositories/UserPassRecoverBaseRepository.php deleted file mode 100644 index 1c67fa13..00000000 --- a/lib/SP/Infrastructure/User/Repositories/UserPassRecoverBaseRepository.php +++ /dev/null @@ -1,147 +0,0 @@ -. - */ - -namespace SP\Infrastructure\User\Repositories; - -use SP\Domain\Core\Exceptions\ConstraintException; -use SP\Domain\Core\Exceptions\QueryException; -use SP\Domain\Core\Exceptions\SPException; -use SP\Domain\User\Ports\UserPassRecoverRepositoryInterface; -use SP\Infrastructure\Common\Repositories\BaseRepository; -use SP\Infrastructure\Database\QueryData; -use SP\Infrastructure\Database\QueryResult; - -/** - * Class UserPassRecoverRepository - * - * @package SP\Infrastructure\Common\Repositories\UserPassRecover - */ -final class UserPassRecoverBaseRepository extends BaseRepository implements UserPassRecoverRepositoryInterface -{ - /** - * Checks recovery limit attempts by user's id and time - * - * @param int $userId - * @param int $time - * - * @return int - * @throws ConstraintException - * @throws QueryException - */ - public function getAttemptsByUserId(int $userId, int $time): int - { - $query = /** @lang SQL */ - 'SELECT userId - FROM UserPassRecover - WHERE userId = ? - AND used = 0 - AND `date` >= ?'; - - $queryData = new QueryData(); - $queryData->setQuery($query); - $queryData->setParams([$userId, $time]); - - return $this->db->doSelect($queryData)->getNumRows(); - } - - /** - * Adds a hash for a user's id - * - * @param int $userId - * @param string $hash - * - * @return int - * @throws ConstraintException - * @throws QueryException - */ - public function add(int $userId, string $hash): int - { - $query = /** @lang SQL */ - 'INSERT INTO UserPassRecover SET - userId = ?, - `hash` = ?, - `date` = UNIX_TIMESTAMP(), - used = 0'; - - $queryData = new QueryData(); - $queryData->setQuery($query); - $queryData->setParams([(int)$userId, $hash]); - $queryData->setOnErrorMessage(__u('Error while generating the recovering hash')); - - return $this->db->doQuery($queryData)->getLastId(); - } - - /** - * Toggles a hash used - * - * @param string $hash - * @param int $time - * - * @return int - * @throws SPException - */ - public function toggleUsedByHash(string $hash, int $time): int - { - $query = /** @lang SQL */ - 'UPDATE UserPassRecover SET used = 1 - WHERE `hash` = ? - AND used = 0 - AND `date` >= ? - LIMIT 1'; - - $queryData = new QueryData(); - $queryData->setQuery($query); - $queryData->setParams([$hash, $time]); - $queryData->setOnErrorMessage(__u('Error while checking hash')); - - return $this->db->doQuery($queryData)->getAffectedNumRows(); - } - - /** - * Comprobar el hash de recuperación de clave. - * - * @param string $hash - * @param int $time - * - * @return QueryResult - * @throws ConstraintException - * @throws QueryException - */ - public function getUserIdForHash(string $hash, int $time): QueryResult - { - $query = /** @lang SQL */ - 'SELECT userId - FROM UserPassRecover - WHERE `hash` = ? - AND used = 0 - AND `date` >= ? - ORDER BY `date` DESC LIMIT 1'; - - $queryData = new QueryData(); - $queryData->setQuery($query); - $queryData->setParams([$hash, $time]); - - return $this->db->doSelect($queryData); - } -} diff --git a/tests/SPT/Infrastructure/User/Repositories/UserPassRecoverTest.php b/tests/SPT/Infrastructure/User/Repositories/UserPassRecoverTest.php new file mode 100644 index 00000000..89aa454a --- /dev/null +++ b/tests/SPT/Infrastructure/User/Repositories/UserPassRecoverTest.php @@ -0,0 +1,176 @@ +. + */ + +namespace SPT\Infrastructure\User\Repositories; + +use Aura\SqlQuery\Common\InsertInterface; +use Aura\SqlQuery\Common\UpdateInterface; +use Aura\SqlQuery\QueryFactory; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Constraint\Callback; +use PHPUnit\Framework\MockObject\MockObject; +use SP\Domain\Common\Models\Simple; +use SP\Domain\Core\Exceptions\ConstraintException; +use SP\Domain\Core\Exceptions\QueryException; +use SP\Domain\Core\Exceptions\SPException; +use SP\Domain\User\Models\UserPassRecover as UserPassRecoverModel; +use SP\Infrastructure\Database\DatabaseInterface; +use SP\Infrastructure\Database\QueryData; +use SP\Infrastructure\Database\QueryResult; +use SP\Infrastructure\User\Repositories\UserPassRecover; +use SPT\UnitaryTestCase; + +/** + * Class UserPassRecoverTest + */ +#[Group('unitary')] +class UserPassRecoverTest extends UnitaryTestCase +{ + + private UserPassRecover $userPassRecover; + private MockObject|DatabaseInterface $database; + + public function testGetAttemptsByUserId() + { + $time = self::$faker->unixTime(); + + $this->database + ->expects($this->once()) + ->method('doSelect') + ->with( + self::callback(static function (QueryData $queryData) use ($time) { + $params = $queryData->getQuery()->getBindValues(); + + return count($params) === 2 + && $params['userId'] === 100 + && $params['date'] === $time + && $queryData->getMapClassName() === Simple::class; + }) + ); + + $this->userPassRecover->getAttemptsByUserId(100, $time); + } + + public function testGetUserIdForHash() + { + $time = self::$faker->unixTime(); + $hash = self::$faker->sha1(); + + $this->database + ->expects($this->once()) + ->method('doSelect') + ->with( + self::callback(static function (QueryData $queryData) use ($hash, $time) { + $params = $queryData->getQuery()->getBindValues(); + + return count($params) === 2 + && $params['hash'] === $hash + && $params['date'] === $time + && $queryData->getMapClassName() === UserPassRecoverModel::class; + }) + ); + + $this->userPassRecover->getUserIdForHash($hash, $time); + } + + /** + * @throws ConstraintException + * @throws QueryException + */ + public function testAdd() + { + $hash = self::$faker->sha1(); + + $callbackCreate = new Callback( + static function (QueryData $arg) use ($hash) { + $query = $arg->getQuery(); + $params = $query->getBindValues(); + + return count($params) === 2 + && $params['userId'] === 100 + && $params['hash'] === $hash + && is_a($query, InsertInterface::class) + && !empty($query->getStatement()); + } + ); + + $this->database + ->expects(self::once()) + ->method('doQuery') + ->with($callbackCreate) + ->willReturn(new QueryResult([1])); + + $this->userPassRecover->add(100, $hash); + } + + /** + * @throws SPException + */ + public function testToggleUsedByHash() + { + $time = self::$faker->unixTime(); + $hash = self::$faker->sha1(); + + $callbackUpdate = new Callback( + static function (QueryData $arg) use ($hash, $time) { + $query = $arg->getQuery(); + $params = $query->getBindValues(); + + return count($params) === 3 + && $params['hash'] === $hash + && $params['date'] === $time + && $params['used'] === 1 + && is_a($query, UpdateInterface::class) + && !empty($query->getStatement()); + } + ); + + $queryResult = new QueryResult(); + + $this->database + ->expects(self::once()) + ->method('doQuery') + ->with($callbackUpdate) + ->willReturn($queryResult->setAffectedNumRows(1)); + + $out = $this->userPassRecover->toggleUsedByHash($hash, $time); + + self::assertEquals(1, $out); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->database = $this->createMock(DatabaseInterface::class); + $queryFactory = new QueryFactory('mysql'); + + $this->userPassRecover = new UserPassRecover( + $this->database, + $this->context, + $this->application->getEventDispatcher(), + $queryFactory, + ); + } +}