From ab6c215705c562751c94d7315b6e3c495de09352 Mon Sep 17 00:00:00 2001 From: Latheesan Kanesamoorthy Date: Sat, 16 Nov 2024 23:35:19 +0000 Subject: [PATCH] staging Refactor the auth implementation as per adam's feedback --- .../Http/Controllers/API/AuthController.php | 132 ++++++++------ .../SocialAuthCallbackController.php | 170 +++++++++++++----- application/app/Models/Project.php | 6 + application/app/Models/ProjectAccount.php | 13 +- .../app/Models/ProjectAccountSession.php | 26 +++ ...5_185552_create_project_accounts_table.php | 5 +- ..._create_project_account_sessions_table.php | 32 ++++ .../resources/views/projects/show.blade.php | 22 ++- application/routes/api.php | 5 +- 9 files changed, 294 insertions(+), 117 deletions(-) create mode 100644 application/app/Models/ProjectAccountSession.php create mode 100644 application/database/migrations/2024_11_16_194111_create_project_account_sessions_table.php diff --git a/application/app/Http/Controllers/API/AuthController.php b/application/app/Http/Controllers/API/AuthController.php index 0059901..4ee52ce 100644 --- a/application/app/Http/Controllers/API/AuthController.php +++ b/application/app/Http/Controllers/API/AuthController.php @@ -5,13 +5,13 @@ use App\Enums\AuthProviderType; use App\Http\Controllers\Controller; use App\Models\Project; -use App\Models\ProjectAccount; use App\Traits\GEOBlockTrait; use App\Traits\IPHelperTrait; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Cookie; use Laravel\Socialite\Facades\Socialite; use Throwable; @@ -40,10 +40,10 @@ public function init(string $publicApiKey, string $authProvider, Request $reques } // Check if reference is provided in the request - if (empty($request->get('reference'))) { + if (empty($request->get('reference')) || strlen($request->get('reference')) > 512) { return response()->json([ 'error' => __('Bad Request'), - 'reason' => __('The reference query string parameter is missing or empty'), + 'reason' => __('The reference query string parameter is empty or larger than 512 characters.'), ], 400); } @@ -68,69 +68,101 @@ public function init(string $publicApiKey, string $authProvider, Request $reques ], 401); } - // Setup project account - $projectAccount = ProjectAccount::query() - ->where('project_id', $project->id) - ->where('reference', $request->get('reference')) - ->where('auth_provider', $authProvider) - ->first(); - if (!$projectAccount) { - $projectAccount = new ProjectAccount; - $projectAccount->fill([ - 'project_id' => $project->id, - 'reference' => $request->get('reference'), - 'auth_provider' => $authProvider, - ]); - $projectAccount->save(); + // Handle wallet auth provider + if ($authProvider === AuthProviderType::WALLET->value) { + // TODO: Handle wallet auth differently + exit('TODO'); } - // Attach project account to hashed user ip in cache for 5 minutes (needed during social auth callback) - Cache::put( - sprintf('project-account:%s', md5($this->getIP($request))), - $projectAccount->id, - 300, - ); - - // TODO: Handle wallet auth differently - - // Redirect to social auth + // Handle social auth provider return Socialite::driver($authProvider) - ->redirect(); + ->redirect() + ->withCookies([ + Cookie::make( + $authProvider . '_auth_attempt', + sprintf('%s#:rewardengine:#%s', $request->get('reference'), $publicApiKey), + ), + ]); + } - public function check(Request $request): JsonResponse + public function check(string $publicApiKey, Request $request): JsonResponse { try { - // Load project account by reference - $projectAccount = ProjectAccount::query() - ->where('reference', $request->get('reference')) - ->first(); - if (!$projectAccount) { + // Check if reference is provided in the request + if (empty($request->get('reference')) || strlen($request->get('reference')) > 512) { return response()->json([ - 'error' => __('Not Found'), - 'reason' => __('Could not find project account by reference'), - ], 404); + 'error' => __('Bad Request'), + 'reason' => __('The reference query string parameter is empty or larger than 512 characters.'), + ], 400); } - // Success - return response()->json([ - 'reference' => $projectAccount->reference, - 'auth_provider' => $projectAccount->auth_provider, - 'auth_provider_id' => $projectAccount->auth_provider_id, - 'auth_name' => $projectAccount->auth_name, - 'auth_email' => $projectAccount->auth_email, - 'auth_avatar' => $projectAccount->auth_avatar, - 'auth_country_code' => $projectAccount->auth_country_code, - 'authenticated_at' => $projectAccount->authenticated_at->toDateTimeString(), - 'is_authenticated' => !empty($projectAccount->authenticated_at), - ]); + // Check and cache the result for 10 seconds + $result = Cache::remember(sprintf('auth-check:%s', md5($publicApiKey . $request->get('reference'))), 10, function () use ($publicApiKey, $request) { + + // Load project, account and session + $project = Project::query() + ->where('public_api_key', $publicApiKey) + ->with(['accounts' => function ($query) { + $query->with(['sessions' => function ($query) { + $query->orderBy('id', 'desc')->limit(1); + }]); + }]) + ->whereHas('accounts.sessions', function ($query) use ($request) { + $query->where('reference', $request->get('reference')); + }) + ->first(); + + // Determine if user is authenticated + $isAuthenticated = ( + $project && + $project->accounts->count() === 1 && + $project->accounts->first()->sessions->count() === 1 + ); + + // Check if this request should be geo-blocked + if ($this->isGEOBlocked($project, $request)) { + + // Invalidate the isAuthenticated state + $isAuthenticated = false; + + } + + // Load project account + $projectAccount = $isAuthenticated ? $project->accounts->first() : null; + + // Load project account session + $projectAccountSession = $isAuthenticated ? $projectAccount->sessions->first() : null; + + // Build result + return [ + 'authenticated' => $isAuthenticated, + 'account' => $isAuthenticated ? [ + 'auth_provider' => $projectAccount->auth_provider, + 'auth_provider_id' => $projectAccount->auth_provider_id, + 'auth_name' => $projectAccount->auth_name, + 'auth_email' => $projectAccount->auth_email, + 'auth_avatar' => $projectAccount->auth_avatar, + ] : null, + 'session' => $isAuthenticated ? [ + 'reference' => $projectAccountSession->reference, + 'session_id' => $projectAccountSession->session_id, + 'auth_country_code' => $projectAccountSession->auth_country_code, + 'authenticated_at' => $projectAccountSession->authenticated_at->toDateTimeString(), + ] : null, + ]; + + }); + + return response()->json($result); } catch (Throwable $exception) { // Log exception $this->logException('Failed to handle auth check', $exception, [ - 'request' => $request->toArray(), + 'publicApiKey' => $publicApiKey, + 'authReference' => $request->get('reference'), ]); // Handle error diff --git a/application/app/Http/Controllers/SocialAuthCallbackController.php b/application/app/Http/Controllers/SocialAuthCallbackController.php index 07333f6..957b5ee 100644 --- a/application/app/Http/Controllers/SocialAuthCallbackController.php +++ b/application/app/Http/Controllers/SocialAuthCallbackController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers; use App\Enums\AuthProviderType; +use App\Models\Project; use App\Models\ProjectAccount; +use App\Models\ProjectAccountSession; use App\Traits\GEOBlockTrait; use App\Traits\IPHelperTrait; use App\Traits\LogExceptionTrait; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; +use Laravel\Socialite\Contracts\User; use Laravel\Socialite\Facades\Socialite; use Throwable; @@ -21,49 +24,25 @@ public function handle(string $authProvider, Request $request): void try { // Validate requested auth provider - if (!in_array($authProvider, AuthProviderType::values(), true)) { - exit(__(':currentProvider provider is not valid, supported providers are: :supportedProviders', [ - 'currentProvider' => $authProvider, - 'supportedProviders' => implode(', ', AuthProviderType::values()), - ])); - } - - // Retrieve project account - $projectAccountId = Cache::get(sprintf('project-account:%s', md5($this->getIP($request)))); - if (empty($projectAccountId)) { - exit(__('Login attempt session expired, please try again')); - } - $projectAccount = ProjectAccount::query() - ->where('id', $projectAccountId) - ->with('project') - ->first(); - if (!$projectAccount) { - exit(__('Login attempt session not found, please try again')); - } - - // Check if this request should be geo-blocked - if ($this->isGEOBlocked($projectAccount->project, $request)) { - exit(__('Accept not permitted')); - } - - // Load social user - $socialUser = null; - try { - $socialUser = Socialite::driver($authProvider)->user(); - } catch (Throwable) {} - if (!$socialUser) { - exit(__('Failed to authenticate via :authProvider, please try again', ['authProvider' => $authProvider])); - } - - // Update project account with social account info - $projectAccount->update([ - 'auth_provider_id' => $socialUser->id, - 'auth_name' => $socialUser->getName(), - 'auth_email' => $socialUser->getEmail(), - 'auth_avatar' => $socialUser->getAvatar(), - 'auth_country_code' => $this->getIPCountryCode($request), - 'authenticated_at' => now(), - ]); + $this->validateRequestedAuthProvider($authProvider); + + // Retrieve auth attempt from cookie + [$authReference, $publicAPIKey] = $this->retrieveAuthAttemptFromCookie($request, $authProvider); + + // Load social user from callback + $socialUser = $this->getSocialUser($authProvider); + + // Load project by public api key + $project = $this->loadProjectByPublicAPIKey($publicAPIKey); + + // Check if this request should be geo-blocked based on project configuration + $this->processGEOBlock($project, $request); + + // Upsert project account + $projectAccount = $this->upsertProjectAccount($project, $authProvider, $socialUser); + + // Record project account session + $this->recordProjectAccountSession($projectAccount, $authReference, $request); // Success exit(__('You have successfully logged in via :authProvider', ['authProvider' => $authProvider])); @@ -71,11 +50,112 @@ public function handle(string $authProvider, Request $request): void } catch (Throwable $exception) { // Handle exception - $this->logException('Failed to handle social auth callback', $exception); + $this->logException('Failed to handle social auth callback', $exception, [ + 'authProvider' => $authProvider, + ]); // Display generic error message exit(__('Failed to authenticate via :authProvider, please try again', ['authProvider' => $authProvider])); } } + + public function validateRequestedAuthProvider(string $authProvider): void + { + if (!in_array($authProvider, AuthProviderType::values(), true)) { + exit(__(':currentProvider provider is not valid, supported providers are: :supportedProviders', [ + 'currentProvider' => $authProvider, + 'supportedProviders' => implode(', ', AuthProviderType::values()), + ])); + } + } + + public function retrieveAuthAttemptFromCookie(Request $request, string $authProvider): array + { + $authAttempt = $request->cookie($authProvider . '_auth_attempt'); + if (empty($authAttempt)) { + exit(__('Login attempt session not found, please try again')); + } + + [$authReference, $publicAPIKey] = explode('#:rewardengine:#', $authAttempt); + if (empty($authReference) || strlen($authReference) > 512) { + exit(__('Login attempt session is empty or larger than 512 characters, please try again')); + } + + return [$authReference, $publicAPIKey]; + } + + public function getSocialUser(string $authProvider): User + { + $socialUser = null; + + try { + $socialUser = Socialite::driver($authProvider)->user(); + } catch (Throwable) {} + + if (!$socialUser) { + exit(__('Failed to authenticate via :authProvider, please try again', ['authProvider' => $authProvider])); + } + + return $socialUser; + } + + public function loadProjectByPublicAPIKey(string $publicAPIKey): Project + { + $project = Project::query() + ->where('public_api_key', $publicAPIKey) + ->select('id') + ->first(); + + if (!$project) { + exit(__('Invalid login attempt session, please try again')); + } + + return $project; + } + + public function processGEOBlock(Project $project, Request $request): void + { + if ($this->isGEOBlocked($project, $request)) { + exit(__('Accept not permitted')); + } + } + + public function upsertProjectAccount(?Project $project, string $authProvider, ?User $socialUser): ProjectAccount + { + $projectAccount = ProjectAccount::query() + ->where('project_id', $project->id) + ->where('auth_provider', $authProvider) + ->where('auth_provider_id', $socialUser->id) + ->first(); + + if (!$projectAccount) { + $projectAccount = new ProjectAccount; + $projectAccount->fill([ + 'project_id' => $project->id, + 'auth_provider' => $authProvider, + 'auth_provider_id' => $socialUser->id, + ]); + } + + $projectAccount->auth_name = $socialUser->getName(); + $projectAccount->auth_email = $socialUser->getEmail(); + $projectAccount->auth_avatar = $socialUser->getAvatar(); + $projectAccount->save(); + + return $projectAccount; + } + + public function recordProjectAccountSession(ProjectAccount $projectAccount, string $authReference, Request $request): void + { + $projectAccountSession = new ProjectAccountSession; + $projectAccountSession->fill([ + 'project_account_id' => $projectAccount->id, + 'reference' => $authReference, + 'session_id' => Str::uuid(), + 'auth_country_code' => $this->getIPCountryCode($request), + 'authenticated_at' => now(), + ]); + $projectAccountSession->save(); + } } diff --git a/application/app/Models/Project.php b/application/app/Models/Project.php index b7a7630..ff58a52 100644 --- a/application/app/Models/Project.php +++ b/application/app/Models/Project.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Project extends Model { @@ -22,6 +23,11 @@ class Project extends Model 'private_api_key', ]; + public function accounts(): HasMany + { + return $this->hasMany(ProjectAccount::class); + } + protected function privateApiKey(): Attribute { return Attribute::make( diff --git a/application/app/Models/ProjectAccount.php b/application/app/Models/ProjectAccount.php index c1d07ab..81aad34 100644 --- a/application/app/Models/ProjectAccount.php +++ b/application/app/Models/ProjectAccount.php @@ -5,23 +5,17 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ProjectAccount extends Model { protected $fillable = [ 'project_id', - 'reference', 'auth_provider', 'auth_provider_id', 'auth_name', 'auth_email', 'auth_avatar', - 'auth_country_code', - 'authenticated_at', - ]; - - protected $casts = [ - 'authenticated_at' => 'datetime:Y-m-d H:i:s', ]; public function project(): BelongsTo @@ -29,6 +23,11 @@ public function project(): BelongsTo return $this->belongsTo(Project::class); } + public function sessions(): HasMany + { + return $this->hasMany(ProjectAccountSession::class); + } + protected function authName(): Attribute { return Attribute::make( diff --git a/application/app/Models/ProjectAccountSession.php b/application/app/Models/ProjectAccountSession.php new file mode 100644 index 0000000..bf1714b --- /dev/null +++ b/application/app/Models/ProjectAccountSession.php @@ -0,0 +1,26 @@ + 'datetime:Y-m-d H:i:s', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(ProjectAccount::class); + } +} diff --git a/application/database/migrations/2024_11_15_185552_create_project_accounts_table.php b/application/database/migrations/2024_11_15_185552_create_project_accounts_table.php index 7eb1333..063c389 100644 --- a/application/database/migrations/2024_11_15_185552_create_project_accounts_table.php +++ b/application/database/migrations/2024_11_15_185552_create_project_accounts_table.php @@ -14,15 +14,12 @@ public function up(): void Schema::create('project_accounts', function (Blueprint $table) { $table->id(); $table->foreignId('project_id')->constrained('projects'); - $table->string('reference', 512); $table->string('auth_provider', 32); $table->unsignedBigInteger('auth_provider_id')->nullable(); $table->string('auth_name', 1024)->nullable(); $table->string('auth_email', 1024)->nullable(); $table->string('auth_avatar', 1024)->nullable(); - $table->string('auth_country_code', 2)->nullable(); - $table->dateTime('authenticated_at')->nullable(); - $table->unique(['project_id', 'reference', 'auth_provider'], 'UQ_project_accounts'); + $table->unique(['project_id', 'auth_provider', 'auth_provider_id'], 'UQ_project_provider_accounts'); $table->timestamps(); }); } diff --git a/application/database/migrations/2024_11_16_194111_create_project_account_sessions_table.php b/application/database/migrations/2024_11_16_194111_create_project_account_sessions_table.php new file mode 100644 index 0000000..b76fd00 --- /dev/null +++ b/application/database/migrations/2024_11_16_194111_create_project_account_sessions_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('project_account_id')->constrained('project_accounts'); + $table->string('reference', 512)->index(); + $table->string('session_id', 64)->index(); + $table->string('auth_country_code', 2)->nullable(); + $table->dateTime('authenticated_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_account_sessions'); + } +}; diff --git a/application/resources/views/projects/show.blade.php b/application/resources/views/projects/show.blade.php index 2eb2b2a..6c293e2 100644 --- a/application/resources/views/projects/show.blade.php +++ b/application/resources/views/projects/show.blade.php @@ -45,23 +45,17 @@
-

- Used when communicating from a frontend (e.g. wallet/social authentication). -

-

- Used when communicating from a backend (e.g. authentication check/stats). -

-
+
+ +
+
+ +
+ + {{ __('Test') }} +
+
+
diff --git a/application/routes/api.php b/application/routes/api.php index 12fc47b..3ef767c 100644 --- a/application/routes/api.php +++ b/application/routes/api.php @@ -14,11 +14,12 @@ Route::prefix('auth')->group(static function () { // Public Endpoints - Route::get('providers', [AuthController::class, 'providers']); + Route::get('providers', [AuthController::class, 'providers'])->name('api.v1.auth.providers'); Route::get('init/{publicApiKey}/{authProvider}', [AuthController::class, 'init'])->middleware(['web'])->name('api.v1.auth.init'); + Route::get('check/{publicApiKey}', [AuthController::class, 'check'])->name('api.v1.auth.check'); // Private Endpoints - Route::post('check', [AuthController::class, 'check'])->middleware(ProjectAPIKeyAuth::class); + // Route::post('xxx', [AuthController::class, 'xxx'])->middleware(ProjectAPIKeyAuth::class); });