<repo_name>picoauth/picoauth<file_sep>/src/Security/Password/Encoder/Plaintext.php
<?php

namespace PicoAuth\Security\Password\Encoder;

/**
 * Plaintext encoder
 *
 * Included mainly for unit testing, usage for any password storage
 * is discouraged.
 */
class Plaintext implements PasswordEncoderInterface
{

    /**
     * The maximum length
     */
    const MAX_LEN = 4096;
    
    /**
     * Case sensitivity option
     *
     * @var bool
     */
    protected $ignoreCase = false;

    public function __construct(array $options = [])
    {
        if (isset($options['ignoreCase'])) {
            if (!is_bool($options['ignoreCase'])) {
                throw new \InvalidArgumentException('Parameter ignoreCase must be a boolean.');
            }
            $this->ignoreCase = $options['ignoreCase'];
        }
    }

    /**
     * @inheritdoc
     */
    public function encode($rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            throw new EncoderException("Invalid length, maximum is ".self::MAX_LEN.".");
        }
        return $rawPassword;
    }

    /**
     * @inheritdoc
     */
    public function isValid($encodedPassword, $rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            return false;
        }
        if ($this->ignoreCase) {
            return hash_equals(strtolower($encodedPassword), strtolower($rawPassword));
        } else {
            return hash_equals($encodedPassword, $rawPassword);
        }
    }

    /**
     * @inheritdoc
     */
    public function needsRehash($encodedPassword)
    {
        return false;
    }
    
    /**
     * @inheritdoc
     */
    public function getMaxAllowedLen()
    {
        return self::MAX_LEN;
    }
}
<file_sep>/src/Storage/Interfaces/LocalAuthStorageInterface.php
<?php

namespace PicoAuth\Storage\Interfaces;

/**
 * Interface for accessing data of local users (LocalAuth module)
 */
interface LocalAuthStorageInterface
{
    /**
     * Get configuration array
     *
     * @return array
     */
    public function getConfiguration();
    
    /**
     * Retrieve user data
     *
     * @param string $name user identifier
     * @return array|null Userdata or null if the user doesn't exist
     */
    public function getUserByName($name);
    
    /**
     * Retrieve user data by email
     *
     * The returned userdata array must contain an additional 'name' key.
     *
     * @param string $email email
     * @return array|null Userdata or null if the user doesn't exist
     */
    public function getUserByEmail($email);
    
    /**
     * Save user
     *
     * @param string $id User identifier
     * @param array $userdata Userdata array
     * @throws \Exception On save failure
     */
    public function saveUser($id, $userdata);

    /**
     * Save reset token
     *
     * @param string $id
     * @param array $data
     */
    public function saveResetToken($id, $data);
    
    /**
     * Get reset token by id
     *
     * The token must be retrievable only once. The method
     * deletes the token entry from the storage on retrieval.
     *
     * @param string $id Token ID
     * @return array|null Token array or null if not found
     */
    public function getResetToken($id);

    /**
     * Username validation
     *
     * Check if the name is valid for this storage type
     * If not, an exception is thrown with explanation message
     *
     * @param string $name Name to be checked
     * @throws \RuntimeException On failure
     */
    public function checkValidName($name);

    /**
     * Return the number of registered users
     *
     * @return int Users count
     */
    public function getUsersCount();
}
<file_sep>/src/Storage/File/FileReader.php
<?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;
    }
}
<file_sep>/tests/PicoAuthTest.php
<?php

namespace PicoAuth;

class PicoAuthTest extends BaseTestCase
{
    
    /**
     * PicoAuth plugin instance
     * @var \PicoAuth
     */
    protected $picoAuth;
    
    protected function setUp()
    {
        $picoMock = $this->getMockBuilder('\Pico')
            ->disableOriginalConstructor()
            ->getMock();
        $this->picoAuth = new \PicoAuth($picoMock);
    }

    public function testGetPlugin()
    {
        $p = $this->picoAuth->getPlugin();
        $this->assertTrue($p instanceof \PicoAuth\PicoAuthPlugin);
    }
    
    public function testHandleEvent()
    {
        $mock = $this->createMock(\PicoAuth\PicoAuthPlugin::class);
        $mock->expects($this->once())
            ->method('handleEvent')
            ->with("onTest", [1,2,3]);
        self::set($this->picoAuth, $mock, 'picoAuthPlugin');
        
        self::set($this->picoAuth, true, 'enabled');
        $this->picoAuth->handleEvent("onTest", [1,2,3]);
        self::set($this->picoAuth, false, 'enabled');
        $this->picoAuth->handleEvent("onSecondTest", [4,5,6]);
    }
}
<file_sep>/content/pwreset.md
---
Title: Password reset
Robots: noindex,nofollow
Template: password
---
<file_sep>/src/Utils.php
<?php
namespace PicoAuth;

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

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

        $query = [];

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

        return null;
    }

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

namespace PicoAuth;

use PHPUnit\Framework\TestCase;

abstract class BaseTestCase extends TestCase
{

    /**
     * Allows testing private/protected methods
     * @param instance $entity
     * @param string $name
     * @return ReflectionMethod
     */
    public static function getMethod($entity, $name)
    {
        $class = new \ReflectionClass($entity);
        $method = $class->getMethod($name);
        $method->setAccessible(true);
        return $method;
    }

    /**
     * Sets property value
     * @param mixed $entity
     * @param mixed $value
     * @param string $propertyName
     */
    public static function set($entity, $value, $propertyName)
    {
        $class = new \ReflectionClass($entity);
        $property = $class->getProperty($propertyName);
        $property->setAccessible(true);
        $property->setValue($entity, $value);
    }
    
    /**
     * Gets property value
     * @param mixed $entity
     * @param string $propertyName
     * @return mixed the value
     */
    public static function get($entity, $propertyName)
    {
        $class = new \ReflectionClass($entity);
        $property = $class->getProperty($propertyName);
        $property->setAccessible(true);
        return $property->getValue($entity);
    }
}
<file_sep>/content/locked.md
---
Title: Locked content
Robots: noindex,nofollow
Template: lock
---

# Locked content
Enter a secret key to unlock the page contents:
<file_sep>/src/Storage/OAuthFileStorage.php
<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Interfaces\OAuthStorageInterface;
use PicoAuth\Cache\NullCache;
use Psr\SimpleCache\CacheInterface;
use PicoAuth\Storage\Configurator\OAuthConfigurator;

/**
 * File storage for OAuth
 */
class OAuthFileStorage extends FileStorage implements OAuthStorageInterface
{

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

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

    /**
     * @inheritdoc
     */
    public function getProviderByName($name)
    {
        $this->readConfiguration();
        return $this->config['providers'][$name];
    }

    /**
     * @inheritdoc
     */
    public function getProviderNames()
    {
        $this->readConfiguration();
        $providers = $this->config['providers'];
        if (is_array($providers)) {
            return array_keys($providers);
        } else {
            return null;
        }
    }
}
<file_sep>/src/PicoAuthInterface.php
<?php

namespace PicoAuth;

/**
 * PicoAuth public API accessible to authentication/authorization modules.
 * @version 1.0
 */
interface PicoAuthInterface
{
    
    /**
     * PicoAuth API version
     * @var int
     */
    const PICOAUTH_API_VERSION = 1;
    
    /**
     * Get PicoAuth's dependency container
     * @return \League\Container\Container
     */
    public function getContainer();
    
    /**
     * Get a value from the plugin configuration
     * @param string $key The configuration key to return
     * @param mixed $default Default value if the key is not set
     * @return mixed
     */
    public function getAuthConfig($key, $default = null);
    
    /**
     * Get the current user.
     * Returns user instance even if the user is not authenticated.
     * @return \PicoAuth\User
     */
    public function getUser();
    
    /**
     * Return full path of plugin's installation directory.
     * @return string absolute path of plugin location
     */
    public function getPluginPath();

    /**
     * Returns URL of the default plugin theme.
     * Used from the default twig templates to load js/css resources.
     * @return string
     */
    public function getDefaultThemeUrl();
    
    /**
     * Get module by name.
     * If the module does not exist or is not enabled, the method returns null.
     * @param string $name Module name
     * @return null|\PicoAuth\Module\AbstractAuthModule
     */
    public function getModule($name);
    
    /**
     * Get flash messages for this request.
     * @return array
     */
    public function getFlashes();
    
    /**
     * Get Pico instance.
     * @see AbstractPicoPlugin::getPico()
     * @return \Pico
     */
    public function getPico();
    
    /**
     * Set user instance.
     * @param \PicoAuth\User $user User instance
     */
    public function setUser($user);
    
    /**
     * Change requested page id.
     * The change can be propagated to Pico only if the call is made
     * from a place originating from \PicoAuth::onRequestUrl().
     * @see \PicoAuth::onRequestUrl()
     * @param string $url Pico page id to be set
     */
    public function setRequestUrl($url);
    
    /**
     * Set the requested file.
     * @param string $file Path to the loaded .md file
     */
    public function setRequestFile($file);

    /**
     * Adds a new value to plugin's Twig variables.
     * Example: if a key "foo" is registered, it can be later displayed in
     * the template under {{ auth.vars.foo }}
     * @param string $key Output key
     * @param mixed $value Output value
     */
    public function addOutput($key, $value);
    
    /**
     * Adds an always allowed page.
     * This url will always be available no matter which authorization modules
     * are active and how are they configured. Useful for authentication modules
     * to allow access to routes that are needed for authentication
     * (e.g. login pages, registration, password reset pages, etc.)
     * Modules need to register these routes before denyAccessIfRestricted event.
     * @param string $url Pico page url to be always allowed
     */
    public function addAllowed($url);
    
    /**
     * Performs CSRF validation.
     * @see \PicoAuth\Security\CSRF
     * @param string $token Token to be validated
     * @param null|string $action Optional action the token is associated with
     * @return boolean true if the token is valid, false otherwise
     */
    public function isValidCSRF($token, $action = null);
    
    /**
     * After-login procedure.
     * Called by any authentication module after successful login. Migrates session,
     * logs the login, invalidates csrf tokens.
     */
    public function afterLogin();
    
    /**
     * Redirects to page ID.
     * Calls exit(), therefore no further code is executed after the call.
     * Can be used also for a generic redirect by setting picoOnly to false.
     * @param string $url Pico page id
     * @param null|string $query Optional HTTP query params to append
     * @param bool $picoOnly true=local scope redirect only, false=any url
     */
    public function redirectToPage($url, $query = null, $picoOnly = true);
    
    /**
     * Redirects to the login page.
     * If the Request instance is given, the method will try to extract an "afterLogin"
     * query param from the referer header, and if it is a valid Pico page id
     * it will add it to the redirect URL.
     * Same as {@see PicoAuthInterface::redirectToPage()}, this method is terminal.
     * @param null|string $query HTTP query params to append
     * @param null|\Symfony\Component\HttpFoundation\Request $httpRequest The current request
     */
    public function redirectToLogin($query = null, $httpRequest = null);
}
<file_sep>/content/register.md
---
Title: Registration
Robots: noindex,nofollow
Template: register
---
<file_sep>/src/Security/Password/Password.php
<?php

namespace PicoAuth\Security\Password;

/**
 * The class for Password manipulation
 *
 * Prevents password disclosure in a stack trace if "display_errors"
 * is enabled and an unhandled exception occurs.
 */
class Password
{

    private $value;

    /**
     * Create Password instance from string
     * @param string $string
     */
    public function __construct($string)
    {
        $this->value = $string;
    }

    /**
     * Get Password value
     * @return string
     */
    public function __toString()
    {
        return $this->value;
    }

    /**
     * Get Password value
     * @return string
     */
    public function get()
    {
        return $this->value;
    }

    /**
     * Prevent var_dump() of Password value
     * @return array
     */
    public function __debugInfo()
    {
        return [
            'value' => '*hidden*',
        ];
    }
}
<file_sep>/src/Storage/Configurator/LocalAuthConfigurator.php
<?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;
    }
}
<file_sep>/tests/Module/Generic/InstallerTest.php
<?php

namespace PicoAuth\Module\Generic;

use PicoAuth\Session\SessionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ParameterBag;
use PicoAuth\PicoAuthInterface;

class InstallerTest extends \PicoAuth\BaseTestCase
{
    
    protected $installer;

    protected function setUp()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);

        $this->installer = new Installer($picoAuth);
    }

    public function testOnPicoRequestAtPicoAuth()
    {
        $pico = $this->createMock(\Pico::class);
        $pico->expects($this->exactly(3))
            ->method("getBaseUrl")
            ->willReturn("/");
        $pico->expects($this->exactly(3))
            ->method("getConfig")
            ->willReturn("");
        $pico->expects($this->exactly(2))
            ->method("getConfigDir")
            ->willReturn("/config");
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getPluginPath")
            ->willReturn("");
        $picoAuth->expects($this->once())
            ->method("setRequestFile")
            ->with("/content/install.md");
        $picoAuth->expects($this->exactly(3))
            ->method("getPico")
            ->willReturn($pico);
        $picoAuth->expects($this->exactly(4))
            ->method("addOutput");
        
        //not used
        $request = $this->createMock(Request::class);
        $request->request = $this->createMock(ParameterBag::class);
        
        $installer = new Installer($picoAuth);
        $installer->onPicoRequest("PicoAuth", $request);
    }

    public function testOnPicoRequestAtPicoAuthModules()
    {
        $pico = $this->createMock(\Pico::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getPluginPath")
            ->willReturn("");
        $picoAuth->expects($this->once())
            ->method("setRequestFile")
            ->with("/content/install.md");
        $picoAuth->expects($this->exactly(1))
            ->method("addOutput");
        
        //not used
        $request = $this->createMock(Request::class);
        $request->request = $this->createMock(ParameterBag::class);
        
        $installer = new Installer($picoAuth);
        $installer->onPicoRequest("PicoAuth/modules", $request);
    }
    
    public function testGetName()
    {
        $this->assertEquals('installer', $this->installer->getName());
    }
}
<file_sep>/src/Module/Authentication/OAuth.php
<?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();
    }
}
<file_sep>/tests/Storage/Configurator/PageLockConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class PageLockConfiguratorTest extends TestCase
{
    
    protected $configurator;
    
    protected function setUp()
    {
        $this->configurator = new PageLockConfigurator;
    }
    
    /**
     * @dataProvider badConfigurations
     */
    public function testValidationErrors($rawConfig)
    {
        $this->expectException(ConfigurationException::class);
        $this->configurator->validate($rawConfig);
    }

    public function badConfigurations()
    {
        return [
            [["encoder"=>1]],
            [["locks"=>[1=>["key"=>"a"]]]],
            [["locks"=>[""=>["key"=>"a"]]]],
            [["locks"=>["a"=>1]]],
            [["locks"=>["a"=>["nokey"=>"a"]]]],
            [["locks"=>["a"=>["key"=>1]]]],
            [["locks"=>["a"=>["key"=>"1","encoder"=>1]]]],
            [["locks"=>["a"=>["key"=>"1","file"=>1]]]],
            
            [["urls"=>1]],
            [["urls"=>[1=>[]]]],
            [["urls"=>[""=>[]]]],
            [["urls"=>["a"=>[]]]],
            [["urls"=>["a"=>["lock"=>1]]]],
            [["urls"=>["a"=>["lock"=>"1","recursive"=>1]]]],
        ];
    }
 
    public function testValidation()
    {
        $res = $this->configurator->validate([
            "locks" => [
                "a" => ["key"=>"a","file"=>"a","encoder"=>"plain"]
            ],
            "urls" => [
                "test" => ["lock"=>"1","recursive"=>true]
            ]
        ]);
        $this->assertArrayHasKey("encoder", $res);
    }
}
<file_sep>/tests/UtilsTest.php
<?php

namespace PicoAuth;

use PHPUnit\Framework\TestCase;

/**
 * PicoAuth plugin tests
 */
class UtilsTest extends TestCase
{
    
    /**
     * @dataProvider queryProvider
     */
    public function testGetRefererQueryParam($url, $key, $expected)
    {
        $this->assertEquals($expected, Utils::getRefererQueryParam($url, $key));
    }
    
    public function queryProvider()
    {
        return [
            [null, null, null],
            ["https://test.xyz?a=1", "a", "1"],
            ["https://test.xyz?a=1&b=2", "b", "2"],
            ["https://", "b", null],
            ["https://test.xyz?a=1&b=2", null, null],
            ["aaa", "b", null],
        ];
    }
    
    /**
     * @dataProvider pageProvider
     */
    public function testIsValidPageId($url, $expected)
    {
        $this->assertEquals($expected, Utils::isValidPageId($url));
    }
    
    public function pageProvider()
    {
        return [
            [null, false],
            ["", false],
            ["index", true],
            ["pAge-Name0_", true],
            ["a?", false],
            ["@^", false],
        ];
    }
}
<file_sep>/src/Storage/FileStorage.php
<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Configurator\ConfigurationException;

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

    /**
     * Configuration file location, redefined in subclasses
     */
    const CONFIG_FILE = '';
    
    /**
     * The configuration array
     * @var array
     */
    protected $config;

    /**
     * The Configurator instance
     * @var null|Configurator\AbstractConfigurator
     */
    protected $configurator;
    
    /**
     * Cache implementation
     * @var \Psr\SimpleCache\CacheInterface
     */
    protected $cache;
    
    /**
     * Base directory
     * @var string
     */
    protected $dir;

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

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

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

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

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

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

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

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

        return $isSuccess;
    }

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

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

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

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

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

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

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

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

        return null;
    }

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

    /**
     * Configuration validation
     *
     * Calls the specific configurator instance, which validates the user
     * provided configuration and adds default values.
     *
     * @param mixed $userConfig Anything read from the configuration file
     * @return array Modified configuration (added defaults)
     * @throws \RuntimeException Missing configurator
     * @throws ConfigurationException Validation error
     */
    protected function validateConfiguration($userConfig = array())
    {
        if (!$this->configurator) {
            throw new \RuntimeException("Configurator class is not set.");
        }
        
        try {
            return $this->configurator->validate($userConfig);
        } catch (ConfigurationException $e) {
            $e->addBeforeMessage("Configuration error in ".static::CONFIG_FILE.":");
            throw $e;
        }
    }
    
    /**
     * Initializes the configuration array
     *
     * If the file exists, tries to read from the cache first, if the cache
     * has the data, but the file is newer, reads the file and renews the cache.
     *
     * md5 is used as a cache key, because some cache providers may not accept
     * characters present in the file path.
     * Collisions or reversibility do not present security concerns.
     *
     * @throws \RuntimeException On file read error
     */
    protected function readConfiguration()
    {
        // Return if already loaded
        if (is_array($this->config)) {
            return;
        }
        
        $fileName = $this->dir . static::CONFIG_FILE;
        
        // Abort if the file doesn't exist
        if (!file_exists($fileName)) {
            $this->config = $this->validateConfiguration();
            return;
        }
        
        $modifyTime = filemtime($fileName);
        if (false === $modifyTime) {
            throw new \RuntimeException("Unable to get mtime of the configuration file.");
        }
        
        // Check if the configuration is in cache
        $cacheKey = md5($fileName);
        if ($this->cache->has($cacheKey)) {
            $this->config=$this->cache->get($cacheKey);
            
            // Cached file is up to date
            if ($this->config["_mtime"] === $modifyTime) {
                return;
            }
        }

        if (($yaml = self::readFile($fileName)) !== false) {
            $config = \Symfony\Component\Yaml\Yaml::parse($yaml);
            $this->config = $this->validateConfiguration($config);
        } else {
            throw new \RuntimeException("Unable to read configuration file.");
        }
        
        // Save to cache with updated modify-time
        $storedConfig = $this->config;
        $storedConfig["_mtime"]= $modifyTime;
        $this->cache->set($cacheKey, $storedConfig);
    }
}
<file_sep>/src/Module/Authentication/LocalAuth/PasswordReset.php
<?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'],
            )
        );
    }
}
<file_sep>/tests/Session/SymfonySessionTest.php
<?php

namespace PicoAuth\Session;

use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

class SymfonySessionTest extends \PicoAuth\BaseTestCase
{
    
    protected $session;
    
    protected function setUp()
    {
        $storage = new MockArraySessionStorage();
        $this->session = new SymfonySession($storage);
    }
    
    public function testClear()
    {
        $symfonySession = $this->createMock(Session::class);
        $symfonySession->expects($this->once())
            ->method('clear');
        self::set($this->session, $symfonySession, 'session');
        
        $this->session->clear();
    }

