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:
Alexey Skobkin 2020-01-20 20:01:51 +03:00
parent 578b6c35d1
commit 2c0c48d88e
No known key found for this signature in database
GPG key ID: 5D5CEF6F221278E7
33 changed files with 2109 additions and 277 deletions

5
.env
View file

@ -24,3 +24,8 @@ SENTRY_DSN=
# docker-compose # docker-compose
PHP_FPM_PORT=9000 PHP_FPM_PORT=9000
APP_LOCAL_PATH=/var/www/magnetico-web/current APP_LOCAL_PATH=/var/www/magnetico-web/current
###> symfony/mailer ###
MAILER_DSN=smtp://localhost
MAILER_FROM=no-reply@magnetico-web.tld
###< symfony/mailer ###

View file

@ -12,17 +12,19 @@
], ],
"type": "project", "type": "project",
"require": { "require": {
"php": "^7.2.0", "php": "^7.3.0",
"ext-ctype": "*", "ext-ctype": "*",
"ext-hash": "*",
"ext-iconv": "*", "ext-iconv": "*",
"sensio/framework-extra-bundle": "^5.1", "sensio/framework-extra-bundle": "^5.1",
"sentry/sentry-symfony": "^2.2",
"symfony/console": "^4.1", "symfony/console": "^4.1",
"symfony/dotenv": "^4.1", "symfony/dotenv": "^4.1",
"symfony/expression-language": "^4.1", "symfony/expression-language": "^4.1",
"symfony/flex": "^1.0", "symfony/flex": "^1.0",
"symfony/form": "^4.1", "symfony/form": "^4.1",
"symfony/framework-bundle": "^4.1", "symfony/framework-bundle": "^4.1",
"symfony/http-client": "^4.1",
"symfony/mailer": "^4.1",
"symfony/monolog-bundle": "^3.3", "symfony/monolog-bundle": "^3.3",
"symfony/orm-pack": "^1.0", "symfony/orm-pack": "^1.0",
"symfony/security-bundle": "^4.1", "symfony/security-bundle": "^4.1",

1753
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View file

@ -15,3 +15,6 @@ monolog:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine"] channels: ["!event", "!doctrine"]
sentry:
type: service
id: Sentry\Monolog\Handler

View file

@ -26,10 +26,10 @@ security:
anonymous: ~ anonymous: ~
provider: default_provider provider: default_provider
form_login: form_login:
login_path: user_login login_path: user_auth_login
check_path: user_login check_path: user_auth_login
logout: logout:
path: user_logout path: user_auth_logout
target: / target: /
remember_me: remember_me:
secret: '%kernel.secret%' secret: '%kernel.secret%'
@ -44,7 +44,7 @@ security:
- { path: ^/api/v1/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/api/v1/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/, roles: ROLE_USER } - { path: ^/api/, roles: ROLE_USER }
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/auth/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register/, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/register/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/magnet/, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/magnet/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: ROLE_USER } - { path: ^/, roles: ROLE_USER }

View file

@ -1,8 +1,11 @@
sentry: sentry:
options: options:
curl_method: async send_default_pii: true
excluded_exceptions:
# skip_capture: # To skip certain exceptions, specify a list below - 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException'
# - 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException' - 'Symfony\Component\HttpKernel\Exception\BadRequestHttpException'
# - 'Symfony\Component\HttpKernel\Exception\BadRequestHttpException' - 'Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException'
# - 'Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException' monolog:
error_handler:
enabled: true
level: error

View file

@ -26,25 +26,33 @@ magnet_redirect:
infoHash: '[0-9a-fA-F]{40}' infoHash: '[0-9a-fA-F]{40}'
user_register: user_register:
path: /register/{inviteCode} path: /register/{code}
controller: App\Controller\UserController::register controller: App\Controller\UserController::register
requirements: requirements:
method: GET method: GET
inviteCode: \w{32} 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: user_account_invites:
path: /account/invites path: /account/invites
controller: App\Controller\AccountController::invites controller: App\Controller\AccountController::invites
requirements: requirements:
method: GET method: GET
user_login:
path: /login
controller: App\Controller\SecurityController::login
user_logout:
path: /logout
# API # API
api_v1_login: api_v1_login:
path: /api/v1/login path: /api/v1/login

View file

@ -3,7 +3,7 @@
parameters: parameters:
locale: 'en' locale: 'en'
env(TRACKER_LIST_FILE): '%kernel.project_dir%/config/public_trackers.json' env(TRACKER_LIST_FILE): '%kernel.project_dir%/config/public_trackers.json'
env(NEW_USER_INVITES): 10 env(NEW_USER_INVITES): '10'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@ -16,6 +16,7 @@ services:
bind: bind:
$publicTrackers: '%env(json:file:resolve:TRACKER_LIST_FILE)%' $publicTrackers: '%env(json:file:resolve:TRACKER_LIST_FILE)%'
$newUserInvites: '%env(NEW_USER_INVITES)%' $newUserInvites: '%env(NEW_USER_INVITES)%'
$fromAddress: '%env(MAILER_FROM)%'
App\: App\:
resource: '../src/*' resource: '../src/*'
@ -44,3 +45,6 @@ services:
App\Search\TorrentSearcher: App\Search\TorrentSearcher:
arguments: arguments:
$classMetadata: '@=service(''doctrine.orm.magneticod_entity_manager'').getClassMetadata(''App\\Magnetico\\Entity\\Torrent'')' $classMetadata: '@=service(''doctrine.orm.magneticod_entity_manager'').getClassMetadata(''App\\Magnetico\\Entity\\Torrent'')'
Monolog\Processor\PsrLogMessageProcessor:
tags: { name: monolog.processor, handler: sentry }

View file

@ -20,7 +20,7 @@ class MainController extends AbstractController
private function createLoginForm(string $username): FormInterface private function createLoginForm(string $username): FormInterface
{ {
$form = $this->createForm(LoginType::class, null, [ $form = $this->createForm(LoginType::class, null, [
'action' => $this->generateUrl('user_login'), 'action' => $this->generateUrl('user_auth_login'),
]); ]);
$form->get('_username')->setData($username); $form->get('_username')->setData($username);
$form->add('submit', SubmitType::class); $form->add('submit', SubmitType::class);

View file

@ -29,7 +29,7 @@ class SecurityController extends AbstractController
private function createLoginForm(string $username): FormInterface private function createLoginForm(string $username): FormInterface
{ {
$form = $this->createForm(LoginType::class, null, [ $form = $this->createForm(LoginType::class, null, [
'action' => $this->generateUrl('user_login'), 'action' => $this->generateUrl('user_auth_login'),
]); ]);
$form->get('_username')->setData($username); $form->get('_username')->setData($username);
$form->add('submit', SubmitType::class); $form->add('submit', SubmitType::class);

View file

@ -2,38 +2,39 @@
namespace App\Controller; namespace App\Controller;
use App\Form\{CreateUserRequestType}; use App\Entity\{Invite, PasswordResetToken};
use App\FormRequest\CreateUserRequest; use App\Repository\PasswordResetTokenRepository;
use App\Form\{Data\PasswordResetRequestData, Data\PasswordResetData, PasswordResetRequestType, PasswordResetType, RegisterType, Data\RegisterData};
use App\Repository\InviteRepository; use App\Repository\InviteRepository;
use App\User\{InviteManager, UserManager}; use App\User\{Exception\UserNotFoundException, InviteManager, PasswordResetManager, UserManager};
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\{Extension\Core\Type\SubmitType, FormError, FormInterface};
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\{Request, Response}; use Symfony\Component\HttpFoundation\{Request, Response};
class UserController extends AbstractController class UserController extends AbstractController
{ {
public function register( public function register(
string $inviteCode, string $code,
Request $request, Request $request,
EntityManagerInterface $em, EntityManagerInterface $em,
UserManager $userManager, UserManager $userManager,
InviteManager $inviteManager, InviteManager $inviteManager,
InviteRepository $inviteRepo InviteRepository $inviteRepo
): Response { ): Response {
$createUserRequest = new CreateUserRequest($inviteCode); $formData = new RegisterData($code);
$form = $this->createRegisterForm($createUserRequest, $inviteCode); $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); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user = $userManager->createUserByInvite( $user = $userManager->createUserByInvite(
$createUserRequest->username, $formData->username,
$createUserRequest->password, $formData->password,
$createUserRequest->email, $formData->email,
$invite $invite
); );
@ -46,14 +47,96 @@ class UserController extends AbstractController
return $this->render('User/register.html.twig', [ return $this->render('User/register.html.twig', [
'form' => $form->createView(), '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, [ $formData = new PasswordResetRequestData();
'action' => $this->generateUrl('user_register', ['inviteCode' => $inviteCode]), $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); $form->add('submit', SubmitType::class);

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

View file

@ -4,6 +4,7 @@ namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@ -63,10 +64,10 @@ class User implements UserInterface, \Serializable
*/ */
private $invites; 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->username = $username;
$this->password = $password; $this->password = $encoder->encodePassword($rawPassword, null);
$this->email = $email; $this->email = $email;
$this->roles = $roles ?: ['ROLE_USER']; $this->roles = $roles ?: ['ROLE_USER'];
$this->createdAt = new \DateTime(); $this->createdAt = new \DateTime();
@ -87,9 +88,9 @@ class User implements UserInterface, \Serializable
return $this->password; 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() public function getSalt()
@ -143,10 +144,10 @@ class User implements UserInterface, \Serializable
/** @see \Serializable::unserialize() */ /** @see \Serializable::unserialize() */
public function unserialize($serialized) public function unserialize($serialized)
{ {
list( [
$this->id, $this->id,
$this->username, $this->username,
$this->password $this->password
) = unserialize($serialized, ['allowed_classes' => false]); ] = unserialize($serialized, ['allowed_classes' => false]);
} }
} }

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

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

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\FormRequest; namespace App\Form\Data;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use App\Validator\Constraints as AppAssert; 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 * @todo implement UniqueEntity constraint for DTO and use it here
*/ */
class CreateUserRequest class RegisterData
{ {
/** /**
* @var string * @var string

View file

@ -21,6 +21,4 @@ class LoginType extends AbstractType
// Empty prefix for default UsernamePasswordFrormAuthenticationListener // Empty prefix for default UsernamePasswordFrormAuthenticationListener
return ''; return '';
} }
} }

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

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

View file

@ -2,13 +2,13 @@
namespace App\Form; namespace App\Form;
use App\FormRequest\CreateUserRequest; use App\Form\Data\RegisterData;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, TextType}; use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, TextType};
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class CreateUserRequestType extends AbstractType class RegisterType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
@ -23,8 +23,7 @@ class CreateUserRequestType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => CreateUserRequest::class, 'data_class' => RegisterData::class,
]); ]);
} }
} }

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

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

View file

@ -0,0 +1,8 @@
<?php
namespace App\User\Exception;
class UserNotFoundException extends \InvalidArgumentException
{
protected $message = 'User not found';
}

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

View file

@ -29,11 +29,10 @@ class UserManager
public function createUser(string $username, string $password, string $email, array $roles = self::DEFAULT_ROLES): User 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( $user = new User(
$username, $username,
$encodedPassword, $this->encoderFactory->getEncoder(User::class),
$password,
$email, $email,
$roles $roles
); );
@ -43,6 +42,14 @@ class UserManager
return $user; 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 public function createUserByInvite(string $username, string $password, string $email, Invite $invite, array $roles = self::DEFAULT_ROLES): User
{ {
if (null !== $invite->getUsedBy()) { if (null !== $invite->getUsedBy()) {

View file

@ -1,4 +1,7 @@
{ {
"clue/stream-filter": {
"version": "v1.4.1"
},
"doctrine/annotations": { "doctrine/annotations": {
"version": "1.0", "version": "1.0",
"recipe": { "recipe": {
@ -62,6 +65,21 @@
"doctrine/reflection": { "doctrine/reflection": {
"version": "v1.0.0" "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": { "jdorn/sql-formatter": {
"version": "v1.2.17" "version": "v1.2.17"
}, },
@ -83,6 +101,33 @@
"pagerfanta/pagerfanta": { "pagerfanta/pagerfanta": {
"version": "v2.0.1" "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": { "phpdocumentor/reflection-common": {
"version": "1.0.1" "version": "1.0.1"
}, },
@ -98,9 +143,21 @@
"psr/container": { "psr/container": {
"version": "1.0.0" "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": { "psr/log": {
"version": "1.0.2" "version": "1.0.2"
}, },
"ralouphie/getallheaders": {
"version": "3.0.3"
},
"sensio/framework-extra-bundle": { "sensio/framework-extra-bundle": {
"version": "4.0", "version": "4.0",
"recipe": { "recipe": {
@ -110,6 +167,9 @@
"ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543" "ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543"
} }
}, },
"sentry/sdk": {
"version": "2.1.0"
},
"sentry/sentry": { "sentry/sentry": {
"version": "1.10.0" "version": "1.10.0"
}, },
@ -191,6 +251,12 @@
"ref": "1279df12895f20d8076324036431833181eb6645" "ref": "1279df12895f20d8076324036431833181eb6645"
} }
}, },
"symfony/http-client": {
"version": "v4.4.2"
},
"symfony/http-client-contracts": {
"version": "v2.0.1"
},
"symfony/http-foundation": { "symfony/http-foundation": {
"version": "v4.1.0" "version": "v4.1.0"
}, },
@ -203,6 +269,18 @@
"symfony/intl": { "symfony/intl": {
"version": "v4.1.0" "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": { "symfony/mime": {
"version": "v4.3.2" "version": "v4.3.2"
}, },
@ -239,6 +317,9 @@
"symfony/polyfill-php73": { "symfony/polyfill-php73": {
"version": "v1.11.0" "version": "v1.11.0"
}, },
"symfony/polyfill-uuid": {
"version": "v1.13.1"
},
"symfony/process": { "symfony/process": {
"version": "v4.1.0" "version": "v4.1.0"
}, },

View file

@ -19,7 +19,7 @@
{% if invite.usedBy %} {% if invite.usedBy %}
Used by <strong>{{ invite.usedBy.username }}</strong>. Used by <strong>{{ invite.usedBy.username }}</strong>.
{% else %} {% 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()"> <input class="form-control" type="url" value="{{ invite_url }}" readonly="readonly" onclick="this.select()">
{% endif %} {% endif %}
</td> </td>

View file

@ -4,4 +4,6 @@
<div id="form-login"> <div id="form-login">
{{ form(form) }} {{ form(form) }}
</div> </div>
<a href="{{ path('user_reset_request') }}" class="btn btn-warning" role="button">Reset</a>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block content %} {% block content %}
{% if inviteValid %} {% if invite is not null %}
<div id="form-register"> <div id="form-register">
{{ form(form) }} {{ form(form) }}
</div> </div>

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

View file

@ -37,7 +37,7 @@
</a> </a>
<div class="dropdown-menu"> <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_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> </div>
</li> </li>
</ul> </ul>

View file

@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="well"> <div class="well">
{% if not is_granted('ROLE_USER') %} {% 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 %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}