9

I built a login form following this form login setup doc.

This is working fine on localhost but not on the production server.

On both localhost and prod, authentication begins successfully

  1. Guard authentication successful
  2. Guard authenticator set success response
  3. Stored the security token in the session
  4. Matched route "easyadmin

    ### var/log/prod.log output with info level
    [2019-07-05 10:28:46] request.INFO: Matched route "app_login". {"route":"app_login","route_parameters":{"_route":"app_login","_controller":"App\\Controller\\SecurityController::login"},"request_uri":"https://example.com/login","method":"POST"} []
    [2019-07-05 10:28:46] security.DEBUG: Checking for guard authentication credentials. {"firewall_key":"main","authenticators":1} []
    [2019-07-05 10:28:46] security.DEBUG: Checking support on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.DEBUG: Calling getCredentials() on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.DEBUG: Passing guard token information to the GuardAuthenticationProvider {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] php.INFO: User Deprecated: The "Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder" class is deprecated since Symfony 4.3, use "Symfony\Component\Security\Core\Encoder\NativePasswordEncoder" instead. {"exception":"[object] (ErrorException(code: 0): User Deprecated: The \"Symfony\\Component\\Security\\Core\\Encoder\\BCryptPasswordEncoder\" class is deprecated since Symfony 4.3, use \"Symfony\\Component\\Security\\Core\\Encoder\\NativePasswordEncoder\" instead. at /var/www/clients/client0/web4/web/vendor/symfony/security-core/Encoder/BCryptPasswordEncoder.php:14)"} []
    
    [2019-07-05 10:28:46] security.INFO: Guard authentication successful! {"token":"[object] (Symfony\\Component\\Security\\Guard\\Token\\PostAuthenticationGuardToken: PostAuthenticationGuardToken(user=\"myemail@gmail.com\", authenticated=true, roles=\"ROLE_EDITOR, ROLE_USER\"))","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    
    [2019-07-05 10:28:46] security.DEBUG: Guard authenticator set success response. {"response":"[object] (Symfony\\Component\\HttpFoundation\\RedirectResponse: HTTP/1.0 302 Found\r\nCache-Control: no-cache, private\r\nDate:          Fri, 05 Jul 2019 10:28:46 GMT\r\nLocation:      /backoffice\r\n\r\n<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\" />\n        <meta http-equiv=\"refresh\" content=\"0;url=/backoffice\" />\n\n        <title>Redirecting to /backoffice</title>\n    </head>\n    <body>\n        Redirecting to <a href=\"/backoffice\">/backoffice</a>.\n    </body>\n</html>)","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    
    [2019-07-05 10:28:46] security.DEBUG: Remember me skipped: it is not configured for the firewall. {"authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.DEBUG: The "App\Security\LoginFormAuthenticator" authenticator set the response. Any later authenticator will not be called {"authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.DEBUG: Stored the security token in the session. {"key":"_security_main"} []
    
    [2019-07-05 10:28:46] request.INFO: Matched route "easyadmin". {"route":"easyadmin","route_parameters":{"_controller":"Symfony\\Bundle\\FrameworkBundle\\Controller\\RedirectController::urlRedirectAction","path":"/backoffice/","permanent":true,"scheme":null,"httpPort":80,"httpsPort":443,"_route":"easyadmin"},"request_uri":"https://example.com/backoffice","method":"GET"} []
    

But while in localhost, I am correctly redirected to the backoffice :

  • Read existing security token from the session
  • User was reloaded from a user provider

    ### var/log/prod.log (following lines, localhost) 
    [2019-07-05 10:19:29] security.DEBUG: Read existing security token from the session. {"key":"_security_main","token_class":"Symfony\\Component\\Security\\Guard\\Token\\PostAuthenticationGuardToken"} []
    [2019-07-05 10:19:29] security.DEBUG: User was reloaded from a user provider. {"provider":"Symfony\\Bridge\\Doctrine\\Security\\User\\EntityUserProvider","username":"raoux.thierry@free.fr"} []
    [2019-07-05 10:19:29] security.DEBUG: Checking for guard authentication credentials. {"firewall_key":"main","authenticators":1} []
    [2019-07-05 10:19:29] security.DEBUG: Checking support on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:19:29] security.DEBUG: Guard authenticator does not support the request. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:19:29] cache.INFO: Lock acquired, now computing item "easyadmin.processed_config" {"key":"easyadmin.processed_config"} []
    

In prod environment, instead :

  • it skips step : reading existing security token
  • does not refresh user as expected
  • instead it populates the TokenStorage with an anonymous Token
  • Acces denied and back to login url

    ### var/log/prod.log (same following lines, but from production server) 
    [2019-07-05 10:28:46] security.DEBUG: Checking for guard authentication credentials. {"firewall_key":"main","authenticators":1} []
    [2019-07-05 10:28:46] security.DEBUG: Checking support on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.DEBUG: Guard authenticator does not support the request. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
    [2019-07-05 10:28:46] security.INFO: Populated the TokenStorage with an anonymous Token. [] []
    [2019-07-05 10:28:46] security.DEBUG: Access denied, the user is not fully authenticated; redirecting to authentication entry point. {"exception":"[object] (Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException(code: 403): Access Denied. at /var/www/clients/client0/web4/web/vendor/symfony/security-http/Firewall/AccessListener.php:72)"} []
    [2019-07-05 10:28:46] security.DEBUG: Calling Authentication entry point. [] []
    [2019-07-05 10:28:46] request.INFO: Matched route "app_login". {"route":"app_login","route_parameters":{"_route":"app_login","_controller":"App\\Controller\\SecurityController::login"},"request_uri":"https://example.com/login","method":"GET"} []
    

security.yaml

security:
    encoders:
        App\Entity\User:
            algorithm: bcrypt
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            logout:
                path: app_logout
    access_control:
        - { path: ^/backoffice, roles: ROLE_EDITOR} # requires_channel: https

routes.yaml

admin:
  path: /backoffice
  controller: EasyCorp\Bundle\EasyAdminBundle\Controller\EasyAdminController

LoginFormAuthenticator

// use...

class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    private $passwordEncoder;

    public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordEncoder = $passwordEncoder;
    }

    public function supports(Request $request)
    {
        return 'app_login' === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );

        return $credentials;
    }

    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(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }
        return new RedirectResponse($this->urlGenerator->generate('admin'));
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate('app_login');
    }
}

