diff --git a/.env.example b/.env.example index b41f9a522156..cb0e65ee77b1 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,11 @@ SENTRY_LARAVEL_DSN=https://39389664f3f14969b4c43dadda00a40b@sentry2.invoicing.co GOOGLE_PLAY_PACKAGE_NAME= APPSTORE_PASSWORD= + +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= +MICROSOFT_REDIRECT_URI= + +APPLE_CLIENT_ID= +APPLE_CLIENT_SECRET= +APPLE_REDIRECT_URI= diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 9a5d5bb00554..ec52fd49644a 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -92,7 +92,7 @@ class LoginController extends BaseController * @return void * deprecated .1 API ONLY we don't need to set any session variables */ - public function authenticated(Request $request, User $user) : void + public function authenticated(Request $request, User $user): void { //$this->setCurrentCompanyId($user->companies()->first()->account->default_company_id); } @@ -168,9 +168,9 @@ class LoginController extends BaseController $this->fireLockoutEvent($request); return response() - ->json(['message' => 'Too many login attempts, you are being throttled'], 401) - ->header('X-App-Version', config('ninja.app_version')) - ->header('X-Api-Version', config('ninja.minimum_client_version')); + ->json(['message' => 'Too many login attempts, you are being throttled'], 401) + ->header('X-App-Version', config('ninja.app_version')) + ->header('X-Api-Version', config('ninja.minimum_client_version')); } if ($this->attemptLogin($request)) { @@ -196,7 +196,7 @@ class LoginController extends BaseController } elseif($user->google_2fa_secret && !$request->has('one_time_password')) { - + return response() ->json(['message' => ctrans('texts.invalid_one_time_password')], 401) ->header('X-App-Version', config('ninja.app_version')) @@ -234,23 +234,23 @@ class LoginController extends BaseController // /* Ensure the user has a valid token */ // if($user->company_users()->count() != $user->tokens()->count()) // { - + // $user->companies->each(function($company) use($user, $request){ - + // if(!CompanyToken::where('user_id', $user->id)->where('company_id', $company->id)->exists()){ - + // CreateCompanyToken::dispatchNow($company, $user, $request->server('HTTP_USER_AGENT')); - + // } - + // }); - + // } // $truth->setCompanyToken(CompanyToken::where('user_id', auth()->user()->id)->where('company_id', $user->account->default_company->id)->first()); /*On the hosted platform, only owners can login for free/pro accounts*/ - if(Ninja::isHosted() && !$cu->first()->is_owner && !$user->account->isEnterpriseClient()) + if (Ninja::isHosted() && !$cu->first()->is_owner && !$user->account->isEnterpriseClient()) return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); event(new UserLoggedIn($user, $user->account->default_company, Ninja::eventVars($user->id))); @@ -267,9 +267,9 @@ class LoginController extends BaseController $this->incrementLoginAttempts($request); return response() - ->json(['message' => ctrans('texts.invalid_credentials')], 401) - ->header('X-App-Version', config('ninja.app_version')) - ->header('X-Api-Version', config('ninja.minimum_client_version')); + ->json(['message' => ctrans('texts.invalid_credentials')], 401) + ->header('X-App-Version', config('ninja.app_version')) + ->header('X-Api-Version', config('ninja.minimum_client_version')); } } @@ -317,29 +317,28 @@ class LoginController extends BaseController { $truth = app()->make(TruthSource::class); - if($truth->getCompanyToken()) + if ($truth->getCompanyToken()) $company_token = $truth->getCompanyToken(); else $company_token = CompanyToken::where('token', $request->header('X-API-TOKEN'))->first(); $cu = CompanyUser::query() - ->where('user_id', $company_token->user_id); + ->where('user_id', $company_token->user_id); - if($cu->count() == 0) + if ($cu->count() == 0) return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400); - $cu->first()->account->companies->each(function ($company) use($cu, $request){ + $cu->first()->account->companies->each(function ($company) use ($cu, $request) { - if($company->tokens()->where('is_system', true)->count() == 0) - { + if ($company->tokens()->where('is_system', true)->count() == 0) { CreateCompanyToken::dispatchNow($company, $cu->first()->user, $request->server('HTTP_USER_AGENT')); } }); - if($request->has('current_company') && $request->input('current_company') == 'true') - $cu->where("company_id", $company_token->company_id); + if ($request->has('current_company') && $request->input('current_company') == 'true') + $cu->where("company_id", $company_token->company_id); - if(Ninja::isHosted() && !$cu->first()->is_owner && !$cu->first()->user->account->isEnterpriseClient()) + if (Ninja::isHosted() && !$cu->first()->is_owner && !$cu->first()->user->account->isEnterpriseClient()) return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); return $this->refreshResponse($cu); @@ -359,24 +358,134 @@ class LoginController extends BaseController */ public function oauthApiLogin() { + + $message = 'Provider not supported'; if (request()->input('provider') == 'google') { return $this->handleGoogleOauth(); + } elseif (request()->input('provider') == 'microsoft') { + if (request()->has('token')) { + return $this->handleSocialiteLogin('microsoft', request()->get('token')); + } else { + $message = 'Bearer token missing for the microsoft login'; + } + } elseif (request()->input('provider') == 'apple') { + if (request()->has('token')) { + return $this->handleSocialiteLogin('apple', request()->get('token')); + } else { + $message = 'Token is missing for the apple login'; + } } return response() - ->json(['message' => 'Provider not supported'], 400) - ->header('X-App-Version', config('ninja.app_version')) - ->header('X-Api-Version', config('ninja.minimum_client_version')); + ->json(['message' => $message], 400) + ->header('X-App-Version', config('ninja.app_version')) + ->header('X-Api-Version', config('ninja.minimum_client_version')); } - private function hydrateCompanyUser() :Builder + private function getSocialiteUser(string $provider, string $token) + { + return Socialite::driver($provider)->userFromToken($token); + } + + private function handleSocialiteLogin($provider, $token) + { + $user = $this->getSocialiteUser($provider, $token); + if ($user) { + return $this->loginOrCreateFromSocialite($user, $provider); + } + return response() + ->json(['message' => ctrans('texts.invalid_credentials')], 401) + ->header('X-App-Version', config('ninja.app_version')) + ->header('X-Api-Version', config('ninja.minimum_client_version')); + + } + + + private function loginOrCreateFromSocialite($user, $provider) + { + $query = [ + 'oauth_user_id' => $user->id, + 'oauth_provider_id' => $provider, + ]; + if ($existing_user = MultiDB::hasUser($query)) { + + if (!$existing_user->account) + return response()->json(['message' => 'User exists, but not attached to any companies! Orphaned user!'], 400); + + Auth::login($existing_user, true); + + $cu = $this->hydrateCompanyUser(); + + if ($cu->count() == 0) + return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400); + + if (Ninja::isHosted() && !$cu->first()->is_owner && !$existing_user->account->isEnterpriseClient()) + return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); + + return $this->timeConstrainedResponse($cu); + + } + //If this is a result user/email combo - lets add their OAuth details details + if ($existing_login_user = MultiDB::hasUser(['email' => $user->email])) { + if (!$existing_login_user->account) + return response()->json(['message' => 'User exists, but not attached to any companies! Orphaned user!'], 400); + + Auth::login($existing_login_user, true); + + auth()->user()->update([ + 'oauth_user_id' => $user->id, + 'oauth_provider_id' => $provider, + ]); + + $cu = $this->hydrateCompanyUser(); + + if ($cu->count() == 0) + return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400); + + if (Ninja::isHosted() && !$cu->first()->is_owner && !$existing_login_user->account->isEnterpriseClient()) + return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); + + return $this->timeConstrainedResponse($cu); + } + $name = OAuth::splitName($user->name); + + $new_account = [ + 'first_name' => $name[0], + 'last_name' => $name[1], + 'password' => '', + 'email' => $user->email, + 'oauth_user_id' => $user->id, + 'oauth_provider_id' => $provider, + ]; + + MultiDB::setDefaultDatabase(); + + $account = CreateAccount::dispatchNow($new_account, request()->getClientIp()); + + Auth::login($account->default_company->owner(), true); + auth()->user()->email_verified_at = now(); + auth()->user()->save(); + + $cu = $this->hydrateCompanyUser(); + + if ($cu->count() == 0) + return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400); + + if (Ninja::isHosted() && !$cu->first()->is_owner && !auth()->user()->account->isEnterpriseClient()) + return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); + + return $this->timeConstrainedResponse($cu); + } + + + private function hydrateCompanyUser(): Builder { $cu = CompanyUser::query()->where('user_id', auth()->user()->id); - if(CompanyUser::query()->where('user_id', auth()->user()->id)->where('company_id', auth()->user()->account->default_company_id)->exists()) + if (CompanyUser::query()->where('user_id', auth()->user()->id)->where('company_id', auth()->user()->account->default_company_id)->exists()) $set_company = auth()->user()->account->default_company; - else{ + else { $set_company = $cu->first()->company; } @@ -392,19 +501,18 @@ class LoginController extends BaseController if($cu->count() == 0) return $cu; - if(auth()->user()->company_users()->count() != auth()->user()->tokens()->distinct('company_id')->count()) - { - - auth()->user()->companies->each(function($company){ - - if(!CompanyToken::where('user_id', auth()->user()->id)->where('company_id', $company->id)->exists()){ - - CreateCompanyToken::dispatchNow($company, auth()->user(), "Google_O_Auth"); - + if (auth()->user()->company_users()->count() != auth()->user()->tokens()->distinct('company_id')->count()) { + + auth()->user()->companies->each(function ($company) { + + if (!CompanyToken::where('user_id', auth()->user()->id)->where('company_id', $company->id)->exists()) { + + CreateCompanyToken::dispatchNow($company, auth()->user(), "Google_O_Auth"); + } - + }); - + } $truth->setCompanyToken(CompanyToken::where('user_id', auth()->user()->id)->where('company_id', $set_company->id)->first()); @@ -444,7 +552,7 @@ class LoginController extends BaseController return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403); return $this->timeConstrainedResponse($cu); - + } //If this is a result user/email combo - lets add their OAuth details details @@ -474,14 +582,14 @@ class LoginController extends BaseController } if ($user) { - + //check the user doesn't already exist in some form if($existing_login_user = MultiDB::hasUser(['email' => $google->harvestEmail($user)])) { if(!$existing_login_user->account) return response()->json(['message' => 'User exists, but not attached to any companies! Orphaned user!'], 400); - + Auth::login($existing_login_user, true); auth()->user()->update([ @@ -490,11 +598,11 @@ class LoginController extends BaseController ]); $cu = $this->hydrateCompanyUser(); - + // $cu = CompanyUser::query() // ->where('user_id', auth()->user()->id); - if($cu->count() == 0) + if ($cu->count() == 0) return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400); if(Ninja::isHosted() && !$cu->first()->is_owner && !$existing_login_user->account->isEnterpriseClient()) @@ -557,7 +665,7 @@ class LoginController extends BaseController if (request()->has('code')) { return $this->handleProviderCallback($provider); } else { - + if(!in_array($provider, ['google'])) return abort(400, 'Invalid provider'); @@ -594,7 +702,7 @@ class LoginController extends BaseController 'oauth_user_id' => $socialite_user->getId(), 'oauth_provider_id' => $provider, 'oauth_user_token' => $oauth_user_token, - 'oauth_user_refresh_token' => $socialite_user->refreshToken + 'oauth_user_refresh_token' => $socialite_user->refreshToken ]; $user->update($update_user); diff --git a/app/Libraries/OAuth/OAuth.php b/app/Libraries/OAuth/OAuth.php index 6d68fa8084b4..3abe1e88ab2a 100644 --- a/app/Libraries/OAuth/OAuth.php +++ b/app/Libraries/OAuth/OAuth.php @@ -29,6 +29,8 @@ class OAuth const SOCIAL_LINKEDIN = 4; const SOCIAL_TWITTER = 5; const SOCIAL_BITBUCKET = 6; + const SOCIAL_MICROSOFT = 7; + const SOCIAL_APPLE = 8; /** * @param Socialite $user @@ -38,8 +40,8 @@ class OAuth { /** 1. Ensure user arrives on the correct provider **/ $query = [ - 'oauth_user_id' =>$socialite_user->getId(), - 'oauth_provider_id'=>$provider, + 'oauth_user_id' => $socialite_user->getId(), + 'oauth_provider_id' => $provider, ]; if ($user = MultiDB::hasUser($query)) { @@ -54,12 +56,12 @@ class OAuth { $name = trim($name); $last_name = (strpos($name, ' ') === false) ? '' : preg_replace('#.*\s([\w-]*)$#', '$1', $name); - $first_name = trim(preg_replace('#'.preg_quote($last_name, '/').'#', '', $name)); + $first_name = trim(preg_replace('#' . preg_quote($last_name, '/') . '#', '', $name)); return [$first_name, $last_name]; } - public static function providerToString(int $social_provider) : string + public static function providerToString(int $social_provider): string { switch ($social_provider) { case SOCIAL_GOOGLE: @@ -74,10 +76,14 @@ class OAuth return 'twitter'; case SOCIAL_BITBUCKET: return 'bitbucket'; + case SOCIAL_MICROSOFT: + return 'microsoft'; + case SOCIAL_APPLE: + return 'apple'; } } - public static function providerToInt(string $social_provider) : int + public static function providerToInt(string $social_provider): int { switch ($social_provider) { case 'google': @@ -92,6 +98,10 @@ class OAuth return SOCIAL_TWITTER; case 'bitbucket': return SOCIAL_BITBUCKET; + case 'microsoft': + return SOCIAL_MICROSOFT; + case 'apple': + return SOCIAL_APPLE; } } @@ -103,7 +113,6 @@ class OAuth $this->provider_id = self::SOCIAL_GOOGLE; return $this; - default: return null; break; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index b84ab256dfb9..3fe1741fd755 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -264,9 +264,9 @@ class EventServiceProvider extends ServiceProvider * @var array */ protected $listen = [ - AccountCreated::class =>[ + AccountCreated::class => [ ], - MessageSending::class =>[ + MessageSending::class => [ ], MessageSent::class => [ MailSentListener::class, @@ -312,35 +312,35 @@ class EventServiceProvider extends ServiceProvider PaymentWasVoided::class => [ PaymentVoidedActivity::class, ], - PaymentWasRestored::class =>[ + PaymentWasRestored::class => [ PaymentRestoredActivity::class, ], // Clients - ClientWasCreated::class =>[ + ClientWasCreated::class => [ CreatedClientActivity::class, ], - ClientWasArchived::class =>[ + ClientWasArchived::class => [ ArchivedClientActivity::class, ], - ClientWasUpdated::class =>[ + ClientWasUpdated::class => [ ClientUpdatedActivity::class, ], - ClientWasDeleted::class =>[ + ClientWasDeleted::class => [ DeleteClientActivity::class, ], - ClientWasRestored::class =>[ + ClientWasRestored::class => [ RestoreClientActivity::class, ], // Documents - DocumentWasCreated::class =>[ + DocumentWasCreated::class => [ ], - DocumentWasArchived::class =>[ + DocumentWasArchived::class => [ ], - DocumentWasUpdated::class =>[ + DocumentWasUpdated::class => [ ], - DocumentWasDeleted::class =>[ + DocumentWasDeleted::class => [ ], - DocumentWasRestored::class =>[ + DocumentWasRestored::class => [ ], CreditWasCreated::class => [ CreatedCreditActivity::class, @@ -404,11 +404,11 @@ class EventServiceProvider extends ServiceProvider InvoiceWasCreated::class => [ CreateInvoiceActivity::class, InvoiceCreatedNotification::class, - // CreateInvoicePdf::class, + // CreateInvoicePdf::class, ], InvoiceWasPaid::class => [ - InvoicePaidActivity::class, - CreateInvoicePdf::class, + InvoicePaidActivity::class, + CreateInvoicePdf::class, ], InvoiceWasViewed::class => [ InvoiceViewedActivity::class, @@ -593,7 +593,12 @@ class EventServiceProvider extends ServiceProvider ], VendorWasUpdated::class => [ VendorUpdatedActivity::class, - ] + ], + \SocialiteProviders\Manager\SocialiteWasCalled::class => [ + // ... Manager won't register drivers that are not added to this listener. + \SocialiteProviders\Apple\AppleExtendSocialite::class . '@handle', + \SocialiteProviders\Microsoft\MicrosoftExtendSocialite::class . '@handle', + ], ]; diff --git a/composer.json b/composer.json index 3e77c6608e99..452f41241b6c 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,8 @@ "sentry/sentry-laravel": "^2", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.3", + "socialiteproviders/apple": "^5.2", + "socialiteproviders/microsoft": "^4.1", "square/square": "13.0.0.20210721", "stripe/stripe-php": "^7.50", "symfony/http-client": "^5.2", diff --git a/config/services.php b/config/services.php index 282d814c4abe..1656cdf10168 100644 --- a/config/services.php +++ b/config/services.php @@ -80,4 +80,14 @@ return [ 'postmark' => [ 'token' => env('POSTMARK_SECRET'), ], + 'microsoft' => [ + 'client_id' => env('MICROSOFT_CLIENT_ID'), + 'client_secret' => env('MICROSOFT_CLIENT_SECRET'), + 'redirect' => env('MICROSOFT_REDIRECT_URI') + ], + 'apple' => [ + 'client_id' => env('APPLE_CLIENT_ID'), + 'client_secret' => env('APPLE_CLIENT_SECRET'), + 'redirect' => env('APPLE_REDIRECT_URI') + ], ];