<?php
namespace PicoAuth\Module\Authentication;

use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Tool\ArrayAccessorTrait;
use League\OAuth2\Client\Provider\AbstractProvider;
use PicoAuth\Log\LoggerTrait;
use PicoAuth\Module\AbstractAuthModule;
use PicoAuth\Storage\Interfaces\OAuthStorageInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use PicoAuth\User;
use PicoAuth\Utils;
use Psr\Log\LoggerAwareInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * OAuth 2.0 authentication
 *
 * Uses authorization code grant
 */
class OAuth extends AbstractAuthModule implements LoggerAwareInterface
{

    use LoggerTrait;
    use ArrayAccessorTrait;

    const LOGIN_CSRF_ACTION = 'OAuth';

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

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

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

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

    /**
     * OAuth 2.0 provider used
     *
     * @var AbstractProvider
     */
    protected $provider;

    /**
     * Configuration array for the provider
     *
     * @var array
     */
    protected $providerConfig;

    public function __construct(
        PicoAuthInterface $picoAuth,
        SessionInterface $session,
        OAuthStorageInterface $storage
    ) {

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

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

    /**
     * Can be used from twig template when dynamically compositing a login page
     * @return OAuthStorageInterface
     */
    public function getStorage()
    {
        return $this->storage;
    }

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

        //SSO login submission
        if ($url === "login" && $post->has("oauth")) {
            //CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"), self::LOGIN_CSRF_ACTION)) {
                $this->picoAuth->redirectToLogin(null, $httpRequest);
            }

            $provider = $post->get("oauth");

            // Find the requested provider (case sensitive)
            $providerConfig = $this->storage->getProviderByName($provider);
            if (!$providerConfig) {
                $this->session->addFlash("error", "Requested provider is not available.");
                $this->picoAuth->redirectToLogin(null, $httpRequest);
            }

            $this->initProvider($providerConfig);

            $this->session->set("provider", $provider);

            $this->saveAfterLogin($httpRequest);

            // Starts with retreiving the OAuth authorization code
            $this->startAuthentication();
        } elseif ($url === $this->config["callbackPage"] && $this->isValidCallback($httpRequest)) {
            // Request on the SSO endpoint
            $provider = $this->session->get("provider");
            $this->session->remove("provider");
            $providerConfig = $this->storage->getProviderByName($provider);
            if (!$providerConfig) {
                $this->session->remove("oauth2state");
                throw new \RuntimeException("Provider removed during auth process.");
            }
            $this->initProvider($providerConfig);
            $this->finishAuthentication($httpRequest);
        }
    }

    /**
     * Initializes an instance of the provider from the configuration
     *
     * @param array $providerConfig Configuration array of the selected provider
     * @throws \RuntimeException If the provider is not resolvable
     */
    protected function initProvider($providerConfig)
    {
        $providerClass = $providerConfig['provider'];
        $options = $providerConfig['options'];

        if (!isset($options['redirectUri'])) {
            // Set OAuth 2.0 callback page from the configuration
            $options['redirectUri'] = $this->picoAuth->getPico()->getPageUrl($this->config["callbackPage"]);
        }

        if (!class_exists($providerClass)) {
            throw new \RuntimeException("Provider class $providerClass does not exist.");
        }

        if (!is_subclass_of($providerClass, AbstractProvider::class, true)) {
            throw new \RuntimeException("Class $providerClass is not a League\OAuth2 provider.");
        }

        $this->provider = new $providerClass($options);
        $this->providerConfig = $providerConfig;
    }

    /**
     * Starts the OAuth 2.0 process
     *
     * Redirects to the authorization URL of the selected provider
     */
    protected function startAuthentication()
    {
        $authorizationUrl = $this->provider->getAuthorizationUrl();
        $this->session->migrate(true);
        $this->session->set("oauth2state", $this->provider->getState());

        // The final redirect, halts the script
        $this->picoAuth->redirectToPage($authorizationUrl, null, false);
    }

    /**
     * Finishes the OAuth 2.0 process
     *
     * Handles the authorization code response to the authorization request
     * Expects that the Request contains a valid callback, which should
     * be checked by {@see OAuth::isValidCallback()}.
     *
     * @param Request $httpRequest
     */
    protected function finishAuthentication(Request $httpRequest)
    {
        $sessionCode = $this->session->get("oauth2state");
        $this->session->remove("oauth2state");

        // Check that the state from OAuth response matches the one in the session
        if ($httpRequest->query->get("state") !== $sessionCode) {
            $this->onStateMismatch();
        }

        // Returns one of https://tools.ietf.org/html/rfc6749#section-4.1.2.1
        if ($httpRequest->query->has("error")) {
            $this->onOAuthError($httpRequest->query->get("error"));
        }

        // Error not set, but code not present (not an RFC complaint response)
        if (!$httpRequest->query->has("code")) {
            $this->onOAuthError("no_code");
        }

        try {
            $accessToken = $this->provider->getAccessToken('authorization_code', [
                'code' => $httpRequest->query->get("code"),
            ]);

            $resourceOwner = $this->provider->getResourceOwner($accessToken);

            $this->saveLoginInfo($resourceOwner);
        } catch (IdentityProviderException $e) {
            $this->onOauthResourceError($e);
        }
    }

    /**
     * Gets an attribute from the resource owner
     *
     * @param string $name Attribute name
     * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner Resource owner instance
     * @return mixed The retrieved value
     */
    protected function getResourceAttribute($name, $resourceOwner)
    {
        // Call resource owner getter first
        $method = "get" . $name;

        if (is_callable(array($resourceOwner, $method))) {
            $res = $resourceOwner->$method();
            return $res;
        } else {
            $resourceArray = $resourceOwner->toArray();
            $res = $this->getValueByKey($resourceArray, $name);
            return $res;
        }
    }

    /**
     * Saves the information from the ResourceOwner
     *
     * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner
     */
    protected function saveLoginInfo($resourceOwner)
    {
        // Initialize the user
        $u = new User();
        $u->setAuthenticated(true);
        $u->setAuthenticator($this->getName());

        // Get user id from the Resource Owner
        $attrMap = $this->providerConfig['attributeMap'];
        $userIdAttr = $attrMap['userId'];
        $userId = $this->getResourceAttribute($userIdAttr, $resourceOwner);
        $u->setId($userId);
        unset($attrMap['userId']);

        // Get display name from the Resource Owner (if configured)
        if (isset($attrMap['displayName'])) {
            $name = $this->getResourceAttribute($attrMap['displayName'], $resourceOwner);
            $u->setDisplayName($name);
            unset($attrMap['displayName']);
        }

        // Retrieve all other custom attributes from the attributeMap
        foreach ($attrMap as $mapKey => $mapValue) {
            $value = $this->getResourceAttribute($mapValue, $resourceOwner);
            $u->setAttribute($mapKey, $value);
        }

        // Set default droups and default attributes
        $u->setGroups($this->providerConfig['default']['groups']);
        foreach ($this->providerConfig['default']['attributes'] as $key => $value) {
            if (null === $u->getAttribute($key)) {
                $u->setAttribute($key, $value);
            }
        }

        $this->picoAuth->setUser($u);
        $this->picoAuth->afterLogin();
    }

    /**
     * Saves the afterLogin parameter
     *
     * @param Request $httpRequest
     */
    protected function saveAfterLogin(Request $httpRequest)
    {
        $referer = $httpRequest->headers->get("referer", null, true);
        $afterLogin = Utils::getRefererQueryParam($referer, "afterLogin");
        if ($afterLogin && Utils::isValidPageId($afterLogin)) {
            $this->session->set("afterLogin", $afterLogin);
        }
    }

    /**
     * Checks if the request and session have all the required fields for a OAuth 2.0 callback
     *
     * - The session must have "provider" name which is the provider the callback
     *   would be returned from.
     * - The request must have "state" query param as a CSRF prevention.
     * - The session must have "oauth2state" which must be a string
     *   a must be non-empty.
     *
     * @param Request $httpRequest
     * @return bool true if the required fields are present, false otherwise
     */
    protected function isValidCallback(Request $httpRequest)
    {
        return $this->session->has("provider")
            && $httpRequest->query->has("state")
            && $this->session->has("oauth2state")
            && is_string($this->session->get("oauth2state"))
            && (strlen($this->session->get("oauth2state")) > 0);
    }

    /**
     * Logs invalid state in the OAuth 2.0 response
     */
    protected function onStateMismatch()
    {
        $this->logger->warning(
            "OAuth2 response state mismatch: provider: {provider} from {addr}",
            array(
                "provider" => get_class($this->provider),
                "addr" => $_SERVER['REMOTE_ADDR']
            )
        );
        $this->session->remove("oauth2state");
        $this->session->addFlash("error", "Invalid OAuth response.");
        $this->picoAuth->redirectToLogin();
    }

    /**
     * On an OAuth error
     *
     * @param string $errorCode
     */
    protected function onOAuthError($errorCode)
    {
        $errorCode = strlen($errorCode > 100) ? substr($errorCode, 0, 100) : $errorCode;

        $this->logger->notice(
            "OAuth2 error response: code {code}, provider {provider}",
            array(
                "code" => $errorCode,
                "provider" => get_class($this->provider),
            )
        );

        $this->session->addFlash("error", "The provider returned an error ($errorCode)");
        $this->picoAuth->redirectToLogin();
    }

    /**
     * On a Resource owner error
     *
     * @param IdentityProviderException $e
     */
    protected function onOauthResourceError(IdentityProviderException $e)
    {
        $this->logger->critical(
            "OAuth2 IdentityProviderException: {e}, provider {provider}",
            array(
                "e" => $e->getMessage(),
                "provider" => get_class($this->provider),
            )
        );

        $this->session->addFlash("error", "Failed to get an access token or user details.");
        $this->picoAuth->redirectToLogin();
    }
}


