diff --git a/config/packages/security.yaml b/config/packages/security.yaml index fb4c593..133e1c1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -2,6 +2,12 @@ security: # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: in_memory: { memory: ~ } + encoders: + App\Entity\User: + algorithm: 'argon2i' + memory_cost: 16384 + time_cost: 2 + threads: 4 firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3b315dc..454a794 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -2,3 +2,4 @@ twig: paths: ['%kernel.project_dir%/templates'] debug: '%kernel.debug%' strict_variables: '%kernel.debug%' + form_themes: ['bootstrap_4_layout.html.twig'] diff --git a/config/routes.yaml b/config/routes.yaml index 3c68b70..f75dc68 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -16,6 +16,13 @@ torrents_show: method: GET id: '\d+' +user_register: + path: /register/{inviteCode} + controller: App\Controller\UserController::register + requirements: + method: GET + inviteCode: \w{32} + # API api_v1_torrents: path: /api/v1/torrents diff --git a/src/Command/AddUserCommand.php b/src/Command/AddUserCommand.php new file mode 100644 index 0000000..f6361a0 --- /dev/null +++ b/src/Command/AddUserCommand.php @@ -0,0 +1,98 @@ +em = $em; + $this->userManager = $userManager; + $this->userRepo = $userRepo; + $this->inviteRepo = $inviterepo; + } + + protected function configure() + { + $this + ->setName('user:add') + ->addArgument('username', InputArgument::REQUIRED, 'Username') + ->addArgument('email', InputArgument::REQUIRED, 'Email') + ->addArgument('password', InputArgument::OPTIONAL, 'Password', null) + ->addOption('invites', 'i', InputOption::VALUE_OPTIONAL, 'Number of invites for user', 0) + ->addOption('role', 'r', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Role to add to the user') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $username = $input->getArgument('username'); + $email = $input->getArgument('email'); + $password = $input->getArgument('password'); + $invites = (int) $input->getOption('invites'); + $roles = (array) $input->getOption('role'); + + if (!$password) { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + $question = new Question('Enter new user\'s password: '); + $question->setHidden(true); + $question->setHiddenFallback(false); + + $password = $questionHelper->ask($input, $output, $question); + } + + if (!$password) { + $output->writeln('User password cannot be empty.'); + + return 1; + } + + if ($roles) { + $user = $this->userManager->createUser($username, $password, $email, $roles); + } else { + $user = $this->userManager->createUser($username, $password, $email); + } + + + $this->userRepo->add($user); + + if ($invites) { + for ($i = 0; $i < $invites; $i++) { + $invite = new Invite($user); + $this->inviteRepo->add($invite); + } + } + + $this->em->flush(); + + $output->writeln(sprintf('User \'%s\' registered, %d invites added.', $user->getUsername(), $invites)); + + return 0; + } + +} \ No newline at end of file diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..4960375 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,64 @@ +inviteCode = $inviteCode; + $form = $this->createRegisterForm($createUserRequest, $inviteCode); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $user = $userManager->createUserByInviteCode( + $createUserRequest->username, + $createUserRequest->password, + $createUserRequest->email, + $createUserRequest->inviteCode + ); + } catch (InvalidInviteException $ex) { + // @FIXME refactor InvalidInviteException to proper validator + $form->get('inviteCode')->addError(new FormError('Invalid invite code')); + + return $this->render('User/register.html.twig', ['form' => $form->createView()]); + } + + $userRepository->add($user); + $em->flush(); + + return $this->redirectToRoute('index'); + } + + return $this->render('User/register.html.twig', ['form' => $form->createView()]); + } + + private function createRegisterForm(CreateUserRequest $createUserRequest, string $inviteCode): FormInterface + { + $form = $this->createForm(CreateUserRequestType::class, $createUserRequest, [ + 'action' => $this->generateUrl('user_register', ['inviteCode' => $inviteCode]), + ]); + + $form->add('submit', SubmitType::class); + + return $form; + } +} \ No newline at end of file diff --git a/src/Entity/Invite.php b/src/Entity/Invite.php index bfdbc85..9b94836 100644 --- a/src/Entity/Invite.php +++ b/src/Entity/Invite.php @@ -6,7 +6,7 @@ use Doctrine\ORM\Mapping as ORM; /** * @ORM\Table(name="invites", schema="users") - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\InviteRepository") */ class Invite { @@ -42,9 +42,9 @@ class Invite */ private $usedBy; - public function __construct(User $user) + public function __construct(User $forUser) { - $this->user = $user; + $this->user = $forUser; $this->code = md5(random_bytes(100)); } @@ -67,4 +67,18 @@ class Invite { return $this->usedBy; } + + public function use(User $user): void + { + if ($this->usedBy) { + throw new \RuntimeException(sprintf( + 'Invite #%d is already used by User#%d and can\'t be used by User#%d', + $this->id, + $this->usedBy->getId(), + $user->getId() + )); + } + + $this->usedBy = $user; + } } \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index 8dec741..e3b0b03 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,7 +8,7 @@ use Symfony\Component\Security\Core\User\UserInterface; /** * @ORM\Table(name="users", schema="users") - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\UserRepository") */ class User implements UserInterface, \Serializable { @@ -42,6 +42,20 @@ class User implements UserInterface, \Serializable */ private $email; + /** + * @var string[] + * + * @ORM\Column(name="roles", type="json") + */ + private $roles = []; + + /** + * @var \DateTime + * + * @ORM\Column(name="created_at", type="datetime") + */ + private $createdAt; + /** * @var Invite[]|ArrayCollection * @@ -49,11 +63,13 @@ class User implements UserInterface, \Serializable */ private $invites; - public function __construct(string $username, string $password, string $email) + public function __construct(string $username, string $password, string $email, array $roles = []) { $this->username = $username; $this->password = $password; $this->email = $email; + $this->roles = $roles ?: ['ROLE_USER']; + $this->createdAt = new \DateTime(); } public function getId(): int @@ -71,6 +87,11 @@ class User implements UserInterface, \Serializable return $this->password; } + public function updatePassword(string $password): void + { + $this->password = $password; + } + public function getSalt() { // Salt is not needed when using Argon2i @@ -83,9 +104,19 @@ class User implements UserInterface, \Serializable return $this->email; } - public function getRoles() + public function getRoles(): array { - return ['ROLE_USER']; + return $this->roles; + } + + public function addRole(string $role): void + { + $this->roles[] = $role; + } + + public function getCreatedAt(): \DateTime + { + return $this->createdAt; } public function eraseCredentials() diff --git a/src/Form/CreateUserRequest.php b/src/Form/CreateUserRequest.php index 7cb7c3f..0647f39 100644 --- a/src/Form/CreateUserRequest.php +++ b/src/Form/CreateUserRequest.php @@ -4,6 +4,9 @@ namespace App\Form; use Symfony\Component\Validator\Constraints as Assert; +/** + * @todo implement UniqueEntity constraint for DTO and use it here + */ class CreateUserRequest { /** @@ -18,7 +21,7 @@ class CreateUserRequest * @var string * * @Assert\NotBlank() - * @Assert\Length(min="8") + * @Assert\Length(min="8", max="4096") */ public $password; diff --git a/src/Repository/InviteRepository.php b/src/Repository/InviteRepository.php new file mode 100644 index 0000000..58bf7b0 --- /dev/null +++ b/src/Repository/InviteRepository.php @@ -0,0 +1,20 @@ +getEntityManager()->persist($invite); + } +} \ No newline at end of file diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..c9b4c14 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,20 @@ +getEntityManager()->persist($user); + } +} \ No newline at end of file diff --git a/src/User/Exception/InvalidInviteException.php b/src/User/Exception/InvalidInviteException.php new file mode 100644 index 0000000..be497ae --- /dev/null +++ b/src/User/Exception/InvalidInviteException.php @@ -0,0 +1,8 @@ +userRepo = $userRepo; + $this->inviteRepo = $inviteRepo; + $this->encoderFactory = $encoderFactory; + } + + public function createUser(string $username, string $password, string $email, array $roles = self::DEFAULT_ROLES): User + { + $encodedPassword = $this->encoderFactory->getEncoder(User::class)->encodePassword($password, null); + + $user = new User( + $username, + $encodedPassword, + $email, + $roles + ); + + return $user; + } + + public function createUserByInviteCode(string $username, string $password, string $email, string $inviteCode, array $roles = self::DEFAULT_ROLES): User + { + /** @var Invite $invite */ + if (null === $invite = $this->inviteRepo->findOneBy(['code' => $inviteCode, 'usedBy' => null])) { + throw new InvalidInviteException(); + } + + $user = $this->createUser($username, $password, $email,$roles); + + $invite->use($user); + + return $user; + } +} \ No newline at end of file diff --git a/templates/User/register.html.twig b/templates/User/register.html.twig new file mode 100644 index 0000000..50491ff --- /dev/null +++ b/templates/User/register.html.twig @@ -0,0 +1,7 @@ +{% extends 'base.html.twig' %} + +{% block content %} +