feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Auth\Service\OtpService;
|
||||
use App\Auth\Service\TokenService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Route exists so the router resolves it; PasswordAuthenticator intercepts
|
||||
* and returns the JWT response before this controller body ever runs.
|
||||
*/
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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');
|
||||
}
|
||||
|
||||
$uuid = $this->otpService->sendCode($mobile);
|
||||
|
||||
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/verify-code', methods: ['POST'])]
|
||||
public function verifyCode(Request $request): JsonResponse
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
$this->otpService->verifyCode($uuid, $code);
|
||||
|
||||
return $this->success(['message' => 'کد با موفقیت تایید شد.']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/register', methods: ['POST'])]
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$realName = trim($data['real_name'] ?? '');
|
||||
|
||||
if (empty($uuid)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
if ($realName !== '') {
|
||||
$user->setRealName($realName);
|
||||
}
|
||||
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'ثبتنام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201);
|
||||
}
|
||||
|
||||
#[Route('/oauth/token', methods: ['POST'])]
|
||||
public function issueToken(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = $data['grant_type'] ?? '';
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($grant !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[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) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401);
|
||||
}
|
||||
|
||||
$tokens = $this->tokenService->issueTokens($user);
|
||||
$tokens['refresh_token'] = $result['rawToken'];
|
||||
|
||||
return new JsonResponse($tokens);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'id' => $user->getId(),
|
||||
'uuid' => $user->getUuid(),
|
||||
'mobile_number' => $user->getMobileNumber(),
|
||||
'realName' => $user->getRealName(),
|
||||
'status' => $user->getStatus(),
|
||||
'roles' => $user->getRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[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))]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'users')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_mobile', columns: ['mobile_number'])]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'mobile_number', type: 'string', length: 20, unique: true)]
|
||||
private string $mobileNumber;
|
||||
|
||||
#[ORM\Column(name: 'password_hash', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $passwordHash = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $realName = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $status = 1;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $mobileNumber)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->mobileNumber = $mobileNumber;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getMobileNumber(): string { return $this->mobileNumber; }
|
||||
public function getEmail(): ?string { return $this->email; }
|
||||
public function getRealName(): ?string { return $this->realName; }
|
||||
public function getStatus(): int { return $this->status; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function getPassword(): ?string { return $this->passwordHash; }
|
||||
public function getPasswordHash(): ?string { return $this->passwordHash; }
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
if (!in_array('ROLE_USER', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
return array_unique($roles);
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string { return $this->mobileNumber; }
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
public function setEmail(?string $email): self { $this->email = $email; return $this; }
|
||||
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setPasswordHash(?string $hash): self { $this->passwordHash = $hash; $this->updatedAt = time(); return $this; }
|
||||
public function setRoles(array $roles): self { $this->roles = $roles; $this->updatedAt = time(); return $this; }
|
||||
public function setStatus(int $status): self { $this->status = $status; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function addRole(string $role): self
|
||||
{
|
||||
if (!in_array($role, $this->roles, true)) {
|
||||
$this->roles[] = $role;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->getRoles(), true);
|
||||
}
|
||||
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function findByMobile(string $mobile): ?User
|
||||
{
|
||||
return $this->findOneBy(['mobileNumber' => $mobile]);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?User
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function save(User $user, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($user);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(User $user, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->remove($user);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Security;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class PasswordAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly JWTTokenManagerInterface $jwtManager,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly RateLimiterFactory $loginLimiter,
|
||||
private readonly int $refreshTokenTtl = 2592000,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->getPathInfo() === '/api/v1/user/login'
|
||||
&& $request->isMethod('POST');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$limiter = $this->loginLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException(60, 'تعداد تلاشهای ورود از حد مجاز گذشت');
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
$pass = $data['password'] ?? '';
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($mobile, fn(string $id) => $this->userRepository->findByMobile($id)),
|
||||
new PasswordCredentials($pass)
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user->isStaff()) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'errors' => [['code' => ErrorCodes::ERR_AUTH_006, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_006)]],
|
||||
], 403);
|
||||
}
|
||||
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$cacheKey = 'refresh_' . hash('sha256', $rawToken);
|
||||
|
||||
$item = $this->cache->getItem($cacheKey);
|
||||
$item->set((string) $user->getId());
|
||||
$item->expiresAfter($this->refreshTokenTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
$this->logger->info('login_success', [
|
||||
'user_id' => $user->getId(),
|
||||
'ip' => $request->getClientIp(),
|
||||
'method' => 'password',
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
$this->logger->warning('login_failed', [
|
||||
'ip' => $request->getClientIp(),
|
||||
'reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'errors' => [['code' => ErrorCodes::ERR_AUTH_005, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_005)]],
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Service;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Message\SendSmsMessage;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class OtpService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly int $otpTtl = 1200,
|
||||
private readonly string $appEnv = 'dev',
|
||||
) {}
|
||||
|
||||
private function key(string $uuid): string
|
||||
{
|
||||
// Cache PSR-6 reserved chars: {}()/\@: and - in UUID must be escaped
|
||||
return 'otp_' . str_replace('-', '_', $uuid);
|
||||
}
|
||||
|
||||
public function sendCode(string $mobile): string
|
||||
{
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
$code = $this->appEnv === 'dev'
|
||||
? '12345'
|
||||
: str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
$item->set(json_encode(['mobile' => $mobile, 'code' => $code, 'attempts' => 0, 'verified' => false]));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
|
||||
}
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
public function verifyCode(string $uuid, string $submittedCode): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
|
||||
}
|
||||
|
||||
$data = json_decode($item->get(), true);
|
||||
|
||||
if ($data['attempts'] >= 5) {
|
||||
$this->cache->delete($this->key($uuid));
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_004, null, 429);
|
||||
}
|
||||
|
||||
if (!hash_equals($data['code'], $submittedCode)) {
|
||||
$data['attempts']++;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
$data['verified'] = true;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getVerifiedOtpData(string $uuid): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
|
||||
}
|
||||
|
||||
$data = json_decode($item->get(), true);
|
||||
|
||||
if (!($data['verified'] ?? false)) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deleteOtp(string $uuid): void
|
||||
{
|
||||
$this->cache->delete($this->key($uuid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JWTTokenManagerInterface $jwtManager,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly int $refreshTokenTtl = 2592000,
|
||||
) {}
|
||||
|
||||
public function issueTokens(User $user): array
|
||||
{
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = $this->storeRefreshToken($user->getId());
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshToken(string $submittedToken): array
|
||||
{
|
||||
$key = $this->refreshKey($submittedToken);
|
||||
$item = $this->cache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_001, 'Refresh Token نامعتبر یا منقضی شده است', 401);
|
||||
}
|
||||
|
||||
$userId = (int) $item->get();
|
||||
$this->cache->delete($key);
|
||||
|
||||
return ['userId' => $userId, 'rawToken' => $this->storeRefreshToken($userId)];
|
||||
}
|
||||
|
||||
public function revokeRefreshToken(string $rawToken): void
|
||||
{
|
||||
$this->cache->delete($this->refreshKey($rawToken));
|
||||
}
|
||||
|
||||
private function storeRefreshToken(int $userId): string
|
||||
{
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$key = $this->refreshKey($rawToken);
|
||||
|
||||
$item = $this->cache->getItem($key);
|
||||
$item->set((string) $userId);
|
||||
$item->expiresAfter($this->refreshTokenTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
private function refreshKey(string $rawToken): string
|
||||
{
|
||||
// hash → hex string → safe cache key (no reserved chars)
|
||||
return 'refresh_' . hash('sha256', $rawToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user