<?php

namespace PicoAuth\Storage\File;

/**
 * File
 *
 * Provides file locking methods and other generic methods over an opened file
 */
class File
{

    /**
     * Number of maximum repeat attempts to lock a file
     */
    const LOCK_MAX_TRIES = 100;

    /**
     * Microseconds to wait before next lock try
     */
    const LOCK_RETRY_WAIT = 20;

    /**
     * File resource
     * @var mixed
     */
    protected $handle;

    /**
     * File location
     * @var string
     */
    protected $filePath;

    /**
     * Options
     * @var array
     */
    protected $options;

    /**
     * Creates a file instance
     *
     * @param string $path File path
     * @param array $options Options array
     */
    public function __construct($path, $options = [])
    {
        $this->filePath = $path;
        $this->options = $options;

        // Apply default options
        $this->options += array(
            "blocking" => true,
            "backup" => false
        );

        // If an opened file is specified
        if (isset($this->options["handle"])) {
            $this->handle = $this->options["handle"];
            unset($this->options["handle"]);
            if (!$this->isOpened()) {
                throw new \InvalidArgumentException("The file must be opened.");
            }
        }
    }

    /**
     * Checks if a file is opened
     *
     * @return bool
     */
    public function isOpened()
    {
        return is_resource($this->handle);
    }

    /**
     * Obtains a file lock
     *
     * @param int $lockType Lock type PHP constant
     * @return boolean true on successful lock, false otherwise
     */
    public function lock($lockType)
    {
        if (!$this->isOpened()) {
            return false;
        }

        if ($this->options["blocking"]) {
            return flock($this->handle, $lockType);
        } else {
            $tries = 0;
            do {
                if (flock($this->handle, $lockType | LOCK_NB)) {
                    return true;
                } else {
                    ++$tries;
                    usleep(self::LOCK_RETRY_WAIT);
                }
            } while ($tries < self::LOCK_MAX_TRIES);

            return false;
        }
    }

    /**
     * Unlocks the file
     *
     * @throws \RuntimeException
     */
    public function unlock()
    {
        if (!flock($this->handle, LOCK_UN)) {
            throw new \RuntimeException("Could not unlock file");
        }
    }

    /**
     * Closes the file
     *
     * @return void
     * @throws \RuntimeException
     */
    public function close()
    {
        if (!$this->isOpened()) {
            return;
        }

        $this->unlock();

        if ($this->handle && !fclose($this->handle)) {
            throw new \RuntimeException("Could not close file " . $this->filePath);
        }
    }

    /**
     * Returns the file handle
     *
     * @return resource|null
     */
    public function getHandle()
    {
        return ($this->isOpened()) ? $this->handle : null;
    }
}


<?php

namespace PicoAuth\Storage\File;

/**
 * File reader
 *
 * Respects the locks while reading
 */
class FileReader extends File
{

    const OPEN_MODE = 'r';

    /**
     * Opens the file for reading
     *
     * Obtains a shared lock for the file
     * @return void
     * @throws \RuntimeException On read/lock error
     */
    public function open()
    {
        if ($this->isOpened()) {
            return;
        }

        if (!file_exists($this->filePath)) {
            throw new \RuntimeException($this->filePath . " does not exist");
        }

        $this->handle = @fopen($this->filePath, self::OPEN_MODE);
        if ($this->handle === false) {
            throw new \RuntimeException("Could not open file for reading: " . $this->filePath);
        }

        if (!$this->lock(LOCK_SH)) {
            $this->close();
            throw new \RuntimeException("Could not aquire a shared lock for " . $this->filePath);
        }
    }

    /**
     * Reads contents of the opened file
     *
     * @return string Read data
     * @throws \RuntimeException On read error
     */
    public function read()
    {
        $this->open();

        // Better performance than fread() from the existing handle
        // but it doesn't respect flock
        $data = file_get_contents($this->filePath);

        if ($data === false) {
            throw new \RuntimeException("Could not read from file " . $this->filePath);
        }
        return $data;
    }
}


<?php

namespace PicoAuth\Module\Authorization;

use PicoAuth\Storage\Interfaces\PageLockStorageInterface;
use PicoAuth\Security\RateLimiting\RateLimitInterface;
use PicoAuth\Session\SessionInterface;
use Symfony\Component\HttpFoundation\Request;
use PicoAuth\PicoAuthInterface;
use PicoAuth\Module\AbstractAuthModule;

/**
 * Basic authorization by page locking
 */
class PageLock extends AbstractAuthModule
{

    const UNLOCK_CSRF_ACTION = 'unlock';

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

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

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

    /**
     * The rate limiter
     *
     * @var RateLimitInterface
     */
    protected $limit;

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

    public function __construct(
        PicoAuthInterface $picoAuth,
        SessionInterface $session,
        PageLockStorageInterface $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 'pageLock';
    }

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

        if ($post->has("page-key")) {
            // CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"), self::UNLOCK_CSRF_ACTION)) {
                $this->picoAuth->redirectToPage($url);
            }

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

            $pageKey = $post->get("page-key");

            $lockId = $this->storage->getLockByURL($url);
            $lockData = $this->storage->getLockById($lockId);

            $keyEncoder = $this->getKeyEncoder($lockData);
            if ($keyEncoder->isValid($lockData["key"], $pageKey)) {
                $unlocked = $this->session->get("unlocked", []);
                $unlocked[] = $lockId;
                $this->session->migrate(true);
                $this->session->set("unlocked", $unlocked);
            } else {
                $this->session->addFlash("error", "The specified key is invalid");
                $this->limit->action("pageLock", true);
            }
            $this->picoAuth->redirectToPage($url);
        }

        // Option to lock the unlocked pages, independent from the plugin's Logout
        // (authenticated user stays in the session and must use Logout to destroy session)
        if ($post->has("logout-locks") && $this->picoAuth->isValidCSRF($post->get("csrf_token"))) {
            $this->session->migrate(true);
            $this->session->set("unlocked", []);
        }

        // Add unlocked locks to the output, so the theme can show "Close session"
        // if any locks are currently opened, that is optional
        $this->picoAuth->addOutput("locks", $this->session->get("unlocked", []));
    }

    /**
     * @inheritdoc
     */
    public function checkAccess($url)
    {
        $lockId = $this->storage->getLockByURL($url);
        if ($lockId) {
            return $this->isUnlocked($lockId);
        } else {
            return true;
        }
    }

    /**
     * @inheritdoc
     */
    public function denyAccessIfRestricted($url)
    {
        $lockId = $this->storage->getLockByURL($url);
        if ($lockId && !$this->isUnlocked($lockId)) {
            $lockData = $this->storage->getLockById($lockId);
            $this->picoAuth->addOutput("unlock_action", $this->picoAuth->getPico()->getPageUrl($url));
            if (isset($lockData['file'])) {
                $contentDir = $this->picoAuth->getPico()->getConfig('content_dir');
                $this->picoAuth->setRequestFile($contentDir . $lockData['file']);
            } else {
                $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/locked.md');
            }
            header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
        }
    }

    /**
     * Returns whether the lock is unlocked
     *
     * @param string $lockId Lock identifier
     * @return boolean true if unlocked, false otherwise
     */
    protected function isUnlocked($lockId)
    {
        $unlocked = $this->session->get("unlocked");
        if ($unlocked && in_array($lockId, $unlocked)) {
            return true;
        }

        return false;
    }

    /**
     * Returns encoder instance for the specified lock
     *
     * @param array $lockData Lock data array
     * @return \PicoAuth\Security\Password\Encoder\PasswordEncoderInterface
     * @throws \RuntimeException If the encoder is not resolvable
     */
    protected function getKeyEncoder($lockData)
    {
        if (isset($lockData['encoder']) && is_string($lockData['encoder'])) {
            $name = $lockData['encoder'];
        } else {
            $name = $this->config["encoder"];
        }

        try {
            $instance = $this->picoAuth->getContainer()->get($name);
        } catch (\Exception $e) {
            throw new \RuntimeException("Specified PageLock encoder not resolvable.");
        }

        return $instance;
    }
}


<?php

namespace PicoAuth\Storage\Configurator;

use PicoAuth\Storage\Configurator\AbstractConfigurator;
use PicoAuth\Storage\Configurator\ConfigurationException;

/**
 * The configurator for LocalAuth
 */
