bearerToken(); if (! is_string($plain) || $plain === '') { return $this->deny($request, 'TOKEN_MISSING', 401); } $token = EarnApiToken::findToken($plain); if (! $token instanceof EarnApiToken || ! $token->tokenable instanceof EarnMember) { return $this->deny($request, 'TOKEN_INVALID', 401); } $request->attributes->set('earn_api_token', $token); $code = match ($token->status()) { EarnApiTokenStatus::Inactive => 'TOKEN_INACTIVE', EarnApiTokenStatus::Expired => 'TOKEN_EXPIRED', EarnApiTokenStatus::Revoked => 'TOKEN_REVOKED', default => null, }; if ($code) { return $this->deny($request, $code, 401, 'token_'.strtolower(substr($code, 6))); } $member = $token->tokenable; if ($member->trashed() || $member->status->value !== 'active') { return $this->deny($request, 'MEMBER_INACTIVE', 403, 'member_inactive'); } if (! $member->hasVerifiedEmail()) { return $this->deny($request, 'EMAIL_NOT_VERIFIED', 403); } if (! $token->can($ability)) { return $this->deny($request, 'TOKEN_INVALID', 401); } $deviceId = $request->header('X-Device-ID'); if (! is_string($deviceId)) { return $this->deny($request, 'DEVICE_ID_MISSING', 422); } $deviceId = $this->devices->normalize($deviceId); if (! $this->devices->valid($deviceId)) { return $this->deny($request, 'DEVICE_ID_INVALID', 422); } $hash = $this->devices->hash($deviceId); $preview = $this->devices->preview($deviceId); $request->attributes->set('earn_device_hash', $hash); $request->attributes->set('earn_device_preview', $preview); $limit = (int) config(match ($mode) { 'bind' => 'earn.api.bind_per_minute', 'results' => 'earn.api.task_results_per_minute', default => 'earn.api.tasks_per_minute', }); $rateKey = 'earn-api:'.$mode.':'.hash('sha256', $token->id.'|'.$hash.'|'.$request->ip()); if (RateLimiter::tooManyAttempts($rateKey, $limit)) { $this->logs->record($request, $mode === 'results' ? 'task_result_rate_limited' : 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview); return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($rateKey)); } RateLimiter::hit($rateKey, 60); if (in_array($mode, ['tasks', 'results'], true) && $token->device_id_hash === null) { return $this->deny($request, 'DEVICE_NOT_BOUND', 409, 'device_not_bound', $hash, $preview); } if (in_array($mode, ['tasks', 'results'], true) && ! hash_equals($token->device_id_hash, $hash)) { $failedKey = 'earn-api:failed-device:'.hash('sha256', $token->id.'|'.$request->ip()); $failedLimit = (int) config('earn.api.failed_device_per_minute'); if (RateLimiter::tooManyAttempts($failedKey, $failedLimit)) { $this->logs->record($request, 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview); return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($failedKey)); } RateLimiter::hit($failedKey, 60); $token->forceFill(['device_mismatch_seen' => true])->saveQuietly(); return $this->deny($request, 'DEVICE_MISMATCH', 409, 'device_mismatch', $hash, $preview); } $member->withAccessToken($token); $request->setUserResolver(fn () => $member); $response = $next($request); if (in_array($mode, ['tasks', 'results'], true) && $response->isSuccessful()) { if ($token->last_ip !== null && $token->last_ip !== $request->ip()) { $this->logs->record($request, 'ip_changed', $response->getStatusCode(), null, $token, $hash, $preview); } $token->forceFill(['last_used_at' => now(), 'last_seen_at' => now(), 'last_ip' => $request->ip()])->saveQuietly(); $this->logs->record($request, $mode === 'results' ? 'task_results_access_granted' : 'tasks_access_granted', $response->getStatusCode(), null, $token, $hash, $preview); } return $response; } private function deny(Request $request, string $code, int $status, string $event = 'tasks_access_denied', ?string $hash = null, ?string $preview = null): JsonResponse { $this->logs->record($request, $event, $status, $code, null, $hash, $preview); return $this->error($code, $status); } private function error(string $code, int $status): JsonResponse { return response()->json(['success' => false, 'error' => ['code' => $code, 'message' => __('earn_api.'.$code)]], $status)->header('Cache-Control', 'no-store'); } }