    public function testInvalidate()
    {
        $symfonySession = $this->createMock(Session::class);
        $symfonySession->expects($this->once())
            ->method('invalidate')
            ->with(321)
            ->willReturn(123);
        self::set($this->session, $symfonySession, 'session');
        
        $res = $this->session->invalidate(321);
        $this->assertEquals(123, $res);
    }
    
    public function testMigrate()
    {
        $symfonySession = $this->createMock(Session::class);
        $symfonySession->expects($this->once())
            ->method('migrate')
            ->with(true, 123)
            ->willReturn(123);
        self::set($this->session, $symfonySession, 'session');
        
        $res = $this->session->migrate(true, 123);
        $this->assertEquals(123, $res);
    }
 
    public function testGetFlash()
    {
        $parameterBag = $this->createMock(ParameterBag::class);
        $parameterBag->expects($this->once())
            ->method('get')
            ->with("key", array(1))
            ->willReturn(array("test"));
        
        $symfonySession = $this->createMock(Session::class);
        $symfonySession->expects($this->once())
            ->method('getFlashBag')
            ->willReturn($parameterBag);
        self::set($this->session, $symfonySession, 'session');
        
        $res = $this->session->getFlash("key", array(1));
        $this->assertEquals(array("test"), $res);
    }
}
<file_sep>/tests/PicoAuthPluginTest.php
<?php

namespace PicoAuth;

use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

/**
 * PicoAuth plugin tests
 */
class PicoAuthPluginTest extends BaseTestCase
{
    
    /**
     * PicoAuth plugin instance
     * @var PicoAuthPlugin
     */
    protected $picoAuth;
    
    /**
     * Session
     * @var \PicoAuth\Session\SessionInterface
     */
    protected $session;
    
    protected function setUp()
    {
        $storage = new MockArraySessionStorage();
        $this->session = new Session\SymfonySession($storage);
        
        $picoMock = $this->getMockBuilder('\Pico')
            ->disableOriginalConstructor()
            ->getMock();
        $this->picoAuth = new PicoAuthPlugin($picoMock);
        self::set($this->picoAuth, $this->session, 'session');
    }
    
    public function testIsValidCSRF()
    {
        $csrf = $this->createMock(Security\CSRF::class);
        $csrf->expects($this->once())
            ->method('checkToken')
            ->with('valid')
            ->willReturn(true);
        
        self::set($this->picoAuth, $csrf, 'csrf');
        
        $this->assertTrue($this->picoAuth->isValidCSRF('valid'));
    }
    
    public function testIsInvalidCSRF()
    {
        $csrf = $this->createMock(Security\CSRF::class);
        $csrf->expects($this->once())
            ->method('checkToken')
            ->with('invalid')
            ->willReturn(false);
        
        self::set($this->picoAuth, $csrf, 'csrf');
        
        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())
               ->method('warning')->willReturnSelf();
        self::set($this->picoAuth, $logger, 'logger');
        $_SERVER["REMOTE_ADDR"] = 'localhost';
        
        $this->assertFalse($this->picoAuth->isValidCSRF('invalid'));
    }
    
    public function testAfterLogin()
    {
        $session = $this->createMock(Session\SessionInterface::class);
        $session->expects($this->once())
            ->method("migrate")
            ->with(true);
        $session->expects($this->once())
            ->method("set")
            ->with('user', $this->anything());
        $session->expects($this->once())
            ->method("has")
            ->with('afterLogin')
            ->willReturn(true);
        $picoAuth = $this->getMockBuilder(PicoAuthPlugin::class)
            ->disableOriginalConstructor()
            ->setMethodsExcept(['afterLogin','setUser'])
            ->getMock();
        
        $csrf = $this->createMock(Security\CSRF::class);
        $csrf->expects($this->once())
            ->method('removeTokens');

        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())
               ->method('info')->willReturnSelf();
        $user = $this->createMock(User::class);
        $user->expects($this->once())
            ->method('getId');
        $user->expects($this->once())
            ->method('getDisplayName');
        $user->expects($this->once())
            ->method('getAuthenticator');
        $r=\Symfony\Component\HttpFoundation\Request::create('/');

        self::set($picoAuth, $csrf, 'csrf');
        self::set($picoAuth, $session, 'session');
        self::set($picoAuth, $logger, 'logger');
        self::set($picoAuth, $r, 'request');
        $picoAuth->setUser($user);
        $picoAuth->afterLogin();
    }
    
    public function testSetUser()
    {
        $user = new User;
        $this->picoAuth->setUser($user);
        
        $this->assertSame($this->picoAuth->getUser(), $user);
        
        $groups = $user->getGroups();
        
        $this->assertFalse(array_search("default", $groups));
        
        $user = new User;
        $user->setAuthenticated(true);
        $this->picoAuth->setUser($user);
        $groups = $user->getGroups();
        
        // Authenticated user should be gained the default group
        $this->assertNotFalse(array_search("default", $groups));
    }
    
    public function testGetModule()
    {
        self::set($this->picoAuth, array('Name'=>1), 'modules');
        $this->assertEquals(1, $this->picoAuth->getModule('Name'));
        
        self::set($this->picoAuth, array(), 'modules');
        $this->assertNull($this->picoAuth->getModule('Name'));
        $this->assertNull($this->picoAuth->getModule(null));
    }
    
    public function testGetPico()
    {
        $picoMock = $this->getMockBuilder('\Pico')
            ->disableOriginalConstructor()
            ->getMock();
        $picoAuth = new PicoAuthPlugin($picoMock);
        $this->assertSame($picoMock, $picoAuth->getPico());
    }
    
    public function testGetDefaultThemeUrl()
    {
        $picoMock = $this->createMock(\Pico::class);
        $picoMock->expects($this->once())
            ->method("getBaseUrl")
            ->willReturn("/");
        $picoAuth = new PicoAuthPlugin($picoMock);
        $this->assertSame("/plugins/PicoAuth/theme", $picoAuth->getDefaultThemeUrl());
    }
    
    public function testSetRequestFile()
    {
        $picoMock = $this->createMock(\Pico::class);
        $picoAuth = new PicoAuthPlugin($picoMock);
        $picoAuth->setRequestFile("/sub/page.md");
        $reqFile=self::get($picoAuth, "requestFile");
        $this->assertSame("/sub/page.md", $reqFile);
    }
    
    public function testSetRequestUrl()
    {
        $picoMock = $this->createMock(\Pico::class);
        $picoMock->expects($this->once())
            ->method("resolveFilePath")
            ->with("sub/page")
            ->willReturn("/sub/page.md");
        $picoAuth = new PicoAuthPlugin($picoMock);
        $picoAuth->setRequestUrl("sub/page");
        $reqUrl=self::get($picoAuth, "requestUrl");
        $this->assertSame("sub/page", $reqUrl);
        $reqFile=self::get($picoAuth, "requestFile");
        $this->assertSame("/sub/page.md", $reqFile);
    }
    
    public function testGetFlashes()
    {
        $session = $this->createMock(Session\SessionInterface::class);
        $session->expects($this->exactly(2))
            ->method("getFlash")
            ->willReturn(["test"]);
        $picoMock = $this->getMockBuilder('\Pico')
            ->disableOriginalConstructor()
            ->getMock();
        $picoAuth = new PicoAuthPlugin($picoMock);
        self::set($picoAuth, $session, 'session');

        $res=$picoAuth->getFlashes();
        $this->assertTrue(count($res)==2);
    }
    
    public function testOnConfigLoaded()
    {
        $picoAuth = $this->getMockBuilder(PicoAuthPlugin::class)
            ->disableOriginalConstructor()
            ->setMethods(['loadDefaultConfig','createContainer','initLogger'])
            ->getMock();
        $picoAuth->expects($this->once())
            ->method("loadDefaultConfig");
        $picoAuth->expects($this->once())
            ->method("createContainer");
        $picoAuth->expects($this->once())
            ->method("initLogger");
        $config=[];
        $picoAuth->handleEvent("onConfigLoaded", [&$config]);
    }
    
    public function testOnRequestUrl()
    {
        $picoAuth = $this->getMockBuilder(PicoAuthPlugin::class)
            ->disableOriginalConstructor()
            ->setMethods(['init','triggerEvent','errorHandler'])
            ->getMock();
        $picoAuth->expects($this->once())
            ->method("init");
        $picoAuth->expects($this->once())
            ->method("triggerEvent")
            ->willThrowException(new \RuntimeException("e"));
        $picoAuth->expects($this->once())
            ->method("errorHandler");
        $url="index";
        $picoAuth->handleEvent("onRequestUrl", [&$url]);
    }
    
    public function testAllowed()
    {
        $picoAuth = $this->getMockBuilder(PicoAuthPlugin::class)
            ->disableOriginalConstructor()
            ->setMethods(['triggerEvent'])
            ->getMock();
        
        $picoAuth->expects($this->once())
            ->method("triggerEvent")
            ->with('denyAccessIfRestricted', $this->anything());
        
        $file = "test.md";
        self::set($picoAuth, $file, 'requestFile');
        self::set($picoAuth, 'test', 'requestUrl');
        
        // Will trigger denyAccessIfRestricted
        $picoAuth->onRequestFile($file);
        
        $picoAuth->addAllowed("test");
        
        // Won't trigger denyAccessIfRestricted
        $picoAuth->onRequestFile($file);
    }
}
<file_sep>/tests/Security/Password/Encoder/Argon2iTest.php
<?php

namespace PicoAuth\Security\Password\Encoder;

use PHPUnit\Framework\TestCase;

class Argon2iTest extends TestCase
{
    
    protected function setUp()
    {
        if (!defined('PASSWORD_ARGON2I')) {
            $this->markTestSkipped(
                'ARGON2I algorithm is not available in the test environment.'
            );
        }
    }
    
    public function testEncodeDecode()
    {
        $arg = new Argon2i();
        $res = $arg->encode("Test");
        $this->assertStringStartsWith("$", $res);
        $this->assertTrue($arg->isValid($res, "Test"));
    }
    
    public function testLengthLimit()
    {
        $raw = str_repeat("r", Argon2i::MAX_LEN+1);
        $arg = new Argon2i();
        $this->assertEquals(Argon2i::MAX_LEN, $arg->getMaxAllowedLen());
        $this->assertFalse($arg->isValid("0", $raw));
        $this->expectException(EncoderException::class);
        $arg->encode($raw);
    }
    
    public function testNeedsRehash()
    {
        $arg1 = new Argon2i(['time_cost'=>1]);
        $res1 = $arg1->encode("BcryptTest");
        
        $arg2 = new Argon2i(['cost'=>2]);
        $this->assertTrue($arg2->needsRehash($res1));
    }
}
<file_sep>/README.md
PicoAuth
====
PicoAuth is a plugin for Pico CMS 2.0 providing various means of authentication and authorization to pages.

