joelbutcher / socialstream

OAuth for Laravel, simplified.
https://docs.socialstream.dev
MIT License
433 stars 66 forks source link

Upgrade from 5.x to 6.x breaks login #370

Closed selfsimilar closed 2 months ago

selfsimilar commented 2 months ago

Stack

Jetstream – Livewire

Package Version

v6.1.5

Laravel Version

v11

Livewire Version

v3.5.6

react Version

No response

Vue Version

No response

PHP Version

PHP 8.3

Problem description

After upgrading SocialStream from v5 to v6, SSO login (specifically Google) now redirects from the "Choose an account" page to the Google account page (myaccount.google.com) instead of back to the application. I don't see where in the upgrade guide that a setting needs to be changed, but I would guess that this is a configuration issue. I see in the log files that a request IS being made to the application after choosing the account, but the result is incorrect.

Expected behavior

After choosing your Google account, you should be redirected back to the application.

Steps to reproduce

This may not be reproducible without more knowledge of config settings.

Reproduction repository

https://github.com/no-example-yet

Relevant log output

No response

joelbutcher commented 2 months ago

If Google is making a request to the application that makes me think the OAuth flow is either successful and there's a redirect happening elsewhere in your application, or that the initial request and callback differs in state verification (try making the socialite requests "stateless" and try setting the access_type option to offline).

If neither of these work, I will need you to link to a valid reproduction repository, otherwise I don't have the time spare to investigate this, especially as I have two apps currently in production using Socialtream version 6.x with Google that work absolutely fine.

selfsimilar commented 2 months ago

I had another big wall of text with more diagnostic information, but I realize I should mention this is part of upgrading Laravel from 10.x to 11.x, and maybe that is a confounding issue?

selfsimilar commented 2 months ago

Wall 'o Text:

The Google SSO was already set to be "stateless", but I did try it with the default "stateful"(?) resolver and it also failed. What's interesting is that after a "successful" SSO login if I just use the browser back button to the login screen, I'll encounter a "wrong password or username" error which is the "failed" auth text. But I don't see that if I just straight navigate to my login page from the Google profile page that I'm redirected to.

I also added some logging to my custom ResolveSocialiteUser class that looks like this:

<?php

namespace App\Actions\Socialstream;

use Illuminate\Support\Facades\Log;
use JoelButcher\Socialstream\Contracts\ResolvesSocialiteUsers;
use JoelButcher\Socialstream\Socialstream;
use Laravel\Socialite\Contracts\User;
use Laravel\Socialite\Facades\Socialite;

class ResolveSocialiteUser implements ResolvesSocialiteUsers
{
    /**
     * Resolve the user for a given provider.
     */
    public function resolve(string $provider): User
    {
        try {
            $socialiteDriver = Socialite::driver($provider);
            $user = match ($provider) {
                Providers::google() => $socialiteDriver->stateless()->user(),
                default => $socialiteDriver->user(),
            };
            Log::debug("SSO found user", ['uid' => $user->id, 'name' => $user->name, 'email' => $user->email]);
        } catch (Exception $e) {
            Log::error('SSO Fail', $e);
            session()->flash('flash.banner', __('Unable to log you in. Please try again.'));
            return redirect('/login');
        }

        if (Socialstream::generatesMissingEmails()) {
            $user->email = $user->getEmail() ?? ("{$user->id}@{$provider}".config('app.domain'));
        }

        return $user;
    }
}

The logs then show:

{
    "uid": "01234567890123456789",
    "name": "SSO User",
    "email": "example@gmail.com"
}

where the $user->id corresponds to the provider_id in the connected_accounts table, not the user_id column. This is probably right, and it definitely seems like Socialstream/Socialite is able to retrieve the right user from the SSO. So a lot of this is still working.

selfsimilar commented 2 months ago

Also, @joelbutcher you say "try setting the access_type option to offline" but I'm confused. I don't see access_type referenced anywhere in the Socialstream or Socialite codebase when I grep my vendor directory. Is this a setting I should be looking for in the Google SSO settings in GCP?

