bittyphp/security

Flexible security middleware.


Keywords
middleware, php, security
License
MIT

Documentation

Bitty Security

Build Status Codacy Badge Total Downloads License

Bitty supports multiple security layers, covering multiple secured areas, with different authentication methods, using multiple user providers, with multiple password encoders, and supports different authorization strategies for each area. That's a whole lot of security!

The best part? It does all this in a fairly tiny package.

For those interested, Bitty uses a role-based access control (RBAC) security model.

Installation

It's best to install using Composer.

$ composer require bittyphp/bitty-security

Setup

Security is added as a middleware component. This middleware is designed to be applied before all other middleware. Security should always be the first priority. However, it's up to you to ensure it's set up that way.

Basic Usage

A basic application will likely only have one security layer with one secured area to shield.

<?php

use Bitty\Application;
use Bitty\Security\SecurityMiddleware;
use Bitty\Security\Shield\FormShield;

$app = new Application();

// Add security first!
$app->add(
    new SecurityMiddleware(
        new FormShield(...)
    )
);

// Then add any other middleware components.
$app->add(...);

Accessing the Security Context

At some point, you'll probably need access to the security context to determine who is logged in. Bitty registers a security.context service with the container automatically when you add the security middleware. If a different security context has already been defined, it will NOT overwrite it. You can use the security context to see who is logged in.

Even if you use multiple shields and each shield has a separate user, the security context will determine which user is being used based on the request given and return that user.

<?php

use Bitty\Application;
use Bitty\Security\SecurityMiddleware;
use Bitty\Security\Shield\FormShield;

$app = new Application();

$app->add(
    new SecurityMiddleware(
        new FormShield(...)
    )
);

$request = $app->getContainer()->get('request');

// See who is logged in.
$user = $app->getContainer()->get('security.context')->getUser($request);

Custom Security Context

If you want to use a custom security context, you can manually create one and pass it into the security middleware.

<?php

use Bitty\Application;
use Bitty\Security\Context\ContextMap;
use Bitty\Security\SecurityMiddleware;
use Bitty\Security\Shield\FormShield;

$app = new Application();

// Define your context.
$myContext = new ContextMap();

$app->add(
    new SecurityMiddleware(
        new FormShield(...),
        // Pass your context in.
        $myContext
    )
);

$request = $app->getContainer()->get('request');

// See who is logged in.
$user = $myContext->getUser($request);

Security Events

The security system triggers events for the following actions. You can use the EventManager to create a listener for the events of your choosing and perform additional security measures. Some examples of things you could do are: logging authentication requests, counting authentication failures to raise security, or sending an email or SMS alert when someone's account logs in from an unknown location.

Event Target Parameters When
security.authentication.start null ['username' => string] Authentication has started.
security.authentication.failure null ['username' => string, 'error' => string] Authentication has failed.
security.authentication.success UserInterface [] Authentication has succeeded.
security.authorization.start UserInterface [] Authorization has started.
security.authorization.failure UserInterface ['error' => string] Authorization has failed.
security.authorization.success UserInterface [] Authorization has succeeded.
security.logout UserInterface [] When a user logs out.

Example Listener

Here's an example listener that monitors for authentication failures and simply logs them as errors. Check out the Event Manager documentation if you want more information on creating an event listener.

<?php

use Bitty\Application;
use Bitty\EventManager\EventInterface;

$app = new Application();

$logger = $app->getContainer()->get('my.logger');

$eventManager = $app->getContainer()->get('event.manager');
$eventManager->attach(
    'security.authorization.failure',
    function (EventInterface $event) use ($logger) {
        $params = $event->getParams();

        $logger->error(
            sprintf(
                'User "%s" failed to login: %s',
                $params['username'],
                $params['error']
            )
        );
    }
);

Shields

Bitty uses "shields" to protect secure areas from unauthorized access. One or multiple shields can be in place to protect the areas you want to secure. For example, you can have one shield to grant basic access and a completely separate shield to restrict access to an administration area. Multiple users can be logged into the separate areas at the same time. Or you can use one shield to secure both areas, but require different authorization for each area. It's all up to you.

Bitty comes with two built-in shields for granting access: an HTTP Basic shield and a form-based login shield. Not enough? No worries, you can use the ShieldInterface or extend the AbstractShield to grant access using any method you want. For example, you could build an AuthTokenShield to grant access using an API token or a NetworkShield to only allow certain IP ranges.

Basic Usage

Each shield is designed to have its own security context, authentication method, authorization strategy, and configuration options. However, you can share any part of that with another shield simply by passing in the same object to both shields.

<?php

use Bitty\Security\Authentication\Authenticator;
use Bitty\Security\Authorization\Authorizer;
use Bitty\Security\Context\Context;
use Bitty\Security\Shield\FormShield;

$myShield = new FormShield(
    new Context(...),
    new Authenticator(...),
    new Authorizer(...),
    $options
);

