Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
TRACK

Dev Tools >

User Login with OAuth

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

Now that it's possible for users to authorize TopCluck to count their COOP eggs, Brent's on his way to showing farmer Scott just whose eggs rule the roost.

Feeling fancy, he wants to make life even easier by letting users skip registration and just login via COOP. Afterall, every farmer who uses the site will already have a COOP account.

Since we've done all the authorization code work already, adding "Login with COOP" or "Login with Facebook" buttons is really easy.

Creating New TopCluck Users

Start back in CoopOAuthController.php, where we handled the exchange of the authorization code for the access token. Right now, this assumes that the user is already logged in and updates their account with the COOP details:

// src/OAuth2Demo/Client/Controllers/CoopOAuthController.php
// ...
public function receiveAuthorizationCode(Application $app, Request $request)
{
    // ...
    $meData = json_decode($response->getBody(), true);

    $user = $this->getLoggedInUser();
    $user->coopAccessToken = $accessToken;
    $user->coopUserId = $meData['id'];
    $this->saveUser($user);
    // ...
}

But instead, let's actively allow anonymous users to go through the authorization process. And when they do, let's create a new user in our database:

public function receiveAuthorizationCode(Application $app, Request $request)
{
    // ...

    $meData = json_decode($response->getBody(), true);

    if ($this->isUserLoggedIn()) {
        $user = $this->getLoggedInUser();
    } else {
        $user = $this->createUser(
            $meData['email'],
            // a blank password - this user hasn't created a password yet!
            '',
            $meData['firstName'],
            $meData['lastName']
        );
    }
    $user->coopAccessToken = $accessToken;
    $user->coopUserId = $meData['id'];
    $user->coopAccessExpiresAt = $expiresAt;
    $this->saveUser($user);
    // ...
}

Some of these functions are specific to my app, but it's simple: if the user isn't logged in, create and insert a new user record using the data from the /api/me endpoint.

Choosing a Password

Notice I'm giving the new user a blank password. Does that mean someone could login as the user by entering a blank password? That would be a huge security hole!

The problem is that the user isn't choosing a password. In fact, they're opt'ing to not have one and to use their COOP account instead. So one way or another, it should not be possible to login to this account using any password. Normally, my passwords are encoded before being saved, like all passwords should be. You can't see it here, but when the password is set to a blank string, I'm skipping the encoding process and actually setting the password in the database to be blank. If someone does try to login using a blank password, it'll be encoded first and won't match what's in the database.

As long as you find some way to prevent anyone from logging in as the user via a password, you're in good shape! You could also have the user choose a password right now or have an area to do that in their profile. I'll mention the first approach in a second.

Finally, let's log the user into this new account:

public function receiveAuthorizationCode(Application $app, Request $request)
{
    // ...

    if ($this->isUserLoggedIn()) {
        $user = $this->getLoggedInUser();
    } else {
        $user = $this->createUser(
            $meData['email'],
            // a blank password - this user hasn't created a password yet!
            '',
            $meData['firstName'],
            $meData['lastName']
        );

        $this->loginUser($user);
    }

    // ...
}

We still need to handle a few edge-cases, but this creates the user, logs them in, and then still updates them with the COOP details.

Let's try it out! Log out and then head over to the login page. Here, we'll add a "Login with COOP" link. The template that renders this page is at views/user/login.twig:

