I want to implement the following authentication scenario in symfony 5:
User sends a login form with username and password, authentication is processed against an LDAP server
if authentication against the LDAP server is successful :
if there is an instance of my App\Entity\User that as the same username as the ldap matching entry, refresh some of its attributes from the ldap server and return this entity
if there is no instance create a new instance of my App\Entity\User and return it
I have implemented a guard authenticator which authenticates well against the LDAP server but it's returning me an instance of Symfony\Component\Ldap\Security\LdapUser and I don't know how to use this object to make relation with others entities!
For instance, let's say I have a Car entity with an owner property that must be a reference to an user.
How can I manage that ?
Here is the code of my security.yaml file:
security:
encoders:
App\Entity\User:
algorithm: auto
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
my_ldap:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: "%env(LDAP_BASE_DN)%"
search_dn: "%env(LDAP_SEARCH_DN)%"
search_password: "%env(LDAP_SEARCH_PASSWORD)%"
default_roles: ROLE_USER
uid_key: uid
extra_fields: ['mail']
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
lazy: true
provider: my_ldap
guard:
authenticators:
- App\Security\LdapFormAuthenticator
I finally found a good working solution.
The missing piece was a custom user provider.
This user provider has the responsibility to authenticate user against ldap and to return the matching App\Entity\User entity. This is done in getUserEntityCheckedFromLdap method of LdapUserProvider class.
If there is no instance of App\Entity\User saved in the database, the custom user provider will instantiate one and persist it. This is the first user connection use case.
Full code is available in this public github repository.
You will find below, the detailed steps I follow to make the ldap connection work.
So, let's declare the custom user provider in security.yaml.
security.yaml:
providers:
ldap_user_provider:
id: App\Security\LdapUserProvider
Now, configure it as a service, to pass some ldap usefull string arguments in services.yaml.
Note since we are going to autowire the Symfony\Component\Ldap\Ldap service, let's add this service configuration too:
services.yaml:
#see https://symfony.com/doc/current/security/ldap.html
Symfony\Component\Ldap\Ldap:
arguments: ['#Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: ldap
port: 389
# encryption: tls
options:
protocol_version: 3
referrals: false
App\Security\LdapUserProvider:
arguments:
$ldapBaseDn: '%env(LDAP_BASE_DN)%'
$ldapSearchDn: '%env(LDAP_SEARCH_DN)%'
$ldapSearchPassword: '%env(LDAP_SEARCH_PASSWORD)%'
$ldapSearchDnString: '%env(LDAP_SEARCH_DN_STRING)%'
Note the arguments of the App\Security\LdapUserProvider come from env vars.
.env:
LDAP_URL=ldap://ldap:389
LDAP_BASE_DN=dc=mycorp,dc=com
LDAP_SEARCH_DN=cn=admin,dc=mycorp,dc=com
LDAP_SEARCH_PASSWORD=s3cr3tpassw0rd
LDAP_SEARCH_DN_STRING='uid=%s,ou=People,dc=mycorp,dc=com'
Implement the custom user provider :
App\Security\LdapUserProvider:
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Ldap\Ldap;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class LdapUserProvider implements UserProviderInterface
{
/**
* #var Ldap
*/
private $ldap;
/**
* #var EntityManager
*/
private $entityManager;
/**
* #var string
*/
private $ldapSearchDn;
/**
* #var string
*/
private $ldapSearchPassword;
/**
* #var string
*/
private $ldapBaseDn;
/**
* #var string
*/
private $ldapSearchDnString;
public function __construct(EntityManagerInterface $entityManager, Ldap $ldap, string $ldapSearchDn, string $ldapSearchPassword, string $ldapBaseDn, string $ldapSearchDnString)
{
$this->ldap = $ldap;
$this->entityManager = $entityManager;
$this->ldapSearchDn = $ldapSearchDn;
$this->ldapSearchPassword = $ldapSearchPassword;
$this->ldapBaseDn = $ldapBaseDn;
$this->ldapSearchDnString = $ldapSearchDnString;
}
/**
* #param string $username
* #return UserInterface|void
* #see getUserEntityCheckedFromLdap(string $username, string $password)
*/
public function loadUserByUsername($username)
{
// must be present because UserProviders must implement UserProviderInterface
}
/**
* search user against ldap and returns the matching App\Entity\User. The $user entity will be created if not exists.
* #param string $username
* #param string $password
* #return User|object|null
*/
public function getUserEntityCheckedFromLdap(string $username, string $password)
{
$this->ldap->bind(sprintf($this->ldapSearchDnString, $username), $password);
$username = $this->ldap->escape($username, '', LdapInterface::ESCAPE_FILTER);
$search = $this->ldap->query($this->ldapBaseDn, 'uid=' . $username);
$entries = $search->execute();
$count = count($entries);
if (!$count) {
throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
}
if ($count > 1) {
throw new UsernameNotFoundException('More than one user found');
}
$ldapEntry = $entries[0];
$userRepository = $this->entityManager->getRepository('App\Entity\User');
if (!$user = $userRepository->findOneBy(['userName' => $username])) {
$user = new User();
$user->setUserName($username);
$user->setEmail($ldapEntry->getAttribute('mail')[0]);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
return $user;
}
/**
* Refreshes the user after being reloaded from the session.
*
* When a user is logged in, at the beginning of each request, the
* User object is loaded from the session and then this method is
* called. Your job is to make sure the user's data is still fresh by,
* for example, re-querying for fresh User data.
*
* If your firewall is "stateless: true" (for a pure API), this
* method is not called.
*
* #return UserInterface
*/
public function refreshUser(UserInterface $user)
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
}
return $user;
// Return a User object after making sure its data is "fresh".
// Or throw a UsernameNotFoundException if the user no longer exists.
throw new \Exception('TODO: fill in refreshUser() inside ' . __FILE__);
}
/**
* Tells Symfony to use this provider for this User class.
*/
public function supportsClass($class)
{
return User::class === $class || is_subclass_of($class, User::class);
}
}
Configure the firewall to use our custom user provider:
security.yaml
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
lazy: true
provider: ldap_user_provider
logout:
path: app_logout
guard:
authenticators:
- App\Security\LdapFormAuthenticator
Write an authentication guard:
App\SecurityLdapFormAuthenticator:
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LdapFormAuthenticator extends AbstractFormLoginAuthenticator
{
use TargetPathTrait;
private $urlGenerator;
private $csrfTokenManager;
public function __construct(UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager)
{
$this->urlGenerator = $urlGenerator;
$this->csrfTokenManager = $csrfTokenManager;
}
public function supports(Request $request)
{
return 'app_login' === $request->attributes->get('_route') && $request->isMethod('POST');
}
public function getCredentials(Request $request)
{
$credentials = [
'username' => $request->request->get('_username'),
'password' => $request->request->get('_password'),
'csrf_token' => $request->request->get('_csrf_token'),
];
$request->getSession()->set(
Security::LAST_USERNAME,
$credentials['username']
);
return $credentials;
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$token = new CsrfToken('authenticate', $credentials['csrf_token']);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException();
}
$user = $userProvider->getUserEntityCheckedFromLdap($credentials['username'], $credentials['password']);
if (!$user) {
throw new CustomUserMessageAuthenticationException('Username could not be found.');
}
return $user;
}
public function checkCredentials($credentials, UserInterface $user)
{
//in this scenario, this method is by-passed since user authentication need to be managed before in getUser method.
return true;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
$request->getSession()->getFlashBag()->add('info', 'connected!');
if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('app_homepage'));
}
protected function getLoginUrl()
{
return $this->urlGenerator->generate('app_login');
}
}
My user entity looks like this:
`App\Entity\User`:
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* #ORM\Entity(repositoryClass=UserRepository::class)
*/
class User implements UserInterface
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=180, unique=true)
*/
private $email;
/**
* #var string The hashed password
* #ORM\Column(type="string")
*/
private $password = 'password is not managed in entity but in ldap';
/**
* #ORM\Column(type="string", length=255)
*/
private $userName;
/**
* #ORM\Column(type="json")
*/
private $roles = [];
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* #see UserInterface
*/
public function getUsername(): string
{
return (string) $this->email;
}
/**
* #see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* #see UserInterface
*/
public function getPassword(): string
{
return (string) $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* #see UserInterface
*/
public function getSalt()
{
// not needed when using the "bcrypt" algorithm in security.yaml
}
/**
* #see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function setUserName(string $userName): self
{
$this->userName = $userName;
return $this;
}
}
For Symfony 6 I do like this.
No extra implementation
security:
role_hierarchy:
ROLE_USER: ROLE_USER
ROLE_ADMIN: [ROLE_USER, ROLE_ADMIN]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
pmh_db:
entity:
class: App\Entity\User
property: username
pmh_ldap:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: '%base_dn%'
search_dn: '%search_dn%'
search_password: '%search_password%'
default_roles: 'ROLE_USER'
uid_key: '%uid_key%'
extra_fields: ['email']
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
pattern: ^/
provider: pmh_db
switch_user: { role: ROLE_ALLOWED_TO_SWITCH }
login_throttling:
max_attempts: 5
form_login_ldap:
login_path: app_login
check_path: app_login
service: Symfony\Component\Ldap\Ldap
dn_string: 'DOMAIN\{username}'
query_string: null
default_target_path: /
logout:
path: /logout
target: /
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 1 week in seconds
path: /
# by default, the feature is enabled by checking a
# checkbox in the login form (see below), uncomment the
# following line to always enable it.
always_remember_me: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: '^/login', roles: PUBLIC_ACCESS }
- { path: '^/admin', roles: [IS_AUTHENTICATED_FULLY, ROLE_ADMIN] }
- { path: '^/', roles: ROLE_USER }
Related
I have a problem to authenticate
api platform (symfony 6)
curl -X 'POST' \
'http://localhost:8001/authentication_token' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"email": "toto#gmail.com",
"password": "string2222"
}'
I have followed the documentation carefully: [https://api-platform.com/docs/core/jwt/]
here is the error:
{
"code": 401,
"message": "Invalid credentials."
}
the user in the database : user
security.yaml
security:
enable_authenticator_manager: true
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
stateless: true
provider: app_user_provider
jwt: ~
main:
lazy: true
provider: app_user_provider
json_login:
check_path: /authentication_token
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
jwt: ~
access_control:
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/authentication_token, roles: PUBLIC_ACCESS }
User.php
<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ApiResource]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private $email;
#[ORM\Column(type: 'json')]
private $roles = [];
#[ORM\Column(type: 'string')]
private $password;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* #see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* #see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* #see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* #see UserInterface
*/
public function eraseCredentials()
{
}
}
I started again with a blank project and I get the same result.
thank you for your help because I am blocked for a while
You really have the wrong credentials. Because based on the screenshot of the User line in the database, you store the password in its pure form without hashing, although you indicated in the security.yaml configuration that you will use password_hashers with the algorithm: auto.
To solve this problem create a user with a hashed password as shown below:
public function createUser(UserPasswordHasherInterface $passwordHasher)
{
$user = new User();
$user->setEmail('toto#gmail.com');
$plaintextPassword = 'string2222';
// hash the password (based on the security.yaml config for the $user class)
$hashedPassword = $passwordHasher->hashPassword(
$user,
$plaintextPassword
);
$user->setPassword($hashedPassword);
// and save user in db
$em = $this->getDoctrine()->getManager();
$em->persist($file);
$em->flush();
}
Symfony doc for password-hasher component
I'm using Symfony 4 "Custom Authentication System with Guard (API Token Example)"Custom Authentication System with Guard (API Token Example)
I want to generate api token when user register from other app(i.e Advance Rest Client) and then want to use this token to access other api's like get articles list.
There is no option to generate token or may be I can't find out.
Here is my code:
config/packages/security.yaml
security:
encoders:
App\Entity\User:
algorithm: bcrypt
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
#property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
stateless: true
anonymous: false
guard:
authenticators:
- App\Security\TokenAuthenticator
main:
pattern: ^/
anonymous: true
guard:
authenticators:
- App\Security\LoginFormAuthenticator
logout:
path: app_logout
# activate different ways to authenticate
# http_basic: true
# https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate
# form_login: true
# https://symfony.com/doc/current/security/form_login_setup.html
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api, roles: [ IS_AUTHENTICATED_FULLY ] }
- { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
Controller/RegistrationController.php
class RegistrationController extends AbstractController{
/**
* #Route("/register/api", name="app_register_api")
*/
public function registerApi(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, TokenAuthenticator $authenticator){
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$passwordEncoder->encodePassword(
$user,
$form->get('plainPassword')->getData()
)
);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($user);
$entityManager->flush();
// do anything else you need here, like send an email
$data = $guardHandler->authenticateUserAndHandleSuccess(
$user,
$request,
$authenticator,
'api' // firewall name in security.yaml
);
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
}
}
}
Security/TokenAuthenticator.php
class TokenAuthenticator extends AbstractGuardAuthenticator
{
private $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning false will cause this authenticator
* to be skipped.
*/
public function supports(Request $request)
{
return $request->headers->has('X-AUTH-TOKEN');
}
/**
* Called on every request. Return whatever credentials you want to
* be passed to getUser() as $credentials.
*/
public function getCredentials(Request $request)
{
return [
'token' => $request->headers->get('X-AUTH-TOKEN'),
];
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$apiToken = $credentials['token'];
if (null === $apiToken) {
return;
}
// if a User object, checkCredentials() is called
return $this->em->getRepository(User::class)
->findOneBy(['apiToken' => $apiToken]);
}
public function checkCredentials($credentials, UserInterface $user)
{
// check credentials - e.g. make sure the password is valid
// no credential check is needed in this case
// return true to cause authentication success
return true;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$data = [
'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
}
/**
* Called when authentication is needed, but it's not sent
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$data = [
// you might translate this message
'message' => 'Authentication Required'
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
public function supportsRememberMe()
{
return false;
}
}
Next problem is how to get user detail from token in articles api?
<?php
namespace App\Controller\Rest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use App\Entity\Article;
use App\Form\ArticleType;
use App\Service\ArticleService;
class ArticleController extends FOSRestController
{
/**
* #var ArticleService
*/
private $articleService;
/**
* ArticleController constructor.
* #param ArticleService $articleService
*/
public function __construct(ArticleService $articleService)
{
$this->articleService = $articleService;
}
/**
* Lists all Articles.
* http://sf4.local/api/articles
* #Rest\Get("/articles")
*
* #return Response
*/
public function getArticles()
{
$items = $this->articlesService->getAllArticles();
return $this->handleView($this->view($items));
}
}
I am writing regarding the Symfony authentication problem, which occurred last month and I still cannot find a solution, so I am dependent on you :D
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* #ORM\Table(name="app_users")
* #ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User implements UserInterface, \Serializable
{
//id,username,password
public function getSalt()
{
return null;
}
public function getPassword()
{
return $this->password;
}
public function getRoles()
{
return array('ROLE_USER');
}
public function eraseCredentials()
{
}
public function serialize()
{
return serialize(array(
$this->id,
$this->username,
$this->password,
// see section on salt below
// $this->salt,
));
}
public function unserialize($serialized)
{
list (
$this->id,
$this->username,
$this->password,
// see section on salt below
// $this->salt
) = unserialize($serialized);
}
}
This is my User entity and now below you can see my security.yaml which I think I configured right:
security:
encoders:
App\Entity\User:
algorithm: bcrypt
providers:
db_provider:
entity:
class: App\Entity\User
property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|img|js)/
security: false
main:
anonymous: true
http_basic: ~
provider: db_provider
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
Whenever I am trying to access /admin route it shows me http-basic login but whenever I input "admin, admin" nothing happens. IN my database I have one user with username:admin and password admin which is hashed by bcrypt.
Not using authentication then everything works as it should, I get all data from the database as it should be after authentication.
Thanks for your help guys!
Your problem
As Med already pointed out, your User entity has the ROLE_USER role as default:
/* App/Entity/User.php */
public function getRoles()
{
return array('ROLE_USER');
}
Your access_control configuration on the other hand states that the route /admin can only be accessed with a user that has the ROLE_ADMIN role:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
That means, your user "admin" lacks the sufficient role to access /admin.
Solution
You need to be able to assign multiple roles to the user. One possible way is saving the roles as a concatenated string and returning it as an array:
/* App/Entity/User.php */
/**
* #ORM\Column(name="roles", type="string")
* #var string
*/
private $roles;
/**
* Get the user roles as an array of strings
* #return array
*/
public function getRoles()
{
return explode($roles, ',');
}
You can even add some methods to manage your roles via the entity class:
/* App/Entity/User.php */
/**
* Add a new role
* #param string $role name of the role
* #return this
*/
public function addRole($role)
{
$roles = $this->getRoles();
if (array_search($role, $roles) === false) {
$roles[] = $role;
$this->roles = implode(',', $roles);
}
return $this;
}
/**
* Remove a role
* #param string $role name of the role
* #return this
*/
public function removeRole($role)
{
$roles = $this->getRoles();
$searchResult = array_search($role, $roles);
if ($searchResult !== false) {
unset($roles[$searchResult]);
$this->roles = implode(',', $roles);
}
return $this;
}
I have 4 different user types in a system (on top of Symfony 2). Each type have some specific fields and behaviour, but all of them have a common base. So it seems it would be good idea to implement single class for each user extending the same superclass.
How can it be achieved? All I have found on the topic is some RollerworksMultiUserBundle.
Using table inheritance in the ORM level and OOP inheritance. Go for Single Table Inheritance if performance is critical (no JOINs) or Class Table Inheritance if you are a purist.
E.g.
Common base class:
use Symfony\Component\Security\Core\User\AdvancedUserInterface;
/**
* #ORM\Entity(repositoryClass="Some\Bundle\Repository\UserRepository")
* #ORM\InheritanceType("SINGLE_TABLE")
* #ORM\DiscriminatorColumn(name="userType", type="string")
* #ORM\DiscriminatorMap({
* "userType1" = "UserType1",
* "userType2" = "UserType2",
* "userType3" = "UserType3",
* "userType4" = "UserType4"
* })
*/
abstract class User implements AdvancedUserInterface
{
/**
* #ORM\Id()
* #ORM\Column(type="integer")
* #ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* #ORM\Column(type="string", length=250, unique=true)
*/
protected $email;
/**
* #ORM\Column(type="string", length=128, nullable=true)
*/
protected $password;
// other fields
public function getSalt()
{
return "some salt number";
}
public function getUsername()
{
return $this->email;
}
public function getPassword()
{
return $this->password;
}
public function getRoles()
{
return array('ROLE_USER');
}
public function eraseCredentials() {}
public function isCredentialsNonExpired()
{
return true;
}
public function isAccountNonLocked()
{
return true;
}
public function isAccountNonExpired()
{
return true;
}
public function isEnabled()
{
return true;
}
public function equals(UserInterface $user)
{
return $user->getUsername() === $this->getUsername() || $user->getEmail() === $this->getEmail();
}
}
The children classes are straightforward (below an example for class UserType1 only):
/**
* #ORM\Entity
*/
class UserType1 extends User
{
// fields of UserType1 class go here
public function getRoles()
{
return array('ROLE_USER_TYPE_1', 'ROLE_USER');
}
}
The rest is pretty much like in the examples. In security.yml:
security:
encoders:
Some\Bundle\Repository\User:
algorithm: sha512
encode_as_base64: false
iterations: 1000
providers:
provider1:
entity: { class: "SomeBundle:User" }
role_hierarchy:
ROLE_USER_TYPE_1: ROLE_USER
ROLE_USER_TYPE_2: ROLE_USER
ROLE_USER_TYPE_3: ROLE_USER
ROLE_USER_TYPE_4: ROLE_USER
firewalls:
firewall1:
pattern: ^/
provider: provider1
form_login:
login_path: /login
check_path: /auth
post_only: true
username_parameter: email
password_parameter: password
always_use_default_target_path: true
default_target_path: /
logout:
path: /logout
target: /login
anonymous: ~
The repository class:
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Security\Core\User\UserInterface;
class UserRepository extends EntityRepository implements UserProviderInterface
{
public function loadUserByUsername($username)
{
$qb = $this->createQueryBuilder('u');
$query = $qb->where('LOWER(u.email) = :email')
->setParameter('email', strtolower($username))
->getQuery();
try {
$user = $query->getSingleResult();
}
catch (NoResultException $e) {
throw new UsernameNotFoundException('User not found.', null, $e);
}
return $user;
}
public function supportsClass($class)
{
return $this->getEntityName() === $class ||
is_subclass_of($class, $this->getEntityName());
}
public function refreshUser(UserInterface $user)
{
$class = get_class($user);
if (!$this->supportsClass($class)) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $class));
}
return $this->find($user->getId());
}
}
I set up two authentication methods for my api:
Private token authenticator by following this tutorial
FOSOAuthServerBundle system for access_token auth: https://github.com/FriendsOfSymfony/FOSOAuthServerBundle
Both works like a charm separately.
I tried to combine those systems with this security config:
security:
encoders:
FOS\UserBundle\Model\UserInterface: sha512
role_hierarchy:
ROLE_ALLOWED_TO_SWITCH: ~
ROLE_SUPPORT: ~
ROLE_ADMIN: [ROLE_SONATA_ADMIN]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_SUPPORT, ROLE_ALLOWED_TO_SWITCH]
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
api_key_user:
id: security.user.provider.api_key
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
oauth_token:
pattern: ^/oauth/v2/token
security: false
api:
pattern: ^/api
stateless: true
simple_preauth:
authenticator: security.authentication.authenticator.api_key
fos_oauth: true
main:
pattern: ^/
form_login:
provider: fos_userbundle
csrf_provider: form.csrf_provider
login_path: /login
check_path: /login_check
anonymous: ~
logout:
path: /logout
switch_user: true
If I try to get an access token with this curl command:
curl "http://localhost:8000/oauth/v2/token?client_id=1_2rqa1al0trwgso8g8co4swsks48cwsckgc8cokswkcgos4csog&client_secret=25a78plm6c2ss044k4skckkwoo8kw4kcoccg8sg0skook4sgwg&grant_type=password&username=test&password=test
It works an I get an access_token, but when I try to use it:
curl -X GET http://localhost:8000/api/changelogs.json -H "Authorization: Bearer MmI2OWNkNjhjMGYwOTUyNDA2OTdlMDBjNjA1YmI3MjVhNTBiMTNhMjI0MGE1YmM3NzgwNjVmZWZmYWNhM2E4YQ" | json_pp
I get:
{
"error" : "invalid_grant",
"error_description" : "The provided access token is invalid."
}
By deactivating my single_preauth api key authenticator, it's works and I can access to my API.
It seems my api key authenticator block all another system.
Here, my ApiKeyAuthenticator class:
class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
private $userProvider;
/**
* #param ApiKeyUserProvider $userProvider
*/
public function __construct(ApiKeyUserProvider $userProvider)
{
$this->userProvider = $userProvider;
}
/**
* {#inheritdoc}
*/
public function createToken(Request $request, $providerKey)
{
$apiKey = str_replace('Bearer ', '', $request->headers->get('Authorization', ''));
if (!$apiKey) {
throw new BadCredentialsException('No API key given.');
}
return new PreAuthenticatedToken('anon.', $apiKey, $providerKey);
}
/**
* {#inheritdoc}
*/
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
$apiKey = $token->getCredentials();
$username = $this->userProvider->getUsernameForApiKey($apiKey);
if (!$username) {
throw new AuthenticationException('The provided access token is invalid.');
}
$user = $this->userProvider->loadUserByUsername($username);
return new PreAuthenticatedToken(
$user,
$apiKey,
$providerKey,
$user->getRoles()
);
}
/**
* {#inheritdoc}
*/
public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
}
/**
* {#inheritdoc}
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
return new JsonResponse([
'error' => 'invalid_grant',
'error_description' => $exception->getMessage()
], 401);
}
}
But I can't find why.
How to combine this two authenticator methods?
Thanks for help.
Finaly found how to handle it but not sure it's the better and proper way.
Don't hesitate to suggest improvements on comments! ;)
First of all, remove fos_oauth key from security.yml file. It should looks like:
security:
firewalls:
# [...]
api:
pattern: ^/api
stateless: true
# This will handle both oauth access token and simple private token
simple_preauth:
authenticator: security.authentication.authenticator.api_key
# [...]
On ApiKeyUserProvider::getUsernameForApiKey method, you will search on both custom api key manager and OAuth access token manager.
The complete class should look like this.
class ApiKeyUserProvider implements UserProviderInterface
{
/**
* #var UserManagerInterface
*/
private $userManager;
/**
* #var ApiKeyManager
*/
private $apiKeyManager;
/**
* #var AccessTokenManagerInterface
*/
private $accessTokenManager;
/**
* #param UserManagerInterface $userManager
* #param ApiKeyManager $apiKeyManager
* #param AccessTokenManagerInterface $accessTokenManager
*/
public function __construct(UserManagerInterface $userManager, ApiKeyManager $apiKeyManager, AccessTokenManagerInterface $accessTokenManager)
{
$this->userManager = $userManager;
$this->apiKeyManager = $apiKeyManager;
$this->accessTokenManager = $accessTokenManager;
}
/**
* #param string $apiKey
*
* #return string|null
*/
public function getUsernameForApiKey($apiKey)
{
// FOSOAuth system
$token = $this->accessTokenManager->findTokenByToken($apiKey);
if ($token) {
return $token->getUser()->getUsername();
}
// Private key system
return $this->apiKeyManager->getUsernameForToken($apiKey);
}
/**
* {#inheritdoc}
*/
public function loadUserByUsername($username)
{
return $this->userManager->findUserByUsername($username);
}
/**
* {#inheritdoc}
*/
public function refreshUser(UserInterface $user)
{
throw new UnsupportedUserException();
}
/**
* {#inheritdoc}
*/
public function supportsClass($class)
{
return 'FOS\UserBundle\Model\User' === $class;
}
}
And voila! Both Private and OAuth token are correctly managed.