[![Build Status](https://travis-ci.org/picoauth/picoauth.svg?branch=master)](https://travis-ci.org/picoauth/picoauth)
[![Latest Stable Version](https://poser.pugx.org/picoauth/picoauth/v/stable)](https://packagist.org/packages/picoauth/picoauth)
[![License](https://poser.pugx.org/picoauth/picoauth/license)](https://github.com/picoauth/picoauth/blob/master/LICENSE)

The functionality of the plugin is contained in independent modules that can be individually enabled and configured to suit the needs of the website owner. Simple description of the modules that are included in the plugin:

* **Local user accounts**
  * Visitors can login to the site using accounts stored locally
  * User accounts defined in a configuration file
  * User registration *(optional)*
  * Password reset function *(optional)*
  * Users can change their password *(optional)*
* **Login using OAuth 2.0 services**
  * Visitors can login to the site using a 3rd party service that supports the OAuth 2.0 protocol
* **Access control**
  * Access control to Pico pages based on permissions of an authenticated user
* **Page locks**
  * Can be used to display selected pages only after the correct page key phrase is entered (no authentication required)

The plugin provides security features like CSRF tokens on all forms, IP-subnet rate limiting on sensitive actions (login attempts, registrations, etc.), option to enable logging, session security configuration, configurable password policy, selectable password hashing algorithms and other security options. There are many options for an advanced configuration like - caching configuration files, password reset options, dependency injection configuration. PicoAuth contains a default theme for its pages (login, registration form, etc.), but can be integrated into any existing Pico template with only few changes. The plugin also supports other alternative authentication/authorization methods that can be added via external plugins.

## Documentation
[GitHub wiki](https://github.com/picoauth/picoauth/wiki) or [picoauth.github.io](https://picoauth.github.io/).

Contains an Installation guide, Full Feature Reference with a description of all configuration options, Security considerations and options for advanced settings.

Screenshots
-----------
![PicoAuth](https://i.imgur.com/FMXWCZd.png)

Install
-------
PicoAuth requires **PHP 5.6.0+** The recommended way of installation is using composer (possible only if you installed Pico using [composer-installer](https://github.com/picocms/composer-installer)).
The plugin can be installed by issuing this command in the Pico root directory:

```
composer require picoauth/picoauth
```

Then visit `/PicoAuth` page (`?PicoAuth` if not using url-rewriting) in your Pico installation to view the installer, which will perform a basic security check of your Pico installation and will guide you through the rest of the installation (like selection of the modules you want to use).

*It is also possible to install the plugin without composer - by downloading the plugin archive from the [Release page](https://github.com/picoauth/picoauth/releases) and extracting it to your `plugins` directory. This is not recommended due to complicated updating, so use it only if composer cannot be used. This method of installation is more described in the [Documentation](#documentation).*

Configuration
-------------
The main plugin configuration is located in Pico's `config/config.yml`. Each enabled authentication/authorization module has its own configuration in the `config/PicoAuth` directory. Usually the file name corresponds to the module name (e.g. `LocalAuth.yml`, `OAuth.yml`, `PageACL.yml`, `PageLock.yml`). Refer to the **plugin [Documentation](#documentation) for the full configuration reference**. See project [picoauth/picoauth-examples](https://github.com/picoauth/picoauth-examples) for in-depth configuration examples.

### See also
* [picoauth/picoauth-theme](https://github.com/picoauth/picoauth-theme) - An example theme for Pico CMS using the PicoAuth plugin. Can serve as a template for customizing your own theme to work with PicoAuth. See the Demo: [https://picoauth.xyz](https://picoauth.xyz).
* [picoauth/picoauth-examples](https://github.com/picoauth/picoauth-examples) - in-depth configuration examples.

Getting help
------------
If you encounter a problem with the plugin that cannot be resolved by reading the corresponding section of the [Documentation](#documentation), you can create a new Issue. Please describe the problem as clear as possible.
<file_sep>/src/Storage/PageLockFileStorage.php
<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Interfaces\PageLockStorageInterface;
use PicoAuth\Cache\NullCache;
use Psr\SimpleCache\CacheInterface;
use PicoAuth\Storage\Configurator\PageLockConfigurator;

/**
 * File storage for PageLock
 */
class PageLockFileStorage extends FileStorage implements PageLockStorageInterface
{

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

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

    /**
     * @inheritdoc
     */
    public function getLockById($lockId)
    {
        $this->readConfiguration();
        
        return isset($this->config['locks'][$lockId]) ? $this->config['locks'][$lockId] : null;
    }

    /**
     * @inheritdoc
     */
    public function getLockByURL($url)
    {
        $this->readConfiguration();

        $urlRecord = self::getItemByUrl($this->config['urls'], $url);

        if ($urlRecord && isset($urlRecord["lock"])) {
            $lockId = $urlRecord["lock"];
            return $lockId;
        } else {
            return null;
        }
    }
}
<file_sep>/tests/Security/RateLimiting/NullRateLimitTest.php
<?php

namespace PicoAuth\Security\RateLimiting;

use PicoAuth\BaseTestCase;

class NullRateLimitTest extends BaseTestCase
{
    
    public function testAction()
    {
        $limit = new NullRateLimit();
        $this->assertTrue($limit->action("any-action"));
    }
    
    public function testGetError()
    {
        $limit = new NullRateLimit();
        $this->assertNull($limit->getError());
    }
}
<file_sep>/src/Storage/Configurator/RateLimitConfigurator.php
<?php

namespace PicoAuth\Storage\Configurator;

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

/**
 * The configurator for RateLimit
 */
class RateLimitConfigurator extends AbstractConfigurator
{
    
    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault(), 1);
        $this->validateGlobalOptions($config);
        $this->validateActionsSection($config);
        return $config;
    }
    
    /**
     * The default configuration must contain all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "cleanupProbability" => 25,
            "actions" => array(
                "login" => array(
                    "ip" => array(
                        "count" => 50,
                        "counterTimeout" => 43200,
                        "blockDuration" => 900,
                        "errorMsg" => "Amount of failed attempts exceeded, wait %min% minutes.",
                    ),
                    "account" => array(
                        "count" => 10,
                        "counterTimeout" => 43200,
                        "blockDuration" => 900,
                        "errorMsg" => "Amount of failed attempts exceeded, wait %min% minutes.",
                    )
                ),
                "passwordReset" => array(
                    "email" => array(
                        "count" => 2,
                        "counterTimeout" => 86400,
                        "blockDuration" => 86400,
                        "errorMsg" => "Maximum of %cnt% reset emails were sent, check your inbox.",
                    ),
                    "ip" => array(
                        "count" => 10,
                        "counterTimeout" => 86400,
                        "blockDuration" => 86400,
                        "errorMsg" => "Amount of maximum submissions exceeded, wait %min% minutes.",
                    ),
                ),
                "registration" => array(
                    "ip" => array(
                        "count" => 2,
                        "blockDuration" => 86400,
                        "errorMsg" => "Amount of maximum submissions exceeded, wait %min% minutes.",
                    ),
                ),
                "pageLock" => array(
                    "ip" => array(
                        "count" => 10,
                        "blockDuration" => 1800,
                    ),
                ),
            )
        );
    }
    
    public function validateGlobalOptions(&$config)
    {
        $this->assertInteger($config, "cleanupProbability", 0, 100);
    }
 
    public function validateActionsSection($config)
    {
        $this->assertArray($config, "actions");
        foreach ($config["actions"] as $actionName => $limitMethods) {
            if (!is_string($actionName) || $actionName=="") {
                throw new ConfigurationException("Action identifier must be a string.");
            }
            $this->assertArray($config["actions"], $actionName);
            
            try {
                $this->validateLimitMethodsSection($limitMethods);
            } catch (ConfigurationException $e) {
                $e->addBeforeMessage("Invalid rateLimit data for $actionName:");
                throw $e;
            }
        }
    }
    
    public function validateLimitMethodsSection(&$config)
    {
        foreach ($config as $methodName => $limitData) {
            if (!is_string($methodName) || $methodName=="") {
                throw new ConfigurationException("Limit method identifier must be a string.");
            }
            $this->assertArray($config, $methodName);
            
            // Mandatory attributes
            $this->assertRequired($limitData, "count");
            $this->assertInteger($limitData, "count", 0);
            $this->assertRequired($limitData, "blockDuration");
            $this->assertInteger($limitData, "blockDuration", 0);
            
            // Set counterTimeout to blockDuration if not specified
            if (!isset($limitData["counterTimeout"])) {
                $limitData["counterTimeout"] = $limitData["blockDuration"];
            } else {
                $this->assertInteger($limitData, "counterTimeout", 0);
            }
            
            // Optional attributes
            $this->assertString($limitData, "errorMsg");
            $this->assertInteger($limitData, "netmask_IPv4", 0, 32);
            $this->assertInteger($limitData, "netmask_IPv6", 0, 128);
        }
    }
}
<file_sep>/theme/js/installer.js
$(document).ready(function() {
    $('#repeatTest').show();
    var inst=new Installer(url_check);
    $('#repeatTest').click(function(){
        inst.checkUrls().bind(inst);
    });
    inst.checkUrls();
});

//------ Response testing results constants -------
/**
 * The returned response has a successful status code and contains
 * most likely a text file (yaml/markup)
 * @type {Number}
 */
var RESPONSE_OK_TEXT=1;

/**
 * The returned response has a successful status code and contains HTML
 * @type {Number}
 */
var RESPONSE_OK_HTML=2;

/**
 * The response is 403 or 404 status code, which is expected for all
 * of the performed pre-installation tests
 * @type {Number}
 */
var RESPONSE_NA_EXPECTED=3;

/**
 * The response does not have a successful status code, but it is not expected
 * for the pre-installation tests
 * @type {Number}
 */
var RESPONSE_NA_UNEXPECTED_STATUS=4;

/**
 * No response received or other error while sending the request
 * @type {Number}
 */
var NO_RESPONSE=5;
//-----------------------------------------------------


function Installer(urls){
    this.urls=urls;
};

Installer.prototype.checkUrls = function(){
    $('#dir_listing, #config_file, #content_file')
        .text("Testing...")
        .removeClass();
    this.testRequest(this.urls.dir_listing, 'dir_listing');
    this.testRequest(this.urls.config_file, 'config_file');
    this.testRequest(this.urls.content_file, 'content_file');
};

Installer.prototype.showTestResult = function(testId, errId, message){
    var elem=$('#'+testId);
    var result;
    
    switch(errId){
        case RESPONSE_OK_TEXT:
            result='critical';
            break;
        case RESPONSE_OK_HTML:
            if(testId==='dir_listing'){
                result='critical';
            }else{
                result='warning';
            }
            break;
        case RESPONSE_NA_EXPECTED:
            result='success';
            break;
        default:
            result='warning';
            break;
    }
    
    elem.html("<strong>"+result.toUpperCase()+"</strong> - "+message);
    elem.addClass(result);
};

Installer.prototype.testRequest = function(url, testId){
    var self=this;
    $.ajax({
        url: url,
        dataType: "text",
        crossDomain: false,
        timeout: 5000,
        cache: false,
        testId: testId,
        resCallback: function(errId, message){
            self.showTestResult(this.testId, errId, message);
            
        },
        beforeSend: function( xhr ) {
            xhr.overrideMimeType( "text/plain" );
        },
        success: function( data, textStatus, xhr ){
            var isTextFile=true;
            if(data.indexOf("<html") >= 0){
                isTextFile=false;
            }
            if(isTextFile){
                this.resCallback(RESPONSE_OK_TEXT,"Text file returned, HTTP "+xhr.status+".");
            }else{
                this.resCallback(RESPONSE_OK_HTML,"Returned successful response, HTTP "+xhr.status+".");
            }
        },
        error: function( xhr, status, errorThrown ) {
            if(typeof xhr.status !== "undefined"){
                if(xhr.status===403 || xhr.status===404){
                    this.resCallback(RESPONSE_NA_EXPECTED,"Not accessible (returns HTTP "+xhr.status+").");
                }else{
                    this.resCallback(RESPONSE_NA_UNEXPECTED_STATUS,"Returned "+xhr.status+", was expecting 403 or 404.");
                }
            }else{
                this.resCallback(NO_RESPONSE,"No response received, "+errorThrown+".");
            }
        }
    });
};
<file_sep>/src/Security/Password/Encoder/BCrypt.php
<?php

namespace PicoAuth\Security\Password\Encoder;

/**
 * BCrypt
 */
class BCrypt implements PasswordEncoderInterface
{

    /**
     * The maximum allowed length
     *
     * The BCrypt algorithm does not use characters after this length.
     */
    const MAX_LEN = 72;
    
    /**
     * The options array
     *
     * @var array
     */
    protected $options;

    public function __construct(array $options = [])
    {
        if (isset($options['cost'])) {
            if ($options['cost'] < 4 || $options['cost'] > 31) {
                throw new \InvalidArgumentException('Cost must be in the range of 4-31.');
            }
        }
        $defaults = array(
            'cost' => 10
        );
        $options += $defaults;
        $this->options = $options;
    }

    /**
     * @inheritdoc
     */
    public function encode($rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            throw new EncoderException("Invalid length, maximum is ".self::MAX_LEN.".");
        }
        return \password_hash($rawPassword, PASSWORD_BCRYPT, $this->options);
    }

    /**
     * @inheritdoc
     */
    public function isValid($encodedPassword, $rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            return false;
        }
        return \password_verify($rawPassword, $encodedPassword);
    }

    /**
     * @inheritdoc
     */
    public function needsRehash($encodedPassword)
    {
        return password_needs_rehash($encodedPassword, PASSWORD_BCRYPT, $this->options);
    }

    /**
     * @inheritdoc
     */
    public function getMaxAllowedLen()
    {
        return self::MAX_LEN;
    }
}
<file_sep>/tests/Storage/Configurator/PageACLConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class PageACLConfiguratorTest extends TestCase
{
    
    protected $configurator;
    
    protected function setUp()
    {
        $this->configurator = new PageACLConfigurator;
    }
    
    /**
     * @dataProvider badConfigurations
     */
    public function testValidationErrors($rawConfig)
    {
        $this->expectException(ConfigurationException::class);
        $this->configurator->validate($rawConfig);
    }

    public function badConfigurations()
    {
        return [
            [["access"=>1]],
            [["access"=>[1=>["users"=>"a"]]]],
            [["access"=>[""=>["users"=>"a"]]]],
            [["access"=>["users"=>1]]],
            [["access"=>["groups"=>1]]],
            [["access"=>[["a"=>["users"=>[1,2,3]]]]]],
            [["access"=>[["a"=>["groups"=>[1,2,3]]]]]],
            [["access"=>[["a"=>["recursive"=>1]]]]],
        ];
    }
 
    public function testValidation()
    {
        $res = $this->configurator->validate(null);
        $this->assertArrayHasKey("access", $res);
        
        $res = $this->configurator->validate([
            "access" => [
                "/a" => ["users"=>["a"]],
                "/b" => ["groups"=>["a"]],
                "/c" => ["users"=>[1],"groups"=>["a"]],
                "/c" => ["users"=>["a"],"groups"=>["a"],"recursive"=>true],
            ],
        ]);
        $this->assertArrayHasKey("access", $res);
    }
}
<file_sep>/src/Module/Authentication/LocalAuth/Registration.php
<?php

namespace PicoAuth\Module\Authentication\LocalAuth;

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

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

    use LoggerTrait;

    const REGISTER_CSRF_ACTION = 'register';

    /**
     * The password reser request
     *
     * @var Request
     */
    protected $httpRequest;
    
    /**
     * The registration configuration array
     *
     * @var array
     */
    protected $config;
    
    /**
     * Instance of a rate limiter
     *
     * @var RateLimitInterface
     */
    protected $limit;
    
    /**
     * Instance of the plugin
     *
     * @var PicoAuthInterface
     */
    protected $picoAuth;
    
    /**
     * Session manager
     *
     * @var SessionInterface
     */
    protected $session;
    
    /**
     * Configuration storage
     *
     * @var LocalAuthStorageInterface
     */
    protected $storage;

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

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

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

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

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

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

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

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

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

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

                $this->logSuccessfulRegistration($reg);

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

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

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

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

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

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

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

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

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

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

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

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

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

        return $isValid;
    }

    /**
     * Logs successful registration
     *
     * @param array $reg Registration array
     */
    protected function logSuccessfulRegistration(array $reg)
    {
        $this->getLogger()->info(
            "New registration: {name} ({email}) from {addr}",
            array(
                "name" => $reg["username"],
                "email" => $reg["email"],
                "addr" => $_SERVER['REMOTE_ADDR']
            )
        );
        
        // Log the amount of users on each 10% of the maximum capacity
        $max = $this->config["maxUsers"];
        $count = $this->storage->getUsersCount()+1;
        if ($count % ceil($max/10) === 0) {
            $percent = intval($count/ceil($max/100));
            $this->getLogger()->warning(
                "The amount of users has reached {percent} of the maximum capacity {max}.",
                array(
                    "percent" => $percent,
                    "max" => $max
                )
            );
        }
    }
    
    /**
     * Aborts the registration if the limit of an amount of users is reached
     */
    protected function assertLimits()
    {
        if ($this->storage->getUsersCount() >= $this->config["maxUsers"]) {
            $this->session->addFlash("error", "New registrations are currently disabled.");
            $this->picoAuth->redirectToPage("register");
        }
    }
}
<file_sep>/tests/Cache/NullCacheTest.php
<?php

namespace PicoAuth\Cache;

class NullCacheTest extends \PicoAuth\BaseTestCase
{
    
    protected $nullCache;
    
    protected function setUp()
    {
        $this->nullCache = new NullCache;
    }
    
    public function testClear()
    {
        $this->assertTrue($this->nullCache->clear());
    }

    public function testDelete()
    {
        $this->assertTrue($this->nullCache->delete("test"));
    }

    public function testDeleteMultiple()
    {
        $this->assertTrue($this->nullCache->deleteMultiple(["t1","t2"]));
    }

    public function testGet()
    {
        $this->assertNull($this->nullCache->get("test"));
        $this->assertEquals(1, $this->nullCache->get("test", 1));
    }

    public function testGetMultiple()
    {
        $this->assertEquals(
            ["t1"=>null,"t2"=>null],
            $this->nullCache->getMultiple(["t1","t2"])
        );
        
        $this->assertEquals(
            ["t1"=>1,"t2"=>1],
            $this->nullCache->getMultiple(["t1","t2"], 1)
        );
    }

    public function testHas()
    {
        $this->assertFalse($this->nullCache->has("test"));
    }

    public function testSet()
    {
        $this->assertFalse($this->nullCache->set("test", 1));
    }

    public function testSetMultiple()
    {
        $this->assertFalse($this->nullCache->setMultiple(["a"=>1,"b"=>2]));
    }
}
<file_sep>/src/Module/Authentication/LocalAuth/EditAccount.php
<?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");
        }
    }
}
<file_sep>/content/login.md
---
Title: Login
Robots: noindex,nofollow
Template: login
---
<file_sep>/src/Module/Authorization/PageLock.php
<?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;
    }
}
<file_sep>/tests/Storage/Configurator/PluginConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class PluginConfiguratorTest extends TestCase
{
    
    protected $configurator;
    
    protected function setUp()
    {
        $this->configurator = new PluginConfigurator;
    }
    
    /**
     * @dataProvider badConfigurations
     */
    public function testValidationErrors($rawConfig)
    {
        $this->expectException(ConfigurationException::class);
        $this->configurator->validate($rawConfig);
    }

    public function badConfigurations()
    {
        return [
            [["authModules"=>1]],
            [["authModules"=>null]],
            [["afterLogin"=>1]],
            [["afterLogout"=>1]],
            [["alterPageArray"=>1]],
            [["sessionInterval"=>-1]],
            [["sessionInterval"=>true]],
            [["sessionTimeout"=>-1]],
            [["sessionTimeout"=>true]],
            [["sessionIdle"=>-1]],
            [["sessionIdle"=>true]],
            [["rateLimit"=>1]],
            [["debug"=>1]],
        ];
    }
    
    /**
     * Tests only the important default values
     */
    public function testDefaults()
    {
        $default = $this->configurator->validate([]);
        $this->assertFalse($default["debug"]);
        $this->assertEquals(
            ["Installer"],
            $default["authModules"]
        );
    }
}
<file_sep>/src/Security/RateLimiting/NullRateLimit.php
<?php

namespace PicoAuth\Security\RateLimiting;

/**
 * Null object of RateLimitInterface, used when rate limiting is disabled.
 */
class NullRateLimit implements RateLimitInterface
{

    /**
     * {@inheritdoc}
     */
    public function action($actionName, $increment = true, $params = array())
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function getError()
    {
        return null;
    }
}
<file_sep>/src/Module/Generic/ExampleModule.php
<?php

namespace PicoAuth\Module\Generic;

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

/**
 * An example Auth module
 *
 * This module does nothing, it contains specifications of all
 * available auth event methods triggered by Pico Auth plugin
 */
class ExampleModule extends AbstractAuthModule
{

    /**
     * Called on each request
     *
     * This is the main method of every module - contains
     * form submission checks, custom routing based on the Url or
     * the Request parameters or no operation.
     *
     * @param string|null $url Requested URL in Pico (page ID)
     * @param Request $httpRequest The current HTTP request
     */
    public function onPicoRequest($url, Request $httpRequest)
    {
    }
    
    /**
     * Checks accessibility for a give page URL
     *
     * Used for evaluating accessibility of a page (for example when removing
     * inaccessible links from Pico page array), to perform an access denial,
     * the module should also implement {@see ExampleModule::denyAccessIfRestricted()}
     *
     * @param string|null $url Pico page URL
     * @return bool true is the site is accessible, false otherwise
     */
    public function checkAccess($url)
    {
        return true;
    }

    /**
     * Denies access if restricted
     *
     * Each authorization module is in control of how it will deny the access:
     * It can be done by a redirect to a different
     * page (while aborting execution of the current request)
     * {@see PicoAuthPlugin::redirectToPage()} or {{@see PicoAuthPlugin::redirectToLogin()}},
     * or by setting a different requestFile (the requested URL will be the same,
     * but the original content will not be displayed) {@see PicoAuthPlugin::setRequestFile()}
     * or {@see PicoAuthPlugin::setRequestUrl()}.
     *
     * @param string|null $url Pico page URL
     */
    public function denyAccessIfRestricted($url)
    {
    }
    
    /**
     * Triggered after a login
     *
     * @param \PicoAuth\User $user User instance
     */
    public function afterLogin(\PicoAuth\User $user)
    {
    }
    
    /**
     * Triggered after a logout
     *
     * Module can perform a custom after-logout redirect or perform
     * a single sign out
     *
     * @param \PicoAuth\User $oldUser The previously logged in user
     */
    public function afterLogout(\PicoAuth\User $oldUser)
    {
    }

    /**
     * Required from AbstractAuthModule
     */
    public function getName()
    {
        return 'example';
    }
}
<file_sep>/src/Security/RateLimiting/RateLimitInterface.php
<?php

namespace PicoAuth\Security\RateLimiting;

/**
 * Rate Limit Interface
 */
interface RateLimitInterface
{

    /**
     * Registers an action and determines if it is rate limited or not
     *
     * @param string $actionName Identifier for the limited action
     * @param bool $increment True if the action counter should be incremented
     *                        (e.g. after unsuccessful login attempt)
     *                        False if the counter should stay unchanged
     *                        (e.g. before processing the login form)
     * @param array $params Additional parameters specific to the action
     * @return bool true if action is allowed, false if blocked
     */
    public function action($actionName, $increment = true, $params = array());

    /**
     * Get error message for the last action() call that returned false
     * @return string|null Message, null if there were no blocked actions
     */
    public function getError();
}
<file_sep>/tests/Storage/Configurator/ConfigurationExceptionTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class ConfigurationExceptionTest extends TestCase
{
       
    public function testAddBeforeMessage()
    {
        $e = new ConfigurationException("test");
        $e->addBeforeMessage("_");
        $this->assertEquals("_ test", $e->getMessage());
    }

    public function testToString()
    {
        $e = new ConfigurationException("test");
        $this->assertEquals("test", (string)$e);
    }
}
<file_sep>/src/Storage/RateLimitFileStorage.php
<?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;
    }
}
<file_sep>/src/Session/SessionInterface.php
<?php

namespace PicoAuth\Session;

/**
 * Simple session manager interface with a direct support for
 * flash values.
 */
interface SessionInterface
{

    /**
     * Checks if a session key is defined.
     * @param string $name Session attribute name
     * @return bool Whether attribute exists
     */
    public function has($name);

    /**
     * Retrieves a value from the session.
     * @param string $name Session attribute name
     * @param mixed $default Default if the attribute is not defined
     * @return mixed Value
     */
    public function get($name, $default = null);

    /**
     * Creates/replaces value of a session attribute with value.
     * @param string $name Session attribute name
     * @param mixed $value Inserted value
     */
    public function set($name, $value);

    /**
     * Removes a session key with a given name.
     * If the key doesn't exist the call does nothing.
     * @param string $name
     */
    public function remove($name);

    /**
     * Clears all session attributes and regenerates the session
     * @param int $lifetime Cookie lifetime in seconds, null = unchanged
     * @return bool Whether the invalidation was successful
     */
    public function invalidate($lifetime = null);

    /**
     * Move the session to a different session id.
     * @param bool $destroy Delete the old session
     * @param int $lifetime Cookie lifetime in seconds, null = unchanged
     */
    public function migrate($destroy = false, $lifetime = null);

    /**
     * Removes all attributes
     */
    public function clear();

    /**
     * Adds a flash value to the session.
     * The flash values should be available only in the next request
     * using the getFlash() call.
     * @param string $type Flash type (category)
     * @param mixed $message Flash value
     */
    public function addFlash($type, $message);

    /**
     * Gets flash values set in the previous request.
     * @param string $type Flash type (category)
     * @param array $default Default response if the category is not defined
     * @return array
     */
    public function getFlash($type, array $default = array());
}
<file_sep>/src/User.php
<?php
namespace PicoAuth;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /**
     * Sets user's attribute.
     * @param string $key
     * @param mixed $value
     * @return $this
     */
    public function setAttribute($key, $value)
    {
        $this->attributes[$key] = $value;
        return $this;
    }
}
<file_sep>/src/Storage/File/FileWriter.php
<?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);
        }
    }
}
<file_sep>/PicoAuth.php
<?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;
    }
}
<file_sep>/src/Security/Password/Policy/PasswordPolicy.php
<?php

namespace PicoAuth\Security\Password\Policy;

use PicoAuth\Security\Password\Password;

/**
 * Class for enforcing password constraints
 *
 * Reference:
 * Unicode graphemes regex: https://www.regular-expressions.info/unicode.html
 */
class PasswordPolicy implements PasswordPolicyInterface
{

    protected $constraints;
    protected $errors;

