diff --git a/.env.example b/.env.example index 1bd0d7fb..10bdd050 100644 --- a/.env.example +++ b/.env.example @@ -60,4 +60,7 @@ PAYSTACK_PUBLIC_KEY= PAYPAL_CLIENT_ID= PAYPAL_CLIENT_SECRET= -SANCTUM_STATEFUL_DOMAINS=localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,::1 \ No newline at end of file +SANCTUM_STATEFUL_DOMAINS=localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,::1 + +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= \ No newline at end of file diff --git a/composer.json b/composer.json index 65b74c05..ebb2329e 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "laravel/fortify": "^1.8.3", "laravel/framework": "^8.77.1", "laravel/sanctum": "^2.12.1", + "laravel/socialite": "^5.2", "laravel/tinker": "^2.6.2", "laravel/ui": "^3.3.1", "league/flysystem-aws-s3-v3": "^1.0.29", diff --git a/config/app.php b/config/app.php index 099b59ff..59a08217 100644 --- a/config/app.php +++ b/config/app.php @@ -161,6 +161,7 @@ return [ Illuminate\View\ViewServiceProvider::class, TeamTNT\Scout\TNTSearchScoutServiceProvider::class, + Laravel\Socialite\SocialiteServiceProvider::class, Intervention\Image\ImageServiceProvider::class, App\Providers\FortifyServiceProvider::class, @@ -228,6 +229,7 @@ return [ 'Image' => Intervention\Image\Facades\Image::class, 'Stripe' => Cartalyst\Stripe\Laravel\Facades\Stripe::class, 'Crawler' => Jaybizzle\LaravelCrawlerDetect\Facades\LaravelCrawlerDetect::class, + 'Socialite' => Laravel\Socialite\Facades\Socialite::class, ], 'deploy_secret' => env('APP_DEPLOY_SECRET'), diff --git a/config/services.php b/config/services.php index effb5d55..ae34d8cd 100644 --- a/config/services.php +++ b/config/services.php @@ -33,4 +33,22 @@ return [ 'client_id' => env('PASSPORT_CLIENT_ID'), 'client_secret' => env('PASSPORT_CLIENT_SECRET'), ], + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('APP_URL') . '/socialite/google/callback' + ], + + 'github' => [ + 'client_id' => env('GITHUB_CLIENT_ID'), + 'client_secret' => env('GITHUB_CLIENT_SECRET'), + 'redirect' => env('APP_URL') . '/socialite/github/callback', + ], + + 'facebook' => [ + 'client_id' => env('FACEBOOK_CLIENT_ID'), + 'client_secret' => env('FACEBOOK_CLIENT_SECRET'), + 'redirect' => env('APP_URL') . '/socialite/facebook/callback', + ], ]; diff --git a/database/migrations/2021_12_20_120702_add_oauth_provider_to_users_table.php b/database/migrations/2021_12_20_120702_add_oauth_provider_to_users_table.php new file mode 100644 index 00000000..ed6ae3c2 --- /dev/null +++ b/database/migrations/2021_12_20_120702_add_oauth_provider_to_users_table.php @@ -0,0 +1,36 @@ +string('oauth_provider')->nullable(); + $table->string('password')->nullable()->change(); + + $table->charset = 'utf8mb4'; + $table->collation = 'utf8mb4_unicode_ci'; + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + // + }); + } +} diff --git a/resources/js/components/Auth/SocialiteAuthenticationButtons.vue b/resources/js/components/Auth/SocialiteAuthenticationButtons.vue new file mode 100644 index 00000000..6b89fd77 --- /dev/null +++ b/resources/js/components/Auth/SocialiteAuthenticationButtons.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/resources/js/routes/routesAuth.js b/resources/js/routes/routesAuth.js index 022a3a6b..43fbfa9b 100644 --- a/resources/js/routes/routesAuth.js +++ b/resources/js/routes/routesAuth.js @@ -1,4 +1,13 @@ const routesAuth = [ + { + name: 'SocialiteCallback', + path: '/socialite/:provider/callback', + component: () => + import(/* webpackChunkName: "chunks/email-verified" */ '../views/Auth/SocialiteCallback'), + meta: { + requiresAuth: false + }, + }, { name: 'SuccessfullyVerified', path: '/successfully-verified', diff --git a/resources/js/store/modules/userAuth.js b/resources/js/store/modules/userAuth.js index 8d7ef4a4..0a0ee2c7 100644 --- a/resources/js/store/modules/userAuth.js +++ b/resources/js/store/modules/userAuth.js @@ -48,6 +48,17 @@ const actions = { router.push({name: 'Homepage'}) }) }, + socialiteRedirect: ({commit}, provider) => { + + axios + .get(`/api/socialite/${provider}/redirect`) + .then((response) => { + if(response.data.url) { + window.location.href = response.data.url + } + }) + .catch(() => this.$isSomethingWrong()) + }, addToFavourites: (context, folder) => { let addFavourites = [] let items = [folder] diff --git a/resources/js/views/Auth/SignIn.vue b/resources/js/views/Auth/SignIn.vue index 3ed819c1..2a1f85d4 100644 --- a/resources/js/views/Auth/SignIn.vue +++ b/resources/js/views/Auth/SignIn.vue @@ -22,6 +22,8 @@ :disabled="isLoading" /> + + {{ $t('page_login.registration_text') }} @@ -151,6 +153,7 @@ \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index ec9f063d..e07d0fbd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,6 @@ 'socialite'], function () { + Route::get('/{provider}/redirect', SocialiteRedirectController::class); + Route::get('/{provider}/callback', SocialiteCallbackController::class); +}); // Password reset Route::group(['prefix' => 'password'], function () { diff --git a/src/App/Socialite/Controllers/SocialiteCallbackController.php b/src/App/Socialite/Controllers/SocialiteCallbackController.php new file mode 100644 index 00000000..8f35996f --- /dev/null +++ b/src/App/Socialite/Controllers/SocialiteCallbackController.php @@ -0,0 +1,51 @@ +runningInConsole()) { + $provider_user = Socialite::driver($provider)->user(); + } else { + $provider_user = Socialite::driver($provider)->stateless()->user(); + } + + // Check if user exist already + $user = User::whereEmail($provider_user->email)->first(); + + if($user) { + // Login User + $this->guard->login($user); + + } else { + + $data = CreateUserData::fromArray([ + 'name' => $provider_user->getname(), + 'email' => $provider_user->getEmail(), + 'avatar' => store_socialite_avatar($provider_user->getAvatar()), + 'oauth_provider' => $provider, + ]); + + // Create User + ($this->createNewUser)($data); + } + + return response('Loged in', 200); + } +} diff --git a/src/App/Socialite/Controllers/SocialiteRedirectController.php b/src/App/Socialite/Controllers/SocialiteRedirectController.php new file mode 100644 index 00000000..8720fc2f --- /dev/null +++ b/src/App/Socialite/Controllers/SocialiteRedirectController.php @@ -0,0 +1,18 @@ +stateless()->redirect()->getTargetUrl(); + + return response()->json([ + 'url' => $url + ]); + } +} diff --git a/src/App/Users/Actions/CreateNewUserAction.php b/src/App/Users/Actions/CreateNewUserAction.php index 4fdd8fb9..c94185d1 100644 --- a/src/App/Users/Actions/CreateNewUserAction.php +++ b/src/App/Users/Actions/CreateNewUserAction.php @@ -3,9 +3,9 @@ namespace App\Users\Actions; use App\Users\Models\User; use Illuminate\Http\Response; +use App\Users\Models\UserSettings; use App\Http\Controllers\Controller; use Illuminate\Auth\Events\Registered; -use App\Users\Requests\RegisterUserRequest; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Routing\ResponseFactory; @@ -20,13 +20,14 @@ class CreateNewUserAction extends Controller /** * Validate and create a new user. */ - public function __invoke( - RegisterUserRequest $request - ): Application | ResponseFactory | Response { + public function __invoke($data) + { $settings = get_settings([ - 'default_max_storage_amount', 'registration', 'user_verification', + 'storage_default', 'registration', 'user_verification', ]); + $is_socialite = is_null($data->password); + // Check if account registration is enabled if (! intval($settings['registration'])) { abort(401); @@ -34,28 +35,33 @@ class CreateNewUserAction extends Controller // Create user $user = User::create([ - 'password' => bcrypt($request->input('password')), - 'email' => $request->input('email'), + 'password' => $is_socialite ? null : bcrypt($data->password), + 'oauth_provider' => $data->oauth_provider, + 'email' => $data->email, ]); - // Mark as verified if verification is disabled - if (! intval($settings['user_verification'])) { - $user->markEmailAsVerified(); - } + UserSettings::unguard(); $user ->settings() ->create([ - 'name' => $request->input('name'), + 'name' => $data->name, + 'storage_capacity' => $settings['storage_default'], + 'avatar' => $data->avatar, ]); + UserSettings::reguard(); + + // Mark as verified if verification is disabled + if ($is_socialite || ! intval($settings['user_verification'])) { + $user->markEmailAsVerified(); + } + event(new Registered($user)); // Log in if verification is disabled - if (! intval($settings['user_verification'])) { + if ($is_socialite || ! intval($settings['user_verification'])) { $this->guard->login($user); } - - return response('User registered successfully', 201); } } diff --git a/src/App/Users/Controllers/Authentication/CheckAccountController.php b/src/App/Users/Controllers/Authentication/CheckAccountController.php index f8665176..76e935f5 100644 --- a/src/App/Users/Controllers/Authentication/CheckAccountController.php +++ b/src/App/Users/Controllers/Authentication/CheckAccountController.php @@ -22,9 +22,10 @@ class CheckAccountController extends Controller } return [ - 'name' => $user->settings->name, - 'avatar' => $user->settings->avatar, - 'verified' => $user->email_verified_at ? 1 : 0, + 'name' => $user->settings->name, + 'avatar' => $user->settings->avatar, + 'verified' => $user->email_verified_at ? 1 : 0, + 'oauth_provider' => $user->password ? null : $user->oauth_provider, ]; } } diff --git a/src/App/Users/Controllers/Authentication/RegisterAuthenticationController.php b/src/App/Users/Controllers/Authentication/RegisterAuthenticationController.php new file mode 100644 index 00000000..060d5f07 --- /dev/null +++ b/src/App/Users/Controllers/Authentication/RegisterAuthenticationController.php @@ -0,0 +1,22 @@ +createNewUser)($data); + } +} diff --git a/src/App/Users/DTO/CreateUserData.php b/src/App/Users/DTO/CreateUserData.php new file mode 100644 index 00000000..0f011979 --- /dev/null +++ b/src/App/Users/DTO/CreateUserData.php @@ -0,0 +1,36 @@ + $request->input('name'), + 'email' => $request->input('email'), + 'avatar' => $request->input('avatar') ?? null, + 'password' => $request->input('password'), + 'oauth_provider' => $request->input('oauth_provider') ?? null, + ]); + } + + public static function fromArray(array $array): self + { + return new self([ + 'name' => $array['name'] ?? null, + 'email' => $array['email'], + 'avatar' => $array['avatar'], + 'password' => $array['password'] ?? null, + 'oauth_provider' => $array['oauth_provider'], + ]); + } +} \ No newline at end of file diff --git a/src/App/Users/Models/User.php b/src/App/Users/Models/User.php index 49e9bc4e..6a2f66de 100644 --- a/src/App/Users/Models/User.php +++ b/src/App/Users/Models/User.php @@ -55,6 +55,7 @@ class User extends Authenticatable implements MustVerifyEmail protected $fillable = [ 'email', 'password', + 'oauth_provider', ]; protected $hidden = [ diff --git a/src/App/Users/Resources/UserResource.php b/src/App/Users/Resources/UserResource.php index 2862537a..3570df4e 100644 --- a/src/App/Users/Resources/UserResource.php +++ b/src/App/Users/Resources/UserResource.php @@ -35,6 +35,7 @@ class UserResource extends JsonResource 'email' => is_demo() ? obfuscate_email($this->email) : $this->email, 'role' => $this->role, 'two_factor_authentication' => $this->two_factor_secret ? true : false, + 'socialite_account' => $this->password ? false : true, 'folders' => $this->folder_tree, 'storage' => $this->storage, 'created_at' => format_date($this->created_at, '%d. %b. %Y'), diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 1ec4d2ba..3add9406 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -3,6 +3,7 @@ use Carbon\Carbon; use ByteUnits\Metric; use App\Users\Models\User; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Domain\Files\Models\File; use Domain\Sharing\Models\Share; @@ -358,14 +359,7 @@ if (! function_exists('store_avatar')) { $intervention = Image::make($image->getRealPath()); // Generate avatar sizes - collect(config('vuefilemanager.avatar_sizes')) - ->each(function ($size) use ($intervention, $avatar_name) { - // fit thumbnail - $intervention->fit($size['size'], $size['size'])->stream(); - - // Store thumbnail to disk - Storage::put("avatars/{$size['name']}-{$avatar_name}", $intervention); - }); + generate_avatar_thumbnails($intervention, $avatar_name); // Return path to image return $avatar_name; @@ -1021,4 +1015,46 @@ if (! function_exists('replace_occurrence')) { $string ); } + +if(! function_exists('get_socialite_avatar')) { + /** + * Get socialite avatar create and store his thumbnails + */ + function store_socialite_avatar($avatar) + { + // Get image from external source + $image = Http::get($avatar)->body(); + + // Generate avatar name + $avatar_name = Str::uuid() . '.jpg'; + + // Create intervention image + $intervention = Image::make($image); + + // Generate avatar sizes + generate_avatar_thumbnails($intervention, $avatar_name); + + // Return name of image + return $avatar_name; + } +} + +if(! function_exists('generate_avatar_thumbnails')) { + /** + * Create avatar thumbnails + */ + function generate_avatar_thumbnails($intervention, $avatar_name) + { + collect(config('vuefilemanager.avatar_sizes')) + ->each(function ($size) use ($intervention, $avatar_name) { + + // fit thumbnail + $intervention->fit($size['size'], $size['size'])->stream(); + + // Store thumbnail to disk + Storage::put("avatars/{$size['name']}-{$avatar_name}", $intervention); + }); + } +} + } diff --git a/tests/App/Socialite/SocialiteTest.php b/tests/App/Socialite/SocialiteTest.php new file mode 100644 index 00000000..d2a1687b --- /dev/null +++ b/tests/App/Socialite/SocialiteTest.php @@ -0,0 +1,96 @@ +get('api/socialite/google/redirect'); + + $this->assertStringContainsString('accounts.google.com/o/oauth2/auth', $response['url']); + } + + /** + * @test + */ + public function it_socialite_callback() + { + // Set default settings + DB::table('settings')->insert([ + [ + 'name' => 'registration', + 'value' => 1, + ], [ + 'name' => 'storage_default', + 'value' => 5, + ] + ]); + + // Create fake image + $fakeImage = UploadedFile::fake() + ->image('fake-avatar.jpg'); + + Http::fake([ + 'https://vuefilemanager.com/avatar.jpg' => Http::response($fakeImage->getContent()), + ]); + + // Create fake user + $socialiteUser = $this->createMock(\Laravel\Socialite\Two\User::class); + $socialiteUser->token = 'fake_token'; + $socialiteUser->id = 'fake_id'; + $socialiteUser->name = 'Jane Doe'; + $socialiteUser->email = 'howdy@hi5ve.digital'; + $socialiteUser->avatar = 'https://vuefilemanager.com/avatar.jpg'; + + // Mock user with FB provider + $provider = $this->createMock(FacebookProvider::class); + $provider->expects($this->any()) + ->method('user') + ->willReturn($socialiteUser); + + // Mock socialite + $stub = $this->createMock(Socialite::class); + + $stub->expects($this->any()) + ->method('driver') + ->willReturn($provider); + + // Replace Socialite Instance with mock + $this->app->instance(Socialite::class, $stub); + + $this->getJson('/api/socialite/facebook/callback'); + + $this + ->assertDatabaseHas('users', [ + 'email' => 'howdy@hi5ve.digital', + 'oauth_provider' => 'facebook', + 'password' => null, + ]) + ->assertDatabaseHas('user_settings', [ + 'name' => 'Jane Doe', + ]); + + $user = User::first(); + + collect(config('vuefilemanager.avatar_sizes')) + ->each( + fn($size) => Storage::disk('local') + ->assertExists("avatars/{$size['name']}-{$user->settings->getRawOriginal('avatar')}") + ); + } +}