diff --git a/src/Repository/BasicTokenRepository.php b/src/Repository/BasicTokenRepository.php new file mode 100644 index 0000000..4e41e8d --- /dev/null +++ b/src/Repository/BasicTokenRepository.php @@ -0,0 +1,492 @@ +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); +} diff --git a/src/Repository/Interfaces/TokenOwnerInterface.php b/src/Repository/Interfaces/TokenOwnerInterface.php new file mode 100644 index 0000000..1347579 --- /dev/null +++ b/src/Repository/Interfaces/TokenOwnerInterface.php @@ -0,0 +1,29 @@ +