diff --git a/.env.example b/.env.example index 31235f8..31441b8 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,7 @@ REDIS_PORT=6379 GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... -GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback +GOOGLE_REDIRECT_URI=https://grub-renewed-nicely.ngrok-free.app/auth/google/callback MAIL_MAILER=smtp MAIL_SCHEME=smtp diff --git a/app/Domains/Auth/Controllers/GoogleAuthController.php b/app/Domains/Auth/Controllers/GoogleAuthController.php new file mode 100644 index 0000000..3a0372a --- /dev/null +++ b/app/Domains/Auth/Controllers/GoogleAuthController.php @@ -0,0 +1,23 @@ +googleAuthService->redirect($request); + } + + public function callback(Request $request): RedirectResponse + { + return $this->googleAuthService->callback($request); + } +} diff --git a/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php new file mode 100644 index 0000000..2b9f14e --- /dev/null +++ b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php @@ -0,0 +1,26 @@ +googleAuthService->exchange($request->validated('oauth_code')); + + return response()->json([ + 'message' => 'Sesion iniciada correctamente.', + 'token' => $authentication['token'], + 'token_type' => 'Bearer', + 'user' => UserResource::make($authentication['user']), + ]); + } +} diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index fa2508d..e5921df 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -10,7 +10,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])] +#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable { diff --git a/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php b/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php new file mode 100644 index 0000000..ad04662 --- /dev/null +++ b/app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php @@ -0,0 +1,21 @@ +> */ + public function rules(): array + { + return [ + 'oauth_code' => ['required', 'uuid'], + ]; + } +} diff --git a/app/Domains/Auth/Services/GoogleAuthService.php b/app/Domains/Auth/Services/GoogleAuthService.php new file mode 100644 index 0000000..140109f --- /dev/null +++ b/app/Domains/Auth/Services/GoogleAuthService.php @@ -0,0 +1,165 @@ +string('tenant')->toString(); + $returnUrl = $request->string('return_url')->toString(); + + $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); + + if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) { + throw ValidationException::withMessages([ + 'tenant' => 'El tenant o la URL de retorno no son validos.', + ]); + } + + $state = (string) Str::uuid(); + Cache::put("google-oauth-context:{$state}", [ + 'tenant_codigo' => $tenant->codigo, + 'return_url' => rtrim($returnUrl, '/'), + ], now()->addMinutes(10)); + + return Socialite::driver('google') + ->scopes(['openid', 'profile', 'email']) + ->stateless() + ->with(['state' => $state]) + ->redirect(); + } + + public function callback(Request $request): RedirectResponse + { + $state = $request->string('state')->toString(); + + /** @var array{tenant_codigo?: string, return_url?: string}|null $context */ + $context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null; + + if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) { + abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.'); + } + + $tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first(); + + if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) { + abort(400, 'La URL de retorno no es valida.'); + } + + /** @var SocialiteUser $googleUser */ + $googleUser = Socialite::driver('google')->stateless()->user(); + $user = $this->resolveUser($googleUser, $tenant); + $token = $user->createToken( + 'google-oauth', + ['*'], + now()->addMinutes((int) config('sanctum.expiration')), + )->plainTextToken; + + $exchangeCode = (string) Str::uuid(); + Cache::put("google-oauth-exchange:{$exchangeCode}", [ + 'user_id' => $user->id, + 'token' => $token, + ], now()->addMinutes(5)); + + return redirect()->to($context['return_url'].'/login?'.http_build_query([ + 'oauth_code' => $exchangeCode, + ])); + } + + /** @return array{user: User, token: string} */ + public function exchange(string $exchangeCode): array + { + /** @var array{user_id: int, token: string}|null $authentication */ + $authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}"); + + if (! $authentication) { + throw ValidationException::withMessages([ + 'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.', + ]); + } + + return [ + 'user' => User::query()->findOrFail($authentication['user_id']), + 'token' => $authentication['token'], + ]; + } + + private function resolveUser(SocialiteUser $googleUser, Tenant $tenant): User + { + $googleId = $googleUser->getId(); + $email = $googleUser->getEmail(); + + if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw ValidationException::withMessages([ + 'google' => 'Google no devolvio una identidad valida.', + ]); + } + + $rawUser = $googleUser instanceof \Laravel\Socialite\Two\User ? $googleUser->getRaw() : []; + $emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false; + if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) { + throw ValidationException::withMessages([ + 'google' => 'La cuenta de Google debe tener el email verificado.', + ]); + } + + $user = User::query()->where('google_id', $googleId)->first(); + if ($user) { + return $user; + } + + $user = User::query()->where('email', $email)->first(); + if ($user) { + $user->forceFill(['google_id' => $googleId])->save(); + + return $user; + } + + $name = $googleUser->getName(); + $user = User::query()->create([ + 'nombre_apellido' => is_string($name) && $name !== '' ? $name : $email, + 'email' => $email, + 'email_verified_at' => now(), + 'google_id' => $googleId, + 'password' => Str::password(64), + ]); + + UserRegistered::dispatch($user, $tenant->codigo); + + return $user; + } + + private function isTenantReturnUrl(string $returnUrl, Tenant $tenant): bool + { + $parts = parse_url($returnUrl); + if (! is_array($parts) + || ! isset($parts['scheme'], $parts['host']) + || isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment']) + || ($parts['path'] ?? '') !== '') { + return false; + } + + $scheme = strtolower($parts['scheme']); + $host = TenantDomainNormalizer::normalize($parts['host']); + $tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio); + + if ($host === null || $tenantDomain === null || $host !== $tenantDomain) { + return false; + } + + return $scheme === 'https' || ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1'], true)); + } +} diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index d429381..9e4cab8 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -1,5 +1,6 @@ post('/logout', LogoutController::class); Route::middleware('auth:sanctum')->get('/me', MeController::class); Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class); diff --git a/config/services.php b/config/services.php index c9d9b3a..f755596 100644 --- a/config/services.php +++ b/config/services.php @@ -39,4 +39,10 @@ return [ 'secret' => env('INTEGRATION_SECRET'), ], + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI'), + ], + ]; diff --git a/database/migrations/2026_07_22_000000_add_google_id_to_users_table.php b/database/migrations/2026_07_22_000000_add_google_id_to_users_table.php new file mode 100644 index 0000000..8622d55 --- /dev/null +++ b/database/migrations/2026_07_22_000000_add_google_id_to_users_table.php @@ -0,0 +1,23 @@ +string('google_id')->nullable()->unique()->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropUnique(['google_id']); + $table->dropColumn('google_id'); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 86a06c5..88ad0e9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ name('auth.google.redirect'); +Route::get('/api/auth/google/callback', [GoogleAuthController::class, 'callback'])->name('auth.google.callback'); + Route::get('/', function () { return view('welcome'); });