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
|
||||
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)/
|
||||
|
|
|
@ -2,3 +2,4 @@ twig:
|
|||
paths: ['%kernel.project_dir%/templates']
|
||||
debug: '%kernel.debug%'
|
||||
strict_variables: '%kernel.debug%'
|
||||
form_themes: ['bootstrap_4_layout.html.twig']
|
||||
|
|
|
@ -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
|
||||
|
|
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\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;
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
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