    /**
     * The minimum length in characters
     *
     * Unicode code point is counted as a single character (NIST recommendation)
     *
     * @param int $n
     * @return $this
     */
    public function minLength($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (mb_strlen($str) < $n) {
                return sprintf("Minimum password length is %d characters.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }
    
    /**
     * The maximum length in characters
     *
     * Note: This option is provided only to allow prevention from DoS attacks
     * using computationally intensive password hashing algorithm on very long inputs.
     * It is not advised to limit the maximum length without a specific reason.
     *
     * @param int $n
     * @return $this
     */
    public function maxLength($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (mb_strlen($str) > $n) {
                return sprintf("Maximum password length is %d characters.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }

    /**
     * The minimum amount of numeric characters
     * @param int $n
     * @return $this
     */
    public function minNumbers($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (preg_match_all("/\\p{N}/", $str) < $n) {
                return sprintf("Password must contain at least %d numbers.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }

    /**
     * The minimum amount of uppercase letters (an uppercase letter that
     * has a lowercase variant, from any language)
     * @param int $n
     * @return $this
     */
    public function minUppercase($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (preg_match_all("/\\p{Lu}/", $str) < $n) {
                return sprintf("Password must contain at least %d uppercase letters.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }

    /**
     * The minimum amount of lowercase letters (a lowercase letter that has
     * an uppercase variant, from any language)
     * @param int $n
     * @return $this
     */
    public function minLowercase($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (preg_match_all("/\\p{Ll}/", $str) < $n) {
                return sprintf("Password must contain at least %d lowercase letters.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }
    
    /**
     * Minimum number of special characters (any character other
     * than letter or number)
     *
     * Matches also language specific letters
     * Possible replacement: "/[\\p{M}\\p{Z}\\p{S}\\p{P}]/"
     * But does not match e.g. "ˇ"
     *
     * @param int $n
     * @return $this
     */
    public function minSpecial($n)
    {
        $this->constraints[] = (function (Password $str) use ($n) {
            if (preg_match_all("/[^A-Za-z0-9]/", $str) < $n) {
                return sprintf("Password must contain at least %d special characters.", $n);
            } else {
                return true;
            }
        });
        return $this;
    }

    /**
     * Matches provided regular expression
     *
     * @param string $regexp Regular expression that must be matched
     * @param string $message Error message on failure
     * @return $this
     * @throws \InvalidArgumentException
     */
    public function matches($regexp, $message)
    {
        if (!is_string($regexp) || !is_string($message)) {
            throw new \InvalidArgumentException("Both arguments must be string.");
        }
        $this->constraints[] = (function (Password $str) use ($regexp, $message) {
            if (!preg_match($regexp, $str)) {
                return $message;
            } else {
                return true;
            }
        });
        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function check(Password $str)
    {
        $this->errors = array();

        foreach ($this->constraints as $constraint) {
            $res = $constraint($str);
            if ($res !== true) {
                $this->errors[] = $res;
            }
        }

        return count($this->errors) === 0;
    }

    /**
     * {@inheritdoc}
     */
    public function getErrors()
    {
        return $this->errors;
    }
}
<file_sep>/tests/Module/Authorization/PageLockTest.php
<?php

namespace PicoAuth\Module\Authorization;

use PicoAuth\Storage\Interfaces\PageLockStorageInterface;
use PicoAuth\Security\RateLimiting\RateLimitInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use League\Container\Container;

class PageLockTest extends \PicoAuth\BaseTestCase
{
    
    protected $pageLock;
    
    protected $testContainer;

    protected function setUp()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageLockStorageInterface::class);
        $storage->expects($this->exactly(1))
            ->method('getConfiguration')
            ->willReturn(null);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $this->pageLock = new PageLock($picoAuth, $sess, $storage, $rateLimit);

        $this->testContainer=new Container;
        $this->testContainer->share('plain', 'PicoAuth\Security\Password\Encoder\Plaintext');
    }
    
    public function testCheckAccess()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $storage = $this->createMock(PageLockStorageInterface::class);
        $storage->expects($this->exactly(3))
            ->method('getLockByURL')
            ->with("url")
            ->willReturnOnConsecutiveCalls("lock1", "lock2", null);
        
        $sess = $this->createMock(SessionInterface::class);
        $sess->expects($this->exactly(2))
            ->method('get')
            ->with("unlocked")
            ->willReturn(["lock1"]);
        
        $pageLock = new PageLock($picoAuth, $sess, $storage, $rateLimit);
        
        $this->assertTrue($pageLock->checkAccess("url"));
        $this->assertFalse($pageLock->checkAccess("url"));
        $this->assertTrue($pageLock->checkAccess("url"));
    }
        
    public function testGetKeyEncoder()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($this->testContainer);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageLockStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn(null);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $this->pageLock = new PageLock($picoAuth, $sess, $storage, $rateLimit);
        
        $method=self::getMethod($this->pageLock, 'getKeyEncoder');
        $method->invokeArgs($this->pageLock, [
            ["encoder" => "plain"]
        ]);
    }

    public function testGetName()
    {
        $this->assertEquals('pageLock', $this->pageLock->getName());
    }
}
<file_sep>/src/Storage/Configurator/OAuthConfigurator.php
<?php

namespace PicoAuth\Storage\Configurator;

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

/**
 * The configurator for OAuth
 */
class OAuthConfigurator extends AbstractConfigurator
{
    
    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault(), 1);
        $this->validateGlobalOptions($config);
        $this->validateProvidersSection($config);
        return $config;
    }
    
    /**
     * The default configuration must contain all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "callbackPage" => "oauth_callback",
            "providers" => [],
        );
    }
    
    public function validateGlobalOptions(&$config)
    {
        $this->assertString($config, "callbackPage");
        $config["callbackPage"]=trim($config["callbackPage"], "/");
    }
 
    public function validateProvidersSection(&$config)
    {
        // The 3 mandatory string values have default=0,
        // so they won't be allowed unless they are set.
        $requiredFileds = array(
            "provider" => "\League\OAuth2\Client\Provider\GenericProvider",
            "options" => array(
                "clientId" => 0,
                "clientSecret" => 0,
            ),
            "attributeMap" => array(
                "userId" => "id"
            ),
            "default" => array(
                "groups" => [],
                "attributes" => []
            )
        );
        foreach ($config["providers"] as $name => $rawProviderData) {
            $this->assertProviderName($name);
            $this->assertArray($config["providers"], $name);
            $providerData = $config["providers"][$name] = $this->applyDefaults($rawProviderData, $requiredFileds, 2);
            $this->assertString($providerData, "provider");
            $this->assertString($providerData["options"], "clientId");
            $this->assertString($providerData["options"], "clientSecret");
            foreach ($providerData["attributeMap"] as $attrName => $mappedName) {
                if (!is_string($attrName) || !is_string($mappedName)) {
                    throw new ConfigurationException("Provider attribute map can contain only strings.");
                }
            }
            $this->assertArrayOfStrings($providerData["default"], "groups");
        }
    }
    
    public function assertProviderName($name)
    {
        if (!is_string($name) || $name==="") {
            throw new ConfigurationException("Provider name must be a non-empty string.");
        }
    }
}
<file_sep>/tests/Module/Authentication/LocalAuth/LocalAuthTest.php
<?php

namespace PicoAuth\Module\Authentication\LocalAuth;

use PicoAuth\Storage\Interfaces\LocalAuthStorageInterface;
use PicoAuth\Security\RateLimiting\RateLimitInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ParameterBag;
use League\Container\Container;
use PicoAuth\Storage\Configurator\LocalAuthConfigurator;

class LocalAuthTest extends \PicoAuth\BaseTestCase
{
    
    protected $localAuth;
    
    protected $configurator;
    
    protected $testContainer;

    protected function setUp()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $this->localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $this->testContainer=new Container;
        $this->initTestContainer($this->testContainer);
        
        $this->configurator = new LocalAuthConfigurator;
    }
    
    protected function initTestContainer(Container $container)
    {
        $shortEncoder = $this->createMock(\PicoAuth\Security\Password\Encoder\PasswordEncoderInterface::class);
        $shortEncoder->expects($this->any())
            ->method('getMaxAllowedLen')
            ->willReturn(4);
        $shortEncoder->expects($this->any())
            ->method('encode')
            ->willThrowException(new \PicoAuth\Security\Password\Encoder\EncoderException("Too long"));
        
        $container->share('plain', 'PicoAuth\Security\Password\Encoder\Plaintext');
        $container->share('plain2', 'PicoAuth\Security\Password\Encoder\Plaintext');
        $container->share('shortPw', $shortEncoder);
    }
    
    public function testHandleLoginNoPostFileds()
    {
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn([]);
        
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $sess = $this->createMock(SessionInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
        
        $request = $this->createMock(Request::class);
        $request->request = $this->createMock(ParameterBag::class);
        $localAuth->onPicoRequest("login", $request);
    }
    
    public function testHandleLoginInvalidCSRF()
    {
        $request = $this->createMock(Request::class);
        $post = new ParameterBag(["username"=>"test","password"=>"<PASSWORD>"]);
        $request->request = $post;
        
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn([]);
        
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('isValidCSRF')
            ->withAnyParameters()
            ->willReturn(false);
        $picoAuth->expects($this->once())
            ->method("redirectToLogin")
            ->with($this->isNull(), $this->equalTo($request));
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("login", $request);
    }
    
    public function testHandleLoginRateLimited()
    {
        $request = $this->createMock(Request::class);
        $post = new ParameterBag(["username"=>"test","password"=>"<PASSWORD>"]);
        $request->request = $post;
        
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate([]));
        
        // The user is rate limited
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $rateLimit->expects($this->once())
            ->method("action")
            ->withAnyParameters()
            ->willReturn(false);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('isValidCSRF')
            ->withAnyParameters()
            ->willReturn(true);
        $picoAuth->expects($this->once())
            ->method("redirectToLogin")
            ->with($this->isNull(), $this->equalTo($request));
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("login", $request);
    }
    
    public function testHandleLoginInvalid()
    {
        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["username"=>"testtt","password"=>"<PASSWORD>"]);
        
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate(["registration" => ["nameLenMax"=>4]]));
        
        // The user is rate limited
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $rateLimit->expects($this->exactly(2))
            ->method("action")
            ->withAnyParameters()
            ->willReturn(true);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('isValidCSRF')
            ->withAnyParameters()
            ->willReturn(true);
        $picoAuth->expects($this->once())
            ->method("redirectToLogin")
            ->with($this->isNull(), $this->equalTo($request));
        
        $localAuth = $this->getMockBuilder(LocalAuth::class)
            ->setConstructorArgs([$picoAuth, $sess, $storage, $rateLimit])
            ->setMethodsExcept(['onPicoRequest','handleLogin'])
            ->getMock();
        $localAuth->expects($this->once())
            ->method('loginAttempt')
            ->willReturn(false);
        $_SERVER["REMOTE_ADDR"] = '10.0.0.1';
        $localAuth->onPicoRequest("login", $request);
    }

    public function testHandleLoginSuccessfulWithRehash()
    {
        $request = $this->createMock(Request::class);
        $post = new ParameterBag(["username"=>"test","password"=>"<PASSWORD>"]);
        $request->request = $post;
        
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        
        // User's encoder is plain, but the configuration uses plain2 => rehash
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate(
                ["encoder" => "plain2","login" => ["passwordRehash"=>true]]
            ));
        $storage->expects($this->exactly(3))
            ->method('getUserByName')
            ->with("test")
            ->willReturn(["pwhash"=>"test","encoder"=>"plain"]);
        $storage->expects($this->once())
            ->method('saveUser')
            ->willReturn(true);
        
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $rateLimit->expects($this->once())
            ->method("action")
            ->withAnyParameters()
            ->willReturn(true);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('isValidCSRF')
            ->withAnyParameters()
            ->willReturn(true);
        $picoAuth->expects($this->once())
            ->method("afterLogin");
        $picoAuth->expects($this->exactly(2))
            ->method("getContainer")
            ->willReturn($this->testContainer);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("login", $request);
    }
    
    public function testGetName()
    {
        $this->assertEquals('localAuth', $this->localAuth->getName());
    }
    
    public function testGetConfig()
    {
        $this->assertEquals(self::get($this->localAuth, 'config'), $this->localAuth->getConfig());
    }
    
    public function testHandleAccountPageWhileNotAuthenticated()
    {
        $request = $this->createMock(Request::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);

        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('getUser')
            ->willReturn(new \PicoAuth\User);
        $picoAuth->expects($this->once())
            ->method('redirectToLogin');
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("account", $request);
    }
    
    public function testHandleAccountPageWhileAuthenticatedNotViaLocalAuth()
    {
        $request = $this->createMock(Request::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);

        $user = new \PicoAuth\User;
        $user->setAuthenticated(true)
            ->setAuthenticator('OAuth');
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('getUser')
            ->willReturn($user);
        $picoAuth->expects($this->once())
            ->method('redirectToPage')
            ->with("index");
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("account", $request);
    }
    
    public function testHandleAccountPage()
    {
        $request = $this->createMock(Request::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate([]));
        $rateLimit = $this->createMock(RateLimitInterface::class);

        $user = new \PicoAuth\User;
        $user->setAuthenticated(true)
            ->setAuthenticator($this->localAuth->getName());
        
        $editMock = $this->createMock(EditAccount::class);
        $editMock->expects($this->once())
            ->method('setConfig')
            ->willReturnSelf();
        $editMock->expects($this->once())
            ->method('handleAccountPage');
        
        $container=new Container;
        $container->share('EditAccount', $editMock);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('getUser')
            ->willReturn($user);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($container);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("account", $request);
    }
    
    public function testHandleRegistration()
    {
        $request = $this->createMock(Request::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate([]));
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $registrationMock = $this->createMock(Registration::class);
        $registrationMock->expects($this->once())
            ->method('setConfig')
            ->willReturnSelf();
        $registrationMock->expects($this->once())
            ->method('handleRegistration');
        
        $container=new Container;
        $container->share('Registration', $registrationMock);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($container);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("register", $request);
    }
    
    public function testHandlePasswordReset()
    {
        $request = $this->createMock(Request::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate([]));
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $pwResetMock = $this->createMock(PasswordReset::class);
        $pwResetMock->expects($this->once())
            ->method('setConfig')
            ->willReturnSelf();
        $pwResetMock->expects($this->once())
            ->method('handlePasswordReset');
        
        $container=new Container;
        $container->share('PasswordReset', $pwResetMock);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($container);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $localAuth->onPicoRequest("password_reset", $request);
    }
    
    public function testUserDataEncodePasswordPwreset()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn(["encoder" => "plain2","login" => ["passwordRehash"=>true]]);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($this->testContainer);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);

        $userData = ["pwreset"=>true];
        $localAuth->userDataEncodePassword($userData, new \PicoAuth\Security\Password\Password("test"));
        
        $this->assertFalse(isset($userData["pwreset"]));
        $this->assertEquals("test", $userData["pwhash"]);
    }
    
    public function testCheckPasswordPolicy()
    {
        $testedPw = new \PicoAuth\Security\Password\Password("test");
        $testedPwTooLong = new \PicoAuth\Security\Password\Password("<PASSWORD>");
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn(["encoder" => "shortPw"]);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        
        $policy = $this->createMock(\PicoAuth\Security\Password\Policy\PasswordPolicyInterface::class);
        $policy->expects($this->exactly(2))
            ->method("check")
            ->withConsecutive($this->equalTo($testedPw), $this->equalTo($testedPwTooLong))
            ->willReturnOnConsecutiveCalls(false, true);
        $policy->expects($this->once())
            ->method("getErrors")
            ->willReturn(["e1"]);
        
        $this->testContainer->share('PasswordPolicy', $policy);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->exactly(4))
            ->method("getContainer")
            ->willReturn($this->testContainer);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
        $localAuth->checkPasswordPolicy($testedPw);
        $localAuth->checkPasswordPolicy($testedPwTooLong);
    }
    
    public function testNeedsPasswordRehash()
    {
        
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->exactly(2))
            ->method('getConfiguration')
            ->willReturnOnConsecutiveCalls(
                ["encoder" => "plain","login" => ["passwordRehash"=>false]],
                ["encoder" => "plain","login" => ["passwordRehash"=>true]]
            );
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($this->testContainer);
        
        for ($i=0; $i<2; $i++) {
            $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
            $method=self::getMethod($localAuth, 'needsPasswordRehash');
            $method->invokeArgs($localAuth, [
                ["pwhash"=>"test"]
            ]);
        }
    }
    
    public function testAbortIfExpired()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        
        $pwResetMock = $this->createMock(PasswordReset::class);
        $pwResetMock->expects($this->once())
            ->method('startPasswordResetSession')
            ->with("test");
        $container=new Container;
        $container->share('PasswordReset', $pwResetMock);
        
        $picoAuth->expects($this->once())
            ->method("redirectToPage")
            ->with("password_reset");
        $picoAuth->expects($this->once())
            ->method("getContainer")
            ->willReturn($container);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
        $method=self::getMethod($localAuth, 'abortIfExpired');
        $method->invokeArgs($localAuth, [
            "test",
            ["pwreset"=>true]
        ]);
    }
    
    public function testPasswordRehash()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn(["encoder" => "shortPw"]);
        $storage->expects($this->once())
            ->method('getUserByName')
            ->with("testUser")
            ->willReturn(["pwhash"=>"teest1","encoder"=>"plain"]);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        
        $pwResetMock = $this->createMock(PasswordReset::class);
        $pwResetMock->expects($this->once())
            ->method('startPasswordResetSession')
            ->with("testUser");
        $container=new Container;
        $this->initTestContainer($container);
        $container->share('PasswordReset', $pwResetMock);
        
        $picoAuth->expects($this->once())
            ->method("redirectToPage")
            ->with("password_reset");
        $picoAuth->expects($this->exactly(2))
            ->method("getContainer")
            ->willReturn($container);
        
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
        $method=self::getMethod($localAuth, 'passwordRehash');
        $method->invokeArgs($localAuth, [
            "testUser",
            new \PicoAuth\Security\Password\Password("<PASSWORD>")
        ]);
    }
    
    /**
     * @dataProvider nameProvider
     */
    public function testIsValidUsername($name, $valid)
    {
        $sess = $this->createMock(SessionInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method('getConfiguration')
            ->willReturn($this->configurator->validate(
                ["registration" => ["nameLenMin"=>3,"nameLenMax"=>4]]
            ));
        $storage->expects($this->any())
            ->method('checkValidName')
            ->willReturn(true);
        $localAuth = new LocalAuth($picoAuth, $sess, $storage, $rateLimit);
        $res=self::getMethod($localAuth, 'isValidUsername')->invokeArgs($localAuth, [$name]);
        $this->assertSame($valid, $res);
    }
    
    public function nameProvider()
    {
        return [
            [null,false],
            [0,false],
            ["aa",false],
            ["aaa",true],
            ["aaaa",true],
            ["aaaaa",false]
        ];
    }
    
    public function testLogin()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(LocalAuthStorageInterface::class);
        $rateLimit = $this->createMock(RateLimitInterface::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method('setUser')
            ->withAnyParameters()
            ->willReturn(true);

        $localAuth = $this->getMockBuilder(LocalAuth::class)
            ->setConstructorArgs([$picoAuth, $sess, $storage, $rateLimit])
            ->setMethods(['abortIfExpired'])
            ->getMock();
        $localAuth->expects($this->once())
            ->method('abortIfExpired');
        
        $localAuth->login("user", [
            "groups"=>["g1"],"displayName"=>"User",
            "attributes"=>["a"=>1]
        ]);
    }
}
<file_sep>/tests/Security/Password/Policy/PasswordPolicyTest.php
<?php

namespace PicoAuth\Security\Password\Policy;

use PHPUnit\Framework\TestCase;
use PicoAuth\Security\Password\Password;

class PasswordPolicyTest extends TestCase
{
    
    public function testMinLength()
    {
        $pw1 = new Password("test");
        $pw2 = new Password("<PASSWORD>");
        $pw3 = new Password("1🐱"); //cat = 4 bytes
        $pw4 = new Password("<PASSWORD>🐱");
        
        $policy = new PasswordPolicy();
        $policy->minLength(5);
        $this->assertFalse($policy->check($pw1));
        $this->assertTrue($policy->check($pw2));
        $this->assertFalse($policy->check($pw3));
        $this->assertTrue($policy->check($pw4));
    }

    public function testMaxLength()
    {
        $pw1 = new Password("test");
        $pw2 = new Password("<PASSWORD>");
        $pw3 = new Password("1🐱");
        
        $policy = new PasswordPolicy();
        $policy->maxLength(4);
        $this->assertTrue($policy->check($pw1));
        $this->assertFalse($policy->check($pw2));
        $this->assertTrue($policy->check($pw3));
    }
    
    public function testMinNumbers()
    {
        $pw1 = new Password("test");
        $pw2 = new Password("<PASSWORD>");
        
        $policy = new PasswordPolicy();
        $policy->minNumbers(1);
        $this->assertFalse($policy->check($pw1));
        $this->assertTrue($policy->check($pw2));
    }
    
    public function testMinUppercase()
    {
        $pw1 = new Password("test");
        $pw2 = new Password("<PASSWORD>");
        $pw3 = new Password("<PASSWORD>");
        
        $policy = new PasswordPolicy();
        $policy->minUppercase(1);
        $this->assertFalse($policy->check($pw1));
        $this->assertTrue($policy->check($pw2));
        $this->assertTrue($policy->check($pw3));
    }
    
    public function testMinLowercase()
    {
        $pw1 = new Password("TEST");
        $pw2 = new Password("<PASSWORD>");
        
        // \p{Ll} regex specifier is unable to match ř as lowercase
        //$pw3 = new Password("<PASSWORD>ř");
        
        $policy = new PasswordPolicy();
        $policy->minLowercase(1);
        $this->assertFalse($policy->check($pw1));
        $this->assertTrue($policy->check($pw2));
    }
    
    public function testMinSpecial()
    {
        $pw1 = new Password("<PASSWORD>");
        $pw2 = new Password("<PASSWORD>^");
        
        $policy = new PasswordPolicy();
        $policy->minSpecial(1);
        $this->assertFalse($policy->check($pw1));
        $this->assertTrue($policy->check($pw2));
    }
    
    public function testMatches()
    {
        $pw1 = new Password("test");
        $pw2 = new Password("<PASSWORD>");
        
        $policy = new PasswordPolicy();
        $policy->matches('/.*1$/', 'explanation');
        $this->assertFalse($policy->check($pw1));
        $this->assertEquals(array('explanation'), $policy->getErrors());
        $this->assertTrue($policy->check($pw2));
        
        $this->expectException(\InvalidArgumentException::class);
        $policy->matches(null, null);
    }
}
<file_sep>/tests/Security/CSRFTest.php
<?php

namespace PicoAuth\Security;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

class CSRFTest extends TestCase
{

    protected $CSRF;
    protected $session;

    protected function setUp()
    {
        $storage = new MockArraySessionStorage();
        $this->session = new \PicoAuth\Session\SymfonySession($storage);
        $this->CSRF = new CSRF($this->session);
    }

    public function testTokenAttributes()
    {
        // Generic (unnamed) token
        $this->CSRF->getToken();
        
        $this->assertTrue($this->session->has('CSRF'));
        
        $tokenStorage = $this->session->get('CSRF');
        
        $this->assertArrayHasKey(CSRF::DEFAULT_SELECTOR, $tokenStorage);
        
        $tokenData = $tokenStorage[CSRF::DEFAULT_SELECTOR];
        
        // Token attributes
        $this->assertArrayHasKey('time', $tokenData);
        $this->assertArrayHasKey('token', $tokenData);
        
        // Token time
        $this->assertGreaterThanOrEqual($tokenData['time'], time());
    }
    
    public function testTokenFormat()
    {
        $token = $this->CSRF->getToken();
        
        $this->assertEquals(2, count(explode(CSRF::TOKEN_DELIMTER, $token)));
    }

    /**
     * @depends testTokenAttributes
     */
    public function testTokenLength()
    {
        // Generic (unnamed) token
        $this->CSRF->getToken();

        $tokenData = $this->session->get('CSRF')[CSRF::DEFAULT_SELECTOR];
        
        // Assert token length in bytes
        $this->assertEquals(CSRF::TOKEN_SIZE, strlen($tokenData['token'])/2);
    }
    
    public function testGetToken()
    {
        $this->assertTrue(is_string($this->CSRF->getToken()));
        
        // Two tokens are not same
        $this->assertNotEquals($this->CSRF->getToken(), $this->CSRF->getToken());
        $this->assertNotEquals($this->CSRF->getToken("a"), $this->CSRF->getToken("a"));
    }
    
    public function testCheckToken()
    {
        $token = $this->CSRF->getToken(null, false); //not reusable
        $actionToken = $this->CSRF->getToken("action", true); //reusable
        $this->assertFalse($this->CSRF->checkToken("test"));
        $this->assertFalse($this->CSRF->checkToken("test", "123"));
        $this->assertFalse($this->CSRF->checkToken("test", "action"));
        
        // First validation
        $this->assertTrue($this->CSRF->checkToken($token));
        $this->assertTrue($this->CSRF->checkToken($actionToken, "action"));
        
        // Second validation of the same token
        $this->assertFalse($this->CSRF->checkToken($token));
        $this->assertTrue($this->CSRF->checkToken($actionToken, "action"));
    }
    
    /**
     * When a token is requested for the 2nd time, the time should be updated.
     * @depends testTokenAttributes
     */
    public function testTokenTimeUpdate()
    {
        $token = $this->CSRF->getToken('test');
        $tokenStorage = $this->session->get('CSRF');
        $tokenValue = $tokenStorage['test']['token'];
        $newTime = ($tokenStorage['test']['time'] -= CSRF::TOKEN_VALIDITY/2);

        // Save the token with altered time
        $this->session->set('CSRF', $tokenStorage);
        
        // Request the same token
        $token2 = $this->CSRF->getToken('test');
        $tokenStorage = $this->session->get('CSRF');
        $tokenValue2 = $tokenStorage['test']['token'];
        
        // Internal token value should remain unchanged
        $this->assertEquals($tokenValue, $tokenValue2);
        
        // Token time should be updated (2nd > 1st)
        $this->assertGreaterThan($newTime, $tokenStorage['test']['time']);
        
        // First token still valid
        $this->assertTrue($this->CSRF->checkToken($token, 'test'));
    }
    
    public function testTokenExpiration()
    {
        $token = $this->CSRF->getToken('expireAction');
        $tokenStorage = $this->session->get('CSRF');
        
        // expire
        $tokenStorage['expireAction']['time'] = 0;

        // Save the token with altered time
        $this->session->set('CSRF', $tokenStorage);
        
        // Expired token will not validate
        $this->assertFalse($this->CSRF->checkToken($token, 'expireAction'));
    }
    
    public function testRemoveTokens()
    {
        $token = $this->CSRF->getToken();
        $this->CSRF->removeTokens();
        $this->assertFalse($this->CSRF->checkToken($token));
    }
}
<file_sep>/src/Storage/Configurator/PageLockConfigurator.php
<?php

namespace PicoAuth\Storage\Configurator;

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

/**
 * The configurator for PageLock
 */
class PageLockConfigurator extends AbstractConfigurator
{
    
    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault(), 0);
        $this->validateGlobalOptions($config);
        $this->validateLocksSection($config);
        $this->validateUrlsSection($config);
        return $config;
    }
    
    /**
     * The default configuration must contain all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "encoder" => "bcrypt",
            "locks" => [],
            "urls" => [],
        );
    }
    
    public function validateGlobalOptions(&$config)
    {
        $this->assertString($config, "encoder");
    }
 
    public function validateLocksSection($config)
    {
        $this->assertArray($config, "locks");
        foreach ($config["locks"] as $lockId => $lockData) {
            if (!is_string($lockId) || $lockId=="") {
                throw new ConfigurationException("Lock identifier must be a string.");
            }
            $this->assertArray($config["locks"], $lockId);
            
            // Mandatory attributes
            $this->assertRequired($lockData, "key");
            $this->assertString($lockData, "key");

            // Optional attributes
            $this->assertString($lockData, "encoder");
            $this->assertString($lockData, "file");
        }
    }
    
    public function validateUrlsSection(&$config)
    {
        $this->assertArray($config, "urls");
        foreach ($config["urls"] as $pageUrl => $ruleData) {
            if (!is_string($pageUrl) || $pageUrl=="") {
                throw new ConfigurationException("Page URL must be a string.");
            }
            $this->assertArray($config["urls"], $pageUrl);
            
            // Add leading /, remove trailing /
            $this->standardizeUrlFormat($config["urls"], $pageUrl);
            
            // Mandatory attributes
            $this->assertRequired($ruleData, "lock");
            $this->assertString($ruleData, "lock");

            // Optional attributes
            $this->assertBool($ruleData, "recursive");
        }
    }
}
<file_sep>/content/install.md
---
Title: Install PicoAuth
Robots: noindex,nofollow
Template: installer
---

# PicoAuth Installation
<file_sep>/content/logout.md
---
Title: Logout
Robots: noindex,nofollow
Template: logout
---
<file_sep>/tests/Security/Password/Encoder/PlaintextTest.php
<?php

namespace PicoAuth\Security\Password\Encoder;

use PHPUnit\Framework\TestCase;

class PlaintextTest extends TestCase
{
    
    public function testConstruct()
    {
        $this->expectException(\InvalidArgumentException::class);
        $p = new Plaintext(['ignoreCase'=>100]);
    }
    
    public function testEncodeDecode()
    {
        $ptext = new Plaintext();
        $res = $ptext->encode("Test");
        $this->assertEquals("Test", $res);
        $this->assertFalse($ptext->isValid($res, "test"));
        $this->assertTrue($ptext->isValid($res, "Test"));
    }
    
    public function testLengthLimit()
    {
        $raw = str_repeat("r", Plaintext::MAX_LEN+1);
        $ptext = new Plaintext();
        $this->assertEquals(Plaintext::MAX_LEN, $ptext->getMaxAllowedLen());
        $this->assertFalse($ptext->isValid("0", $raw));
        $this->expectException(EncoderException::class);
        $ptext->encode($raw);
    }
    
    public function testNeedsRehash()
    {
        $ptext = new Plaintext();
        $this->assertFalse($ptext->needsRehash('a'));
    }
    
    public function testIgnoreCase()
    {
        $ptext = new Plaintext(['ignoreCase'=>true]);
        $res = $ptext->encode("Test");
        $this->assertTrue($ptext->isValid($res, "test"));
        $this->assertTrue($ptext->isValid($res, "TesT"));
    }
}
<file_sep>/src/Mail/MailerInterface.php
<?php

namespace PicoAuth\Mail;

/**
 * A simple Mailer Interface PicoAuth uses to send mail.
 */
interface MailerInterface
{

    /**
     * Initialize the mailer before sending mail
     */
    public function setup();

    /**
     * Set email recipient
     * @param string $mail
     */
    public function setTo($mail);

    /**
     * Set email subject
     * @param string $subject
     */
    public function setSubject($subject);
    
    /**
     * Set email body
     * @param string $body
     */
    public function setBody($body);

    /**
     * Send email with the previous configuration
     * @return bool true on success, false on failure
     */
    public function send();
    
    /**
     * Gets error message if send() returned false
     * @return string Error message
     */
    public function getError();
}
<file_sep>/src/Log/LoggerTrait.php
<?php

namespace PicoAuth\Log;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * Optional logger trait.
 * By using this trait, a class can be injected with a logger instance.
 */
trait LoggerTrait
{

    /**
     * Logger instance.
     * @var LoggerInterface
     */
    protected $logger;

    /**
     * Sets logger.
     * @param LoggerInterface $logger
     */
    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * Gets logger.
     * If the logger instance is not set beforehand, it is set to NullLogger and returned.
     * @return LoggerInterface
     */
    protected function getLogger()
    {
        if (!$this->logger) {
            $this->logger = new NullLogger();
        }
        return $this->logger;
    }
}
<file_sep>/src/Storage/Interfaces/PageACLStorageInterface.php
<?php

namespace PicoAuth\Storage\Interfaces;

/**
 * Interface for accessing PageACL configuration
 */
interface PageACLStorageInterface
{
    
    /**
     * Get configuration array
     *
     * @return array
     */
    public function getConfiguration();
    
    /**
     * Get rule by Pico page URL
     *
     * @param string $url Page url
     * @return array|null Rule data array, null if not found
     */
    public function getRuleByURL($url);
}
<file_sep>/content/error.md
---
Title: Internal error
Robots: noindex,nofollow
Template: error.500
---
<file_sep>/src/Storage/Configurator/ConfigurationException.php
<?php

namespace PicoAuth\Storage\Configurator;

/**
 * Exception caused by a bad configuration
 */
class ConfigurationException extends \Exception
{

    public function __construct($message, $code = 0, \Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
    
    /**
     * Adds a message before the current one
     * @param string $msg
     */
    public function addBeforeMessage($msg)
    {
        $this->message = $msg . " ". $this->message;
    }
    
    /**
     * Converts to string
     * @return string
     */
    public function __toString()
    {
        return $this->message;
    }
}
<file_sep>/src/Storage/PageACLFileStorage.php
<?php

namespace PicoAuth\Storage;

use PicoAuth\Storage\Interfaces\PageACLStorageInterface;
use PicoAuth\Cache\NullCache;
use Psr\SimpleCache\CacheInterface;
use PicoAuth\Storage\Configurator\PageACLConfigurator;

/**
 * File storage for PageACL
 */
class PageACLFileStorage extends FileStorage implements PageACLStorageInterface
{

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

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

    /**
     * @inheritdoc
     */
    public function getRuleByURL($url)
    {
        $this->readConfiguration();

        return self::getItemByUrl($this->config['access'], $url);
    }
}
<file_sep>/content/account.md
---
Title: Password change
Robots: noindex,nofollow
Template: password
---
<file_sep>/src/Storage/File/File.php
<?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;
    }
}
<file_sep>/src/Session/SymfonySession.php
<?php

namespace PicoAuth\Session;

use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface;

/**
 * Session manager implementation using HttpFoundation
 */
class SymfonySession implements SessionInterface
{

    /**
     * Symfony session instance
     * @var Session
     */
    protected $session;

    public function __construct(SessionStorageInterface $storage = null)
    {
        $storage = $storage ?: new NativeSessionStorage();
        $flashes = new AutoExpireFlashBag('_flash');
        $this->session = new Session($storage, null, $flashes);
    }

    /**
     * @inheritdoc
     */
    public function has($name)
    {
        return $this->session->has($name);
    }

    /**
     * @inheritdoc
     */
    public function get($name, $default = null)
    {
        return $this->session->get($name, $default);
    }

    /**
     * @inheritdoc
     */
    public function set($name, $value)
    {
        $this->session->set($name, $value);
    }

    /**
     * @inheritdoc
     */
    public function remove($name)
    {
        $this->session->remove($name);
    }

    /**
     * @inheritdoc
     */
    public function invalidate($lifetime = null)
    {
        return $this->session->invalidate($lifetime);
    }

    /**
     * @inheritdoc
     */
    public function migrate($destroy = false, $lifetime = null)
    {
        return $this->session->migrate($destroy, $lifetime);
    }

    /**
     * @inheritdoc
     */
    public function clear()
    {
        $this->session->clear();
    }

    /**
     * @inheritdoc
     */
    public function addFlash($type, $message)
    {
        $this->session->getFlashBag()->add($type, $message);
    }

    /**
     * @inheritdoc
     */
    public function getFlash($type, array $default = array())
    {
        return $this->session->getFlashBag()->get($type, $default);
    }
}
<file_sep>/src/Storage/Configurator/AbstractConfigurator.php
<?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;
    }
}
<file_sep>/tests/Security/Password/Encoder/BcryptTest.php
<?php

namespace PicoAuth\Security\Password\Encoder;

use PHPUnit\Framework\TestCase;

class BcryptTest extends TestCase
{
    
    public function testConstruct()
    {
        $b1 = new BCrypt();
        $this->expectException(\InvalidArgumentException::class);
        $b2 = new BCrypt(['cost'=>100]);
    }
    
    public function testEncodeDecode()
    {
        $bcrypt = new BCrypt(['cost'=>4]);
        $res = $bcrypt->encode("BcryptTest");
        $this->assertStringStartsWith("$2", $res);
        $this->assertTrue($bcrypt->isValid($res, "BcryptTest"));
    }
    
    public function testLengthLimit()
    {
        $raw = str_repeat("r", 73);
        $bcrypt = new BCrypt(['cost'=>4]);
        $this->assertEquals(72, $bcrypt->getMaxAllowedLen());
        $this->assertFalse($bcrypt->isValid("0", $raw));
        $this->expectException(EncoderException::class);
        $bcrypt->encode($raw);
    }
    
    public function testNeedsRehash()
    {
        $bcrypt4 = new BCrypt(['cost'=>4]);
        $res4 = $bcrypt4->encode("BcryptTest");
        
        $bcrypt5 = new BCrypt(['cost'=>5]);
        $this->assertTrue($bcrypt5->needsRehash($res4));
    }
}
<file_sep>/src/Storage/Configurator/PageACLConfigurator.php
<?php

namespace PicoAuth\Storage\Configurator;

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

/**
 * The configurator for PageACL
 */
class PageACLConfigurator extends AbstractConfigurator
{
    
    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault(), 0);
        $this->validateAccessRules($config);
        return $config;
    }
    
    /**
     * The default configuration must contain all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "access" => [],
        );
    }
 
    protected function validateAccessRules(&$config)
    {
        $this->assertArray($config, "access");
        foreach ($config["access"] as $pageUrl => $ruleData) {
            if (!is_string($pageUrl) || $pageUrl=="") {
                throw new ConfigurationException("Page URL must be a string.");
            }
            $this->assertArray($config["access"], $pageUrl);
            
            // Add leading /, remove trailing /
            $this->standardizeUrlFormat($config["access"], $pageUrl);
            
            // Optional attributes
            $this->assertBool($ruleData, "recursive");
            
            $this->assertArrayOfStrings($ruleData, "users");
            $this->assertArrayOfStrings($ruleData, "groups");
        }
    }
}
<file_sep>/src/Module/Authentication/LocalAuth/LocalAuth.php
<?php
namespace PicoAuth\Module\Authentication\LocalAuth;

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

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

    const LOGIN_CSRF_ACTION = 'login';

    /**
     * Instance of the plugin
     *
     * @var PicoAuthInterface
     */
    protected $picoAuth;
    
    /**
     * Session manager
     *
     * @var SessionInterface
     */
    protected $session;
    
    /**
     * Configuration storage
     *
     * @var LocalAuthStorageInterface
     */
    protected $storage;

    /**
     * The Configuration array
     *
     * @var array
     */
    protected $config;
    
    /**
     * The rate limiter
     *
     * @var RateLimitInterface
     */

    protected $limit;

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

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

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

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

    /**
     * @inheritdoc
     */
    public function onPicoRequest($url, Request $httpRequest)
    {
        switch ($url) {
            case "login":
                $this->handleLogin($httpRequest);
                break;
            case "register":
                $this->handleRegistration($httpRequest);
                break;
            case "account":
                $this->handleAccountPage($httpRequest);
                break;
            case "password_reset":
                $this->handlePasswordReset($httpRequest);
                break;
        }
    }
    
    /**
     * Get LocalAuth configuration
     *
     * Can be used from twig template when dynamically compositing a login page
     * (e.g. to find out which functions are enabled to show appropriate links)
     *
     * @return array
     */
    public function getConfig()
    {
        return $this->config;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /**
     * Performs a password rehash
     *
     * @param string $username User identifier
     * @param Password $password The <PASSWORD> be resaved
     */
    protected function passwordRehash($username, Password $password)
    {
        $userData = $this->storage->getUserByName($username);
        
        try {
            $this->userDataEncodePassword($userData, $password);
        } catch (\PicoAuth\Security\Password\Encoder\EncoderException $e) {
            // The encoder was changed to one that is not able to store this password
            $this->session->addFlash("error", "Please set a new password.");
            $this->picoAuth->getContainer()->get('PasswordReset')->startPasswordResetSession($username);
            $this->picoAuth->redirectToPage("password_reset");
        }
        
        $this->storage->saveUser($username, $userData);
    }

    /**
     * Checks username validity
     *
     * @param string $name Username to be checked
     * @return boolean true if the username is valid, false otherwise
     */
    protected function isValidUsername($name)
    {
        if (!is_string($name)
            || !$this->storage->checkValidName($name)
            || strlen($name) < $this->config["registration"]["nameLenMin"]
            || strlen($name) > $this->config["registration"]["nameLenMax"]
        ) {
            return false;
        }
        return true;
    }
    
    /**
     * Logs an invalid login attempt
     *
     * @param array $name Username the attempt was for
     */
    protected function logInvalidLoginAttempt($name)
    {
        // Trim logged name to the maximum allowed length
        $max = $this->config["registration"]["nameLenMax"];
        if (strlen($name)>$max) {
            $max = substr($name, 0, $max) . " (trimmed)";
        }
        $this->getLogger()->notice(
            "Invalid login attempt for {name} by {addr}",
            array(
                "name" => $name,
                "addr" => $_SERVER['REMOTE_ADDR'],
            )
        );
    }

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

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

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

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

    /**
     * On password reset request
     *
     * @param Request $httpRequest
     */
    protected function handlePasswordReset(Request $httpRequest)
    {
        $passwordReset = $this->picoAuth->getContainer()->get('PasswordReset');
        $passwordReset->setConfig($this->config)
            ->handlePasswordReset($httpRequest);
    }
}
<file_sep>/tests/Storage/Configurator/AbstractConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class AbstractConfiguratorTest extends TestCase
{
    
    /**
     * The tested class name
     */
    const FQCN = '\PicoAuth\Storage\Configurator\AbstractConfigurator';
    
    public function testApplyDefaultsInvalidArguments()
    {
        $this->expectException(\InvalidArgumentException::class);
        $stub = $this->getMockForAbstractClass(self::FQCN);
        $stub->applyDefaults([], [], -1);
    }
    
    /**
     * @dataProvider applyDefaultsProvider
     */
    public function testApplyDefaults($config, array $defaults, $depth, $expected)
    {
        $stub = $this->getMockForAbstractClass(self::FQCN);
        $this->assertEquals($expected, $stub->applyDefaults($config, $defaults, $depth));
    }
    
    public function applyDefaultsProvider()
    {
        return [
            [null,      ["a"],      0,  ["a"]],
            [["a"=>1],  ["a"=>2],   0,  ["a"=>1]],
            [["b"=>1],  ["a"=>2],   0,  ["a"=>2,"b"=>1]],
            
            [["a"=>["aa"=>0,"ab"=>1]],  ["a"=>1],           0, ["a"=>["aa"=>0,"ab"=>1]]],
            [["a"=>["aa"=>0,"ab"=>1]],  ["a"=>[]],          1, ["a"=>["aa"=>0,"ab"=>1]]],
            [["a"=>["aa"=>0]],          ["a"=>["ab"=>1]],   1, ["a"=>["aa"=>0,"ab"=>1]]],
            
            [["a"=>["b"=>["c"=>1]]],    ["a"=>["b"=>["d"=>1]]], 2, ["a"=>["b"=>["c"=>1,"d"=>1]]]],
        ];
    }
    
    /**
     * @dataProvider invalidApplyDefaultsProvider
     */
    public function testInvalidApplyDefaults($config, array $defaults, $depth)
    {
        $this->expectException(ConfigurationException::class);
        $stub = $this->getMockForAbstractClass(self::FQCN);
        $stub->applyDefaults($config, $defaults, $depth);
    }
    
    public function invalidApplyDefaultsProvider()
    {
        return [
            [["a"=>[]],         ["a"=>1],                   1],
            [["a"=>1],          ["a"=>[]],                  1],
            [["a"=>["aa"=>0,"ab"=>1]], ["a"=>["aa"=>[]]],   2],
        ];
    }
    
    public function testAssertRequired()
    {
        $this->expectException(ConfigurationException::class);
        $stub = $this->getMockForAbstractClass(self::FQCN);
        $stub->assertRequired([], "key");
    }
    
    /**
     * @dataProvider notArrayOfStrings
     */
    public function testAssertArrayOfStrings($val)
    {
        $this->expectException(ConfigurationException::class);
        $stub = $this->getMockForAbstractClass(self::FQCN);
        
        $arr=array(
            "key"=>$val
        );
        $stub->assertArrayOfStrings($arr, "key");
    }
    
    public function notArrayOfStrings()
    {
        return [
            [null],
            [1],
            [["a"=>1]],
            [[1,2,3,4]],
            [["a","b","c",1,"d"]],
        ];
    }
    
    /**
     * @dataProvider notIntOrFalse
     */
    public function testAssertIntOrFalse($val, $min = null, $max = null)
    {
        $this->expectException(ConfigurationException::class);
        $stub = $this->getMockForAbstractClass(self::FQCN);
        
        $arr=array(
            "key"=>$val
        );
        $stub->assertIntOrFalse($arr, "key", $min, $max);
    }
    
    public function notIntOrFalse()
    {
        return [
            [null],
            [true],
            ["false"],
            [["a"=>1]],
            [0,1,2],
            [3,1,2],
            [true,1,2],
        ];
    }
    
    public function testStandardizeUrlFormat()
    {
        $stub = $this->getMockForAbstractClass(self::FQCN);
        
        // Prepend a slash
        $arr=["page"=>1];
        $stub->standardizeUrlFormat($arr, "page");
        $this->assertEquals(["/page"=>1], $arr);
        
        // Remove the additional slash
        $arr=["page/"=>1];
        $stub->standardizeUrlFormat($arr, "page/");
        $this->assertEquals(["/page"=>1], $arr);
        
        $arr=false;
        $stub->standardizeUrlFormat($arr, "page");
        $this->assertEquals(false, $arr);
    }
}
<file_sep>/src/Security/Password/Encoder/PasswordEncoderInterface.php
<?php

namespace PicoAuth\Security\Password\Encoder;

/**
 * Interface for a password representation
 */
interface PasswordEncoderInterface
{

    /**
     * Returns representation of the raw password suitable for storage
     * Generally, a password hashing algorithm is applied
     * @throws EncoderException if the rawPassword cannot be processed
     * @param string $rawPassword
     * @return string Resulting representation
     */
    public function encode($rawPassword);

    /**
     * Returns true if the supplied $rawPassword is valid
     * @param string $encodedPassword
     * @param string $rawPassword
     * @return bool true if validated successfully
     */
    public function isValid($encodedPassword, $rawPassword);

    /**
     * Returns if the password needs rehashing
     *
     * This is usually the case when algorithm options have changed since
     * the creation of the original hash and therefore they may not meet
     * the cost requirements.
     * @param string $encodedPassword
     * @return bool If the password needs to be rehashed
     */
    public function needsRehash($encodedPassword);
    
    /**
     * Returns the maximum length of rawPassword that can be used with the encoder.
     * This can be either algorithm limitation (Bcrypt limits to 72) or a protection
     * against a DoS by allowing time-consuming computation of unnecessarily long
     * raw strings.
     * @return int|null Maximum allowed strlen, null if unlimited
     */
    public function getMaxAllowedLen();
}
<file_sep>/src/Security/Password/Policy/PasswordPolicyInterface.php
<?php

namespace PicoAuth\Security\Password\Policy;

use PicoAuth\Security\Password\Password;

/**
 * Interface for enforcing password constraints
 */
interface PasswordPolicyInterface
{

    /**
     * Checks validity of the given string against the constraints of the Policy
     * @param Password $password
     * @return bool
     */
    public function check(Password $password);

    /**
     * Returns an array of errors from the last check() call
     * @return string[]
     */
    public function getErrors();
}
<file_sep>/src/Storage/RateLimitSqliteStorage.php
<?php

namespace PicoAuth\Storage;

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

/**
 * Sqlite storage for rate limiting
 *
 * Still uses FileStorage for the configuration file,
 * the rate limiting data are stored in Sqlite 3 database.
 */
class RateLimitSqliteStorage extends FileStorage implements RateLimitStorageInterface
{

    /**
     * Database location
     */
    const DB_NAME = 'PicoAuth/data/ratelimit.db';

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

    /**
     * SQLite 3 Database instance
     *
     * @var \SQLite3|null
     */
    protected $db = null;
    
    /**
     * Storage options
     * @var array
     */
    protected $options;

    public function __construct($dir, CacheInterface $cache = null, array $options = [])
    {
        if (!extension_loaded("sqlite3")) {
            throw new \RuntimeException("Extension sqlite3 is required for RateLimitSqliteStorage.");
        }
        
        $this->dir = $dir;
        $this->cache = ($cache!==null) ? $cache : new NullCache;
        $this->configurator = new RateLimitConfigurator;
        
        // Set options and apply defaults
        $this->options = $options;
        $this->options += array(
            "busyTimeout" => 10000
        );
        
        $bt = $this->options["busyTimeout"];
        if (!is_int($bt) || $bt<0) {
            throw new \InvalidArgumentException("Invalid busyTimeout value.");
        }
    }
    
    /**
     * @inheritdoc
     */
    public function getLimitFor($action, $type, $key)
    {
        $stmt = $this->getStatement("SELECT ts, cnt FROM limits WHERE
            action=:action AND type=:type AND entity=:key;");

        $stmt->bindValue(':action', $action, SQLITE3_TEXT);
        $stmt->bindValue(':type', $type, SQLITE3_TEXT);
        $stmt->bindValue(':key', $key, SQLITE3_TEXT);
        
        if (!($res = $stmt->execute())) {
            throw new \RuntimeException("Could not execute getLimitFor statement.");
        }
        
        $arr = $res->fetchArray(SQLITE3_ASSOC);
        $stmt->close();
            
        return $arr ? $arr : null;
    }
    
    /**
     * @inheritdoc
     */
    public function updateLimitFor($action, $type, $key, $limitData)
    {
        $stmt = $this->getStatement("INSERT OR REPLACE INTO limits (action, type, entity, ts, cnt)
            VALUES (:action, :type, :key, :ts, :cnt);");
        
        $stmt->bindValue(':action', $action, SQLITE3_TEXT);
        $stmt->bindValue(':type', $type, SQLITE3_TEXT);
        $stmt->bindValue(':key', $key, SQLITE3_TEXT);
        $stmt->bindValue(':ts', $limitData["ts"], SQLITE3_INTEGER);
        $stmt->bindValue(':cnt', $limitData["cnt"], SQLITE3_INTEGER);

        if (!($res = $stmt->execute())) {
            throw new \RuntimeException("Could not execute updateLimitFor statement.");
        }
        
        $stmt->close();
    }

    /**
     * @inheritdoc
     */
    public function cleanup($action, $type, $config)
    {
        $stmt = $this->getStatement("DELETE FROM limits WHERE ts < :time;");
        
        // Delete expired records
        $threshold = time() - $config["counterTimeout"];
        $stmt->bindValue(':time', $threshold, SQLITE3_INTEGER);
        
        if (!($res = $stmt->execute())) {
            throw new \RuntimeException("Could not execute cleanup statement.");
        }

        $stmt->close();
        
        // Number of deleted rows
        return $this->db->changes();
    }

    /**
     * {@inheritdoc}
     *
     * In file storage, save() is called after all changes.
     * In sqlite storage the changes are persisted directly, in the above
     * method calls, so no action is needed.
     */
    public function save($action, $type)
    {
        return;
    }
    
    /**
     * @inheritdoc
     */
    public function transaction($action, $type, $state)
    {
        switch ($state) {
            // Start transaction
            case self::TRANSACTION_BEGIN:
                $this->openDatabase();

                if (!$this->db->exec("BEGIN EXCLUSIVE TRANSACTION;")) {
                    throw new \RuntimeException("Could not begin transaction.");
                }
                
                break;

            // Commit
            case self::TRANSACTION_END:
                if (!$this->db) {
                    throw new \InvalidArgumentException("Cannot end transaction in closed DB.");
                }

                if (!$this->db->exec("END TRANSACTION;")) {
                    throw new \RuntimeException("Could not commit transaction.");
                }
                
                // Close the database
                if (!$this->db->close()) {
                    throw new \RuntimeException("Could not close the database.");
                }
                
                $this->db=null;
        
                break;

            default:
                throw new \InvalidArgumentException("Unexpected transaction argument.");
        }
    }
    
    /**
     * Prepares a statement
     *
     * @param string $query
     * @return \SQLite3Stmt
     * @throws \RuntimeException
     */
    protected function getStatement($query)
    {
        $this->openDatabase();
        
        if (!($stmt = $this->db->prepare($query))) {
            throw new \RuntimeException("Could not prepare statement.");
        }
        
        return $stmt;
    }
    
    /**
     * Opens the database if not opened
     *
     * Also creates the schema if not created
     *
     * @return void
     */
    protected function openDatabase()
    {
        // Return if already conntected
        if ($this->db) {
            return;
        }
        
        // Open or create the database file
        $dbPath = $this->dir . self::DB_NAME;
        if (!\file_exists($dbPath)) {
            self::preparePath($this->dir, dirname(self::DB_NAME));
        }
        
        // Opens the database file (will throw \Exception on failure)
        $this->db = new \SQLite3($dbPath, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE);
        
        // Maximum waiting time
        if (!$this->db->busyTimeout($this->options["busyTimeout"])) {
            throw new \RuntimeException("Could not set busy timout.");
        }

        // Create schema if not exists
        $res = $this->db->exec("
        CREATE TABLE IF NOT EXISTS limits (
            id INTEGER PRIMARY KEY,
            action TEXT NOT NULL,
            type TEXT NOT NULL,
            entity TEXT NOT NULL,
            ts INTEGER NOT NULL,
            cnt INTEGER NOT NULL
        );
        CREATE UNIQUE INDEX IF NOT EXISTS limitIndex ON limits (action,type,entity);
        CREATE INDEX IF NOT EXISTS tsIndex ON limits (ts);
        ");

        if (!$res) {
            throw new \RuntimeException("Could not create table schema.");
        }
    }
}
<file_sep>/content/pwbeginreset.md
---
Title: Password reset
Robots: noindex,nofollow
Template: password_begin_reset
---

Enter the email used to register the account and a password reset link will be sent to it.<file_sep>/src/Module/Authorization/PageACL.php
<?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;
    }
}
<file_sep>/tests/Module/AbstractAuthModuleTest.php
<?php

namespace PicoAuth\Module;

use PicoAuth\PicoAuthInterface;

class AbstractAuthModuleTest extends \PicoAuth\BaseTestCase
{
    
    protected $abstractModule;

    protected function setUp()
    {
        $this->abstractModule = $this->getMockBuilder(AbstractAuthModule::class)
            ->getMockForAbstractClass();
    }

    public function testHandleEvent()
    {
        $this->assertNull($this->abstractModule->handleEvent("nonExistent", []));
    }
}
<file_sep>/src/Storage/Interfaces/RateLimitStorageInterface.php
<?php

namespace PicoAuth\Storage\Interfaces;

/**
 * Interface for accessing RateLimit data
 */
interface RateLimitStorageInterface
{
    
    /**
     * Close storage transaction
     */
    const TRANSACTION_END = 0;
    
    /**
     * Begin storage transaction
     */
    const TRANSACTION_BEGIN = 1;
    
    /**
     * Get configuration array
     *
     * @return array
     */
    public function getConfiguration();
    
    /**
     * Get limit data for action-type-identifier
     *
     * @param string $action E.g. "login"
     * @param string $type E.g. "account"
     * @param string $key E.g. "user1"
     * @return array|null
     * @throws \Exception On read error
     */
    public function getLimitFor($action, $type, $key);
    
    /**
     * Update limit data
     *
     * @param string $action E.g. "login"
     * @param string $type E.g. "account"
     * @param string $key E.g. "user1"
     * @param array $limitData New limit data array
     */
    public function updateLimitFor($action, $type, $key, $limitData);
    
    /**
     * Clean expired records
     *
     * @param string $action
     * @param string $type
     * @param array $config Limit configuration
     * @return int Number of removed records
     */
    public function cleanup($action, $type, $config);
    
    /**
     * Save datafile for action-type
     *
     * @param string $action
     * @param string $type
     * @throws \Exception On save error
     */
    public function save($action, $type);
    
    /**
     * Transaction control (TCL)
     *
     * Begins or ends an exclusive transaction. After the transaction begins,
     * all subsequent calls to other storage methods must be executed atomically,
     * until the transaction ends.
     *
     * @param string $action
     * @param string $type
     * @param int $state One of the TRANSACTION_ constants
     */
    public function transaction($action, $type, $state);
}
<file_sep>/src/Storage/LocalAuthFileStorage.php
<?php

namespace PicoAuth\Storage;

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

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

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

    /**
     * Directory with user files
     */
    const USERS_DIR = 'PicoAuth/users';
    
    /**
     * Extension of user files
     */
    const USERFILE_EXT = '.yml';

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

    /**
     * Already fetched users
     *
     * @var array
     */
    protected $fetchedUsers = array();
    
    /**
     * Number of users
     *
     * @var int
     */
    protected $usersCount;
    
    /**
     * The LocalAuthConfigurator instance
     * @var Configurator\LocalAuthConfigurator
     */
    protected $configurator;
    
    public function __construct($dir, CacheInterface $cache = null)
    {
        $this->dir = $dir;
        $this->cache = ($cache!==null) ? $cache : new NullCache;
        $this->configurator = new LocalAuthConfigurator;
    }

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

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

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

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

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

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

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

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

        return null;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return array_diff($files, array('..', '.'));
    }
}
<file_sep>/src/Security/Password/Encoder/Argon2i.php
<?php

namespace PicoAuth\Security\Password\Encoder;

/**
 * Argon2i
 *
 * PHP RFC: https://wiki.php.net/rfc/argon2_password_hash
 */
class Argon2i implements PasswordEncoderInterface
{

    /**
     * The maximum length
     *
     * Not a limitation of the algorithm, but allowing very long passwords
     * could lead to DoS using the computation difficulty of the algorithm.
     */
    const MAX_LEN = 4096;
    
    /**
     * The array with options
     *
     * @var array
     */
    protected $options;

    public function __construct(array $options = [])
    {
        if (!defined('PASSWORD_ARGON2I')) {
            throw new \RuntimeException("ARGON2I is not supported.");
        }
        $defaults = [
            'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
            'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST,
            'threads' => PASSWORD_ARGON2_DEFAULT_THREADS
        ];
        $options += $defaults;
        
        $this->validateOptions($options);
        
        $this->options = $options;
    }

    /**
     * @inheritdoc
     */
    public function encode($rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            throw new EncoderException("Invalid length, maximum is ".self::MAX_LEN.".");
        }
        return password_hash($rawPassword, PASSWORD_ARGON2I, $this->options);
    }

    /**
     * @inheritdoc
     */
    public function isValid($encodedPassword, $rawPassword)
    {
        if (strlen($rawPassword) > self::MAX_LEN) {
            return false;
        }
        return password_verify($rawPassword, $encodedPassword);
    }

    /**
     * @inheritdoc
     */
    public function needsRehash($encodedPassword)
    {
        return password_needs_rehash($encodedPassword, PASSWORD_ARGON2I, $this->options);
    }
    
    /**
     * Validates algorithm options
     *
     * From the algorithm's reference:
     * https://password-hashing.net/argon2-specs.pdf
     *
     * @param array $options
     * @throws \InvalidArgumentException
     */
    private function validateOptions($options)
    {
        if ($options['threads'] < 1) {
            throw new \InvalidArgumentException('Number of threads must be positive.');
        }
        
        if ($options['time_cost'] < 1) {
            throw new \InvalidArgumentException('Time cost must be positive.');
        }
        
        if ($options['memory_cost'] < $options['threads']*8) {
            throw new \InvalidArgumentException('Memory cost must be number of kilobytes from 8*threads.');
        }
    }

    /**
     * @inheritdoc
     */
    public function getMaxAllowedLen()
    {
        return self::MAX_LEN;
    }
}
<file_sep>/tests/Storage/Configurator/LocalAuthConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class LocalAuthConfiguratorTest extends TestCase
{
    
    protected $configurator;
    
    protected function setUp()
    {
        $this->configurator = new LocalAuthConfigurator;
    }
    
    /**
     * @dataProvider badConfigurations
     */
    public function testValidationErrors($rawConfig)
    {
        $this->expectException(ConfigurationException::class);
        $this->configurator->validate($rawConfig);
    }

    public function badConfigurations()
    {
        return [
            [["encoder"=>1]],
            [["login"=>["passwordRehash"=>1]]],
            [["accountEdit"=>["enabled"=>1]]],
            [["passwordReset"=>["enabled"=>1]]],
            [["passwordReset"=>["emailMessage"=>1]]],
            [["passwordReset"=>["emailMessage"=>"noUrlPlaceholder"]]],
            [["passwordReset"=>["emailSubject"=>1]]],
            [["passwordReset"=>["tokenIdLen"=>"a"]]],
            [["passwordReset"=>["tokenIdLen"=>-1]]],
            [["passwordReset"=>["tokenIdLen"=>0]]],
            [["passwordReset"=>["tokenIdLen"=>2000000]]],
            [["passwordReset"=>["tokenLen"=>"a"]]],
            [["passwordReset"=>["tokenLen"=>-1]]],
            [["passwordReset"=>["tokenLen"=>1]]],
            [["passwordReset"=>["tokenLen"=>2000000]]],
            [["passwordReset"=>["tokenValidity"=>"a"]]],
            [["passwordReset"=>["tokenValidity"=>0]]],
            [["passwordReset"=>["resetTimeout"=>"a"]]],
            [["passwordReset"=>["resetTimeout"=>0]]],
            [["registration"=>["maxUsers"=>-1]]],
            [["registration"=>["maxUsers"=>"25"]]],
            [["registration"=>["nameLenMin"=>"a"]]],
            [["registration"=>["nameLenMin"=>0]]],
            [["registration"=>["nameLenMax"=>"a"]]],
            [["registration"=>["nameLenMax"=>0]]],
            [["registration"=>["nameLenMax"=>2000000]]],
            [["registration"=>["nameLenMax"=>8,"nameLenMin"=>9]]],
            [["users"=>1]],
            [["users"=>["a."=>[]]]],
            [["users"=>["aaaaa."=>[]]]],
            [["users"=>[1=>[]]]],
            [["users"=>["tester"=>[]]]],
            [["users"=>["tester"=>["pwhash"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"b","email"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"c","attributes"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"d","encoder"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"e","pwreset"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"f","groups"=>1]]]],
            [["users"=>["tester"=>["pwhash"=>"g","groups"=>[1,"a",3]]]]],
            [["users"=>["tester"=>["pwhash"=>"h"],"TESTER"=>["pwhash"=>"i"]]]],
            [["users"=>["tester"=>["pwhash"=>"i","displayName"=>false]]]],
        ];
    }
    
    public function testValidation()
    {
        $res = $this->configurator->validate(null);
        $this->assertArrayHasKey("encoder", $res);
        
        $res = $this->configurator->validate([
            "users" => [
                "TeStEr" => ["pwhash"=>"i"],
            ],
        ]);
        $this->assertArrayHasKey("users", $res);
        $this->assertArrayHasKey("tester", $res["users"]);
    }
}
<file_sep>/tests/UserTest.php
<?php

namespace PicoAuth;

use PHPUnit\Framework\TestCase;

/**
 * PicoAuth plugin tests
 */
class UserTest extends TestCase
{
    
    public function testDefault()
    {
        $u = new User();
        $this->assertFalse($u->getAuthenticated());
    }
    

    public function testSetAuthenticated()
    {
        $u = new User();
        $u->setAuthenticated(true);
        $u->setAuthenticator("test");
        
        $this->assertTrue($u->getAuthenticated());
        $this->assertEquals("test", $u->getAuthenticator());
        
        $u->setAuthenticated(false);
        $this->assertEquals(null, $u->getAuthenticated());
    }
    
    public function testAttributes()
    {
        $u = new User();
        $this->assertNull($u->getAttribute("a"));
        $this->assertNull($u->getAttribute(null));
        
        $u->setAttribute("a", 1);
        $this->assertEquals(1, $u->getAttribute("a"));
    }
    
    public function testId()
    {
        $u = new User();
        $u->setId("u1");
        $this->assertEquals("u1", $u->getId());
    }

    public function testDisplayName()
    {
        $u = new User();
        $this->assertNull($u->getDisplayName());
        $u->setDisplayName("a");
        $this->assertEquals("a", $u->getDisplayName());
    }
    
    public function testGroups()
    {
        $u = new User();
        $u->setGroups(null);
        $this->assertEquals([], $u->getGroups());
        $u->setGroups(0);
        $this->assertEquals([], $u->getGroups());
        $u->setGroups("g0");
        $this->assertEquals([], $u->getGroups());
        $u->addGroup("g1");
        $this->assertEquals(["g1"], $u->getGroups());
        $u->addGroup("g2");
        $this->assertEquals(["g1","g2"], $u->getGroups());
        $u->setGroups(["g3"]);
        $this->assertEquals(["g3"], $u->getGroups());
        $u->addGroup(null);
        $u->addGroup(3);
        $u->addGroup(["g4"]);
        $this->assertEquals(["g3"], $u->getGroups());
    }
}
<file_sep>/src/Cache/NullCache.php
<?php

namespace PicoAuth\Cache;

use Psr\SimpleCache\CacheInterface;

/**
 * NullCache to be used when a real instance is not injected.
 * Makes null checking unnecessary on places when a cache is utilized.
 */
class NullCache implements CacheInterface
{
    
    /**
     * {@inheritdoc}
     */
    public function clear()
    {
        return true;
    }

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

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

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

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

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

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

    /**
     * {@inheritdoc}
     */
    public function setMultiple($values, $ttl = null)
    {
        return false;
    }
}
<file_sep>/tests/Security/RateLimiting/RateLimitTest.php
<?php

namespace PicoAuth\Security\RateLimiting;

use Psr\Log\LoggerInterface;
use PicoAuth\BaseTestCase;
use PicoAuth\Storage\Configurator\RateLimitConfigurator;
use PicoAuth\Storage\Interfaces\RateLimitStorageInterface;

class RateLimitTest extends BaseTestCase
{
    
    protected $limit;
    protected $configurator;
    protected $testConfig;
    
    protected function setUp()
    {
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $this->limit = new RateLimit($sto);
        $this->testConfig = array(
            "count" => 1,
            "blockDuration" => 10000,
            "counterTimeout" =>20000,
            "errorMsg" => "%cnt%"
        );
        $this->configurator = new RateLimitConfigurator;
    }
    
    /**
     * @dataProvider ipProvider
     */
    public function testGetSubnet($ip, $mask, $expected)
    {
        $getSubnet = self::getMethod($this->limit, 'getSubnet');
        $this->assertEquals($expected, $getSubnet->invokeArgs($this->limit, [$ip, $mask]));
    }

    public function ipProvider()
    {
        return [
            ["10.0.0.1",32,"10.0.0.1/32"],
            ["10.0.0.3",31,"10.0.0.2/31"],
            ["10.0.0.7",30,"10.0.0.4/30"],
            ["10.0.0.15",29,"10.0.0.8/29"],
            ["10.0.255.255",17,"10.0.128.0/17"],
            ["10.11.12.13",24,"10.11.12.0/24"],
            ["10.11.12.13",8,"10.0.0.0/8"],
            ["10.11.12.13",8,"10.0.0.0/8"],
            ["ffff::",1,"8000::/1"],
            ["fd00:c2b6:b24b:be67:2827:688d:e6a1:6a3b",64,"2000:dead:beef:4dad::/64"],
            ["1::",0,"::/0"],
        ];
    }
    
    /**
     * @dataProvider invalidSubnetArgumentsProvider
     */
    public function testGetSubnetInvalidArguments($ip, $mask)
    {
        $this->expectException(\InvalidArgumentException::class);
        $getSubnet = self::getMethod($this->limit, 'getSubnet');
        $getSubnet->invokeArgs($this->limit, [$ip, $mask]);
    }

    public function invalidSubnetArgumentsProvider()
    {
        return [
            // Invalid netmasks
            ["10.0.0.1","32"],
            ["10.0.0.1",null],
            ["10.0.0.1",33],
            ["10.0.0.1",-1],
            ["2000::",-1],
            ["2000::",129],
            
            // Invalid IP
            ["2000:cg:",129],
            ["2000:::",24],
            ["10.0.0.0.1",24],
            ["10.0.0",24],
            ["10.0.0.",24],
            [null,24],
            [24,24],
        ];
    }
    
    public function testGetLimitFor()
    {
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $sto->expects($this->once())
            ->method("getLimitFor")
            ->with("a", "b", "c")
            ->willReturn(null);
        $this->limit = new RateLimit($sto);
        
        $method = self::getMethod($this->limit, 'getLimitFor');
        $ret = $method->invokeArgs($this->limit, ["a","b","c"]);
        $this->assertEquals(array("ts" => 0, "cnt" => 0), $ret);
    }
    
    public function testAction()
    {
        $this->assertTrue($this->limit->action("undefined-action"));
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $sto->expects($this->once())
            ->method("getConfiguration")
            ->willReturn($this->configurator->validate([]));
        $sto->expects($this->at(1))
            ->method("transaction");
        $sto->expects($this->at(2))
            ->method("getLimitFor")
            ->with("login", "ip", "10.0.0.1/".RateLimit::DEFAULT_NETMASK_IPV4)
            ->willReturn(array("ts" => time(), "cnt" => 1000000));
        $sto->expects($this->at(3))
            ->method("transaction");
        $sto->expects($this->at(4))
            ->method("transaction");
        $sto->expects($this->at(5))
            ->method("getLimitFor")
            ->with("login", "ip", "2000::/".RateLimit::DEFAULT_NETMASK_IPV6)
            ->willReturn(array("ts" => time(), "cnt" => 1000000));
        $sto->expects($this->at(6))
            ->method("transaction");
        $limit = new RateLimit($sto);
        $_SERVER["REMOTE_ADDR"] = '10.0.0.1';
        $this->assertFalse($limit->action("login"));
        $_SERVER["REMOTE_ADDR"] = '2000::';
        $this->assertFalse($limit->action("login"));
    }
    
    public function testIncrementCounter()
    {
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $sto->expects($this->once())
            ->method("getConfiguration")
            ->willReturn($this->configurator->validate([]));
        $sto->expects($this->once())
            ->method("save")
            ->with("login", "ip")
            ->willReturn(true);
        $sto->expects($this->once())
            ->method("updateLimitFor");
        $sto->expects($this->once())
            ->method("getLimitFor")
            ->with("login", "ip", "10.0.0.1/32")
            ->willReturn(array("ts" => time(), "cnt" => 0));

        
        $limit = new RateLimit($sto);
        
        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->exactly(1))
               ->method('notice')->willReturnSelf();
        $limit->setLogger($logger);
        
        $method = self::getMethod($limit, 'incrementCounter');
        $method->invokeArgs($limit, ["login","ip","10.0.0.1/32",$this->testConfig]);
    }
    
    public function testIncrementCounterNoIncrement()
    {
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $sto->expects($this->never())
            ->method("save")
            ->with("login", "ip")
            ->willReturn(true);
        $sto->expects($this->once())
            ->method("getLimitFor")
            ->with("login", "ip", "10.0.0.1/32")
            ->willReturn(array("ts" => time(), "cnt" => 1));

        $limit = new RateLimit($sto);

        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->never())
            ->method('notice')->willReturnSelf();
        $limit->setLogger($logger);

        $method = self::getMethod($limit, 'incrementCounter');
        $method->invokeArgs($limit, ["login", "ip", "10.0.0.1/32", $this->testConfig]);
    }
    
    public function testIncrementCounterResetCounter()
    {
        $sto = $this->createMock(RateLimitStorageInterface::class);
        $sto->expects($this->once())
            ->method("save")
            ->with("login", "ip")
            ->willReturn(true);
        $sto->expects($this->once())
            ->method("getLimitFor")
            ->with("login", "ip", "10.0.0.1/32")
            ->willReturn(array("ts" => 0, "cnt" => 10));

        $limit = new RateLimit($sto);
        $method = self::getMethod($limit, 'incrementCounter');
        $method->invokeArgs($limit, ["login", "ip", "10.0.0.1/32", $this->testConfig]);
    }
    
    public function testSetErrorMessage()
    {
        $method = self::getMethod($this->limit, 'setErrorMessage');
        
        $method->invokeArgs($this->limit, [null]);
        $this->assertTrue(is_string($this->limit->getError()));
        
        $method->invokeArgs($this->limit, [$this->testConfig]);
        $this->assertEquals("1", $this->limit->getError());
    }
    
    public function testGetEntityId()
    {
        $method = self::getMethod($this->limit, 'getEntityId');
        $this->assertNull($method->invokeArgs($this->limit, ["test",array()]));
        $this->assertEquals(md5("t1"), $method->invokeArgs(
            $this->limit,
            ["account",[],[
                "name" => "t1"
            ]]
        ));
        $this->assertEquals(md5("<EMAIL>"), $method->invokeArgs(
            $this->limit,
            ["email",[],[
                "email" => "<EMAIL>"
            ]]
        ));
    }
}
<file_sep>/src/Storage/Interfaces/OAuthStorageInterface.php
<?php

namespace PicoAuth\Storage\Interfaces;

/**
 * Interface for accessing OAuth configuration
 */
interface OAuthStorageInterface
{
    
    /**
     * Get provider configuration
     *
     * @param string $name Provider identifier
     * @return array|null Provider configuration, null if not found
     */
    public function getProviderByName($name);

    /**
     * Get supported providers
     *
     * @return string[]|null
     */
    public function getConfiguration();
    
    /**
     * Get available providers
     *
     * Can be used in the login form to print all available providers
     *
     * @return string[] Array with provider identifiers
     */
    public function getProviderNames();
}
<file_sep>/src/container.php
<?php
/**
 * Default container definition for PicoAuth plugin.
 *
 * Don't edit the plugin's file - but create a copy in
 * {PicoRoot}/config/PicoAuth/container.php
 *
 * If the container.php exists in the configuration directory it is used
 * instead of the default one.
 */

use League\Container\Argument\RawArgument;
use League\Container\Container;

$container = new Container;

// Version of this file, so PicoAuth can detect possibly outdated definition
$container->share('Version', new RawArgument(10000));


// ****************** Configuration cache setup ********************************

// Any implementation of Psr\SimpleCache\CacheInterface
// e.g. from package cache/cache: $pool = new ApcuCachePool();
$pool = 'PicoAuth\Cache\NullCache';
$container->share('cache', $pool);

// ****************** Logger setup *********************************************

// Any implementation of \Psr\Log\LoggerInterface
// e.g. $log = new \Monolog\Logger('name');
$log = 'Psr\Log\NullLogger';
$container->share('logger', $log);

// ****************** Mail setup ***********************************************

// Any implementation of PicoAuth\Mail\MailerInterface
//include 'Mailer.php';
//$container->share('mailer','PicoAuth\Mail\Mailer');

// ****************** Password policy setup ************************************

// Specify constraints of the default policy
// Or provide an alternative implementation of PicoAuth\Security\Password\Policy\PasswordPolicyInterface

$container->share('PasswordPolicy', 'PicoAuth\Security\Password\Policy\PasswordPolicy')
    ->withMethodCall('minLength', [new RawArgument(8)]);

// ****************** Session management configuration *************************

// PicoAuth default session driver
$container->share('session', 'PicoAuth\Session\SymfonySession')
    ->withArgument('session.storage');

$container->share('session.storage', 'Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage')
    ->withArgument(new RawArgument(array(
        //"cookie_secure" => "0",       // Set to "1" if using HTTPS
        "cookie_lifetime" => "0",       // Until user closes the browser
        "gc_maxlifetime" => "7200",     // Activity timeout 2hrs
        "name" => "Pico",               // Session cookie name
        "cookie_httponly" => "1",
    )));

// *****************************************************************************

// PicoAuth modules
$container->share('LocalAuth', 'PicoAuth\Module\Authentication\LocalAuth\LocalAuth')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('LocalAuth.storage')
    ->withArgument('RateLimit');

$container->share('OAuth', 'PicoAuth\Module\Authentication\OAuth')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('OAuth.storage')
    ->withMethodCall('setLogger', ['logger']);

$container->share('PageACL', 'PicoAuth\Module\Authorization\PageACL')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('PageACL.storage');

$container->share('PageLock', 'PicoAuth\Module\Authorization\PageLock')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('PageLock.storage')
    ->withArgument('RateLimit');

$container->share('Installer', 'PicoAuth\Module\Generic\Installer')
    ->withArgument('PicoAuth');

// Storage
$container->share('LocalAuth.storage', 'PicoAuth\Storage\LocalAuthFileStorage')
    ->withArgument('configDir')
    ->withArgument('cache');
$container->share('OAuth.storage', 'PicoAuth\Storage\OAuthFileStorage')
    ->withArgument('configDir')
    ->withArgument('cache');
$container->share('PageACL.storage', 'PicoAuth\Storage\PageACLFileStorage')
    ->withArgument('configDir')
    ->withArgument('cache');
$container->share('PageLock.storage', 'PicoAuth\Storage\PageLockFileStorage')
    ->withArgument('configDir')
    ->withArgument('cache');
$container->share('RateLimit.storage', 'PicoAuth\Storage\RateLimitFileStorage')
    ->withArgument('configDir')
    ->withArgument('cache');

// Password hashing options
$container->add('bcrypt', 'PicoAuth\Security\Password\Encoder\BCrypt');
$container->add('argon2i', 'PicoAuth\Security\Password\Encoder\Argon2i');
$container->add('plain', 'PicoAuth\Security\Password\Encoder\Plaintext');

// Rate limiting
$container->share('RateLimit', 'PicoAuth\Security\RateLimiting\RateLimit')
    ->withArgument('RateLimit.storage')
    ->withMethodCall('setLogger', ['logger']);

// LocalAuth extensions
$container->share('PasswordReset', 'PicoAuth\Module\Authentication\LocalAuth\PasswordReset')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('LocalAuth.storage')
    ->withArgument('RateLimit')
//  ->withMethodCall('setMailer',['mailer'])
    ->withMethodCall('setLogger', ['logger']);

$container->share('Registration', 'PicoAuth\Module\Authentication\LocalAuth\Registration')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('LocalAuth.storage')
    ->withArgument('RateLimit')
    ->withMethodCall('setLogger', ['logger']);

$container->share('EditAccount', 'PicoAuth\Module\Authentication\LocalAuth\EditAccount')
    ->withArgument('PicoAuth')
    ->withArgument('session')
    ->withArgument('LocalAuth.storage');

return $container;
<file_sep>/tests/Module/Authentication/OAuthTest.php
<?php

namespace PicoAuth\Module\Authentication;

use PicoAuth\Storage\Interfaces\OAuthStorageInterface;
use PicoAuth\Session\SessionInterface;
use PicoAuth\PicoAuthInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ParameterBag;
use League\Container\Container;
use PicoAuth\Storage\Configurator\LocalAuthConfigurator;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

class OAuthTest extends \PicoAuth\BaseTestCase
{
    
    protected $oAuth;
    
    protected $session;

    protected function setUp()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = new MockArraySessionStorage();
        $this->session = new \PicoAuth\Session\SymfonySession($storage);
        $storage = $this->createMock(OAuthStorageInterface::class);

        $this->oAuth = new OAuth($picoAuth, $this->session, $storage);
    }

    private function getTestProvider()
    {
        return array(
            "provider" => "\League\OAuth2\Client\Provider\GenericProvider",
            "options" => array(
                "clientId" => "t",
                "clientSecret" => "t",
                "urlAuthorize" => "https://test.com/authorize",
                "urlAccessToken" => "https://test.com/oauth/token",
                "urlResourceOwnerDetails" => "https://test.com/user",
            ),
            "attributeMap" => array(
                "userId" => "id",
                "displayName" => "dispName",
                "attr" => "attr",
            ),
            "default" => array(
                "groups" => ["testGroup","g2"],
                "attributes" => ["img"=>"test.png"]
            )
        );
    }
    
    public function testGetName()
    {
        $this->assertEquals('OAuth', $this->oAuth->getName());
    }
    
    public function testGetStorage()
    {
        $this->assertEquals(self::get($this->oAuth, 'storage'), $this->oAuth->getStorage());
    }
    
    public function testOnPicoRequestLoginSubmission()
    {
        $pico = $this->createMock(\Pico::class);
        $pico->expects($this->exactly(1))
            ->method("getPageUrl")
            ->willReturn("callback");
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("isValidCSRF")
            ->willReturn(false);
        $picoAuth->expects($this->once())
            ->method("redirectToLogin") //after the wrong CSRF
            ->withAnyParameters();
        $picoAuth->expects($this->once())
            ->method("getPico")
            ->willReturn($pico);
        $picoAuth->expects($this->once())
            ->method("redirectToPage")
            ->with(
                $this->stringStartsWith($this->getTestProvider()["options"]["urlAuthorize"]),
                null,
                false
            );
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method("getProviderByName")
            ->willReturn($this->getTestProvider());
        $OAuth = new OAuth($picoAuth, $sess, $storage);
        
        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        $request->headers = new ParameterBag(["referer"=>"http://test.com/?afterLogin=index"]);
        
        $OAuth->handleEvent("onPicoRequest", ["login", $request]);
    }
    
    /**
     * Request for provider that doesn't exist
     */
    public function testOnPicoRequestBadProvider()
    {
        $pico = $this->createMock(\Pico::class);
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->once())
            ->method("isValidCSRF")
            ->willReturn(true);
        
        // Redirect call will abort the script, here simulated with exception
        $picoAuth->expects($this->once())
            ->method("redirectToLogin")
            ->withAnyParameters()
            ->willThrowException(new \LogicException());
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method("getProviderByName")
            ->willReturn(null);
        $OAuth = new OAuth($picoAuth, $sess, $storage);
        
        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        
        $this->expectException(\LogicException::class);
        
        $OAuth->onPicoRequest("login", $request);
    }
    
