Merged in feature/upgrade (pull request #33)

A bunch of changes and upgrades
This commit is contained in:
Alexey Eschenko 2020-01-20 18:26:39 +00:00
commit e3436fe6db
42 changed files with 3564 additions and 1421 deletions

17
.env
View file

@ -24,3 +24,20 @@ 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 ###
###> google/recaptcha ###
# To use Google Recaptcha, you must register a site on Recaptcha's admin panel:
# https://www.google.com/recaptcha/admin
#GOOGLE_RECAPTCHA_SITE_KEY=
#GOOGLE_RECAPTCHA_SECRET=
###< google/recaptcha ###
###> excelwebzone/recaptcha-bundle ###
EWZ_RECAPTCHA_SITE_KEY=
EWZ_RECAPTCHA_SECRET=
###< excelwebzone/recaptcha-bundle ###

View file

@ -45,7 +45,7 @@ for production usage.
See [Symfony database configuration](https://symfony.com/doc/current/doctrine.html#configuring-the-database)
documentation for more details.
You **must** set environment variables for both databases: magneticod's SQLite and magnetico-web's PostgreSQL.
You **must** set environment variables for both databases: magneticod's and magnetico-web's PostgreSQL.
## Database schema migration

View file

@ -12,17 +12,20 @@
],
"type": "project",
"require": {
"php": "^7.2.0",
"php": "^7.4.0",
"ext-ctype": "*",
"ext-hash": "*",
"ext-iconv": "*",
"excelwebzone/recaptcha-bundle": "^1.5",
"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",

4225
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
@ -13,4 +12,5 @@ return [
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['all' => true],
EWZ\Bundle\RecaptchaBundle\EWZRecaptchaBundle::class => ['all' => true],
];

View file

@ -0,0 +1,2 @@
ewz_recaptcha:
enabled: false

View file

@ -0,0 +1,4 @@
# See https://github.com/excelwebzone/EWZRecaptchaBundle for full configuration
ewz_recaptcha:
public_key: '%env(EWZ_RECAPTCHA_SITE_KEY)%'
private_key: '%env(EWZ_RECAPTCHA_SECRET)%'

View file

@ -0,0 +1,21 @@
#services:
#
# # Inject this service in your controllers/services to verify a submitted captcha.
# ReCaptcha\ReCaptcha:
# arguments:
# $secret: '%env(GOOGLE_RECAPTCHA_SECRET)%'
# $requestMethod: '@ReCaptcha\RequestMethod'
#
# # Curl is set here as default transport to communicate with Google servers.
# # If you do not have php-curl extension, you can change for a socket or a plain POST request.
# # Check out the repository for all other request methods:
# # https://github.com/google/recaptcha/tree/master/src/ReCaptcha/RequestMethod
# ReCaptcha\RequestMethod: '@ReCaptcha\RequestMethod\CurlPost'
# ReCaptcha\RequestMethod\CurlPost: null
# ReCaptcha\RequestMethod\Curl: null
#
## Uncomment this line if you want to inject the site key to all your Twig templates.
## You can also inject the "google_recaptcha_site_key" container parameter to your controllers.
##twig:
## globals:
## google_recaptcha_site_key: '%google_recaptcha_site_key%'

View file

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

View file

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

View file

@ -8,10 +8,8 @@ security:
manager_name: default
encoders:
App\Entity\User:
algorithm: 'argon2i'
memory_cost: 16384
time_cost: 2
threads: 4
# https://symfony.com/blog/new-in-symfony-4-3-native-password-encoder
algorithm: 'auto'
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@ -28,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%'
@ -46,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 }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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,24 @@
<?php
namespace App\Form\Data;
use EWZ\Bundle\RecaptchaBundle\Validator\Constraints as ReCaptcha;
use Symfony\Component\Validator\Constraints as Assert;
class PasswordResetRequestData
{
/**
* @var string
*
* @Assert\Email()
* @Assert\NotBlank()
*/
public $email;
/**
* @var string
*
* @ReCaptcha\IsTrue
*/
public $recaptcha;
}

View file

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

View file

@ -11,8 +11,8 @@ class LoginType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('_username', TextType::class, ['mapped' => false])
->add('_password', PasswordType::class, ['mapped' => false])
->add('_username', TextType::class, ['mapped' => false, 'required' => true])
->add('_password', PasswordType::class, ['mapped' => false, 'required' => true])
;
}
@ -21,6 +21,4 @@ class LoginType extends AbstractType
// Empty prefix for default UsernamePasswordFrormAuthenticationListener
return '';
}
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Form;
use App\Form\Data\PasswordResetRequestData;
use Symfony\Component\Form\{AbstractType, Extension\Core\Type\EmailType, FormBuilderInterface};
use EWZ\Bundle\RecaptchaBundle\Form\Type\EWZRecaptchaType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PasswordResetRequestType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class, ['required' => true])
->add('recaptcha', EWZRecaptchaType::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,29 +2,28 @@
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)
{
$builder
->add('username', TextType::class)
->add('password', PasswordType::class)
->add('email', EmailType::class)
->add('inviteCode', TextType::class)
->add('username', TextType::class, ['required' => true])
->add('password', PasswordType::class, ['required' => true])
->add('email', EmailType::class, ['required' => true])
->add('inviteCode', TextType::class, ['required' => true])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => CreateUserRequest::class,
'data_class' => RegisterData::class,
]);
}
}
}

