Initial commit
This commit is contained in:
286
app/Helpers/ProfileAuthorizationHelper.php
Normal file
286
app/Helpers/ProfileAuthorizationHelper.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Profile Authorization Helper
|
||||
*
|
||||
* Provides centralized authorization validation for profile operations.
|
||||
* Prevents IDOR (Insecure Direct Object Reference) vulnerabilities by
|
||||
* validating that authenticated users have permission to act on profiles.
|
||||
*/
|
||||
class ProfileAuthorizationHelper
|
||||
{
|
||||
/**
|
||||
* Get authenticated profile from any guard (multi-guard support).
|
||||
* Returns the authenticated model (User, Organization, Bank, or Admin).
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
private static function getAuthenticatedProfile()
|
||||
{
|
||||
// Check all guards and return the authenticated model
|
||||
return Auth::guard('admin')->user()
|
||||
?: Auth::guard('bank')->user()
|
||||
?: Auth::guard('organization')->user()
|
||||
?: Auth::guard('web')->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the authenticated user has ownership/access to a profile.
|
||||
*
|
||||
* This function prevents IDOR attacks by ensuring:
|
||||
* - Users can only access their own User profile
|
||||
* - Users can only access Organizations they're linked to
|
||||
* - Users can only access Banks they're linked to
|
||||
* - Users can only access Admin profiles they're linked to
|
||||
*
|
||||
* @param mixed $profile The profile to validate (User, Organization, Bank, or Admin)
|
||||
* @param bool $throwException Whether to throw 403 exception (default: true)
|
||||
* @return bool True if authorized, false if not (when $throwException = false)
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
|
||||
*/
|
||||
public static function validateProfileOwnership($profile, bool $throwException = true): bool
|
||||
{
|
||||
$authenticatedProfile = self::getAuthenticatedProfile();
|
||||
|
||||
// Must be authenticated
|
||||
if (!$authenticatedProfile) {
|
||||
Log::warning('ProfileAuthorizationHelper: Attempted profile access without authentication', [
|
||||
'profile_id' => $profile?->id,
|
||||
'profile_type' => $profile ? get_class($profile) : null,
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(401, 'Authentication required');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// IMPORTANT: Verify guard matches profile type to prevent cross-guard attacks
|
||||
// Even if the user has a relationship with the profile, they must be authenticated on the correct guard
|
||||
$expectedGuardForProfile = null;
|
||||
if ($profile instanceof \App\Models\Bank) {
|
||||
$expectedGuardForProfile = 'bank';
|
||||
} elseif ($profile instanceof \App\Models\Organization) {
|
||||
$expectedGuardForProfile = 'organization';
|
||||
} elseif ($profile instanceof \App\Models\Admin) {
|
||||
$expectedGuardForProfile = 'admin';
|
||||
} elseif ($profile instanceof \App\Models\User) {
|
||||
$expectedGuardForProfile = 'web';
|
||||
}
|
||||
|
||||
// Check which guard the current authentication is from
|
||||
$currentGuard = null;
|
||||
if (Auth::guard('admin')->check() && Auth::guard('admin')->user() === $authenticatedProfile) {
|
||||
$currentGuard = 'admin';
|
||||
} elseif (Auth::guard('bank')->check() && Auth::guard('bank')->user() === $authenticatedProfile) {
|
||||
$currentGuard = 'bank';
|
||||
} elseif (Auth::guard('organization')->check() && Auth::guard('organization')->user() === $authenticatedProfile) {
|
||||
$currentGuard = 'organization';
|
||||
} elseif (Auth::guard('web')->check() && Auth::guard('web')->user() === $authenticatedProfile) {
|
||||
$currentGuard = 'web';
|
||||
}
|
||||
|
||||
// Prevent cross-guard access
|
||||
if ($expectedGuardForProfile && $currentGuard && $expectedGuardForProfile !== $currentGuard) {
|
||||
Log::warning('ProfileAuthorizationHelper: Cross-guard access attempt blocked', [
|
||||
'authenticated_guard' => $currentGuard,
|
||||
'target_profile_type' => get_class($profile),
|
||||
'expected_guard' => $expectedGuardForProfile,
|
||||
'profile_id' => $profile->id,
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: Cannot access ' . class_basename($profile) . ' profile from ' . $currentGuard . ' guard');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if authenticated profile is same type and ID as target profile (direct match)
|
||||
if (get_class($authenticatedProfile) === get_class($profile) && $authenticatedProfile->id === $profile->id) {
|
||||
// User is accessing their own profile of same type
|
||||
Log::info('ProfileAuthorizationHelper: Direct profile access authorized', [
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_id' => $profile->id,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For cross-profile access, we need to check relationships via User model
|
||||
// Get the underlying User for relationship checks
|
||||
$authenticatedUser = null;
|
||||
if ($authenticatedProfile instanceof \App\Models\User) {
|
||||
$authenticatedUser = $authenticatedProfile;
|
||||
} elseif ($authenticatedProfile instanceof \App\Models\Admin) {
|
||||
// Admin can access if they ARE the target admin (already checked above)
|
||||
// For other profile types, need to get linked users
|
||||
if (!($profile instanceof \App\Models\Admin)) {
|
||||
// Admin trying to access non-admin profile - get one of the linked users
|
||||
$authenticatedUser = $authenticatedProfile->users()->first();
|
||||
}
|
||||
} elseif ($authenticatedProfile instanceof \App\Models\Organization) {
|
||||
// Organization can access if they ARE the target org (already checked above)
|
||||
// For other profile types, need to get linked users
|
||||
if (!($profile instanceof \App\Models\Organization)) {
|
||||
$authenticatedUser = $authenticatedProfile->users()->first();
|
||||
}
|
||||
} elseif ($authenticatedProfile instanceof \App\Models\Bank) {
|
||||
// Bank can access if they ARE the target bank (already checked above)
|
||||
// For other profile types, need to get linked users
|
||||
if (!($profile instanceof \App\Models\Bank)) {
|
||||
$authenticatedUser = $authenticatedProfile->users()->first();
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't get a User for relationship checking and it's not a direct match, deny
|
||||
if (!$authenticatedUser) {
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: Cannot validate cross-profile access');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate based on target profile type
|
||||
if ($profile instanceof \App\Models\User) {
|
||||
// User can only access their own user profile
|
||||
if ($profile->id !== $authenticatedUser->id) {
|
||||
Log::warning('ProfileAuthorizationHelper: Unauthorized User profile access attempt', [
|
||||
'authenticated_user_id' => $authenticatedUser->id,
|
||||
'target_user_id' => $profile->id,
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: You cannot access another user\'s profile');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} elseif ($profile instanceof \App\Models\Organization) {
|
||||
// User must be linked to this organization
|
||||
if (!$authenticatedUser->organizations()->where('organization_user.organization_id', $profile->id)->exists()) {
|
||||
Log::warning('ProfileAuthorizationHelper: Unauthorized Organization access attempt', [
|
||||
'authenticated_user_id' => $authenticatedUser->id,
|
||||
'target_organization_id' => $profile->id,
|
||||
'user_organizations' => $authenticatedUser->organizations()->pluck('organizations.id')->toArray(),
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: You are not linked to this organization');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} elseif ($profile instanceof \App\Models\Bank) {
|
||||
// User must be linked to this bank
|
||||
if (!$authenticatedUser->banksManaged()->where('bank_user.bank_id', $profile->id)->exists()) {
|
||||
Log::warning('ProfileAuthorizationHelper: Unauthorized Bank access attempt', [
|
||||
'authenticated_user_id' => $authenticatedUser->id,
|
||||
'target_bank_id' => $profile->id,
|
||||
'user_banks' => $authenticatedUser->banksManaged()->pluck('banks.id')->toArray(),
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: You are not linked to this bank');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} elseif ($profile instanceof \App\Models\Admin) {
|
||||
// User must be linked to this admin profile
|
||||
if (!$authenticatedUser->admins()->where('admin_user.admin_id', $profile->id)->exists()) {
|
||||
Log::warning('ProfileAuthorizationHelper: Unauthorized Admin access attempt', [
|
||||
'authenticated_user_id' => $authenticatedUser->id,
|
||||
'target_admin_id' => $profile->id,
|
||||
'user_admins' => $authenticatedUser->admins()->pluck('admins.id')->toArray(),
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(403, 'Unauthorized: You are not linked to this admin profile');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Unknown profile type
|
||||
Log::error('ProfileAuthorizationHelper: Unknown profile type', [
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_id' => $profile?->id,
|
||||
]);
|
||||
|
||||
if ($throwException) {
|
||||
abort(500, 'Unknown profile type');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authorization successful
|
||||
Log::info('ProfileAuthorizationHelper: Profile access authorized', [
|
||||
'authenticated_user_id' => $authenticatedUser->id,
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_id' => $profile->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate profile ownership and throw exception if unauthorized.
|
||||
*
|
||||
* Convenience method for the most common use case.
|
||||
*
|
||||
* @param mixed $profile The profile to validate
|
||||
* @return void
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
|
||||
*/
|
||||
public static function authorize($profile): void
|
||||
{
|
||||
self::validateProfileOwnership($profile, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to profile without throwing exception.
|
||||
*
|
||||
* @param mixed $profile The profile to check
|
||||
* @return bool True if authorized, false otherwise
|
||||
*/
|
||||
public static function can($profile): bool
|
||||
{
|
||||
return self::validateProfileOwnership($profile, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the web-authenticated user owns a profile (for profile switching).
|
||||
*
|
||||
* This method is specifically for profile switching and does NOT enforce guard matching
|
||||
* since during a switch, the user is authenticated on 'web' guard but wants to access
|
||||
* an elevated profile (Admin, Bank, Organization).
|
||||
*
|
||||
* @param mixed $profile The profile to check ownership of
|
||||
* @return bool True if the web-authenticated user owns this profile
|
||||
*/
|
||||
public static function userOwnsProfile($profile): bool
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
if (!$user || !$profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check based on profile type
|
||||
if ($profile instanceof \App\Models\User) {
|
||||
return $profile->id === $user->id;
|
||||
} elseif ($profile instanceof \App\Models\Organization) {
|
||||
return $user->organizations()->where('organization_user.organization_id', $profile->id)->exists();
|
||||
} elseif ($profile instanceof \App\Models\Bank) {
|
||||
return $user->banksManaged()->where('bank_user.bank_id', $profile->id)->exists();
|
||||
} elseif ($profile instanceof \App\Models\Admin) {
|
||||
return $user->admins()->where('admin_user.admin_id', $profile->id)->exists();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user