Reload user role after change without re-logging - symfony

How to refresh logged in user role e.g. when it has been changed by admin user? I've found the always_authenticate_before_granting security option (it's not included in Symfony 4 documentation) and set it to true.
security.yaml:
security:
always_authenticate_before_granting: true
encoders:
App\Entity\Main\User:
algorithm: bcrypt
providers:
app_user_provider:
entity:
class: App\Entity\Main\User
property: email
role_hierarchy:
ROLE_ADMIN: ROLE_USER
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: ~
guard:
authenticators:
- App\Security\LoginFormAuthenticator
form_login:
login_path: login
check_path: login
logout:
path: logout
target: homepage
remember_me:
secret: '%kernel.secret%'
path: /
access_control:
- { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/.*, roles: ROLE_USER }
but it doesn't take effect.
UPDATE
I've created onRequest subscriber:
class RequestSubscriber implements EventSubscriberInterface
{
private $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => 'onRequest'
];
}
public function onRequest(GetResponseEvent $event): void
{
if (!$event->isMasterRequest()) {
return;
}
if(!$token = $this->tokenStorage->getToken()) return;
$sessionUser = $token->getUser();
if ($sessionUser instanceof User) {
$this->tokenStorage->setToken(new PostAuthenticationGuardToken($sessionUser, 'main', $sessionUser->getRoles()));
}
}
}
and now I can refresh the updated roles on every request, but comparing sessionUser to databaseUser is pointless, because the sessionUser always contains newly updated roles, though in Symfony Profiler > Security Token are listed the old ones (in case when I don't set the new token, of course).

Tl;dr I'm afraid you will have to introduce a mechanism of your own in order to make this work.
The session token is stored inside the user's session. This will have quite an impact on your application's performance, because each time a call to the database will be required in order to check if the role had changed.
So you will need a request listener which will compare database role with current user role, and if it is not same, replace the token in the session, this time with new role list, eg. (pseudo code):
sessionUser = tokenStorage.token.user
databaseUser = userRepository.find(sessionUser.id)
if sessionUser.roles !== databaseUser.roles
tokenStorage.setToken(new PostAuthenticationGuardToken(…))
or use a cache as a flag carrier to notify the user about the change. This method is going to be much quicker for sure.
sessionUser = tokenStorage.token.user
// of course the flag has to be set when the user's role gets changed
cacheToken = 'users.role_reload.' . sessionUser.id
if cache.has(cacheToken)
databaseUser = userRepository.find(sessionUser.id)
tokenStorage.setToken(new PostAuthenticationGuardToken(…))
cache.remove(cacheToken)
Either way the user has to ask the application has there been role change, on each request.

Related

Symfony 6 Custom login strange behavior

I have a custom login system where using username/password against LDAP server. Based on the response i know that they are correct. The response is array with user details. I don't use the password hasher, the only information i store about the user is email/name which i recieve from the LDAP response.
My Authenticator
class CustomAuthenticator extends AbstractLoginFormAuthenticator
{
public function authenticate(Request $request): Passport
{
$uname = $request->request->get('uname', '');
$password = $request->request->get('password', '');
// check them against the LDAP server
$ldapData = $this->ldapChecker($uname, $password); // binds ldap server with username/password from login form. If the bind is success the ldap returns array containing user details (name, email etc)
if(is_array($ldapData)){
// if its array the submitted username/password are correct.
$email = $ldapData[0]['email'] // getting the user email from the ldap response
// here i'm checking if the username exist in the database if not i create new user
$checkUserExist = $this->user->findOneBy(['uname'=>$uname]);
if(!$checkUserExist){
// creating new user with the data from ldap response;
// storing name, uname, email
}
return new Passport(
new UserBadge($email, function($userIdentifier) {
$user = $this->user->findOneBy(['email' => $userIdentifier]);
if (!$user) {
throw new UserNotFoundException('passport fail');
}
return $user;
}),
new CustomCredentials(function($credentials, User $user) {
return true;
}, $password)
);
}
// here is logic to handle wrong username password
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
My security.yaml
security:
# enable_authenticator_manager: true
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
role_hierarchy:
ROLE_ADMIN: ROLE_USER
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
main:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\CustomAuthenticator
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: 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: ^/logout$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/, roles: ROLE_USER }
when#test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
The problem is sometimes when i open the app it loads user details from another user / or after loggin it shows details of another user or when i open incognito tab and open app it shows that i'm logged. I disabled the twig cache because i thought is cache problem but the problem persist.

Symfony 5 security.interactive_login event not called

I want to use the security.interactive_login event to update my User's last login field.
The event is successfully registered:
php bin/console debug:event-dispatcher security.interactive_login
Registered Listeners for "security.interactive_login" Event
===========================================================
------- ------------------------------------------------------------------------ ----------
Order Callable Priority
------- ------------------------------------------------------------------------ ----------
#1 App\EventSubscriber\UserLocaleSubscriber::onSecurityInteractiveLogin() 0
------- ------------------------------------------------------------------------ ----------
But it lands on Not called listeners in the Symfony profiler.
This is the event subscriber:
class UserLocaleSubscriber implements EventSubscriberInterface
{
private $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
{
/** #var User $user */
$user = $event->getAuthenticationToken()->getUser();
$user->setLastLoginAt(new DateTime());
$this->em->persist($user);
$this->em->flush();
}
public static function getSubscribedEvents()
{
return [
SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
];
}
}
And there is my security.yaml file:
security:
enable_authenticator_manager: true
encoders:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js|fonts)/
security: false
main:
lazy: true
provider: app_user_provider
guard:
authenticators:
- App\Security\LoginAuthenticator
logout:
path: app_logout
target: app_login # where to redirect after logout
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 1 week in seconds
path: /
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/(?!login), roles: ROLE_ADMIN }
The LoginAuthenticator class is Symfony's default generated one.
Why the interactive login event is not called?
When using the new(ish) authentication manager, the INTERACTIVE_LOGIN event is replaced with the LoginSuccessEvent.
# my subscriber
public static function getSubscribedEvents()
{
return [
//SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
LoginSuccessEvent::class => 'onLoginSuccess'
];
}
public function onLoginSuccess(LoginSuccessEvent $event)
{
$user = $event->getUser();
$user->setCount($user->getCount() + 1);
$this->em->flush();
//dd($user);
}
I'm not sure if this is explicitly documented yet. Like many upgrade deprecations, the code is very confusing. I tried to trace through what was happening and quickly got lost (once again) in the Security forest.
Events are talked about here.
I discovered this behavior by creating a fresh 5.1 project, running make:auth and adding a listener for both events. But I forgot to add enable_authenticator_manager: true to the security config.
So the INTERACTIVE_LOGIN event was fired. After enabling the new manager, the LoginSuccessEvent was fired. Notice that the new event has some additional helper methods such as getUser. Makes the code a tiny bit cleaner.
Off-topic but I would caution against flushing the entity manager inside of a listener. It can be a bit unpredictable depending on what else is going on. Might consider just getting the database connection and executing a SQL update.

