diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d511a..a4ae40c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip ## Unreleased ### Added -- A test suite of 55 tests: unit tests for the builder, the token, the repository, the validation concern, the facade - and the service provider, and feature tests that run the flows of the readme end to end against a database. It - covers 98% of `src/`. +- The valid tokens of a model can be looked up without knowing a token: `$user->hasValidTemporaryToken('reset-password')` + and `$user->validTemporaryTokens('reset-password')` answer whether the model still has a token that has neither + expired nor been used up, which a "forgot password" flow needs before it sends a new pin. The same questions are + answered by `TokenBuilder::setRelatedItem($user)->hasAnyValidToken()` and `findValidTokens()` — without a related + item they look at the tokens of every model — and by `TokensRepository::hasAnyValidToken()` and + `findValidTokens()`. Both ignore the unique id: `findValidToken()` and `isValid()` stay the way to validate a token + you have. ([#6](https://github.com/shetabit/token-builder/issues/6)) +- A test suite of 69 tests: unit tests for the builder, the token, the repository, the validation concern, the token + trait, the facade and the service provider, and feature tests that run the flows of the readme end to end against a + database. It covers 100% of `src/`. - GitHub Actions workflows running the test suite (PHP 8.4 and 8.5, Laravel 12 and 13, lowest and highest dependencies), the coding style check, the static analysis and the code coverage on every pull request and on every push to `master`. The coverage has to stay above 95%. diff --git a/README.md b/README.md index dee65f0..c8c7e5a 100755 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ we have 2 things that can **expire** genereted tokens: - [Add relations](#useful-methods) - [Attach custom data](#useful-methods) - [Retrieve tokens](#retrieve-tokens) + - [The valid tokens of a model](#the-valid-tokens-of-a-model) - [TokenBuilder reference](#tokenbuilder-reference) - [setUniqueId](#setUniqueId) - [getUniqueId](#getUniqueId) @@ -53,6 +54,13 @@ we have 2 things that can **expire** genereted tokens: - [build](#build) - [findToken](#findToken) - [findValidToken](#findValidToken) + - [findValidTokens](#findValidTokens) + - [hasAnyValidToken](#hasAnyValidToken) + - [Model (HasTemporaryTokens) reference](#model-reference) + - [temporaryTokens](#temporaryTokens) + - [temporaryTokenBuilder](#temporaryTokenBuilder) + - [validTemporaryTokens](#validTemporaryTokens) + - [hasValidTemporaryToken](#hasValidTemporaryToken) - [Token (Eloquent) reference](#token-reference) - [use](#use) - [hasUsed](#hasUsed) @@ -287,6 +295,62 @@ $tokenObject = $user->temporaryTokenBuilder()->setUniqueId($token)->findToken(); $tokenObject = $user->temporaryTokenBuilder()->setUniqueId($token)->findValidToken(); ``` +#### The valid tokens of a model + +sometimes you don't have a token yet and want to know whether a model still has one that is valid: +a `forgot password` pin should not be sent again while the one that was sent is still usable. + +a model that uses the `HasTemporaryTokens` trait can answer that itself: + +```php +use App\User; + +$user = User::first(); + +// does this user still have a valid token? +if ($user->hasValidTemporaryToken()) { + echo 'this user has a valid token'; +} + +// the same question, for one type of token only +if ($user->hasValidTemporaryToken('reset-password')) { + echo 'a reset pin was sent and is still valid'; +} + +// retrieve them, latest one first +$tokenObjects = $user->validTemporaryTokens('reset-password'); + +echo $tokenObjects->count(); +echo $tokenObjects->first()?->token; +``` + +so a pin is only built when there is none left: + +```php +if (!$user->hasValidTemporaryToken('reset-password')) { + $user->temporaryTokenBuilder() + ->setType('reset-password') + ->setUsageLimit(1) + ->setExpireDate(Carbon::now()->addMinutes(5)) + ->build(6); +} +``` + +the `TokenBuilder` answers the same questions, and without a relation it looks at the tokens of every model: + +```php +use Shetabit\TokenBuilder\Facade\TokenBuilder; + +// the valid tokens of one user, of one type +$tokenObjects = TokenBuilder::setRelatedItem($user)->setType('reset-password')->findValidTokens(); + +// is there any valid reset pin at all? +$exists = TokenBuilder::setType('reset-password')->hasAnyValidToken(); +``` + +**notice:** `findValidTokens` and `hasAnyValidToken` ask whether there is *any* valid token, so they ignore +`setUniqueId`. use `findValidToken` or `isValid` to validate a token you have. + #### TokenBuilder reference This is a reference for TokenBuilder methods. @@ -359,6 +423,38 @@ This is a reference for TokenBuilder methods. find token if it is valid and return `null` if not exists. +- ###### findValidTokens + + retrieve every valid token of the related item (and of the type, when one is set), latest one first, as an eloquent + collection. without a related item it looks at the tokens of every model. the unique id plays no part in it. + +- ###### hasAnyValidToken + + determine if there is any valid token of the related item (and of the type, when one is set), and returns a boolean + result. the unique id plays no part in it either. + +#### Model Reference + +these methods are added to your own models by the `Shetabit\TokenBuilder\Traits\HasTemporaryTokens` trait. + +- ###### temporaryTokens + + the `morphMany` relation with every token of the model, valid or not. + +- ###### temporaryTokenBuilder + + a `TokenBuilder` with the model set as the related item. + +- ###### validTemporaryTokens + + retrieve the valid tokens of the model, latest one first, as an eloquent collection. + a type can be given (`validTemporaryTokens('reset-password')`) to only look at the tokens of that type. + +- ###### hasValidTemporaryToken + + determine if the model still has a valid token, and returns a boolean result. + a type can be given here as well. + #### Token Reference - ###### use @@ -398,8 +494,8 @@ PHP 8.4 and 8.5, against Laravel 12 and 13 and against both the lowest and the h coding style is checked with PHP_CodeSniffer, the sources are analysed with PHPStan (level 7, with larastan) and the code coverage of the test suite is measured and has to stay above 95%. -The suite has two parts: `tests/Unit` covers the builder, the token, the repository and the provider on their own, and -`tests/Feature` runs the flows of this readme end to end against a database. +The suite has two parts: `tests/Unit` covers the builder, the token, the repository, the token trait and the provider +on their own, and `tests/Feature` runs the flows of this readme end to end against a database. You can run the same checks locally. With PHP and Composer installed on your machine: diff --git a/src/Facade/TokenBuilder.php b/src/Facade/TokenBuilder.php index ca59527..ec23ac9 100755 --- a/src/Facade/TokenBuilder.php +++ b/src/Facade/TokenBuilder.php @@ -26,6 +26,8 @@ * @method static Token create(int $length = 8) * @method static Token|null findToken() * @method static Token|null findValidToken() + * @method static \Illuminate\Database\Eloquent\Collection findValidTokens() + * @method static bool hasAnyValidToken() * @method static bool isValid() * @method static bool isInvalid() * @method static bool isNotValid() diff --git a/src/Repositories/TokensRepository.php b/src/Repositories/TokensRepository.php index 3748ee0..0c56a0a 100755 --- a/src/Repositories/TokensRepository.php +++ b/src/Repositories/TokensRepository.php @@ -3,6 +3,7 @@ namespace Shetabit\TokenBuilder\Repositories; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use InvalidArgumentException; @@ -28,7 +29,7 @@ public function model() : string */ public function findToken(mixed $token, string|null $type = null, Model|null $tokenable = null) : Token|null { - return $this->query($tokenable, $token, $type)->first(); + return $this->query($tokenable, $type)->where('token', '=', $token)->first(); } /** @@ -36,15 +37,36 @@ public function findToken(mixed $token, string|null $type = null, Model|null $to */ public function findValidToken(mixed $token, string|null $type = null, Model|null $tokenable = null) : Token|null { - return $this->query($tokenable, $token, $type)->valid()->first(); + return $this->query($tokenable, $type)->where('token', '=', $token)->valid()->first(); } /** - * The query that looks a token up, of the given model or of all of them. + * Retrieve every valid token, of the given model or of all of them. + * + * @return Collection + */ + public function findValidTokens(string|null $type = null, Model|null $tokenable = null) : Collection + { + /** @var Collection $tokens */ + $tokens = $this->query($tokenable, $type)->valid()->latest()->get(); + + return $tokens; + } + + /** + * Determine if there is any valid token, of the given model or of all of them. + */ + public function hasAnyValidToken(string|null $type = null, Model|null $tokenable = null) : bool + { + return $this->query($tokenable, $type)->valid()->exists(); + } + + /** + * The query that looks tokens up, of the given model or of all of them. * * @return Builder|MorphMany */ - private function query(Model|null $tokenable, mixed $token, string|null $type) : Builder|MorphMany + private function query(Model|null $tokenable, string|null $type) : Builder|MorphMany { if ($tokenable !== null && !method_exists($tokenable, 'temporaryTokens')) { throw new InvalidArgumentException( @@ -55,7 +77,6 @@ private function query(Model|null $tokenable, mixed $token, string|null $type) : $query = $tokenable === null ? $this->model->newQuery() : $tokenable->temporaryTokens(); return $query - ->where('token', '=', $token) ->when($type !== null && $type !== '', fn (Builder $query) => $query->where('type', '=', $type)) ->with('tokenable'); } diff --git a/src/Traits/Concerns/Validation.php b/src/Traits/Concerns/Validation.php index 5e2e7fc..4f65a1f 100644 --- a/src/Traits/Concerns/Validation.php +++ b/src/Traits/Concerns/Validation.php @@ -2,6 +2,7 @@ namespace Shetabit\TokenBuilder\Traits\Concerns; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Shetabit\TokenBuilder\Models\Token; use Shetabit\TokenBuilder\Repositories\TokensRepository; @@ -42,6 +43,30 @@ public function findValidToken() : Token|null return $this->repository()->findValidToken($token, $this->getType(), $this->getRelatedItem()); } + /** + * Retrieve every valid token of the related model, latest one first. + * + * The unique id is not part of this lookup: it answers which tokens the + * related model (and the type, when one is set) still has, which is what + * "may I hand out a new one?" asks, before any token is known. + * + * @return Collection + */ + public function findValidTokens() : Collection + { + return $this->repository()->findValidTokens($this->getType(), $this->getRelatedItem()); + } + + /** + * Determine if the related model has any valid token. + * + * As with findValidTokens(), the unique id is not part of this lookup. + */ + public function hasAnyValidToken() : bool + { + return $this->repository()->hasAnyValidToken($this->getType(), $this->getRelatedItem()); + } + /** * Determine if token is valid */ diff --git a/src/Traits/HasTemporaryTokens.php b/src/Traits/HasTemporaryTokens.php index 1820b58..dfcb532 100755 --- a/src/Traits/HasTemporaryTokens.php +++ b/src/Traits/HasTemporaryTokens.php @@ -2,6 +2,7 @@ namespace Shetabit\TokenBuilder\Traits; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Shetabit\TokenBuilder\Builder; @@ -24,6 +25,26 @@ public function temporaryTokens() : MorphMany return $this->morphMany(Token::class, 'tokenable'); } + /** + * Get the tokens that have neither expired nor been used up, latest one + * first, of the given type when one is given. + * + * @return Collection + */ + public function validTemporaryTokens(string|null $type = null) : Collection + { + return $this->temporaryTokenBuilderOfType($type)->findValidTokens(); + } + + /** + * Determine if there still is a valid token, of the given type when one is + * given. + */ + public function hasValidTemporaryToken(string|null $type = null) : bool + { + return $this->temporaryTokenBuilderOfType($type)->hasAnyValidToken(); + } + /** * Token builder factory method. */ @@ -31,4 +52,14 @@ public function temporaryTokenBuilder() : Builder { return new Builder()->setRelatedItem($this); } + + /** + * A builder for this model, scoped to a type when one is given. + */ + private function temporaryTokenBuilderOfType(string|null $type) : Builder + { + $builder = $this->temporaryTokenBuilder(); + + return $type === null || $type === '' ? $builder : $builder->setType($type); + } } diff --git a/tests/Feature/BuildingTokensTest.php b/tests/Feature/BuildingTokensTest.php index e9d6291..cfda1ba 100644 --- a/tests/Feature/BuildingTokensTest.php +++ b/tests/Feature/BuildingTokensTest.php @@ -57,6 +57,33 @@ public function testATokenOfAUserIsBuiltAndFoundThroughThatUser() : void $this->assertTrue($token->is($found)); } + public function testAUserIsOnlySentANewPinWhenTheOneTheyHaveIsNoLongerValid() : void + { + $user = $this->createUser(); + + // "Forgot password": no pin was sent yet, so one is built. + $this->assertFalse($user->hasValidTemporaryToken('reset-password')); + + $pin = $user->temporaryTokenBuilder() + ->setType('reset-password') + ->setUsageLimit(1) + ->setExpireDate(now()->addMinutes(5)) + ->build(6); + + // The user asks again while the pin they were sent is still valid. + $this->assertTrue($user->hasValidTemporaryToken('reset-password')); + $this->assertSame([$pin->getKey()], $user->validTemporaryTokens('reset-password')->modelKeys()); + + // A pin of another type, or of another user, is none of this flow's business. + $this->assertFalse($user->hasValidTemporaryToken('sms-verification')); + $this->assertFalse($this->createUser('Someone else')->hasValidTemporaryToken('reset-password')); + + // The pin is used to reset the password, and a new one may be sent again. + $user->temporaryTokenBuilder()->setType('reset-password')->setUniqueId($pin->token)->findValidToken()?->use(); + + $this->assertFalse($user->hasValidTemporaryToken('reset-password')); + } + public function testTheDataOfATokenSurvivesTheRoundTrip() : void { $data = ['mobile' => '9373620353', 'name' => 'John Doe']; diff --git a/tests/Unit/HasTemporaryTokensTest.php b/tests/Unit/HasTemporaryTokensTest.php new file mode 100644 index 0000000..62d5aa2 --- /dev/null +++ b/tests/Unit/HasTemporaryTokensTest.php @@ -0,0 +1,84 @@ +createUser(); + + $token = $user->temporaryTokenBuilder()->build(); + + $this->assertTrue($user->is($token->tokenable)); + } + + public function testAModelWithoutTokensHasNoValidOnes() : void + { + $user = $this->createUser(); + + $this->assertFalse($user->hasValidTemporaryToken()); + $this->assertFalse($user->hasValidTemporaryToken('reset-password')); + $this->assertCount(0, $user->validTemporaryTokens()); + } + + public function testItAnswersWithTheValidTokensOfTheModel() : void + { + $user = $this->createUser(); + $other = $this->createUser('Someone else'); + + $reset = $user->temporaryTokenBuilder()->setType('reset-password')->setUsageLimit(1)->build(); + $login = $user->temporaryTokenBuilder()->setType('login')->build(); + $other->temporaryTokenBuilder()->setType('reset-password')->build(); + + $this->assertTrue($user->hasValidTemporaryToken()); + $this->assertTrue($user->hasValidTemporaryToken('reset-password')); + $this->assertCount(2, $user->validTemporaryTokens()); + $this->assertSame([$reset->getKey()], $user->validTemporaryTokens('reset-password')->modelKeys()); + $this->assertSame([$login->getKey()], $user->validTemporaryTokens('login')->modelKeys()); + } + + public function testAUsedUpTokenIsNoValidTokenOfTheModel() : void + { + $user = $this->createUser(); + + $token = $user->temporaryTokenBuilder()->setType('reset-password')->setUsageLimit(1)->build(); + + $this->assertTrue($user->hasValidTemporaryToken('reset-password')); + + $token->use(); + + $this->assertFalse($user->hasValidTemporaryToken('reset-password')); + $this->assertFalse($user->hasValidTemporaryToken()); + $this->assertCount(0, $user->validTemporaryTokens('reset-password')); + + // It is still there, it just can not be used any more. + $this->assertSame(1, $user->temporaryTokens()->count()); + } + + public function testAnExpiredTokenIsNoValidTokenOfTheModel() : void + { + $user = $this->createUser(); + + $user->temporaryTokenBuilder()->setType('reset-password')->setExpireDate(now()->addMinutes(5))->build(); + + $this->assertTrue($user->hasValidTemporaryToken('reset-password')); + + $this->travelTo(now()->addMinutes(6)); + + $this->assertFalse($user->hasValidTemporaryToken('reset-password')); + $this->assertCount(0, $user->validTemporaryTokens()); + } + + public function testAnEmptyTypeLooksAtEveryTokenOfTheModel() : void + { + $user = $this->createUser(); + + $user->temporaryTokenBuilder()->setType('reset-password')->build(); + + $this->assertTrue($user->hasValidTemporaryToken('')); + $this->assertCount(1, $user->validTemporaryTokens('')); + } +} diff --git a/tests/Unit/TokensRepositoryTest.php b/tests/Unit/TokensRepositoryTest.php index be662e8..056d295 100644 --- a/tests/Unit/TokensRepositoryTest.php +++ b/tests/Unit/TokensRepositoryTest.php @@ -94,6 +94,61 @@ public function testItOnlyFindsAValidTokenWithFindValidToken() : void $this->assertNotNull($repository->findToken($token->token)); } + public function testItFindsEveryValidTokenOfAModel() : void + { + $user = $this->createUser(); + $other = $this->createUser('Someone else'); + + $login = new Builder()->setRelatedItem($user)->setType('login')->setUsageLimit(1)->build(); + $signup = new Builder()->setRelatedItem($user)->setType('signup')->build(); + new Builder()->setRelatedItem($other)->setType('login')->build(); + + $repository = new TokensRepository(); + + $this->assertSame(3, $repository->findValidTokens()->count()); + $this->assertSame(2, $repository->findValidTokens(null, $user)->count()); + $this->assertSame([$login->getKey()], $repository->findValidTokens('login', $user)->modelKeys()); + $this->assertSame([$signup->getKey()], $repository->findValidTokens('signup', $user)->modelKeys()); + + $login->use(); + + $this->assertSame([], $repository->findValidTokens('login', $user)->modelKeys()); + $this->assertSame([$signup->getKey()], $repository->findValidTokens(null, $user)->modelKeys()); + } + + public function testItAnswersWhetherThereIsAnyValidTokenLeft() : void + { + $user = $this->createUser(); + $token = new Builder()->setRelatedItem($user)->setType('login')->setUsageLimit(1)->build(); + + $repository = new TokensRepository(); + + $this->assertTrue($repository->hasAnyValidToken()); + $this->assertTrue($repository->hasAnyValidToken(null, $user)); + $this->assertTrue($repository->hasAnyValidToken('login', $user)); + $this->assertFalse($repository->hasAnyValidToken('signup', $user)); + $this->assertFalse($repository->hasAnyValidToken(null, $this->createUser('Someone else'))); + + $token->use(); + + $this->assertFalse($repository->hasAnyValidToken('login', $user)); + } + + public function testAnExpiredTokenIsNoValidTokenOfAModelEither() : void + { + $user = $this->createUser(); + new Builder()->setRelatedItem($user)->setExpireDate(now()->addMinutes(5))->build(); + + $repository = new TokensRepository(); + + $this->assertTrue($repository->hasAnyValidToken(null, $user)); + + $this->travelTo(now()->addMinutes(6)); + + $this->assertFalse($repository->hasAnyValidToken(null, $user)); + $this->assertSame(0, $repository->findValidTokens(null, $user)->count()); + } + public function testItRefusesAModelThatCanNotCarryTokens() : void { $post = Post::query()->create(['title' => 'A post']); diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php index 6582d5a..0c97e76 100644 --- a/tests/Unit/ValidationTest.php +++ b/tests/Unit/ValidationTest.php @@ -60,4 +60,55 @@ public function testATokenThatDoesNotExistIsNotValid() : void $this->assertFalse(new Builder()->setUniqueId('a-token-that-was-never-built')->isValid()); $this->assertFalse(new Builder()->isValid()); } + + public function testItTellsWhetherAModelStillHasAValidToken() : void + { + $user = $this->createUser(); + + $builder = new Builder()->setRelatedItem($user)->setType('reset-password'); + + $this->assertFalse($builder->hasAnyValidToken()); + $this->assertCount(0, $builder->findValidTokens()); + + $token = $builder->setUsageLimit(1)->build(); + + $this->assertTrue($builder->hasAnyValidToken()); + $this->assertSame([$token->getKey()], $builder->findValidTokens()->modelKeys()); + + $token->use(); + + $this->assertFalse($builder->hasAnyValidToken()); + $this->assertCount(0, $builder->findValidTokens()); + } + + public function testTheValidTokensOfAModelAreItsOwnOnesOfItsOwnType() : void + { + $user = $this->createUser(); + $other = $this->createUser('Someone else'); + + $token = new Builder()->setRelatedItem($user)->setType('reset-password')->build(); + new Builder()->setRelatedItem($other)->setType('reset-password')->build(); + + $this->assertSame( + [$token->getKey()], + new Builder()->setRelatedItem($user)->setType('reset-password')->findValidTokens()->modelKeys() + ); + $this->assertFalse(new Builder()->setRelatedItem($user)->setType('login')->hasAnyValidToken()); + + // Without a related model, the tokens of everyone are looked at. + $this->assertCount(2, new Builder()->setType('reset-password')->findValidTokens()); + $this->assertTrue(new Builder()->hasAnyValidToken()); + } + + public function testTheUniqueIdIsNoPartOfTheLookupOfEveryValidToken() : void + { + $user = $this->createUser(); + new Builder()->setRelatedItem($user)->build(); + + $builder = new Builder()->setRelatedItem($user)->setUniqueId('a-token-that-was-never-built'); + + $this->assertNull($builder->findValidToken()); + $this->assertTrue($builder->hasAnyValidToken()); + $this->assertCount(1, $builder->findValidTokens()); + } }