    public function testOnPicoRequestStateMismatch()
    {
        $pico = $this->createMock(\Pico::class);
        $log = $this->createMock(\Psr\Log\LoggerInterface::class);
        $log->expects($this->once())
            ->method("warning");
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $this->session->set("provider", "testProvider");
        $this->session->set("oauth2state", "testState");
        $storage = $this->createMock(OAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method("getConfiguration")
            ->willReturn([
                "callbackPage" => "callback"
            ]);
        $storage->expects($this->once())
            ->method("getProviderByName")
            ->with("testProvider")
            ->willReturn($this->getTestProvider());
        $picoAuth->expects($this->any())
            ->method("getPico")
            ->willReturn($pico);
        $picoAuth->expects($this->once())
            ->method("redirectToLogin")
            ->withAnyParameters()
            ->willThrowException(new \LogicException());
        $OAuth = new OAuth($picoAuth, $this->session, $storage);
        $OAuth->setLogger($log);
        $_SERVER["REMOTE_ADDR"] = '10.0.0.1';
        
        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        $request->query = new ParameterBag(["state"=>"testStateWrong"]);
        
        $this->expectException(\LogicException::class);
        $OAuth->onPicoRequest("callback", $request);
    }
    
    public function testOnPicoRequestCallbackProviderMissing()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $this->session->set("provider", "testProvider");
        $this->session->set("oauth2state", "testState");
        $storage = $this->createMock(OAuthStorageInterface::class);
        $storage->expects($this->once())
            ->method("getConfiguration")
            ->willReturn([
                "callbackPage" => "callback"
            ]);
        $storage->expects($this->once())
            ->method("getProviderByName")
            ->with("testProvider")
            ->willReturn(null);
        $OAuth = new OAuth($picoAuth, $this->session, $storage);
        
        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        $request->query = new ParameterBag(["state"=>"testState"]);
        
        $this->expectException(\RuntimeException::class);
        $OAuth->onPicoRequest("callback", $request);
    }
    