authentication in symfony api with ldaptools+jwt+fosuser

In my api I'm trying to authenticate a user using LdapToolsBundle, FOSUserBundle and LexikJWTAuthenticationBundle. Doing things step by step and following the integration docs for fosuser and ldaptools and later the jwt docs I manage to acomplish the following:
FosUserBundle + LdapToolsBundle was successfull
Api Integration + FosUserBundle + LdapToolsBundle was successfull
Jwt + FosUserBundle + LdapToolsBundle on Api failed.
the problem is that I just can only log in against my database but not ldap.
in my database I have one user record which I created with fos command and any password(making shure the authentication is on ldap and not fosuser). So far so good. but once instroduced JWT the authentication is made by fosuser instead of ldap authentication guard. When I change the password with fos command I can get the token with out problems.
this is my config:
security:
encoders:
FOS\UserBundle\Model\UserInterface: bcrypt
LdapTools\Bundle\LdapToolsBundle\Security\User\LdapUser: plaintext
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
ldap:
id: ldap_tools.security.user.ldap_user_provider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_login:
pattern: ^/login
stateless: true
provider: fos_userbundle
anonymous: true
form_login:
check_path: /login
require_previous_session: false
username_parameter: username
password_parameter: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
require_previous_session: false
guard:
authenticators:
- ldap_tools.security.ldap_guard_authenticator
logout: true
api:
pattern: ^/
stateless: true
lexik_jwt: ~
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, role: IS_AUTHENTICATED_FULLY }
My question is how can I get to work the ldap authenticator? I'll show the wanted workflow
Auth request is received (username and password)
The fosuser provider finds the user or user not found(bad credentials-->end)
The ldap authenticator guard authenticate the user against the domain server or bad credentials -->end
The user is logged in successfully and token is received
but I still getting bad credentials with a registered user in database and domain server (the user credential works)
Thanks in advance!
Diggin into the ldap_tools.security.ldap_guard_authenticator authenticator(namespace LdapTools\Bundle\LdapToolsBundle\Security;) I found this
public function getUser($credentials, UserProviderInterface $userProvider)
{
$domain = $this->ldap->getDomainContext();
try {
$credDomain = isset($credentials['ldap_domain']) ? $credentials['ldap_domain'] : '';
$this->switchDomainIfNeeded($credDomain);
$this->setLdapCredentialsIfNeeded($credentials['username'], $credentials['password'], $userProvider);
$user = $userProvider->loadUserByUsername($credentials['username']);
$this->userChecker->checkPreAuth($user);
return $user;
} catch (UsernameNotFoundException $e) {
$this->hideOrThrow($e, $this->options['hide_user_not_found_exceptions']);
} catch (BadCredentialsException $e) {
$this->hideOrThrow($e, $this->options['hide_user_not_found_exceptions']);
} catch (LdapConnectionException $e) {
$this->hideOrThrow($e, $this->options['hide_user_not_found_exceptions']);
} catch (\Exception $e) {
$this->hideOrThrow($e, $this->options['hide_user_not_found_exceptions']);
} finally {
$this->switchDomainBackIfNeeded($domain);
}
}
This method loads an user given an userprovider and some credentials.
So, the getUser relies in the userprovider in order to load any user, but since you are using fos_userbundle as userprovider for your api_login firewall you are, in fact, authenticating against your local database. Try using the ldap userprovider in your config instead.
Of course, doing this you will authenticate against the ldap server and not the flow you descibed above. To do so consider handling authentication by yourself, so you can handle the flow as you want.
Also you can do like this
customauthenticator and in your public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
you can check credentials against your ldap server with the service "#ldap_tools.ldap_manager"->authenticate($user, $password, &$errorMessage = false, &$errorNumber = false).
Here is my answer: Just force the auth after checking the ldap server.
public function onAuthenticationFailure(AuthenticationFailureEvent $event)//when auth fails on DB(Allways!!!)
{
$userToken = $event->getAuthenticationToken();
$username = $userToken->getUsername();
$password = $this->requestStack->getCurrentRequest()->get('password');
if ($this->ldapManager->authenticate($username, $password)) {//good credentials
$token = new UsernamePasswordToken($userToken, 'yes', "public", $userToken->getRoles());
$this->container->get('security.token_storage')->setToken($token);//set a token
$event = new InteractiveLoginEvent($this->requestStack->getCurrentRequest(), $token); //dispatch the auth event
$event->stopPropagation();
$this->container->get('event_dispatcher')->dispatch(SecurityEvents::INTERACTIVE_LOGIN,$event);
}
//symfony takes care of the response
}
So far this is the best answer I've found