class LocalAuthConfigurator extends AbstractConfigurator
{

    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault());
        $this->validateGlobalOptions($config);
        $this->validateLoginSection($config);
        $this->validateAccountEditSection($config);
        $this->validatePasswordResetSection($config);
        $this->validateRegistrationSection($config);
        $this->validateUsersSection($config);
        return $config;
    }

    /**
     * The default configuration must contain all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "encoder" => "bcrypt",
            "login" => array(
                "passwordRehash" => false
            ),
            "accountEdit" => array(
                "enabled" => false,
            ),
            "passwordReset" => array(
                "enabled" => false,
                "emailMessage" => "Hello,\n\nVisit the link to reset your password "
                                    . "to %site_title%.\n\nReset URL:\n%url%",
                "emailSubject" => "%site_title% - Password Reset",
                "tokenIdLen" => 10,
                "tokenLen" => 50,
                "tokenValidity" => 7200,
                "resetTimeout" => 1800,
            ),
            "registration" => array(
                "enabled" => false,
                "maxUsers" => 10000,
                "nameLenMin" => 3,
                "nameLenMax" => 20,
            ),
        );
    }

    public function validateGlobalOptions($config)
    {
        $this->assertString($config, "encoder");
    }

    public function validateLoginSection($config)
    {
        $section = $config["login"];
        $this->assertBool($section, "passwordRehash");
    }

    public function validateAccountEditSection($config)
    {
        $section = $config["accountEdit"];
        $this->assertBool($section, "enabled");
    }

    public function validatePasswordResetSection($config)
    {
        $section = $config["passwordReset"];
        $this->assertBool($section, "enabled")
            ->assertStringContaining($section, "emailMessage", "%url%")
            ->assertString($section, "emailSubject")
            ->assertInteger($section, "tokenIdLen", 4, 128)
            ->assertInteger($section, "tokenLen", 4, 1024)
            ->assertInteger($section, "tokenValidity", 60)
            ->assertInteger($section, "resetTimeout", 60);
    }

    public function validateRegistrationSection($config)
    {
        $section = $config["registration"];
        $this->assertBool($section, "enabled")
            ->assertInteger($section, "maxUsers", 0)
            ->assertInteger($section, "nameLenMin", 1)
            ->assertInteger($section, "nameLenMax", 1, 200)
            ->assertGreaterThan($section, "nameLenMax", "nameLenMin");
    }

    /**
     * Validates the users section of the configuration
     *
     * May have side effects in the configuration array,
     * if some usernames are not defined in lowercase
     *
     * @param array $config Configuration reference
     * @throws ConfigurationException On a failed assertation
     */
    public function validateUsersSection(&$config)
    {
        if (!isset($config["users"])) {
            // No users are specified in the configuration file
            return;
        }
        $this->assertArray($config, "users");

        foreach ($config["users"] as $username => $userData) {
            $this->assertUsername($username, $config);
            try {
                $this->validateUserData($userData);
            } catch (ConfigurationException $e) {
                $e->addBeforeMessage("Invalid userdata for $username:");
                throw $e;
            }

            // Assure case insensitivity of username indexing
            $lowercaseName = strtolower($username);
            if ($username !== $lowercaseName) {
                if (!isset($config["users"][$lowercaseName])) {
                    $config["users"][$lowercaseName] = $userData;
                    unset($config["users"][$username]);
                } else {
                    throw new ConfigurationException("User $username is defined multiple times.");
                }
            }
        }
    }

    /**
     * Validates all user parameters
     *
     * @param array $userData Userdata array
     * @throws ConfigurationException On a failed assertation
     */
    public function validateUserData($userData)
    {
        $this->assertRequired($userData, "pwhash");
        $this->assertString($userData, "pwhash");

        // All remaining options are optional
        $this->assertString($userData, "email");
        $this->assertArray($userData, "attributes");
        $this->assertString($userData, "encoder");
        $this->assertBool($userData, "pwreset");
        $this->assertArrayOfStrings($userData, "groups");
        $this->assertString($userData, "displayName");
    }

    /**
     * Asserts a valid username format
     *
     * @param string $username Username being checked
     * @param array $config The configuration array
     * @throws ConfigurationException On a failed assertation
     */
    public function assertUsername($username, $config)
    {
        if (!is_string($username)) {
            throw new ConfigurationException("Username $username must be a string.");
        }
        $len = strlen($username);
        $minLen=$config["registration"]["nameLenMin"];
        $maxLen=$config["registration"]["nameLenMax"];
        if ($len < $minLen || $len > $maxLen) {
            throw new ConfigurationException(
                sprintf("Length of a username $username must be between %d-%d characters.", $minLen, $maxLen)
            );
        }
        if (!$this->checkValidNameFormat($username)) {
            throw new ConfigurationException("Username $username contains invalid character/s.");
        }
    }

    /**
     * Checks a valid username format
     *
     * @param string $name file name to be checked
     * @return bool true if the name format is accepted
     */
    public function checkValidNameFormat($name)
    {
        return preg_match('/^[a-z0-9_\-]+$/i', $name) === 1;
    }
}


<?php

namespace PicoAuth;

use Symfony\Component\HttpFoundation\Request;
use PicoAuth\Storage\Configurator\PluginConfigurator;
use PicoAuth\Security\CSRF;
use PicoAuth\PicoAuthInterface;
use PicoAuth\User;
use PicoAuth\Utils;

/**
 * The main PicoAuth plugin class
 *
 * @author  Pavel Tuma
 * @link    https://github.com/picoauth/picoauth
 * @license http://opensource.org/licenses/MIT The MIT License
 * @version 1.0
 */
class PicoAuthPlugin implements PicoAuthInterface
{

    /**
     * PicoAuth plugin version
     * @var string
     */
    const VERSION = '1.0.0';

    /**
     * PicoAuth version ID
     * @var int
     */
    const VERSION_ID = 10000;

    /**
     * The name of the plugin
     * @var string
     */
    const PLUGIN_NAME = 'PicoAuth';

    /**
     * Action name for the logout action
     * @var string
     */
    const LOGOUT_CSRF_ACTION = 'logout';

    /**
     * Session manager for the plugin
     * @var Session\SessionInterface
     */
    protected $session;

    /**
     * CSRF Token manager
     * @var CSRF
     */
    protected $csrf;

    /**
     * Dependency container of the plugin
     * @var \League\Container\Container
     */
    protected $container;

    /**
     * Optional instance of a logger
     * @see PicoAuth::initLogger()
     * @var \Psr\Log\LoggerInterface
     */
    protected $logger;

    /**
     * The current user
     * @see PicoAuthPlugin::getUser()
     * @see PicoAuthPlugin::getUserFromSession()
     * @var User
     */
    protected $user;

    /**
     * The current request
     * @var \Symfony\Component\HttpFoundation\Request
     */
    protected $request;

    /**
     * Pico page id of the current request
     * @var string
     */
    protected $requestUrl;

    /**
     * Requested .md file that will be displayed
     * @see PicoAuthPlugin::onRequestFile()
     * @var null|string
     */
    protected $requestFile = null;

    /**
     * Plugin's configuration from Pico's main configuration file
     * @var array
     */
    protected $config;

    /**
     * Loaded PicoAuth modules
     * @var Module\AbstractAuthModule[]
     */
    protected $modules = array();

    /**
     * Output variables that will be accessible in Twig
     * @var array
     */
    protected $output = array();

    /**
     * Error flag, if true the plugin will display an error page
     * @see PicoAuthPlugin::errorHandler()
     * @var bool
     */
    protected $errorOccurred = false;

    /**
     * Full path of the location of PicoAuth plugin
     * @var string
     */
    protected $pluginDir;

    /**
     * PicoCMS instance
     * @var \Pico
     */
    protected $pico;

    /**
     * Always allowed routes
     * @see PicoAuthPlugin::addAllowed()
     * @var string[]
     */
    protected $alwaysAllowed = ["login", "logout"];

    /**
     * Creates the plugin
     * @param \Pico $pico PicoCMS instance
     */
    public function __construct(\Pico $pico)
    {
        $this->pico = $pico;
        $this->pluginDir = dirname(__DIR__);
    }

    /**
     * Handles an event that was triggered by Pico
     *
     * @param string $eventName Name of the Pico event
     * @param array $params Event parameters
     */
    public function handleEvent($eventName, array $params)
    {
        if (method_exists($this, $eventName)) {
            call_user_func_array(array($this, $eventName), $params);
        }
    }

    /**
     * Triggers an auth event for all enabled Auth modules
     *
     * @param string $eventName
     * @param array $params
     */
    public function triggerEvent($eventName, array $params = array())
    {
        foreach ($this->modules as $module) {
            $module->handleEvent($eventName, $params);
        }
    }

    //-- Pico API registered methods  ------------------------------------------

    /**
     * Pico API event - onConfigLoaded
     *
     * Validates plugin's configuration from the Pico's configuration file,
     * fills it with default values, saves it to the config property.
     * Initializes the plugin's dependency container.
     *
     * @param array $config CMS configuration
     */
    public function onConfigLoaded(array &$config)
    {
        $config[self::PLUGIN_NAME] = $this->loadDefaultConfig($config);
        $this->config = $config[self::PLUGIN_NAME];
        $this->createContainer();
        $this->initLogger();
    }

    /**
     * Pico API event - onRequestUrl
     *
     * Runs active authentication and authorization modules.
     *
     * @param string $url Pico page id (e.g. "index" or "sub/page")
     */
    public function onRequestUrl(&$url)
    {
        $this->requestUrl = $url;

        try {
            // Plugin initialization
            $this->init();

            // Check submissions in all modules and apply their routers
            $this->triggerEvent('onPicoRequest', [$url, $this->request]);
        } catch (\Exception $e) {
            $this->errorHandler($e, $url);
        }

        if (!$this->errorOccurred) {
            $this->authRoutes();
        }
    }

    /**
     * Pico API event - onRequestFile
     *
     * The plugin will change the requested file if the requested page
     * is one of the plugin's pages or if it has been set by one of the plugin's
     * modules in {@see PicoAuth::setRequestFile()}.
     *
     * @param string $file Reference to the file name Pico will load.
     */
    public function onRequestFile(&$file)
    {
        // A special case for an error state of the plugin
        if ($this->errorOccurred) {
            $file = $this->requestFile;
            return;
        }

        try {
            // Resolve a normalized version of the url
            $realUrl = ($this->requestFile) ? $this->requestUrl : $this->resolveRealUrl($file);

            // Authorization
            if (!in_array($realUrl, $this->alwaysAllowed, true)) {
                $this->triggerEvent('denyAccessIfRestricted', [$realUrl]);
            }
        } catch (\Exception $e) {
            $realUrl = (isset($realUrl)) ? $realUrl : "";
            $this->errorHandler($e, $realUrl);
        }

        if ($this->requestFile) {
            $file = $this->requestFile;
        } else {
            switch ($this->requestUrl) {
                case 'login':
                    $file = $this->pluginDir . '/content/login.md';
                    break;
                case 'logout':
                    $file = $this->pluginDir . '/content/logout.md';
                    break;
            }
        }
    }

