Skip to content

[Security] Lazily load the user during the check passport event #37846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

wouterj
Copy link
Member

@wouterj wouterj commented Aug 16, 2020

Q A
Branch? master
Bug fix? no
New feature? yes
Deprecations? yes
Tickets Fix #37436
License MIT
Doc PR tbd

Before

class ApiKeyAuthenticator extends AbstractAuthenticator
{
    // ...

    public function authenticate(Request $request): PassportInterface
    {
        $email = $request->headers->get('X-USER-EMAIL');
        if (false === strpos($email, '@')) {
            throw new BadCredentialsException('Email is not a valid email address.');
        }

        $user = $this->userRepository->findOneBy(['email' => $email]);
        if (null === $user) {
            throw new UsernameNotFoundException();
        }

        return new SelfValidatingPassport($user);
    }
}

After

class ApiKeyAuthenticator extends AbstractAuthenticator
{
    // ...

    public function authenticate(Request $request): PassportInterface
    {
        $email = $request->headers->get('X-USER-EMAIL');
        if (false === strpos($email, '@')) {
            throw new BadCredentialsException('Email is not a valid email address.');
        }

        // a global ChainUserProvider (or firewall provider if explicitly configured) will be
        // used to load the User with $email as username
        return new SelfValidatingPassport($email);

        // or a custom closure to load the user
        return new SelfValidatingPassport(new UserBadge($email, function ($username) {
            return $this->userRepository->findOneBy(['email' => $username]);
        });
    }
}

Doing it this way has a couple advantages (some of which are already mentioned in the issue):

  • Some listeners on CheckPassportEvent need to execute before loading the user - to reduce resources (e.g. CSRF protection, if CSRF fails, no DB call should be made to load user - and also login throttling);
  • Some listeners require knowing the username of the login action (e.g. login throttling on IP and username);
  • The UserProviderListener allows to remove yet another centralized action in the authentication process from the authenticator class to the Symfony framework.

Automatic User Provider integration

Instead of passing the credentials and a closure to UserBadge, you can also just pass a (string) username. The user provider will then be used to load the user. This only works for custom_authenticators as of this moment.

  • By default, a chain user provider with all configured user_providers will be used as the user provider;
  • However, if you explicitly configure a provider for that firewall, that provider will be used (using a listener with higher priority).

@fabpot
Copy link
Member

fabpot commented Aug 16, 2020

I like this very much.

@wouterj wouterj force-pushed the issue-37436/user-in-checkpassword-event branch 4 times, most recently from 4099e8f to 8220186 Compare August 17, 2020 20:01
@wouterj
Copy link
Member Author

wouterj commented Aug 17, 2020

Thank you @fabpot 😃

This PR is ready for a review now, I've added some functional tests to make sure things are working.

There are 2 future considerations here:

  1. Should we also rely on the UserProviderListener in the core authenticators? (removing their provider option and dependency on UserProviderInterface)?
  2. User providers are also often used to dynamically add the PasswordUpgradeBadge. Should we move this logic to the UserProviderListener instead?
    if ($this->userProvider instanceof PasswordUpgraderInterface) {
    $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider));
    }

@wouterj wouterj marked this pull request as ready for review August 17, 2020 20:04
@fabpot
Copy link
Member

fabpot commented Aug 18, 2020

I think you 2 additional changes you mentioned are worth investigating.

Copy link
Member

@fabpot fabpot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made a small suggestion, not sure if that makes sense. What do you think about it?

private $userLoader;
private $user;

