Files
timebank-cc-public/app/Helpers/ProfileAuthorizationHelper.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

287 lines
12 KiB
PHP

<?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;
}
}