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