public function __construct(?string $username, ?callable $userLoader = null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably document both of these, because:

  1. We need to communicate that $username is not necessarily a username... but just some "identifier". We probably need to communicate its purpose. For example, suppose I have a login form that has an email field AND a company drop-down. So, when I query the database for the user, the query is something like:

SELECT * FROM user WHERE email = :email AND company_id = :companyId

Obviously, I would probably make the query and load the user by passing a 2nd argument to new UserBadge() with a callback that makes the custom query.

The question is: what value should I pass as the $username? Should it just be $email? Probably not. Should it be ['email' => $email, 'companyId' => $companyId]? Well, right now, the first argument must be a string... and probably (?) we want to keep it that way. So, should the user pass $email'._'.$companyId... because the purpose of this argument is to be some "unique string identifier" of the user?

  1. We should document that the 2nd arg is optional, must return UserInterface or null and will be passed the $username.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe $username should be renamed.

Copy link
Member Author

@wouterj wouterj Aug 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some PHPdocs to this method (a bit too much to my liking, happy to accept more brief suggestions).

I've also renamed $username to $userIdentifier, please let me known if that doesn't make sense :)

->args([
service('security.user_providers'),
])
->tag('kernel.event_listener', ['event' => CheckPassportEvent::class, 'priority' => 1024, 'method' => 'checkPassport'])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this first service used? I may just be missing it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the idea is:

  • Always have this service, which chains all configured user providers and runs at 1024
  • If a firewall has provider set, register the same listener for that specific firewall with only the configured firewall at priority 2048

The listener does not configure the user provider if a user loader is already set (e.g. by the authenticator using a closure or by the firewall specific listener).

return;
}

$badge->setUserLoader([$this->userProvider, 'loadUserByUsername']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nice implementation

@wouterj wouterj force-pushed the issue-37436/user-in-checkpassword-event branch 4 times, most recently from 5154f9b to 42c1a7e Compare August 22, 2020 15:56
Copy link
Member

@chalasr chalasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one, 1 minor comment. Should we add a CHANGELOG entry?

@fabpot fabpot added this to the next milestone Aug 23, 2020
@wouterj wouterj force-pushed the issue-37436/user-in-checkpassword-event branch from 42c1a7e to e61adff Compare August 26, 2020 08:22
@fabpot fabpot force-pushed the issue-37436/user-in-checkpassword-event branch from e61adff to 907ef31 Compare August 27, 2020 14:29
@fabpot
Copy link
Member

fabpot commented Aug 27, 2020

Thank you @wouterj.

@fabpot fabpot merged commit 2248639 into symfony:master Aug 27, 2020
@wouterj wouterj deleted the issue-37436/user-in-checkpassword-event branch August 27, 2020 19:20
@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
@fabpot fabpot mentioned this pull request Oct 5, 2020
chalasr added a commit that referenced this pull request Nov 18, 2020
…ith the new UserBadge (wouterj)

This PR was merged into the 5.2 branch.

Discussion
----------

[Security] Update password upgrader listener to work with the new UserBadge

| Q             | A
| ------------- | ---
| Branch?       | 5.2
| Bug fix?      | yes
| New feature?  | yes
| Deprecations? | no
| Tickets       | -
| License       | MIT
| Doc PR        | -

While working on a new amazing `make:auth` maker, @jrushlow discovered that we forgot to update the `PasswordUpgradeBadge` with the 5.2 `UserBadge` changes (ref #37846).

This PR fixes it, by making the password upgrader optional and falling back to the user provider instead. Without these changes, each authenticator still needs to know the user repository/user provider in order to pass it to `PasswordUpgradeBadge`.

I'm sorry for catching this soo late in the release cycle. There is a BC break involved here, but it's (a) very unlikely to impact application code and (b) in an experimental class.

Commits
-------

e39e844 Default to user provider, if available, in password upgrader
fabpot added a commit that referenced this pull request Mar 17, 2021
…rInterface implementation to Passport (wouterj)

This PR was merged into the 5.3-dev branch.

Discussion
----------

[Security] Remove deprecated support for passing a UserInterface implementation to Passport

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | no
| Deprecations? | no
| Tickets       | -
| License       | MIT
| Doc PR        | -

In #37846 (review) , we agreed to have a deprecation path of only one minor release as the `Passport` feature is still experimental.

Commits
-------

99cf2a3 [Security] Disallow passing a UserInterface to Passport
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[RFC][Security] Load user during CheckPassportEvent
7 participants