Files
clinicpro/src/Auth/Controller/AuthController.php
T
hamedandClaude Opus 4.8 670cef24f4 fix(security): per-mobile OTP cap + refresh-token rotation (M6, M7)
M6: send-code rate-limited only per IP, so a victim's number could be
SMS-flooded from rotating IPs. Add a per-mobile bucket (same 5/hour policy)
keyed by the validated mobile.

M7: /oauth/token/refresh reused the presented refresh token verbatim (no
rotation) and never re-checked the user. The rotation infra already existed
(issueTokens mints a fresh refresh token) — the controller just discarded it.
Now revoke the presented token (single-use), issue a fresh pair, and reject a
suspended user (status != 1).

Regressions: tests/Auth/SendCodeMobileRateLimitTest,
tests/Auth/RefreshTokenRotationTest (both fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:25:22 +03:30

788 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Auth\Controller;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Auth\Repository\UserActiveContextRepository;
use App\Auth\Repository\UserRepository;
use App\Auth\Service\OtpService;
use App\Auth\Service\TokenService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Auth')]
class AuthController extends BaseController
{
public function __construct(
private readonly UserRepository $userRepo,
private readonly OtpService $otpService,
private readonly TokenService $tokenService,
private readonly RateLimiterFactory $sendCodeLimiter,
private readonly RateLimiterFactory $verifyCodeLimiter,
private readonly RateLimiterFactory $tokenIssueLimiter,
private readonly RateLimiterFactory $passwordResetLimiter,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly EntityManagerInterface $em,
) {}
/**
* Route exists so the router resolves it; PasswordAuthenticator intercepts
* and returns the JWT response before this controller body ever runs.
*/
#[OA\Post(
path: '/api/v1/user/login',
summary: 'Staff login (Admin / Doctor / Clinic / Secretary)',
description: 'ورود با شماره موبایل و رمز عبور — فقط برای کاربران دارای نقش ROLE_ADMIN، ROLE_DOCTOR، ROLE_CLINIC یا ROLE_SECRETARY. کاربران عادی باید از OTP استفاده کنند.',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile_number', 'password'],
properties: [
new OA\Property(property: 'mobile_number', type: 'string', example: '09120671713', description: 'شماره موبایل ثبت‌شده'),
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'admin1234', description: 'رمز عبور'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'ورود موفق — JWT و refresh token برگردانده می‌شود',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'access_token', type: 'string', description: 'JWT — عمر ۱ ساعت'),
new OA\Property(property: 'refresh_token', type: 'string', description: 'Refresh token — عمر ۳۰ روز'),
new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'),
new OA\Property(property: 'expires_in', type: 'integer', example: 3600),
new OA\Property(property: 'refresh_token_expires_in', type: 'integer', example: 2592000),
]
)
),
new OA\Response(
response: 401,
description: 'اطلاعات ورود نادرست',
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', example: 'ERR_AUTH_005'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
new OA\Response(response: 403, description: 'کاربر نقش staff ندارد (ROLE_ADMIN/ROLE_DOCTOR/ROLE_CLINIC/ROLE_SECRETARY)'),
new OA\Response(response: 429, description: 'تعداد تلاش‌های ورود از حد مجاز گذشت'),
]
)]
#[Route('/api/v1/user/login', methods: ['POST'])]
public function login(): JsonResponse
{
return $this->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 <access_token>`',
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))]);
}
}