In Symfony, during the authentication, I want to attribute specific role to my user.
If I specify ->setRoles() in my authenticator, or my "getRoles" function, I come back to the login page, anonymously
Following code in Authenticator doesn't work
public function getUser($credentials, UserProviderInterface $userProvider)
{
$token = new CsrfToken('authenticate', $credentials['csrf_token']);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException();
}
$user = $this->entityManager->getRepository(User::class)->findOneBy(['customId' => $credentials['customId']]);
if (!$user) {
// fail authentication with a custom error
throw new CustomUserMessageAuthenticationException('CustomId could not be found.');
}
if($user->getId() == 2) {
$user->setRoles(['ROLE_SUPER_ADMIN']);
}
return $user;
}
This code in my Entity doesn't work
/**
* #see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
if($this->getId() == 2) {
$this->setRoles(['ROLE_SUPER_ADMIN']);
}
return array_unique($roles);
}
If you change the user object it will not match the one in the database. Symfony will recognize this as someone messing with the stored data and log you out for safety.
You can change how the comparison of the user is done by implementing the EquatableInterface:
class User implements EquatableInterface
{
public function isEqual(UserInterface $user): bool
{
// Example for what your comparison could look like
return $user->getUsername() === $this->getUsername() && $user->getId() === $this->getId();
}
}
You can find this (in a rather small section) in the docs: https://symfony.com/doc/current/security/user_provider.html#comparing-users-manually-with-equatableinterface
Related
So basically I want to create something like #IsGranted.
I used #IsGranted on my application to access control to prevent a simple user from accessing an admin page for example.
On my entity, I have a boolean field called is_Active
if it's true (1) then the user can use his account
if it's false (0) then he gets redirected to an error page!
In this case, I am not going to test on the Rolesfield of the user but I am gonna test on the is_Active field that's why I can't use the #IsGranted.
I created an error twig page active.html.twig
and I place it on templates folder, and I found myself FORCED to add those 2 lines on every controller function.
if ($this->getUser()->getIsActive()==false) {
return $this->render('active.html.twig');}
Here is an example:
/**
* #IsGranted("ROLE_ADMIN")
* #Route("/", name="user_index", methods={"GET"})
*/
public function index(UserRepository $userRepository): Response
{
if ($this->getUser()->getIsActive()==false) {
return $this->render('active.html.twig');}
return $this->render('user/index.html.twig', [
'users' => $userRepository->findAll(),
]);
}
This is very heavy and bad to add this if statement on every function (I have +30 functions on the app)
Maybe I can create something similar to #IsGranted and use it on the annotation of each function instead?
You can keep using #IsGranted with a custom voter. https://symfony.com/doc/current/security/voters.html#creating-the-custom-voter
Create new voter like in the documentation
public const ACTIVE = 'active';
protected function supports(string $attribute, $subject)
{
return $attribute === self::ACTIVE;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if ($user instanceof User && !$user->isActive()) {
throw new InactiveUserException();
}
return true;
}
Then you can create a listener for InactiveUserException and show what ever you want to the client.
In your controller you'll need to put #IsGranted("active") or #Security(expression="is_granted('active')") before the route method or controller
I would use the authentication for this then you don't have to touch your controllers. You can check if they are logged in and active then they can view the content or if they fail auth then you can direct them to another route with your active.html.twig.
You can also just have this set on certain routes or all of them.
https://symfony.com/doc/current/security/guard_authentication.html
Sample Authenticator and set this just for your admin routes then you can have a normal authenticator without checking for an active user on the checkCredentials for all other routes.
<?php
namespace App\Security;
use App\Entity\User;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Twig\Environment;
class AdminAuthenticator extends AbstractGuardAuthenticator
{
/** #var Environment */
private $twig;
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
public function supports(Request $request): bool
{
$email = $request->request->get('email');
$password = $request->request->get('password');
return $email && $password;
}
public function getCredentials(Request $request)
{
$email = $request->request->get('email');
$password = $request->request->get('password');
return [
'email' => $email,
'password' => $password
];
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$email = $credentials['email'];
return $userProvider->loadUserByUsername($email);
}
public function checkCredentials($credentials, UserInterface $user)
{
$password = $credentials['password'];
if (!$this->passwordEncoder->isPasswordValid($user, $password)) {
throw new CustomUserMessageAuthenticationException(
'Sorry, you\'ve entered an invalid username or password.'
);
}
if (!$user->isActive()) {
throw new NotActiveUserException(
'This account is not active'
);
}
return true;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
if ($exception instanceof NotActiveUserException) {
// You should redirect here but you get the idea!
$this->twig->render('active.html.twig');
}
// Do something else for any other failed auth
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return new JsonResponse('success', Response::HTTP_OK);
}
public function start(Request $request, AuthenticationException $authException = null)
{
return new JsonResponse('Not Authorized', Response::HTTP_UNAUTHORIZED);
}
public function supportsRememberMe()
{
return false;
}
}
Then in your security.yaml
firewalls:
admin:
pattern: ^/admin
provider: user
guard:
authenticators:
- App\Security\AdminAuthenticator
Things we want to achieve in our application are:
Non-unique usernames [Done]
Unique username and email combination
FosUserBundle will fetch all users (on user login) with given username and checks if any of the users has the given password (hashed with bcrypt). When a user is found it logs the user in.
Making username non unique was quite simple by just overriding the username field in the user ORM. But we're kinda stuck with how to proceed in achieving the last two points. We've started creating a custom User Provider but it seems Symfony Security can only handle one user(name).
Is there anyone with experience that might be able to help us? If you need more information or code snippets, please ask. Thank you in advance!
So after looking through alot of the documentation for the Symfony Security module we figured it out.
We added an extra field (displayname) to the User model because Symfony is completely build around the fact that usernames are Unique. It always fetches the first user with the given username, this is not what we wanted.
So we started with writing our own Guard Authentication System, this was pretty straight forward although we had to make some adjustments.
This was all working well, but we ran into a problem with the built-in UsernamePasswordFormAuthenticationListener, this listener was still picking up the displayname from the login form. We actually want the unique username so that Symfony knows which user to use.
We created a custom listener that extended the standard listener and made sure the username was not fetched from the login form but from the user token.
So our flow is now like this: The user fills in his username (actually his displayname) and password, the system fetches all users with that displayname. Then we loop these users and check if someone has that password. If so, authenticate the user.
On user create the admin fills in the displayname and the system will autoincrement this as a username. (admin_1, admin_2, ...).
We have to monitor if what #kero said is true, but with Bcrypt it seems that even with simple passwords like "123", it results in a different hash for each user.
The only thing that is left is to have a UniqueConstraint on the unique combination of the displayname and email. If anyone knows how this can be achieved in our orm.xml and form, thank you.
http://symfony.com/doc/current/security/guard_authentication.html
Custom Guard Authenticator
class Authenticator extends AbstractGuardAuthenticator
{
private $encoderFactory;
private $userRepository;
private $tokenStorage;
private $router;
public function __construct(EncoderFactoryInterface $encoderFactory, UserRepositoryInterface $userRepository, TokenStorageInterface $tokenStorage, Router $router)
{
$this->encoderFactory = $encoderFactory;
$this->userRepository = $userRepository;
$this->tokenStorage = $tokenStorage;
$this->router = $router;
}
/**
* Called on every request. Return whatever credentials you want,
* or null to stop authentication.
*/
public function getCredentials(Request $request)
{
$encoder = $this->encoderFactory->getEncoder(new User());
$displayname = $request->request->get('_username');
$password = $request->request->get('_password');
$users = $this->userRepository->findByDisplayname($displayname);
if ($users !== []) {
foreach ($users as $user) {
if ($encoder->isPasswordValid($user->getPassword(), $password, $user->getSalt())) {
return ['username' => $user->getUsername(), 'password' => $user->getPassword()];
}
}
} else {
if ($this->tokenStorage->getToken() !== null) {
$user = $this->tokenStorage->getToken()->getUser();
return ['username' => $user->getUsername(), 'password' => $user->getPassword()];
}
}
return null;
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
if ($credentials !== null) {
return $userProvider->loadUserByUsername($credentials["username"]);
}
return null;
}
public function checkCredentials($credentials, UserInterface $user)
{
if ($user !== null) {
return true;
} else {
return false;
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$exclusions = ['/login'];
if (!in_array($request->getPathInfo(), $exclusions)) {
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
throw $exception;
}
}
/**
* Called when authentication is needed, but it's not sent
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$data = array(
// you might translate this message
'message' => 'Authentication Required'
);
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
public function supportsRememberMe()
{
return false;
}
}
Custom listener
class CustomAuthListener extends UsernamePasswordFormAuthenticationListener
{
private $csrfTokenManager;
private $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, $providerKey, AuthenticationSuccessHandlerInterface $successHandler, AuthenticationFailureHandlerInterface $failureHandler, array $options = array(), LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null, CsrfTokenManagerInterface $csrfTokenManager = null)
{
parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge(array(
'username_parameter' => '_username',
'password_parameter' => '_password',
'csrf_parameter' => '_csrf_token',
'csrf_token_id' => 'authenticate',
'post_only' => true,
), $options), $logger, $dispatcher);
$this->csrfTokenManager = $csrfTokenManager;
$this->tokenStorage = $tokenStorage;
}
/**
* {#inheritdoc}
*/
protected function attemptAuthentication(Request $request)
{
if ($user = $this->tokenStorage->getToken() !== null) {
$user = $this->tokenStorage->getToken()->getUser();
$username = $user->getUsername();
if ($this->options['post_only']) {
$password = ParameterBagUtils::getParameterBagValue($request->request, $this->options['password_parameter']);
} else {
$password = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']);
}
if (strlen($username) > Security::MAX_USERNAME_LENGTH) {
throw new BadCredentialsException('Invalid username.');
}
$request->getSession()->set(Security::LAST_USERNAME, $username);
return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey));
} else {
return null;
}
}
}
Listener service
<service id="security.authentication.listener.form" class="Your\Path\To\CustomAuthListener" parent="security.authentication.listener.abstract" abstract="true" />
You can subscribe on my website. I use FOSUserBundle.
When the user subscribes, he won the role ROLE_SUBSCRIBER giving it access to new page.
I would like this role expires after a period that I recorded in the User entity.
class User extends BaseUser
{
// ...
* #ORM\Column(type="datetime")
protected $subscribeExpiration;
public function setSubscribeExpiration(\DateTime $subscribeExpiration) {
$this->subscribeExpiration = clone $subscribeExpiration;
return $this;
}
public function getSubscribeExpiration() {
return $this->subscribeExpiration;
}
// ...
}
Don't use a ROLE, but a Voter.
Then, in your voter check for the expireDate to decide if the user is a subsciber or not :
// src/AppBundle/Security/PostVoter.php
namespace AppBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use AppBundle\Entity\User;
class SubscriberVoter extends Voter
{
const IS_SUBSCRIBER = 'is_subscriber';
protected function supports($attribute, $subject)
{
if (!in_array($attribute, array(self::IS_SUBSCRIBER))) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// you know $subject is a Post object, thanks to supports
/** #var Post $post */
$post = $subject;
switch ($attribute) {
case self::IS_SUBSCRIBER:
$expireDate = $user->getSubscriberExpireDate();
$currendDate = new \DateTime();
return (null !== $expireDate && $expireDate > $currendDate);
}
throw new \LogicException('This code should not be reached!');
}
}
To check this 'role' :
$this->isGranted('is_subscriber');
I am using FOSUserBundle for my symfony2 project. Upon registration, I check with the function below if the user has the default role ROLE_USER that FOSUB gives.
/**
* Returns true if user has ROLE_USER
*
* #return boolean
*/
public function hasDefaultRole() {
return ($this->hasRole('ROLE_USER'));
}
If this function returns true, I set up a new account registration form and on submit the roles are changed and ROLE_USER is removed.
EDIT :
$user = $this->container->get('security.context')->getToken()->getUser();
...
$userManager = $this->container->get('fos_user.user_manager');
$user->removeRole("ROLE_USER");
$user->setRoles(array("ROLE_TEACHER", "ROLE_TEACHER_BASIC"));
$user->setStatus(1);
$userManager->updateUser($user);
$this->resetToken($user);
restetToken does this :
$token = new UsernamePasswordToken($user, null, 'main', $user->getRoles());
$this->container->get('security.context')->setToken($token);
I have checked the database and there is no role user anymore. If I logout, and login back again, $user->hasDefaultRole() still returns true. What am I not seeing here? Or is this an expected behaviour?
FOSUserBundle always add the default role (ROLE_USER) to the list of roles to ensure that users always have at least on role, so no matter what you do you won't be able to remove it.
FOSUserBundle\Model\User
/**
* Returns the user roles
*
* #return array The roles
*/
public function getRoles()
{
$roles = $this->roles;
foreach ($this->getGroups() as $group) {
$roles = array_merge($roles, $group->getRoles());
}
// we need to make sure to have at least one role
$roles[] = static::ROLE_DEFAULT;
return array_unique($roles);
}
FOSUserBundle\Model\UserInterface
const ROLE_DEFAULT = 'ROLE_USER';
Also you will never find the ROLE_USER in your database as it never actually adds it.
FOSUserBundle\Model\User
public function addRole($role)
{
$role = strtoupper($role);
if ($role === static::ROLE_DEFAULT) {
return $this;
}
if (!in_array($role, $this->roles, true)) {
$this->roles[] = $role;
}
return $this;
}
My roles are stored in the database and I am trying to load them dynamically upon login. What I'm doing is querying for the roles and setting them on the user object in my user provider as seen here:
public function loadUserByUsername($username) {
$q = $this
->createQueryBuilder('u')
->where('u.username = :username')
->setParameter('username', $username)
->getQuery()
;
try {
// The Query::getSingleResult() method throws an exception
// if there is no record matching the criteria.
$user = $q->getSingleResult();
// Permissions
$permissions = $this->_em->getRepository('E:ModulePermission')->getPermissionsForUser($user);
if ($permissions !== null) {
foreach ($permissions as $permission) {
$name = strtoupper(str_replace(" ", "_", $permission['name']));
$role = "ROLE_%s_%s";
if ($permission['view']) {
$user->addRole(sprintf($role, $name, 'VIEW'));
}
if ($permission['add']) {
$user->addRole(sprintf($role, $name, 'ADD'));
}
if ($permission['edit']) {
$user->addRole(sprintf($role, $name, 'EDIT'));
}
if ($permission['delete']) {
$user->addRole(sprintf($role, $name, 'DELETE'));
}
}
}
} catch (NoResultException $e) {
throw new UsernameNotFoundException(sprintf('Unable to find an active admin Entity:User object identified by "%s".', $username), null, 0, $e);
}
return $user;
}
And the user entity:
class User implements AdvancedUserInterface, \Serializable {
....
protected $roles;
....
public function __construct() {
$this->salt = base_convert(sha1(uniqid(mt_rand(), true)), 16, 36);
$this->roles = array();
}
....
public function getRoles() {
$roles = $this->roles;
// Ensure we having something
$roles[] = static::ROLE_DEFAULT;
return array_unique($roles);
}
public function addRole($role) {
$role = strtoupper($role);
$roles = $this->getRoles();
if ($role === static::ROLE_DEFAULT) {
return $this;
}
if (!in_array($role, $roles, true)) {
$this->roles[] = $role;
}
return $this;
}
public function hasRole($role) {
$role = strtoupper($role);
$roles = $this->getRoles();
return in_array($role, $roles, true);
}
}
This works fine and dandy and I see the correct roles when I do:
$this->get('security.context')->getUser()->getRoles()
The problem (I think), is that the token does not know about these roles. Because calling getRoles() on the token is showing only ROLE_USER, which is the default role.
It seems to me that the token is being created before the user is loaded by the UserProvider. I've looked through a lot of the security component but I can't for the life of me find the right part of the process to hook into to set these roles correctly so that the token knows about them.
Update Following the Load roles from database doc works fine, but this does not match my use case as shown here. My schema differs as each role has additional permissions (view/add/edit/delete) and this is why I am attempting the approach here. I don't want to have to alter my schema just to work with Symfony's security. I'd rather understand why these roles are not properly bound (not sure the correct doctrine word here) on my user object at this point.
It looks like you may not be aware of the built in role management that Symfony offers. Read the docs - Managing roles in the database It is actually quite simple to do what you want, all you need to do is implement an interface and define your necessary function. The docs I linked to provide great examples. Take a look.
UPDATE
It looks like the docs don't give you the use statement for the AdvancedUserInterface. Here it is:
// in your user entity
use Symfony\Component\Security\Core\User\AdvancedUserInterface;
then in your role entity:
use Symfony\Component\Security\Core\Role\RoleInterface;
The docs show you how to do the rest.
UPDATE
Take a look at this blog post, which shows how to create roles dynamically:
Dynamically create roles
The problem here stemmed from the fact that I thought I was implementing
Symfony\Component\Security\Core\User\EquatableInterface;
but wasn't (as you can see in the original question, I forgot to add it to my class definition). I'm leaving this here for people if they come across it. All you need is to implement this interface, and add the following method to your user entity.
public function isEqualTo(UserInterface $user) {
if ($user instanceof User) {
// Check that the roles are the same, in any order
$isEqual = count($this->getRoles()) == count($user->getRoles());
if ($isEqual) {
foreach($this->getRoles() as $role) {
$isEqual = $isEqual && in_array($role, $user->getRoles());
}
}
return $isEqual;
}
return false;
}