Security controller

// use...

class SecurityController extends AbstractController
{
    /**
     * @Route("/login", name="app_login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render(
          'security/login.html.twig',
          [
            'last_username' => $lastUsername,
            'error' => $error,
          ]
        );
    }

    /**
     * @Route("/logout", name="app_logout")
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function logout()
    {
        return $this->redirectToRoute('home');
    }
}
//... skipped forgottenPassword and resetPassword methods

EDIT:

php bin/console debug:config security output

Current configuration for extension with alias "security"
=========================================================

security:
encoders:
    App\Entity\User:
        algorithm: bcrypt
        hash_algorithm: sha512
        key_length: 40
        ignore_case: false
        encode_as_base64: true
        iterations: 5000
        cost: null
        memory_cost: null
        time_cost: null
        threads: null
providers:
    app_user_provider:
        entity:
            class: App\Entity\User
            property: email
            manager_name: null
firewalls:
    dev:
        pattern: ^/(_(profiler|wdt)|css|images|js)/
        security: false
        methods: {  }
        user_checker: security.user_checker
        stateless: false
        logout_on_user_change: true
    main:
        anonymous:
            secret: null
        guard:
            authenticators:
                - App\Security\LoginFormAuthenticator
            entry_point: null
        logout:
            path: app_logout
            csrf_parameter: _csrf_token
            csrf_token_id: logout
            target: /
            invalidate_session: true
            delete_cookies: {  }
            handlers: {  }
        methods: {  }
        security: true
        user_checker: security.user_checker
        stateless: false
        logout_on_user_change: true
access_control:
    -
        path: ^/backoffice
        roles:
            - ROLE_EDITOR
        requires_channel: null
        host: null
        port: null
        ips: {  }
        methods: {  }
        allow_if: null
access_decision_manager:
    strategy: affirmative
    allow_if_all_abstain: false
    allow_if_equal_granted_denied: true
access_denied_url: null
session_fixation_strategy: migrate
hide_user_not_found: true
always_authenticate_before_granting: false
erase_credentials: true
role_hierarchy: {  }

EDIT 2

AS @Arno commented, I edited framework.yaml to save sessions in var/ directory and I can check that this step works without permissions issues, each time I hit the login form, a sess_ file is written.

Worth saying that if I comment :

access_control:
    - { path: ^/odelices_admin, roles: ROLE_USER}

I can access backoffice.

EDIT 3 : session behavior

So now sessions are saved into var/sessions/prod.

  1. I clean the dir : sudo rm -r var/sessions/prod/sess_*
  2. I open Chrome and the url, it sets a PHPSSID cookie with the same value as a first sess_xyz file :

    _sf2_attributes|a:2:{s:19:"_csrf/https-contact";s:43:"Oq-QpN21bI_BUDcVbv0ocyrYsTzQo3aJr80QAk2AR7w";s:19:"_csrf/https-booking";s:43:"z_L4TG7Wg0jydwl5VabfJMx0NBhQgeasuAiqxksLvD8";}_sf2_meta|a:3:{s:1:"u";i:1562668584;s:1:"c";i:1562668584;s:1:"l";s:1:"0";}
    
  3. I go to login page. New PHPSSID value associated with a new sess_xyz file :

    _sf2_attributes|a:1:{s:24:"_csrf/https-authenticate";s:43:"erWMU-irtptcZodr8UOjFtxiuyE23LbAeFHRnXgcNdc";}_sf2_meta|a:3:{s:1:"u";i:1562668662;s:1:"c";i:1562668662;s:1:"l";s:1:"0";}
    
  4. I log in with correct values. This creates 3 new ssid_xyz files.

    # 1st one shows user logged in with correct roles and so on
    _sf2_attributes|a:3:{s:24:"_csrf/https-authenticate";s:43:"erWMU-irtptcZodr8UOjFtxiuyE23LbAeFHRnXgcNdc";s:23:"_security.last_username";s:21:"user_email@gmail.com";s:14:"_security_main";s:799:"C:67:"Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken":718:{a:2:{i:0;s:4:"main";i:1;a:5:{i:0;O:15:"App\Entity\User":6:{s:19:"^@App\Entity\User^@id";i:1;s:22:"^@App\Entity\User^@email";s:21:"user_email@gmail.com";s:22:"^@App\Entity\User^@roles";a:1:{i:0;s:11:"ROLE_EDITOR";}s:25:"^@App\Entity\User^@password";s:60:"$2y$13$cXaR7Ss.kTH1U.T/Rzi6m.ALsKwWCLDcO5/OIeRDAq02iylmf4us6";s:21:"^@App\Entity\User^@name";s:7:"Thierry";s:13:"^@*^@resetToken";N;}i:1;b:1;i:2;a:2:{i:0;O:41:"Symfony\Component\Security\Core\Role\Role":1:{s:47:"^@Symfony\Component\Security\Core\Role\Role^@role";s:11:"ROLE_EDITOR";}i:1;O:41:"Symfony\Component\Security\Core\Role\Role":1:{s:47:"^@Symfony\Component\Security\Core\Role\Role^@role";s:9:"ROLE_USER";}}i:3;a:0:{}i:4;a:2:{i:0;s:11:"ROLE_EDITOR";i:1;s:9:"ROLE_USER";}}}}";}_sf2_meta|a:3:{s:1:"u";i:1562668713;s:1:"c";i:1562668713;s:1:"l";s:1:"0";}
    
    # 2nd one ...is empty
    
    # 3rd one refers to backoffice url
    _sf2_attributes|a:1:{s:26:"_security.main.target_path";s:42:"https://mywebsite.com/backoffice";}_sf2_meta|a:3:{s:1:"u";i:1562668713;s:1:"c";i:1562668713;s:1:"l";s:1:"0";}
    
    # last one is similar to point 3, before logging, only ssid value differs, and a corresponding cookie is set on Chrome
    _sf2_attributes|a:1:{s:24:"_csrf/https-authenticate";s:43:"3UC5dCRrahc2qhdZ167Jg4HKTJCexf8PFlefibTVpYk";}_sf2_meta|a:3:{s:1:"u";i:1562668713;s:1:"c";i:1562668713;s:1:"l";s:1:"0";}
    

EDIT 4 : User Entity

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
// use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User implements UserInterface # , EquatableInterface
{
    /**
     * @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 = [];

    /**
     * @var string The hashed password
     * @ORM\Column(type="string")
     */
    private $password;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @var string le token qui servira lors de l'oubli de mot de passe
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    protected $resetToken;