User object is not accessable in the controller in symfony

I'm trying to use auto login feature in symfony something like firing the login event and setting the user object. The user object is available in that controller but when I try to use other controller the user object says annon instead of showing the logged in user info
Controller A
private function autoLogin($request, $username)
{
$em = $this->getDoctrine()->getManager();
if (!$usr = $em->getRepository('AppBundle:User')->findOneBy(['username' => $username])) {
throw $this->createNotFoundException('User does not exist');
}
$token = new UsernamePasswordToken($usr, $usr->getPassword(), "secured_area", $usr->getRoles());
$this->get('security.token_storage')->setToken($token);
$loginEvent = new InteractiveLoginEvent($request, $token);
$this->get("event_dispatcher")->dispatch("security.interactive_login", $loginEvent);
$user = $this->get('security.token_storage')->getToken()->getUser();
dump($user); // can see user object without any issue
if (!$this->get('security.authorization_checker')->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException();
}
return $usr;
}
Controller B
public function editAction(Request $request)
{
$user = $this->get('security.token_storage')->getToken()->getUser();
print_r($user); // result is annon.
}
security.yml
security:
encoders:
AppBundle\Entity\User:
algorithm: bcrypt
providers:
doctrine_provider:
entity:
class: AppBundle:User
property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
secured_area:
anonymous: ~
provider: doctrine_provider
pattern: ^/
form_login:
login_path: security_login
check_path: security_login
csrf_token_generator: security.csrf.token_manager
logout:
path: /logout
target: /login
access_control:
- { path: ^/.*, roles: IS_AUTHENTICATED_ANONYMOUSLY }
I'd assume that you're not using any security setup and the second controller is called after user refreshes the page.
The most likely problem is that your user is not persisted into the session. Symfony\Component\Security\Http\Firewall\ContextListener is responsible for that. If you have a look at onKernelResponse() method you can find out how it does it. Basically it gets token from token storage, serialize it and stores to the session. On the request it does opposite: gets token from session and puts it to token storage.
I'd suggest that you play with the configuration of firewall and set up something like this:
firewalls:
autologin:
pattern: /autologinUrl/
context: autologing
In this case context listener will be called doing session-related stuff and your code should work.

