User registration implemented: registration form, user:add command. User creation time and roles are now stored in the DB. Simple invite system implemented.
This commit is contained in:
parent
3212f29b3d
commit
d953e8a319
|
@ -2,6 +2,12 @@ security:
|
||||||
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
||||||
providers:
|
providers:
|
||||||
in_memory: { memory: ~ }
|
in_memory: { memory: ~ }
|
||||||
|
encoders:
|
||||||
|
App\Entity\User:
|
||||||
|
algorithm: 'argon2i'
|
||||||
|
memory_cost: 16384
|
||||||
|
time_cost: 2
|
||||||
|
threads: 4
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
|
|
|
@ -2,3 +2,4 @@ twig:
|
||||||
paths: ['%kernel.project_dir%/templates']
|
paths: ['%kernel.project_dir%/templates']
|
||||||
debug: '%kernel.debug%'
|
debug: '%kernel.debug%'
|
||||||
strict_variables: '%kernel.debug%'
|
strict_variables: '%kernel.debug%'
|
||||||
|
form_themes: ['bootstrap_4_layout.html.twig']
|
||||||
|
|
|
@ -16,6 +16,13 @@ torrents_show:
|
||||||
method: GET
|
method: GET
|
||||||
id: '\d+'
|
id: '\d+'
|
||||||
|
|
||||||
|
user_register:
|
||||||
|
path: /register/{inviteCode}
|
||||||
|
controller: App\Controller\UserController::register
|
||||||
|
requirements:
|
||||||
|
method: GET
|
||||||
|
inviteCode: \w{32}
|
||||||
|
|
||||||
# API
|
# API
|
||||||
api_v1_torrents:
|
api_v1_torrents:
|
||||||
path: /api/v1/torrents
|
path: /api/v1/torrents
|
||||||
|
|
98
src/Command/AddUserCommand.php
Normal file
98
src/Command/AddUserCommand.php
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Invite;
|
||||||
|
use App\Repository\{InviteRepository, UserRepository};
|
||||||
|
use App\User\UserManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||||
|
use Symfony\Component\Console\Input\{InputArgument, InputInterface, InputOption};
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Question\Question;
|
||||||
|
|
||||||
|
class AddUserCommand extends Command
|
||||||
|
{
|
||||||
|
/** @var EntityManagerInterface */
|
||||||
|
private $em;
|
||||||
|
|
||||||
|
/** @var UserManager */
|
||||||
|
private $userManager;
|
||||||
|
|
||||||
|
/** @var UserRepository */
|
||||||
|
private $userRepo;
|
||||||
|
|
||||||
|
/** @var InviteRepository */
|
||||||
|
private $inviteRepo;
|
||||||
|
|
||||||
|
public function __construct(EntityManagerInterface $em, UserManager $userManager, UserRepository $userRepo, InviteRepository $inviterepo)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
64
src/Controller/UserController.php
Normal file
64
src/Controller/UserController.php
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Form\{CreateUserRequest, CreateUserRequestType};
|
||||||
|
use App\Repository\{UserRepository};
|
||||||
|
use App\User\Exception\InvalidInviteException;
|
||||||
|
use App\User\UserManager;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\{FormError, FormInterface};
|
||||||
|
use Symfony\Component\HttpFoundation\{Request, Response};
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function register(
|
||||||
|
string $inviteCode,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
UserManager $userManager,
|
||||||
|
UserRepository $userRepository
|
||||||
|
): Response {
|
||||||
|
$createUserRequest = new CreateUserRequest();
|
||||||
|
$createUserRequest->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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Table(name="invites", schema="users")
|
* @ORM\Table(name="invites", schema="users")
|
||||||
* @ORM\Entity()
|
* @ORM\Entity(repositoryClass="App\Repository\InviteRepository")
|
||||||
*/
|
*/
|
||||||
class Invite
|
class Invite
|
||||||
{
|
{
|
||||||
|
@ -42,9 +42,9 @@ class Invite
|
||||||
*/
|
*/
|
||||||
private $usedBy;
|
private $usedBy;
|
||||||
|
|
||||||
public function __construct(User $user)
|
public function __construct(User $forUser)
|
||||||
{
|
{
|
||||||
$this->user = $user;
|
$this->user = $forUser;
|
||||||
$this->code = md5(random_bytes(100));
|
$this->code = md5(random_bytes(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,4 +67,18 @@ class Invite
|
||||||
{
|
{
|
||||||
return $this->usedBy;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ORM\Table(name="users", schema="users")
|
* @ORM\Table(name="users", schema="users")
|
||||||
* @ORM\Entity()
|
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
|
||||||
*/
|
*/
|
||||||
class User implements UserInterface, \Serializable
|
class User implements UserInterface, \Serializable
|
||||||
{
|
{
|
||||||
|
@ -42,6 +42,20 @@ class User implements UserInterface, \Serializable
|
||||||
*/
|
*/
|
||||||
private $email;
|
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
|
* @var Invite[]|ArrayCollection
|
||||||
*
|
*
|
||||||
|
@ -49,11 +63,13 @@ class User implements UserInterface, \Serializable
|
||||||
*/
|
*/
|
||||||
private $invites;
|
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->username = $username;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
$this->email = $email;
|
$this->email = $email;
|
||||||
|
$this->roles = $roles ?: ['ROLE_USER'];
|
||||||
|
$this->createdAt = new \DateTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
@ -71,6 +87,11 @@ class User implements UserInterface, \Serializable
|
||||||
return $this->password;
|
return $this->password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatePassword(string $password): void
|
||||||
|
{
|
||||||
|
$this->password = $password;
|
||||||
|
}
|
||||||
|
|
||||||
public function getSalt()
|
public function getSalt()
|
||||||
{
|
{
|
||||||
// Salt is not needed when using Argon2i
|
// Salt is not needed when using Argon2i
|
||||||
|
@ -83,9 +104,19 @@ class User implements UserInterface, \Serializable
|
||||||
return $this->email;
|
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()
|
public function eraseCredentials()
|
||||||
|
|
|
@ -4,6 +4,9 @@ namespace App\Form;
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo implement UniqueEntity constraint for DTO and use it here
|
||||||
|
*/
|
||||||
class CreateUserRequest
|
class CreateUserRequest
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -18,7 +21,7 @@ class CreateUserRequest
|
||||||
* @var string
|
* @var string
|
||||||
*
|
*
|
||||||
* @Assert\NotBlank()
|
* @Assert\NotBlank()
|
||||||
* @Assert\Length(min="8")
|
* @Assert\Length(min="8", max="4096")
|
||||||
*/
|
*/
|
||||||
public $password;
|
public $password;
|
||||||
|
|
||||||
|
|
20
src/Repository/InviteRepository.php
Normal file
20
src/Repository/InviteRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Invite;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||||
|
|
||||||
|
class InviteRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(RegistryInterface $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Invite::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(Invite $invite): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($invite);
|
||||||
|
}
|
||||||
|
}
|
20
src/Repository/UserRepository.php
Normal file
20
src/Repository/UserRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||||
|
|
||||||
|
class UserRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(RegistryInterface $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(User $user): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($user);
|
||||||
|
}
|
||||||
|
}
|
8
src/User/Exception/InvalidInviteException.php
Normal file
8
src/User/Exception/InvalidInviteException.php
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User\Exception;
|
||||||
|
|
||||||
|
class InvalidInviteException extends \Exception
|
||||||
|
{
|
||||||
|
protected $message = 'Invalid invite';
|
||||||
|
}
|
57
src/User/UserManager.php
Normal file
57
src/User/UserManager.php
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
use App\Entity\{Invite, User};
|
||||||
|
use App\Repository\{InviteRepository, UserRepository};
|
||||||
|
use App\User\Exception\InvalidInviteException;
|
||||||
|
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||||
|
|
||||||
|
class UserManager
|
||||||
|
{
|
||||||
|
private const DEFAULT_ROLES = ['ROLE_USER'];
|
||||||
|
|
||||||
|
/** @var UserRepository */
|
||||||
|
private $userRepo;
|
||||||
|
|
||||||
|
/** @var InviteRepository */
|
||||||
|
private $inviteRepo;
|
||||||
|
|
||||||
|
/** @var EncoderFactoryInterface */
|
||||||
|
private $encoderFactory;
|
||||||
|
|
||||||
|
public function __construct(EncoderFactoryInterface $encoderFactory, UserRepository $userRepo, InviteRepository $inviteRepo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
7
templates/User/register.html.twig
Normal file
7
templates/User/register.html.twig
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="form-register">
|
||||||
|
{{ form(form) }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue