DEV Community

Vinicius
Vinicius

Posted on

CakeDC/Users: why "Remember me" sets a cookie but never logs you back in

My users kept complaining that the admin panel logged them out "all the time", even with Remember me checked. I assumed it was just the PHP session expiring. Half of that was right. The other half was a quiet configuration trap in the CakePHP authentication stack. It probably affects other CakePHP 5 apps that use cakedc/users with the default authenticators.

TL;DR: the Cookie authenticator in CakeDC/Users' default config has no identifier of its own. In cakephp/authentication 3.3, an authenticator without one inherits the identifier of the previous authenticator that had one. In the default order, that's the Token authenticator, which looks users up by api_token. So the remember-me cookie is written but never matches anyone. Give Cookie its own identifier and it works.

Versions where I hit it: CakePHP 5.4, cakedc/users 16.0.4, cakedc/auth (its CookieAuthenticator), cakephp/authentication 3.3.7.

The symptoms

  • You sign in with Remember me checked.
  • The browser does get a cookie. It's called CookieAuth; remember_me is only the name of the form checkbox. It's httponly and expires in one month.
  • Once the PHP session ends (garbage collection, server restart, deploy, or you delete the session cookie), the next request redirects to /login. The remember-me cookie is still there, but it is ignored.
  • Nothing is logged. To the framework, a cookie that matches no user is just a failed authentication, so it moves on to the next authenticator.

To reproduce it quickly, sign in with Remember me, delete only the session cookie (CAKEPHP by default) in devtools, and reload. If you land on the login page, you have this problem.

How authenticators get their identifier

cakephp/authentication separates authenticators (where the credentials come from: session, form, token, cookie…) from identifiers (how those credentials become a user: password check, token lookup…).

Since 3.3, the recommended style is to put the identifier inside each authenticator's config. loadIdentifier() is deprecated. Here is how the collection builds each authenticator (src/Authenticator/AuthenticatorCollection.php):

protected function _create(object|string $class, string $alias, array $config): AuthenticatorInterface
{
    if (is_string($class)) {
        if (!empty($config['identifier'])) {
            $this->_identifiers = new IdentifierCollection((array)$config['identifier']);
        }

        return new $class($this->_identifiers, $config);
    }

    return $class;
}
Enter fullscreen mode Exit fullscreen mode

$this->_identifiers is a property of the collection, shared by every authenticator it creates. An authenticator with an identifier replaces it. An authenticator without one gets whatever the previous one left there.

The default CakeDC/Users config

Here is the relevant part of vendor/cakedc/users/config/users.php, trimmed and in the order it appears:

'Authenticators' => [
    'Session' => [...],                       // no identifier (doesn't need one)
    'Form' => [
        'className' => 'CakeDC/Auth.Form',
        'identifier' => ['Authentication.Password' => [...]],   // ① sets Password
    ],
    'Token' => [
        'className' => 'Authentication.Token',
        'identifier' => ['Authentication.Token' => [
            'tokenField' => 'api_token',                         // ② replaces it with Token
        ]],
    ],
    'Cookie' => [
        'className' => 'CakeDC/Auth.Cookie',
        'rememberMeField' => 'remember_me',
        'cookie' => ['expires' => new \DateTime('+1 month'), 'httponly' => true],
        // ③ no identifier → inherits the Token identifier from ②
    ],
    'Social' => [...],
    // ...
],
Enter fullscreen mode Exit fullscreen mode

The remember-me token is [username, expires, hmac]. When the cookie authenticator sees one, it asks its identifier for the user with that username and then checks the HMAC against the user's password hash. Its identifier, though, is the TokenIdentifier, which searches the api_token column. No user has their username as an API token, so the result is FAILURE_IDENTITY_NOT_FOUND and you're logged out.

Nothing in the remember-me code itself is broken. The cookie authenticator just ends up with the wrong identifier, because of where it sits in the array.

The fix

Give the Cookie authenticator an identifier of its own, in your app's users config. That's the file you pass in Users.config, loaded after the plugin's, for example config/users.php or config/app_users.php:

return [
    'Auth' => [
        'Authenticators' => [
            // "Remember me": the Cookie authenticator needs its OWN identifier.
            // Without one, cakephp/authentication 3.3 hands it the identifier
            // of the previous authenticator that had one (Token / api_token),
            // and the cookie never finds the user.
            'Cookie' => [
                'identifier' => [
                    'Authentication.Password' => [
                        'fields' => [
                            'username' => 'username',
                            'password' => 'password',
                        ],
                        'resolver' => [
                            'className' => 'Authentication.Orm',
                            'finder' => 'active',
                        ],
                    ],
                ],
            ],
        ],
        // ... the rest of your Auth config
    ],
];
Enter fullscreen mode Exit fullscreen mode

Some notes on this block:

  • It merges with the plugin's config. You only add the identifier key; className, cookie options and rememberMeField stay as the plugin defines them. Check with debug(Configure::read('Auth.Authenticators.Cookie')) if your config loading is unusual.
  • The username field must be what the cookie stores. The token is built from the cookie authenticator's fields.username, which defaults to username. If your users sign in by e-mail and the username column holds the e-mail, this works as is. If you changed the cookie authenticator's fields, use the same column here.
  • Keep the active finder, so a disabled user can't come back in through an old cookie.
  • You don't need to clear existing cookies. Tokens that were already issued start working, as long as they haven't expired.

A regression test

This bug fails silently, so it's worth a test that would have caught it. With IntegrationTestTrait:

public function testCookieSignsBackInAfterTheSessionEnds(): void
{
    $this->enableCsrfToken();
    $this->enableSecurityToken();
    $this->post('/login', [
        'username' => 'staff@example.com',
        'password' => 'secret',
        'remember_me' => '1',
    ]);
    $this->assertRedirectContains('/dashboard');

    // Pull the remember-me cookie out of the response
    $cookie = null;
    foreach ($this->_response->getHeader('Set-Cookie') as $header) {
        if (str_starts_with($header, 'CookieAuth=')) {
            $cookie = urldecode(explode(';', substr($header, strlen('CookieAuth=')))[0]);
        }
    }
    $this->assertNotEmpty($cookie);

    // "The session died": drop it, keep only the cookie
    $this->_session = [];
    $_SESSION = [];
    $this->cookie('CookieAuth', $cookie);

    $this->get('/dashboard');
    $this->assertResponseOk();
}
Enter fullscreen mode Exit fullscreen mode

Add a second test with remember_me => '0' that expects a redirect to /login after the session is dropped, so you know the cookie is the only thing letting the user back in.

Two notes on the test:

  • $this->_response->getCookie('remember_me') returns null, because the cookie isn't called remember_me. Read the Set-Cookie headers, or use the configured cookie.name.
  • The test failed before the fix and passed after it. Check that it fails without the fix (git stash), or it isn't proving anything.

The same trap elsewhere

The underlying rule is general: in cakephp/authentication 3.3, an authenticator without identifier uses the identifier of the last authenticator above it that had one. So:

  • If you add or enable an authenticator that identifies users (a custom one, LDAP, JWT…), give it an explicit identifier, even if it "obviously" uses the same one as Form.
  • If you reorder Auth.Authenticators, or remove Token because you don't use API keys, which identifier each one inherits can change without anything telling you.
  • When an authentication "just fails", look at the Result status. FAILURE_IDENTITY_NOT_FOUND with valid credentials points at the wrong identifier, not at a bad password. You can inspect it with $this->Authentication->getResult() in a controller (->getStatus(), ->getErrors()).

The other half: the PHP session itself

Once remember-me works, a short session is only a small annoyance. It's still worth fixing, because it was the reason people noticed the bug in the first place:

  • PHP's default session.gc_maxlifetime is 1440 seconds (24 minutes).
  • On shared hosting, sessions often live in a shared directory where another site's lower gc_maxlifetime, or a system cron, can delete yours early.

In config/app.php:

'Session' => [
    'defaults' => 'cake',   // files in TMP/sessions, not the server-wide dir
    'timeout' => 120,       // minutes of inactivity (sets gc_maxlifetime)
],
Enter fullscreen mode Exit fullscreen mode

'defaults' => 'cake' keeps the session files inside your app, out of the shared directory. Two things to keep in mind:

  • tmp/ usually isn't in git, so create tmp/sessions on deploy, or with a guarded mkdir in config/bootstrap.php. It has to be writable by the web server user.
  • With Deployer or similar, make sure tmp is a shared dir, or every deploy logs everyone out.
  • Moving the session directory invalidates the current sessions once. With remember-me fixed, users who checked the box won't even notice.

Checklist for your own CakePHP apps

  1. composer show cakedc/users cakephp/authentication. Are you on cakephp/authentication 3.3+ with CakeDC's default Authenticators?
  2. debug(Configure::read('Auth.Authenticators')). Does Cookie have an identifier? Which authenticator with an identifier comes before it?
  3. Manual test: sign in with Remember me, delete only the session cookie, reload.
  4. Add the Cookie.identifier block above. Use the same username column the cookie uses and keep the active finder.
  5. Add the regression test, and make sure it fails without the fix.
  6. Optionally, set Session.defaults = 'cake' and a timeout, and make tmp/sessions writable and shared between deploys.

If you know whether this also happens in older cakephp/authentication versions, or whether CakeDC plans to add an identifier to the default Cookie config, let me know in the comments.

Top comments (0)