At work, we’ve decided to upgrade our entire workflow. One of the main decisions we’ve had to make, was which PHP framework we wanted to start using. I have quite some experience with Zend Framework, so I was kinda biased towards that framework. However, the team didn’t want to create a new codebase on ZF1. Since waiting till ZF2 is released was not an option, we decided to give Symfony2 a try.
To each build up some experience with SF2, each team member was tasked to create a blog system with SF2. I decided to use MongoDB as my preferred method of data persistence, since the rest of the team also showed quite some interest in using this technology. Following the documentation on the Symfony2 website, I quickly set up my BlogBundle, with an administration interface and everything. The next step was to secure the backoffice with user credentials coming from a MongoDB collection. This is when a whole world of hurt opened up: almost no information on the intertubes on how to do this. I figured out I would need a custom UserInterface and UserProviderInterface. People kept referring to the FOSUserBundle. The Friends of Symfony are a cool bunch, but I wanted to write my own code, as part of the learning process. A couple of hours and an almost-headache later, I had figured it out. Since there will probably be other people struggling with this, here’s how I did it.
Setting up the Security configuration
When you want to create parts of an application which need to be accessed by logged in users, you need to create firewall rules for this. Here’s my configuration.
File: /app/config/security.yml
security:
encoders:
MediaMates\BlogBundle\Document\User: md5
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
providers:
default:
id: mm_security_provider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
login:
pattern: ^/blog/backoffice/login$
anonymous: ~
security: false
secured_area:
pattern: ^/
anonymous: ~
logout:
path: /blog/backoffice/logout
target: /blog
form_login:
login_path: /blog/backoffice/login
check_path: /blog/backoffice/login_check
access_control:
- { path: ^/blog/backoffice, roles: ROLE_ADMIN}
Important here is the “providers” part. As you can see, I specified a provider named “default”. I want to use a custom provider, and I refer to it in the services via its ID. The ID here is “mm_security_provider”. The rest of the config is pretty standard. The firewall will allow anonymous users to the entire path, but the access controll requires an admin role if you access anything from the backoffice.
The “mm_security_provider” is still unknown to the dependency injection container, so we have to configure it. Here’s the configuration:
File: /src/MediaMates/BlogBundle/Resources/config/services.yml
services:
mm_service_blog:
class: MediaMates\BlogBundle\Service\Blog
arguments: [@doctrine.odm.mongodb.document_manager]
mm_service_user:
class: MediaMates\BlogBundle\Service\User
arguments: [@doctrine.odm.mongodb.document_manager]
mm_security_provider:
class: MediaMates\BlogBundle\Security\MongoProvider
arguments: [@mm_service_user]
The “mm_security_provider” is configured to create an object of class MediaMates\BlogBundle\Security\MongoProvider. The provider will need to access the User collection in my MongoDB. I have taken a Service Oriented Architecture (SOA) approach, where I gather all business intelligence in so called Service Classes. My provider needs the correct service to talk to the database. So I configured an argument for the constructor which refers to my User service. In case you don’t work with a SOA, you can just replace the argument with @doctrine.odm.mongodb.document_manager. This should inject the Doctrine MongoDB ODM into your provider instead of a User service class.
Implementing the UserProviderInterface
Now that we have made the necessary configurations, it’s time to implement the actual custom provider that will allow the Security layer to talk to a MongoDB collection. First I’ll show you the code, and then I’ll explain what happens.
File: /src/MediaMates/BlogBundle/Security/MongoProvider.php
namespace MediaMates\BlogBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use MediaMates\BlogBundle\Service;
class MongoProvider implements UserProviderInterface
{
protected $userService;
public function __construct (Service\User $userService)
{
$this->userService = $userService;
}
public function loadUserByUsername($username)
{
$user = $this->userService->getUserByUsername($username);
if (null === $user) {
throw new UsernameNotFoundException(sprintf('User "%s" not found', $username));
}
return $user;
}
public function refreshUser (UserInterface $user)
{
return $this->loadUserByUsername( $user->getUsername() );
}
public function supportsClass($class)
{
return $class === 'MediaMates\BlogBundle\Document\User';
}
}
The most important part is to extend Doctrine\ODM\MongoDB\DocumentRepository and implement Symfony\Component\Security\Core\User\UserProviderInterface. The interface only requires you to implement 3 methods: “loadUserbyUsername”, “refreshUser” and “supportsClass”. I have also implemented the constructor. The argument of the constructor, in my case, is my User service, like I specified earlier in my services configuration. In case you changed the configuration to the Doctrine MongoDB ODM, then you will have to update the constructor argument to be of the correct type.
loadUserByUsername is called by the Security layer, when the given username & password need to be verified. You have to find a user by his/her username. If this fails, you throw a UsernameNotFoundException. If you do find a user, you just return the object.
refreshUser is called by the Security layer, whenever it deems it necessary to reload the given user. This already happens after login, because it is possible that the object retrieved for verifying the credentials, is incomplete. With refreshUser, you should return a complete User object.
supportsClass returns wether or not the given class name is supported by your provider.
Implementing the UserInterface
The hard part is over now. Only thing remaining, is creating a User model which implements the correct interface, so that the Security layer knows what to expect. Here’s my model, complete with MongoDB ODM annotations, properties, getters & setters, and required methods by the interface.
File: /src/MediaMates/BlogBundle/Document/User.php
namespace MediaMates\BlogBundle\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @MongoDB\Document
*/
class User implements UserInterface
{
/**
*
* @MongoDB\Id
*/
protected $userID;
/**
* @MongoDB\String
*/
protected $username;
/**
* @MongoDB\String
*/
protected $password;
/**
* @MongoDB\String
*/
protected $firstname;
/**
* @MongoDB\String
*/
protected $lastname;
/**
* @var string A salt for the password
*/
protected $salt = "Blog";
/**
* The roles a user has
*
* @return array
*/
public function getRoles ()
{
return array(
'ROLE_ADMIN'
);
}
/**
* Erases the credential information
*/
public function eraseCredentials ()
{
$this->password = null;
}
/**
* Verifies if given user equals the current user
*
* @param mixed $user
* @return Boolean
*/
public function equals (UserInterface $user)
{
return ($this->getUsername() === $user->getUsername());
}
/**
* Returns the salt
*
* @return string
*/
public function getSalt ()
{
return $this->salt;
}
/**
* Get userID
*
* @return id $userID
*/
public function getUserID()
{
return $this->userID;
}
/**
* Set username
*
* @param string $username
*/
public function setUsername($username)
{
$this->username = $username;
}
/**
* Get username
*
* @return string $username
*/
public function getUsername()
{
return $this->username;
}
/**
* Set password
*
* @param string $password
*/
public function setPassword($password)
{
$this->password = $password;
}
/**
* Get password
*
* @return string $password
*/
public function getPassword()
{
return $this->password;
}
/**
* Set firstname
*
* @param string $firstname
*/
public function setFirstname($firstname)
{
$this->firstname = $firstname;
}
/**
* Get firstname
*
* @return string $firstname
*/
public function getFirstname()
{
return $this->firstname;
}
/**
* Set lastname
*
* @param string $lastname
*/
public function setLastname($lastname)
{
$this->lastname = $lastname;
}
/**
* Get lastname
*
* @return string $lastname
*/
public function getLastname()
{
return $this->lastname;
}
}
Methods required by the interface are: “getUsername”, “getSalt”, “getRoles”, “eraseCredentials”, “equals”. I hard coded the getRoles return value. The eraseCredentials method should remove sensitive information from your model. In our case, the password. The equals method lets you implement how two User models should be compared. I just made a comparison against the username, but of course this could (an should!) be more elaborate.
Conclusion
As it turns out, writing your own provider for authenticating against a MongoDB User collection wasn’t all that hard. You just have to figure out which classes & methods to implement, and it just works. Nevertheless, Symfony2 is quite young, and documentation still is scarce. The official documentation has some examples yes, but they usually don’t go beyond the basics. The cookbook has some specific use cases, which is nice. I hope this write-up will help you to implement your custom provider. If you have any suggestions or remarks, don’t hesitate to comment.