feat(auth): implement Google OAuth integration with user authentication and token exchange
This commit is contained in:
@@ -54,7 +54,7 @@ REDIS_PORT=6379
|
|||||||
|
|
||||||
GOOGLE_CLIENT_ID=...
|
GOOGLE_CLIENT_ID=...
|
||||||
GOOGLE_CLIENT_SECRET=...
|
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_MAILER=smtp
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=smtp
|
||||||
|
|||||||
23
app/Domains/Auth/Controllers/GoogleAuthController.php
Normal file
23
app/Domains/Auth/Controllers/GoogleAuthController.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Services\GoogleAuthService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GoogleAuthController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
||||||
|
|
||||||
|
public function redirect(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->googleAuthService->redirect($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->googleAuthService->callback($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
||||||
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\GoogleAuthService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class GoogleTokenExchangeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
||||||
|
|
||||||
|
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sesion iniciada correctamente.',
|
||||||
|
'token' => $authentication['token'],
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'user' => UserResource::make($authentication['user']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
|
|||||||
21
app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php
Normal file
21
app/Domains/Auth/Requests/GoogleTokenExchangeRequest.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class GoogleTokenExchangeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<int, string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'oauth_code' => ['required', 'uuid'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/Domains/Auth/Services/GoogleAuthService.php
Normal file
165
app/Domains/Auth/Services/GoogleAuthService.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
||||||
|
class GoogleAuthService
|
||||||
|
{
|
||||||
|
public function redirect(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$tenantCode = $request->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||||
use App\Domains\Auth\Controllers\LoginController;
|
use App\Domains\Auth\Controllers\LoginController;
|
||||||
use App\Domains\Auth\Controllers\LogoutController;
|
use App\Domains\Auth\Controllers\LogoutController;
|
||||||
use App\Domains\Auth\Controllers\MeController;
|
use App\Domains\Auth\Controllers\MeController;
|
||||||
@@ -9,6 +10,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
|
|
||||||
Route::post('/register', RegisterController::class);
|
Route::post('/register', RegisterController::class);
|
||||||
Route::post('/login', LoginController::class);
|
Route::post('/login', LoginController::class);
|
||||||
|
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||||
|
|||||||
@@ -39,4 +39,10 @@ return [
|
|||||||
'secret' => env('INTEGRATION_SECRET'),
|
'secret' => env('INTEGRATION_SECRET'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'google' => [
|
||||||
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('GOOGLE_REDIRECT_URI'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\GoogleAuthController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/auth/google/redirect', [GoogleAuthController::class, 'redirect'])->name('auth.google.redirect');
|
||||||
|
Route::get('/api/auth/google/callback', [GoogleAuthController::class, 'callback'])->name('auth.google.callback');
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user