    /**
     * Pico API event - onPagesLoaded
     *
     * Removes pages that should not be displayed in the menus
     * (for example the 403 page) from the Pico's page array.
     * If "alterPageArray" plugin configuration is enabled, the pages with
     * restricted access are also removed from the page array.
     *
     * @param array $pages Pico page array
     */
    public function onPagesLoaded(array &$pages)
    {
        unset($pages["403"]);

        if (!$this->config["alterPageArray"]) {
            return;
        }

        // Erase all pages if an error occurred
        if ($this->errorOccurred) {
            $pages = array();
            return;
        }

        foreach ($pages as $id => $page) {
            try {
                $allowed = $this->checkAccess($id);
            } catch (\Exception $e) {
                $this->errorHandler($e, $this->requestUrl);
                $pages = array();
                return;
            }

            if (!$allowed) {
                unset($pages[$id]);
            }
        }
    }

    /**
     * Pico API event - onTwigRegistered
     *
     * Registers CSRF functions in Pico's Twig environment.
     *
     * @param \Twig_Environment $twig Reference to the twig environment
     */
    public function onTwigRegistered(&$twig)
    {
        // If a theme is not found, it will be searched for in PicoAuth/theme
        $twig->getLoader()->addPath($this->pluginDir . '/theme');

        $this_instance = $this;
        $twig->addFunction(
            new \Twig_SimpleFunction(
                'csrf_token',
                function ($action = null) use (&$this_instance) {
                    return $this_instance->csrf->getToken($action);
                },
                array('is_safe' => array('html'))
            )
        );
        $twig->addFunction(
            new \Twig_SimpleFunction(
                'csrf_field',
                function ($action = null) use (&$this_instance) {
                    return '<input type="hidden" name="csrf_token" value="'
                        . $this_instance->csrf->getToken($action)
                        . '">';
                },
                array('is_safe' => array('html'))
            )
        );
    }

    /**
     * Pico API event
     *
     * Makes certain variables accessible in the twig templates
     *
     * @param string $templateName Name of the twig template file
     * @param array $twigVariables Twig variables
     */
    public function onPageRendering(&$templateName, array &$twigVariables)
    {
        $twigVariables['auth']['plugin'] = $this;
        $twigVariables['auth']['vars'] = $this->output;

        // Variables useful only in successful execution
        if (!$this->errorOccurred) {
            $twigVariables['auth']['user'] = $this->user;

            // Previous form submission
            $old = $this->session->getFlash('old');
            if (count($old) && isset($old[0])) {
                $twigVariables['auth']['old'] = $old[0];
            }
        }
    }

    //-- PicoAuthInterface public methods avaialble to Auth modules ------------

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

    /**
     * {@inheritdoc}
     */
    public function getAuthConfig($key, $default = null)
    {
        return isset($this->config[$key]) ? $this->config[$key] : $default;
    }

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

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

    /**
     * {@inheritdoc}
     */
    public function getDefaultThemeUrl()
    {
        return $this->pico->getBaseUrl() . 'plugins/PicoAuth/theme';
    }

    /**
     * {@inheritdoc}
     */
    public function getModule($name)
    {
        if (isset($this->modules[$name])) {
            return $this->modules[$name];
        }
        return null;
    }

    /**
     * {@inheritdoc}
     */
    public function getFlashes()
    {
        $types = array("error", "success");
        $result = array();
        foreach ($types as $value) {
            $flashesArr = $this->session->getFlash($value);
            if (count($flashesArr)) {
                $result[$value] = $flashesArr;
            }
        }
        return $result;
    }

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

    /**
     * {@inheritdoc}
     */
    public function setUser($user)
    {
        if ($user->getAuthenticated()) {
            $user->addGroup("default");
        }
        $this->user = $user;
    }

    /**
     * {@inheritdoc}
     *
     * Will not take effect if invoked after onRequestFile Pico event.
     * After that, the file is already read by Pico.
     */
    public function setRequestUrl($url)
    {
        $this->requestUrl = $url;
        $this->requestFile = $this->pico->resolveFilePath($url);
    }

    /**
     * {@inheritdoc}
     */
    public function setRequestFile($file)
    {
        $this->requestFile = $file;
    }

    /**
     * {@inheritdoc}
     */
    public function addOutput($key, $value)
    {
        $this->output[$key] = $value;
    }

    /**
     * {@inheritdoc}
     */
    public function addAllowed($url)
    {
        $this->alwaysAllowed[] = $url;
    }

