Created an implementation of a token repository that isn't restricted to users and can be re-used for other objects.

This commit is contained in:
2022-02-15 12:53:31 +00:00
parent 512e13f57e
commit 091febf255
4 changed files with 498 additions and 0 deletions

View File

@@ -15,6 +15,9 @@
* SMTP server password: SMTP_PASSWORD * SMTP server password: SMTP_PASSWORD
*/ */
return [ return [
'debug' => [
'tokens' => true,
],
'organisation' => [ 'organisation' => [
'registration' => [ 'registration' => [
'require_approval' => true, 'require_approval' => true,

View File

@@ -0,0 +1,444 @@
<?php
/*
* AVSDev UF Organisations (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-organisations
* @license https://git.avsdev.uk/avsdev/sprinkle-organisations/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\Organisations\Repository;
use Carbon\Carbon;
use UserFrosting\Sprinkle\Organisations\Repository\Interfaces\TokenOwnerInterface;
use UserFrosting\Sprinkle\Core\Database\Models\Model;
use UserFrosting\Sprinkle\Core\Util\ClassMapper;
/**
* 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;
}
/**
* 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
*/
protected function removeExisting(TokenOwnerInterface $tokenOwner)
{
if ($this->tokenLogger) {
$this->tokenLogger->debug('Completing token 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,25 @@
<?php
/*
* AVSDev UF Organisations (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-organisations
* @license https://git.avsdev.uk/avsdev/sprinkle-organisations/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\Organisations\Repository\Interfaces;
/**
* Token Owner Interface.
*
* Represents a Token Owner object. The only requirement is that it must provide a unique id per object.
*/
interface TokenOwnerInterface
{
/**
* Returns a unique ID for this Token Owner
*
* @return integer|string
*/
public function getId();
}

View File

@@ -10,9 +10,13 @@
namespace UserFrosting\Sprinkle\Organisations\ServicesProvider; namespace UserFrosting\Sprinkle\Organisations\ServicesProvider;
use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Capsule\Manager as Capsule;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use UserFrosting\Sprinkle\Core\Log\MixedFormatter;
use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface; use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface;
use UserFrosting\Sprinkle\Organisations\Twig\OrganisationsExtension; use UserFrosting\Sprinkle\Organisations\Twig\OrganisationsExtension;
use UserFrosting\Sprinkle\Organisations\Repository\OrganisationApprovalRepository; use UserFrosting\Sprinkle\Organisations\Repository\OrganisationApprovalRepository;
@@ -104,6 +108,28 @@ class ServicesProvider
return $view; return $view;
}); });
/*
* Token logging with Monolog.
*
* Extend this service to push additional handlers onto the 'tokens' log stack.
*
* @return \Monolog\Logger
*/
$container['tokenLogger'] = function ($c) {
$logger = new Logger('tokens');
$logFile = $c->locator->findResource('log://userfrosting.log', true, true);
$handler = new StreamHandler($logFile);
$formatter = new MixedFormatter(null, null, true);
$handler->setFormatter($formatter);
$logger->pushHandler($handler);
return $logger;
};
/* /*
* Returns a callback that handles merging any organisation objects. * Returns a callback that handles merging any organisation objects.
* *