View file

@ -4,11 +4,11 @@ namespace App\Magnetico\Repository;
use App\Magnetico\Entity\Torrent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Doctrine\Persistence\ManagerRegistry;
class TorrentRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Torrent::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

@ -5,11 +5,11 @@ namespace App\Repository;
use App\Entity\{ApiToken, User};
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Doctrine\Persistence\ManagerRegistry;
class ApiTokenRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiToken::class);
}

View file

@ -2,13 +2,13 @@
namespace App\Repository;
use Doctrine\Persistence\ManagerRegistry;
use App\Entity\{Invite, User};
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;
class InviteRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Invite::class);
}

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

@ -4,11 +4,11 @@ namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Doctrine\Persistence\ManagerRegistry;
class UserRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}

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
{
$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;
}
}
}

View file

@ -1,4 +1,7 @@
{
"clue/stream-filter": {
"version": "v1.4.1"
},
"doctrine/annotations": {
"version": "1.0",
"recipe": {
@ -29,9 +32,6 @@
"ref": "ae205d5114e719deb64d2110f56ef910787d1e04"
}
},
"doctrine/doctrine-cache-bundle": {
"version": "1.3.3"
},
"doctrine/doctrine-migrations-bundle": {
"version": "1.2",
"recipe": {
@ -65,12 +65,55 @@
"doctrine/reflection": {
"version": "v1.0.0"
},
"egulias/email-validator": {
"version": "2.1.14"
},
"excelwebzone/recaptcha-bundle": {
"version": "1.5",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "1.5",
"ref": "fd4da7bc71749db65bc83abf5d164bfa9c839cf4"
},
"files": [
"config/packages/dev/ewz_recaptcha.yaml",
"config/packages/ewz_recaptcha.yaml"
]
},
"google/recaptcha": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "1.1",
"ref": "d087df3e087f50da3955f2def05079380da5894b"
},
"files": [
"config/packages/google_recaptcha.yaml"
]
},
"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"
},
"jean85/pretty-package-versions": {
"version": "1.2"
},
"laminas/laminas-zendframework-bridge": {
"version": "1.0.1"
},
"monolog/monolog": {
"version": "1.23.0"
},
@ -83,6 +126,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 +168,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 +192,9 @@
"ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543"
}
},
"sentry/sdk": {
"version": "2.1.0"
},
"sentry/sentry": {
"version": "1.10.0"
},
@ -152,6 +237,9 @@
"symfony/dotenv": {
"version": "v4.1.0"
},
"symfony/error-handler": {
"version": "v4.4.2"
},
"symfony/event-dispatcher": {
"version": "v4.1.0"
},
@ -188,6 +276,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"
},
@ -200,6 +294,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"
},
@ -236,6 +342,9 @@
"symfony/polyfill-php73": {
"version": "v1.11.0"
},
"symfony/polyfill-uuid": {
"version": "v1.13.1"
},
"symfony/process": {
"version": "v4.1.0"
},
@ -344,6 +453,9 @@
"twig/twig": {
"version": "v2.4.8"
},
"webimpress/safe-writer": {
"version": "2.0.0"
},
"webmozart/assert": {
"version": "1.3.0"
},

View file

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

View file

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

View file

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

View file

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