    public function testInitProviderNonExistent()
    {
        $OAuth = $this->getMockBuilder(OAuth::class)
            ->setMethodsExcept(["initProvider"])
            ->disableOriginalConstructor()
            ->getMock();
        $this->expectException(\RuntimeException::class);
        $method=self::getMethod($OAuth, 'initProvider');
        $method->invokeArgs($OAuth, [[
            "provider" => "\Non\Existing\Provider",
            "options" => ["redirectUri"=>""]
        ]]);
    }

    public function testInitProviderWrongClass()
    {
        $OAuth = $this->getMockBuilder(OAuth::class)
            ->setMethodsExcept(["initProvider"])
            ->disableOriginalConstructor()
            ->getMock();
        $this->expectException(\RuntimeException::class);
        $method=self::getMethod($OAuth, 'initProvider');
        $method->invokeArgs($OAuth, [[
            "provider" => self::class, //random class that is not a League\OAuth2 provider
            "options" => ["redirectUri"=>""]
        ]]);
    }

    public function testLoggingEvents()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $OAuth = new OAuth($picoAuth, $this->session, $storage);
        $idpExc=new IdentityProviderException("test", 0, "");
        self::set($OAuth, new self(), 'provider'); //any class, but must be set
        
        $log = $this->createMock(\Psr\Log\LoggerInterface::class);
        $log->expects($this->at(0))
            ->method("warning");
        $log->expects($this->at(1))
            ->method("notice");
        $log->expects($this->at(2))
            ->method("critical");
        $OAuth->setLogger($log);
        
