Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.30% |
108 / 111 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| MasqueradeService | |
97.30% |
108 / 111 |
|
66.67% |
2 / 3 |
9 | |
0.00% |
0 / 1 |
| generate | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
5 | |||
| generateCompanyToken | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
1 | |||
| start | |
92.68% |
38 / 41 |
|
0.00% |
0 / 1 |
3.00 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Services\Admin; |
| 4 | |
| 5 | use App\Http\Models\Admin\Company; |
| 6 | use App\Http\Models\Auth\User; |
| 7 | use App\Http\Models\MasqueradeLog; |
| 8 | use Carbon\Carbon; |
| 9 | use Firebase\JWT\JWT; |
| 10 | use Firebase\JWT\Key; |
| 11 | use Illuminate\Http\Request; |
| 12 | |
| 13 | /** |
| 14 | * MasqueradeService |
| 15 | * |
| 16 | * Handles admin masquerade (impersonation) operations using JWT tokens. |
| 17 | * The generate() method creates a JWT bundling the masqueraded user's Passport |
| 18 | * access token and admin session backup. The start() method decodes the JWT |
| 19 | * and returns session data for the frontend to switch contexts. |
| 20 | */ |
| 21 | class MasqueradeService |
| 22 | { |
| 23 | /** |
| 24 | * Generate a masquerade JWT token for impersonating a target user. |
| 25 | * |
| 26 | * Creates a Passport token for the target user, bundles it with the admin's |
| 27 | * session data into a signed JWT, and returns the token with a masquerade URL. |
| 28 | * |
| 29 | * @param User $admin The admin user performing the masquerade |
| 30 | * @param array $data Validated request data containing user_id, optional company_id, and optional admin_refresh_token |
| 31 | * @param Request $request The HTTP request (for audit logging) |
| 32 | * @return array{masquerade_token: string, masquerade_url: string, expires_at: int} |
| 33 | * |
| 34 | * @throws \Illuminate\Database\Eloquent\ModelNotFoundException When target user or company not found |
| 35 | */ |
| 36 | public function generate(User $admin, array $data, Request $request): array |
| 37 | { |
| 38 | $companyId = $data['company_id'] ?? null; |
| 39 | |
| 40 | $query = User::where('_id', $data['user_id']); |
| 41 | if ($companyId) { |
| 42 | $query->where('company_id', $companyId); |
| 43 | } |
| 44 | $targetUser = $query->firstOrFail(); |
| 45 | |
| 46 | $company = $companyId ? Company::findOrFail($companyId) : $targetUser->company; |
| 47 | |
| 48 | $expiryHours = config('services.masquerade.token_expiry_hours', 2); |
| 49 | $expiresAt = Carbon::now()->addHours($expiryHours); |
| 50 | |
| 51 | $tokenResult = $targetUser->createToken('MasqueradeToken'); |
| 52 | $tokenResult->token->expires_at = $expiresAt; |
| 53 | $tokenResult->token->save(); |
| 54 | |
| 55 | $secret = config('services.masquerade.jwt_secret'); |
| 56 | |
| 57 | $payload = [ |
| 58 | 'masquerade_access_token' => $tokenResult->accessToken, |
| 59 | 'masquerade_user_id' => (string) $targetUser->_id, |
| 60 | 'masquerade_company_id' => $company ? (string) $company->_id : null, |
| 61 | 'masquerade_company_slug' => $company?->slug, |
| 62 | 'masquerade_company_name' => $company?->name, |
| 63 | 'admin_access_token' => $request->bearerToken(), |
| 64 | 'admin_refresh_token' => $data['admin_refresh_token'] ?? null, |
| 65 | 'admin_user_id' => (string) $admin->_id, |
| 66 | 'iat' => now()->timestamp, |
| 67 | 'exp' => $expiresAt->timestamp, |
| 68 | ]; |
| 69 | |
| 70 | $jwt = JWT::encode($payload, $secret, 'HS256'); |
| 71 | |
| 72 | $masqueradeUrl = rtrim(config('romeo.frontend-base-url'), '/').'/masquerade?token='.$jwt; |
| 73 | |
| 74 | MasqueradeLog::create([ |
| 75 | 'admin_id' => (string) $admin->_id, |
| 76 | 'admin_email' => $admin->email, |
| 77 | 'target_user_id' => (string) $targetUser->_id, |
| 78 | 'target_user_email' => $targetUser->email, |
| 79 | 'company_id' => $company ? (string) $company->_id : null, |
| 80 | 'action' => 'generate', |
| 81 | 'ip_address' => $request->ip(), |
| 82 | 'user_agent' => $request->userAgent(), |
| 83 | 'expires_at' => $expiresAt, |
| 84 | ]); |
| 85 | |
| 86 | return [ |
| 87 | 'masquerade_token' => $jwt, |
| 88 | 'masquerade_url' => $masqueradeUrl, |
| 89 | 'expires_at' => $expiresAt->timestamp, |
| 90 | ]; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Generate a masquerade JWT token for a company context. |
| 95 | * |
| 96 | * Creates a signed JWT containing company metadata so the admin portal |
| 97 | * can switch the company context. No Passport access token swap is needed. |
| 98 | * |
| 99 | * @param User $admin The admin user performing the masquerade |
| 100 | * @param Company $company The target company to masquerade into |
| 101 | * @param Request $request The HTTP request (for audit logging) |
| 102 | * @return array{masquerade_url: string, expires_at: int} |
| 103 | */ |
| 104 | public function generateCompanyToken(User $admin, Company $company, Request $request): array |
| 105 | { |
| 106 | $expiryHours = config('services.masquerade.token_expiry_hours', 2); |
| 107 | $expiresAt = Carbon::now()->addHours($expiryHours); |
| 108 | |
| 109 | $secret = config('services.masquerade.jwt_secret'); |
| 110 | |
| 111 | $payload = [ |
| 112 | 'masquerade_company_id' => (string) $company->_id, |
| 113 | 'masquerade_company_slug' => $company->slug, |
| 114 | 'masquerade_company_name' => $company->name, |
| 115 | 'admin_user_id' => (string) $admin->_id, |
| 116 | 'iat' => now()->timestamp, |
| 117 | 'exp' => $expiresAt->timestamp, |
| 118 | ]; |
| 119 | |
| 120 | $jwt = JWT::encode($payload, $secret, 'HS256'); |
| 121 | |
| 122 | $masqueradeUrl = rtrim(config('romeo.admin-app-url'), '/').'/masquerade/company?token='.$jwt; |
| 123 | |
| 124 | MasqueradeLog::create([ |
| 125 | 'admin_id' => (string) $admin->_id, |
| 126 | 'admin_email' => $admin->email, |
| 127 | 'target_user_id' => null, |
| 128 | 'target_user_email' => null, |
| 129 | 'company_id' => (string) $company->_id, |
| 130 | 'action' => 'generate_company', |
| 131 | 'ip_address' => $request->ip(), |
| 132 | 'user_agent' => $request->userAgent(), |
| 133 | 'expires_at' => $expiresAt, |
| 134 | ]); |
| 135 | |
| 136 | return [ |
| 137 | 'masquerade_url' => $masqueradeUrl, |
| 138 | 'expires_at' => $expiresAt->timestamp, |
| 139 | ]; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Start a masquerade session by decoding a JWT token. |
| 144 | * |
| 145 | * Validates and decodes the masquerade JWT, looks up the target user and admin user, and returns session data for the frontend to switch contexts. |
| 146 | * |
| 147 | * @param string $token The masquerade JWT token |
| 148 | * @param Request $request The HTTP request (for audit logging) |
| 149 | * @return array{user: array, admin_backup: array, expires_at: int} |
| 150 | * |
| 151 | * @throws \Firebase\JWT\ExpiredException When JWT has expired |
| 152 | * @throws \Firebase\JWT\SignatureInvalidException When JWT signature is invalid |
| 153 | * @throws \UnexpectedValueException When JWT is malformed |
| 154 | */ |
| 155 | public function start(string $token, Request $request): array |
| 156 | { |
| 157 | $parts = explode('.', $token); |
| 158 | if (count($parts) !== 3) { |
| 159 | throw new \UnexpectedValueException('Invalid masquerade token format'); |
| 160 | } |
| 161 | |
| 162 | $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); |
| 163 | if (($header['alg'] ?? null) !== 'HS256') { |
| 164 | throw new \UnexpectedValueException( |
| 165 | 'Invalid token: expected masquerade token (HS256), got '.($header['alg'] ?? 'unknown').'. Ensure you are passing the masquerade_token from the generate endpoint, not a Passport access token.' |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | $secret = config('services.masquerade.jwt_secret'); |
| 170 | $decoded = JWT::decode($token, new Key($secret, 'HS256')); |
| 171 | |
| 172 | $targetUser = User::with('company')->findOrFail($decoded->masquerade_user_id); |
| 173 | $adminUser = User::findOrFail($decoded->admin_user_id); |
| 174 | |
| 175 | $targetUser['is_poc'] = $targetUser->isPOC(); |
| 176 | |
| 177 | MasqueradeLog::create([ |
| 178 | 'admin_id' => $decoded->admin_user_id, |
| 179 | 'admin_email' => $adminUser->email, |
| 180 | 'target_user_id' => $decoded->masquerade_user_id, |
| 181 | 'target_user_email' => $targetUser->email, |
| 182 | 'company_id' => $decoded->masquerade_company_id, |
| 183 | 'action' => 'start', |
| 184 | 'ip_address' => $request->ip(), |
| 185 | 'user_agent' => $request->userAgent(), |
| 186 | 'expires_at' => Carbon::createFromTimestamp($decoded->exp), |
| 187 | ]); |
| 188 | |
| 189 | return [ |
| 190 | 'user' => [ |
| 191 | 'token_type' => 'Bearer', |
| 192 | 'access_token' => $decoded->masquerade_access_token, |
| 193 | 'expires_in' => $decoded->exp - now()->timestamp, |
| 194 | 'session_expires_in' => intval(config('auth.passport.refresh_token_expiry')), |
| 195 | 'user_details' => $targetUser, |
| 196 | 'company' => $decoded->masquerade_company_slug, |
| 197 | ], |
| 198 | 'admin_backup' => [ |
| 199 | 'access_token' => $decoded->admin_access_token, |
| 200 | 'refresh_token' => $decoded->admin_refresh_token ?? null, |
| 201 | 'user_id' => $decoded->admin_user_id, |
| 202 | 'email' => $adminUser->email, |
| 203 | ], |
| 204 | 'expires_at' => $decoded->exp, |
| 205 | ]; |
| 206 | } |
| 207 | } |