OAuth Account Password Creation

This allows a user that was created via OAuth to set a local password on their account.
This commit is contained in:
Refringe 2024-09-27 16:51:13 -04:00
parent bba61fa814
commit 46550b5d8f
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
5 changed files with 256 additions and 6 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Profile;
use App\Actions\Fortify\PasswordValidationRules;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm as JetstreamUpdatePasswordForm;
class UpdatePasswordForm extends JetstreamUpdatePasswordForm
{
use PasswordValidationRules;
/**
* Update the user's password.
*
* This method has been overwritten to allow a user that has a null password to set a password for their account
* without needing to provide their current password. This is useful for users that have been created using OAuth.
*/
public function updatePassword(UpdatesUserPasswords $updater): void
{
$this->resetErrorBag();
$user = Auth::user();
if ($user->password !== null) {
parent::updatePassword($updater);
} else {
// User has a null password. Allow them to set a new password without their current password.
Validator::make($this->state, [
'password' => $this->passwordRules(),
])->validateWithBag('updatePassword');
auth()->user()->forceFill([
'password' => Hash::make($this->state['password']),
])->save();
$this->state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
$this->dispatch('saved');
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Livewire\Profile\UpdatePasswordForm;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;
@ -45,6 +47,9 @@ class AppServiceProvider extends ServiceProvider
$this->registerNumberMacros();
$this->registerCarbonMacros();
// Register Livewire component overrides.
$this->registerLivewireOverrides();
// This gate determines who can access the Pulse dashboard.
Gate::define('viewPulse', function (User $user) {
return $user->isAdmin();
@ -100,4 +105,12 @@ class AppServiceProvider extends ServiceProvider
return $date->format('M jS, g:i A');
});
}
/**
* Register Livewire component overrides.
*/
private function registerLivewireOverrides(): void
{
Livewire::component('profile.update-password-form', UpdatePasswordForm::class);
}
}

View File

@ -3,7 +3,9 @@
namespace Database\Factories;
use App\Models\OAuthConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class OAuthConnectionFactory extends Factory
{
@ -15,7 +17,13 @@ class OAuthConnectionFactory extends Factory
public function definition(): array
{
return [
//
'user_id' => User::factory(),
'provider_name' => $this->faker->randomElement(['discord', 'google', 'facebook']),
'provider_id' => (string) $this->faker->unique()->numberBetween(100000, 999999),
'token' => Str::random(40),
'refresh_token' => Str::random(40),
'created_at' => now(),
'updated_at' => now(),
];
}
}

View File

@ -8,11 +8,18 @@
</x-slot>
<x-slot name="form">
<div class="col-span-6 sm:col-span-4">
<x-label for="current_password" value="{{ __('Current Password') }}" />
<x-input id="current_password" type="password" class="mt-1 block w-full" wire:model="state.current_password" autocomplete="current-password" />
<x-input-error for="current_password" class="mt-2" />
</div>
@if (auth()->user()->password === null)
<div class="col-span-6 sm:col-span-4 mt-3 max-w-xl text-sm text-gray-600 dark:text-gray-400">
<p>{{ __('Your account does not have a password set. We recommend setting a password so that you can recover your account if you need to.') }}</p>
</div>
@else
<div class="col-span-6 sm:col-span-4">
<x-label for="current_password" value="{{ __('Current Password') }}" />
<x-input id="current_password" type="password" class="mt-1 block w-full" wire:model="state.current_password" autocomplete="current-password" />
<x-input-error for="current_password" class="mt-2" />
</div>
@endif
<div class="col-span-6 sm:col-span-4">
<x-label for="password" value="{{ __('New Password') }}" />

View File

@ -0,0 +1,172 @@
<?php
use App\Livewire\Profile\UpdatePasswordForm;
use App\Models\User;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
it('creates a new user and attaches the OAuth provider when logging in via OAuth', function () {
// Mock the Socialite user
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('provider-user-id');
$socialiteUser->shouldReceive('getEmail')->andReturn('newuser@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('New User');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getAvatar')->andReturn('avatar-url');
$socialiteUser->token = 'access-token';
$socialiteUser->refreshToken = 'refresh-token';
// Mock Socialite facade
Socialite::shouldReceive('driver->user')->andReturn($socialiteUser);
// Hit the callback route
$response = $this->get('/login/discord/callback');
// Assert that the user was created
$user = User::where('email', 'newuser@example.com')->first();
expect($user)->not->toBeNull()
->and($user->name)->toBe('New User');
// Assert that the OAuth provider was attached
$oAuthConnection = $user->oAuthConnections()->whereProvider('discord')->first();
expect($oAuthConnection)->not->toBeNull()
->and($oAuthConnection->provider_id)->toBe('provider-user-id');
// Assert the user is authenticated
$this->assertAuthenticatedAs($user);
// Assert redirect to dashboard
$response->assertRedirect(route('dashboard'));
});
it('attaches a new OAuth provider to an existing user when logging in via OAuth', function () {
// Create an existing user
$user = User::factory()->create([
'email' => 'existinguser@example.com',
'name' => 'Existing User',
'password' => Hash::make('password123'),
]);
// Mock the Socialite user
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('new-provider-user-id');
$socialiteUser->shouldReceive('getEmail')->andReturn('existinguser@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('Existing User Updated');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getAvatar')->andReturn('new-avatar-url');
$socialiteUser->token = 'new-access-token';
$socialiteUser->refreshToken = 'new-refresh-token';
// Mock Socialite facade
Socialite::shouldReceive('driver->user')->andReturn($socialiteUser);
// Hit the callback route
$response = $this->get('/login/discord/callback');
// Refresh user data
$user->refresh();
// Assert that the username was not updated
expect($user->name)->toBe('Existing User')
->and($user->name)->not->toBe('Existing User Updated');
// Assert that the new OAuth provider was attached
$oauthConnection = $user->oAuthConnections()->whereProvider('discord')->first();
expect($oauthConnection)->not->toBeNull()
->and($oauthConnection->provider_id)->toBe('new-provider-user-id');
// Assert the user is authenticated
$this->assertAuthenticatedAs($user);
// Assert redirect to dashboard
$response->assertRedirect(route('dashboard'));
});
it('hides the current password field when the user has no password', function () {
// Create a user with no password
$user = User::factory()->create([
'password' => null,
]);
$this->actingAs($user);
// Visit the profile page
$response = $this->get('/user/profile');
$response->assertStatus(200);
// Assert that the current password field is not displayed
$response->assertDontSee(__('Current Password'));
});
it('shows the current password field when the user has a password', function () {
// Create a user with a password
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$this->actingAs($user);
// Visit the profile page
$response = $this->get('/user/profile');
$response->assertStatus(200);
// Assert that the current password field is displayed
$response->assertSee(__('Current Password'));
});
it('allows a user without a password to set a new password without entering the current password', function () {
// Create a user with a NULL password
$user = User::factory()->create([
'password' => null,
]);
$this->actingAs($user);
// Test the Livewire component
Livewire::test(UpdatePasswordForm::class)
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasNoErrors();
// Refresh user data
$user->refresh();
// Assert that the password is now set
expect(Hash::check('newpassword123', $user->password))->toBeTrue();
});
it('requires a user with a password to enter the current password to set a new password', function () {
$user = User::factory()->create([
'password' => Hash::make('oldpassword'),
]);
$this->actingAs($user);
// Without current password
Livewire::test(UpdatePasswordForm::class)
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasErrors(['current_password' => 'required']);
// With incorrect current password
Livewire::test(UpdatePasswordForm::class)
->set('state.current_password', 'wrongpassword')
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasErrors(['current_password']);
// With correct current password
Livewire::test(UpdatePasswordForm::class)
->set('state.current_password', 'oldpassword')
->set('state.password', 'newpassword123')
->set('state.password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasNoErrors();
$user->refresh();
expect(Hash::check('newpassword123', $user->password))->toBeTrue();
});