joelbutcher commented 2 months ago

@selfsimilar yes, this is a Google OAuth parameter, nothing to do with Laravel or Socialstream

majweb commented 2 months ago

I can't log in from any provider. What's going on?

[2024-09-06 14:37:34] local.ERROR: {"exception":"[object] (Laravel\Socialite\Two\InvalidStateException(code: 0): at /var/www/html/vendor/laravel/socialite/src/Two/AbstractProvider.php:237) [stacktrace]

0 /var/www/html/app/Actions/Socialstream/ResolveSocialiteUser.php(23): Laravel\Socialite\Two\AbstractProvider->user()

1 /var/www/html/vendor/joelbutcher/socialstream/src/Http/Controllers/OAuthController.php(55): App\Actions\Socialstream\ResolveSocialiteUser->resolve()........

chrome_4I59yv1IpB

joelbutcher commented 2 months ago

@majweb the hint is in the name of the exception thrown by Laravel Socialite:

The state that's used to verify the callback request is for the same session that requested the redirect to the OAuth provider. Call ->stateless() in the generate redirect request in the resolve socialite user classes

majweb commented 2 months ago

where exactly? Because here I have ->stateless() on the screen Providers::google.....

If I add $socialiteDriver->stateless()->user() still not working

If it doesn't throw this error, it goes through but doesn't log in the user. The first time it saves to the connected table with this error anyway. If it already exists in the table, it will not log in the next time. These are conclusions from observations.

majweb commented 2 months ago

Fresh installation. Laravel 11 with the current Social Stream package. (Inertiajs Vuejs) Well configured. After selecting the provider, it does not log in the user and does not redirect to the panel.

joelbutcher commented 2 months ago

@majweb I don't get this problem on a fresh install here:

joelbutcher/socialstream-demo

https://demo.socialstream.dev

majweb commented 2 months ago

Can you share the code on github? After logging in, you get 403 but it's probably from a non-existent route.

What could be the reason why it doesn't log me in as a user? How is it that the same fresh applications don't work the same? Maybe something in config/session.php. The best thing is that everything worked before.

majweb commented 2 months ago

https://github.com/user-attachments/assets/286fe80c-f2b7-46b0-9fdf-d2cf61d6cddd

Look

joelbutcher commented 2 months ago

@majweb I linked the repo on my previous comment

joelbutcher commented 2 months ago

Looks like the issue I have is because filament is also installed

majweb commented 2 months ago

chrome_hPPnAi4NSU

Repo not found

joelbutcher commented 2 months ago

Ah, didn't realise it was private. Have made it public

majweb commented 2 months ago

Please look abctest.pl Select login and account type as worker (then socials appear) and try to log in via github. Please log in twice because the first time it works, the second time it doesn't.

repo:https://github.com/majweb/work

joelbutcher commented 2 months ago

You're using v6.1.5. Update your composer deps to use 6.1.6 minimum version

majweb commented 2 months ago

Unfortunately, the problem persists. I have version 6.1.6 and it still does not log me in. What else could be the problem?

public function resolve(string $provider): User
    {
        try {
            $socialiteDriver = Socialite::driver($provider);
            $user = match ($provider) {
                Providers::google() => $socialiteDriver->stateless()->user(),
                Providers::linkedinOpenId() => $socialiteDriver->stateless()->user(),
                default => $socialiteDriver->stateless()->user(),
            };
            Log::debug("SSO found user", ['uid' => $user->id, 'name' => $user->name, 'email' => $user->email]);
        } catch (Exception $e) {
            Log::error('SSO Fail', $e);
            session()->flash('flash.banner', __('Unable to log you in. Please try again.'));
            return redirect('/login');
        }

[2024-09-17 06:21:18] production.DEBUG: SSO found user {"uid":22354155,"name":"xxxx xxxxx","email":"xxx@xxx.pl"}

The user is return why he is not logging in?

joelbutcher commented 2 months ago

@majweb if that code is from your project, you're not returning the user from that method inside the try block

joelbutcher commented 2 months ago

@majweb I also can't use that repo as an appropriate reproduction repo to investigate this further, as I don't have a license for spatie's media library

majweb commented 2 months ago

Screenshot_20240917_205237_Chrome

Bottom I return user.

You don't need install media library

joelbutcher commented 2 months ago

You don't need install media library

Not entirely true, some of your form requests use it and your JS-deps require it as well, build fails without it.

joelbutcher commented 2 months ago

I can't replicate this on a fresh Laravel install, and I don't have the time to debug your specific use case using the linked reproduction repo.

Please either update your repo so that I can install all deps without being blocked by a paywall or remove these deps from the project so I can install and build your app to provide further assistance.

Until I can easily build and run your application locally without errors due to paid packages, I'm closing this issue.

majweb commented 2 months ago

ok, I'll take care of it

majweb commented 2 months ago

https://github.com/majweb/work repo is ok

joelbutcher commented 2 months ago

@majweb Getting this

error during build:
[vite]: Rollup failed to resolve import "@spatie/media-library-pro-vue3-attachment" from "/Users/joel/Projects/majweb/work/resources/js/Pages/Buy/Article/Create.vue".
This is most likely unintended because it can break your application at runtime.
If you do want to externalize this module explicitly add it to
`build.rollupOptions.external`
majweb commented 2 months ago

Ok i Fix imports. Now must be good.

joelbutcher commented 2 months ago

@majweb the issue is you're setting a password for the user when they register. Socialstream registers a custom user provider for Fortify to validateCredentials which verifies that the password is null and will attempt to authenticate the user if the given provider account matches the user that has been found in the database with the same email. Socialstream requires the users password to be NULL

In CreateUserFromProvider remove line 38 and this should work

majweb commented 2 months ago

Unfortunately, this problem still exists. When logging in for the first time, there is no problem, when I want to log in again, I don't log in again. Please try to log in twice.

joelbutcher commented 2 months ago

@majweb this issue is with using Fortify::authenticateUsing() callback. It's not very well documented (which I'll fix).

I suggest changing this code: https://github.com/majweb/work/blob/1f176e67ae7595b46ce86197fba88e99091b142a/app/Providers/FortifyServiceProvider.php#L34-L43

to something like:

Fortify::authenticateUsing(function (Request $request) {
    $user = null;
    $provider = $request->route('provider');

    // 1a. Attempt the resolve the user via socialstream
    if ($provider) {
        $socialUser = app(ResolvesSocialiteUsers::class)
            ->resolve($provider);

        $connectedAccount = Socialstream::$connectedAccountModel::where('email', $socialUser->getEmail())->first();

        if (! $connectedAccount) {
            throw ValidationException::withMessages([
                Fortify::username() => [__('auth.failed')],
            ]);
        }

        $user = $connectedAccount->user;
    }

    // 1b. Attempt to resolve the user if email present in request (i.e. from login form).
    if (! $user && $request->has('email') {
        $user = User::where('email', $request->email)->first();
    }

    // 2. Check if the resolved user is blocked and handle
    if ($user->blockedByAdmin()) {
        throw ValidationException::withMessages([
            Fortify::username() => [__('auth.blocked')],
        ]);
    }

    // 3. User is not blocked, log in if from Socialstream route
    if ($provider) {
        return $user;
    }

    // 4. User hasn't set a password, so must login using an OAuth provider
    if (is_null($user->password) {
        throw ValidationException::withMessages([
            Fortify::username() => [__('auth.failed')],
        ]);
    }

    // 5. Verify the password if the user has logged in via a form
    return Hash::check($request->password, $user->password) ? $user : null;
});
joelbutcher commented 2 months ago

Dev guide and explanation here

majweb commented 2 months ago

Bad link to guide

joelbutcher commented 2 months ago

@majweb https://docs.socialstream.dev/guides/overriding-fortifys-authentication

majweb commented 2 months ago

Ok according to the instructions. I created a folder and class app/Auth/SocialstreamUserProvider

<?php
declare(strict_types=1);

namespace JoelButcher\Socialstream\Auth;

use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;

class SocialstreamUserProvider extends EloquentUserProvider
{
    public function validateCredentials(UserContract $user, #[\SensitiveParameter] array $credentials): bool
    {
        if (is_null($user->getAuthPassword())) {
            return false;
        }

        return parent::validateCredentials($user, $credentials);
    }
}

I registered at Providers/SocialstreamServiceProvider.php

<?php

namespace App\Providers;

use App\Actions\Socialstream\CreateConnectedAccount;
use App\Actions\Socialstream\CreateUserFromProvider;
use App\Actions\Socialstream\GenerateRedirectForProvider;
use App\Actions\Socialstream\HandleInvalidState;
use App\Actions\Socialstream\ResolveSocialiteUser;
use App\Actions\Socialstream\UpdateConnectedAccount;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use JoelButcher\Socialstream\Auth\SocialstreamUserProvider;
use JoelButcher\Socialstream\Concerns\ConfirmsFilament;
use JoelButcher\Socialstream\Socialstream;
use Laravel\Fortify\Fortify;

class SocialstreamServiceProvider extends ServiceProvider
{
    use ConfirmsFilament;

    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureAuth();

        Socialstream::resolvesSocialiteUsersUsing(ResolveSocialiteUser::class);
        Socialstream::createUsersFromProviderUsing(CreateUserFromProvider::class);
        Socialstream::createConnectedAccountsUsing(CreateConnectedAccount::class);
        Socialstream::updateConnectedAccountsUsing(UpdateConnectedAccount::class);
        Socialstream::handlesInvalidStateUsing(HandleInvalidState::class);
        Socialstream::generatesProvidersRedirectsUsing(GenerateRedirectForProvider::class);
    }

    private function configureAuth(): void
    {
        Auth::provider('eloquent', fn ($app, array $config) => new SocialstreamUserProvider(
            hasher: $app['hash'],
            model: $config['model']
        ));
    }

}

And I changed the auth pipeline

Fortify::authenticateUsing(function (Request $request) {
    $user = null;
    $provider = $request->route('provider');

    // 1a. Attempt the resolve the user via socialstream
    if ($provider) {
        $socialUser = app(ResolvesSocialiteUsers::class)
            ->resolve($provider);

        $connectedAccount = Socialstream::$connectedAccountModel::where('email', $socialUser->getEmail())->first();

        if (! $connectedAccount) {
            throw ValidationException::withMessages([
                Fortify::username() => [__('auth.failed')],
            ]);
        }

        $user = $connectedAccount->user;
    }

    // 1b. Attempt to resolve the user if email present in request (i.e. from login form).
    if (! $user && $request->has('email')) {
            $user = User::where('email', $request->email)->first();
    }

    // 2. Check if the resolved user is blocked and handle
    if ($user->blockedByAdmin()) {
        throw ValidationException::withMessages([
            Fortify::username() => [__('auth.blocked')],
        ]);
    }

    // 3. User is not blocked, log in if from Socialstream route
    if ($provider) {
        return $user;
    }

    // 4. User hasn't set a password, so must login using an OAuth provider
    if (is_null($user->password)) {
        throw ValidationException::withMessages([
            Fortify::username() => [__('auth.failed')],
        ]);
    }

    // 5. Verify the password if the user has logged in via a form
    return Hash::check($request->password, $user->password) ? $user : null;
});

Are these all the steps?

majweb commented 1 month ago

?

joelbutcher commented 1 month ago

@majweb does this work for you? Are you on the latest version of 6.x? If this works then yes. If not, lemme know and I'll try to carve out some time next week.

majweb commented 1 month ago

It works, but I have a question whether I implemented it correctly.

joelbutcher commented 1 month ago

To me it looks like you have done exactly what's needed to me 🙂

majweb commented 1 week ago

I found bug look topic Overriding Fortify's Authentication custom LoginRequest #385