        self::getMethod($OAuth, 'onStateMismatch')->invokeArgs($OAuth, []);
        self::getMethod($OAuth, 'onOAuthError')->invokeArgs($OAuth, ["errCode"]);
        self::getMethod($OAuth, 'onOauthResourceError')->invokeArgs($OAuth, [$idpExc]);
    }
    
    public function testSaveLoginInfo()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $resourceOwner = $this->createMock(\League\OAuth2\Client\Provider\ResourceOwnerInterface::class);
        $resourceOwner->expects($this->exactly(2))
            ->method("toArray")
            ->willReturnOnConsecutiveCalls(["dispName"=>"test"], ["attr"=>"attr value"]);
        $picoAuth->expects($this->once())
            ->method("afterLogin");
        $user = null;
        $picoAuth->expects($this->once())
            ->method("setUser")
            ->with($this->callback(function ($subject) use (&$user) {
                $user = $subject;
                return true;
            }));
        $OAuth = new OAuth($picoAuth, $this->session, $storage);
        self::set($OAuth, $this->getTestProvider(), 'providerConfig');
        self::getMethod($OAuth, 'saveLoginInfo')->invokeArgs($OAuth, [$resourceOwner]);
        
        $this->assertInstanceOf(\PicoAuth\User::class, $user);
        $this->assertSame($this->getTestProvider()["default"]["groups"], $user->getGroups());
        $this->assertEquals("test.png", $user->getAttribute("img"));
    }

    public function testFinishAuthentication()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $provider = $this->createMock(\League\OAuth2\Client\Provider\GenericProvider::class);
        $provider->expects($this->once())
            ->method("getAccessToken")
            ->willReturn($this->createMock(\League\OAuth2\Client\Token\AccessToken::class));
        $provider->expects($this->once())
            ->method("getResourceOwner")
            ->willReturn($this->createMock(\League\OAuth2\Client\Provider\ResourceOwnerInterface::class));

        $this->session->set("provider", "testProvider");
        $this->session->set("oauth2state", "testState");
        $OAuth = $this->getMockBuilder(OAuth::class)
            ->setMethods(['saveLoginInfo','onOAuthError'])
            ->setConstructorArgs([$picoAuth, $this->session, $storage])
            ->getMock();
        $OAuth->expects($this->once())
            ->method("saveLoginInfo");

        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        $request->query = new ParameterBag(["state"=>"testState", "code"=>"testCode"]);

        self::set($OAuth, $provider, 'provider');
        self::getMethod($OAuth, 'finishAuthentication')->invokeArgs($OAuth, [$request]);
    }

    public function testFinishAuthenticationErrors()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $storage = $this->createMock(OAuthStorageInterface::class);
        $provider = $this->createMock(\League\OAuth2\Client\Provider\GenericProvider::class);
        $provider->expects($this->once())
            ->method("getAccessToken")
            ->willThrowException(new IdentityProviderException("idpExc", 0, "'"));

        $this->session->set("provider", "testProvider");
        $this->session->set("oauth2state", "testState");
        $OAuth = $this->getMockBuilder(OAuth::class)
            ->setMethods(['onOauthResourceError','onOAuthError'])
            ->setConstructorArgs([$picoAuth, $this->session, $storage])
            ->getMock();
        $OAuth->expects($this->once())
            ->method("onOauthResourceError");

        $request = $this->createMock(Request::class);
        $request->request = new ParameterBag(["oauth"=>"test"]);
        $request->query = new ParameterBag(["state"=>"testState", "error"=>"err"]);

        self::set($OAuth, $provider, 'provider');
        self::getMethod($OAuth, 'finishAuthentication')->invokeArgs($OAuth, [$request]);
    }
}
<file_sep>/tests/Module/Authorization/PageACLTest.php
<?php