    /**
     * {@inheritdoc}
     */
    public function isValidCSRF($token, $action = null)
    {
        if (!$this->csrf->checkToken($token, $action)) {
            $this->logger->warning(
                "CSRFt mismatch: for {action} from {addr}",
                array(
                    "action" => $action,
                    "addr" => $_SERVER['REMOTE_ADDR']
                )
            );
            $this->session->addFlash("error", "Invalid CSRF token, please try again.");
            return false;
        } else {
            return true;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function afterLogin()
    {
        $this->csrf->removeTokens();

        // Migrate session to a new ID to prevent fixation
        $this->session->migrate(true);

        // Set authentication information
        $this->session->set('user', serialize($this->user));

        $this->logger->info(
            "Login: {id} ({name}) via {method} from {addr}",
            array(
                "id" => $this->user->getId(),
                "name" => $this->user->getDisplayName(),
                "method" => $this->user->getAuthenticator(),
                "addr" => $_SERVER['REMOTE_ADDR']
            )
        );

        $this->triggerEvent("afterLogin", [$this->user]);

        $afterLogin = Utils::getRefererQueryParam($this->request->headers->get("referer"), "afterLogin");
        if ($afterLogin && Utils::isValidPageId($afterLogin)) {
            $this->redirectToPage($afterLogin);
        } elseif ($this->session->has("afterLogin")) {
            $page = $this->session->get("afterLogin");
            $this->session->remove("afterLogin");
            $this->redirectToPage($page);
        }

        //default redirect after login
        $this->redirectToPage($this->config["afterLogin"]);
    }

    /**
     * {@inheritdoc}
     */
    public function redirectToPage($url, $query = null, $picoOnly = true)
    {
        $finalUrl = "/";

        if ($picoOnly) {
            $append = "";

            if ($query) {
                if (!is_string($query)) {
                    throw new \InvalidArgumentException("Query must be a string.");
                }
                $rewrite = $this->getPico()->isUrlRewritingEnabled();
                $urlChar = ($rewrite) ? '?' : '&';
                $append .= $urlChar . $query;
            }
            $finalUrl = $this->pico->getPageUrl($url) . $append;
        } else {
            $finalUrl = $url;
        }

        header('Location: ' . $finalUrl);
        exit();
    }

    /**
     * {@inheritdoc}
     */
    public function redirectToLogin($query = null, $httpRequest = null)
    {
        /* Attempt to extract afterLogin param from the request referer.
         * Login form submissions are sent to /login (without any GET query params)
         * So in case of unsuccessful login attempt the afterLogin information would be lost.
         */
        if ($httpRequest && $httpRequest->headers->has("referer")) {
            $referer = $httpRequest->headers->get("referer");
            $page = Utils::getRefererQueryParam($referer, "afterLogin");
            if (Utils::isValidPageId($page)) {
                $query .= ($query ? '&' : '') . "afterLogin=" . $page;
            }
        }

        $this->redirectToPage("login", $query);
    }

    //-- Protected methods to PicoAuth -----------------------------------------

    /**
     * Resolves a given content file path to the pico url
     *
     * Used for obtaining a unique url for a page file. This is required
     * for authorization as the $url pico provides in onRequest is not unique
     * and can have many equivalents (e.g. /pico/?page vs /pico/?./page).
     * Case sensitivity is the same as returned from realpath().
     *
     * The naming rules follow Pico-defined standards:
     * /content/index.md => ""
     * /content/sub/page.md => "sub/page"
     * /content/sub/index.md => "sub"
     *
     * Example: pico content_dir is /var/pico/content
     *   then file name /var/pico/content/sub/page.md returns sub/page
     *
     * @param string $fileName Pico page file path (from onRequestFile event)
     * @return string Resolved page url
     * @throws \RuntimeException If the filepath cannot be resolved to a url
     */
    protected function resolveRealUrl($fileName)
    {
        $fileNameClean = str_replace("\0", '', $fileName);
        $realPath = realpath($fileNameClean);

        if ($realPath === false) {
            // the page doesn't exist or realpath failed
            return $this->requestUrl;
        }

        // Get Pico content path and file extension
        $contentPath = realpath($this->pico->getConfig('content_dir'));
        $contentExt = $this->pico->getConfig('content_ext');

        if (strpos($realPath, $contentPath) !== 0) {
            // The file is not inside the content path (symbolic link)
            throw new \RuntimeException("The plugin cannot be used with "
                . "symbolic links inside the content directory.");
        }

        // Get a relative path of $realPath from inside the $contentPath and remove an extension
        // len+1 to remove trailing path delimeter, which $contentPath doesn't have
        $name = substr($realPath, strlen($contentPath)+1, -strlen($contentExt));

        // Always use forward slashes
        if (DIRECTORY_SEPARATOR !== '/') {
            $name = str_replace(DIRECTORY_SEPARATOR, '/', $name);
        }

        // If the name ends with "/index", remove it, for the main page returns ""
        if (strlen($name) >= 5 && 0 === substr_compare($name, "index", -5)) {
            $name= rtrim(substr($name, 0, -5), '/');
        }

        return $name;
    }

    /**
     * Initializes the main plugin components
     */
    protected function init()
    {
        $this->loadModules();

        $this->session = $this->container->get('session');
        $this->csrf = new CSRF($this->session);
        $this->user = $this->getUserFromSession();

        $this->request = Request::createFromGlobals();

        // Auto regenerate_id on specified intervals
        $this->sessionTimeoutCheck("sessionInterval", "_migT", false);
        // Enforce absolute maximum duration of a session
        $this->sessionTimeoutCheck("sessionTimeout", "_start", true);
        // Invalidate session if it is idle for too long
        $this->sessionTimeoutCheck("sessionIdle", "_idle", true, true);
    }

    /**
     * Checks the session timeouts
     *
     * Checks multiple session timeouts and applies the appropriate
     * actions, see the usage in {@see PicoAuthPlugin::init()}
     *
     * @param string $configKey The configuration key containing the timeout value
     * @param string $sessKey The session key with the deciding timestamp
     * @param bool $clear If set to true, the session will be destroyed (invalidate)
     *                    If set to false, the session will be migrated (change sessid)
     * @param bool $alwaysUpdate If set to true, the timestamp in the session (under $sessKey)
     *                    will be updated on every call, otherwise only on timeout
     */
    protected function sessionTimeoutCheck($configKey, $sessKey, $clear, $alwaysUpdate = false)
    {
        if ($this->config[$configKey] !== false) {
            $t = time();
            if ($this->session->has($sessKey)) {
                if ($this->session->get($sessKey) < $t - $this->config[$configKey]) {
                    if ($clear) {
                        $this->session->invalidate();
                    } else {
                        $this->session->migrate(true);
                    }
                    $this->session->set($sessKey, $t);
                } elseif ($alwaysUpdate) {
                    $this->session->set($sessKey, $t);
                }
            } else {
                $this->session->set($sessKey, $t);
            }
        }
    }

    /**
     * Applies a default plugin configuration to the Pico config file.
     *
     * @param array $config Configuration of Pico CMS; plugin's specific
     *                      configuration is under PLUGIN_NAME index.
     */
    protected function loadDefaultConfig(array $config)
    {
        $configurator = new PluginConfigurator;
        $validConfig = $configurator->validate(
            isset($config[self::PLUGIN_NAME]) ? $config[self::PLUGIN_NAME] : null
        );

        return $validConfig;
    }

    /**
     * Creates PicoAuth's dependency container instance.
     *
     * The plugin loads a default container definition from src/container.php
     * If there is a container.php in plugin configuration directory it is used
     * instead.
     *
     * @throws \RuntimeException if the user provided invalid container.php
     */
    protected function createContainer()
    {
        $configDir = $this->pico->getConfigDir();
        $userContainer = $configDir . "PicoAuth/container.php";

        // If a user provided own container definiton, it is used
        if (is_file($userContainer) && is_readable($userContainer)) {
            $this->container = include $userContainer;
            if ($this->container === false || !($this->container instanceof \League\Container\Container)) {
                throw new \RuntimeException("The container.php does not return container instance.");
            }
        } else {
            $this->container = include $this->pluginDir . '/src/container.php';
        }

        // Additional container entries
        $this->container->share('configDir', new \League\Container\Argument\RawArgument($configDir));
        $this->container->share('PicoAuth', $this);
        if (!$this->config["rateLimit"]) {
            $this->container->share('RateLimit', \PicoAuth\Security\RateLimiting\NullRateLimit::class);
        }
    }

    /**
     * Logger initialization
     */
    protected function initLogger()
    {
        try {
            $this->logger = $this->container->get("logger");
        } catch (\League\Container\Exception\NotFoundException $e) {
            $this->logger = new \Psr\Log\NullLogger();
        }
    }

    /**
     * Loads plugin's modules defined in the configuration
     *
     * @throws \RuntimeException if one of the modules could not be loaded
     */
    protected function loadModules()
    {
        foreach ($this->config["authModules"] as $name) {
            try {
                $instance = $this->container->get($name);
            } catch (\League\Container\Exception\NotFoundException $e) {
                if (!class_exists($name)) {
                    throw new \RuntimeException("PicoAuth module not found: " . $name);
                }
                $instance = new $name;
            }

            if (!is_subclass_of($instance, Module\AbstractAuthModule::class, false)) {
                throw new \RuntimeException("PicoAuth module class must inherit from AbstractAuthModule.");
            }

            $name = $instance->getName();
            $this->modules[$name] = $instance;
        }
    }

    /**
     * Plugin routes that are always present
     */
    protected function authRoutes()
    {
        switch ($this->requestUrl) {
            case 'login':
                // Redirect already authenticated user visiting login page
                if ($this->user->getAuthenticated()) {
                    $this->redirectToPage($this->config["afterLogin"]);
                }
                break;
            case 'logout':
                // Redirect non authenticated user to login
                if (!$this->user->getAuthenticated()) {
                    $this->redirectToLogin();
                }
                $this->checkLogoutSubmission();
                break;
        }
    }

    /**
     * Checks the current request for logout action
     */
    protected function checkLogoutSubmission()
    {
        $post = $this->request->request;
        if ($post->has("logout")) {
            if (!$this->isValidCSRF($post->get("csrf_token"), self::LOGOUT_CSRF_ACTION)) {
                $this->redirectToPage("logout");
            }
            $this->logout();
        }
    }

    /**
     * Logs out the current user
     */
    protected function logout()
    {
        $oldUser = $this->user;
        $this->user = new User();

        // Removes all session data (and invalidates all CSRF tokens)
        $this->session->invalidate();

        $this->triggerEvent("afterLogout", [$oldUser]);

        // After logout redirect to main page
        // If user was on restricted page, 403 would appear right after logout
        $this->redirectToPage($this->config["afterLogout"]);
    }

    /**
     * Checks access to a given Pico URL
     *
     * @param string $url Pico Page url
     * @return bool True if the url is accessible, false otherwise
     */
    protected function checkAccess($url)
    {
        foreach ($this->modules as $module) {
            if (false === $module->handleEvent('checkAccess', [$url])) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns the current user
     *
     * If there is no user in the session, a blank User instance is returned
     *
     * @return User
     */
    protected function getUserFromSession()
    {
        if (!$this->session->has('user')) {
            return new User();
        } else {
            return unserialize($this->session->get('user'));
        }
    }

    /**
     * Fatal error handler
     *
     * If an uncaught exception raises from the auth module, an error
     * page will be rendered and HTTP 500 code returned.
     * The error details are logged, and if "debug: true" is set in the plugin
     * configuration, the error details are displayed also on the error page.
     *
     * @param \Exception $e Exception from the module
     * @param string $url Pico page id that was being loaded
     */
    protected function errorHandler(\Exception $e, $url = "")
    {
        $this->errorOccurred = true;
        $this->requestFile = $this->pluginDir . '/content/error.md';
        if ($this->config["debug"] === true) {
            $this->addOutput("_exception", (string)$e);
        }
        $this->logger->critical(
            "Exception on url '{url}': {e}",
            array(
                "url" => $url,
                "e" => $e
            )
        );
        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);

        // Change url to prevent other plugins that use url-based routing from
        // changing the request file.
        $this->requestUrl="500";
    }
}


<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Interfaces\RateLimitStorageInterface;
use PicoAuth\Cache\NullCache;
use Psr\SimpleCache\CacheInterface;
use PicoAuth\Storage\Configurator\RateLimitConfigurator;

/**
 * File storage for RateLimit
 */
class RateLimitFileStorage extends FileStorage implements RateLimitStorageInterface
{

    /**
     * Data directory
     */
    const DATA_DIR = 'PicoAuth/data/';

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

    /**
     * Configuration of the limits
     * @var array
     */
    protected $limits = array();

    /**
     * Transaction files
     * @var \PicoAuth\Storage\File\FileReader[]
     */
    protected $tFiles = array();

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

    /**
     * @inheritdoc
     */
    public function getLimitFor($action, $type, $key)
    {
        $id = $this->getId($action, $type);
        $fileName = $this->dir . static::DATA_DIR . $id;

        if (isset($this->tFiles[$id])) {
            $file = $this->tFiles[$id]->read();
            if (!$file) {
                $this->limits[$id] = array();
            } else {
                $this->limits[$id] = unserialize($file);
            }
        } elseif (\file_exists($fileName)) {
            if (($file = self::readFile($fileName)) === false) {
                throw new \RuntimeException("Unable to read limit file.");
            }
            $this->limits[$id] = unserialize($file);
        } else {
            $this->limits[$id] = array();
        }

        return (isset($this->limits[$id][$key])) ? $this->limits[$id][$key] : null;
    }

    /**
     * @inheritdoc
     */
    public function updateLimitFor($action, $type, $key, $limitData)
    {
        $id = $this->getId($action, $type);
        $this->limits[$id][$key] = $limitData;
    }

    /**
     * @inheritdoc
     */
    public function cleanup($action, $type, $config)
    {
        $id = $this->getId($action, $type);
        $time = time();
        $removed = 0;
        foreach ($this->limits[$id] as $key => $limit) {
            if ($time > $limit["ts"] + $config["counterTimeout"]) {
                unset($this->limits[$id][$key]);
                ++$removed;
            }
        }

        return $removed;
    }

    /**
     * {@inheritdoc}
     *
     * Save must be part of an exclusive transaction.
     */
    public function save($action, $type)
    {
        $id = $this->getId($action, $type);
        $fileName = $this->dir . static::DATA_DIR . $id;
        $file = serialize($this->limits[$id]);

        // Write the updated records
        if (isset($this->tFiles[$id]) && $this->tFiles[$id]->isOpened()) {
            $writer = new \PicoAuth\Storage\File\FileWriter(
                $fileName,
                ["handle"=>$this->tFiles[$id]->getHandle()]
            );
            $writer->write($file);
        } else {
            throw new \RuntimeException("Transaction file not opened.");
        }
    }

    /**
     * @inheritdoc
     */
    public function transaction($action, $type, $state)
    {
        $id = $this->getId($action, $type);

        switch ($state) {
            // Start transaction
            case self::TRANSACTION_BEGIN:
                $this->openTransactionFile($id);
                if (!$this->tFiles[$id]->lock(LOCK_EX)) {
                    throw new \RuntimeException("Could not lock the file.");
                }
                break;

            // Commit
            case self::TRANSACTION_END:
                if (!isset($this->tFiles[$id])) {
                    throw new \RuntimeException("Transation is not openened, cannot close.");
                }
                $this->tFiles[$id]->unlock();
                $this->tFiles[$id]->close();
                break;

            default:
                throw new \InvalidArgumentException("Unexpected transaction argument.");
        }
    }

    /**
     * Opens the transaction file
     *
     * This file is opened for read and write operations and it's lock
     * will stay active during the transaction calls started and ended with
     * {@see RateLimitFileStorage::transaction()}
     *
     * @param string $id action-type file id
     * @throws \RuntimeException On file open error
     */
    private function openTransactionFile($id)
    {
        if (!isset($this->tFiles[$id])) {
            self::preparePath($this->dir, self::DATA_DIR);
            $fileName = $this->dir . static::DATA_DIR . $id;

            $handle = @fopen($fileName, 'c+');
            if ($handle === false) {
                throw new \RuntimeException("Could not open file: " . $fileName);
            }
            $this->tFiles[$id] = new \PicoAuth\Storage\File\FileReader(
                $fileName,
                ["handle"=>$handle]
            );
        }
    }

    /**
     * Get a single identifier for action-type pair
     *
     * @param string $action
     * @param string $type
     * @return string
     */
    protected function getId($action, $type)
    {
        return $action . "_" . $type;
    }
}


<?php

namespace PicoAuth\Storage\File;

/**
 * File writer
 *
 * Opens the file with an exclusive access
 */
class FileWriter extends File
{

    /**
     * Mode the file will be opened with
     */
    const OPEN_MODE = 'c+';

    /**
     * Will be set to true if write error/s occurred
     * @var bool
     */
    protected $writeErrors = false;

    /**
     * File name of the write backup file, if used
     * @var string|null
     */
    protected $bkFilePath = null;

    /**
     * Opens the file for writing
     *
     * Obtains an exclusive lock
     * Creates a backup file if the corresponding class option is set
     *
     * @return void
     * @throws \RuntimeException On open/lock error
     */
    public function open()
    {
        if ($this->isOpened()) {
            return;
        }

        $this->handle = @fopen($this->filePath, self::OPEN_MODE);
        if ($this->handle === false) {
            throw new \RuntimeException("Could not open file for writing: " . $this->filePath);
        }

        if (!$this->lock(LOCK_EX)) {
            $this->close();
            throw new \RuntimeException("Could not aquire an exclusive lock for " . $this->filePath);
        }

        if ($this->options["backup"]) {
            $this->createBkFile();
        }

        $this->writeErrors = false;
    }

    /**
     * Writes contents of the file
     *
     * @param string $data Data to write
     * @throws \InvalidArgumentException data not a string
     * @throws \RuntimeException Write error
     */
    public function write($data)
    {
        if (!is_string($data)) {
            throw new \InvalidArgumentException("The data is not a string.");
        }

        $this->open();

        if (!ftruncate($this->handle, 0)) {
            $this->writeErrors = true;
            throw new \RuntimeException("Could not truncate file " . $this->filePath);
        }

        fseek($this->handle, 0);

        $res = fwrite($this->handle, $data);
        if (strlen($data) !== $res) {
            $this->writeErrors = true;
            throw new \RuntimeException("Could not write to file " . $this->filePath);
        }
    }

    /**
     * @inheritdoc
     */
    public function close()
    {
        // Unlock and close the main file
        parent::close();

        // Remove the backup file if there were no write errors or errors
        // when closing the file (above call would throw an exception)
        $this->removeBkFile();
    }

    /**
     * Creates a backup file before writing
     *
     * If there is no write permission for the directory the original file is in,
     * the backup file is not created. That way this option can be disabled,
     * if not considered as needed.
     *
     * @throws \RuntimeException On write error
     */
    protected function createBkFile()
    {
        if (!is_writable(dirname($this->filePath))) {
            return;
        }

        $this->bkFilePath = $this->filePath . '.' . date("y-m-d-H-i-s") . '.bak';
        $bkHandle = @fopen($this->bkFilePath, 'x+');
        if ($bkHandle === false) {
            $this->close();
            throw new \RuntimeException("Could not create a temporary file " . $this->bkFilePath);
        }
        $stat = fstat($this->handle);
        if (stream_copy_to_stream($this->handle, $bkHandle) !== $stat['size']) {
            $this->close();
            throw new \RuntimeException("Could not create a copy of " . $this->filePath);
        }

        if (!fclose($bkHandle)) {
            throw new \RuntimeException("Could not close a backup file " . $this->bkFilePath);
        }

        fseek($this->handle, 0);
    }

    /**
     * Closes the backup file
     *
     * Removes it if the write operation to the original file ended successfully
     *
     * @return void
     */
    protected function removeBkFile()
    {
        if (!$this->options["backup"]) {
            return;
        }

        // Remove backup file if the write was successful
        if (!$this->writeErrors && $this->bkFilePath) {
            unlink($this->bkFilePath);
        }
    }
}


<?php

namespace PicoAuth\Module\Authorization;

use PicoAuth\Storage\Interfaces\PageACLStorageInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use PicoAuth\Module\AbstractAuthModule;

/**
 * Authorization based on pre-defined access rules
 */
class PageACL extends AbstractAuthModule
{

    protected $runtimeRules = [];

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

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

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

    public function __construct(
        PicoAuthInterface $picoAuth,
        SessionInterface $session,
        PageACLStorageInterface $storage
    ) {

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

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

    /**
     * @inheritdoc
     */
    public function checkAccess($url)
    {
        $user = $this->picoAuth->getUser();
        $id = $user->getId();

        $rule = $this->storage->getRuleByURL($url);

        if ($rule === null && count($this->runtimeRules)) {
            $rule = \PicoAuth\Storage\FileStorage::getItemByUrl($this->runtimeRules, $url);
        }

        if ($rule !== null) {
            if (isset($rule['users']) && in_array($id, $rule['users'])) {
                return true;
            }
            if (isset($rule['groups'])) {
                $groups = $user->getGroups();
                foreach ($groups as $group) {
                    if (in_array($group, $rule['groups'])) {
                        return true;
                    }
                }
            }
            return false;
        }

        return true;
    }

    /**
     * @inheritdoc
     */
    public function denyAccessIfRestricted($url)
    {
        if (!$this->checkAccess($url)) {
            $user = $this->picoAuth->getUser();
            if ($user->getAuthenticated()) {
                // The user is logged in but does not have permissions to view the page
                $this->picoAuth->setRequestUrl("403");
                header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
            } else {
                // The user is not logged in -> redirect to login
                $this->session->addFlash("error", "Login first to access this page");
                $afterLogin = "afterLogin=" . $url;
                $this->picoAuth->redirectToLogin($afterLogin);
            }
        }
    }

    /**
     * Adds an additional ACL rule
     *
     * Can be used by other plugins to control access to certain pages
     *
     * @param string $url Url for which the rule applies
     * @param array $rule Rule to be added
     */
    public function addRule($url, $rule)
    {
        if (!is_string($url) || !is_array($rule)) {
            throw new \InvalidArgumentException("addRule() expects a string and an array.");
        }
        $this->runtimeRules[$url] = $rule;
    }
}


<?php

/**
 * PicoAuth wrapper class
 *
 * The implementation of the plugin itself is in PicoAuthPlugin class.
 * The purpose of this class is only to register the plugin's autoloader
 * if it is not installed in Pico with pico-composer. After that is done,
 * it relies all Pico events to the main class with
 * an overridden {@see PicoAuth::handleEvent()} method.
 *
 * @see \PicoAuth\PicoAuthPlugin
 */
class PicoAuth extends AbstractPicoPlugin
{

    /**
     * Pico API version used by this plugin.
     * @var int
     */
    const API_VERSION = 2;

    /**
     * The main class of the plugin
     * @var \PicoAuth\PicoAuthPlugin
     */
    protected $picoAuthPlugin;

    /**
     * Constructs a new PicoAuth instance
     *
     * If PicoAuth is not installed as a pico-composer plugin, this will
     * register plugin's own autoloader. This will only work if any shared
     * dependencies between Pico's and PicoAuths's autoloader are
     * are present in the same versions.
     *
     * @param \Pico $pico
     */
    public function __construct(\Pico $pico)
    {
        parent::__construct($pico);

        if (!class_exists('\PicoAuth\PicoAuthPlugin', true)) {
            if (is_file(__DIR__ . '/vendor/autoload.php')) {
                require_once __DIR__ . '/vendor/autoload.php';
            } else {
                die("PicoAuth is neither installed as a pico-composer plugin "
                    . "nor it has its own vendor/autoload.php.");
            }
        }

        $this->picoAuthPlugin = new \PicoAuth\PicoAuthPlugin($pico);
    }

    /**
     * Pico API events pass-through
     *
     * Pico plugin events are sent over to the main PicoAuthPlugin class.
     * {@inheritdoc}
     *
     * @param string $eventName
     * @param array $params
     */
    public function handleEvent($eventName, array $params)
    {
        parent::handleEvent($eventName, $params);

        if ($this->isEnabled()) {
            $this->picoAuthPlugin->handleEvent($eventName, $params);
        }
    }

    /**
     * Gets the instance of the main class
     *
     * May be used if other plugins installed in Pico and depending on PicoAuth
     * need to access to the PicoAuthPlugin class.
     *
     * @return \PicoAuth\PicoAuthPlugin
     */
    public function getPlugin()
    {
        return $this->picoAuthPlugin;
    }
}


<?php

namespace PicoAuth\Module\Authentication\LocalAuth;

use PicoAuth\Storage\Interfaces\LocalAuthStorageInterface;
use PicoAuth\Security\Password\Password;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * EditAccount component for LocalAuth
 */
class EditAccount
{

    /**
     * Configuration array for EditAccount
     *
     * @var array
     */
    protected $config;

    /**
     * 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
    ) {

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

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

    /**
     * Account page request
     *
     * @param Request $httpRequest
     * @return void
     */
    public function handleAccountPage(Request $httpRequest)
    {
        // Check if the functionality is enabled by the configuration
        if (!$this->config["enabled"]) {
            return;
        }

        $user = $this->picoAuth->getUser();
        $this->picoAuth->addAllowed("account");
        $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/account.md');

        //check form submission
        $post = $httpRequest->request;
        if ($post->has("new_password")
            && $post->has("new_password_repeat")
            && $post->has("old_password")
        ) {
            $newPassword = new Password($post->get("new_password"));
            $newPasswordRepeat = new Password($post->get("new_password_repeat"));
            $oldPassword = new Password($post->get("old_password"));
            $username = $user->getId();

            // CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"))) {
                $this->picoAuth->redirectToPage("account");
            }

            if ($newPassword->get() !== $newPasswordRepeat->get()) {
                $this->session->addFlash("error", "The passwords do not match.");
                $this->picoAuth->redirectToPage("account");
            }

            // The current password check
            $localAuth = $this->picoAuth->getContainer()->get('LocalAuth');
            if (!$localAuth->loginAttempt($username, $oldPassword)) {
                $this->session->addFlash("error", "The current password is incorrect");
                $this->picoAuth->redirectToPage("account");
            }

            // Check password policy
            if (!$localAuth->checkPasswordPolicy($newPassword)) {
                $this->picoAuth->redirectToPage("account");
            }

            // Save user data
            $userData = $this->storage->getUserByName($username);
            $localAuth->userDataEncodePassword($userData, $newPassword);
            $this->storage->saveUser($username, $userData);

            $this->session->addFlash("success", "Password changed successfully.");
            $this->picoAuth->redirectToPage("account");
        }
    }
}


<?php

namespace PicoAuth\Storage\Configurator;

/**
 * Abstract configurator for validation of configuration arrays
 * and setting default values.
 */
abstract class AbstractConfigurator
{

    /**
     * Validates the configuration
     *
     * Performs assertations on all fields of the configuration array,
     * adds default values for the fields that were not specified.
     *
     * @throws ConfigurationException On validation failure
     * @param mixed $config Value read from the configuration file
     * @return array Resulting configuration with default values
     */
    abstract public function validate($config);

    /**
     * Assert the property is present
     * @param array $config Array being validated
     * @param string $key Inspected array key
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertRequired($config, $key)
    {
        if (!isset($config[$key])) {
            throw new ConfigurationException("Property $key is required.");
        }
        return $this;
    }

    /**
     * Assert the property is array or is not set
     * @param array $config Array being validated
     * @param string $key Inspected array key
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertArray($config, $key)
    {
        if (array_key_exists($key, $config) && !is_array($config[$key])) {
            throw new ConfigurationException($key." section must be an array.");
        }
        return $this;
    }

    /**
     * Assert the property is boolean or is not set
     * @param array $config Array being validated
     * @param string|int $key Inspected array key
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertBool($config, $key)
    {
        if (array_key_exists($key, $config) && !is_bool($config[$key])) {
            throw new ConfigurationException($key." must be a boolean value.");
        }
        return $this;
    }

    /**
     * Assert the property is integer or is not set
     * @param array $config Array being validated
     * @param string|int $key Inspected array key
     * @param int $lowest The lowest accepted values
     * @param int $highest The highest accepted value
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertInteger($config, $key, $lowest = null, $highest = null)
    {
        if (array_key_exists($key, $config)) {
            if (!is_int($config[$key])) {
                throw new ConfigurationException($key." must be an integer.");
            }
            if ($lowest !== null && $config[$key] < $lowest) {
                throw new ConfigurationException($key." cannot be lower than ".$lowest);
            }
            if ($highest !== null && $config[$key] > $highest) {
                throw new ConfigurationException($key." cannot be higher than ".$highest);
            }
        }
        return $this;
    }

    /**
     * Assert both properties are set and 1st is greater than second
     * @param array $config Array being validated
     * @param string|int $keyGreater Array key with greater value
     * @param string|int $keyLower Array key with lower value
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertGreaterThan($config, $keyGreater, $keyLower)
    {
        if (!isset($config[$keyLower])
            || !isset($config[$keyGreater])
            || $config[$keyLower] >= $config[$keyGreater]) {
            throw new ConfigurationException($keyGreater." must be greater than ".$keyLower);
        }
        return $this;
    }

    /**
     * Assert the property is string or is not set
     * @param array $config Array being validated
     * @param string|int $key Inspected array key
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertString($config, $key)
    {
        if (array_key_exists($key, $config) && !is_string($config[$key])) {
            throw new ConfigurationException($key." must be a string.");
        }
        return $this;
    }

    /**
     * Assert a string contains given substring or is not set
     * @param array $config Array being validated
     * @param string|int $key Inspected array key
     * @param string $searchedPart The string being searched
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertStringContaining($config, $key, $searchedPart)
    {
        $this->assertString($config, $key);
        if (array_key_exists($key, $config) && strpos($config[$key], $searchedPart) === false) {
            throw new ConfigurationException($key." must contain ".$searchedPart);
        }
        return $this;
    }

    /**
     * Assert the value is array of strings or is not set
     * @param array $config Array being validated
     * @param string|int $key Inspected array key
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertArrayOfStrings($config, $key)
    {
        if (!array_key_exists($key, $config)) {
            return $this;
        }
        if (!is_array($config[$key])) {
            throw new ConfigurationException($key." section must be an array.");
        }
        foreach ($config[$key] as $value) {
            if (!is_string($value)) {
                throw new ConfigurationException("Values in the `{$key}` must be strings"
                    . gettype($value) . " found.");
            } elseif ($value==="") {
                throw new ConfigurationException("Empty string not allowed in `{$key}` array.");
            }
        }
        return $this;
    }

    /**
     * Assert the value is either integer within bounds or false
     *
     * Some configuration values can be either false (disabled)
     * or have an integer value set if enabled
     *
     * @param array $config
     * @param string|int $key Array being validated
     * @param int $lowest The lowest accepted value
     * @param int $highest The highest accepted value
     * @return $this
     * @throws ConfigurationException On a failed assertation
     */
    public function assertIntOrFalse($config, $key, $lowest = null, $highest = null)
    {
        try {
            $this->assertInteger($config, $key, $lowest, $highest);
        } catch (ConfigurationException $e) {
            if ($config[$key]!==false) {
                throw new ConfigurationException(
                    "Key `{$key}` can be either false or a non-negative integer."
                );
            }
        }
        return $this;
    }

    /**
     * Corrects URL index if it does not have a correct format
     *
     * Add a leading slash to the page URL if not present.
     * And remove trailing slash from the end if present.
     * If the change is made, the array key is moved inside the
     * {$rules} array.
     *
     * @param array $rules Reference to $rules array
     * @param string $pageUrl URL to be checked and corrected
     */
    public function standardizeUrlFormat(&$rules, $pageUrl)
    {
        if (!is_string($pageUrl) || $pageUrl==="" || !is_array($rules) ||
                !array_key_exists($pageUrl, $rules) ) {
            return;
        }

        $oldIndex=$pageUrl;
        if ($pageUrl[0] !== '/') {
            $pageUrl='/'.$pageUrl;
        }
        $len=strlen($pageUrl);
        if ($len>1 && $pageUrl[$len-1]==='/') {
            $pageUrl= rtrim($pageUrl, '/');
        }
        if ($oldIndex!==$pageUrl) {
            $rules[$pageUrl]=$rules[$oldIndex];
            unset($rules[$oldIndex]);
        }
    }

    /**
     * Returns configuration merged with the default values
     *
     * Performs a union operation up to {$depth} level of sub-arrays.
     *
     * @param array|null $config The configuration array supplied by the user
     * @param array $defaults The default configuration array
     * @return array The resulting configuration array
     */
    public function applyDefaults($config, array $defaults, $depth = 1)
    {
        if (!is_int($depth) || $depth < 0) {
            throw new \InvalidArgumentException("Depth must be non-negative integer.");
        }

        if (!is_array($config)) {
            return $defaults;
        }

        if ($depth === 0) {
            $config += $defaults;
            return $config;
        }

        foreach ($defaults as $key => $defaultValue) {
            // Use the default value, if user's array is missing this key
            if (!isset($config[$key])) {
                $config[$key] = $defaultValue;
                continue;
            }

            if (is_array($defaultValue)) {
                if (is_array($config[$key])) {
                    $config[$key] = $this->applyDefaults($config[$key], $defaultValue, $depth-1);
                } else {
                    throw new ConfigurationException("Configuration key "
                        .$key." expects an array, a scalar value found.");
                }
            } else {
                if (is_array($config[$key])) {
                    throw new ConfigurationException("Configuration key "
                        .$key." expects scalar, an array found.");
                }
            }
        }

        return $config;
    }
}


<?php

namespace PicoAuth\Module\Authentication\LocalAuth;

use PicoAuth\Log\LoggerTrait;
use PicoAuth\Mail\MailerInterface;
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;

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

    use LoggerTrait;

    /**
     * Mailer instance
     *
     * @see PasswordReset::setsetMailer()
     * @var \PicoAuth\Mail\MailerInterface
     */
    protected $mailer;

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

    /**
     * The password reset 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 a Mailer instance
     *
     * @param MailerInterface $mailer
     * @return $this
     */
    public function setMailer(MailerInterface $mailer)
    {
        $this->mailer = $mailer;
        return $this;
    }

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

    /**
     * On a password reset request
     *
     * @return void
     */
    public function handlePasswordReset(Request $httpRequest)
    {
        $this->httpRequest = $httpRequest;

        // Check if a valid reset link is present
        $this->checkResetLink();

        // Check if the user already has a password reset session
        $resetData = $this->session->get("pwreset");

        if ($resetData === null) {
            $this->beginPasswordReset();
        } else {
            $this->finishPasswordReset($resetData);
        }
    }

    /**
     * The first stage of a password reset process
     *
     * Shows a form to request a password reset and processes
     * its submission (sends password reset link on success)
     *
     * @return void
     */
    protected function beginPasswordReset()
    {
        // Check if password reset is enabled
        if (!$this->config["enabled"]) {
            return;
        }
        $this->picoAuth->addAllowed("password_reset");
        $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/pwbeginreset.md');

        if (count($this->session->getFlash('_pwresetsent'))) {
            $this->picoAuth->addOutput("resetSent", true);
            return;
        }

        $post = $this->httpRequest->request;
        if ($post->has("reset_email")) {
            // CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"))) {
                $this->picoAuth->redirectToPage("password_reset");
            }

            $email = trim($post->get("reset_email"));
            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                $this->session->addFlash("error", "Email address does not have a valid format.");
                $this->picoAuth->redirectToPage("password_reset");
            }

            // Check if the action is not rate limited
            if (!$this->limit->action("passwordReset", true, array("email" => $email))) {
                $this->session->addFlash("error", $this->limit->getError());
                $this->picoAuth->redirectToPage("password_reset");
            }

            if ($userData = $this->storage->getUserByEmail($email)) {
                $this->sendResetMail($userData);
            }

            // Always display a message with success
            $this->session->addFlash("_pwresetsent", true);
            $this->session->addFlash("success", "Reset link sent via email.");
            $this->picoAuth->redirectToPage("password_reset");
        }
    }