Advanced Usage

For more advanced setups, you might need multiple shields to protect different areas based on different rules. Not a problem! You can build a collection of shields to do exactly that!

<?php

use Bitty\Security\Shield\FormShield;
use Bitty\Security\Shield\HttpBasicShield;
use Bitty\Security\Shield\ShieldCollection;

$myShield = new ShieldCollection(
    [
        // Protect area 1
        new FormShield(...),

        // Protect area 2
        new HttpBasicShield(...),

        // Protect area 3
        new FormShield(...),
    ]
);

You can get even more advanced by stacking a ShieldCollection inside another ShieldCollection. Or if you set up the shields inside a collection to share the same context, they can become really strong layers of security. For example, you could build a NetworkShield to block access based on IP address and then have a FormShield show up only for users with a valid IP. As long as both shields have the same context, they will both protect the same area.

Context

Each shield has its own security context to define which area(s) to secure and to keep track of who is logged in. The context is automatically added to the ContextMap of the SecurityMiddleware. This allows the security layer to determine who is logged in even if you have multiple shields configured.

Bitty only comes with a session-based security context. Don't want to track users that way? No problemo! You can create your own security context by using the ContextInterface. For example, if you were to create an API token shield, you'd probably want to make an InMemoryContext so that authentication doesn't persist on subsequent requests.

Basic Usage

At a minimum, you need to give a name to the context and a list of paths to protect. The name is used to store authentication data. Different contexts might require different authentication, so it's important to keep it all separate.

The list of paths should be an array indexed by a regex pattern with an array of roles required to access the path as the value. In case that didn't make sense, it's probably easier to see it as code:

<?php

$paths = [
    'some_regex' => ['list', 'of', 'roles'],
    // ...
];

Since the pattern is a regex, you can get very specific - just make sure you escape any special characters! To allow anyone to access a path, use an empty array for the roles.

Just remember, the first pattern that matches is the one used. So always put your "allow" statements at the top, then your "deny" statements. Ordering matters. If you do it wrong, you might block all access.

<?php

use Bitty\Security\Context\Context;

// Do this!
$context = new Context(
    'my_secure_area',
    [
        // Allow anyone to access /admin/login
        '^/admin/login$' => [],

        // Restrict all other /admin/ access to user's with ROLE_ADMIN
        '^/admin/' => ['ROLE_ADMIN'],
    ]
);

// DON'T do this.
$context = new Context(
    'my_secure_area',
    [
        // Restrict all /admin/ access to user's with ROLE_ADMIN
        '^/admin/' => ['ROLE_ADMIN'],

        // Now no one can log in.
        '^/admin/login$' => [],
    ]
);

Advanced Usage

You can also control additional aspects of the security context by overriding some of the default parameters.

<?php

use Bitty\Security\Context\Context;

$context = new Context(
    'my_secure_area',
    [
        // Your paths
        ...
    ],
    [
        // Whether or not this is the default context.
        'default' => true,

        // How long (in seconds) sessions are good for.
        // Defaults to 24 hours.
        'ttl' => 86400,

        // Timeout (in seconds) to invalidate a session after no activity.
        // Defaults to zero (disabled).
        'timeout' => 0,

        // Delay (in seconds) to wait before destroying an old session.
        // Sessions are flagged as "destroyed" during re-authentication.
        // Allows for a network lag in asynchronous applications.
        'destroy.delay' => 300
    ]
);

Another option is to create a custom context by overwriting Context::getDefaultConfig(). You could then use your custom context in different shields or different applications and always have your desired defaults.

Authentication

The built-in authentication supports any number of user providers which can all use the same password encoder or different classes of users can use different encoders.

Bitty only comes with an InMemoryUserProvider. You'll most likely want to load users from a database, so you'll have to build a custom user provider using the UserProviderInterface. The User Provider section goes into more detail on how to create custom providers.

Basic Usage

A simple application will probably only have one source of users that all use the same password encoding method.

<?php

use Bitty\Security\Authentication\Authenticator;
use Bitty\Security\Encoder\BcryptEncoder;
use Bitty\Security\User\Provider\InMemoryUserProvider;

$authenticator = new Authenticator(
    new InMemoryUserProvider(
        [
            'user' => [
                // Password is "user"
                'password' => '$2y$10$99Ru4p3RYylJObg919g1iOCvbI0hPl/glCjRwITNQ7cHO6jxdumrC',
                'roles' => ['ROLE_USER'],

                // Optionally, you an specifiy a salt.
                // However, bcrypt gets its from the password string.
                // 'salt' => null,
            ],
            'admin' => [
                // Password is "admin"
                'password' => '$2y$10$mcjBnwIm90iz6OH0HXEyGO3QWaCdO29RX60uiBzMqrenBsEHgIARK',
                'roles' => ['ROLE_ADMIN'],
            ],
        ]
    ),
    new BcryptEncoder()
);

