Added generic token ownership classes. Could be used to replace password_resets and verifications models.

This commit is contained in:
2023-06-06 14:47:55 +01:00
parent eff5b545a9
commit 53d5e1b0e9
2 changed files with 521 additions and 0 deletions

View 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);
}

View 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();
}