Password reset implemented, some refactoring, Sentry SDK and bundle upgraded, some routes changed. PHP requirements is up to 7.3.0.
This commit is contained in:
parent
578b6c35d1
commit
2c0c48d88e
5
.env
5
.env
|
@ -24,3 +24,8 @@ SENTRY_DSN=
|
|||
# docker-compose
|
||||
PHP_FPM_PORT=9000
|
||||
APP_LOCAL_PATH=/var/www/magnetico-web/current
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=smtp://localhost
|
||||
MAILER_FROM=no-reply@magnetico-web.tld
|
||||
###< symfony/mailer ###
|
||||
|
|
|
@ -12,17 +12,19 @@
|
|||
],
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^7.2.0",
|
||||
"php": "^7.3.0",
|
||||
"ext-ctype": "*",
|
||||
"ext-hash": "*",
|
||||
"ext-iconv": "*",
|
||||
"sensio/framework-extra-bundle": "^5.1",
|
||||
"sentry/sentry-symfony": "^2.2",
|
||||
"symfony/console": "^4.1",
|
||||
"symfony/dotenv": "^4.1",
|
||||
"symfony/expression-language": "^4.1",
|
||||
"symfony/flex": "^1.0",
|
||||
"symfony/form": "^4.1",
|
||||
"symfony/framework-bundle": "^4.1",
|
||||
"symfony/http-client": "^4.1",
|
||||
"symfony/mailer": "^4.1",
|
||||
"symfony/monolog-bundle": "^3.3",
|
||||
"symfony/orm-pack": "^1.0",
|
||||
"symfony/security-bundle": "^4.1",
|
||||
|
|
1753
composer.lock
generated
1753
composer.lock
generated
File diff suppressed because it is too large
Load diff
3
config/packages/mailer.yaml
Normal file
3
config/packages/mailer.yaml
Normal file
|
@ -0,0 +1,3 @@
|
|||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
|
@ -15,3 +15,6 @@ monolog:
|
|||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
sentry:
|
||||
type: service
|
||||
id: Sentry\Monolog\Handler
|
||||
|
|
|
@ -26,10 +26,10 @@ security:
|
|||
anonymous: ~
|
||||
provider: default_provider
|
||||
form_login:
|
||||
login_path: user_login
|
||||
check_path: user_login
|
||||
login_path: user_auth_login
|
||||
check_path: user_auth_login
|
||||
logout:
|
||||
path: user_logout
|
||||
path: user_auth_logout
|
||||
target: /
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
|
@ -44,7 +44,7 @@ security:
|
|||
- { path: ^/api/v1/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/api/, roles: ROLE_USER }
|
||||
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/auth/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/register/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/magnet/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/, roles: ROLE_USER }
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
sentry:
|
||||
options:
|
||||
curl_method: async
|
||||
|
||||
# skip_capture: # To skip certain exceptions, specify a list below
|
||||
# - 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException'
|
||||
# - 'Symfony\Component\HttpKernel\Exception\BadRequestHttpException'
|
||||
# - 'Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException'
|
||||
send_default_pii: true
|
||||
excluded_exceptions:
|
||||
- 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException'
|
||||
- 'Symfony\Component\HttpKernel\Exception\BadRequestHttpException'
|
||||
- 'Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException'
|
||||
monolog:
|
||||
error_handler:
|
||||
enabled: true
|
||||
level: error
|
||||
|
|
|
@ -26,25 +26,33 @@ magnet_redirect:
|
|||
infoHash: '[0-9a-fA-F]{40}'
|
||||
|
||||
user_register:
|
||||
path: /register/{inviteCode}
|
||||
path: /register/{code}
|
||||
controller: App\Controller\UserController::register
|
||||
requirements:
|
||||
method: GET
|
||||
inviteCode: \w{32}
|
||||
|
||||
user_reset_request:
|
||||
path: /auth/reset/request
|
||||
controller: App\Controller\UserController::requestReset
|
||||
|
||||
user_reset:
|
||||
path: /auth/reset/{code}
|
||||
controller: App\Controller\UserController::reset
|
||||
|
||||
user_auth_login:
|
||||
path: /auth/login
|
||||
controller: App\Controller\SecurityController::login
|
||||
|
||||
user_auth_logout:
|
||||
path: /auth/logout
|
||||
|
||||
user_account_invites:
|
||||
path: /account/invites
|
||||
controller: App\Controller\AccountController::invites
|
||||
requirements:
|
||||
method: GET
|
||||
|
||||
user_login:
|
||||
path: /login
|
||||
controller: App\Controller\SecurityController::login
|
||||
|
||||
user_logout:
|
||||
path: /logout
|
||||
|
||||
# API
|
||||
api_v1_login:
|
||||
path: /api/v1/login
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
parameters:
|
||||
locale: 'en'
|
||||
env(TRACKER_LIST_FILE): '%kernel.project_dir%/config/public_trackers.json'
|
||||
env(NEW_USER_INVITES): 10
|
||||
env(NEW_USER_INVITES): '10'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
|
@ -16,6 +16,7 @@ services:
|
|||
bind:
|
||||
$publicTrackers: '%env(json:file:resolve:TRACKER_LIST_FILE)%'
|
||||
$newUserInvites: '%env(NEW_USER_INVITES)%'
|
||||
$fromAddress: '%env(MAILER_FROM)%'
|
||||
|
||||
App\:
|
||||
resource: '../src/*'
|
||||
|
@ -44,3 +45,6 @@ services:
|
|||
App\Search\TorrentSearcher:
|
||||
arguments:
|
||||
$classMetadata: '@=service(''doctrine.orm.magneticod_entity_manager'').getClassMetadata(''App\\Magnetico\\Entity\\Torrent'')'
|
||||
|
||||
Monolog\Processor\PsrLogMessageProcessor:
|
||||
tags: { name: monolog.processor, handler: sentry }
|
||||
|
|
|
@ -20,7 +20,7 @@ class MainController extends AbstractController
|
|||
private function createLoginForm(string $username): FormInterface
|
||||
{
|
||||
$form = $this->createForm(LoginType::class, null, [
|
||||
'action' => $this->generateUrl('user_login'),
|
||||
'action' => $this->generateUrl('user_auth_login'),
|
||||
]);
|
||||
$form->get('_username')->setData($username);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
|
|
@ -29,7 +29,7 @@ class SecurityController extends AbstractController
|
|||
private function createLoginForm(string $username): FormInterface
|
||||
{
|
||||
$form = $this->createForm(LoginType::class, null, [
|
||||
'action' => $this->generateUrl('user_login'),
|
||||
'action' => $this->generateUrl('user_auth_login'),
|
||||
]);
|
||||
$form->get('_username')->setData($username);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
|
|
@ -2,38 +2,39 @@
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Form\{CreateUserRequestType};
|
||||
use App\FormRequest\CreateUserRequest;
|
||||
use App\Entity\{Invite, PasswordResetToken};
|
||||
use App\Repository\PasswordResetTokenRepository;
|
||||
use App\Form\{Data\PasswordResetRequestData, Data\PasswordResetData, PasswordResetRequestType, PasswordResetType, RegisterType, Data\RegisterData};
|
||||
use App\Repository\InviteRepository;
|
||||
use App\User\{InviteManager, UserManager};
|
||||
use App\User\{Exception\UserNotFoundException, InviteManager, PasswordResetManager, UserManager};
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\{Extension\Core\Type\SubmitType, FormError, FormInterface};
|
||||
use Symfony\Component\HttpFoundation\{Request, Response};
|
||||
|
||||
class UserController extends AbstractController
|
||||
{
|
||||
public function register(
|
||||
string $inviteCode,
|
||||
string $code,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
UserManager $userManager,
|
||||
InviteManager $inviteManager,
|
||||
InviteRepository $inviteRepo
|
||||
): Response {
|
||||
$createUserRequest = new CreateUserRequest($inviteCode);
|
||||
$form = $this->createRegisterForm($createUserRequest, $inviteCode);
|
||||
$formData = new RegisterData($code);
|
||||
$form = $this->createRegisterForm($formData, $code);
|
||||
|
||||
$invite = $inviteRepo->findOneBy(['code' => $inviteCode, 'usedBy' => null]);
|
||||
/** @var Invite $invite */
|
||||
$invite = $inviteRepo->findOneBy(['code' => $code, 'usedBy' => null]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user = $userManager->createUserByInvite(
|
||||
$createUserRequest->username,
|
||||
$createUserRequest->password,
|
||||
$createUserRequest->email,
|
||||
$formData->username,
|
||||
$formData->password,
|
||||
$formData->email,
|
||||
$invite
|
||||
);
|
||||
|
||||
|
@ -46,14 +47,96 @@ class UserController extends AbstractController
|
|||
|
||||
return $this->render('User/register.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'inviteValid' => $invite ? true : null,
|
||||
'invite' => $invite,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createRegisterForm(CreateUserRequest $createUserRequest, string $inviteCode): FormInterface
|
||||
public function requestReset(Request $request, EntityManagerInterface $em, PasswordResetManager $manager): Response
|
||||
{
|
||||
$form = $this->createForm(CreateUserRequestType::class, $createUserRequest, [
|
||||
'action' => $this->generateUrl('user_register', ['inviteCode' => $inviteCode]),
|
||||
$formData = new PasswordResetRequestData();
|
||||
$form = $this->createResetRequestForm($formData);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
$message = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$manager->sendResetLink($formData->email);
|
||||
|
||||
$message = 'Password reset link was sent';
|
||||
} catch (UserNotFoundException $e) {
|
||||
$form->addError(new FormError('User not found'));
|
||||
} catch (\Throwable $e) {
|
||||
\Sentry\captureException($e);
|
||||
$form->addError(new FormError('Something happened. Try again later or contact the administrator.'));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('User/reset.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
public function reset(
|
||||
string $code,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
UserManager $manager,
|
||||
PasswordResetTokenRepository $tokenRepository
|
||||
): Response
|
||||
{
|
||||
$formData = new PasswordResetData();
|
||||
$form = $this->createResetForm($formData, $code);
|
||||
|
||||
/** @var PasswordResetToken $token */
|
||||
$token = $tokenRepository->find($code);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($token && $token->isValid()) {
|
||||
$manager->changePassword($token->getUser(), $formData->password);
|
||||
|
||||
$em->remove($token);
|
||||
$em->flush();
|
||||
|
||||
return $this->redirectToRoute('user_auth_login');
|
||||
} else {
|
||||
$form->addError(new FormError('Invalid token.'));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('User/reset.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createResetRequestForm(PasswordResetRequestData $formData): FormInterface
|
||||
{
|
||||
$form = $this->createForm(PasswordResetRequestType::class, $formData, [
|
||||
'action' => $this->generateUrl('user_reset_request'),
|
||||
]);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
private function createResetForm(PasswordResetData $formData, string $code): FormInterface
|
||||
{
|
||||
$form = $this->createForm(PasswordResetType::class, $formData, [
|
||||
'action' => $this->generateUrl('user_reset', ['code' => $code]),
|
||||
]);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
private function createRegisterForm(RegisterData $formData, string $code): FormInterface
|
||||
{
|
||||
$form = $this->createForm(RegisterType::class, $formData, [
|
||||
'action' => $this->generateUrl('user_register', ['code' => $code]),
|
||||
]);
|
||||
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
|
67
src/Entity/PasswordResetToken.php
Normal file
67
src/Entity/PasswordResetToken.php
Normal file
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* @ORM\Table(schema="users", name="password_reset_tokens")
|
||||
* @ORM\Entity(readOnly=true)
|
||||
*/
|
||||
class PasswordResetToken
|
||||
{
|
||||
/**
|
||||
* @var User
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="User", fetch="EAGER")
|
||||
* @ORM\JoinColumn(name="user_id", nullable=false, onDelete="CASCADE")
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Id()
|
||||
* @ORM\Column(name="code", type="text", nullable=false)
|
||||
*/
|
||||
private $code;
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="valid_until", type="datetime", nullable=false)
|
||||
*/
|
||||
private $validUntil;
|
||||
|
||||
public function __construct(User $user, \DateInterval $validFor = null)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->code = hash('sha3-384', uniqid('reset', true));
|
||||
|
||||
$now = new \DateTime();
|
||||
|
||||
if ($validFor) {
|
||||
$this->validUntil = $now->add($validFor);
|
||||
} else {
|
||||
$this->validUntil = $now->add(new \DateInterval('P1D'));
|
||||
}
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
$now = new \DateTime();
|
||||
|
||||
return $now < $this->validUntil;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ namespace App\Entity;
|
|||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
|
@ -63,10 +64,10 @@ class User implements UserInterface, \Serializable
|
|||
*/
|
||||
private $invites;
|
||||
|
||||
public function __construct(string $username, string $password, string $email, array $roles = [])
|
||||
public function __construct(string $username, PasswordEncoderInterface $encoder, string $rawPassword, string $email, array $roles = [])
|
||||
{
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->password = $encoder->encodePassword($rawPassword, null);
|
||||
$this->email = $email;
|
||||
$this->roles = $roles ?: ['ROLE_USER'];
|
||||
$this->createdAt = new \DateTime();
|
||||
|
@ -87,9 +88,9 @@ class User implements UserInterface, \Serializable
|
|||
return $this->password;
|
||||
}
|
||||
|
||||
public function updatePassword(string $password): void
|
||||
public function changePassword(PasswordEncoderInterface $encoder, string $rawPassword): void
|
||||
{
|
||||
$this->password = $password;
|
||||
$this->password = $encoder->encodePassword($rawPassword, null);
|
||||
}
|
||||
|
||||
public function getSalt()
|
||||
|
@ -143,10 +144,10 @@ class User implements UserInterface, \Serializable
|
|||
/** @see \Serializable::unserialize() */
|
||||
public function unserialize($serialized)
|
||||
{
|
||||
list(
|
||||
[
|
||||
$this->id,
|
||||
$this->username,
|
||||
$this->password
|
||||
) = unserialize($serialized, ['allowed_classes' => false]);
|
||||
] = unserialize($serialized, ['allowed_classes' => false]);
|
||||
}
|
||||
}
|
17
src/Form/Data/PasswordResetData.php
Normal file
17
src/Form/Data/PasswordResetData.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form\Data;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class PasswordResetData
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\NotBlank
|
||||
* @Assert\Length(min="8", max="4096")
|
||||
* @Assert\NotCompromisedPassword(skipOnError=true)
|
||||
*/
|
||||
public $password;
|
||||
}
|
16
src/Form/Data/PasswordResetRequestData.php
Normal file
16
src/Form/Data/PasswordResetRequestData.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form\Data;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class PasswordResetRequestData
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\Email()
|
||||
* @Assert\NotBlank()
|
||||
*/
|
||||
public $email;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\FormRequest;
|
||||
namespace App\Form\Data;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
|
@ -8,7 +8,7 @@ use App\Validator\Constraints as AppAssert;
|
|||
/**
|
||||
* @todo implement UniqueEntity constraint for DTO and use it here
|
||||
*/
|
||||
class CreateUserRequest
|
||||
class RegisterData
|
||||
{
|
||||
/**
|
||||
* @var string
|
|
@ -21,6 +21,4 @@ class LoginType extends AbstractType
|
|||
// Empty prefix for default UsernamePasswordFrormAuthenticationListener
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
22
src/Form/PasswordResetRequestType.php
Normal file
22
src/Form/PasswordResetRequestType.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Data\PasswordResetRequestData;
|
||||
use Symfony\Component\Form\{AbstractType, Extension\Core\Type\EmailType, FormBuilderInterface};
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class PasswordResetRequestType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('email', EmailType::class);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PasswordResetRequestData::class,
|
||||
]);
|
||||
}
|
||||
}
|
32
src/Form/PasswordResetType.php
Normal file
32
src/Form/PasswordResetType.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Data\PasswordResetData;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\{HiddenType, PasswordType, RepeatedType};
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class PasswordResetType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('password', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'invalid_message' => 'The password fields must match.',
|
||||
'required' => true,
|
||||
'first_options' => ['label' => 'Password'],
|
||||
'second_options' => ['label' => 'Repeat'],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PasswordResetData::class,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\FormRequest\CreateUserRequest;
|
||||
use App\Form\Data\RegisterData;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, TextType};
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class CreateUserRequestType extends AbstractType
|
||||
class RegisterType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
|
@ -23,8 +23,7 @@ class CreateUserRequestType extends AbstractType
|
|||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => CreateUserRequest::class,
|
||||
'data_class' => RegisterData::class,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
34
src/Migrations/Version20200118004840.php
Normal file
34
src/Migrations/Version20200118004840.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20200118004840 extends AbstractMigration
|
||||
{
|
||||
public function getDescription() : string
|
||||
{
|
||||
return 'Adding password reset tokens.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema) : void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
|
||||
|
||||
$this->addSql('CREATE TABLE users.password_reset_tokens (code TEXT NOT NULL, user_id INT NOT NULL, valid_until TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(code))');
|
||||
$this->addSql('CREATE INDEX IDX_261A8E65A76ED395 ON users.password_reset_tokens (user_id)');
|
||||
$this->addSql('ALTER TABLE users.password_reset_tokens ADD CONSTRAINT FK_CCD4B965A76ED395 FOREIGN KEY (user_id) REFERENCES users.users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema) : void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
|
||||
|
||||
$this->addSql('DROP TABLE users.password_reset_tokens');
|
||||
}
|
||||
}
|
20
src/Repository/PasswordResetTokenRepository.php
Normal file
20
src/Repository/PasswordResetTokenRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\PasswordResetToken;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PasswordResetTokenRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PasswordResetToken::class);
|
||||
}
|
||||
|
||||
public function add(PasswordResetToken $token): void
|
||||
{
|
||||
$this->getEntityManager()->persist($token);
|
||||
}
|
||||
}
|
8
src/User/Exception/UserNotFoundException.php
Normal file
8
src/User/Exception/UserNotFoundException.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\User\Exception;
|
||||
|
||||
class UserNotFoundException extends \InvalidArgumentException
|
||||
{
|
||||
protected $message = 'User not found';
|
||||
}
|
79
src/User/PasswordResetManager.php
Normal file
79
src/User/PasswordResetManager.php
Normal file
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\User;
|
||||
|
||||
use App\Repository\PasswordResetTokenRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\Entity\{PasswordResetToken, User};
|
||||
use App\Repository\UserRepository;
|
||||
use App\User\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\{Generator\UrlGeneratorInterface, RouterInterface};
|
||||
|
||||
class PasswordResetManager
|
||||
{
|
||||
/** @var UserRepository */
|
||||
private $userRepo;
|
||||
|
||||
/** @var PasswordResetTokenRepository */
|
||||
private $tokenRepo;
|
||||
|
||||
/** @var EntityManagerInterface */
|
||||
private $em;
|
||||
|
||||
/** @var MailerInterface */
|
||||
private $mailer;
|
||||
|
||||
/** @var RouterInterface */
|
||||
private $router;
|
||||
|
||||
/** @var string */
|
||||
private $fromAddress;
|
||||
|
||||
public function __construct(
|
||||
UserRepository $userRepo,
|
||||
PasswordResetTokenRepository $tokenRepo,
|
||||
EntityManagerInterface $em,
|
||||
MailerInterface $mailer,
|
||||
RouterInterface $router,
|
||||
string $fromAddress
|
||||
) {
|
||||
$this->userRepo = $userRepo;
|
||||
$this->tokenRepo = $tokenRepo;
|
||||
$this->em = $em;
|
||||
$this->mailer = $mailer;
|
||||
$this->router = $router;
|
||||
$this->fromAddress = $fromAddress;
|
||||
}
|
||||
|
||||
public function sendResetLink(string $address): void
|
||||
{
|
||||
/** @var User $user */
|
||||
if (null === $user = $this->userRepo->findOneBy(['email' => $address])) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
// @todo add limits
|
||||
$token = new PasswordResetToken($user);
|
||||
$this->tokenRepo->add($token);
|
||||
$this->em->flush();
|
||||
|
||||
$link = $this->router->generate('user_reset', ['code' => $token->getCode()], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$mail = (new Email())
|
||||
->from($this->fromAddress)
|
||||
->to($user->getEmail())
|
||||
->subject('Password reset')
|
||||
->text(<<<MAIL
|
||||
Here is your password reset link:
|
||||
|
||||
$link
|
||||
MAIL
|
||||
)
|
||||
;
|
||||
|
||||
$this->mailer->send($mail);
|
||||
}
|
||||
}
|
|
@ -29,11 +29,10 @@ class UserManager
|
|||
|
||||
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,
|
||||
$this->encoderFactory->getEncoder(User::class),
|
||||
$password,
|
||||
$email,
|
||||
$roles
|
||||
);
|
||||
|
@ -43,16 +42,24 @@ class UserManager
|
|||
return $user;
|
||||
}
|
||||
|
||||
public function changePassword(User $user, string $rawPassword): void
|
||||
{
|
||||
$user->changePassword(
|
||||
$this->encoderFactory->getEncoder(User::class),
|
||||
$rawPassword
|
||||
);
|
||||
}
|
||||
|
||||
public function createUserByInvite(string $username, string $password, string $email, Invite $invite, array $roles = self::DEFAULT_ROLES): User
|
||||
{
|
||||
if (null !== $invite->getUsedBy()) {
|
||||
throw new InvalidInviteException();
|
||||
}
|
||||
|
||||
$user = $this->createUser($username, $password, $email,$roles);
|
||||
$user = $this->createUser($username, $password, $email, $roles);
|
||||
|
||||
$invite->use($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
81
symfony.lock
81
symfony.lock
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"clue/stream-filter": {
|
||||
"version": "v1.4.1"
|
||||
},
|
||||
"doctrine/annotations": {
|
||||
"version": "1.0",
|
||||
"recipe": {
|
||||
|
@ -62,6 +65,21 @@
|
|||
"doctrine/reflection": {
|
||||
"version": "v1.0.0"
|
||||
},
|
||||
"egulias/email-validator": {
|
||||
"version": "2.1.14"
|
||||
},
|
||||
"guzzlehttp/guzzle": {
|
||||
"version": "6.5.2"
|
||||
},
|
||||
"guzzlehttp/promises": {
|
||||
"version": "v1.3.1"
|
||||
},
|
||||
"guzzlehttp/psr7": {
|
||||
"version": "1.6.1"
|
||||
},
|
||||
"http-interop/http-factory-guzzle": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"jdorn/sql-formatter": {
|
||||
"version": "v1.2.17"
|
||||
},
|
||||
|
@ -83,6 +101,33 @@
|
|||
"pagerfanta/pagerfanta": {
|
||||
"version": "v2.0.1"
|
||||
},
|
||||
"paragonie/random_compat": {
|
||||
"version": "v9.99.99"
|
||||
},
|
||||
"php": {
|
||||
"version": "7.4"
|
||||
},
|
||||
"php-http/client-common": {
|
||||
"version": "2.1.0"
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.7.4"
|
||||
},
|
||||
"php-http/guzzle6-adapter": {
|
||||
"version": "v2.0.1"
|
||||
},
|
||||
"php-http/httplug": {
|
||||
"version": "2.1.0"
|
||||
},
|
||||
"php-http/message": {
|
||||
"version": "1.8.0"
|
||||
},
|
||||
"php-http/message-factory": {
|
||||
"version": "v1.0.2"
|
||||
},
|
||||
"php-http/promise": {
|
||||
"version": "v1.0.0"
|
||||
},
|
||||
"phpdocumentor/reflection-common": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
|
@ -98,9 +143,21 @@
|
|||
"psr/container": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"psr/http-client": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"psr/http-factory": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/http-message": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/log": {
|
||||
"version": "1.0.2"
|
||||
},
|
||||
"ralouphie/getallheaders": {
|
||||
"version": "3.0.3"
|
||||
},
|
||||
"sensio/framework-extra-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
|
@ -110,6 +167,9 @@
|
|||
"ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543"
|
||||
}
|
||||
},
|
||||
"sentry/sdk": {
|
||||
"version": "2.1.0"
|
||||
},
|
||||
"sentry/sentry": {
|
||||
"version": "1.10.0"
|
||||
},
|
||||
|
@ -191,6 +251,12 @@
|
|||
"ref": "1279df12895f20d8076324036431833181eb6645"
|
||||
}
|
||||
},
|
||||
"symfony/http-client": {
|
||||
"version": "v4.4.2"
|
||||
},
|
||||
"symfony/http-client-contracts": {
|
||||
"version": "v2.0.1"
|
||||
},
|
||||
"symfony/http-foundation": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
|
@ -203,6 +269,18 @@
|
|||
"symfony/intl": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/mailer": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "master",
|
||||
"version": "4.3",
|
||||
"ref": "15658c2a0176cda2e7dba66276a2030b52bd81b2"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/mailer.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mime": {
|
||||
"version": "v4.3.2"
|
||||
},
|
||||
|
@ -239,6 +317,9 @@
|
|||
"symfony/polyfill-php73": {
|
||||
"version": "v1.11.0"
|
||||
},
|
||||
"symfony/polyfill-uuid": {
|
||||
"version": "v1.13.1"
|
||||
},
|
||||
"symfony/process": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
{% if invite.usedBy %}
|
||||
Used by <strong>{{ invite.usedBy.username }}</strong>.
|
||||
{% else %}
|
||||
{% set invite_url = url('user_register', { inviteCode: invite.code }) %}
|
||||
{% set invite_url = url('user_register', { code: invite.code }) %}
|
||||
<input class="form-control" type="url" value="{{ invite_url }}" readonly="readonly" onclick="this.select()">
|
||||
{% endif %}
|
||||
</td>
|
||||
|
|
|
@ -4,4 +4,6 @@
|
|||
<div id="form-login">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
|
||||
<a href="{{ path('user_reset_request') }}" class="btn btn-warning" role="button">Reset</a>
|
||||
{% endblock %}
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% if inviteValid %}
|
||||
{% if invite is not null %}
|
||||
<div id="form-register">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
|
|
11
templates/User/reset.html.twig
Normal file
11
templates/User/reset.html.twig
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% if message is defined and message is not empty %}
|
||||
<div class="alert alert-success" role="alert">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="form-reset">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -37,7 +37,7 @@
|
|||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a href="{{ path('user_account_invites') }}" class="dropdown-item"><i class="fas fa-user-plus"></i> Invites</a>
|
||||
<a href="{{ path('user_logout') }}" class="dropdown-item"><i class="fas fa-sign-out-alt"></i> Logout</a>
|
||||
<a href="{{ path('user_auth_logout') }}" class="dropdown-item"><i class="fas fa-sign-out-alt"></i> Logout</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% block content %}
|
||||
<div class="well">
|
||||
{% if not is_granted('ROLE_USER') %}
|
||||
<a href="{{ path('user_login') }}" class="btn btn-lg btn-primary"><i class="fas fa-sign-in-alt"></i> Login</a>
|
||||
<a href="{{ path('user_auth_login') }}" class="btn btn-lg btn-primary"><i class="fas fa-sign-in-alt"></i> Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
Loading…
Reference in a new issue