Advanced Usage

You may also want to load users from different sources and each source might need to use a different password encoder. No worries, there's a class for that. We'll simply create a UserProviderCollection and the authentication layer will look for a user from each user provider until it finds one.

Once it does find a user, it will look at the list of encoders to determine how to encode the password for the specific type of user that was returned.

This is very similar to (and inspired by) the way Symfony does it.

<?php

use Bitty\Security\Authentication\Authenticator;
use Bitty\Security\Encoder\PlainTextEncoder;
use Bitty\Security\User\Provider\InMemoryUserProvider;
use Bitty\Security\User\Provider\UserProviderCollection;
use Bitty\Security\User\User;

$authenticator = new Authenticator(
    new UserProviderCollection(
        [
            // Returns instance of Bitty\Security\User\User
            new InMemoryUserProvider(
                [
                    'user' => [
                        'password' => 'user',
                        'roles' => ['ROLE_USER'],
                    ],
                    'admin' => [
                        'password' => 'admin',
                        'roles' => ['ROLE_ADMIN'],
                    ],
                ]
            ),
            // ...
        ]
    ),
    [
        // Define which user classes use which encoders.
        User::class => new PlainTextEncoder(),
    ]
);

User Providers

All users are loaded using a user provider. However, the only user provider that comes with Bitty is the InMemoryUserProvider. Luckily, we can build any sort of custom user provider using the UserProviderInterface.

Creating a Custom User

Each user provider is expected to return an instance of UserInterface. If we want to make our own user provider, we'll first have to make a user it can return.

The user object is stored in the session, so the less data there is to store, the better. Other than the interface methods, you may want to define a __sleep or __wakeup method to define what properties are safe to store in the session.

<?php

use Bitty\Security\User\UserInterface;

class MyUser implements UserInterface
{
    // ...

    /**
     * Only serialize non-sensitive data.
     *
     * @return string[]
     */
    public function __sleep()
    {
        return ['id', 'username', 'roles'];
    }
}

Creating a Custom User Provider

Now that we have a user, we'll need to make a way of loading it. That's where the UserProviderInterface comes in. Alternatively, you can extend the AbstractUserProvider, but it is not required.

In this example, we're going to build a very basic database user provider.

<?php

use Bitty\Security\Exception\AuthenticationException;
use Bitty\Security\User\Provider\UserProviderInterface;
use Bitty\Security\User\UserInterface;

class MyDatabaseUserProvider implements UserProviderInterface
{
    protected $db = null;

    public function __construct($user, $pass, $db, $host = 'localhost')
    {
        $this->db = new \PDO('mysql:host='.$host.';dbname='.$db, $user, $pass);
    }

    public function getUser($username)
    {
        // Protect against absurdly long usernames.
        if (strlen($username) > UserProviderInterface::MAX_USERNAME_LEN) {
            throw new AuthenticationException('Invalid username.');
        }

        $stmt = $this->db->prepare('SELECT * FROM users WHERE username = ?');
        $stmt->execute([$username]);

        $user = $stmt->fetch();
        if (!$user) {
            return;
        }

        return new MyUser($user);
    }
}

Encoders

Encoders both encode and verify passwords. There are three encoders that come with Bitty that should handle most needs: PlainTextEncoder, MessageDigestEncoder, and the BcryptEncoder (recommended default).

PlainTextEncoder

The PlainTextEncoder, as you may have guessed, doesn't actually encode a password; it simply returns password as it was received. It comes in handy when testing the authentication system, but is definitely not recommended for real world use.

<?php

use Bitty\Security\Encoder\PlainTextEncoder;

$encoder = new PlainTextEncoder();

$encoder->encode('password');

MessageDigestEncoder

The MessageDigestEncoder wraps PHP's built-in hash function and supports a wide variety of hashing algorithms. This includes md5, sha1, sha256, sha512, and an entire list of others.

<?php

use Bitty\Security\Encoder\MessageDigestEncoder;

$algorithm = 'sha256';
$encoder   = new MessageDigestEncoder($algorithm);

$encoder->encode('password');

BrcyptEncoder

The recommended default encoder is the BcryptEncoder. It wraps PHP's password_hash and password_verify functions and is likely to be the most secure and reliable method of encoding user passwords.

<?php

use Bitty\Security\Encoder\BrcyptEncoder;

$cost    = 10;
$encoder = new BrcyptEncoder($cost);

$encoder->encode('password');

Custom Encoders

If the default encoders aren't enough, you can also build your own using the EncoderInterface or by extending the AbstractEncoder. For example, if you're using PHP 7.2+, you could make an Argon2 encoder. This is the hashing function recommended by the Open Web Application Security Project (OWASP).

Authorization

TODO: Write this.

Strategies

TODO: Write this.

Voters

TODO: Write this.