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:
Alexey Skobkin 2018-06-25 01:42:26 +03:00
parent 3212f29b3d
commit d953e8a319
13 changed files with 344 additions and 8 deletions

View file

@ -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)/

View file

@ -2,3 +2,4 @@ twig:
paths: ['%kernel.project_dir%/templates']
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes: ['bootstrap_4_layout.html.twig']

View file

@ -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

View 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;
}
}

View 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;
}
}

View file

@ -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;
}
}

View file

@ -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()

View file

@ -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;

View 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);
}
}

View 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);
}
}

View 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
View 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;
}
}

View file

@ -0,0 +1,7 @@
{% extends 'base.html.twig' %}
{% block content %}
<div id="form-register">
{{ form(form) }}
</div>
{% endblock %}