error(ErrorCodes::ERR_AUTH_005, ErrorCodes::message(ErrorCodes::ERR_AUTH_005), 401); } #[OA\Post( path: '/api/v1/user/send-code', summary: 'مرحله ۱ — ارسال کد OTP به موبایل', description: "**جریان لاگین با موبایل (OTP):**\n\n**مرحله ۱:** ارسال کد → **مرحله ۲:** تأیید کد (`/api/v1/user/verify-code`) → **مرحله ۳:** دریافت JWT (`/oauth/token`)\n\n> ⚠️ در محیط **dev** پیامکی ارسال نمی‌شود و کد همیشه `12345` است.", requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['mobile'], properties: [ new OA\Property(property: 'mobile', type: 'string', example: '09120671713', description: 'شماره موبایل ۱۱ رقمی'), ] ) ), responses: [ new OA\Response( response: 200, description: 'کد OTP ارسال شد — uuid را برای مرحله بعد نگه دارید', content: new OA\JsonContent( properties: [ new OA\Property(property: 'uuid', type: 'string', format: 'uuid', description: 'شناسه یکتا برای verify-code و oauth/token'), new OA\Property(property: 'message', type: 'string', example: 'کد تایید با موفقیت ارسال شد.'), ] ) ), new OA\Response(response: 422, description: 'فرمت موبایل نادرست'), new OA\Response(response: 429, description: 'تعداد درخواست از حد مجاز گذشت (۳ بار در ۵ دقیقه)'), ] )] #[Route('/api/v1/user/send-code', methods: ['POST'])] public function sendCode(Request $request): JsonResponse { $limiter = $this->sendCodeLimiter->create($request->getClientIp() ?? 'unknown'); if (!$limiter->consume(1)->isAccepted()) { return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); } $data = json_decode($request->getContent(), true) ?? []; $mobile = trim($data['mobile'] ?? ''); if (!preg_match('/^09[0-9]{9}$/', $mobile)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile'); } // Per-mobile cap (in addition to per-IP) so a victim's number can't be // SMS-flooded from rotating IPs. $mobileLimiter = $this->sendCodeLimiter->create('mobile:' . $mobile); if (!$mobileLimiter->consume(1)->isAccepted()) { return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); } $uuid = $this->otpService->sendCode($mobile); return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']); } #[OA\Post( path: '/api/v1/user/verify-code', summary: 'مرحله ۲ — تأیید کد OTP', description: "uuid را از مرحله ۱ (`/api/v1/user/send-code`) وارد کنید.\n\n> در محیط **dev** کد همیشه `12345` است.\n\nپس از تأیید موفق، به مرحله ۳ (`/oauth/token`) بروید.", requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['uuid', 'code'], properties: [ new OA\Property(property: 'uuid', type: 'string', format: 'uuid', description: 'uuid دریافت‌شده از send-code'), new OA\Property(property: 'code', type: 'string', example: '12345', description: 'کد ۵ رقمی — در dev همیشه 12345'), ] ) ), responses: [ new OA\Response( response: 200, description: 'کد تأیید شد — به مرحله ۳ بروید', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', properties: [ new OA\Property(property: 'message', type: 'string', example: 'کد با موفقیت تایید شد.'), new OA\Property(property: 'is_new_user', type: 'boolean', example: false, description: 'اگر true باشد کاربر جدید است و باید ثبت‌نام کند (`/api/v1/user/register`)'), ], type: 'object'), ] ) ), new OA\Response(response: 400, description: 'کد نادرست یا uuid منقضی'), new OA\Response(response: 422, description: 'uuid یا code ارسال نشده'), new OA\Response(response: 429, description: 'تعداد تلاش از حد مجاز گذشت (۵ بار)'), ] )] #[Route('/api/v1/user/verify-code', methods: ['POST'])] public function verifyCode(Request $request): JsonResponse { if ($resp = $this->enforceLimit($this->verifyCodeLimiter, $request)) { return $resp; } $data = json_decode($request->getContent(), true) ?? []; $uuid = trim($data['uuid'] ?? ''); $code = trim($data['code'] ?? ''); if (empty($uuid) || empty($code)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid و code الزامی است', 422); } $otpData = $this->otpService->verifyCode($uuid, $code); $isNewUser = $this->userRepo->findByMobile($otpData['mobile']) === null; return $this->success([ 'message' => 'کد با موفقیت تایید شد.', 'grant' => $otpData['grant'], 'is_new_user' => $isNewUser, ]); } #[OA\Post( path: '/api/v1/user/register', summary: 'Register a new user after OTP verification', requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['grant'], properties: [ new OA\Property(property: 'grant', type: 'string', description: 'grant یک‌بارمصرف از verify-code'), new OA\Property(property: 'real_name', type: 'string', example: 'علی محمدی'), ] ) ), responses: [ new OA\Response( response: 201, description: 'User registered successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'message', type: 'string', example: 'ثبت‌نام با موفقیت انجام شد.'), new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), ], type: 'object' ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response( response: 422, description: 'Missing uuid', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: false), new OA\Property( property: 'errors', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'code', type: 'string'), new OA\Property(property: 'message', type: 'string'), ], type: 'object' ) ), ] ) ), ] )] #[Route('/api/v1/user/register', methods: ['POST'])] public function register(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $grant = trim($data['grant'] ?? ''); $realName = trim($data['real_name'] ?? ''); if ($grant === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422); } $mobile = $this->otpService->consumeGrant($grant); $user = $this->userRepo->findByMobile($mobile) ?? new User($mobile); if ($realName !== '') { $user->setRealName($realName); } $this->userRepo->save($user); return $this->success(['message' => 'ثبت‌نام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201); } #[OA\Post( path: '/oauth/token', summary: 'مرحله ۳ — دریافت JWT با grant تأییدشده', description: 'فیلد `grant` را از پاسخ مرحله ۲ (`/api/v1/user/verify-code`) وارد کنید. این grant یک‌بارمصرف و کوتاه‌عمر (۱۲۰ ثانیه) است. access_token را در header درخواست‌های بعدی استفاده کنید: `Authorization: Bearer `', requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['grant_type', 'grant'], properties: [ new OA\Property(property: 'grant_type', type: 'string', enum: ['mobile'], example: 'mobile'), new OA\Property(property: 'grant', type: 'string', description: 'grant یک‌بارمصرف از verify-code'), ] ) ), responses: [ new OA\Response( response: 200, description: 'Tokens issued successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'token', type: 'string'), new OA\Property(property: 'refresh_token', type: 'string'), ] ) ), new OA\Response( response: 400, description: 'Invalid grant_type', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: false), new OA\Property( property: 'errors', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'code', type: 'string'), new OA\Property(property: 'message', type: 'string'), ], type: 'object' ) ), ] ) ), ] )] #[Route('/oauth/token', methods: ['POST'])] public function issueToken(Request $request): JsonResponse { if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) { return $resp; } $data = json_decode($request->getContent(), true) ?? []; $grantType = $data['grant_type'] ?? ''; $grant = trim($data['grant'] ?? ''); if ($grantType !== 'mobile') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400); } if ($grant === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422); } $mobile = $this->otpService->consumeGrant($grant); $user = $this->userRepo->findByMobile($mobile) ?? new User($mobile); $this->userRepo->save($user); return new JsonResponse($this->tokenService->issueTokens($user)); } #[Route('/api/v1/user/otp-login', methods: ['POST'])] public function otpLogin(Request $request): JsonResponse { if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) { return $resp; } $data = json_decode($request->getContent(), true) ?? []; $grant = trim($data['grant'] ?? ''); if ($grant === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422); } $mobile = $this->otpService->consumeGrant($grant); $user = $this->userRepo->findByMobile($mobile); if (!$user) { return $this->error(ErrorCodes::ERR_AUTH_005, 'کاربری با این شماره یافت نشد', 401); } return new JsonResponse($this->tokenService->issueTokens($user)); } #[Route('/api/v1/user/reset-password', methods: ['POST'])] public function resetPassword(Request $request): JsonResponse { if ($resp = $this->enforceLimit($this->passwordResetLimiter, $request)) { return $resp; } $data = json_decode($request->getContent(), true) ?? []; $grant = trim($data['grant'] ?? ''); $newPassword = trim($data['new_password'] ?? ''); if ($grant === '' || mb_strlen($newPassword) < 8) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant و رمز عبور (حداقل ۸ کاراکتر) الزامی است', 422); } $mobile = $this->otpService->consumeGrant($grant); $user = $this->userRepo->findByMobile($mobile); if (!$user) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404); } $user->setPasswordHash($this->hasher->hashPassword($user, $newPassword)); $this->em->flush(); return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']); } #[OA\Post( path: '/oauth/token/refresh', summary: 'Refresh access token using a refresh token', requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['refresh_token'], properties: [ new OA\Property(property: 'refresh_token', type: 'string'), ] ) ), responses: [ new OA\Response( response: 200, description: 'Token refreshed successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'token', type: 'string'), new OA\Property(property: 'refresh_token', type: 'string'), ] ) ), new OA\Response( response: 401, description: 'Invalid or missing refresh token', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: false), new OA\Property( property: 'errors', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'code', type: 'string'), new OA\Property(property: 'message', type: 'string'), ], type: 'object' ) ), ] ) ), ] )] #[Route('/oauth/token/refresh', methods: ['POST'])] public function refreshToken(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $refreshToken = trim($data['refresh_token'] ?? ''); if (empty($refreshToken)) { return $this->error(ErrorCodes::ERR_AUTH_001, 'refresh_token الزامی است', 401); } $result = $this->tokenService->refreshToken($refreshToken); $user = $this->userRepo->find($result['userId']); if ($user === null || $user->getStatus() !== 1) { return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); } // Rotate: the presented refresh token is single-use. Revoke it and issue a // fresh access + refresh pair, so a stolen token can't be reused. $this->tokenService->revokeRefreshToken($refreshToken); return new JsonResponse($this->tokenService->issueTokens($user)); } #[OA\Get( path: '/oauth/userinfo', summary: 'Get current authenticated user info', security: [['bearerAuth' => []]], responses: [ new OA\Response( response: 200, description: 'User info returned successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'id', type: 'integer'), new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'mobile_number', type: 'string'), new OA\Property(property: 'realName', type: 'string'), new OA\Property(property: 'status', type: 'string'), new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')), ], type: 'object' ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response( response: 401, description: 'Unauthenticated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: false), new OA\Property( property: 'errors', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'code', type: 'string'), new OA\Property(property: 'message', type: 'string'), ], type: 'object' ) ), ] ) ), ] )] #[Route('/oauth/userinfo', methods: ['GET'])] public function userInfo(#[CurrentUser] ?User $user): JsonResponse { if ($user === null) { return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); } $primaryRole = $this->resolvePrimaryRole($user); $availableContexts = $this->buildAvailableContexts($user); // اگر یک context داری، خودکار فعال کن $activeCtx = $this->contextRepo->findByUser($user); if ($activeCtx === null && count($availableContexts) === 1) { $activeCtx = $this->contextRepo->upsert($user, $availableContexts[0]['db_uuid']); } $dbUuid = $activeCtx?->getDbUuid(); $dbKey = $dbUuid !== null ? $this->buildDbKey($dbUuid) : null; $context = $dbUuid !== null ? $this->findContextByDbUuid($dbUuid, $availableContexts) : null; $doctor = $this->doctorRepo->findByUser($user); return $this->success([ 'id' => $user->getId(), 'uuid' => $user->getUuid(), 'mobile_number' => $user->getMobileNumber(), 'realName' => $user->getRealName(), 'status' => $user->getStatus(), 'roles' => $user->getRoles(), 'primary_role' => $primaryRole, 'db_uuid' => $dbUuid, 'db_key' => $dbKey, 'doctor_uuid' => $doctor?->getUuid(), 'context' => $context, 'available_contexts' => $availableContexts, ]); } #[OA\Post( path: '/api/v1/auth/switch-context', summary: 'تغییر محیط کاری فعال', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['db_uuid'], properties: [ new OA\Property(property: 'db_uuid', type: 'string', format: 'uuid', description: 'UUID محیط کاری انتخاب‌شده از لیست available_contexts'), ] ) ), responses: [ new OA\Response(response: 200, description: 'Context تغییر کرد'), new OA\Response(response: 401, description: 'توکن وجود ندارد'), new OA\Response(response: 403, description: 'db_uuid در لیست context های این کاربر نیست'), new OA\Response(response: 422, description: 'db_uuid ارسال نشده'), ] )] #[Route('/api/v1/auth/switch-context', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function switchContext(Request $request, #[CurrentUser] ?User $user): JsonResponse { if ($user === null) { return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); } $data = json_decode($request->getContent(), true) ?? []; $dbUuid = trim($data['db_uuid'] ?? ''); if ($dbUuid === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'db_uuid الزامی است', 422); } $availableContexts = $this->buildAvailableContexts($user); $matched = $this->findContextByDbUuid($dbUuid, $availableContexts); if ($matched === null) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی به این محیط کاری مجاز نیست', 403); } $this->contextRepo->upsert($user, $dbUuid); return $this->success([ 'db_uuid' => $dbUuid, 'db_key' => $this->buildDbKey($dbUuid), 'context' => $matched, ]); } // ── Helpers ────────────────────────────────────────────────────────────── private function enforceLimit(RateLimiterFactory $factory, Request $request): ?JsonResponse { $limiter = $factory->create($request->getClientIp() ?? 'unknown'); if (!$limiter->consume(1)->isAccepted()) { return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); } return null; } private function resolvePrimaryRole(User $user): string { $roles = $user->getRoles(); if (in_array('ROLE_ADMIN', $roles, true)) return 'admin'; if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic'; if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor'; if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary'; if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation'; return 'user'; } private function buildAvailableContexts(User $user): array { $contexts = []; // دکتر: مطب شخصی + کلینیک‌های عضو if ($doctor = $this->doctorRepo->findByUser($user)) { $contexts[] = [ 'type' => 'doctor', 'db_uuid' => $doctor->getUuid(), 'name' => 'مطب شخصی ' . $doctor->getName(), 'role' => 'doctor', ]; foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { // پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک می‌گیرد تا // فقط نوبت‌های خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک. // اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست می‌شود. $isOwner = $clinic->getUser()->getId() === $user->getId(); $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => $isOwner ? 'clinic' : 'doctor', 'scope' => $isOwner ? null : 'clinic', 'doctor_uuid' => $doctor->getUuid(), ]; } } // صاحب کلینیک (اگر قبلاً اضافه نشده) if ($clinic = $this->clinicRepo->findByUser($user)) { $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid()); if (empty($alreadyAdded)) { $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => 'clinic', ]; } } // منشی: هر رابطه فعال با scope مجزا foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) { // scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر) $clinicUuid = $rel->getClinic()->getUuid(); $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && ($c['role'] ?? '') === 'secretary'); if (empty($alreadyAdded)) { $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinicUuid, 'name' => 'کلینیک ' . ($rel->getClinic()->getName() ?? ''), 'role' => 'secretary', 'scope' => 'clinic', 'permissions' => $rel->getPermissions(), ]; } } else { // scope مطب شخصی $contexts[] = [ 'type' => 'doctor', 'db_uuid' => $rel->getDoctor()->getUuid(), 'name' => 'مطب ' . $rel->getDoctor()->getName(), 'role' => 'secretary', 'scope' => 'doctor', 'permissions' => $rel->getPermissions(), ]; } } return $contexts; } private function findContextByDbUuid(string $dbUuid, array $contexts): ?array { foreach ($contexts as $ctx) { if ($ctx['db_uuid'] === $dbUuid) { return $ctx; } } return null; } private function buildDbKey(string $dbUuid): string { return hash_hmac('sha256', $dbUuid, $this->getParameter('kernel.secret')); } #[OA\Post( path: '/oauth/logout', summary: 'Logout and optionally revoke refresh token', requestBody: new OA\RequestBody( required: false, content: new OA\JsonContent( properties: [ new OA\Property(property: 'refresh_token', type: 'string'), ] ) ), responses: [ new OA\Response( response: 200, description: 'Logged out successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'message', type: 'string', example: 'خروج با موفقیت انجام شد'), ], type: 'object' ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), ] )] #[Route('/oauth/logout', methods: ['POST'])] public function logout(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $refreshToken = trim($data['refresh_token'] ?? ''); if ($refreshToken !== '') { $this->tokenService->revokeRefreshToken($refreshToken); } return $this->success(['message' => 'خروج با موفقیت انجام شد']); } #[Route('/session/token', methods: ['GET'])] public function sessionToken(): JsonResponse { return new JsonResponse(['token' => bin2hex(random_bytes(16))]); } }