    /**
     * The second stage of a password reset process
     *
     * Shows a password reset form and processes its submission
     *
     * @param array $resetData The resetData array with a fixed structure defined
     *                         in {@see PasswordReset::startPasswordResetSession()}
     */
    protected function finishPasswordReset(array $resetData)
    {
        if (time() > $resetData['validity']) {
            $this->session->remove("pwreset");
            $this->session->addFlash("error", "Page validity expired, please try again.");
            $this->picoAuth->redirectToLogin();
        }

        $this->picoAuth->addOutput("isReset", true);
        $this->picoAuth->setRequestFile($this->picoAuth->getPluginPath() . '/content/pwreset.md');

        // Check the form submission
        $post = $this->httpRequest->request;
        if ($post->has("new_password")
            && $post->has("new_password_repeat")
        ) {
            $newPassword = new Password($post->get("new_password"));
            $newPasswordRepeat = new Password($post->get("new_password_repeat"));
            $username = $resetData['user'];

            // CSRF validation
            if (!$this->picoAuth->isValidCSRF($post->get("csrf_token"))) {
                $this->picoAuth->redirectToPage("password_reset");
            }

            if ($newPassword->get() !== $newPasswordRepeat->get()) {
                $this->session->addFlash("error", "The passwords do not match.");
                $this->picoAuth->redirectToPage("password_reset");
            }

            // Check password policy
            $localAuth = $this->picoAuth->getContainer()->get('LocalAuth');
            if (!$localAuth->checkPasswordPolicy($newPassword)) {
                $this->picoAuth->redirectToPage("password_reset");
            }

            // Remove pwreset record on successful submission
            $this->session->remove("pwreset");

            // Save the new userdata
            $userData = $this->storage->getUserByName($username);
            $localAuth->userDataEncodePassword($userData, $newPassword);
            $this->storage->saveUser($username, $userData);
            $this->logPasswordReset($username);

            $localAuth->login($username, $userData);
            $this->picoAuth->afterLogin();
        }
    }

