diff --git a/app/Dto/Repository/DataCaptchaRepository/DataCaptcha.php b/app/Dto/Repository/DataCaptchaRepository/DataCaptcha.php new file mode 100644 index 0000000..8007dd8 --- /dev/null +++ b/app/Dto/Repository/DataCaptchaRepository/DataCaptcha.php @@ -0,0 +1,21 @@ +captchaId; + } + + public function getCoordinators(): array + { + return $this->coordinators; + } +} diff --git a/app/Dto/Request/Api/V1/Captcha/CheckingDto.php b/app/Dto/Request/Api/V1/Captcha/CheckingDto.php new file mode 100644 index 0000000..81b31d9 --- /dev/null +++ b/app/Dto/Request/Api/V1/Captcha/CheckingDto.php @@ -0,0 +1,29 @@ +captchaPublicToken; + } + + public function getCaptchaKey(): string + { + return $this->captchaKey; + } + + public function getCoordinators(): array + { + return $this->coordinators; + } +} diff --git a/app/Enums/CaptchaLogType.php b/app/Enums/CaptchaLogType.php index 34f2dc2..6ccfefc 100644 --- a/app/Enums/CaptchaLogType.php +++ b/app/Enums/CaptchaLogType.php @@ -5,4 +5,6 @@ namespace App\Enums; enum CaptchaLogType: int { case Created = 1; + case Error = 2; + case Verified = 3; } diff --git a/app/Http/Controllers/Api/V1/CaptchaController.php b/app/Http/Controllers/Api/V1/CaptchaController.php index 989c759..070a06c 100644 --- a/app/Http/Controllers/Api/V1/CaptchaController.php +++ b/app/Http/Controllers/Api/V1/CaptchaController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Requests\Api\V1\Captcha\CaptchaRequest; +use App\Http\Requests\Api\V1\Captcha\CheckingRequest; use App\Http\Resources\Api\V1\Captcha; +use App\Http\Resources\Api\V1\CaptchaVerified; use App\Services\Api\V1\CaptchaService; use Illuminate\Http\JsonResponse; @@ -24,4 +26,16 @@ final class CaptchaController extends Controller return response()->json(new Captcha($result)); } + + public function checking(CheckingRequest $request): JsonResponse + { + $maxCountError = config('captcha.validate_max_count_errors'); + $params = $request->getDto(); + $result = $this->captchaService->checking($params, $maxCountError); + if (!$result->isSuccess()) { + return response()->json($result->getData())->setStatusCode($result->getCode()); + } + + return response()->json(new CaptchaVerified($result)); + } } diff --git a/app/Http/Requests/Api/V1/Captcha/CheckingRequest.php b/app/Http/Requests/Api/V1/Captcha/CheckingRequest.php new file mode 100644 index 0000000..5601d60 --- /dev/null +++ b/app/Http/Requests/Api/V1/Captcha/CheckingRequest.php @@ -0,0 +1,68 @@ +hasHeader('public-token')) { + return false; + } + $captchaToken = $captchaTokenRepository->getCaptchaTokenByPublicToken($this->header('public-token')); + if (is_null($captchaToken)) { + return false; + } + + $this->captchaToken = $captchaToken; + + return true; + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'captcha_key' => ['required', 'string', 'max:75'], + 'verification' => ['required', 'array'], + 'verification.*' => ['required', 'array', 'size:2'], + 'verification.*.x' => ['required', 'numeric', 'min:0'], + 'verification.*.y' => ['required', 'numeric', 'min:0'], + ]; + } + + public function getDto(): CheckingDto + { + $httpUserData = new HttpUserData( + $this->getClientIp(), + $this->userAgent(), + $this->header('referer') + ); + + $captchaPublicToken = new CaptchaPublicToken( + $this->captchaToken, + $httpUserData + ); + + return new CheckingDto( + captchaPublicToken: $captchaPublicToken, + captchaKey: $this->input('captcha_key'), + coordinators: $this->input('verification'), + ); + } +} diff --git a/app/Http/Resources/Api/V1/CaptchaVerified.php b/app/Http/Resources/Api/V1/CaptchaVerified.php new file mode 100644 index 0000000..febee6d --- /dev/null +++ b/app/Http/Resources/Api/V1/CaptchaVerified.php @@ -0,0 +1,28 @@ + $this->resource->getKey() + ]; + } +} diff --git a/app/Models/CaptchaLog.php b/app/Models/CaptchaLog.php index 1689454..a2f564b 100644 --- a/app/Models/CaptchaLog.php +++ b/app/Models/CaptchaLog.php @@ -20,6 +20,7 @@ final class CaptchaLog extends Model * @var array */ protected $fillable = [ + 'captcha_id', 'type', 'ip', 'user_agent', diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index f953b4c..284cff4 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -50,5 +50,10 @@ class RouteServiceProvider extends ServiceProvider Limit::perHour(config('rate_limiting.login_max_email_request', 10))->by($request->getClientIp() . '-' . $request->input('email')), ]; }); + RateLimiter::for('captcha-checking', function (Request $request) { + return [ + Limit::perMinute(config('captcha.validate_max_count_errors', 5))->by($request->input('captcha_key', null)), + ]; + }); } } diff --git a/app/Repositories/CaptchaLogRepository.php b/app/Repositories/CaptchaLogRepository.php new file mode 100644 index 0000000..6be864d --- /dev/null +++ b/app/Repositories/CaptchaLogRepository.php @@ -0,0 +1,20 @@ +when($captchaId, function (Builder $query, int $captchaId) { + $query->where('id', $captchaId); + }) + ->where('type', '=', $type) + ->count(); + } +} diff --git a/app/Repositories/CaptchaRepository.php b/app/Repositories/CaptchaRepository.php new file mode 100644 index 0000000..18f6184 --- /dev/null +++ b/app/Repositories/CaptchaRepository.php @@ -0,0 +1,13 @@ +where('id', $id)->first(); + } +} diff --git a/app/Repositories/DataCaptchaRepository.php b/app/Repositories/DataCaptchaRepository.php index 464eaf8..570d362 100644 --- a/app/Repositories/DataCaptchaRepository.php +++ b/app/Repositories/DataCaptchaRepository.php @@ -2,6 +2,7 @@ namespace App\Repositories; +use App\Dto\Repository\DataCaptchaRepository\DataCaptcha; use App\Exceptions\Repositories\DataCaptchaRepositoryException; use App\Models\Captcha; use App\Services\GenerateTokenCommand\GenerateTokenUlidCommand; @@ -28,4 +29,22 @@ final readonly class DataCaptchaRepository return $key; } + + public function getByKey(string $key): ?DataCaptcha + { + $dataCaptcha = Cache::driver('redis')->get($key); + if (is_null($dataCaptcha)) { + return null; + } + + return new DataCaptcha( + captchaId: $dataCaptcha['id'], + coordinators: $dataCaptcha['coordinators'], + ); + } + + public function destroy(string $key): void + { + Cache::driver('redis')->delete($key); + } } diff --git a/app/ServiceResults/Api/V1/CaptchaService/CaptchaVerifiedResult.php b/app/ServiceResults/Api/V1/CaptchaService/CaptchaVerifiedResult.php new file mode 100644 index 0000000..de9d62c --- /dev/null +++ b/app/ServiceResults/Api/V1/CaptchaService/CaptchaVerifiedResult.php @@ -0,0 +1,17 @@ +key; + } +} diff --git a/app/Services/Api/V1/CaptchaService.php b/app/Services/Api/V1/CaptchaService.php index 008e41a..a64172a 100644 --- a/app/Services/Api/V1/CaptchaService.php +++ b/app/Services/Api/V1/CaptchaService.php @@ -3,10 +3,17 @@ namespace App\Services\Api\V1; use App\Dto\Request\Api\V1\Captcha\CaptchaPublicToken; +use App\Dto\Request\Api\V1\Captcha\CheckingDto; +use App\Enums\CaptchaLogType; +use App\Repositories\CaptchaLogRepository; +use App\Repositories\CaptchaRepository; use App\Repositories\DataCaptchaRepository; use App\ServiceResults\Api\V1\CaptchaService\Captcha; +use App\ServiceResults\Api\V1\CaptchaService\CaptchaVerifiedResult; use App\ServiceResults\ServiceResultError; use App\Services\Captcha\CaptchaHandler; +use App\Services\Captcha\CheckingCommand; +use App\Services\CaptchaLog\CaptchaLogHandler; use App\Services\Service; use Carbon\Carbon; use Illuminate\Support\Facades\DB; @@ -16,7 +23,11 @@ final class CaptchaService extends Service public function __construct( private readonly CaptchaGenerateService $captchaGenerateService, private readonly CaptchaHandler $captchaHandler, + private readonly CaptchaLogHandler $captchaLogHandler, private readonly DataCaptchaRepository $dataCaptchaRepository, + private readonly CaptchaLogRepository $captchaLogRepository, + private readonly CaptchaRepository $captchaRepository, + private readonly CheckingCommand $checkingCommand, ) { } public function createKeyWithCaptcha(CaptchaPublicToken $captchaPublicToken, Carbon $expires): ServiceResultError | Captcha @@ -43,4 +54,40 @@ final class CaptchaService extends Service key: $captchaKey ); } + + public function checking(CheckingDto $checkingDto, int $maxCountError): ServiceResultError | CaptchaVerifiedResult + { + try { + $captchaData = $this->dataCaptchaRepository->getByKey($checkingDto->getCaptchaKey()); + if (is_null($captchaData)) { + return $this->errValidate(__('Captcha not found or verification period has expired. Please try to refresh the captcha.')); + } + if (!$this->checkingCommand->execute($captchaData->getCoordinators(), $checkingDto->getCoordinators())) { + $this->captchaLogHandler->handleStore($captchaData->getCaptchaId(), CaptchaLogType::Error, $checkingDto->getCaptchaPublicToken()->getHttpUserData()); + return $this->errValidate(__('CAPTCHA validation failed. Please try again.')); + } + $result = DB::transaction(function () use ($checkingDto, $captchaData, $maxCountError) { + $errorCount = $this->captchaLogRepository->countByType(CaptchaLogType::Error, $captchaData->getCaptchaId()); + if ($errorCount > $maxCountError) { + $this->dataCaptchaRepository->destroy($checkingDto->getCaptchaKey()); + return $this->errValidate(__('You have exceeded the number of attempts. Please refresh the captcha.')); + } + + $this->captchaLogHandler->handleStore($captchaData->getCaptchaId(), CaptchaLogType::Verified, $checkingDto->getCaptchaPublicToken()->getHttpUserData()); + $this->dataCaptchaRepository->destroy($checkingDto->getCaptchaKey()); + + $modelCaptcha = $this->captchaRepository->getCaptchaById($captchaData->getCaptchaId()); + if (is_null($modelCaptcha)) { + return $this->errValidate(__('Captcha not found or verification period has expired. Please try to refresh the captcha.')); + } + + return new CaptchaVerifiedResult(key: $modelCaptcha->uuid); + }); + } catch (\Throwable $e) { + report($e); + return $this->errService('Captcha service error!'); + } + + return $result; + } } diff --git a/app/Services/Captcha/CaptchaHandler.php b/app/Services/Captcha/CaptchaHandler.php index 12fd273..43aa1ce 100644 --- a/app/Services/Captcha/CaptchaHandler.php +++ b/app/Services/Captcha/CaptchaHandler.php @@ -21,7 +21,7 @@ final readonly class CaptchaHandler $captcha = $captchaToken->captchas()->create([ 'uuid' => $this->uuidCommand->unique($captchaToken->captchas()->getQuery(), 'uuid') ]); - $this->captchaLogHandler->handleStore($captcha, CaptchaLogType::Created, $httpUserData); + $this->captchaLogHandler->handleStore($captcha->id, CaptchaLogType::Created, $httpUserData); return $captcha; } diff --git a/app/Services/Captcha/CheckingCommand.php b/app/Services/Captcha/CheckingCommand.php new file mode 100644 index 0000000..286db18 --- /dev/null +++ b/app/Services/Captcha/CheckingCommand.php @@ -0,0 +1,53 @@ + $coordinators) { + /** + * @var Coordinators $coordinators + */ + + if (empty($userCoordinators[$index]) || !isset($userCoordinators[$index]['x']) || !isset($userCoordinators[$index]['y'])) { + return false; + } + + if (!$this->isInside($coordinators, $userCoordinators[$index])) { + return false; + } + } + + return true; + } + + private function isInside(Coordinators $captchaCoordinators, array $userCoordinators): bool + { + $xMin = min($captchaCoordinators->getX1(), $captchaCoordinators->getX2(), $captchaCoordinators->getX3(), $captchaCoordinators->getX4()); + $xMax = max($captchaCoordinators->getX1(), $captchaCoordinators->getX2(), $captchaCoordinators->getX3(), $captchaCoordinators->getX4()); + + $yMin = min($captchaCoordinators->getY1(), $captchaCoordinators->getY2(), $captchaCoordinators->getY3(), $captchaCoordinators->getY4()); + $yMax = max($captchaCoordinators->getY1(), $captchaCoordinators->getY2(), $captchaCoordinators->getY3(), $captchaCoordinators->getY4()); + + if ( + $xMin > $userCoordinators['x'] + || $xMax < $userCoordinators['x'] + || $yMin > $userCoordinators['y'] + || $yMax < $userCoordinators['y'] + ) { + return false; + } + + return true; + } +} diff --git a/app/Services/CaptchaLog/CaptchaLogHandler.php b/app/Services/CaptchaLog/CaptchaLogHandler.php index c0cc2cf..bba2f51 100644 --- a/app/Services/CaptchaLog/CaptchaLogHandler.php +++ b/app/Services/CaptchaLog/CaptchaLogHandler.php @@ -10,13 +10,24 @@ use Illuminate\Support\Str; final readonly class CaptchaLogHandler { - public function handleStore(Captcha $captcha, CaptchaLogType $captchaLogType, HttpUserData $httpUserData): CaptchaLog + public function handleStore(int $captchaId, CaptchaLogType $captchaLogType, HttpUserData $httpUserData): CaptchaLog { - return $captcha->captchaLogs()->create([ + $userAgent = $httpUserData->getUserAgent(); + if (!is_null($userAgent)) { + $userAgent = Str::limit($userAgent, 255, ''); + } + + $referer = $httpUserData->getReferer(); + if (!is_null($referer)) { + $referer = Str::limit($referer, 10000, ''); + } + + return CaptchaLog::create([ + 'captcha_id' => $captchaId, 'type' => $captchaLogType, 'ip' => $httpUserData->getClientIp(), - 'user_agent' => Str::limit($httpUserData->getUserAgent(), 255, ''), - 'referer' => Str::limit($httpUserData->getReferer(), 10000, ''), + 'user_agent' => $userAgent, + 'referer' => $referer, ]); } } diff --git a/config/captcha.php b/config/captcha.php index a640364..4943a2f 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -2,6 +2,7 @@ return [ 'waiting_for_captcha_verification_in_seconds' => env('WAITING_FOR_CAPTCHA_VERIFICATION_IN_SECONDS', 900), + 'validate_max_count_errors' => env('CAPTCHA_VALIDATE_MAX_COUNT_ERRORS', 3), 'imageClass' => \App\Captcha\Images\Image::class, 'types' => [ 'string' => [ diff --git a/lang/en.json b/lang/en.json index c9cf9e4..25d656a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -62,5 +62,8 @@ "Captcha token created successfully": "Captcha token created successfully", "Captcha token updated successfully": "Captcha token updated successfully", "The captcha token has been removed": "The captcha token has been removed", - "Failed to generate token.": "Failed to generate token." + "Failed to generate token.": "Failed to generate token.", + "Captcha not found or verification period has expired. Please try to refresh the captcha.": "Captcha not found or verification period has expired. Please try to refresh the captcha.", + "CAPTCHA validation failed. Please try again.": "CAPTCHA validation failed. Please try again.", + "You have exceeded the number of attempts. Please refresh the captcha.": "You have exceeded the number of attempts. Please refresh the captcha." } diff --git a/lang/ru.json b/lang/ru.json index 81b8482..6a9f7ad 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -62,5 +62,8 @@ "Captcha token created successfully": "Токен капчи успешно создан", "Captcha token updated successfully": "Токен капчи успешно обновлен", "The captcha token has been removed": "Токен капчи был удален", - "Failed to generate token.": "Не удалось сгенерировать токен." + "Failed to generate token.": "Не удалось сгенерировать токен.", + "Captcha not found or verification period has expired. Please try to refresh the captcha.": "Капча не найдена или истёк срок проверки. По пробуйтн обновить капчу.", + "CAPTCHA validation failed. Please try again.": "Не удалось пройти проверку CAPTCHA. Пожалуйста, попробуйте еще раз.", + "You have exceeded the number of attempts. Please refresh the captcha.": "Вы превысили количество попыток. Обновите каптчу." } diff --git a/public/swagger.json b/public/swagger.json index 6d41a88..0b2aad4 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -198,15 +198,12 @@ "CaptchaVerification": { "type": "object", "properties": { - "status": { - "type": "boolean" - }, - "message": { + "captcha_key": { "type": "string" } }, "required": [ - "status", "message" + "captcha_key" ] }, "CaptchaChecking": { diff --git a/routes/api-v1.php b/routes/api-v1.php index a6295b9..fcbfb6b 100644 --- a/routes/api-v1.php +++ b/routes/api-v1.php @@ -15,3 +15,4 @@ use Illuminate\Support\Facades\Route; */ Route::get('/captcha', [\App\Http\Controllers\Api\V1\CaptchaController::class, 'getCaptcha']); +Route::middleware(['throttle:captcha-checking'])->post('/captcha', [\App\Http\Controllers\Api\V1\CaptchaController::class, 'checking']);