{# views/user/login.twig #}

<div class="form-group">
    <div class="col-lg-10 col-lg-offset-2">
        <button type="submit" class="btn btn-primary">Login!</button>
        OR
        <a href="{{ path('coop_authorize_start') }}"
            class="btn btn-default">Login with COOP</a>
    </div>
</div>

The URL for the link is the same as the "Authorize" button on the homepage. If you're already logged in, we'll just update your account. But if you're not, we'll create a new account and log you in. It's that simple!

Let's also completely reset the database, which you can do just by deleting the data/topcluck.sqlite file inside the client/ directory:

$ rm data/topcluck.sqlite

When we try it out, we're redirected to COOP, sent back to TopCluck, and are suddenly logged in. If we look at our user details, we can see we're logged in as Brent, with COOP User ID 2.

Handling Existing Users

There's one big hole in our logic. If I logout and go through the process again, it blows up! This time, it tries to create a second new user for Brent instead of using the one from before. Let's fix that. For organization, I'm going to create a new private function called findOrCreateUser() in this same class. If we can find a user with this COOP User ID, then we can just log the user into that account. If not, we'll keep creating a new one:

public function receiveAuthorizationCode(Application $app, Request $request)
{
    // ...

    if ($this->isUserLoggedIn()) {
        $user = $this->getLoggedInUser();
    } else {
        $user = $this->findOrCreateUser($meData);

        $this->loginUser($user);
    }

    // ...
}

private function findOrCreateUser(array $meData)
{
    if ($user = $this->findUserByCOOPId($meData['id'])) {
        // this is an existing user. Yay!
        return $user;
    }

    $user = $this->createUser(
        $meData['email'],
        // a blank password - this user hasn't created a password yet!
        '',
        $meData['firstName'],
        $meData['lastName']
    );

    return $user;
}

Try the process again. No error this time - we find the existing user and use it instead of creating a new one.

Duplicate Emails

There is one other edge-case. What if we don't find any users with this COOP user id, but there is already a user with this email? This might be because the user registered on TopCluck, but hasn't gone through the COOP authorization process.

Pretty easily, we can do another lookup by email:

private function findOrCreateUser(array $meData)
{
    if ($user = $this->findUserByCOOPId($meData['id'])) {
        // this is an existing user. Yay!
        return $user;
    }

    if ($user = $this->findUserByEmail($meData['email'])) {
        // we match by email
        // we have to think if we should trust this. Is it possible to
        // register at COOP with someone else's email?
        return $user;
    }

    $user = $this->createUser(
        $meData['email'],
        // a blank password - this user hasn't created a password yet!
        '',
        $meData['firstName'],
        $meData['lastName']
    );

    return $user;
}

Cool. But be careful. Is it easy to fake someone else's email address on COOP? If so, I could register with someone else's email there and then use this to login to that user's TopCluck account. With something other than COOP's own user id, you need to think about whether or not it's possible that you're getting falsified information. If you're not sure, it might be safe to break the process here and force the user to type in their TopCluck password for this account before linking them. That's a bit more work, but we do it here on KnpUniversity.com.

Finishing Registration

When you do have a new user, instead of just creating the account, you may want to show them a finish registration form. This would let them choose a password and fill out any other fields you want.

We've got more OAuth-focused things that we need to get to, so we'll leave this to you. But the key is simple: store at least the coopAccessToken, coopUserId and token expiration in the session and redirect to a registration form with fields like email, password and anything else you need. You could also store the email in the session and use it to prepopulate the form, or even make another API request to /api/me to get it. When they finally submit a valid form, just create your user then. It's really just like any registration form, except that you'll also save the COOP access token, user id, and expiration when you create your user.

Leave a comment!

10
Login or Register to join the conversation

Coming from the basic PHP track , this feels like a big jump. There should be an intermediate course in PHP.

Reply

Hey @Akshit!

Ah, yes, this IS a big jump - you're right. And this is kind of a specialized topic - you either want to learn specifically about OAuth, or you don't.

After the basic PHP track, I would recommend jumping into our object-oriented track :) https://symfonycasts.com/tr...

Also, how did you go from the basic PHP track to this tutorial? I wouldn't recommend this jump - and I want to make sure that we're not accidentally linking from the basic PHP track to this tutorial.

Thanks!

Reply
Francois Avatar
Francois Avatar Francois | posted 1 year ago

Hi!
I never use Oauth to login, and am very confused with the UX logic here.
Is it really the regular way to do : when the user doesn't exist, we just create an account without telling him?
Or is it just an example and in real life login and registration are separate?