    /**
     * Validates a password reset link
     *
     * If a valid password reset link was provided, starts a password reset
     * session and redirects to a password change form
     *
     * @return void
     */
    protected function checkResetLink()
    {
        // Check if the reset links are enabled, if token is present,
        // if it has a valid format and length
        if (!$this->config["enabled"]
            || !($token = $this->httpRequest->query->get("confirm", false))
            || !preg_match("/^[a-f0-9]+$/", $token)
            || strlen($token) !== 2*($this->config["tokenIdLen"]+$this->config["tokenLen"])) {
            return;
        }

        // Delete the active password reset session, if set
        $this->session->remove("pwreset");

        // Split token parts
        $tokenId = substr($token, 0, 2 * $this->config["tokenIdLen"]);
        $verifier = substr($token, 2 * $this->config["tokenIdLen"]);

        // Validate token timeout
        $tokenData = $this->storage->getResetToken($tokenId);

        // Token not found or expired
        if (!$tokenData || time() > $tokenData['valid']) {
            $this->session->addFlash("error", "Reset link has expired.");
            $this->getLogger()->warning("Bad reset token {t} from {addr}", [$token, $_SERVER['REMOTE_ADDR']]);
            $this->picoAuth->redirectToPage("password_reset");
        }

        if (hash_equals($tokenData['token'], hash('sha256', $verifier))) {
            $this->session->addFlash("success", "Please set a new password.");
            $this->startPasswordResetSession($tokenData['user']);
            $this->logResetLinkVisit($tokenData);
            $this->picoAuth->redirectToPage("password_reset");
        }
    }

