diff --git a/composer.json b/composer.json index 9e40525cc..1a0129579 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "symfony/event-dispatcher": "2.1.*", "symfony/http-foundation": "2.1.*", "symfony/http-kernel": "2.1.*", + "symfony/security": "2.1.*", "symfony/routing": "2.1.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 2168dcd16..f1032c9f3 100644 --- a/composer.lock +++ b/composer.lock @@ -1,5 +1,5 @@ { - "hash": "2159a9aea3c462e2837553e85846d0ac", + "hash": "18fdd4879bd4a6f9f92e7d9e032d20cd", "packages": [ { "package": "pimple/pimple", @@ -34,6 +34,11 @@ "version": "dev-master", "source-reference": "526d5d663f0b3170a91f916f912075609120e09a" }, + { + "package": "symfony/http-kernel", + "version": "dev-master", + "source-reference": "fd5935fb6cd03dbd06930f2e3065c931694a5c92" + }, { "package": "symfony/http-kernel", "version": "dev-master", @@ -41,9 +46,10 @@ "alias-version": "2.1.9999999.9999999-dev" }, { - "package": "symfony/http-kernel", + "package": "symfony/routing", "version": "dev-master", - "source-reference": "fd5935fb6cd03dbd06930f2e3065c931694a5c92" + "alias-pretty-version": "2.1.x-dev", + "alias-version": "2.1.9999999.9999999-dev" }, { "package": "symfony/routing", @@ -51,10 +57,15 @@ "source-reference": "4eef37eee0961782dfe66a23df4fc280ff1a9e44" }, { - "package": "symfony/routing", + "package": "symfony/security", "version": "dev-master", "alias-pretty-version": "2.1.x-dev", "alias-version": "2.1.9999999.9999999-dev" + }, + { + "package": "symfony/security", + "version": "dev-master", + "source-reference": "cfbb58936b3b9e9b5c31d191ed8056acd2932eb8" } ], "packages-dev": [ @@ -81,24 +92,24 @@ { "package": "swiftmailer/swiftmailer", "version": "dev-master", - "alias-pretty-version": "4.1.x-dev", - "alias-version": "4.1.9999999.9999999-dev" + "source-reference": "d33d54cc8a081b0b85734744936ede1ba230dd64" }, { "package": "swiftmailer/swiftmailer", "version": "dev-master", - "source-reference": "d33d54cc8a081b0b85734744936ede1ba230dd64" + "alias-pretty-version": "4.1.x-dev", + "alias-version": "4.1.9999999.9999999-dev" }, { "package": "symfony/browser-kit", "version": "dev-master", - "source-reference": "6d1864547be92e51972a416fae9460b8be4afe0e" + "alias-pretty-version": "2.1.x-dev", + "alias-version": "2.1.9999999.9999999-dev" }, { "package": "symfony/browser-kit", "version": "dev-master", - "alias-pretty-version": "2.1.x-dev", - "alias-version": "2.1.9999999.9999999-dev" + "source-reference": "6d1864547be92e51972a416fae9460b8be4afe0e" }, { "package": "symfony/css-selector", @@ -111,6 +122,11 @@ "version": "dev-master", "source-reference": "d0a98b37fbb57188766fd7c7d757354397ee6ead" }, + { + "package": "symfony/dom-crawler", + "version": "dev-master", + "source-reference": "2e27527036c4cd608692718414835173c40f52bd" + }, { "package": "symfony/dom-crawler", "version": "dev-master", @@ -118,9 +134,10 @@ "alias-version": "2.1.9999999.9999999-dev" }, { - "package": "symfony/dom-crawler", + "package": "symfony/finder", "version": "dev-master", - "source-reference": "2e27527036c4cd608692718414835173c40f52bd" + "alias-pretty-version": "2.1.x-dev", + "alias-version": "2.1.9999999.9999999-dev" }, { "package": "symfony/finder", @@ -128,7 +145,7 @@ "source-reference": "9ee9a907afeef52956187e862714a7702ca26590" }, { - "package": "symfony/finder", + "package": "symfony/form", "version": "dev-master", "alias-pretty-version": "2.1.x-dev", "alias-version": "2.1.9999999.9999999-dev" @@ -139,7 +156,7 @@ "source-reference": "e9068070fab8919f63e1a4e6313325082f4a1aa2" }, { - "package": "symfony/form", + "package": "symfony/locale", "version": "dev-master", "alias-pretty-version": "2.1.x-dev", "alias-version": "2.1.9999999.9999999-dev" @@ -150,10 +167,9 @@ "source-reference": "741210486db314ff288a44de2628da7ee31d383e" }, { - "package": "symfony/locale", + "package": "symfony/monolog-bridge", "version": "dev-master", - "alias-pretty-version": "2.1.x-dev", - "alias-version": "2.1.9999999.9999999-dev" + "source-reference": "ee24f08e2e74ee964018ce9d5de2a37977f6ec6b" }, { "package": "symfony/monolog-bridge", @@ -161,11 +177,6 @@ "alias-pretty-version": "2.1.x-dev", "alias-version": "2.1.9999999.9999999-dev" }, - { - "package": "symfony/monolog-bridge", - "version": "dev-master", - "source-reference": "ee24f08e2e74ee964018ce9d5de2a37977f6ec6b" - }, { "package": "symfony/options-resolver", "version": "dev-master", @@ -177,11 +188,6 @@ "alias-pretty-version": "2.1.x-dev", "alias-version": "2.1.9999999.9999999-dev" }, - { - "package": "symfony/process", - "version": "dev-master", - "source-reference": "f4f101fc7c1adb8b157058dcc1715f28f1d53208" - }, { "package": "symfony/process", "version": "dev-master", @@ -189,9 +195,9 @@ "alias-version": "2.1.9999999.9999999-dev" }, { - "package": "symfony/translation", + "package": "symfony/process", "version": "dev-master", - "source-reference": "db3e85934353a130d743b2ddd53dd678c8ebca12" + "source-reference": "f4f101fc7c1adb8b157058dcc1715f28f1d53208" }, { "package": "symfony/translation", @@ -200,10 +206,9 @@ "alias-version": "2.1.9999999.9999999-dev" }, { - "package": "symfony/twig-bridge", + "package": "symfony/translation", "version": "dev-master", - "alias-pretty-version": "2.1.x-dev", - "alias-version": "2.1.9999999.9999999-dev" + "source-reference": "db3e85934353a130d743b2ddd53dd678c8ebca12" }, { "package": "symfony/twig-bridge", @@ -230,19 +235,13 @@ { "package": "twig/twig", "version": "dev-master", - "alias-pretty-version": "1.8.x-dev", - "alias-version": "1.8.9999999.9999999-dev" + "source-reference": "ca33207bb22fe6365d13bdaf034f936e30b53560" }, { "package": "twig/twig", "version": "dev-master", "alias-pretty-version": "1.8.x-dev", "alias-version": "1.8.9999999.9999999-dev" - }, - { - "package": "twig/twig", - "version": "dev-master", - "source-reference": "ca33207bb22fe6365d13bdaf034f936e30b53560" } ], "aliases": [ diff --git a/doc/providers/security.rst b/doc/providers/security.rst new file mode 100644 index 000000000..34ad970a6 --- /dev/null +++ b/doc/providers/security.rst @@ -0,0 +1,460 @@ +SecurityServiceProvider +======================= + +The *SecurityServiceProvider* manages authentication and authorization for +your applications. + +Parameters +---------- + +n/a + +Services +-------- + +* **security.context**: The main entry point for the security provider. Use it + to get the current user token. + +* **security.authentication_manager**: An instance of + `AuthenticationProviderManager + `_, + responsible for authentication. + +* **security.access_manager**: An instance of `AccessDecisionManager + `_, + responsible for authorization. + +* **security.session_strategy**: Define the session strategy used for + authentication (default to a migration strategy). + +* **security.user_checker**: Checks user flags after authentication. + +* **security.last_error**: Returns the last authentication errors when given a + Request object. + +* **security.encoder_factory**: Defines the encoding strategies for user + passwords (default to use a digest algorithm for all users). + +.. note:: + + The service provider defines many other services that are used internally + but rarely need to be customized. + +Registering +----------- + +.. code-block:: php + + $app->register(new Silex\Provider\SecurityServiceProvider()); + +.. note:: + + The Symfony Security component does not come with the ``silex`` archives, + so you need to add it as a dependency to your ``composer.json`` file: + + .. code-block:: json + + "require": { + "symfony/security": "2.1.*" + } + +Usage +----- + +The Symfony Security component is powerful. To learn more about it, read the +`Symfony2 Security documentation +`_. + +.. tip:: + + When a security configuration does not behave as expected, enable logging + (with the Monolog extension for instance) as the Security Component logs a + lot of interesting information about what it does and why. + +Below is a list of recipes that cover some common use cases. + +Accessing the current User +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The current user information is stored in a token that is accessible via the +``security.context`` service:: + + $token = $app['security.context']->getToken(); + +If there is no information about the user, the token is ``null``. If the user +is known, you can get it with a call to ``getUser()``:: + + if (null !== $token) { + $user = $token->getUser(); + } + +The user can be a string, and object with a ``_toString()`` method, or an +instance of `UserInterface +`_. + +Securing a Path with HTTP Authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following configuration uses HTTP basic authentication to secure URLs +under ``/admin/``:: + + $app['security.firewalls'] = array( + 'admin' => array( + 'pattern' => '^/admin/', + 'http' => true, + 'users' => array( + // raw password is foo + 'admin' => array('ROLE_ADMIN', '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='), + ), + ), + ); + +The ``pattern`` is a regular expression; the ``http`` setting tells the +security layer to use HTTP basic authentication and the ``users`` entry +defines valid users. + +Each user is defined with the following information: + +* The role or an array of roles for the user (roles are strings beginning with + ``ROLE_`` and ending with anything you want); + +* The user encoded password. + +.. caution:: + + All users must at least have one role associated with them. + +The default configuration of the extension enforces encoded passwords. To +generate a valid encoded password from a raw password, use the +``security.encoder`` service:: + + // find the encoded password for foo + $password = $app['security.encoder']->encodePassword('foo', null); + +The second argument is the salt to be used for the user (defaults to +``null``). + +When the user is authenticated, the user stored in the token is an instance of +`User +`_ + +Securing a Path with a Form +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Using a form to authenticate users is very similar to the above configuration. +Instead of using the ``http`` setting, use the ``form`` one and define these +two parameters: + +* **login_path**: The login path where the user is redirected when he is + accessing a secured area without being authenticated so that he can enter + his credentials; + +* **check_path**: The check URL used by Symfony to validate the credentials of + the user. + +Here is how to secure all URLs under ``/admin/`` with a form:: + + $app['security.firewalls'] = array( + 'admin' => array( + 'pattern' => '^/admin/', + 'form' => array('login_path' => '/login', 'check_path' => '/admin/login_check'), + 'users' => array( + 'admin' => array('ROLE_ADMIN', '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='), + ), + ), + ); + +Always keep in mind the following two golden rules: + +* The ``login_path`` path must always be defined **outside** the secured area; + +* The ``check_path`` path must always be defined **inside** the secured area. + +For the login form to work, create a controller where you start the session:: + + $app->get('/login', function(Request $request) use ($app) { + $app['session']->start(); + + return $app['twig']->render('login.html', array( + 'error' => $app['security.last_error']($request), + 'last_username' => $app['session']->get('_security.last_username'), + )); + }); + +The ``error`` and ``last_username`` variables contain the last authentication +error and the last username entered by the user in case of an authentication +error. + +Create the associated template: + +.. code-block:: jinja + +
+ {{ error }} + + + +
+ +.. note:: + + The ``admin_login_check`` route is automatically defined by Symfony and + its name is derived from the ``check_path`` value (all ``/`` are replaced + with ``_`` and the leading ``/`` is stripped). + +Defining more than one Firewall +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You are not limited to define one firewall per project. + +Configuring several firewalls is useful when you want to secure different +parts of your website with different authentication strategies or for +different users (like using an HTTP basic authentication for the website API +and a form to secure your website administration area). + +It's also useful when you want to secure all URLs except the login form:: + + $app['security.firewalls'] = array( + 'login' => array( + 'pattern' => '^/login$', + ), + 'secured' => array( + 'pattern' => '^.*$', + 'form' => array('login_path' => '/login', 'check_path' => '/login_check'), + 'users' => array( + 'admin' => array('ROLE_ADMIN', '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='), + ), + ), + ); + +The order of the firewall configurations is significant as the first one to +match wins. The above configuration first ensures that the ``/login`` URL is +not secured (no authentication settings), and then it secures all other URLs. + +Adding a Logout +~~~~~~~~~~~~~~~ + +When using a form for authentication, you can let users log out if you add the +``logout`` setting:: + + $app['security.firewalls'] = array( + 'secured' => array( + 'form' => array('login_path' => '/login', 'check_path' => '/admin/login_check'), + 'logout' => array('logout_path' => '/logout'), + + // ... + ), + ); + +A route is automatically generated, based on the configured path (all ``/`` +are replaced with ``_`` and the leading ``/`` is stripped): + +.. code-block:: jinja + + Logout + +Allowing Anonymous Users +~~~~~~~~~~~~~~~~~~~~~~~~ + +When securing only some parts of your website, the user information are not +available in non-secured areas. To make the user accessible in such areas, +enabled the ``anonymous`` authentication mechanism:: + + $app['security.firewalls'] = array( + 'unsecured' => array( + 'anonymous' => true, + + // ... + ), + ); + +When enabling the anonymous setting, a user will always be accessible from the +security context; if the user is not authenticated, it returns the ``anon.`` +string. + +Checking User Roles +~~~~~~~~~~~~~~~~~~~ + +To check if a user is granted some role, use the ``isGranted()`` method on the +security context:: + + if ($app['security.context']->isGranted('ROLE_ADMIN') { + // ... + } + +You can check roles in Twig templates too: + +.. code-block:: jinja + + {% if is_granted('ROLE_ADMIN') %} + Switch to Fabien + {% endif %} + +You can check is a user is "fully authenticated" (not an anonymous user for +instance) with the special ``IS_AUTHENTICATED_FULLY`` role: + +.. code-block:: jinja + + {% if is_granted('IS_AUTHENTICATED_FULLY') %} + Logout + {% else %} + Login + {% endif %} + +.. tip:: + + Don't use the ``getRoles()`` method to check user roles. + +Impersonating a User +~~~~~~~~~~~~~~~~~~~~ + +If you want to be able to switch to another user (without knowing the user +credentials), enable the ``switch_user`` authentication strategy:: + + $app['security.firewalls'] = array( + 'unsecured' => array( + 'switch_user' => array('parameter' => '_switch_user', 'role' => 'ROLE_ALLOWED_TO_SWITCH'), + + // ... + ), + ); + +Switching to another user is now a matter of adding the ``_switch_user`` query +parameter to any URL when logged in as a user who has the +``ROLE_ALLOWED_TO_SWITCH`` role: + +.. code-block:: jinja + + {% if is_granted('ROLE_ALLOWED_TO_SWITCH') %} + Switch to user Fabien + {% endif %} + +You can check that you are impersonating a user by checking the special +``ROLE_PREVIOUS_ADMIN``. This is useful for instance to allow the user to +switch back to his primary account: + +.. code-block:: jinja + + {% if is_granted('ROLE_PREVIOUS_ADMIN') %} + You are an admin but you've switched to another user, + exit the switch. + {% endif %} + +Defining a Role Hierarchy +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Defining a role hierarchy allows to automatically grant users some additional +roles:: + + $app['security.role_hierarchy'] = array( + 'ROLE_ADMIN' => array('ROLE_USER', 'ROLE_ALLOWED_TO_SWITCH'), + ); + +With this configuration, all users with the ``ROLE_ADMIN`` role also +automatically have the ``ROLE_USER`` and ``ROLE_ALLOWED_TO_SWITCH`` roles. + +Defining Access Rules +~~~~~~~~~~~~~~~~~~~~~ + +Roles are a great way to adapt the behavior of your website depending on +groups of users, but they can also be used to further secure some areas by +defining access rules:: + + $app['security.access_rules'] = array( + array('^/admin', 'ROLE_ADMIN', 'https'), + array('^.*$', 'ROLE_USER'), + ); + +With the above configuration, users must have the ``ROLE_ADMIN`` to access the +``/admin`` section of the website, and ``ROLE_USER`` for everything else. +Furthermore, the admin section can only be accessible via HTTPS (if that's not +the case, the user will be automatically redirected). + +Defining a custom User Provider +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Using an array of users is simple and useful when securing an admin section of +a personal website, but you can override this default mechanism with you own. + +The ``users`` setting can be defined as a service that returns an instance of +`UserProvider +`_:: + + 'users' => $app->share(function () use ($app) { + return new UserProvider($app['db']); + }), + +Here is a simple example of a user provider, where Doctrine DBAL is used to +store the users:: + + use Symfony\Component\Security\Core\User\UserProviderInterface; + use Symfony\Component\Security\Core\User\UserInterface; + use Symfony\Component\Security\Core\User\User; + use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; + use Doctrine\DBAL\Connection; + use Doctrine\DBAL\Schema\Table; + + class UserProvider implements UserProviderInterface + { + private $conn; + + public function __construct(Connection $conn) + { + $this->conn = $conn; + } + + public function loadUserByUsername($username) + { + $stmt = $this->conn->executeQuery('SELECT * FROM users WHERE username = ?', array(strtolower($username))); + + if (!$user = $stmt->fetch()) { + throw new UsernameNotFoundException(sprintf('Username "%s" does not exist.', $username)); + } + + return new User($user['username'], $user['password'], explode(',', $user['roles']), true, true, true, true); + } + + public function refreshUser(UserInterface $user) + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user))); + } + + return $this->loadUserByUsername($user->getUsername()); + } + + public function supportsClass($class) + { + return $class === 'Symfony\Component\Security\Core\User\User'; + } + } + +In this example, instances of the default ``User`` class are created for the +users, but you can define your own class; the only requirement is that the +class must implement `UserInterface +`_ + +And here is the code that you can use to create the database schema and some +sample users:: + + $schema = $conn->getSchemaManager(); + if (!$schema->tablesExist('users')) { + $users = new Table('users'); + $users->addColumn('id', 'integer', array('unsigned' => true)); + $users->setPrimaryKey(array('id')); + $users->addColumn('username', 'string', array('length' => 32)); + $users->addUniqueIndex(array('username')); + $users->addColumn('password', 'string', array('length' => 255)); + $users->addColumn('roles', 'string', array('length' => 255)); + + $schema->createTable($users); + + $this->conn->executeQuery('INSERT INTO users (username, password, roles) VALUES ("fabien", "5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg==", "ROLE_USER")'); + $this->conn->executeQuery('INSERT INTO users (username, password, roles) VALUES ("admin", "5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg==", "ROLE_ADMIN")'); + } + +.. tip:: + + If you are using the Doctrine ORM, the Symfony bridge for Doctrine + provides a user provider class that is able to load users from your + entities. diff --git a/src/Silex/Application.php b/src/Silex/Application.php index 0b9e3e4c3..3cbf6b024 100644 --- a/src/Silex/Application.php +++ b/src/Silex/Application.php @@ -614,7 +614,7 @@ public static function getSubscribedEvents() ), KernelEvents::CONTROLLER => 'onKernelController', KernelEvents::RESPONSE => 'onKernelResponse', - KernelEvents::EXCEPTION => 'onKernelException', + KernelEvents::EXCEPTION => array('onKernelException', -10), KernelEvents::TERMINATE => 'onKernelTerminate', KernelEvents::VIEW => array('onKernelView', -10), ); diff --git a/src/Silex/Provider/SecurityServiceProvider.php b/src/Silex/Provider/SecurityServiceProvider.php new file mode 100644 index 000000000..4a0c64003 --- /dev/null +++ b/src/Silex/Provider/SecurityServiceProvider.php @@ -0,0 +1,407 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silex\Provider; + +use Silex\Application; +use Silex\ServiceProviderInterface; + +use Symfony\Component\HttpFoundation\RequestMatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\SecurityContext; +use Symfony\Component\Security\Core\SecurityContextInterface; +use Symfony\Component\Security\Core\User\UserChecker; +use Symfony\Component\Security\Core\User\InMemoryUserProvider; +use Symfony\Component\Security\Core\Encoder\EncoderFactory; +use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder; +use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider; +use Symfony\Component\Security\Core\Authentication\Provider\AnonymousAuthenticationProvider; +use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver; +use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter; +use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Role\RoleHierarchy; +use Symfony\Component\Security\Http\Firewall; +use Symfony\Component\Security\Http\FirewallMap; +use Symfony\Component\Security\Http\Firewall\AccessListener; +use Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener; +use Symfony\Component\Security\Http\Firewall\BasicAuthenticationListener; +use Symfony\Component\Security\Http\Firewall\LogoutListener; +use Symfony\Component\Security\Http\Firewall\SwitchUserListener; +use Symfony\Component\Security\Http\Firewall\AnonymousAuthenticationListener; +use Symfony\Component\Security\Http\Firewall\ContextListener; +use Symfony\Component\Security\Http\Firewall\ExceptionListener; +use Symfony\Component\Security\Http\Firewall\ChannelListener; +use Symfony\Component\Security\Http\EntryPoint\FormAuthenticationEntryPoint; +use Symfony\Component\Security\Http\EntryPoint\BasicAuthenticationEntryPoint; +use Symfony\Component\Security\Http\EntryPoint\RetryAuthenticationEntryPoint; +use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; +use Symfony\Component\Security\Http\Logout\SessionLogoutHandler; +use Symfony\Component\Security\Http\AccessMap; +use Symfony\Component\Security\Http\HttpUtils; + +/** + * Symfony Security component Provider. + * + * @author Fabien Potencier + */ +class SecurityServiceProvider implements ServiceProviderInterface +{ + protected $fakeRoutes; + + public function register(Application $app) + { + // used to register routes for login_check and logout + $this->fakeRoutes = array(); + + $that = $this; + + $app['security.context'] = $app->share(function () use ($app) { + return new SecurityContext($app['security.authentication_manager'], $app['security.access_manager']); + }); + + $app['security.authentication_manager'] = $app->share(function () use ($app) { + $manager = new AuthenticationProviderManager($app['security.authentication_providers']); + $manager->setEventDispatcher($app['dispatcher']); + + return $manager; + }); + + // by default, all users use the digest encoder + $app['security.encoder_factory'] = $app->share(function () use ($app) { + return new EncoderFactory(array( + 'Symfony\Component\Security\Core\User\UserInterface' => $app['security.encoder.digest'], + )); + }); + + $app['security.encoder.digest'] = $app->share(function () use ($app) { + return new MessageDigestPasswordEncoder(); + }); + + $app['security.user_checker'] = $app->share(function () use ($app) { + return new UserChecker(); + }); + + $app['security.access_manager'] = $app->share(function () use ($app) { + if (!isset($app['security.role_hierarchy'])) { + $app['security.role_hierarchy'] = array(); + } + + return new AccessDecisionManager(array( + new RoleHierarchyVoter(new RoleHierarchy($app['security.role_hierarchy'])), + new AuthenticatedVoter($app['security.trust_resolver']), + )); + }); + + $app['security.firewall'] = $app->share(function () use ($app) { + return new Firewall($app['security.firewall_map'], $app['dispatcher']); + }); + + $app['security.channel_listener'] = $app->share(function () use ($app) { + return new ChannelListener( + $app['security.access_map'], + new RetryAuthenticationEntryPoint($app['request.http_port'], $app['request.https_port']), + $app['logger'] + ); + }); + + $app['security.firewall_map'] = $app->share(function () use ($app) { + $map = new FirewallMap(); + $entryPoint = 'form'; + $providers = array(); + foreach ($app['security.firewalls'] as $name => $firewall) { + $pattern = isset($firewall['pattern']) ? $firewall['pattern'] : null; + $users = isset($firewall['users']) ? $firewall['users'] : array(); + unset($firewall['pattern'], $firewall['users']); + + $protected = count($firewall); + + $listeners = array($app['security.channel_listener']); + + if ($protected) { + if (!isset($app['security.context_listener.'.$name])) { + if (!isset($app['security.user_provider.'.$name])) { + $app['security.user_provider.'.$name] = is_array($users) ? $app['security.user_provider.inmemory._proto']($users) : $users; + } + + $app['security.context_listener.'.$name] = $app['security.context_listener._proto']( + $name, + array($app['security.user_provider.'.$name]) + ); + } + + $listeners[] = $app['security.context_listener.'.$name]; + } + + if (count($firewall)) { + foreach (array('logout', 'pre_auth', 'form', 'http', 'remember_me', 'anonymous') as $type) { + if (isset($firewall[$type])) { + $options = $firewall[$type]; + + // normalize options + if (!is_array($options)) { + if (!$options) { + continue; + } + + $options = array(); + } + + if ('http' == $type) { + $entryPoint = 'http'; + } + + if (!isset($app['security.authentication.'.$name.'.'.$type])) { + $app['security.authentication.'.$name.'.'.$type] = $app['security.authentication.'.$type.'._proto']($name, $options); + } + + $listeners[] = $app['security.authentication.'.$name.'.'.$type]; + } + } + + if ($protected) { + $listeners[] = $app['security.access_listener']; + + if (isset($firewall['switch_user'])) { + $listeners[] = $app['security.authentication.switch_user._proto']($name, $firewall['switch_user']); + } + } + } + + if ($protected && !isset($app['security.exception_listener.'.$name])) { + $app['security.exception_listener.'.$name] = $app['security.exception_listener._proto']($entryPoint, $name); + } + + $map->add( + is_string($pattern) ? new RequestMatcher($pattern) : $pattern, + $listeners, + $protected ? $app['security.exception_listener.'.$name] : null + ); + } + + return $map; + }); + + $app['security.authentication_providers'] = $app->share(function () use ($app) { + $providers = array(); + foreach ($app['security.firewalls'] as $name => $firewall) { + unset($firewall['pattern'], $firewall['users']); + + if (!count($firewall)) { + continue; + } + + if (!isset($app['security.authentication_provider.'.$name])) { + $a = 'anonymous' == $name ? 'anonymous' : 'dao'; + $app['security.authentication_provider.'.$name] = $app['security.authentication_provider.'.$a.'._proto']($name); + } + $providers[] = $app['security.authentication_provider.'.$name]; + } + + return $providers; + }); + + $app['security.access_listener'] = $app->share(function () use ($app) { + return new AccessListener( + $app['security.context'], + $app['security.access_manager'], + $app['security.access_map'], + $app['security.authentication_manager'], + $app['logger'] + ); + }); + + $app['security.access_map'] = $app->share(function () use ($app) { + $map = new AccessMap(); + + if (!isset($app['security.access_rules'])) { + $app['security.access_rules'] = array(); + } + + foreach ($app['security.access_rules'] as $rule) { + if (is_string($rule[0])) { + $rule[0] = new RequestMatcher($rule[0]); + } + + $map->add($rule[0], (array) $rule[1], isset($rule[2]) ? $rule[2] : null); + } + + return $map; + }); + + $app['security.trust_resolver'] = $app->share(function () use ($app) { + return new AuthenticationTrustResolver('Symfony\Component\Security\Core\Authentication\Token\AnonymousToken', 'Symfony\Component\Security\Core\Authentication\Token\RememberMeToken'); + }); + + $app['security.session_strategy'] = $app->share(function () use ($app) { + return new SessionAuthenticationStrategy('migrate'); + }); + + $app['security.http_utils'] = $app->share(function () use ($app) { + return new HttpUtils(); + }); + + $app['security.last_error'] = $app->protect(function (Request $request) { + if ($request->attributes->has(SecurityContextInterface::AUTHENTICATION_ERROR)) { + return $request->attributes->get(SecurityContextInterface::AUTHENTICATION_ERROR)->getMessage(); + } + + $session = $request->getSession(); + if ($session && $session->has(SecurityContextInterface::AUTHENTICATION_ERROR)) { + $error = $session->get(SecurityContextInterface::AUTHENTICATION_ERROR)->getMessage(); + $session->remove(SecurityContextInterface::AUTHENTICATION_ERROR); + + return $error; + } + }); + + // prototypes (used by the Firewall Map) + + $app['security.context_listener._proto'] = $app->protect(function ($providerKey, $userProviders) use ($app) { + return new ContextListener( + $app['security.context'], + $userProviders, + $providerKey, + $app['logger'], + $app['dispatcher'] + ); + }); + + $app['security.user_provider.inmemory._proto'] = $app->protect(function ($params) use ($app) { + $users = array(); + foreach ($params as $name => $user) { + $users[$name] = array('roles' => (array) $user[0], 'password' => $user[1]); + } + + return new InMemoryUserProvider($users); + }); + + $app['security.exception_listener._proto'] = $app->protect(function ($entryPoint, $name) use ($app) { + if (!isset($app['security.entry_point.'.$entryPoint.'.'.$name])) { + $app['security.entry_point.'.$entryPoint.'.'.$name] = $app['security.entry_point.'.$entryPoint.'._proto']($name); + } + + return new ExceptionListener( + $app['security.context'], + $app['security.trust_resolver'], + $app['security.http_utils'], + $app['security.entry_point.'.$entryPoint.'.'.$name], + null, // errorPage + null, // AccessDeniedHandlerInterface + $app['logger'] + ); + }); + + $app['security.authentication.form._proto'] = $app->protect(function ($providerKey, $options) use ($app, $that) { + $that->addFakeRoute(array('post', $tmp = isset($options['check_path']) ? $options['check_path'] : '/login_check', str_replace('/', '_', ltrim($tmp, '/')))); + + return new UsernamePasswordFormAuthenticationListener( + $app['security.context'], + $app['security.authentication_manager'], + $app['security.session_strategy'], + $app['security.http_utils'], + $providerKey, + $options, + null, // AuthenticationSuccessHandlerInterface + null, // AuthenticationFailureHandlerInterface + $app['logger'], + $app['dispatcher'], + isset($options['with_csrf']) && $options['with_csrf'] && isset($app['form.csrf_provider']) ? $app['form.csrf_provider'] : null + ); + }); + + $app['security.authentication.http._proto'] = $app->protect(function ($providerKey, $options) use ($app) { + return new BasicAuthenticationListener( + $app['security.context'], + $app['security.authentication_manager'], + $providerKey, + $app['security.entry_point.http'], + $app['logger'] + ); + }); + + $app['security.authentication.anonymous._proto'] = $app->protect(function ($providerKey, $options) use ($app) { + return new AnonymousAuthenticationListener( + $app['security.context'], + $providerKey, + $app['logger'] + ); + }); + + $app['security.authentication.logout._proto'] = $app->protect(function ($providerKey, $options) use ($app, $that) { + $that->addFakeRoute(array('get', $tmp = isset($options['logout_path']) ? $options['logout_path'] : '/logout', str_replace('/', '_', ltrim($tmp, '/')))); + + $listener = new LogoutListener( + $app['security.context'], + $app['security.http_utils'], + $options, + null, // LogoutSuccessHandlerInterface + isset($options['with_csrf']) && $options['with_csrf'] && isset($app['form.csrf_provider']) ? $app['form.csrf_provider'] : null + ); + + $listener->addHandler(new SessionLogoutHandler()); + + return $listener; + }); + + $app['security.authentication.switch_user._proto'] = $app->protect(function ($name, $options) use ($app, $that) { + return new SwitchUserListener( + $app['security.context'], + $app['security.user_provider.'.$name], + $app['security.user_checker'], + $name, + $app['security.access_manager'], + $app['logger'], + isset($options['parameter']) ? $options['parameter'] : '_switch_user', + isset($options['role']) ? $options['role'] : 'ROLE_ALLOWED_TO_SWITCH', + $app['dispatcher'] + ); + }); + + $app['security.entry_point.form._proto'] = $app->protect(function ($name, $loginPath = '/login', $useForward = false) use ($app) { + return new FormAuthenticationEntryPoint($app, $app['security.http_utils'], $loginPath, $useForward); + }); + + $app['security.entry_point.http._proto'] = $app->protect(function ($name, $realName = 'Secured') use ($app) { + return new BasicAuthenticationEntryPoint($realName); + }); + + $app['security.authentication_provider.dao._proto'] = $app->protect(function ($name) use ($app) { + return new DaoAuthenticationProvider( + $app['security.user_provider.'.$name], + $app['security.user_checker'], + $name, + $app['security.encoder_factory'] + ); + }); + + $app['security.authentication_provider.anonymous._proto'] = $app->protect(function ($name) use ($app) { + return new AnonymousAuthenticationProvider($name); + }); + } + + public function boot(Application $app) + { + $app['dispatcher']->addListener('kernel.request', array($app['security.firewall'], 'onKernelRequest'), 8); + + foreach ($this->fakeRoutes as $route) { + $method = $route[0]; + + $app->$method($route[1], function() {})->bind($route[2]); + } + } + + public function addFakeRoute($route) + { + $this->fakeRoutes[] = $route; + } +} diff --git a/src/Silex/Provider/TwigServiceProvider.php b/src/Silex/Provider/TwigServiceProvider.php index 8240e3f86..7894da66d 100644 --- a/src/Silex/Provider/TwigServiceProvider.php +++ b/src/Silex/Provider/TwigServiceProvider.php @@ -17,6 +17,7 @@ use Symfony\Bridge\Twig\Extension\RoutingExtension as TwigRoutingExtension; use Symfony\Bridge\Twig\Extension\TranslationExtension as TwigTranslationExtension; use Symfony\Bridge\Twig\Extension\FormExtension as TwigFormExtension; +use Symfony\Bridge\Twig\Extension\SecurityExtension as TwigSecurityExtension; /** * Twig Provider. @@ -54,6 +55,10 @@ public function register(Application $app) $twig->addExtension(new TwigTranslationExtension($app['translator'])); } + if (isset($app['security.context'])) { + $twig->addExtension(new TwigSecurityExtension($app['security.context'])); + } + if (isset($app['form.factory'])) { if (!isset($app['twig.form.templates'])) { $app['twig.form.templates'] = array('form_div_layout.html.twig'); diff --git a/tests/Silex/Tests/Provider/SecurityServiceProviderTest.php b/tests/Silex/Tests/Provider/SecurityServiceProviderTest.php new file mode 100644 index 000000000..1fc78c30d --- /dev/null +++ b/tests/Silex/Tests/Provider/SecurityServiceProviderTest.php @@ -0,0 +1,143 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Silex\Tests\Provider; + +use Silex\Application; +use Silex\WebTestCase; +use Silex\Provider\SecurityServiceProvider; +use Silex\Provider\SessionServiceProvider; +use Symfony\Component\HttpFoundation\Request; + +/** + * SecurityServiceProvider + * + * @author Fabien Potencier + */ +class SecurityServiceProviderTest extends WebTestCase +{ + public function setUp() + { + if (!is_dir(__DIR__.'/../../../../vendor/symfony/security')) { + $this->markTestSkipped('Security dependency was not installed.'); + } + + parent::setUp(); + } + + public function test() + { + $app = $this->app; + + $client = $this->createClient(); + + $client->request('get', '/'); + $this->assertEquals('ANONYMOUS', $client->getResponse()->getContent()); + + $client->request('post', '/login_check', array('_username' => 'fabien', '_password' => 'bar')); + $this->assertEquals('Bad credentials', $app['security.last_error']($client->getRequest())); + // hack to re-close the session as the previous assertions re-opens it + $client->getRequest()->getSession()->save(); + + $client->request('post', '/login_check', array('_username' => 'fabien', '_password' => 'foo')); + $this->assertEquals('', $app['security.last_error']($client->getRequest())); + $client->getRequest()->getSession()->save(); + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $this->assertEquals('http://localhost/', $client->getResponse()->headers->get('Location')); + + $client->request('get', '/'); + $this->assertEquals('fabienAUTHENTICATED', $client->getResponse()->getContent()); + $client->request('get', '/admin'); + $this->assertEquals(403, $client->getResponse()->getStatusCode()); + + $client->request('get', '/logout'); + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $this->assertEquals('http://localhost/', $client->getResponse()->headers->get('Location')); + + $client->request('get', '/'); + $this->assertEquals('ANONYMOUS', $client->getResponse()->getContent()); + + $client->request('get', '/admin'); + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $this->assertEquals('http://localhost/login', $client->getResponse()->headers->get('Location')); + + $client->request('post', '/login_check', array('_username' => 'admin', '_password' => 'foo')); + $this->assertEquals('', $app['security.last_error']($client->getRequest())); + $client->getRequest()->getSession()->save(); + $this->assertEquals(302, $client->getResponse()->getStatusCode()); + $this->assertEquals('http://localhost/admin', $client->getResponse()->headers->get('Location')); + + $client->request('get', '/'); + $this->assertEquals('adminAUTHENTICATEDADMIN', $client->getResponse()->getContent()); + $client->request('get', '/admin'); + $this->assertEquals('admin', $client->getResponse()->getContent()); + } + + public function createApplication() + { + $app = new Application(); + $app->register(new SessionServiceProvider()); + $app->register(new SecurityServiceProvider(), array( + 'security.firewalls' => array( + 'login' => array( + 'pattern' => '^/login$', + ), + 'default' => array( + 'pattern' => '^.*$', + 'anonymous' => true, + 'form' => true, + 'logout' => true, + 'users' => array( + // password is foo + 'fabien' => array('ROLE_USER', '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='), + 'admin' => array('ROLE_ADMIN', '5FZ2Z8QIkA7UTZ4BYkoC+GsReLf569mSKDsfods6LYQ8t+a8EW9oaircfMpmaLbPBh4FOBiiFyLfuZmTSUwzZg=='), + ), + ), + ), + 'security.access_rules' => array( + array('^/admin', 'ROLE_ADMIN'), + ), + 'security.role_hierarchy' => array( + 'ROLE_ADMIN' => array('ROLE_USER'), + ), + )); + + $app->get('/login', function(Request $request) use ($app) { + $app['session']->start(); + + return $app['security.last_error']($request); + }); + + $app->get('/', function() use ($app) { + $user = $app['security.context']->getToken()->getUser(); + + $content = is_object($user) ? $user->getUsername() : 'ANONYMOUS'; + + if ($app['security.context']->isGranted('IS_AUTHENTICATED_FULLY')) { + $content .= 'AUTHENTICATED'; + } + + if ($app['security.context']->isGranted('ROLE_ADMIN')) { + $content .= 'ADMIN'; + } + + return $content; + }); + + $app->get('/admin', function() use ($app) { + return 'admin'; + }); + + $app['session.test'] = true; + + return $app; + } +}