More generally, the "connect" feature seems to me very different than the "API access on behalfs" feature. It's actually so much mixed and the same thing?

Thanks !!!

Reply

Hey Francois,

Well, not exactly. When a new user (that wasn't registered on your website) is trying to login via OAuth - usually you may want to open kind of registration page, but prefill it with the data you get from the OAuth provider, e.g. email, name, etc. (usually we do not show password field for them as it's redundant, they already chose OAuth way that replaces password) so that users may edit this information if they needed and then complete registration. Only then you will create a new account for the user and link it to their OAuth provider.

But when the same user returns and trying to login again - this time you will already have their OAuth id in the DB, and you just log them in.

The workflow should be something like this.

About the connect feature - yes, it's a bit different. To connect OAuth provider, the user should be already logged in, and when they approve to connect their OAuth account - you just recored the OAuth ID on the current user, and so, when this user will login later via OAuth - you system will already has their OAuth ID in the DB and just log the related account in.

I hope this helps!

Cheers!

Reply
Francois Avatar

Hi Victor!

Thanks for these explanations!

And then, about what you wrote : "But when the same user returns and trying to login again - this time you will already have their OAuth id in the DB, and you just log them in."
Here, how do you check that it's really him who try to login? I'm missing a

piece I think

Thanks

Reply

Hey Francois,

Good question! Well, every time a user click on OAuth social button on your website - you're sending an API request to the OAuth provider, right? And so, the provider is sending back all the info about the users, like his OAuth ID, if the user approved the oauth authorization. That's how you know which user is trying to login via OAuth, you have his ID, and so you can search by the OAuth id in your DB and find the proper user that should be authenticated on your website.

I'd recommend you to look at https://github.com/knpunive... if you're interested in implementing OAuth in a Symfony application :)

Cheers!

Reply
Francois Avatar

Thanks!
Understood :)

Small feedback : it would be very good on the main presentation page of each courses to have the date of publication. There's no real way to know it otherwise, and so you don't really know what you sign for.
And 2nd feedback: It would be cool that you mention this oauth2-client-bundle in the course. Maybe it could be added in the main textual presentation (if you don't want to edit video)? I guess a lot of student who take the course today miss it

Reply

Hey Francois,

Great! :)

> Small feedback : it would be very good on the main presentation page of each courses to have the date of publication. There's no real way to know it otherwise, and so you don't really know what you sign for.

Thank you for leaving this little feedback! Yeah, I see your point, though we did it on purpose. This way we want to say that publication date isn't that much important and this content is still relevant. The more important to know which version are used in the course, and that's why we implemented a nice feature where you can click on main dependency name, e.g. on "Symfony 5.0" button on this course: https://symfonycasts.com/sc... - we will show the composer.json content with the exact versions installed. Unfortunately, this OAuth tutorial contains an old file structure, and this does not support this feature, but almost all our other tutorials support it. I hope this is helpful for you.

> And 2nd feedback: It would be cool that you mention this oauth2-client-bundle in the course. Maybe it could be added in the main textual presentation (if you don't want to edit video)? I guess a lot of student who take the course today miss it

Agree, I just double-checked and there's no links to that bundle on this tutorial, we only mention it in comments. So, we will think about a good place where to put a note linking to that bundle, thanks! :)

Cheers!

Reply
Default user avatar
Default user avatar Dung's Kcl | posted 5 years ago

// src/OAuth2Demo/Client/Controllers/CoopOAuthController.php
// ...
public function receiveAuthorizationCode(Application $app, Request $request)
{
// ...
$meData = json_decode($response->getBody(), true);

$user = $this->getLoggedInUser();
$user->coopAccessToken = $accessToken;
$user->coopUserId = $meData['id'];
$this->saveUser($user);
// ...

Reply

Hey Dung,

Could you explain what you're trying to say us a bit?

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

userVoice