  /*public function __construct($username, $password, array $roles)
  {
    $this->username = $username;
    $this->password = $password;
    $this->roles = $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 getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return string
     */
    public function getResetToken(): string
    {
      return $this->resetToken;
    }

    /**
     * @param string $resetToken
     */
    public function setResetToken(?string $resetToken): void
    {
      $this->resetToken = $resetToken;
    }

    public function __toString() {
      return $this->getName() ;
    }

/*    public function isEqualTo(UserInterface $user)
    {

      if ($this->password !== $user->getPassword()) {
        return false;
      }


      if ($this->email !== $user->getUsername()) {
        return false;
      }

      return true;
    }*/
}

Stack

Debian Stretch, Nginx + Varnish : Nginx handles 443 requests, pass them to Varnish as a cache proxy, which delivers cached objects or pass requests to nginx backend on 8083 port. This is working like a charm for another app with similar login logic (the lone difference is the buggy one redirects to easyadmin instead of a custom admin), so I don't think it is related to the stack.

vhost

server { # this block only redirects www to non www
        listen aaa.bbb.ccc.ddd:443 ssl;
        server_name www.somewebsite.com;

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_certificate /var/www/clients/client0/web4/ssl/somewebsite.com-le.crt;
        ssl_certificate_key /var/www/clients/client0/web4/ssl/somewebsite.com-le.key;

        return 301 https://somewebsite.com$request_uri;
}