namespace PicoAuth\Module\Authorization;

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

class PageACLTest extends \PicoAuth\BaseTestCase
{
    
    protected $pageACL;

    protected function setUp()
    {
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageACLStorageInterface::class);

        $this->pageACL = new PageACL($picoAuth, $sess, $storage);
    }

    /**
     * @dataProvider accessTestProvider
     */
    public function testCheckAccess($user, $rule, $expectedAccess)
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageACLStorageInterface::class);
        $storage->expects($this->exactly(1))
            ->method('getRuleByURL')
            ->willReturn($rule);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->exactly(1))
            ->method('getUser')
            ->willReturn($user);
        
        $pageACL = new PageACL($picoAuth, $sess, $storage);
        $res = $pageACL->checkAccess("randomUrl");
        
        $this->assertSame($expectedAccess, $res);
    }
    
    public function accessTestProvider()
    {
        
        $user = new \PicoAuth\User;
        $user->setId("user1");
        $user->addGroup("testGroup");
        
        return [
            [$user,[
                "users" => null,
                "groups" => null,
            ],false],
            
            [$user,[
                "users" => [],
                "groups" => [],
            ],false],
            
            [$user,[
                "users" => ["test","u8","tester"],
                "groups" => ["g1","group"],
            ],false],
            
            // Match on username
            [$user,[
                "users" => ["test","u8","tester", "user1"],
                "groups" => ["g1","group"],
            ],true],
            
            // Match on group
            [$user,[
                "users" => ["test","u8","tester"],
                "groups" => ["g1","group", "testGroup"],
            ],true],
            
            // No rule exists => access allowed
            [$user,null,true],
            
            [$user,[],false],
        ];
    }
    
    public function testRuntimeRules()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageACLStorageInterface::class);
        $storage->expects($this->exactly(1))
            ->method('getRuleByURL')
            ->willReturn(null);
        
        $user = new \PicoAuth\User;
        $user->setId("user1");
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->exactly(1))
            ->method('getUser')
            ->willReturn($user);
        
        $pageACL = new PageACL($picoAuth, $sess, $storage);
        $pageACL->addRule("randomUrl", ["users"=>["user1"]]);
        $res = $pageACL->checkAccess("randomUrl");
        $this->assertTrue($res);
        
        $this->expectException(\InvalidArgumentException::class);
        $pageACL->addRule("randomUrl", 1);
    }
    
    public function testDenyAccessIfRestrictedNotAuthenticated()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageACLStorageInterface::class);
        $storage->expects($this->exactly(1))
            ->method('getRuleByURL')
            ->willReturn([]);
        
        $user = new \PicoAuth\User;
        $user->setId("user1");
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->exactly(2))
            ->method('getUser')
            ->willReturn($user);
        $picoAuth->expects($this->exactly(1))
            ->method('redirectToLogin')
            ->with($this->equalTo("afterLogin=randomUrl"));
        
        $pageACL = new PageACL($picoAuth, $sess, $storage);
        $pageACL->denyAccessIfRestricted("randomUrl");
    }
    
    /**
     * @runInSeparateProcess
     */
    public function testDenyAccessIfRestrictedAuthenticatedButDenied()
    {
        $sess = $this->createMock(SessionInterface::class);
        $storage = $this->createMock(PageACLStorageInterface::class);
        $storage->expects($this->exactly(1))
            ->method('getRuleByURL')
            ->willReturn([]);
        
        $user = new \PicoAuth\User;
        $user->setId("user1");
        $user->setAuthenticated(true);
        
        $picoAuth = $this->createMock(PicoAuthInterface::class);
        $picoAuth->expects($this->exactly(2))
            ->method('getUser')
            ->willReturn($user);
        $picoAuth->expects($this->exactly(1))
            ->method('setRequestUrl')
            ->with("403");
        
        $_SERVER['SERVER_PROTOCOL'] = "HTTP/1.1";
        $pageACL = new PageACL($picoAuth, $sess, $storage);
        $pageACL->denyAccessIfRestricted("randomUrl");
    }
    
    public function testGetName()
    {
        $this->assertEquals('pageACL', $this->pageACL->getName());
    }
}
<file_sep>/tests/ContainerTest.php
<?php

namespace PicoAuth;

class ContainerTest extends \PicoAuth\BaseTestCase
{
    
    protected $container;
    
    protected function setUp()
    {
        $path = __DIR__ . '/../src/container.php';
        $this->container = include $path;
    }
    
    public function testContainer()
    {
        $this->assertNotNull($this->container);
        $this->assertTrue($this->container instanceof \League\Container\Container);
    }
    
    /**
     * @dataProvider dependencyNamesProvider
     */
    public function testContainerHas($name)
    {
        $this->assertTrue($this->container->has($name));
    }
    
    public function dependencyNamesProvider()
    {
        return [
            ["Version"],
            ["cache"],
            ["logger"],
            ["PasswordPolicy"],
            ["session"],
            ["session.storage"],
            ["Version"],
            ["LocalAuth"],
            ["OAuth"],
            ["PageACL"],
            ["PageLock"],
            ["Installer"],
            ["LocalAuth.storage"],
            ["OAuth.storage"],
            ["PageACL.storage"],
            ["PageLock.storage"],
            ["RateLimit.storage"],
            ["bcrypt"],
            ["argon2i"],
            ["plain"],
            ["RateLimit"],
            ["PasswordReset"],
            ["Registration"],
            ["EditAccount"],
        ];
    }
}
<file_sep>/src/Module/AbstractAuthModule.php
<?php
namespace PicoAuth\Module;

/**
 * Abstract class for plugin modules
 */
abstract class AbstractAuthModule
{

    /**
     * Handles an event
     *
     * @param string $eventName Event name
     * @param array $params Event parameters
     * @return mixed Call return value or NULL if not called
     */
    public function handleEvent($eventName, array $params)
    {
        if (method_exists($this, $eventName)) {
            return call_user_func_array(array($this, $eventName), $params);
        }
        return null;
    }
    
    /**
     * Get module name
     *
     * @return string Module name
     */
    abstract public function getName();
}
<file_sep>/src/Module/Generic/Installer.php
<?php
namespace PicoAuth\Module\Generic;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        $this->picoAuth->addOutput("installer_https", $res);
    }
    
    /**
     * Checks content and config dir locations
     *
     * Ideally, both of these directories should be outside the web server
     * root, which is Pico::getRootDir().
     * Adds the result to the output variables for display in the template.
     */
    protected function webRootDirsTest()
    {
        $pico = $this->picoAuth->getPico();
        
        $webRoot = realpath($pico->getRootDir());
        $configDir = realpath($pico->getConfigDir());
        $contentDir = realpath($pico->getConfig('content_dir'));
        
        // Webroot path must not be at the beginning of both of the paths
        $config = (substr($configDir, 0, strlen($webRoot)) !== $webRoot);
        $content = (substr($contentDir, 0, strlen($webRoot)) !== $webRoot);
        
        $this->picoAuth->addOutput("installer_wr_config", $config);
        $this->picoAuth->addOutput("installer_wr_content", $content);
    }

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

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

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

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

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

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

        // Adds output to the template variables
        $this->picoAuth->addOutput("installer_modules_config", $yaml);
        $this->picoAuth->addOutput("installer_modules_names", $modulesNames);
    }
}
<file_sep>/src/Storage/Interfaces/PageLockStorageInterface.php
<?php

namespace PicoAuth\Storage\Interfaces;

/**
 * Interface for accessing PageLock configuration
 */
interface PageLockStorageInterface
{
    /**
     * Get configuration array
     *
     * @return array
     */
    public function getConfiguration();
    
    /**
     * Get lock by its identifier
     *
     * @param string $lockId
     * @return array|null Lock data array, null if not found
     */
    public function getLockById($lockId);
    
    /**
     * Get lock by Pico page URL
     *
     * @param string $url Page url
     * @return string LockID
     */
    public function getLockByURL($url);
}
<file_sep>/src/Storage/Configurator/PluginConfigurator.php
<?php

namespace PicoAuth\Storage\Configurator;

use PicoAuth\Storage\Configurator\AbstractConfigurator;

/**
 * The configurator for the plugin
 *
 * This is the generic part of the plugin's configuration contained in the Pico's
 * configuration file (usually config.yml)
 */
class PluginConfigurator extends AbstractConfigurator
{
    protected $config;
    
    /**
     * @inheritdoc
     */
    public function validate($rawConfig)
    {
        $config = $this->applyDefaults($rawConfig, $this->getDefault(), 0);
        $this->validateGlobalOptions($config);
        return $config;
    }
    
    /**
     * The default configuration contains all keys that are required.
     * @return array
     */
    protected function getDefault()
    {
        return array(
            "authModules" => [
                "Installer"
            ],
            "afterLogin" => "index",
            "afterLogout" => "index",
            "alterPageArray" => false,
            "sessionInterval" => 900,
            "sessionTimeout" => 7200,
            "sessionIdle" => 3600,
            "rateLimit" => false,
            "debug" => false,
        );
    }
    
    public function validateGlobalOptions($config)
    {
        $this->assertArrayOfStrings($config, "authModules");
        $this->assertString($config, "afterLogin");
        $this->assertString($config, "afterLogout");
        $this->assertBool($config, "alterPageArray");
        $this->assertBool($config, "rateLimit");
        $this->assertBool($config, "debug");
        $this->assertIntOrFalse($config, "sessionInterval", 0);
        $this->assertIntOrFalse($config, "sessionTimeout", 0);
        $this->assertIntOrFalse($config, "sessionIdle", 0);
    }
}
<file_sep>/tests/Storage/Configurator/OAuthConfiguratorTest.php
<?php

namespace PicoAuth\Storage\Configurator;

use PHPUnit\Framework\TestCase;

class OAuthConfiguratorTest extends TestCase
{
    
    protected $configurator;
    
    protected function setUp()
    {
        $this->configurator = new OAuthConfigurator;
    }
    
    /**
     * @dataProvider badConfigurations
     */
    public function testValidationErrors($rawConfig)
    {
        $this->expectException(ConfigurationException::class);
        $this->configurator->validate($rawConfig);
    }

    public function badConfigurations()
    {
        return [
            [["callbackPage"=>1]],
            [["providers"=>[1=>1]]],
            [["providers"=>["t"=>1]]],
            [["providers"=>["t"=>[]]]],
            [["providers"=>["t"=>["provider"=>""]]]],
            [["providers"=>["t"=>["options"=>[]]]]],
            [["providers"=>["t"=>["options"=>["clientId"=>1,"clientSecret"=>"a"]]]]],
            [["providers"=>["t"=>[
                "default"=>["groups"=>["a",1]],
                "options"=>["clientId"=>"a","clientSecret"=>"a"]]]]],
            [["providers"=>["t"=>[
                "attributeMap"=>["a",1],
                "options"=>["clientId"=>"a","clientSecret"=>"a"]]]]],
            [["providers"=>["t"=>[
                "attributeMap"=>[1,"a"],
                "options"=>["clientId"=>"a","clientSecret"=>"a"]]]]],
        ];
    }
 
    public function testValidation()
    {
        $res = $this->configurator->validate(null);
        $this->assertArrayHasKey("providers", $res);
        
        $res = $this->configurator->validate(
            ["providers"=>["test"=>["options"=>["clientId"=>"a","clientSecret"=>"a"]]]]
        );
        
        $res = $this->configurator->validate(
            ["providers"=> [
                "test"=>[
                    "provider" => "a",
                    "options"=>[
                        "clientId"=>"a",
                        "clientSecret"=>"a"
                    ]
                ]
            ]]
        );
        $this->assertArrayHasKey("providers", $res);
    }
}
<file_sep>/src/Security/RateLimiting/RateLimit.php
<?php

namespace PicoAuth\Security\RateLimiting;

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

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

    use LoggerTrait;

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

    /**
     * Configuration of actions for rate limiting
     *
     * @var array
     */
    protected $actions;
    
    /**
     * Rate limit configuration array
     *
     * @var array
     */
    protected $config;
    
    /**
     * Storage instance
     *
     * @var RateLimitStorageInterface
     */
    protected $storage;
    
    /**
     * An error message
     *
     * @see RateLimit::getError()
     * @see RateLimit::setErrorMessage()
     * @var string
     */
    protected $errorMessage;

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

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

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

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

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

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

        return true;
    }

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

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

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

        return $ipSubnet;
    }

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

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

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

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

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

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

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

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

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

        $time = time();

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

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

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

        $limitBeforeIncrement = $limit;

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

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

        // Update the limit if the entity is not blocked
        if ($limit["cnt"] <= $config["count"]) {
            $this->storage->updateLimitFor($actionName, $blockType, $entityId, $limit);
            if (rand(0, 100) <= $this->config["cleanupProbability"]) {
                $this->storage->cleanup($actionName, $blockType, $config);
            }
            $this->storage->save($actionName, $blockType);
        }
        
        // Close the transaction
        $this->storage->transaction($actionName, $blockType, RateLimitStorageInterface::TRANSACTION_END);
        
        // Returns the limit array before the increment
        return $limitBeforeIncrement;
    }

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

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

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

        $this->errorMessage = str_replace(array_keys($replace), $replace, $msg);
    }
}
<file_sep>/src/Security/CSRF.php
<?php

namespace PicoAuth\Security;

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

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

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

    /**
     * Character used to split token parts
     */
    const TOKEN_DELIMTER = '.';
    
    /**
     * Seconds of token validity
     *
     * Used as a default value if not specified explicitely in {@see CSRF::checkToken()}
     */
    const TOKEN_VALIDITY = 3600;
    
    /**
     * Session index for the CSRF manager
     */
    const SESSION_KEY = 'CSRF';
    
    /**
     * Session manager
     * @var \PicoAuth\Session\SessionInterface
     */
    protected $session;

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

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

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

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

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

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

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

        // Check token expiration
        if ($this->isExpired($tokenData, $tokenValidity)) {
            $this->ivalidateToken($index, $tokenStorage);
            return false;
        }
        
        // Check correct format of received token
        $parts = explode(self::TOKEN_DELIMTER, $string);
        if (count($parts) !== 2) {
            return false;
        }

        // Validate the token
        $trueToken = $tokenData['token'];
        $key = $parts[0];           // A key used to create a keyed hash of the real token
        $tokenHMAC = $parts[1];     // Keyed hash of the real token
        $trueTokenHMAC = $this->tokenHMAC($trueToken, $key);
        $isValid = \hash_equals($trueTokenHMAC, $tokenHMAC);
        
        // Remove if it is a one-time token
        if ($isValid && !$tokenData['reuse']) {
            $this->ivalidateToken($index, $tokenStorage);
        }
        
        return $isValid;
    }

    /**
     * Invalidates tokens for all actions
     * Should be called after any authentication
     */
    public function removeTokens()
    {
        $this->session->remove(self::SESSION_KEY);
    }
    
    /**
     * Checks token time validity
     * @param array $tokenData
     * @return bool true if token is expired (is after validity)
     */
    protected function isExpired(array $tokenData, $tokenValidity = null)
    {
        return time() >
            $tokenData['time'] + (($tokenValidity!==null) ? $tokenValidity : self::TOKEN_VALIDITY);
    }

    /**
     * The real token is used as a key in the hash_hmac to create a keyed
     * hash of the $key
     * @param string $token
     * @param string $key
     * @return string
     */
    protected function tokenHMAC($token, $key)
    {
        return hash_hmac('sha256', $key, $token, false);
    }
    
    /**
     * Invalidates the specific token by unsetting it from the tokenStorage
     * array in the session.
     * @param string $index Token index
     * @param array $tokenStorage Token storage array with all tokens
     */
    protected function ivalidateToken($index, array &$tokenStorage)
    {
        unset($tokenStorage[$index]);
        $this->session->set(self::SESSION_KEY, $tokenStorage);
    }
}
<file_sep>/src/PicoAuthPlugin.php
<?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  <NAME>
 * @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";
    }
}
<file_sep>/tests/Security/Password/PasswordTest.php
<?php

namespace PicoAuth\Security\Password;

use PHPUnit\Framework\TestCase;

class PasswordTest extends TestCase
{
    
    public function testToString()
    {
        $p = new Password("test");
        $this->assertSame("test", (string)$p);
        $this->assertSame("test", $p->get());
    }
    
    public function testDebugOutput()
    {
        $p = new Password('<PASSWORD>');
        ob_start();
        var_dump($p);
        $result = ob_get_clean();
        
        $this->assertFalse(strpos($result, '12345'));
        $this->assertRegexp('/\*hidden\*/', $result);
    }
}
