From baa293fd1b1417d32e1cac415d6e694867023b91 Mon Sep 17 00:00:00 2001 From: Ilya Vasilenko <i.vasilenko@iqdev.digital> Date: Mon, 10 Jun 2024 13:46:04 +0500 Subject: [PATCH] login & register --- app/config/packages/framework.yaml | 2 + .../packages/lexik_jwt_authentication.yaml | 1 + app/config/packages/security.yaml | 26 +- app/config/routes.yaml | 3 + app/config/services.yaml | 46 +++- app/migrations/Version20240607055116.php | 42 ++++ app/src/Controller/AuthController.php | 22 ++ app/src/Entity/User.php | 237 ++++++++++++++++++ app/src/Entity/UserImage.php | 80 ++++++ app/src/Listeners/JwtListener.php | 92 +++++++ app/src/Repository/UserImageRepository.php | 43 ++++ app/src/Repository/UserRepository.php | 86 +++++++ app/src/Response/ApiResponse.php | 97 +++++++ app/src/Response/TokenResponse.php | 14 ++ .../Service/Action/ActionServiceInterface.php | 14 ++ app/src/Service/Action/BaseActionService.php | 34 +++ app/src/Service/Action/Classes/None.php | 22 ++ app/src/Service/Action/Classes/Register.php | 109 ++++++++ app/src/Service/Dto/BaseDto.php | 93 +++++++ .../Service/Dto/Classes/ChangePasswordDto.php | 28 +++ app/src/Service/Dto/Classes/NoneDto.php | 10 + app/src/Service/Dto/Classes/RegisterDto.php | 48 ++++ app/src/Service/Dto/DtoServiceInterface.php | 14 ++ .../Service/Response/BaseResponseService.php | 22 ++ .../Response/Classes/ProfileResponse.php | 14 ++ app/src/Service/Response/Classes/Response.php | 10 + .../Response/ResponseServiceInterface.php | 10 + app/src/Validators/PasswordValidator.php | 71 ++++++ 28 files changed, 1286 insertions(+), 4 deletions(-) create mode 100644 app/migrations/Version20240607055116.php create mode 100644 app/src/Controller/AuthController.php create mode 100644 app/src/Entity/User.php create mode 100644 app/src/Entity/UserImage.php create mode 100644 app/src/Listeners/JwtListener.php create mode 100644 app/src/Repository/UserImageRepository.php create mode 100644 app/src/Repository/UserRepository.php create mode 100644 app/src/Response/ApiResponse.php create mode 100644 app/src/Response/TokenResponse.php create mode 100644 app/src/Service/Action/ActionServiceInterface.php create mode 100644 app/src/Service/Action/BaseActionService.php create mode 100644 app/src/Service/Action/Classes/None.php create mode 100644 app/src/Service/Action/Classes/Register.php create mode 100644 app/src/Service/Dto/BaseDto.php create mode 100644 app/src/Service/Dto/Classes/ChangePasswordDto.php create mode 100644 app/src/Service/Dto/Classes/NoneDto.php create mode 100644 app/src/Service/Dto/Classes/RegisterDto.php create mode 100644 app/src/Service/Dto/DtoServiceInterface.php create mode 100644 app/src/Service/Response/BaseResponseService.php create mode 100644 app/src/Service/Response/Classes/ProfileResponse.php create mode 100644 app/src/Service/Response/Classes/Response.php create mode 100644 app/src/Service/Response/ResponseServiceInterface.php create mode 100644 app/src/Validators/PasswordValidator.php diff --git a/app/config/packages/framework.yaml b/app/config/packages/framework.yaml index 877eb25..5fc005d 100644 --- a/app/config/packages/framework.yaml +++ b/app/config/packages/framework.yaml @@ -8,6 +8,8 @@ framework: #esi: true #fragments: true + serializer: + name_converter: serializer.name_converter.camel_case_to_snake_case when@test: framework: diff --git a/app/config/packages/lexik_jwt_authentication.yaml b/app/config/packages/lexik_jwt_authentication.yaml index edfb69d..dd7a3aa 100644 --- a/app/config/packages/lexik_jwt_authentication.yaml +++ b/app/config/packages/lexik_jwt_authentication.yaml @@ -2,3 +2,4 @@ lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 3600 diff --git a/app/config/packages/security.yaml b/app/config/packages/security.yaml index 367af25..670446f 100644 --- a/app/config/packages/security.yaml +++ b/app/config/packages/security.yaml @@ -4,14 +4,33 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: + login: + pattern: ^/api/login + stateless: true + json_login: + check_path: /api/login + username_path: email + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + + api: + pattern: ^/api + stateless: true + jwt: ~ + dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -22,6 +41,9 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/api/login, roles: PUBLIC_ACCESS } + - { path: ^/api/register, roles: PUBLIC_ACCESS } + - { path: ^/api, roles: ROLE_CONFIRMED } # - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } diff --git a/app/config/routes.yaml b/app/config/routes.yaml index 41ef814..dbc41f6 100644 --- a/app/config/routes.yaml +++ b/app/config/routes.yaml @@ -3,3 +3,6 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute + +api_login: + path: /api/login \ No newline at end of file diff --git a/app/config/services.yaml b/app/config/services.yaml index 2d6a76f..8c127d8 100644 --- a/app/config/services.yaml +++ b/app/config/services.yaml @@ -20,5 +20,47 @@ services: - '../src/Entity/' - '../src/Kernel.php' - # add more service definitions when explicit configuration is needed - # please note that last definitions always *replace* previous ones + # СервиÑÑ‹ дейÑтвий + App\Service\Action\ActionServiceInterface $registerService: '@App\Service\Action\Classes\Register' + + App\Service\Action\ActionServiceInterface: '@App\Service\Action\Classes\None' + + + # СервиÑÑ‹ Dto + App\Service\Dto\DtoServiceInterface $registerDto: '@App\Service\Dto\Classes\RegisterDto' + + App\Service\Dto\DtoServiceInterface: '@App\Service\Dto\Classes\NoneDto' + + + # СервиÑÑ‹ ответа + App\Service\Response\ResponseServiceInterface $profileResponse: '@App\Service\Response\Classes\ProfileResponse' + + App\Service\Response\ResponseServiceInterface: '@App\Service\Response\Classes\Response' + + + + # Ð¡Ð¾Ð±Ñ‹Ñ‚Ð¸Ñ JWT авторизации + acme_api.event.authentication_success_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_authentication_success, method: onAuthenticationSuccessResponse } + + acme_api.event.authentication_failure_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_authentication_failure, method: onAuthenticationFailureResponse } + + acme_api.event.jwt_invalid_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_invalid, method: onJWTInvalid } + + acme_api.event.jwt_notfound_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_not_found, method: onJWTNotFound } + + acme_api.event.jwt_expired_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_expired, method: onJWTExpired } \ No newline at end of file diff --git a/app/migrations/Version20240607055116.php b/app/migrations/Version20240607055116.php new file mode 100644 index 0000000..9baa894 --- /dev/null +++ b/app/migrations/Version20240607055116.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace DoctrineMigrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +/** + * Auto-generated Migration: Please modify to your needs! + */ +final class Version20240607055116 extends AbstractMigration +{ + public function getDescription(): string + { + return ''; + } + + public function up(Schema $schema): void + { + // this up() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE user_image_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE "user" (id INT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, surname VARCHAR(255) NOT NULL, patronymic VARCHAR(255) NOT NULL, phone_number VARCHAR(255) DEFAULT NULL, confirm BOOLEAN NOT NULL, deleted BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)'); + $this->addSql('CREATE TABLE user_image (id INT NOT NULL, related_user_id INT DEFAULT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_27FFFF0798771930 ON user_image (related_user_id)'); + $this->addSql('ALTER TABLE user_image ADD CONSTRAINT FK_27FFFF0798771930 FOREIGN KEY (related_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP SEQUENCE "user_id_seq" CASCADE'); + $this->addSql('DROP SEQUENCE user_image_id_seq CASCADE'); + $this->addSql('ALTER TABLE user_image DROP CONSTRAINT FK_27FFFF0798771930'); + $this->addSql('DROP TABLE "user"'); + $this->addSql('DROP TABLE user_image'); + } +} diff --git a/app/src/Controller/AuthController.php b/app/src/Controller/AuthController.php new file mode 100644 index 0000000..553613e --- /dev/null +++ b/app/src/Controller/AuthController.php @@ -0,0 +1,22 @@ +<?php + +namespace App\Controller; + +use App\Response\ApiResponse; +use App\Service\Action\ActionServiceInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/api', name: 'api_')] +class AuthController extends AbstractController +{ + #[Route('/register', name: 'register', methods: ['POST'])] + public function register( + ActionServiceInterface $registerService + ): JsonResponse + { + return $registerService->getResponse(); + } +} diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php new file mode 100644 index 0000000..47fb67c --- /dev/null +++ b/app/src/Entity/User.php @@ -0,0 +1,237 @@ +<?php + +namespace App\Entity; + +use App\Repository\UserRepository; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Validator\Constraints as Assert; + +#[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] +#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] +class User implements UserInterface, PasswordAuthenticatedUserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 180)] + private ?string $email = null; + + /** + * @var list<string> The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(length: 255)] + private ?string $surname = null; + + #[ORM\Column(length: 255)] + private ?string $patronymic = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $phone_number = null; + + #[ORM\OneToOne(mappedBy: 'related_user', cascade: ['persist', 'remove'])] + private ?UserImage $image = null; + + #[ORM\Column] + private ?bool $confirm = null; + + #[ORM\Column] + private ?bool $deleted = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + * + * @return list<string> + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + if ($this->isDeleted()) { + $roles[] = 'ROLE_DELETED'; + } else if ($this->isConfirm()) { + $roles[] = 'ROLE_CONFIRMED'; + } else { + $roles[] = 'ROLE_NOT_CONFIRMED'; + } + + return array_unique($roles); + } + + /** + * @param list<string> $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getSurname(): ?string + { + return $this->surname; + } + + public function setSurname(string $surname): static + { + $this->surname = $surname; + + return $this; + } + + public function getPatronymic(): ?string + { + return $this->patronymic; + } + + public function setPatronymic(string $patronymic): static + { + $this->patronymic = $patronymic; + + return $this; + } + + public function getPhoneNumber(): ?string + { + return $this->phone_number; + } + + public function setPhoneNumber(?string $phone_number): static + { + $this->phone_number = $phone_number; + + return $this; + } + + public function getImage(): ?UserImage + { + return $this->image; + } + + public function setImage(?UserImage $image): static + { + // unset the owning side of the relation if necessary + if ($image === null && $this->image !== null) { + $this->image->setRelatedUser(null); + } + + // set the owning side of the relation if necessary + if ($image !== null && $image->getRelatedUser() !== $this) { + $image->setRelatedUser($this); + } + + $this->image = $image; + + return $this; + } + + public function isConfirm(): ?bool + { + return $this->confirm; + } + + public function setConfirm(bool $confirm): static + { + $this->confirm = $confirm; + + return $this; + } + + public function isDeleted(): ?bool + { + return $this->deleted; + } + + public function setDeleted(bool $deleted): static + { + $this->deleted = $deleted; + + return $this; + } + + public function getFullName(): string + { + return $this->getSurname() . ' ' . $this->getName() . ' ' . $this->getPatronymic() ?: ''; + } +} diff --git a/app/src/Entity/UserImage.php b/app/src/Entity/UserImage.php new file mode 100644 index 0000000..439f120 --- /dev/null +++ b/app/src/Entity/UserImage.php @@ -0,0 +1,80 @@ +<?php + +namespace App\Entity; + +use App\Repository\UserImageRepository; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity(repositoryClass: UserImageRepository::class)] +class UserImage +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\OneToOne(inversedBy: 'image', cascade: ['persist', 'remove'])] + private ?User $related_user = null; + + #[ORM\Column(length: 255)] + private ?string $path = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(length: 255)] + private ?string $type = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getRelatedUser(): ?User + { + return $this->related_user; + } + + public function setRelatedUser(?User $related_user): static + { + $this->related_user = $related_user; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } +} diff --git a/app/src/Listeners/JwtListener.php b/app/src/Listeners/JwtListener.php new file mode 100644 index 0000000..625ee5f --- /dev/null +++ b/app/src/Listeners/JwtListener.php @@ -0,0 +1,92 @@ +<?php + +namespace App\Listeners; + +use App\Entity\User; +use App\Response\ApiResponse; +use App\Response\TokenResponse; +use JsonException; +use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationFailureEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent; +use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent; +use Symfony\Component\HttpFoundation\Response; + +class JwtListener +{ + /** + * @param AuthenticationSuccessEvent $event + * + * @return void + * + * @throws JsonException + */ + public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event): void + { + $data = $event->getData(); + $user = $event->getUser(); + + if (!$user instanceof User) { + return; + } + + if ($user->isDeleted()) { + $response = new ApiResponse(); + $response->addError('Пользователь удален'); + } else { + $response = new TokenResponse(); + $response->setToken($data['token']); + $response->addMessage('ЗдравÑтвуйте, ' . $user->getFullName()); + } + + $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $event->setData($data); + } + + /** + * @param AuthenticationFailureEvent $event + */ + public function onAuthenticationFailureResponse(AuthenticationFailureEvent $event): void + { + $response = new ApiResponse(); + $response->addError('Ðеверный email или пароль'); + $event->setResponse($response); + } + + /** + * @param JWTInvalidEvent $event + */ + public function onJWTInvalid(JWTInvalidEvent $event): void + { + $response = new ApiResponse(); + $response->addError('Ðеверный токен авторизации'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response); + } + + /** + * @param JWTNotFoundEvent $event + */ + public function onJWTNotFound(JWTNotFoundEvent $event): void + { + $response = new ApiResponse(); + $response->addError('ОтÑутÑтвует токен'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response); + } + + /** + * @param JWTExpiredEvent $event + */ + public function onJWTExpired(JWTExpiredEvent $event): void + { + $response = new ApiResponse(); + $response->addError('Срок дейÑÑ‚Ð²Ð¸Ñ Ð²Ð°ÑˆÐµÐ³Ð¾ токена иÑтек, пожалуйÑта, обновите его'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response); + } +} \ No newline at end of file diff --git a/app/src/Repository/UserImageRepository.php b/app/src/Repository/UserImageRepository.php new file mode 100644 index 0000000..2c67991 --- /dev/null +++ b/app/src/Repository/UserImageRepository.php @@ -0,0 +1,43 @@ +<?php + +namespace App\Repository; + +use App\Entity\UserImage; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; + +/** + * @extends ServiceEntityRepository<UserImage> + */ +class UserImageRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserImage::class); + } + + // /** + // * @return UserImage[] Returns an array of UserImage objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('u.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?UserImage + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/src/Repository/UserRepository.php b/app/src/Repository/UserRepository.php new file mode 100644 index 0000000..53ebb8c --- /dev/null +++ b/app/src/Repository/UserRepository.php @@ -0,0 +1,86 @@ +<?php + +namespace App\Repository; + +use App\Entity\User; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; + +/** + * @extends ServiceEntityRepository<User> + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + + /** + * ПоиÑк Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ð¿Ð¾ Email или номеру телефона + * + * @param string|null $sEmail + * @param string|null $sPhone + * @param int|null $iIgnoreId + * + * @return User|null + */ + public function findOneByUniq(?string $sEmail = null, ?string $sPhone = null, ?int $iIgnoreId = null): ?User + { + $oQuery = $this->createQueryBuilder('u'); + if (!empty($sEmail)) { + $oQuery->orWhere('u.email = :email')->setParameter('email', $sEmail); + } + if (!empty($sPhone)) { + $oQuery->orWhere('u.phone_number = :phone_number')->setParameter('phone_number', $sPhone); + } + if (!empty($iIgnoreId)) { + $oQuery->andWhere('u.id != :id')->setParameter('id', $iIgnoreId); + } + return $oQuery + ->getQuery() + ->getOneOrNullResult(); + } + + // /** + // * @return User[] Returns an array of User objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('u.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?User + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/src/Response/ApiResponse.php b/app/src/Response/ApiResponse.php new file mode 100644 index 0000000..87b5533 --- /dev/null +++ b/app/src/Response/ApiResponse.php @@ -0,0 +1,97 @@ +<?php + +namespace App\Response; + +use ReflectionClass; +use Symfony\Component\HttpFoundation\JsonResponse; + +class ApiResponse extends JsonResponse +{ + private bool $status = true; + + private array $messages = []; + + private ?array $responseData = null; + + public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false) + { + parent::__construct($data, $status, $headers, $json); + $this->setResult(); + } + + /** + * Добавление ошибки + * + * @param string $message + * + * @return self + */ + public function addError(string $message): self + { + $this->status = false; + return $this->addMessage($message); + } + + /** + * Добавление ошибок + * + * @param array $errors + * + * @return $this + */ + public function addErrors(array $errors): self + { + $this->status = false; + foreach ($errors as $error) { + $this->addError($error); + } + + return $this; + } + + /** + * Добавление ÑÐ¾Ð¾Ð±Ñ‰ÐµÐ½Ð¸Ñ + * + * @param string $message + * + * @return self + */ + public function addMessage(string $message): self + { + $this->messages[] = $message; + return $this->setResult(); + } + + /** + * ЗапиÑÑŒ контента ответа + * + * @param array|null $responseData + * + * @return void + */ + public function setResponseData(?array $responseData): void + { + $this->responseData = $responseData; + } + + /** + * УÑтановка результата + * + * @return self + */ + protected function setResult(): self + { + $result = [ + 'status' => $this->status, + ]; + + if (!empty($this->responseData)) { + $result['data'] = $this->responseData; + } + if (!isset($result['data'])) { + $result['message'] = implode(', ', $this->messages); + } + + return $this->setData($result); + } +} \ No newline at end of file diff --git a/app/src/Response/TokenResponse.php b/app/src/Response/TokenResponse.php new file mode 100644 index 0000000..a941d6c --- /dev/null +++ b/app/src/Response/TokenResponse.php @@ -0,0 +1,14 @@ +<?php + +namespace App\Response; + +class TokenResponse extends ApiResponse +{ + public function setToken(string $token): self + { + $this->setResponseData([ + 'token' => $token, + ]); + return $this; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/ActionServiceInterface.php b/app/src/Service/Action/ActionServiceInterface.php new file mode 100644 index 0000000..cec8a68 --- /dev/null +++ b/app/src/Service/Action/ActionServiceInterface.php @@ -0,0 +1,14 @@ +<?php + +namespace App\Service\Action; + +use App\Response\ApiResponse; + +interface ActionServiceInterface +{ + public function getResponse(): ApiResponse; + + public function runAction(): void; + + public function validate(): bool; +} \ No newline at end of file diff --git a/app/src/Service/Action/BaseActionService.php b/app/src/Service/Action/BaseActionService.php new file mode 100644 index 0000000..a2007b7 --- /dev/null +++ b/app/src/Service/Action/BaseActionService.php @@ -0,0 +1,34 @@ +<?php + +namespace App\Service\Action; + +use App\Response\ApiResponse; +use App\Service\Dto\DtoServiceInterface; +use App\Service\Response\ResponseServiceInterface; + +abstract class BaseActionService implements ActionServiceInterface +{ + protected ?ResponseServiceInterface $responseService; + + public function __construct( + ResponseServiceInterface $baseResponseService, + ) + { + $this->responseService = $baseResponseService; + } + + public function getResponse(): ApiResponse + { + if ($this->validate()) { + $this->runAction(); + } + + if ($this->responseService) { + return $this->responseService->getResponse(); + } + + $response = new ApiResponse(); + $response->addError('Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¾Ñ‚Ð²ÐµÑ‚Ð°'); + return $response; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/None.php b/app/src/Service/Action/Classes/None.php new file mode 100644 index 0000000..d75a862 --- /dev/null +++ b/app/src/Service/Action/Classes/None.php @@ -0,0 +1,22 @@ +<?php + +namespace App\Service\Action\Classes; + +use App\Service\Action\BaseActionService; + +class None extends BaseActionService +{ + + public function runAction(): void + { + + } + + public function validate(): bool + { + if ($this->responseService) { + $this->responseService->getResponse()->addError('ДейÑтвие не выбрано'); + } + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/Register.php b/app/src/Service/Action/Classes/Register.php new file mode 100644 index 0000000..f6ec013 --- /dev/null +++ b/app/src/Service/Action/Classes/Register.php @@ -0,0 +1,109 @@ +<?php + +namespace App\Service\Action\Classes; + +use App\Entity\User; +use App\Service\Action\BaseActionService; +use App\Service\Dto\Classes\RegisterDto; +use App\Service\Dto\DtoServiceInterface; +use App\Service\Response\ResponseServiceInterface; +use Doctrine\Persistence\ManagerRegistry; +use ReflectionClass; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; + +class Register extends BaseActionService +{ + /** + * @param RegisterDto $registerDto + * @param ResponseServiceInterface $profileResponse + * @param UserPasswordHasherInterface $passwordHasher + * @param ManagerRegistry $doctrine + */ + public function __construct( + private DtoServiceInterface $registerDto, + private ResponseServiceInterface $profileResponse, + private UserPasswordHasherInterface $passwordHasher, + private ManagerRegistry $doctrine, + ) + { + parent::__construct($profileResponse); + } + + /** + * РегиÑÑ‚Ñ€Ð°Ñ†Ð¸Ñ + * + * @return void + */ + public function runAction(): void + { + $user = $this->createUser(); + if ($user !== null) { + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($user->getEmail(), $user->getPhoneNumber()); + + if ($userExists) { + $this->profileResponse->getResponse()->addError('Пользователь уже ÑущеÑтвует'); + } else { + try { + $user->setDeleted(false); + $user->setConfirm(false); + $hashedPassword = $this->passwordHasher->hashPassword( + $user, + $this->registerDto->getClass()->password ?: '' + ); + $user->setPassword($hashedPassword); + + $em = $this->doctrine->getManager(); + + $em->persist($user); + $em->flush(); + $this->profileResponse->getResponse()->addMessage('Пользователь зарегиÑтрирован'); + } catch (\Exception $exception) { + $this->profileResponse->getResponse()->addError('Ошибка региÑтрации пользователÑ'); + } + + } + } + } + + /** + * Ð’Ð°Ð»Ð¸Ð´Ð°Ñ†Ð¸Ñ + * + * @return bool + */ + public function validate(): bool + { + return $this->registerDto->validate($this->profileResponse); + } + + /** + * Создание Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ð¸Ð· Dto + * + * @return User|null + */ + private function createUser(): ?User + { + $user = null; + + $data = $this->registerDto->toArray(); + + if ($data) { + $user = new User(); + + $reflectionClass = new ReflectionClass($user); + foreach ($reflectionClass->getProperties() as $property) { + $type = $property->getType(); + if (isset($data[$property->getName()])) { + $sValue = $data[$property->getName()] ?: null; + if ($sValue !== null && $type !== null && ($type->getName() !== 'array')) { + $property->setValue($user, $sValue); + } + } + } + } else { + $this->profileResponse->getResponse()->addError('Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð´Ð°Ð½Ð½Ñ‹Ñ…'); + } + + return $user; + } +} \ No newline at end of file diff --git a/app/src/Service/Dto/BaseDto.php b/app/src/Service/Dto/BaseDto.php new file mode 100644 index 0000000..7cbf7f5 --- /dev/null +++ b/app/src/Service/Dto/BaseDto.php @@ -0,0 +1,93 @@ +<?php + +namespace App\Service\Dto; + +use App\Service\Response\ResponseServiceInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +abstract class BaseDto implements DtoServiceInterface +{ + private ?Request $request = null; + + public function __construct( + private ?ValidatorInterface $validator, + ?RequestStack $requestStack = null, + ) + { + if ($requestStack) { + $this->request = $requestStack->getCurrentRequest(); + } + } + + + /** + * Получение клаÑÑа Dto + * + * @return DtoServiceInterface|null + */ + public function getClass(): ?DtoServiceInterface + { + if ($this->request) { + $normalizer = new ObjectNormalizer( + null, + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer( + [$normalizer, new DateTimeNormalizer()], + [new JsonEncoder()] + ); + return $serializer->deserialize($this->request->getContent(), static::class, 'json'); + } + + return null; + } + + public function toArray(): ?array + { + try { + $oNormalizer = new ObjectNormalizer( + null, + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $oSerializer = new Serializer([$oNormalizer], [new JsonEncoder()]); + $sData = $oSerializer->serialize($this->getClass(), 'json'); + return json_decode($sData, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $oException) { + return null; + } + } + + /** + * Ð’Ð°Ð»Ð¸Ð´Ð°Ñ†Ð¸Ñ Dto + * + * @param ResponseServiceInterface $response + * + * @return bool + */ + public function validate(ResponseServiceInterface $response): bool + { + $apiResponse = $response->getResponse(); + + $bValid = true; + $aErrors = $this->validator->validate($this->getClass()); + if (count($aErrors) > 0) { + foreach ($aErrors as $error) { + $apiResponse->addError($error->getMessage()); + } + $bValid = false; + } + return $bValid; + } +} \ No newline at end of file diff --git a/app/src/Service/Dto/Classes/ChangePasswordDto.php b/app/src/Service/Dto/Classes/ChangePasswordDto.php new file mode 100644 index 0000000..f15145b --- /dev/null +++ b/app/src/Service/Dto/Classes/ChangePasswordDto.php @@ -0,0 +1,28 @@ +<?php + +namespace App\Service\Dto\Classes; + +use App\Service\Dto\BaseDto; +use App\Validators\PasswordValidator; +use Symfony\Component\Validator\Constraints as Assert; + +#[Assert\Callback([PasswordValidator::class, 'validateRepeatPassword'])] +#[Assert\Callback([PasswordValidator::class, 'validateNewPassword'])] +#[Assert\Callback([PasswordValidator::class, 'validatePassword'])] +class ChangePasswordDto extends BaseDto +{ + #[Assert\NotBlank( + message: 'Ðе получен текущий пароль.', + )] + public ?string $oldPassword = null; + + #[Assert\NotBlank( + message: 'Ðе получен новый пароль.', + )] + public ?string $password = null; + + #[Assert\NotBlank( + message: 'Ðе получен повтор нового паролÑ.', + )] + public ?string $repeatPassword = null; +} \ No newline at end of file diff --git a/app/src/Service/Dto/Classes/NoneDto.php b/app/src/Service/Dto/Classes/NoneDto.php new file mode 100644 index 0000000..bb31bf3 --- /dev/null +++ b/app/src/Service/Dto/Classes/NoneDto.php @@ -0,0 +1,10 @@ +<?php + +namespace App\Service\Dto\Classes; + +use App\Service\Dto\BaseDto; + +class NoneDto extends BaseDto +{ + +} \ No newline at end of file diff --git a/app/src/Service/Dto/Classes/RegisterDto.php b/app/src/Service/Dto/Classes/RegisterDto.php new file mode 100644 index 0000000..74247d5 --- /dev/null +++ b/app/src/Service/Dto/Classes/RegisterDto.php @@ -0,0 +1,48 @@ +<?php + +namespace App\Service\Dto\Classes; + +use App\Service\Dto\BaseDto; +use App\Validators\PasswordValidator; +use Symfony\Component\Validator\Constraints as Assert; + +#[Assert\Callback([PasswordValidator::class, 'validateRepeatPassword'])] +#[Assert\Callback([PasswordValidator::class, 'validatePassword'])] +class RegisterDto extends BaseDto +{ + #[Assert\NotBlank( + message: 'Ðе получен Email.', + )] + #[Assert\Email( + message: 'Email "{{ value }}" неверный.', + )] + public ?string $email = null; + + #[Assert\NotBlank( + message: 'Ðе получен пароль.', + )] + public ?string $password = null; + + #[Assert\NotBlank( + message: 'Ðе получен повтор паролÑ.', + )] + public ?string $repeatPassword = null; + + #[Assert\NotBlank( + message: 'Ðе получено имÑ.', + )] + public ?string $name = null; + + #[Assert\NotBlank( + message: 'Ðе получена фамилиÑ.', + )] + public ?string $surname = null; + + public ?string $patronymic = null; + + #[Assert\Regex( + pattern: '/^((8|\+7)[\- ]?)?(\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10}$/i', + message: 'Ðеверный формат телефона' + )] + public ?string $phoneNumber = null; +} \ No newline at end of file diff --git a/app/src/Service/Dto/DtoServiceInterface.php b/app/src/Service/Dto/DtoServiceInterface.php new file mode 100644 index 0000000..b3e9f45 --- /dev/null +++ b/app/src/Service/Dto/DtoServiceInterface.php @@ -0,0 +1,14 @@ +<?php + +namespace App\Service\Dto; + +use App\Service\Response\ResponseServiceInterface; + +interface DtoServiceInterface +{ + public function getClass(): ?DtoServiceInterface; + + public function validate(ResponseServiceInterface $response): bool; + + public function toArray(): ?array; +} \ No newline at end of file diff --git a/app/src/Service/Response/BaseResponseService.php b/app/src/Service/Response/BaseResponseService.php new file mode 100644 index 0000000..1e39d34 --- /dev/null +++ b/app/src/Service/Response/BaseResponseService.php @@ -0,0 +1,22 @@ +<?php + +namespace App\Service\Response; + +use App\Response\ApiResponse; + +abstract class BaseResponseService implements ResponseServiceInterface +{ + private ApiResponse $response; + + public function __construct( + + ) + { + $this->response = new ApiResponse(); + } + + public function getResponse(): ApiResponse + { + return $this->response; + } +} \ No newline at end of file diff --git a/app/src/Service/Response/Classes/ProfileResponse.php b/app/src/Service/Response/Classes/ProfileResponse.php new file mode 100644 index 0000000..384f3d0 --- /dev/null +++ b/app/src/Service/Response/Classes/ProfileResponse.php @@ -0,0 +1,14 @@ +<?php + +namespace App\Service\Response\Classes; + +use App\Entity\User; +use App\Service\Response\BaseResponseService; + +class ProfileResponse extends BaseResponseService +{ + public function setUser(User $user): void + { + + } +} \ No newline at end of file diff --git a/app/src/Service/Response/Classes/Response.php b/app/src/Service/Response/Classes/Response.php new file mode 100644 index 0000000..390215f --- /dev/null +++ b/app/src/Service/Response/Classes/Response.php @@ -0,0 +1,10 @@ +<?php + +namespace App\Service\Response\Classes; + +use App\Service\Response\BaseResponseService; + +class Response extends BaseResponseService +{ + +} \ No newline at end of file diff --git a/app/src/Service/Response/ResponseServiceInterface.php b/app/src/Service/Response/ResponseServiceInterface.php new file mode 100644 index 0000000..d291c5a --- /dev/null +++ b/app/src/Service/Response/ResponseServiceInterface.php @@ -0,0 +1,10 @@ +<?php + +namespace App\Service\Response; + +use App\Response\ApiResponse; + +interface ResponseServiceInterface +{ + public function getResponse(): ApiResponse; +} \ No newline at end of file diff --git a/app/src/Validators/PasswordValidator.php b/app/src/Validators/PasswordValidator.php new file mode 100644 index 0000000..b674f4c --- /dev/null +++ b/app/src/Validators/PasswordValidator.php @@ -0,0 +1,71 @@ +<?php + +namespace App\Validators; + +use Symfony\Component\Validator\Context\ExecutionContextInterface; + +class PasswordValidator +{ + /** + * Проверка на неÑовпадение нового Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð¸ его повтора + * + * @param mixed $value + * @param ExecutionContextInterface $context + * @param mixed $payload + * + * @return void + */ + public static function validateRepeatPassword(mixed $value, ExecutionContextInterface $context, mixed $payload): void + { + $object = $context->getObject(); + if ($object) { + if ($object->password !== $object->repeatPassword) { + $context->buildViolation('Повтор Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð½Ðµ Ñовпадает') + ->addViolation(); + } + } + } + + /** + * Проверка на Ñовпадение нового Ð¿Ð°Ñ€Ð¾Ð»Ñ Ð¸ Ñтарого + * + * @param mixed $value + * @param ExecutionContextInterface $context + * @param mixed $payload + * + * @return void + */ + public static function validateNewPassword(mixed $value, ExecutionContextInterface $context, mixed $payload): void + { + $object = $context->getObject(); + if ($object) { + if ($object->password === $object->oldPassword) { + $context->buildViolation('Ðовый пароль не должен Ñовпадать Ñо Ñтарым') + ->addViolation(); + } + } + } + + /** + * Проверка Ð¿Ð°Ñ€Ð¾Ð»Ñ + * + * @param mixed $value + * @param ExecutionContextInterface $context + * @param mixed $payload + * + * @return void + */ + public static function validatePassword(mixed $value, ExecutionContextInterface $context, mixed $payload): void + { + $reg = '/(?=^.{8,}$)((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/'; + + $object = $context->getObject(); + + if ($object) { + if (!preg_match($reg, $object->password)) { + $context->buildViolation('Пароль должен Ñодержать Ñтрочные и пропиÑные латинÑкие буквы, цифры, ÑпецÑимволы. Минимум 8 Ñимволов') + ->addViolation(); + } + } + } +} \ No newline at end of file -- GitLab