    /**
     * Starts a password reset session
     *
     * A password reset session is active when session key pwreset is present,
     * which contains user identifier of the user who the reset session is valid
     * for and an expiration date after which the password session cannot be used
     * to change a password.
     *
     * @param string $user The user identifier
     */
    public function startPasswordResetSession($user)
    {
        $this->session->migrate(true);
        $this->session->set("pwreset", array(
            'user' => $user,
            'validity' => time() + $this->config["resetTimeout"]
        ));
    }

    protected function createResetToken($username)
    {
        // Get the reset token
        $tokenId = bin2hex(random_bytes($this->config["tokenIdLen"]));
        $verifier = bin2hex(random_bytes($this->config["tokenLen"]));

        $url = $this->picoAuth->getPico()->getPageUrl("password_reset", array(
            'confirm' => $tokenId . $verifier
        ));

        $tokenData = array(
            'token' => hash('sha256', $verifier),
            'user' => $username,
            'valid' => time() + $this->config["tokenValidity"]
        );

        $this->storage->saveResetToken($tokenId, $tokenData);

        return $url;
    }

    /**
     * Sends a password reset link
     *
     * @param array $userData Userdata array of an existing user
     * @return void
     */
    protected function sendResetMail($userData)
    {
        if (!$this->mailer) {
            $this->getLogger()->critical("Sending mail but no mailer is set!");
            return;
        }

        $url = $this->createResetToken($userData['name']);

        // Replaces Pico-specific placeholders (like %site_title%)
        $message = $this->picoAuth->getPico()->substituteFileContent($this->config["emailMessage"]);
        $subject = $this->picoAuth->getPico()->substituteFileContent($this->config["emailSubject"]);

        // Replaces placeholders in the configured email message template
        $message = str_replace("%url%", $url, $message);
        $message = str_replace("%username%", $userData['name'], $message);

        $this->mailer->setup();
        $this->mailer->setTo($userData['email']);
        $this->mailer->setSubject($subject);
        $this->mailer->setBody($message);

        if (!$this->mailer->send()) {
            $this->getLogger()->critical("Mailer error: {e}", ["e" => $this->mailer->getError()]);
        } else {
            $this->getLogger()->info("PwReset email sent to {email}", ["email" => $userData['email']]);
        }
    }

    /**
     * Logs a valid reset link visit
     *
     * @param array $tokenData Reset token data array
     */
    protected function logResetLinkVisit(array $tokenData)
    {
        $this->getLogger()->info(
            "Valid pwReset link for {name} visited by {addr}",
            array(
                "name" => $tokenData['user'],
                "addr" => $_SERVER['REMOTE_ADDR'],
            )
        );
    }

    /**
     * Logs a completed password reset
     *
     * @param string $username
     */
    protected function logPasswordReset($username)
    {
        $this->getLogger()->info(
            "Completed password reset for {name} by {addr}",
            array(
                "name" => $username,
                "addr" => $_SERVER['REMOTE_ADDR'],
            )
        );
    }
}