server { # this block redirects ssl requests to Varnish
        listen aaa.bbb.ccc.ddd:443 ssl;
        server_name somewebsite.com;

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_certificate /var/www/clients/client0/web4/ssl/somewebsite.com-le.crt;
        ssl_certificate_key /var/www/clients/client0/web4/ssl/somewebsite.com-le.key;

        location / {
            # Pass the request on to Varnish.
            proxy_pass  http://127.0.0.1;

            # Pass some headers to the downstream server, so it can identify the host.
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            # Tell any web apps that the session is HTTPS.
            proxy_set_header X-Forwarded-Proto https;
            proxy_redirect     off;
        }
}

server { # now sent to backend 
        listen aaa.bbb.ccc.ddd:8083;
        server_name somewebsite.com;
        root   /var/www/somewebsite.com/web/public;

        location / {
            try_files $uri /index.php$is_args$args;
       }
       location ~ ^/index\.php(/|$) {
            fastcgi_pass 127.0.0.1:8998;

            fastcgi_split_path_info ^(.+\.php)(/.*)$;
            include fastcgi_params;

            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT $realpath_root;

            internal;
        }
        location ~ \.php$ {
            return 404;
        }

        error_log /var/log/ispconfig/httpd/somewebsite.com/error.log;
        access_log /var/log/ispconfig/httpd/somewebsite.com/access.log combined;

        location ~ /\. {
                        deny all;
        }
        location ^~ /.well-known/acme-challenge/ {
                        access_log off;
                        log_not_found off;
                        root /usr/local/ispconfig/interface/acme/;
                        autoindex off;
                        try_files $uri $uri/ =404;
        }
        location = /favicon.ico {
            log_not_found off;
            access_log off;
            expires max;
        }
        location = /robots.txt {
            allow all;
            log_not_found off;
            access_log off;
        }
}

