<?php

namespace Pvm\ArtisanBeans\Console;

class ExportCommand extends BaseCommand
{
    protected $commandName = 'export';

    protected $commandArguments = '
        {path : Path to export jobs}
        {state=ready : State [ready|delayed|buried]}
        {tube? : Tube name}
        {count=1 : Number of jobs to export}
    ';

    protected $description = 'Export jobs';

    protected $path;

    protected $state;

    protected $count = 1;

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $tube = $this->argument('tube') ?: $this->defaultTube;

        $exported = 0;
        while ($job = $this->peekJob($tube, $this->state)) {
            if ($this->count > 0 && $exported >= $this->count) {
                break;
            }

            $this->exportJob($job);

            $this->deleteJob($job);

            $exported++;
        }

        if (0 == $exported) {
            $this->renderJobNotFoundMessage($tube, $this->state);
        } else {
            $this->comment("Exported $exported jobs from '$tube' tube in '$this->state' state.");
        }
    }

    /**
     * Exports job to a file.
     *
     * @param $job
     */
    protected function exportJob($job)
    {
        $stats = $this->getJobStats($job);

        $contents = $this->renderForExport($job, $stats);

        $filename = trim($this->path, '/').'/'.$this->buildJobFileName($job, $stats);

        if (file_exists($filename)) {
            throw new \RuntimeException('File already exists.');
        }

        if (! file_put_contents($filename, $contents)) {
            throw new \RuntimeException('Error saving the file.');
        }
    }

    private function buildJobFileName($job, $stats)
    {
        return (int) (microtime(true) * 1000).'_'.$stats['id'].'.json';
    }

    private function renderForExport($job, $stats)
    {
        ksort($stats);

        $jobData = [
            'stats' => $stats,
            'data'  => $job->getData(),
        ];

        return json_encode((object) [
            'meta' => [
                'version' => '1.0',
                'hash'    => md5(json_encode((object) $jobData)),
            ],
            'job' => $jobData,
        ], JSON_PRETTY_PRINT);
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        $this->path = $this->argument('path');
        if (! is_dir($this->path) || ! is_writable($this->path)) {
            throw new \InvalidArgumentException("Path '$this->path' doesn't exist or is not writable.");
        }

        $this->state = strtolower($this->argument('state'));
        if (! in_array($this->state, ['ready', 'buried', 'delayed'])) {
            throw new \InvalidArgumentException("Invalid state '$this->state'.");
        }

        if ($this->argument('count')) {
            if (false === ($this->count = filter_var($this->argument('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
                throw new \InvalidArgumentException('Count should be a positive integer.');
            }
        }
    }
}


<?php

namespace Pvm\ArtisanBeans;

use Illuminate\Support\ServiceProvider;

class ArtisanBeansServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     */
    public function boot()
    {
        //
    }

    /**
     * Register the application services.
     */
    public function register()
    {
        $commands = [
            'Bury',
            'Delete',
            'Kick',
            'PauseTube',
            'Peek',
            'Purge',
            'Put',
            'ServerStats',
            'TubeStats',
            'UnpauseTube',
            'Move',
            'Export',
            'Import',
        ];

        foreach ($commands as $command) {
            $this->registerCommand($command);
        }
    }

    /**
     * Register a console command.
     *
     * @param $command
     */
    protected function registerCommand($command)
    {
        $abstract = "command.artisan.beans.$command";
        $commandClass = "\\Pvm\\ArtisanBeans\\Console\\{$command}Command";

        $this->app->singleton($abstract, function ($app) use ($commandClass) {
            return new $commandClass();
        });

        $this->commands($abstract);
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

class ImportCommand extends BaseCommand
{
    protected $commandName = 'import';

    protected $commandArguments = '
        {path : Path to a job file. Patterns are supported (i.e. *.json)}
        {tube? : Tube name}
    ';

    protected $commandOptions = '
        {--delay=0 : Delay (in seconds)}
        {--ttr= : New TTR (in seconds)}
        {--priority= : New priority}
    ';

    protected $description = 'Import jobs';

    protected $path;

    protected $delay;

    protected $ttr;

    protected $priority;

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $imported = 0;

        $files = $this->getJobFiles($this->path);

        if (($totalFiles = count($files)) > 1) {
            if (! $this->confirmToProceed("You are about to import $totalFiles jobs.")) {
                return;
            }
        }

        foreach ($files as $filename) {
            try {
                $this->importJob($filename, $this->argument('tube'), $this->option('delay'), $this->option('ttr'), $this->option('priority'));
                $imported++;
            } catch (\RuntimeException $e) {
                $this->reportImported($imported);

                throw $e;
            }
        }

        $this->reportImported($imported);
    }

    private function reportImported($imported)
    {
        $this->comment("Imported $imported jobs.");
    }

    /**
     * @param $path
     *
     * @return array
     */
    private function getJobFiles($path)
    {
        $files = [];

        foreach (glob($path) as $filename) {
            if (is_dir($filename)) {
                continue;
            }

            $files[] = $filename;
        }

        return $files;
    }

    /**
     * @param $filename
     * @param $tube
     * @param int  $delay
     * @param null $ttr
     * @param null $priority
     */
    private function importJob($filename, $tube = null, $delay = 0, $ttr = null, $priority = null)
    {
        if (0 == filesize($filename)) {
            throw new \RuntimeException("File '$filename' is empty.");
        }

        $data = json_decode(file_get_contents($filename));

        if ((is_null($data) && JSON_ERROR_NONE !== json_last_error()) ||
            ! isset($data->meta->version) ||
            ! isset($data->meta->hash) ||
            ! isset($data->job->data) ||
            ! isset($data->job->stats) ||
            '1.0' !== $data->meta->version) {
            throw new \RuntimeException("File '$filename' is not a valid job file or has an unsupported format.");
        }

        $hash = md5(json_encode($data->job));
        if ($hash !== $data->meta->hash) {
            throw new \RuntimeException("The contents of the file '$filename' has changed.");
        }

        $tube = $tube ?: $data->job->stats->tube;
        $ttr = $ttr ?: $data->job->stats->ttr;
        $priority = $priority ?: $data->job->stats->pri;

        $this->putJob($tube, $data->job->data, $priority, $delay, $ttr);
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        $this->path = $this->argument('path');

        if ($this->option('delay')) {
            if (false === ($this->delay = filter_var($this->option('delay'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]))) {
                throw new \InvalidArgumentException('Delay should be a positive integer or 0.');
            }
        }

        if ($this->option('ttr')) {
            if (false === ($this->ttr = filter_var($this->option('ttr'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
                throw new \InvalidArgumentException('TTR should be a positive integer.');
            }
        }

        if ($this->option('priority')) {
            if (false === ($this->priority = filter_var($this->option('priority'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]))) {
                throw new \InvalidArgumentException('Priority should be a positive integer or 0.');
            }
        }
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

class BuryCommand extends BaseCommand
{
    protected $commandName = 'bury';

    protected $commandArguments = '
        {tube? : Tube name}
        {count=1 : Number of jobs to bury}
    ';

    protected $commandOptions = '
        {--priority= : New priority}
    ';

    protected $description = 'Bury a job';

    protected $count = 1;

    protected $priority;

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $tube = $this->argument('tube') ?: $this->defaultTube;

        if ($this->count > 1) {
            if (! $this->confirmToProceed("You are about to bury $this->count jobs in '$tube' tube.")) {
                return;
            }
        }

        $buried = 0;
        while ($job = $this->reserveJob($tube)) {
            if ($this->count > 0 && $buried >= $this->count) {
                break;
            }

            $this->buryJob($job, $this->priority);

            $buried++;
        }

        if (0 == $buried) {
            $this->renderJobNotFoundMessage($tube, 'ready');
        } else {
            $this->comment("Buried $buried jobs in '$tube' tube.");
        }
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        if ($this->argument('count')) {
            if (false === ($this->count = filter_var($this->argument('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
                throw new \InvalidArgumentException('Count should be a positive integer.');
            }
        }

        if (! is_null($this->option('priority'))) {
            if (false === ($this->priority = filter_var($this->option('priority'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]))) {
                throw new \InvalidArgumentException('Priority should be a positive integer or 0.');
            }
        }
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

use Pheanstalk\Response;
use Pheanstalk\Pheanstalk;
use Illuminate\Console\Command;
use Pheanstalk\Exception\ServerException;

abstract class BaseCommand extends Command
{
    protected $signature;

    protected $namespace = 'beans';

    protected $commonOptions = '
        {--c|connection= : Beanstalkd connection to use}
        {--H|host= : Beanstalkd Host}
        {--P|port= : Beanstalkd Port}
    ';

    protected $commandArguments = '';

    protected $commandOptions = '';

    protected $host = 'localhost';

    protected $port;

    protected $defaultTube;

    protected $pheanstalk;

    public function __construct()
    {
        $this->buildCommandSignature();

        parent::__construct();

        $this->port = Pheanstalk::DEFAULT_PORT;
        $this->defaultTube = Pheanstalk::DEFAULT_TUBE;
    }

    /**
     * Peek a job in a spesific state.
     *
     * @param $tube
     * @param $state
     *
     * @throws ServerException
     * @throws \Exception
     */
    protected function peekJob($tube, $state)
    {
        $peekMethod = 'peek'.ucfirst($state);

        try {
            return $this->getPheanstalk()->$peekMethod($tube);
        } catch (ServerException $e) {
            if ($this->isNotFoundException($e)) {
                return;
            }

            throw $e;
        }
    }

    /**
     * Reserve a job from the tube.
     *
     * @param $tube
     *
     * @throws ServerException
     * @throws \Exception
     *
     * @return bool|object|\Pheanstalk\Job|void
     */
    protected function reserveJob($tube)
    {
        try {
            return $this->getPheanstalk()->reserveFromTube($tube, 0);
        } catch (ServerException $e) {
            if ($this->isNotFoundException($e)) {
                return;
            }

            throw $e;
        }
    }

    /**
     * Returns the job's statistics.
     *
     * @param $job
     *
     * @throws ServerException
     * @throws \Exception
     *
     * @return array
     */
    protected function getJobStats($job)
    {
        try {
            return (array) $this->getPheanstalk()->statsJob($job);
        } catch (ServerException $e) {
            if ($this->isNotFoundException($e)) {
                return;
            }

            throw $e;
        }
    }

    /**
     * Delete the job.
     *
     * @param $job
     */
    protected function deleteJob($job)
    {
        $this->getPheanstalk()->delete($job);
    }

    /**
     * Bury the job.
     *
     * @param $job
     * @param int $priority New priority
     */
    protected function buryJob($job, $priority = null)
    {
        if (is_null($priority)) {
            $priority = Pheanstalk::DEFAULT_PRIORITY;
        }

        $this->getPheanstalk()->bury($job, $priority);
    }

    /**
     * Kick the job.
     *
     * @param $tube
     * @param int $count
     *
     * @return int
     */
    protected function kickJob($tube, $count = 1)
    {
        return $this->getPheanstalk()
            ->useTube($tube)
            ->kick($count);
    }

    /**
     * Puts a job in the queue.
     *
     * @param string $tube
     * @param string $body
     * @param int    $priority
     * @param int    $delay
     * @param int    $ttr
     *
     * @return int
     */
    protected function putJob($tube, $body, $priority, $delay, $ttr)
    {
        $id = $this->getPheanstalk()
            ->putInTube($tube, $body, $priority, $delay, $ttr);

        return $id;
    }

    /**
     * Returns a Pheanstalk instance.
     *
     * @return Pheanstalk
     */
    public function getPheanstalk()
    {
        if (! $this->pheanstalk) {
            $this->pheanstalk = new Pheanstalk($this->host, $this->port);
        }

        return $this->pheanstalk;
    }

    /**
     * Generic logic for reading ang validating command's arguments and options.
     */
    protected function parseArguments()
    {
        $this->parseConnection($this->option('connection'));

        if ($this->option('host')) {
            $this->host = $this->option('host');
        }

        if ($this->option('port')) {
            $this->port = (int) $this->option('port');
        }

        $this->parseCommandArguments();
    }

    /**
     * Command specific logic for reading ang validating arguments and options.
     */
    protected function parseCommandArguments()
    {
    }

    /**
     * Tries to figure out and set host, port and default tube
     * from the Laravel's queue.php config.
     *
     * @param $connectionName
     */
    protected function parseConnection($connectionName)
    {
        $connection = null;

        // If user provided the connection name read it directly
        if ($connectionName) {
            if (! $connection = config("queue.connections.$connectionName")) {
                throw new \InvalidArgumentException("Connection '$connectionName' doesn't exist.");
            }
        }

        // Try default connection
        if (! $connection) {
            $defaultConnection = config('queue.default');

            if ('beanstalkd' == config("queue.connections.$defaultConnection.driver")) {
                $connection = config("queue.connections.$defaultConnection");
            }
        }

        // Try first connection that has beanstalkd driver
        if (! $connection) {
            foreach (config('queue.connections') as $connection) {
                if ('beanstalkd' == $connection['driver']) {
                    break;
                }
            }
        }

        if (! empty($connection['host'])) {
            $parsedConfigHost = explode(':', $connection['host']);

            $this->host = $parsedConfigHost[0];

            if (isset($parsedConfigHost[1])) {
                $this->port = $parsedConfigHost[1];
            }
        }

        if (! empty($connection['queue'])) {
            $this->defaultTube = $connection['queue'];
        }
    }

    /**
     * Build a command's signature.
     *
     * @return string
     */
    protected function buildCommandSignature()
    {
        $this->signature = $this->namespace.':'.$this->commandName.' '.
            $this->commandArguments.
            $this->commandOptions.
            $this->commonOptions;
    }

    /**
     * Transforms an assoc array into a multidementional one
     * which is expected by helper table() method.
     *
     * @param $data
     *
     * @return array
     */
    protected function transformForTable($data)
    {
        $result = [];
        foreach ($data as $key => $value) {
            $result[] = [$key, $value];
        }

        return $result;
    }

    /**
     * Validates the file exists, is readable and optionaly is not empty
     * and returns an absolute path to the file.
     *
     * @param $filePath
     * @param string $message
     *
     * @return string
     */
    protected function validateFile($filePath, $message = 'File', $allowEmpty = true)
    {
        if (! file_exists($filePath) || ! is_readable($filePath)) {
            throw new \RuntimeException("$message '{$filePath}' doesn't exist or is not readable.");
        }

        if (! $allowEmpty && 0 === filesize($filePath)) {
            throw new \RuntimeException("$message '{$filePath}' is empty.");
        }

        return realpath($filePath);
    }

    /**
     * If a job or other object doesn't exist beanstalkd returns NOT_FOUND response
     * We use is to determine whether we need to display a normal message
     * or re-throw an exception.
     *
     * @param \Exception $e
     *
     * @return bool
     */
    protected function isNotFoundException(\Exception $e)
    {
        return false !== strpos($e->getMessage(), Response::RESPONSE_NOT_FOUND);
    }

    /**
     * Displays job information.
     *
     * @param $job
     */
    protected function renderJob($job)
    {
        $stats = $this->getJobStats($job);

        $format = '<info>id</info>: %u, <info>length</info>: %u, <info>priority</info>: %u, <info>delay</info>: %u, <info>age</info>: %u, <info>ttr</info>: %u';
        $line = sprintf($format, $job->getId(), strlen($job->getData()), $stats['pri'], $stats['delay'], $stats['age'], $stats['ttr']);
        $this->output->writeln($line);

        $format = '<comment>reserves</comment>: %u, <comment>releases</comment>: %u, <comment>buries</comment>: %u, <comment>kicks</comment>: %u, <comment>timeouts</comment>: %u';
        $line = sprintf($format, $stats['reserves'], $stats['releases'], $stats['buries'], $stats['kicks'], $stats['timeouts']);
        $this->output->writeln($line);

        $this->output->writeln('<comment>body:</comment>');

        $data = $job->getData();
        $this->output->writeln("\"$data\"");
    }

    /**
     * Displays message.
     *
     * @param $tube
     * @param $state
     */
    protected function renderJobNotFoundMessage($tube, $state = 'ready')
    {
        $message = "Tube '$tube'".('default' == $tube ? '' : " doesn't exist or")." has no jobs in '$state' state.";

        $this->comment($message);
    }

    /**
     * Returns the maximum allowed size for a job body.
     *
     * @return int
     */
    protected function getMaxJobSize()
    {
        $stats = $this->getServerStats('max-job-size');

        return isset($stats['max-job-size']) ? (int) $stats['max-job-size'] : 65535;
    }

    /**
     * @param null   $message
     * @param string $question
     *
     * @return bool
     */
    protected function confirmToProceed($message = null, $question = 'Are you sure you want to proceed?')
    {
        if ($message) {
            $this->comment($message);
        }

        return $this->confirm($question);
    }

    /**
     * Returns statistics for the tube.
     *
     * @param $tube
     *
     * @throws ServerException
     * @throws \Exception
     *
     * @return object|\Pheanstalk\Response
     */
    protected function getTubeStats($tube)
    {
        try {
            $stats = $this->getPheanstalk()->statsTube($tube);
        } catch (ServerException $e) {
            if ($this->isNotFoundException($e)) {
                throw new \RuntimeException("Tube '$tube' doesn't exist.");
            }

            throw $e;
        }

        return $stats;
    }

    /**
     * Lists all currently available tubes.
     *
     * @return array
     */
    protected function getTubes()
    {
        return (array) $this->getPheanstalk()->listTubes();
    }

    /**
     * Lists server statistics optionaly filtering keys by a pattern.
     *
     * @param string $pattern
     *
     * @return array
     */
    protected function getServerStats($pattern = '')
    {
        $stats = (array) $this->getPheanstalk()->stats();

        if (! empty($pattern)) {
            $stats = array_filter($stats, function ($key) use ($pattern) {
                return 1 === preg_match("/$pattern/i", $key);
            }, ARRAY_FILTER_USE_KEY);
        }

        ksort($stats);

        return $stats;
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

class MoveCommand extends BaseCommand
{
    protected $commandName = 'move';

    protected $commandArguments = '
        {from : Source tube name}
        {to : Destination tube name}
        {state=ready : State [ready|delayed|buried] in the source tube}
        {count=1 : Number of jobs to move}
    ';

    protected $commandOptions = '
        {--delay=0 : Delay (in seconds)}
        {--ttr= : New TTR (in seconds)}
        {--priority= : New priority}
    ';

    protected $description = 'Move jobs between tubes';

    protected $state;

    protected $count;

    protected $delay;

    protected $priority;

    protected $ttr;

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        if ($this->count > 1) {
            if (! $this->confirmToProceed("You are about to move $this->count jobs from '".$this->argument('from')."' to '".$this->argument('to')."'.")) {
                return;
            }
        }

        $moved = 0;
        while ($job = $this->getNextJob($this->argument('from'), $this->state)) {
            if ($this->count > 0 && $moved >= $this->count) {
                break;
            }
            // Read the job's stats in order to preserve priority and ttr
            $stats = $this->getJobStats($job);

            $this->putJob($this->argument('to'), $job->getData(), $this->priority ?: $stats['pri'], $this->delay, $this->ttr ?: $stats['ttr']);

            $this->deleteJob($job);

            $moved++;
        }

        if (0 == $moved) {
            $this->renderJobNotFoundMessage($this->argument('from'), $this->state);
        } else {
            $this->comment("Moved $moved jobs from '".$this->argument('from')."' to '".$this->argument('to')."'.");
        }
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        $this->state = strtolower($this->argument('state'));
        if (! in_array($this->state, ['ready', 'buried', 'delayed'])) {
            throw new \InvalidArgumentException("Invalid state '$this->state'.");
        }

        if (false === ($this->count = filter_var($this->argument('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
            throw new \InvalidArgumentException('Count should be a positive integer.');
        }

        if (false === ($this->delay = filter_var($this->option('delay'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]))) {
            throw new \InvalidArgumentException('Delay should be a positive integer or 0.');
        }

        if (! is_null($this->option('ttr'))) {
            if (false === ($this->ttr = filter_var($this->option('ttr'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
                throw new \InvalidArgumentException('TTR should be a positive integer.');
            }
        }

        if (! is_null($this->option('priority'))) {
            if (false === ($this->priority = filter_var($this->option('priority'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]))) {
                throw new \InvalidArgumentException('Priority should be a positive integer or 0.');
            }
        }
    }

    /**
     * Fetches the next job from the tube.
     * For ready jobs do reserve, for delayed and buried - peek.
     *
     * @param $tube
     * @param $state
     *
     * @return bool|object|\Pheanstalk\Job|void
     */
    private function getNextJob($tube, $state)
    {
        if ('ready' == $this->state) {
            return $this->reserveJob($tube);
        }

        return $this->peekJob($tube, $state);
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

class DeleteCommand extends PeekCommand
{
    protected $commandName = 'delete';

    protected $description = 'Delete a job';

    protected $state = 'ready';

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $tube = $this->argument('tube') ?: $this->defaultTube;

        if (! $job = $this->peekJob($tube, $this->state)) {
            return $this->renderJobNotFoundMessage($tube, $this->state);
        }

        $this->renderJob($job);

        if (! $this->confirm('Are you sure you want to delete this job?')) {
            return;
        }

        $this->deleteJob($job);

        $this->comment('The job has been deleted.');
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

class PeekCommand extends BaseCommand
{
    protected $commandName = 'peek';

    protected $commandArguments = '
        {state=ready : State [ready|delayed|buried]}
        {tube? : Tube name}
    ';

    protected $commandOptions = '';

    protected $description = 'Peek a job';

    protected $state = 'ready';

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $tube = $this->argument('tube') ?: $this->defaultTube;

        if (! $job = $this->peekJob($tube, $this->state)) {
            return $this->renderJobNotFoundMessage($tube, $this->state);
        }

        $this->renderJob($job);
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        $this->state = strtolower($this->argument('state'));
        if (! in_array($this->state, ['ready', 'buried', 'delayed'])) {
            throw new \InvalidArgumentException("Invalid state '$this->state'.");
        }
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

use Pheanstalk\Exception\ServerException;

class TubeStatsCommand extends BaseCommand
{
    protected $commandName = 'tube';

    protected $commandArguments = '
        {tube? : Tube name}
    ';

    protected $commandOptions = '';

    protected $description = 'Show tube statistics';

    protected $tubeStatsHeaders = [
        'Tube', 'Buried', 'Delayed', 'Ready', 'Reserved', 'Urgent', 'Waiting', 'Total',
    ];

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        if ($this->argument('tube')) {
            return $this->renderTubeStats($this->argument('tube'));
        }

        return $this->renderAllStats();
    }

    /**
     * @throws ServerException
     * @throws \Exception
     */
    protected function renderAllStats()
    {
        foreach ($this->getTubes() as $tube) {
            $data[] = $this->transformTubeStatsForTable($this->getTubeStats($tube));
        }

        $this->table($this->tubeStatsHeaders, $data);
    }

    /**
     * @param $tube
     *
     * @throws ServerException
     * @throws \Exception
     */
    protected function renderTubeStats($tube)
    {
        $this->table(['Property', 'Value'], $this->transformForTable($this->getTubeStats($tube)));
    }

    /**
     * @param $stats
     *
     * @return array
     */
    protected function transformTubeStatsForTable($stats)
    {
        return [
            $stats['name'].($stats['pause-time-left'] ? " (paused {$stats['pause-time-left']})" : ''),
            $stats['current-jobs-buried'],
            $stats['current-jobs-delayed'],
            $stats['current-jobs-ready'],
            $stats['current-jobs-reserved'],
            $stats['current-jobs-urgent'],
            $stats['current-waiting'],
            $stats['total-jobs'],
        ];
    }
}


<?php

namespace Pvm\ArtisanBeans\Console;

use Pheanstalk\Exception\ServerException;

class PauseTubeCommand extends BaseCommand
{
    protected $commandName = 'pause';

    protected $commandArguments = '
        {tube : Tube name}
        {delay : Seconds before jobs can be reserved from the tube}
    ';

    protected $description = 'Pause the tube';

    protected $delay;

    /**
     * {@inheritdoc}
     */
    public function handle()
    {
        $this->parseArguments();

        $tube = $this->argument('tube');

        try {
            $this->getPheanstalk()->pauseTube($tube, $this->delay);
        } catch (ServerException $e) {
            if ($this->isNotFoundException($e)) {
                return $this->comment("Tube '$tube' doesn't exist.");
            }

            throw $e;
        }

        return $this->comment($this->getSuccessMessage($tube));
    }

    /**
     * {@inheritdoc}
     */
    protected function parseCommandArguments()
    {
        if (false === ($this->delay = filter_var($this->argument('delay'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]))) {
            throw new \InvalidArgumentException('Delay should be a positive integer.');
        }
    }

    /**
     * @param $tube
     *
     * @return string
     */
    protected function getSuccessMessage($tube)
    {
        return "Tube '$tube' has been paused for $this->delay sec.";
    }
}


<?php
namespace PicoAuth;

/**
 * PicoAuth helper methods.
 */
final class Utils
{

    /**
     * Searches the url for a query parameter of a given key.
     * @param string $url
     * @param string $key
     * @return mixed
     */
    public static function getRefererQueryParam($url, $key)
    {
        if (!$url) {
            return null;
        }

        $query = [];

        parse_str(parse_url($url, PHP_URL_QUERY), $query);
        if (isset($query[$key])) {
            return $query[$key];
        }

        return null;
    }

    /**
     * Validates a non-empty Pico url param.
     * @param string $url
     * @return bool
     */
    public static function isValidPageId($url)
    {
        return 1 === preg_match("/^(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/", $url);
    }
}


<?php

namespace PicoAuth\Security\RateLimiting;

use PicoAuth\Log\LoggerTrait;
use Psr\Log\LoggerAwareInterface;
use PicoAuth\Storage\Interfaces\RateLimitStorageInterface;

/**
 * Rate Limiter
 */
class RateLimit implements RateLimitInterface, LoggerAwareInterface
{

    use LoggerTrait;

    /**
     * Default net mask applied on IPv4 addresses
     */
    const DEFAULT_NETMASK_IPV4 = 32;

    /**
     * Default net mask applied on IPv6 addresses
     */
    const DEFAULT_NETMASK_IPV6 = 48;

    /**
     * Configuration of actions for rate limiting
     *
     * @var array
     */
    protected $actions;

    /**
     * Rate limit configuration array
     *
     * @var array
     */
    protected $config;

    /**
     * Storage instance
     *
     * @var RateLimitStorageInterface
     */
    protected $storage;

    /**
     * An error message
     *
     * @see RateLimit::getError()
     * @see RateLimit::setErrorMessage()
     * @var string
     */
    protected $errorMessage;

    public function __construct(RateLimitStorageInterface $storage)
    {
        $this->storage = $storage;
        $this->config = $this->storage->getConfiguration();
        $this->actions = $this->config["actions"];
    }

    /**
     * @inheritdoc
     */
    public function action($actionName, $increment = true, $params = array())
    {
        // the action is not configured for rate limiting
        if (!isset($this->actions[$actionName])) {
            return true;
        }

        $configOptions = $this->actions[$actionName];

        foreach ($configOptions as $blockType => $config) {
            if (!($entityId = $this->getEntityId($blockType, $config, $params))) {
                continue;
            }

            if ($increment) {
                $limit = $this->incrementCounter($actionName, $blockType, $entityId, $config);
            } else {
                $limit = $this->getLimitFor($actionName, $blockType, $entityId);
            }

            // If the action is not allowed, other potential blockTypes are not evaluated
            if (!$this->isAllowed($limit, $config)) {
                $this->setErrorMessage($config);
                return false;
            }
        }

        return true;
    }

    /**
     * @inheritdoc
     */
    public function getError()
    {
        return $this->errorMessage;
    }

    /**
     * Returns whether the entity is currently rate limited
     *
     * @param array $limit Limit data for the entity
     * @param array $config Limit configuration
     * @return boolean false if the entity is rate limited, true otherwise
     */
    protected function isAllowed($limit, $config)
    {
        if ($limit["cnt"] >= $config["count"]) {
            if (time() > $limit["ts"] + $config["blockDuration"]) {
                return true;
            } else {
                return false;
            }
        } else {
            return true;
        }
    }

    /**
     * Returns IP subnet string for the client
     *
     * The net mask applied to the IP is used from the configuration supplied
     * to the method, if not present default net mask is used.
     *
     * @param array $config Limit configuration
     * @return string IP subnet string
     */
    protected function getIp($config)
    {
        $remoteAddr = $_SERVER['REMOTE_ADDR'];
        if (filter_var($remoteAddr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            $netmask = (isset($config["netmask_IPv4"]))
                ? $config["netmask_IPv4"] : self::DEFAULT_NETMASK_IPV4;
        } else {
            $netmask = (isset($config["netmask_IPv6"]))
                ? $config["netmask_IPv6"] : self::DEFAULT_NETMASK_IPV6;
        }

        $ipSubnet = $this->getSubnet($remoteAddr, $netmask);

        return $ipSubnet;
    }

    /**
     * Applies a given netmask to an IP address
     *
     * Both IPv4 and IPv6 are supported and detected automatically (inet_pton).
     *
     * @param string $ip IP address string
     * @param int $netmask IP netmask to be applied
     * @return string IP string in format: IP/netmask
     * @throws \InvalidArgumentException If the IP or netmask is not valid
     */
    protected function getSubnet($ip, $netmask)
    {
        $binString = @inet_pton($ip);
        if ($binString === false) {
            throw new \InvalidArgumentException("Not a valid IP address.");
        }

        // Length of the IP in bytes (4 or 16)
        $byteLen = mb_strlen($binString, "8bit");
        if (!is_int($netmask) || $netmask < 0 || $netmask > $byteLen * 8) {
            throw new \InvalidArgumentException("Not a valid netmask.");
        }

        for ($byte = $byteLen - 1; ($byte + 1) * 8 > $netmask; --$byte) {
            // Bitlength of a mask for the current byte
            $maskLen = min(8, ($byte + 1) * 8 - $netmask);

            // Create the byte mask of maskLen bits
            $mask = (~((1 << $maskLen) - 1)) & 0xff;

            // Export byte as 'unsigned char' and apply the mask
            $maskedByte = $mask & unpack('C', $binString[$byte])[1];
            $binString[$byte] = pack('C', $maskedByte);
        }

        return inet_ntop($binString) . '/' . $netmask;
    }

    /**
     * Gets an entity identifier
     *
     * Returns a string that will identify the entity
     * in the rate limiting process. It can be a hash of a username/email,
     * or IP subnet of a size defined by the configuration.
     *
     * md5 is applied on a user-supplied input (email, username),
     * collisions or reversibility do not present security concerns.
     *
     * @param string $blockType Blocking type (account,email,ip)
     * @param array $config Configuration array for the specific rate limit
     * @param array $params Additional parameters from the action() call
     * @return string Entity id
     */
    protected function getEntityId($blockType, $config, $params = array())
    {
        $entityId = null;
        if ($blockType === "account" && isset($params["name"])) {
            $entityId = md5($params["name"]);
        } elseif ($blockType === "email" && isset($params["email"])) {
            $entityId = md5($params["email"]);
        } elseif ($blockType === "ip") {
            $entityId = $this->getIp($config);
        }
        return $entityId;
    }

    /**
     * Retrieves a limit array for a given action,type,id
     *
     * @param string $actionName Action id (e.g. "login")
     * @param string $blockType Block type (e.g. "ip")
     * @param string $entityId Entity identifier
     * @return array Limit data array
     */
    protected function getLimitFor($actionName, $blockType, $entityId)
    {
        $limit = $this->storage->getLimitFor($actionName, $blockType, $entityId);
        if ($limit === null) {
            $limit = array("ts" => 0, "cnt" => 0);
        }
        return $limit;
    }

    /**
     * Increments a counter for the specified limit
     *
     * @param string $actionName Action id
     * @param string $blockType Block type
     * @param string $entityId Entity identifier
     * @param array $config Limit configuration array
     * @return array Limit data array before the increment
     */
    protected function incrementCounter($actionName, $blockType, $entityId, $config)
    {
        // Begin an exclusive transaction
        $this->storage->transaction($actionName, $blockType, RateLimitStorageInterface::TRANSACTION_BEGIN);

        $limit = $this->getLimitFor($actionName, $blockType, $entityId);

        $time = time();

        $resetCounter =
            // Counter reset after specified timeout since the last action
            ($time > $limit["ts"] + $config["counterTimeout"]) ||

            // Counter reset after blockout delay timeout
            ($limit["cnt"] >= $config["count"] && $time > $limit["ts"] + $config["blockDuration"]);

        if ($resetCounter) {
            $limit["cnt"] = 0;
        }

        $limitBeforeIncrement = $limit;

        // The limit will be reached with this increment
        if ($limit["cnt"] === $config["count"] - 1) {
            $this->logRateLimitReached($actionName, $blockType, $entityId, $config);
        }

        ++$limit["cnt"];
        $limit["ts"] = $time;

        // Update the limit if the entity is not blocked
        if ($limit["cnt"] <= $config["count"]) {
            $this->storage->updateLimitFor($actionName, $blockType, $entityId, $limit);
            if (rand(0, 100) <= $this->config["cleanupProbability"]) {
                $this->storage->cleanup($actionName, $blockType, $config);
            }
            $this->storage->save($actionName, $blockType);
        }

        // Close the transaction
        $this->storage->transaction($actionName, $blockType, RateLimitStorageInterface::TRANSACTION_END);

        // Returns the limit array before the increment
        return $limitBeforeIncrement;
    }

    /**
     * Logs that the rate limit was reached
     *
     * @param string $actionName
     * @param string $blockType
     * @param string $entityId
     * @param array $config
     */
    protected function logRateLimitReached($actionName, $blockType, $entityId, $config)
    {
        $this->getLogger()->notice(
            "Rate limit of {cnt} reached: {action} for {entity} ({type}).",
            array('cnt' => $config["count"], 'action' => $actionName, 'entity' => $entityId, 'type' => $blockType)
        );
    }

    /**
     * Sets the error message that will be retrievable via {@see RateLimit::getError()}
     *
     * @param array $config Limit configuration
     */
    protected function setErrorMessage($config)
    {
        if (isset($config["errorMsg"])) {
            $msg = $config["errorMsg"];
        } else {
            $msg = "Rate limit exceeded, wait %min% minutes.";
        }

        $replace = array(
            "%min%" => intval(ceil($config["blockDuration"] / 60)),
            "%cnt%" => $config["count"],
        );

        $this->errorMessage = str_replace(array_keys($replace), $replace, $msg);
    }
}


<?php

namespace PicoAuth\Module\Authentication\LocalAuth;

use PicoAuth\Log\LoggerTrait;
use PicoAuth\Storage\Interfaces\LocalAuthStorageInterface;
use PicoAuth\Security\Password\Password;
use PicoAuth\Security\RateLimiting\RateLimitInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use Psr\Log\LoggerAwareInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Registration component for LocalAuth
 */
class Registration implements LoggerAwareInterface
{

    use LoggerTrait;

    const REGISTER_CSRF_ACTION = 'register';

    /**
     * The password reser request
     *
     * @var Request
     */
    protected $httpRequest;

    /**
     * The registration configuration array
     *
     * @var array
     */
    protected $config;

    /**
     * Instance of a rate limiter
     *
     * @var RateLimitInterface
     */
    protected $limit;

    /**
     * Instance of the plugin
     *
     * @var PicoAuthInterface
     */
    protected $picoAuth;

    /**
     * Session manager
     *
     * @var SessionInterface
     */
    protected $session;

    /**
     * Configuration storage
     *
     * @var LocalAuthStorageInterface
     */
    protected $storage;

    public function __construct(
        PicoAuthInterface $picoAuth,
        SessionInterface $session,
        LocalAuthStorageInterface $storage,
        RateLimitInterface $limit
    ) {

        $this->picoAuth = $picoAuth;
        $this->session = $session;
        $this->storage = $storage;
        $this->limit = $limit;
    }

    /**
     * Sets the LocalAuth configuration array
     *
     * @param array $config
     * @return $this
     */
    public function setConfig(array $config)
    {
        $this->config = $config["registration"];
        return $this;
    }

    /**
     * On a registration request
     *
     * @return void
     */
    public function handleRegistration(Request $httpRequest)
    {
        // Abort if disabled
        if (!$this->config["enabled"]) {
            return;
        }

        $user = $this->picoAuth->getUser();
        if ($user->getAuthenticated()) {
            $this->picoAuth->redirectToPage("index");
        }

        $this->picoAuth->addAllowed("register");
        $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/register.md');

        // Check the form submission
        $post = $httpRequest->request;
        if ($post->has("username")
            && $post->has("email")
            && $post->has("password")
            && $post->has("password_repeat")
        ) {
            // CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"), self::REGISTER_CSRF_ACTION)) {
                $this->picoAuth->redirectToPage("register");
            }

            // Abort if a limit for maximum users is exceeded
            $this->assertLimits();

            // Registration fields
            $reg = array(
                "username" => strtolower(trim($post->get("username"))),
                "email" => trim($post->get("email")),
                "password" => new Password($post->get("password")),
                "passwordRepeat" => new Password($post->get("password_repeat")),
            );

            $isValid = $this->validateRegistration($reg);

            if ($isValid) {
                // Check if the action is not rate limited
                if (!$this->limit->action("registration")) {
                    $this->session->addFlash("error", $this->limit->getError());
                    $this->picoAuth->redirectToPage("register");
                }

                $this->logSuccessfulRegistration($reg);

                $userData = array('email' => $reg["email"]);

                $localAuth = $this->picoAuth->getContainer()->get('LocalAuth');
                $localAuth->userDataEncodePassword($userData, $reg["password"]);

                $this->storage->saveUser($reg["username"], $userData);

                $this->session->addFlash("success", "Registration completed successfully, you can now log in.");
                $this->picoAuth->redirectToLogin();
            } else {
                // Prefill the old values to the form
                $this->session->addFlash("old", array(
                    'username' => $reg["username"],
                    'email' => $reg["email"]
                ));

                // Redirect back and display errors
                $this->picoAuth->redirectToPage("register");
            }
        }
    }

    /**
     * Validates the submitted registration
     *
     * @param array $reg Registration array
     * @return boolean true if the registration is valid and can be saved, false otherwise
     */
    protected function validateRegistration(array $reg)
    {
        $isValid = true;

        // Username format
        try {
            $this->storage->checkValidName($reg["username"]);
        } catch (\RuntimeException $e) {
            $isValid = false;
            $this->session->addFlash("error", $e->getMessage());
        }

        // Username length
        $min = $this->config["nameLenMin"];
        $max = $this->config["nameLenMax"];
        if (strlen($reg["username"]) < $min || strlen($reg["username"]) > $max) {
            $isValid = false;
            $this->session->addFlash(
                "error",
                sprintf("Length of a username must be between %d-%d characters.", $min, $max)
            );
        }

        // Email format
        if (!filter_var($reg["email"], FILTER_VALIDATE_EMAIL)) {
            $isValid = false;
            $this->session->addFlash("error", "Email address does not have a valid format.");
        }

        // Email unique
        if (null !== $this->storage->getUserByEmail($reg["email"])) {
            $isValid = false;
            $this->session->addFlash("error", "This email is already in use.");
        }

        // Password repeat matches
        if ($reg["password"]->get() !== $reg["passwordRepeat"]->get()) {
            $isValid = false;
            $this->session->addFlash("error", "The passwords do not match.");
        }

        // Check password policy
        $localAuth = $this->picoAuth->getContainer()->get('LocalAuth');
        if (!$localAuth->checkPasswordPolicy($reg["password"])) {
            $isValid = false;
        }

        // Username unique
        if ($this->storage->getUserByName($reg["username"]) !== null) {
            $isValid = false;
            $this->session->addFlash("error", "The username is already taken.");
        }

        return $isValid;
    }

    /**
     * Logs successful registration
     *
     * @param array $reg Registration array
     */
    protected function logSuccessfulRegistration(array $reg)
    {
        $this->getLogger()->info(
            "New registration: {name} ({email}) from {addr}",
            array(
                "name" => $reg["username"],
                "email" => $reg["email"],
                "addr" => $_SERVER['REMOTE_ADDR']
            )
        );

        // Log the amount of users on each 10% of the maximum capacity
        $max = $this->config["maxUsers"];
        $count = $this->storage->getUsersCount()+1;
        if ($count % ceil($max/10) === 0) {
            $percent = intval($count/ceil($max/100));
            $this->getLogger()->warning(
                "The amount of users has reached {percent} of the maximum capacity {max}.",
                array(
                    "percent" => $percent,
                    "max" => $max
                )
            );
        }
    }

    /**
     * Aborts the registration if the limit of an amount of users is reached
     */
    protected function assertLimits()
    {
        if ($this->storage->getUsersCount() >= $this->config["maxUsers"]) {
            $this->session->addFlash("error", "New registrations are currently disabled.");
            $this->picoAuth->redirectToPage("register");
        }
    }
}


<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Configurator\ConfigurationException;

/**
 * Base File Storage
 */
class FileStorage
{

    /**
     * Configuration file location, redefined in subclasses
     */
    const CONFIG_FILE = '';

    /**
     * The configuration array
     * @var array
     */
    protected $config;

    /**
     * The Configurator instance
     * @var null|Configurator\AbstractConfigurator
     */
    protected $configurator;

    /**
     * Cache implementation
     * @var \Psr\SimpleCache\CacheInterface
     */
    protected $cache;

    /**
     * Base directory
     * @var string
     */
    protected $dir;

    /**
     * The last error message
     * @var string
     */
    protected static $lastError;

    /**
     * Reads contents of a file to a string
     *
     * Acquires blocking shared lock for the file.
     *
     * @param string $fileName Name of the file to read.
     * @param array $options Options for the Reader instance
     * @return string|bool String with the file contents on success, FALSE otherwise.
     */
    public static function readFile($fileName, $options = [])
    {
        $reader = new File\FileReader($fileName, $options);
        $success = true;
        $contents = null;

        try {
            $reader->open();
            $contents = $reader->read();
        } catch (\RuntimeException $e) {
            self::$lastError = $e->getMessage();
            $success = false;
        }

        try {
            $reader->close();
        } catch (\RuntimeException $e) {
            self::$lastError = $e->getMessage();
            $success = false;
        }

        return ($success) ? $contents : false;
    }

    /**
     * Writes data to a file
     *
     * @param string $fileName Name of the file
     * @param string $data File contents to write
     * @param array $options Options for the Writer instance
     * @return bool Was the write operation successful
     */
    public static function writeFile($fileName, $data, $options = [])
    {
        $writer = new File\FileWriter($fileName, $options);
        $isSuccess = true;
        $written = 0;

        try {
            $writer->open();
            $written = $writer->write($data);
        } catch (\RuntimeException $e) {
            self::$lastError = $e->getMessage();
            $isSuccess = false;
        }

        try {
            $writer->close();
        } catch (\RuntimeException $e) {
            self::$lastError = $e->getMessage();
            $isSuccess = false;
        }

        return $isSuccess;
    }

    /**
     * Prepares path
     *
     * Check if $path relative to $basePath exists and is writable.
     * Attempts to create it if it does not exist. Warning is supressed
     * in case of mkdir failure.
     *
     * @param string $basePath
     * @param string $path
     */
    public static function preparePath($basePath, $path)
    {
        $basePath = rtrim($basePath, '/');
        $path = ltrim($path, '/');
        $fullPath = $basePath . '/' . $path;

        if (file_exists($fullPath)) {
            if (!is_dir($fullPath)) {
                throw new \RuntimeException("Cannot create a directory, regular file already exists: {$path}.");
            }
            if (!is_writable($fullPath)) {
                throw new \RuntimeException("Directory is not writable: {$path}.");
            }
        } else {
            $res=@mkdir($fullPath, 0770, true);
            if (!$res) {
                throw new \RuntimeException("Unable to create a directory: {$path}.");
            }
        }
    }

    /**
     * Get item by URL
     *
     * Find an item that applies to a given url
     * Url keys must begin with a /
     *
     * @param array|null $items
     * @param string $url
     * @return array|null
     */
    public static function getItemByUrl($items, $url)
    {
        if (!isset($items)) {
            return null;
        }

        // Check for the exact rule
        if (array_key_exists("/" . $url, $items)) {
            return $items["/" . $url];
        }

        $urlParts = explode("/", trim($url, "/"));
        $urlPartsLen = count($urlParts);

        while ($urlPartsLen > 0) {
            unset($urlParts[--$urlPartsLen]);

            $subUrl = "/" . join("/", $urlParts);

            // Use the higher level rule, if it doesn't have deactivated recursive application
            if (array_key_exists($subUrl, $items)
                && (!isset($items[$subUrl]["recursive"])
                        || $items[$subUrl]["recursive"]===true)) {
                return $items[$subUrl];
            }
        }

        return null;
    }

    /**
     * Returns the configuration
     *
     * Reads the configuration if it hasn't been already read.
     *
     * @return Array
     */
    public function getConfiguration()
    {
        $this->readConfiguration();
        return $this->config;
    }

    /**
     * Configuration validation
     *
     * Calls the specific configurator instance, which validates the user
     * provided configuration and adds default values.
     *
     * @param mixed $userConfig Anything read from the configuration file
     * @return array Modified configuration (added defaults)
     * @throws \RuntimeException Missing configurator
     * @throws ConfigurationException Validation error
     */
    protected function validateConfiguration($userConfig = array())
    {
        if (!$this->configurator) {
            throw new \RuntimeException("Configurator class is not set.");
        }

        try {
            return $this->configurator->validate($userConfig);
        } catch (ConfigurationException $e) {
            $e->addBeforeMessage("Configuration error in ".static::CONFIG_FILE.":");
            throw $e;
        }
    }

    /**
     * Initializes the configuration array
     *
     * If the file exists, tries to read from the cache first, if the cache
     * has the data, but the file is newer, reads the file and renews the cache.
     *
     * md5 is used as a cache key, because some cache providers may not accept
     * characters present in the file path.
     * Collisions or reversibility do not present security concerns.
     *
     * @throws \RuntimeException On file read error
     */
    protected function readConfiguration()
    {
        // Return if already loaded
        if (is_array($this->config)) {
            return;
        }

        $fileName = $this->dir . static::CONFIG_FILE;

        // Abort if the file doesn't exist
        if (!file_exists($fileName)) {
            $this->config = $this->validateConfiguration();
            return;
        }

        $modifyTime = filemtime($fileName);
        if (false === $modifyTime) {
            throw new \RuntimeException("Unable to get mtime of the configuration file.");
        }

        // Check if the configuration is in cache
        $cacheKey = md5($fileName);
        if ($this->cache->has($cacheKey)) {
            $this->config=$this->cache->get($cacheKey);

            // Cached file is up to date
            if ($this->config["_mtime"] === $modifyTime) {
                return;
            }
        }

        if (($yaml = self::readFile($fileName)) !== false) {
            $config = \Symfony\Component\Yaml\Yaml::parse($yaml);
            $this->config = $this->validateConfiguration($config);
        } else {
            throw new \RuntimeException("Unable to read configuration file.");
        }

        // Save to cache with updated modify-time
        $storedConfig = $this->config;
        $storedConfig["_mtime"]= $modifyTime;
        $this->cache->set($cacheKey, $storedConfig);
    }
}


<?php

namespace PicoAuth\Security;

/**
 * PicoAuth CSRF token manager
 *
 * The token consists of two concatenated parts:
 * key and HMAC-SHA256(realToken,key)
 *
 * The realToken is stored in the session under the action identifier.
 * Together with realToken there is also a token generation timestamp
 * for evaluating expired tokens.
 *
 * The above method allows for different but still valid output strings,
 * which makes it impossible to reveal using BREACH attack (if HTTP compression
 * is used, and the site reflects user input) if it is not
 * prevented by other means. [1]
 *
 * 1. GLUCK, Yoel; HARRIS, Neal; PRADO, Angelo.
 * BREACH: reviving the CRIME attack. Unpublished manuscript, 2013.
 */
class CSRF
{

    /**
     * Length of the random part of the token in bytes
     */
    const TOKEN_SIZE = 20;

    /**
     * Default action index when generic token is requested
     */
    const DEFAULT_SELECTOR = '_';

    /**
     * Character used to split token parts
     */
    const TOKEN_DELIMTER = '.';

    /**
     * Seconds of token validity
     *
     * Used as a default value if not specified explicitely in {@see CSRF::checkToken()}
     */
    const TOKEN_VALIDITY = 3600;

    /**
     * Session index for the CSRF manager
     */
    const SESSION_KEY = 'CSRF';

    /**
     * Session manager
     * @var \PicoAuth\Session\SessionInterface
     */
    protected $session;

    /**
     * Constructs CSRF manager with the provided session storage
     * @param \PicoAuth\Session\SessionInterface $session
     */
    public function __construct(\PicoAuth\Session\SessionInterface $session)
    {
        $this->session = $session;
    }

    /**
     * Retrieve a token for the specified action
     * @param string $action An action the token is associated with
     * @param bool $reuse Is the token valid for multiple submissions
     * @return string Token string
     */
    public function getToken($action = null, $reuse = true)
    {
        $tokenStorage = $this->session->get(self::SESSION_KEY, []);

        $index = ($action) ? $action : self::DEFAULT_SELECTOR;

        if (!isset($tokenStorage[$index])) {
            $token = bin2hex(random_bytes(self::TOKEN_SIZE));
            $tokenStorage[$index] = array(
                'time' => time(),
                'token' => $token
            );
        } else {
            // Token already exists and is not expired
            $token = $tokenStorage[$index]['token'];

            // Update token time
            $tokenStorage[$index]['time'] = time();
        }

        $tokenStorage[$index]['reuse'] = $reuse;

        $key = bin2hex(random_bytes(self::TOKEN_SIZE));
        $tokenHMAC = $this->tokenHMAC($token, $key);

        $this->session->set(self::SESSION_KEY, $tokenStorage);

        return $key . self::TOKEN_DELIMTER . $tokenHMAC;
    }

    /**
     * Token validation
     * @param string $string token string to be validated
     * @param string $action action the validated token is associated with
     * @return boolean true on successful validation
     */
    public function checkToken($string, $action = null, $tokenValidity = null)
    {
        // Get token data from session
        $index = ($action) ? $action : self::DEFAULT_SELECTOR;
        $tokenStorage = $this->session->get(self::SESSION_KEY, []);
        if (!isset($tokenStorage[$index])) {
            return false;
        }
        $tokenData = $tokenStorage[$index];

        // Check token expiration
        if ($this->isExpired($tokenData, $tokenValidity)) {
            $this->ivalidateToken($index, $tokenStorage);
            return false;
        }

        // Check correct format of received token
        $parts = explode(self::TOKEN_DELIMTER, $string);
        if (count($parts) !== 2) {
            return false;
        }

        // Validate the token
        $trueToken = $tokenData['token'];
        $key = $parts[0];           // A key used to create a keyed hash of the real token
        $tokenHMAC = $parts[1];     // Keyed hash of the real token
        $trueTokenHMAC = $this->tokenHMAC($trueToken, $key);
        $isValid = \hash_equals($trueTokenHMAC, $tokenHMAC);

        // Remove if it is a one-time token
        if ($isValid && !$tokenData['reuse']) {
            $this->ivalidateToken($index, $tokenStorage);
        }

        return $isValid;
    }

    /**
     * Invalidates tokens for all actions
     * Should be called after any authentication
     */
    public function removeTokens()
    {
        $this->session->remove(self::SESSION_KEY);
    }

    /**
     * Checks token time validity
     * @param array $tokenData
     * @return bool true if token is expired (is after validity)
     */
    protected function isExpired(array $tokenData, $tokenValidity = null)
    {
        return time() >
            $tokenData['time'] + (($tokenValidity!==null) ? $tokenValidity : self::TOKEN_VALIDITY);
    }

    /**
     * The real token is used as a key in the hash_hmac to create a keyed
     * hash of the $key
     * @param string $token
     * @param string $key
     * @return string
     */
    protected function tokenHMAC($token, $key)
    {
        return hash_hmac('sha256', $key, $token, false);
    }

    /**
     * Invalidates the specific token by unsetting it from the tokenStorage
     * array in the session.
     * @param string $index Token index
     * @param array $tokenStorage Token storage array with all tokens
     */
    protected function ivalidateToken($index, array &$tokenStorage)
    {
        unset($tokenStorage[$index]);
        $this->session->set(self::SESSION_KEY, $tokenStorage);
    }
}


<?php
namespace PicoAuth\Module\Generic;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ParameterBag;
use PicoAuth\PicoAuthInterface;
use PicoAuth\Module\AbstractAuthModule;

/**
 * PicoAuth installation module
 */
class Installer extends AbstractAuthModule
{

    const CONFIG_PLUGIN_KEY = 'PicoAuth';
    const CONFIG_MODULES_KEY = 'authModules';

    /**
     * PicoAuth plugin
     * @var PicoAuthInterface
     */
    protected $picoAuth;

    /**
     * Modules supported by the installer script
     * Container-registered service names
     * @var array
     */
    protected $modules = array(
        "LocalAuth" => "LocalAuth",
        "OAuth" => "OAuth",
        "PageACL" => "PageACL",
        "PageLock" => "PageLock"
    );

    /**
     * Creates an installer
     *
     * @param PicoAuthInterface $picoAuth
     */
    public function __construct(PicoAuthInterface $picoAuth)
    {
        $this->picoAuth = $picoAuth;
    }

    /**
     * @inheritdoc
     */
    public function getName()
    {
        return 'installer';
    }

    /**
     * @inheritdoc
     */
    public function onPicoRequest($url, Request $httpRequest)
    {
        $post = $httpRequest->request;

        if ($url === "PicoAuth") {
            $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/install.md');

            $this->checkServerConfiguration();
        } elseif ($url === "PicoAuth/modules") {
            $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/install.md');

            if ($post->has("generate")) {
                $this->configGenerationAction($post);
            } else {
                $this->picoAuth->addOutput("installer_step", 1);
            }
        }
    }

    /**
     * Gets absolute Urls for pre-installation test
     *
     * In order to perform checks that the server configuration does not
     * allow public access to Pico configuration or content directory,
     * the tested Urls are passed to Javascript, where response
     * tests are performed using AJAX calls.
     *
     * The method assumes default locations of the config and content
     * directories, as specified in the Pico's index.php file.
     */
    protected function checkServerConfiguration()
    {
        $pico = $this->picoAuth->getPico();

        // Pico config.yml file
        $configDir = $pico->getBaseUrl() . basename($pico->getConfigDir());
        $configFile = $configDir . "/config.yml";

        // index.md file
        $contentDir = $pico->getBaseUrl() . basename($pico->getConfig('content_dir'));
        $indexFile = $contentDir . "/index" . $pico->getConfig('content_ext');

        $urls = array(
            'dir_listing' => $configDir,
            'config_file' => $configFile,
            'content_file' => $indexFile
        );

        $this->httpsTest();
        $this->webRootDirsTest();
        $this->picoAuth->addOutput("installer_urltest", $urls);
    }

    /**
     * Checks if this Pico installation uses https
     *
     * The resulting boolean value is set as a template variable
     * to be displayed by the installer.
     */
    protected function httpsTest()
    {
        $pico = $this->picoAuth->getPico();
        $url = $pico->getBaseUrl();

        $res = (substr($url, 0, 8) === "https://");

        $this->picoAuth->addOutput("installer_https", $res);
    }

    /**
     * Checks content and config dir locations
     *
     * Ideally, both of these directories should be outside the web server
     * root, which is Pico::getRootDir().
     * Adds the result to the output variables for display in the template.
     */
    protected function webRootDirsTest()
    {
        $pico = $this->picoAuth->getPico();

        $webRoot = realpath($pico->getRootDir());
        $configDir = realpath($pico->getConfigDir());
        $contentDir = realpath($pico->getConfig('content_dir'));

        // Webroot path must not be at the beginning of both of the paths
        $config = (substr($configDir, 0, strlen($webRoot)) !== $webRoot);
        $content = (substr($contentDir, 0, strlen($webRoot)) !== $webRoot);

        $this->picoAuth->addOutput("installer_wr_config", $config);
        $this->picoAuth->addOutput("installer_wr_content", $content);
    }

    /**
     * Form submission requesting to generate the plugin configuration
     *
     * @param ParameterBag $post
     */
    protected function configGenerationAction(ParameterBag $post)
    {
        //CSRF validation
        if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"))) {
            // On a token mismatch the submission gets ignored
            $this->picoAuth->addOutput("installer_step", 1);
            return;
        }

        $this->picoAuth->addOutput("installer_step", 2);
        $this->outputModulesConfiguration($post);
    }

    /**
     * Creates a plugin configuration based on the selection
     *
     * @param ParameterBag $post
     */
    protected function outputModulesConfiguration(ParameterBag $post)
    {
        $modulesClasses = array();
        $modulesNames = array();

        foreach ($this->modules as $key => $value) {
            if ($post->has($key)) {
                $modulesClasses[] = $value;
                $modulesNames[] = $key;
            }
        }

        $config = array(
            self::CONFIG_PLUGIN_KEY => array(
                self::CONFIG_MODULES_KEY => $modulesClasses
            )
        );

        $yaml = \Symfony\Component\Yaml\Yaml::dump($config, 2, 4);

        // Adds output to the template variables
        $this->picoAuth->addOutput("installer_modules_config", $yaml);
        $this->picoAuth->addOutput("installer_modules_names", $modulesNames);
    }
}


<?php
namespace PicoAuth;

/**
 * The User entity.
 */
class User
{

    /**
     * Unique identifier of the user.
     * @var null|string
     */
    protected $id = null;

    /**
     * Optional displayable name.
     * @var null|string
     */
    protected $displayName = null;

    /**
     * Authentication flag.
     * @var bool
     */
    protected $authenticated = false;

    /**
     * Module name that was used to authenticate the user.
     * @see PicoAuth\Module\AuthModule::getName()
     * @var string|null Authenticator name
     */
    protected $authenticator = null;

    /**
     * Groups the user is member of.
     * @var string[]
     */
    protected $groups = [];

    /**
     * User attributes.
     * @var array
     */
    protected $attributes = [];

    /**
     * Gets user's authenticated state.
     * @return bool
     */
    public function getAuthenticated()
    {
        return $this->authenticated;
    }

    /**
     * Sets user's authenticated state.
     * @param bool $v
     * @return $this
     */
    public function setAuthenticated($v)
    {
        if (!$v) {
            $this->authenticator = null;
        }
        $this->authenticated = $v;
        return $this;
    }

    /**
     * Gets user's authenticator.
     * Will return null if the user is not authenticated.
     * @see User::authenticator
     * @return null|string
     */
    public function getAuthenticator()
    {
        return $this->authenticator;
    }

    /**
     * Sets user's authenticator.
     * @param string $name
     * @return $this
     */
    public function setAuthenticator($name)
    {
        $this->authenticator = $name;
        return $this;
    }

    /**
     * Gets user's id.
     * @return string
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Sets user's id.
     * @param string $id
     * @return $this
     */
    public function setId($id)
    {
        $this->id = $id;
        return $this;
    }

    /**
     * Returns user's display name if available.
     * @return string|null
     */
    public function getDisplayName()
    {
        return $this->displayName;
    }

    /**
     * Sets user's display name.
     * @param string $displayName
     * @return $this
     */
    public function setDisplayName($displayName)
    {
        $this->displayName = $displayName;
        return $this;
    }

    /**
     * Gets user's groups.
     * @return string[]
     */
    public function getGroups()
    {
        return $this->groups;
    }

    /**
     * Sets user's groups.
     * @param string[] $groups
     * @return $this
     */
    public function setGroups($groups)
    {
        if (is_array($groups)) {
            $this->groups = $groups;
        }
        return $this;
    }

    /**
     * Adds group.
     * @param string $group
     */
    public function addGroup($group)
    {
        if (is_string($group)) {
            $this->groups[] = $group;
        }
    }

    /**
     * Gets user's attribute if available.
     * @param string $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        return (isset($this->attributes[$key])) ? $this->attributes[$key] : null;
    }

    /**
     * Sets user's attribute.
     * @param string $key
     * @param mixed $value
     * @return $this
     */
    public function setAttribute($key, $value)
    {
        $this->attributes[$key] = $value;
        return $this;
    }
}


<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Interfaces\LocalAuthStorageInterface;
use PicoAuth\Cache\NullCache;
use Psr\SimpleCache\CacheInterface;
use PicoAuth\Storage\Configurator\LocalAuthConfigurator;
use PicoAuth\Storage\File\FileWriter;

/**
 * File storage for LocalAuth
 */
class LocalAuthFileStorage extends FileStorage implements LocalAuthStorageInterface
{

    /**
     * Configuration file name
     */
    const CONFIG_FILE = 'PicoAuth/LocalAuth.yml';

    /**
     * Directory with user files
     */
    const USERS_DIR = 'PicoAuth/users';

    /**
     * Extension of user files
     */
    const USERFILE_EXT = '.yml';

    /**
     * Reset tokens filename
     */
    const RESET_TOKENS = 'PicoAuth/data/reset_tokens.yml';

    /**
     * Already fetched users
     *
     * @var array
     */
    protected $fetchedUsers = array();

    /**
     * Number of users
     *
     * @var int
     */
    protected $usersCount;

    /**
     * The LocalAuthConfigurator instance
     * @var Configurator\LocalAuthConfigurator
     */
    protected $configurator;

    public function __construct($dir, CacheInterface $cache = null)
    {
        $this->dir = $dir;
        $this->cache = ($cache!==null) ? $cache : new NullCache;
        $this->configurator = new LocalAuthConfigurator;
    }

    /**
     * @inheritdoc
     */
    public function getUserByName($name)
    {

        // Check if the user wasn't already fetched during this request
        if (isset($this->fetchedUsers[$name])) {
            return $this->fetchedUsers[$name];
        }

        // Validate correct username format before search
        try {
            $this->checkValidName($name);
        } catch (\RuntimeException $e) {
            return null;
        }

        // Search in user files
        $userFileName = $this->dir . self::USERS_DIR . '/' . $name . self::USERFILE_EXT;

        if (($yaml = self::readFile($userFileName)) !== false) {
            $userData = \Symfony\Component\Yaml\Yaml::parse($yaml);
            $this->configurator->validateUserData($userData);
            $this->fetchedUsers[$name] = $userData;
            return $userData;
        }

        // Search in the main configuration file
        $this->readConfiguration();
        if (isset($this->config['users']) && isset($this->config['users'][$name])) {
            $this->fetchedUsers[$name] = $this->config['users'][$name];
            return $this->config['users'][$name];
        }

        return null;
    }

    /**
     * @inheritdoc
     */
    public function getUserByEmail($email)
    {
        // Search in user files
        $searchDir = $this->dir . self::USERS_DIR;
        if (is_dir($searchDir)) {
            $userFiles = $this->getDirFiles($searchDir);
            foreach ($userFiles as $filename) {
                $username = substr($filename, 0, -strlen(self::USERFILE_EXT));
                $user = $this->getUserByName($username);
                if (isset($user['email']) && $email === $user['email']) {
                    $user['name'] = $username;
                    return $user;
                }
            }
        }

        // Search in the main configuration file
        if (isset($this->config['users'])) {
            foreach ($this->config['users'] as $name => $userData) {
                if (isset($userData['email']) && $email === $userData['email']) {
                    $userData['name'] = $name;
                    return $userData;
                }
            }
        }

        return null;
    }

    /**
     * @inheritdoc
     */
    public function saveUser($id, $userdata)
    {
        $dir = $this->dir . self::USERS_DIR;
        self::preparePath($this->dir, self::USERS_DIR);

        $yaml = \Symfony\Component\Yaml\Yaml::dump($userdata, 2, 2);

        $userFileName = $dir . '/' . $id . self::USERFILE_EXT;

        // Write the new user file
        if ((self::writeFile($userFileName, $yaml, ["backup" => true]) === false)) {
            throw new \RuntimeException("Unable to save the user data (". basename($userFileName).").");
        }

        // If exists, remove the user record from the main configuration file
        // Needs write permission for users.yml
        $this->readConfiguration();
        if (isset($this->config['users']) && isset($this->config['users'][$id])) {
            unset($this->config['users'][$id]);
            $yaml = \Symfony\Component\Yaml\Yaml::dump($this->config, 3, 2);
            $fileName = $this->dir . static::CONFIG_FILE;
            if ((self::writeFile($fileName, $yaml, ["backup" => true]) === false)) {
                throw new \RuntimeException("Unable to save new configuration (".static::CONFIG_FILE.").");
            }
        }
    }

    /**
     * {@inheritdoc}
     *
     * When adding a new token entry, the file must be read first, the token
     * added and then saved. An exclusive access to the token file must be held
     * since the initial read in order to avoid lost writes.
     */
    public function saveResetToken($id, $data)
    {
        $fileName = $this->dir . self::RESET_TOKENS;
        $tokens = array();
        $writer = new FileWriter($fileName);

        // Get exclusive access and read the current tokens
        try {
            $writer->open();
            $reader = new \PicoAuth\Storage\File\FileReader(
                $fileName,
                ["handle"=>$writer->getHandle()]
            );
            $yaml = $reader->read();
            $tokens = \Symfony\Component\Yaml\Yaml::parse($yaml);
        } catch (\RuntimeException $e) {
            // File doesn't exist, no write permission, read or parse error
            // If not writeable, will fail in saveResetTokens
            $tokens = array();
        }

        // Add the new token entry
        $tokens[$id] = $data;

        // Save the token file, while keeping the same file lock
        $this->saveResetTokens($tokens, $writer);
    }

    /**
     * Saves reset tokens
     *
     * @param array $tokens Tokens array
     * @param FileWriter $writer Optional file writer to use
     * @throws \RuntimeException On save error
     */
    protected function saveResetTokens($tokens, FileWriter $writer = null)
    {
        // Before saving, remove all expired tokens
        $time = time();
        foreach ($tokens as $id => $token) {
            if ($time > $token['valid']) {
                unset($tokens[$id]);
            }
        }

        $fileName = $this->dir . self::RESET_TOKENS;
        $yaml = \Symfony\Component\Yaml\Yaml::dump($tokens, 1, 2);

        if ($writer && $writer->isOpened()) {
            // An exclusive lock is already held, then use the given writer instance
            $writer->write($yaml); // Will throw on write error
        } else {
            self::preparePath($this->dir, dirname(self::RESET_TOKENS));
            if ((self::writeFile($fileName, $yaml) === false)) {
                throw new \RuntimeException("Unable to save token file (".self::RESET_TOKENS.").");
            }
        }
    }

    /**
     * @inheritdoc
     */
    public function getResetToken($id)
    {
        $fileName = $this->dir . self::RESET_TOKENS;
        $tokens = array();

        if (($yaml = self::readFile($fileName)) !== false) {
            $tokens = \Symfony\Component\Yaml\Yaml::parse($yaml);
        }

        if (isset($tokens[$id])) {
            $token = $tokens[$id];
            unset($tokens[$id]);                // Remove the token when retrieved
            $this->saveResetTokens($tokens);
            return $token;
        } else {
            return null;
        }
    }

    /**
     * @inheritdoc
     */
    public function checkValidName($name)
    {
        if (!$this->configurator->checkValidNameFormat($name)) {
            throw new \RuntimeException("A username can contain only alphanumeric characters.");
        }
        return true;
    }

    /**
     * @inheritdoc
     */
    public function getUsersCount()
    {
        if (!isset($this->usersCount)) {
            try {
                $files = $this->getDirFiles($this->dir . self::USERS_DIR);
                $this->usersCount=count($files);
            } catch (\RuntimeException $e) {
                // Return 0 if the users directory cannot be listed (no users yet)
                $this->usersCount=0;
            }
        }

        return $this->usersCount;
    }

    /**
     * Gets directory files
     *
     * @param string $searchDir Searched directory
     * @return array File names
     * @throws \RuntimeException On read error
     */
    protected function getDirFiles($searchDir)
    {
        // Error state is handled by the excpetion, warning disabled
        $files = @scandir($searchDir, SCANDIR_SORT_NONE);
        if ($files === false) {
            throw new \RuntimeException("Cannot list directory contents: {$searchDir}.");
        }

        return array_diff($files, array('..', '.'));
    }
}


<?php
namespace PicoAuth\Module\Authentication\LocalAuth;

use PicoAuth\Module\AbstractAuthModule;
use PicoAuth\Storage\Interfaces\LocalAuthStorageInterface;
use PicoAuth\Security\Password\Password;
use PicoAuth\Security\RateLimiting\RateLimitInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use PicoAuth\User;
use Psr\Log\LoggerAwareInterface;
use PicoAuth\Log\LoggerTrait;
use Symfony\Component\HttpFoundation\Request;

/**
 * Authentication with local user accounts
 */
class LocalAuth extends AbstractAuthModule implements LoggerAwareInterface
{

    use LoggerTrait;

    const LOGIN_CSRF_ACTION = 'login';

    /**
     * Instance of the plugin
     *
     * @var PicoAuthInterface
     */
    protected $picoAuth;

    /**
     * Session manager
     *
     * @var SessionInterface
     */
    protected $session;

    /**
     * Configuration storage
     *
     * @var LocalAuthStorageInterface
     */
    protected $storage;

    /**
     * The Configuration array
     *
     * @var array
     */
    protected $config;

    /**
     * The rate limiter
     *
     * @var RateLimitInterface
     */

    protected $limit;

    public function __construct(
        PicoAuthInterface $picoAuth,
        SessionInterface $session,
        LocalAuthStorageInterface $storage,
        RateLimitInterface $limit
    ) {

        $this->picoAuth = $picoAuth;
        $this->session = $session;
        $this->storage = $storage;
        $this->limit = $limit;

        $this->config = $this->storage->getConfiguration();
    }

    /**
     * @inheritdoc
     */
    public function getName()
    {
        return 'localAuth';
    }

    /**
     * @inheritdoc
     */
    public function onPicoRequest($url, Request $httpRequest)
    {
        switch ($url) {
            case "login":
                $this->handleLogin($httpRequest);
                break;
            case "register":
                $this->handleRegistration($httpRequest);
                break;
            case "account":
                $this->handleAccountPage($httpRequest);
                break;
            case "password_reset":
                $this->handlePasswordReset($httpRequest);
                break;
        }
    }

    /**
     * Get LocalAuth configuration
     *
     * Can be used from twig template when dynamically compositing a login page
     * (e.g. to find out which functions are enabled to show appropriate links)
     *
     * @return array
     */
    public function getConfig()
    {
        return $this->config;
    }

    /**
     * Handles a login submission
     *
     * @param Request $httpRequest Login request
     * @return void
     */
    protected function handleLogin(Request $httpRequest)
    {
        $post = $httpRequest->request;
        if (!$post->has("username") || !$post->has("password")) {
            return;
        }

        //CSRF validation
        if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"), self::LOGIN_CSRF_ACTION)) {
            $this->picoAuth->redirectToLogin(null, $httpRequest);
            return;
        }

        $username = strtolower(trim($post->get("username")));
        $password = new Password($post->get("password"));

        //Check if the login action is not rate limited
        if (!$this->limit->action("login", false, array("name" => $username))) {
            $this->session->addFlash("error", $this->limit->getError());
            $this->picoAuth->redirectToLogin(null, $httpRequest);
            return;
        }

        if (!$this->loginAttempt($username, $password)) {
            $this->logInvalidLoginAttempt($username);
            $this->limit->action("login", true, array("name" => $username));
            $this->session->addFlash("error", "Invalid username or password");
            $this->picoAuth->redirectToLogin(null, $httpRequest);
            return;
        } else {
            $userData = $this->storage->getUserByName($username);
            if ($this->needsPasswordRehash($userData)) {
                $this->passwordRehash($username, $password);
            }
            $this->login($username, $userData);
            $this->picoAuth->afterLogin();
        }
    }

    /**
     * Attempt a login with the specified credentials
     *
     * @param string $username Username
     * @param Password $password Password
     * @return bool Whether login was successful
     */
    public function loginAttempt($username, Password $password)
    {
        $userData = $this->storage->getUserByName($username);
        $encoder = $this->getPasswordEncoder($userData);

        $dummy = bin2hex(\random_bytes(32));
        $dummyHash = $encoder->encode($dummy);

        if (!$userData) {
            // The user doesn't exist, dummy call is performed to prevent time analysis
            $encoder->isValid($dummyHash, $password);
            return false;
        }

        return $encoder->isValid($userData['pwhash'], $password->get());
    }

    /**
     * Logs the user in
     *
     * @param string $id User identifier
     * @param array $userData User data array
     */
    public function login($id, $userData)
    {
        $this->abortIfExpired($id, $userData);

        $u = new User();
        $u->setAuthenticated(true);
        $u->setAuthenticator($this->getName());
        $u->setId($id);

        if (isset($userData['groups'])) {
            $u->setGroups($userData['groups']);
        }
        if (isset($userData['displayName'])) {
            $u->setDisplayName($userData['displayName']);
        }
        if (isset($userData['attributes'])) {
            foreach ($userData['attributes'] as $key => $value) {
                $u->setAttribute($key, $value);
            }
        }
        $this->picoAuth->setUser($u);
    }

    /**
     * Aborts the current request if a password reset is required
     *
     * Starts a password reset session and redirects to the
     * password reset form.
     *
     * @param string $id User identifier
     * @param array $userData User data array
     */
    protected function abortIfExpired($id, $userData)
    {
        if (isset($userData['pwreset']) && $userData['pwreset']) {
            $this->session->addFlash("error", "Please set a new password.");
            $this->picoAuth->getContainer()->get('PasswordReset')->startPasswordResetSession($id);
            $this->picoAuth->redirectToPage("password_reset");
        }
    }

    /**
     * Returns encoder instance for the specified user
     *
     * If the user data array is not specified, returns the default
     * encoder instance.
     *
     * @param null|array $userData User data array
     * @return \PicoAuth\Security\Password\Encoder\PasswordEncoderInterface
     * @throws \RuntimeException If the encoder is not resolvable
     */
    protected function getPasswordEncoder($userData = null)
    {
        if (isset($userData['encoder']) && is_string($userData['encoder'])) {
            $name = $userData['encoder'];
        } else {
            $name = $this->config["encoder"];
        }

        $container = $this->picoAuth->getContainer();

        if (!$container->has($name)) {
            throw new \RuntimeException("Specified LocalAuth encoder is not resolvable.");
        }

        return $container->get($name);
    }

    /**
     * Fills user-data array with the encoded password
     *
     * @param array $userData User data array
     * @param Password $newPassword The password to be encoded
     */
    public function userDataEncodePassword(&$userData, Password $newPassword)
    {
        $encoderName = $this->config["encoder"];
        $encoder = $this->picoAuth->getContainer()->get($encoderName);

        $userData['pwhash'] = $encoder->encode($newPassword->get());
        $userData['encoder'] = $encoderName;
        if (isset($userData['pwreset'])) {
            unset($userData['pwreset']);
        }
    }

    /**
     * Validates the password policy constraints against the supplied password
     *
     * Additionally, a maximum length constraint is added based on the limitations
     * of the current password encoder used for storage.
     *
     * @param Password $password Password string to be checked
     * @return boolean true - passed, false otherwise
     */
    public function checkPasswordPolicy(Password $password)
    {
        $result = true;
        $policy = $this->picoAuth->getContainer()->get("PasswordPolicy");
        $maxAllowedLen = $this->getPasswordEncoder()->getMaxAllowedLen();
        if (is_int($maxAllowedLen) && strlen($password)>$maxAllowedLen) {
            $this->session->addFlash("error", "Maximum length is {$maxAllowedLen}.");
            $result = false;
        }
        if (!$policy->check($password)) {
            $errors = $policy->getErrors();
            foreach ($errors as $error) {
                $this->session->addFlash("error", $error);
            }
            return false;
        }

        return $result;
    }

    /**
     * Returns whether a rehash is needed
     *
     * @param array $userData User data array
     * @return boolean true if needed, false otherwise
     */
    protected function needsPasswordRehash(array $userData)
    {
        // Return if password rehashing is not enabled
        if ($this->config["login"]["passwordRehash"] !== true) {
            return false;
        }

        // Password hash is created using a different algorithm than default
        if (isset($userData['encoder']) && $userData['encoder'] !== $this->config["encoder"]) {
            return true;
        }
        $encoder = $this->getPasswordEncoder($userData);

        // If password hash algorithm options have changed
        return $encoder->needsRehash($userData['pwhash']);
    }

    /**
     * Performs a password rehash
     *
     * @param string $username User identifier
     * @param Password $password The password to be resaved
     */
    protected function passwordRehash($username, Password $password)
    {
        $userData = $this->storage->getUserByName($username);

        try {
            $this->userDataEncodePassword($userData, $password);
        } catch (\PicoAuth\Security\Password\Encoder\EncoderException $e) {
            // The encoder was changed to one that is not able to store this password
            $this->session->addFlash("error", "Please set a new password.");
            $this->picoAuth->getContainer()->get('PasswordReset')->startPasswordResetSession($username);
            $this->picoAuth->redirectToPage("password_reset");
        }

        $this->storage->saveUser($username, $userData);
    }

    /**
     * Checks username validity
     *
     * @param string $name Username to be checked
     * @return boolean true if the username is valid, false otherwise
     */
    protected function isValidUsername($name)
    {
        if (!is_string($name)
            || !$this->storage->checkValidName($name)
            || strlen($name) < $this->config["registration"]["nameLenMin"]
            || strlen($name) > $this->config["registration"]["nameLenMax"]
        ) {
            return false;
        }
        return true;
    }

    /**
     * Logs an invalid login attempt
     *
     * @param array $name Username the attempt was for
     */
    protected function logInvalidLoginAttempt($name)
    {
        // Trim logged name to the maximum allowed length
        $max = $this->config["registration"]["nameLenMax"];
        if (strlen($name)>$max) {
            $max = substr($name, 0, $max) . " (trimmed)";
        }
        $this->getLogger()->notice(
            "Invalid login attempt for {name} by {addr}",
            array(
                "name" => $name,
                "addr" => $_SERVER['REMOTE_ADDR'],
            )
        );
    }

    /**
     * On account page request
     *
     * @param Request $httpRequest
     * @return void
     */
    protected function handleAccountPage(Request $httpRequest)
    {
        $user = $this->picoAuth->getUser();
        if (!$user->getAuthenticated()) {
            $this->session->addFlash("error", "Login to access this page.");
            $this->picoAuth->redirectToLogin();
            return;
        }

        // Page for password editing available only to local accounts
        if ($user->getAuthenticator() !== $this->getName()) {
            $this->picoAuth->redirectToPage("index");
            return;
        }

        $editAccount = $this->picoAuth->getContainer()->get('EditAccount');
        $editAccount->setConfig($this->config)
            ->handleAccountPage($httpRequest);
    }

    /**
     * On registration request
     *
     * @param Request $httpRequest
     */
    protected function handleRegistration(Request $httpRequest)
    {
        $registration = $this->picoAuth->getContainer()->get('Registration');
        $registration->setConfig($this->config)
            ->handleRegistration($httpRequest);
    }

    /**
     * On password reset request
     *
     * @param Request $httpRequest
     */
    protected function handlePasswordReset(Request $httpRequest)
    {
        $passwordReset = $this->picoAuth->getContainer()->get('PasswordReset');
        $passwordReset->setConfig($this->config)
            ->handlePasswordReset($httpRequest);
    }
}


<?php

namespace PicoAuth\Cache;

use Psr\SimpleCache\CacheInterface;

/**
 * NullCache to be used when a real instance is not injected.
 * Makes null checking unnecessary on places when a cache is utilized.
 */
class NullCache implements CacheInterface
{

    /**
     * {@inheritdoc}
     */
    public function clear()
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function delete($key)
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function deleteMultiple($keys)
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function get($key, $default = null)
    {
        return $default;
    }

    /**
     * {@inheritdoc}
     */
    public function getMultiple($keys, $default = null)
    {
        $result = [];
        foreach ($keys as $key) {
            $result[$key] = $default;
        }
        return $result;
    }

    /**
     * {@inheritdoc}
     */
    public function has($key)
    {
        return false;
    }

    /**
     * {@inheritdoc}
     */
    public function set($key, $value, $ttl = null)
    {
        return false;
    }

    /**
     * {@inheritdoc}
     */
    public function setMultiple($values, $ttl = null)
    {
        return false;
    }
}