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>
788 lines
35 KiB
PHP
788 lines
35 KiB
PHP
<?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))]);
|
||
}
|
||
}
|