Could this be related to permissions on some dir ? HTTPS ? EasyAdmin ? How can I make sure the security token was stored in the session, even it is logged as stored ? I also tried to change access_control to role ROLE_USER so that any authenticated user should access. No way.

Any help is really appreciated.

Kojo
  • 305
  • 9
  • 20
  • 1
    Can you show use your configuration using php bin/console debug:config security – Dylan Kas Jul 05 '19 at 12:11
  • Did you clear cache on the server after upload? – mutantkeyboard Jul 05 '19 at 12:13
  • Just posted `php bin/console debug:config security` output. Yes, I'm searching the issue since yesterday evening and cleared cache about 100 times, checked permissions issues... – Kojo Jul 05 '19 at 12:16
  • 1
    Are you using the default settings for saving sessions (configured under framework.session)? If so, sessions are saved as files and it can definitely be a permission problem. By default they should be saved in a "sessions" folder in you cache dir, but you can change that like described here: https://symfony.com/doc/3.4/session/sessions_directory.html. Maybe you can try giving the directory 777 permissions. It should create one file per session of the format "sess_" with the serialized user inside. – Arno Hilke Jul 08 '19 at 22:04
  • @ArnoHilke Thanks a lot for this hint. Well I switched to [4.3 doc](https://symfony.com/doc/current/session.html) then I changed my setup to explicitely save the sessions in var dir, and so far session is written there, so unfortunately this is not the blocking point :(( – Kojo Jul 09 '19 at 08:27
  • What about the fact a new value is given to the ssid each time I hit a url (login page or not) ?? Is it a normal behavior ??? – Kojo Jul 09 '19 at 08:41
  • 1
    @Kojo The session ID should change every time your authentication level changes, i.e. on login and logout, but not on normal requests. One reason could be that your user changes or is detected as changed, e.g. because of faulty (de)serialization. You could also check 1) whether a proper session is created when you log in (check the content of the file for a user with proper email and role) and 2) whether the cookie is properly set and sent with this session ID on your client. – Arno Hilke Jul 09 '19 at 10:02
  • @ArnoHilke thanks again ! I edited the post with all the behavior. Please have a look, yes cookies are set regarding to ssid files, but whenever redirection is done, back to beginnig... I have to attend some team meetings this afternoon I'll be back after... I really don't know what to think :-(( – Kojo Jul 09 '19 at 11:04
  • can we see your User Entity please ? – Yoann MIR Jul 09 '19 at 13:59
  • Please also check there is no `stateless: true` option under your firewall. It will block session start for the user, and all stay anonymous after successful authenticaltion. – Alexander Jul 03 '20 at 13:40

4 Answers4

6

So here are my comments in a more structured way, so that it might help someone else having problems with authentication in Symfony.

Make sure sessions are saved

By default, each session is saved as a file with the name sess_<id> in <project_dir>/var/cache/<env>/sessions or as defined by save_path in your php.ini if framework.session.handler is set to null. Configure your session directory explicitly and make sure a session file is created when you log in. If not, check the permissions for that folder.

# app/config/config.yml (Symfony 3)
# config/packages/framework.yaml (Symfony 4)
framework:
    session:
        handler_id: 'session.handler.native_file'
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'

Cf. https://symfony.com/doc/current/session.html#configuration

Make sure sessions are correct and used

When you login, a session with a new ID should be created. Check the content of the file. It should contain your serialized user under the firewall name (e.g. main), including your identifier (e.g. email) and your user role(s) (e.g. ROLE_USER). A problem here could be caused by faulty authentication, security config, or serialization.

_sf2_attributes|a:3:{s:18:"_csrf/authenticate";s:43:"n2oap401u4P4O7m_IhPODZ6Bz7EHl-DDsHxBEl-fhxc";s:23:"_security.last_username";s:10:"foo@bar.de";s:14:"_security_main";s:545:"C:67:"Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken":464:{a:2:{i:0;s:4:"main";i:1;a:5:{i:0;O:15:"App\Entity\User":4:{s:19:"App\Entity\Userid";i:1;s:22:"App\Entity\Useremail";s:10:"foo@bar.de";s:22:"App\Entity\Userroles";a:0:{}s:25:"App\Entity\Userpassword";s:60:"$2y$13$qwbtasafa58lPonX6B5a9eV4lziF7EZWP8NFLAe3blpCJVhQgPVOS";}i:1;b:1;i:2;a:1:{i:0;O:41:"Symfony\Component\Security\Core\Role\Role":1:{s:47:"Symfony\Component\Security\Core\Role\Rolerole";s:9:"ROLE_USER";}}i:3;a:0:{}i:4;a:1:{i:0;s:9:"ROLE_USER";}}}}";}_sf2_meta|a:3:{s:1:"u";i:1563015142;s:1:"c";i:1563015142;s:1:"l";s:1:"0";}

Check that a cookie with the same ID is set in your browser on login. The name of the cookie is defined by session.name in your php.ini, by default it is PHPSESSID. It should be sent with every request you make (e.g. Cookie: PHPSESSID=lpcf79ff8jdv2iigsgvepnr9bb). If the correct session exists, but you have a different cookie in your browser, you might have been immediately logged out after a success redirect.

Make sure the user is refreshed properly

The session ID should only change when your user changes (e.g. on login and logout). If it changes after normal requests (e.g. you are immediately logged out) or your session seems to be ignored, the problem might be that Symfony considers your user changed. This can be caused by faulty (de)serialization or comparison.

By default, Symfony uses the serialized results of getPassword(), getUsername(), and getSalt() from the session to compare against the user provided by the user provider (e.g. the database). If any of those values changes, you are logged out (cf. https://symfony.com/doc/current/security/user_provider.html#understanding-how-users-are-refreshed-from-the-session).

Thus, you should make sure that the user provided by e.g. your database is correct and matches the deserialized user from the session. Make sure the relevant fields are properly serialized. If you implement the Serializable interface, make sure your serialize() method matches your unserialize(). If you implement EquatableInterface, make sure your isEqualTo() method works correctly. Both of those interfaces are optional though, so you might consider to remove them for debugging purposes.

Arno Hilke
  • 926
  • 3
  • 12
  • The "isEqualTo()" and the serialization methods worked for me, thanks ! – Laurent Oct 02 '19 at 14:36
  • 1
    It surprised me that `hasUserChanged` in `Symfony\Component\Security\Core\Authentication\Token\AbstractToken` was called during the refresh. It worked for me by implementing `EquatableInterface` in my `User` entity, and defining `isEqualTo`. – Julian Dec 29 '20 at 12:34
  • 1
    @Julian As mentioned in the [docs](https://symfony.com/doc/5.2/security/user_provider.html#understanding-how-users-are-refreshed-from-the-session) I also linked above, they say it's for security reasons, to make sure the deserialized user still matches the freshly queried user. In some older [docs](https://symfony.com/doc/3.4/security/entity_provider.html#understanding-serialize-and-how-a-user-is-saved-in-the-session) they also state that in theory it would be enough to just save the ID in the session and use that to query the user, so I am not exactly sure why it was done this way. – Arno Hilke Dec 30 '20 at 15:15
5

Happen to me a year ago, authentication succesfull, redirect and logged as anonymous. Make me want to smash my head against walls. The problem i had back then was that I created user according to an old course from KnpUniversity and it did not implement EquatableInterface and the isEqualTo method. Hope it'll work for you.

Make sure your User Entity implements EquatableInterface

namespace App\Entity;

use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\EquatableInterface;

    class User implements UserInterface, EquatableInterface
{
    private $username;
    private $password;
    private $salt;
    private $roles;

    public function __construct($username, $password, $salt, array $roles)
    {
        $this->username = $username;
        $this->password = $password;
        $this->salt = $salt;
        $this->roles = $roles;
    }

    // your setters, getters and whatever ...

    public function isEqualTo(UserInterface $user)
    {
        if (!$user instanceof WebserviceUser) {
            return false;
        }

        if ($this->password !== $user->getPassword()) {
            return false;
        }

        if ($this->salt !== $user->getSalt()) {
            return false;
        }

        if ($this->username !== $user->getUsername()) {
            return false;
        }

        return true;
    }
}
Yoann MIR
  • 763
  • 3
  • 14
  • Thank you Yoann. That does not change the behavior. :-(( I'm going to post User Entity – Kojo Jul 09 '19 at 18:13
  • You might be wondering : I made this check in my try to implement EquatableInterface `if ($this->email !== $user->getUsername())` because getUsername returns email as set in security.yaml provider... this is working in localhost, not in prod – Kojo Jul 09 '19 at 18:19
  • that's odd and maybe related to your issue – Yoann MIR Jul 09 '19 at 20:09
  • As far is I understand, by default the user is refreshed comparing last serialized one with current, using `getSalt()`, `getPassword()` and `getUsername()` from the User Entity. `getUsername` is **not** `getName()`, it should return a unique value, and you define which one in security.yaml -> providers entity property. don't you agree ? Your implementation is working fine indeed with my config, but there was no reason to add this check in my so basic configuration. Anyway, thanks to you, I learned a great new way to customize this step ! – Kojo Jul 10 '19 at 13:21
3

It ends that Varnish setup was the faulty.

I completely forgot that I have to specify any backoffice pattern such as /admin/ , /backoffice/ and more not to be cached by the proxy, but instead directly passed to backend.

sub vcl_recv {
    # ...
    if (req.url ~ "^/status\.php$" ||
        req.url ~ "^/update\.php$" ||
        req.url ~ "^/install\.php" ||
        req.url ~ "^/admin$" ||
        req.url ~ "^/admin/.*$" ||
        req.url ~ "^/flag/.*$" ||
        req.url ~ "^.*/ajax/.*$" ||
        req.url ~ "^.*/ahah/.*$" ||
        req.url ~ "^/info/.*$" ||
        req.url ~ "^/system/files/.*$" ||
        req.url ~ "^/user" ||
        req.url ~ "^/users/.*$" ||
        req.url ~ "^/user/.*$" ) {

       return (pass);
    }
    # ...

This was already set up for the other symfony app I mention in the question, and several Drupal websites. At least it obliged me to dig deeply in Symfony 4 User authentication process, and how to debug it ! Maybe this step by step debug post will help further readers ?!

Kojo
  • 305
  • 9
  • 20
0

I had the same symptom that was described by the OP but in a Symfony 5.1 app and using my own existing User entity. Turns out the issue is I did not implement \Serializable on my User entity and the 2 required methods:

public function serialize()
{
    return serialize(array(
        $this->id,
        $this->username,
        $this->password,
        // see section on salt below
        // $this->salt,
    ));
}

/** @see \Serializable::unserialize() */
public function unserialize($serialized)
{
    list (
        $this->id,
        $this->username,
        $this->password,
        // see section on salt below
        // $this->salt
    ) = unserialize($serialized, array('allowed_classes' => false));
}

Once that was rectified, the app was able to stay logged in both in dev and prod environments, and both with and without using "Remember Me" option. The logic behind it is explained at the official site.

David
  • 1