Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%.
Expand Down
100 changes: 98 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions src/Facade/TokenBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Token> findValidTokens()
* @method static bool hasAnyValidToken()
* @method static bool isValid()
* @method static bool isInvalid()
* @method static bool isNotValid()
Expand Down
31 changes: 26 additions & 5 deletions src/Repositories/TokensRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,23 +29,44 @@ 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();
}

/**
* Retrieve a token if it is valid.
*/
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<int, Token>
*/
public function findValidTokens(string|null $type = null, Model|null $tokenable = null) : Collection
{
/** @var Collection<int, Token> $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<Token>|MorphMany<Token, Model>
*/
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(
Expand All @@ -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');
}
Expand Down
25 changes: 25 additions & 0 deletions src/Traits/Concerns/Validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, Token>
*/
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
*/
Expand Down
31 changes: 31 additions & 0 deletions src/Traits/HasTemporaryTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,11 +25,41 @@ 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<int, Token>
*/
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.
*/
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);
}
}
27 changes: 27 additions & 0 deletions tests/Feature/BuildingTokensTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
Loading
Loading