Added generic token ownership classes. Could be used to replace password_resets and verifications models.
This commit is contained in:
492
src/Repository/BasicTokenRepository.php
Normal file
492
src/Repository/BasicTokenRepository.php
Normal file
@@ -0,0 +1,492 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* AVSDev UF Tweaks (https://avsdev.uk)
|
||||
*
|
||||
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
|
||||
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
|
||||
*/
|
||||
|
||||
namespace UserFrosting\Sprinkle\UFTweaks\Repository;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use UserFrosting\Sprinkle\Core\Database\Models\Model;
|
||||
use UserFrosting\Sprinkle\Core\Util\ClassMapper;
|
||||
use UserFrosting\Sprinkle\UFTweaks\Repository\Interfaces\TokenOwnerInterface;
|
||||
|
||||
/**
|
||||
* An abstract class for interacting with a repository of time-sensitive tokens which can be owned by any object.
|
||||
*
|
||||
* Tokens are used, for example, to perform password resets and new account email verifications.
|
||||
*
|
||||
* @author Craig Williams (https://avsdev.uk)
|
||||
*/
|
||||
abstract class BasicTokenRepository
|
||||
{
|
||||
/**
|
||||
* @var ClassMapper
|
||||
*/
|
||||
protected $classMapper;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $algorithm;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $modelIdentifier;
|
||||
|
||||
/**
|
||||
* @var TokenLogger
|
||||
*/
|
||||
protected $tokenLogger;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new TokenRepository object.
|
||||
*
|
||||
* @param ClassMapper $classMapper Maps generic class identifiers to specific class names.
|
||||
* @param string $algorithm The hashing algorithm to use when storing generated tokens.
|
||||
*/
|
||||
public function __construct(ClassMapper $classMapper, $algorithm = 'sha512', $tokenLogger = null, $debug = false)
|
||||
{
|
||||
$this->classMapper = $classMapper;
|
||||
$this->algorithm = $algorithm;
|
||||
$this->tokenLogger = ($debug ? $tokenLogger : null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new token.
|
||||
*
|
||||
* @param TokenOwnerInterface $tokenOwner The object to associate with this token, be it an organisation, user, group etc.
|
||||
* @param int $timeout The time, in seconds, after which this token should expire.
|
||||
*
|
||||
* @return Model The model (PasswordReset, Verification, etc) object that stores the token.
|
||||
*/
|
||||
public function create(TokenOwnerInterface $tokenOwner, $timeout)
|
||||
{
|
||||
// Remove any previous tokens for this tokenOwner
|
||||
$this->removeExisting($tokenOwner);
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Creating new token for {{owner}} which expires in {{timeout}} seconds', [
|
||||
'tokenOwner' => $tokenOwner,
|
||||
'timeout' => $timeout
|
||||
]);
|
||||
}
|
||||
|
||||
// Compute expiration time
|
||||
$expiresAt = Carbon::now()->addSeconds($timeout);
|
||||
|
||||
$model = $this->classMapper->createInstance($this->modelIdentifier);
|
||||
|
||||
// Generate a random token
|
||||
$model->setToken($this->generateRandomToken());
|
||||
|
||||
// Hash the password reset token for the stored version
|
||||
$hash = hash($this->algorithm, $model->getToken());
|
||||
|
||||
$model->fill([
|
||||
'owner_id' => $tokenOwner->getId(),
|
||||
'hash' => $hash,
|
||||
'completed' => false,
|
||||
'expires_at' => ($timeout >= 0 ? $expiresAt : null),
|
||||
]);
|
||||
|
||||
$model->save();
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Completed new token', ['model' => $model]);
|
||||
}
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a specified token by removing it from the database.
|
||||
*
|
||||
* @param int $token The token to remove.
|
||||
*
|
||||
* @return Model|false
|
||||
*/
|
||||
public function cancel($token)
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Cancelling token {{token}}', [
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
// Hash the password reset token for the stored version
|
||||
$hash = hash($this->algorithm, $token);
|
||||
|
||||
// Find an incomplete reset request for the specified hash
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('hash', $hash)
|
||||
->where('completed', false)
|
||||
->first();
|
||||
|
||||
if ($model === null) {
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->warn('Token not found!');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Deleting matched model', [
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a token-based process, invoking updateTokenOwner() in the child object to do the actual action.
|
||||
*
|
||||
* @param int $token The token to complete.
|
||||
* @param mixed[] $params An optional list of parameters to pass to updateUser().
|
||||
*
|
||||
* @return Model|false
|
||||
*/
|
||||
public function complete($token, $params = [])
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Completing token for {{token}}', [
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
// Hash the token for the stored version
|
||||
$hash = hash($this->algorithm, $token);
|
||||
|
||||
// Find an unexpired, incomplete token for the specified hash
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('hash', $hash)
|
||||
->where('completed', false)
|
||||
->where(function($query) {
|
||||
return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at');
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($model === null) {
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->warn('Token not found!');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Found:', [
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
$ret = $this->updateTokenOwner($model->owner_id, $model, $params);
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Return of updateTokenOwner: {{ret}}', [
|
||||
'ret' => $ret
|
||||
]);
|
||||
}
|
||||
|
||||
$model->fill([
|
||||
'completed' => true,
|
||||
'completed_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$model->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a token-based process using the owner instead of the token,
|
||||
* invoking updateTokenOwner() in the child object to do the actual action.
|
||||
*
|
||||
* @param int $token The token to complete.
|
||||
* @param mixed[] $params An optional list of parameters to pass to updateUser().
|
||||
*
|
||||
* @return Model|false
|
||||
*/
|
||||
public function completeForOwner(TokenOwnerInterface $tokenOwner, $params = [])
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Completing token for {{owner}}', [
|
||||
'owner' => $tokenOwner
|
||||
]);
|
||||
}
|
||||
|
||||
// Find an unexpired, incomplete tokens for the specified owner.
|
||||
// Using first() works because owners can only have at most 1 active token
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('owner_id', $tokenOwner->getId())
|
||||
->where('completed', false)
|
||||
->where(function($query) {
|
||||
return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at');
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($model === null) {
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->warn('Token not found!');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Found:', [
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
$ret = $this->updateTokenOwner($model->owner_id, $model, $params);
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Return of updateTokenOwner: {{ret}}', [
|
||||
'ret' => $ret
|
||||
]);
|
||||
}
|
||||
|
||||
$model->fill([
|
||||
'completed' => true,
|
||||
'completed_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$model->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts a completed token request to its previously incomplete state.
|
||||
*
|
||||
* @param TokenOwnerInterface $tokenOwner The owner of the token to revert.
|
||||
*
|
||||
* @return Model|false
|
||||
*/
|
||||
public function revert(TokenOwnerInterface $tokenOwner)
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Reverting token for {{owner}}', [
|
||||
'owner' => $tokenOwner
|
||||
]);
|
||||
}
|
||||
|
||||
// Find an unexpired, incomplete tokens for the specified owner.
|
||||
// Using first() works because owners can only have at most 1 active token
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('owner_id', $tokenOwner->getId())
|
||||
->where('completed', true)
|
||||
->where(function($query) {
|
||||
return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at');
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($model === null) {
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->warn('Token not found!');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Found:', [
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
$model->fill([
|
||||
'completed' => false,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$model->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a specified token owner has an incomplete and unexpired token.
|
||||
*
|
||||
* @param TokenOwnerInterface $tokenOwner The token owner object to look up.
|
||||
* @param int $token Optionally, try to match a specific token.
|
||||
*
|
||||
* @return Model|false
|
||||
*/
|
||||
public function exists(TokenOwnerInterface $tokenOwner, $token = null)
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Searching for token for {{owner}} (with optional {{token}})', [
|
||||
'owner' => $tokenOwner,
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('owner_id', $tokenOwner->id)
|
||||
->where('completed', false)
|
||||
->where(function($query) {
|
||||
return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at');
|
||||
});
|
||||
|
||||
if ($token) {
|
||||
// get token hash
|
||||
$hash = hash($this->algorithm, $token);
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Token hash: {{hash}}', [
|
||||
'hash' => $hash
|
||||
]);
|
||||
}
|
||||
$model->where('hash', $hash);
|
||||
}
|
||||
|
||||
$result = $model->first() ?: false;
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Found:', [
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an unexpired and un-completed token for the specified owner.
|
||||
*
|
||||
* @param TokenOwnerInterface $tokenOwner The token owner object to look up.
|
||||
* @param int $token Optionally, try to match a specific token.
|
||||
*
|
||||
* @return owner_id|null
|
||||
*/
|
||||
public function findOwner($token)
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Searching token for owner of {{token}}', [
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
// get token hash
|
||||
$hash = hash($this->algorithm, $token);
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Token hash: {{hash}}', [
|
||||
'hash' => $hash
|
||||
]);
|
||||
}
|
||||
|
||||
$model = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('hash', $hash)
|
||||
->where('completed', false)
|
||||
->where(function($query) {
|
||||
return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at');
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Found:', [
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
return $model ? $model->owner_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all existing tokens from the database for a particular owner.
|
||||
*
|
||||
* @param TokenOwnerInterface $tokenOwner
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function removeExisting(TokenOwnerInterface $tokenOwner)
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Removing all tokens for {{owner}}', [
|
||||
'owner' => $tokenOwner
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('owner_id', $tokenOwner->getId())
|
||||
->delete();
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Deletion result: {{result}}', [
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired tokens from the database.
|
||||
*
|
||||
* @return bool|null
|
||||
*/
|
||||
public function removeExpired()
|
||||
{
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Removing expired tokens...');
|
||||
}
|
||||
|
||||
$result = $this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('completed', false)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', Carbon::now())
|
||||
->delete();
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Deletion result: {{result}}', [
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new random token.
|
||||
*
|
||||
* This generates a token to use for verifying anything you like, with a unique reference.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function generateRandomToken()
|
||||
{
|
||||
do {
|
||||
$gen = md5(uniqid(mt_rand(), false));
|
||||
} while ($this->classMapper->getClassMapping($this->modelIdentifier)::query()
|
||||
->where('hash', hash($this->algorithm, $gen))
|
||||
->first());
|
||||
|
||||
if ($this->tokenLogger) {
|
||||
$this->tokenLogger->debug('Generated token {{token}}', [
|
||||
'token' => $gen
|
||||
]);
|
||||
}
|
||||
|
||||
return $gen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the owner of the token during the token completion process.
|
||||
*
|
||||
* This method is called during complete(), and is a way for concrete implementations to modify the owner.
|
||||
*
|
||||
* @param integer $owner_id The id of the token owner
|
||||
* @param mixed[] $args
|
||||
*
|
||||
* @return mixed[] $args the list of parameters that were supplied to the call to `complete()`
|
||||
*/
|
||||
abstract protected function updateTokenOwner($owner_id, $model, $args);
|
||||
}
|
||||
29
src/Repository/Interfaces/TokenOwnerInterface.php
Normal file
29
src/Repository/Interfaces/TokenOwnerInterface.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* AVSDev UF Tweaks (https://avsdev.uk)
|
||||
*
|
||||
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
|
||||
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
|
||||
*/
|
||||
|
||||
namespace UserFrosting\Sprinkle\UFTweaks\Repository\Interfaces;
|
||||
|
||||
/**
|
||||
* Token Owner Interface.
|
||||
*
|
||||
* Represents a Token Owner object. The only requirement is that it must
|
||||
* provide a unique id per object. This allows a soft-map id or hashed id to
|
||||
* be used.
|
||||
*/
|
||||
interface TokenOwnerInterface
|
||||
{
|
||||
/**
|
||||
* Returns a unique ID for this Token Owner
|
||||
*
|
||||
* This MUST match the models owner_id type.
|
||||
*
|
||||
* @return integer|string
|
||||
*/
|
||||
public function getId();
|
||||
}
|
||||
Reference in New Issue
Block a user