Combine a stateful application with stateless JWT

We have a FOSUserBundle login system authenticating via LDAP and the fr3d LDAP bundle. It behaves like a normal multiple page application using sessions. We also have several RESTful endpoints using the FOSRestbundle and normal sessions for authentication. However, we need to share a few end points with an external application.
We managed to implement JWT using the Lexik bundle. It returns a token just fine. However, I don't know the best way to let a user using our login form to get this token so their request can pass it along in the header or session. My question is how to allow a user to login to our application in a stateful manner, but also receive the JWT and pass it to the server on ajax requests. This way I can allow external clients to connect directly to the API. Below is my symfony2 security configuration, security.yml:
security:
#erase_credentials: false
encoders:
FOS\UserBundle\Model\UserInterface: bcrypt
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
providers:
chain_provider:
chain:
providers: [my_user_provider, fr3d_ldapbundle]
in_memory:
memory:
users:
admin: { password: secret, roles: 'ROLE_ADMIN' }
my_user_provider:
id: app.custom_user_provider
fos_userbundle:
id: fos_user.user_provider.username
fr3d_ldapbundle:
id: fr3d_ldap.security.user.provider
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, role: IS_AUTHENTICATED_FULLY }
- { path: ^/api, role: IS_AUTHENTICATED_FULLY }
- { path: ^/api/login, role: IS_AUTHENTICATED_ANONYMOUSLY }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_login:
pattern: ^/api/login
fr3d_ldap: ~
provider: chain_provider
anonymous: true
stateless: false
form_login:
check_path: /api/login_check
username_parameter: username
password_parameter: password
require_previous_session: false
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api
provider: chain_provider
stateless: false
lexik_jwt:
throw_exceptions: true
create_entry_point: true
main:
pattern: ^/
fr3d_ldap: ~
form_login:
# provider: fos_userbundle
provider: chain_provider
always_use_default_target_path: true
default_target_path: /
csrf_provider: security.csrf.token_manager
logout: true
anonymous: true
switch_user: { role: ROLE_LIMS-BIOINFO}
EDIT:
Based on Kévin's answer I decided to implement a custom Twig extension to get the token for the logged in user on each page load:
AppBundle/Extension/JsonWebToken.php:
<?php
namespace AppBundle\Extension;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class JsonWebToken extends \Twig_Extension
{
/**
* #var ContainerInterface
*/
private $container;
/**
* #var JWTManagerInterface
*/
private $jwt;
public function __construct(ContainerInterface $container, JWTManagerInterface $jwt)
{
$this->container = $container;
$this->jwt = $jwt;
}
public function getName()
{
return 'json_web_token';
}
public function getFunctions()
{
return [
'json_web_token' => new \Twig_Function_Method($this, 'getToken')
];
}
public function getToken()
{
$user = $this->container->get('security.token_storage')->getToken()->getUser();
$token = $this->jwt->create($user);
return $token;
}
}
app/config/services.yml:
app.twig_jwt:
class: AppBundle\Extension\JsonWebToken
arguments: ["#service_container", "#lexik_jwt_authentication.jwt_manager"]
tags:
- { name: twig.extension }
app/Resources/views/layout.html.twig
<script>window.jsonWebToken = '{{ json_web_token() }}';</script>
app/Resources/modules/layout/app.js:
var jsonWebToken = window.jsonWebToken;
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization","Bearer " + jsonWebToken);
}
});
So far this seems to be working well. It let's my external API users and internal application users share the same authentication methods.
As the JWT token must be stored client-side (and not in a cookie to prevent CSRF attacks), you can use the create method of the lexik_jwt_authentication.jwt_manager service provided by LexikJWTAuthenticationBundle to generate a token after the login, then inject this token in a <script> tag in the generated HTML.
Hey i recently came across this same situation.
To generate the JWT I created a redirect listener
class RedirectListener implements EventSubscriberInterface
{
//the private variables go up here
public function __construct(\Twig_Environment $twig, TokenStorageInterface $sam, EntityManagerInterface $em, JWTTokenManagerInterface $JWTTokenManager)
{
$this->twig = $twig;
$this->sam = $sam;
$this->em = $em;
$this->JWTTokenManager = $JWTTokenManager;
}
public function kernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
$route = $request->get('_route');
$routeParams = $request->get('_route_params');
$pathInfo = $request->getPathInfo();
$matchApp = preg_match('/\/app/', $pathInfo);
if ($event->isMasterRequest()) {
if ($matchApp) {
$token = $this->sam->getToken();
if ($token) {
/** #var User $user */
$user = $token->getUser();
if($user instanceof User){
$token = $this->JWTTokenManager->create($user);
$this->twig->addGlobal('jwt', $token);
}
}
}
}
return $event;
}
}
This helped me get the JWT to my twig template (I used my base template to make sure it's present on every page)
{% if jwt is defined %}
<span class="hidden" id="jwt" data-jwt="{{ jwt ? jwt : 'null' }}"></span>
{% endif %}
Now using autobahn JS I can subscribe using the JWT :
let jwt = $('#jwt').data('jwt');
let connection = new Connection({url:"ws://127.0.0.1:8080/ws", realm:"realm1"});
connection.onopen = (session) => {
function onevent(args) {
console.log("Event:", args[0])
}
session.subscribe(jwt, onevent);
}
connection.open();
Next the server can now receive messages with the JWT from the JS
public function __construct(ContainerInterface $container)
{
$router = new Router();
$realm = "realm1";
$router->addInternalClient(new Pusher($realm, $router->getLoop()));
$router->addTransportProvider(new RatchetTransportProvider("0.0.0.0", 8080));
try{
$router->start();
} catch (\Exception $exception){
var_dump($exception->getMessage());
}
}
I now need to register a module for the router that will act as a listener and send messages back to the registered topic (JWT).
I'm not 100% there yet so any advice would be appreciated and I'll keep this updated as I go along.

Resources