Skip to content

Commit

Permalink
staging Refactor the auth implementation as per adam's feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
latheesan-k committed Nov 16, 2024
1 parent 64eb680 commit ab6c215
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 117 deletions.
132 changes: 82 additions & 50 deletions application/app/Http/Controllers/API/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand All @@ -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
Expand Down
170 changes: 125 additions & 45 deletions application/app/Http/Controllers/SocialAuthCallbackController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -21,61 +24,138 @@ 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]));

} 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();
}
}
6 changes: 6 additions & 0 deletions application/app/Models/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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(
Expand Down
Loading

0 comments on commit ab6c215

Please sign in to comment.