diff --git a/assets/app.js b/assets/app.js deleted file mode 100644 index bb0a6aa..0000000 --- a/assets/app.js +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Welcome to your app's main JavaScript file! - * - * We recommend including the built version of this JavaScript file - * (and its CSS file) in your base layout (base.html.twig). - */ - -// any CSS you import will output into a single css file (app.css in this case) -import './styles/app.css'; - -// start the Stimulus application -import './bootstrap'; diff --git a/composer.json b/composer.json index e675339..040ac1e 100644 --- a/composer.json +++ b/composer.json @@ -19,12 +19,14 @@ "symfony/flex": "^1.3.1", "symfony/framework-bundle": "5.3.*", "symfony/mailer": "5.3.*", + "symfony/monolog-bundle": "^3.7", "symfony/proxy-manager-bridge": "5.3.*", "symfony/security-bundle": "5.3.*", "symfony/twig-bundle": "^5.2", "symfony/validator": "5.3.*", "symfony/webpack-encore-bundle": "^1.11", "symfony/yaml": "5.3.*", + "symfonycasts/reset-password-bundle": "^1.8", "symfonycasts/verify-email-bundle": "^1.5", "twig/extra-bundle": "^2.12|^3.0", "twig/intl-extra": "^3.3", diff --git a/composer.lock b/composer.lock index 4c3bf06..7bbe825 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8933f3ecbe9471974eca76a88ea9b2a4", + "content-hash": "34381deb043c87393a1f92bbeab2aaff", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1949,6 +1949,102 @@ ], "time": "2021-02-25T21:54:58+00:00" }, + { + "name": "monolog/monolog", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1cb1cde8e8dd0f70cc0fe51354a59acad9302084", + "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7", + "graylog2/gelf-php": "^1.4.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "phpspec/prophecy": "^1.6.1", + "phpstan/phpstan": "^0.12.59", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3", + "ruflin/elastica": ">=0.90 <7.0.1", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-12-14T13:15:25+00:00" + }, { "name": "nikic/php-parser", "version": "v4.10.5", @@ -4183,6 +4279,170 @@ ], "time": "2021-05-26T17:43:10+00:00" }, + { + "name": "symfony/monolog-bridge", + "version": "v5.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "84841557874df015ef2843aa16ac63d09f97c7b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/84841557874df015ef2843aa16ac63d09f97c7b9", + "reference": "84841557874df015ef2843aa16ac63d09f97c7b9", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1|^2", + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/http-kernel": "^5.3", + "symfony/service-contracts": "^1.1|^2" + }, + "conflict": { + "symfony/console": "<4.4", + "symfony/http-foundation": "<5.3" + }, + "require-dev": { + "symfony/console": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/mailer": "^4.4|^5.0", + "symfony/messenger": "^4.4|^5.0", + "symfony/mime": "^4.4|^5.0", + "symfony/security-core": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "suggest": { + "symfony/console": "For the possibility to show log messages in console commands depending on verbosity settings.", + "symfony/http-kernel": "For using the debugging handlers together with the response life cycle of the HTTP kernel.", + "symfony/var-dumper": "For using the debugging handlers like the console handler or the log server handler." + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v5.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-05-26T17:43:10+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "4054b2e940a25195ae15f0a49ab0c51718922eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/4054b2e940a25195ae15f0a49ab0c51718922eb4", + "reference": "4054b2e940a25195ae15f0a49ab0c51718922eb4", + "shasum": "" + }, + "require": { + "monolog/monolog": "~1.22 || ~2.0", + "php": ">=7.1.3", + "symfony/config": "~4.4 || ^5.0", + "symfony/dependency-injection": "^4.4 || ^5.0", + "symfony/http-kernel": "~4.4 || ^5.0", + "symfony/monolog-bridge": "~4.4 || ^5.0" + }, + "require-dev": { + "symfony/console": "~4.4 || ^5.0", + "symfony/phpunit-bridge": "^5.1", + "symfony/yaml": "~4.4 || ^5.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-31T07:20:47+00:00" + }, { "name": "symfony/options-resolver", "version": "v5.3.0", @@ -6904,6 +7164,57 @@ ], "time": "2021-05-26T17:43:10+00:00" }, + { + "name": "symfonycasts/reset-password-bundle", + "version": "v1.8.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/reset-password-bundle.git", + "reference": "a41cceff06039e586619b1505af05f77b22b41b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/a41cceff06039e586619b1505af05f77b22b41b5", + "reference": "a41cceff06039e586619b1505af05f77b22b41b5", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/config": "^4.4 | ^5.0", + "symfony/dependency-injection": "^4.4 | ^5.0", + "symfony/deprecation-contracts": "^2.2", + "symfony/http-kernel": "^4.4 | ^5.0" + }, + "conflict": { + "doctrine/orm": "<2.7", + "symfony/framework-bundle": "<4.4", + "symfony/http-foundation": "<4.4" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.0.3", + "doctrine/orm": "^2.7", + "friendsofphp/php-cs-fixer": "^3.0", + "symfony/framework-bundle": "^4.4 | ^5.0", + "symfony/phpunit-bridge": "^5.0", + "vimeo/psalm": "^4.3" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\ResetPassword\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Symfony bundle that adds password reset functionality.", + "support": { + "issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues", + "source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.8.0" + }, + "time": "2021-05-05T18:21:50+00:00" + }, { "name": "symfonycasts/verify-email-bundle", "version": "v1.5.0", diff --git a/config/bundles.php b/config/bundles.php index 0e811a5..5edf360 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -13,4 +13,6 @@ return [ SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], + SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml new file mode 100644 index 0000000..b1998da --- /dev/null +++ b/config/packages/dev/monolog.yaml @@ -0,0 +1,19 @@ +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + # uncomment to get logging in your browser + # you may have to allow bigger header sizes in your Web server configuration + #firephp: + # type: firephp + # level: info + #chromephp: + # type: chromephp + # level: info + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index cad7f78..4de3f78 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -7,9 +7,10 @@ framework: # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. session: - handler_id: null - cookie_secure: auto - cookie_samesite: lax + enabled: true + handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler + cookie_secure: 'auto' + cookie_samesite: 'lax' #esi: true #fragments: true diff --git a/config/packages/prod/deprecations.yaml b/config/packages/prod/deprecations.yaml new file mode 100644 index 0000000..60026a1 --- /dev/null +++ b/config/packages/prod/deprecations.yaml @@ -0,0 +1,8 @@ +# As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists +#monolog: +# channels: [deprecation] +# handlers: +# deprecation: +# type: stream +# channels: [deprecation] +# path: php://stderr diff --git a/config/packages/prod/monolog.yaml b/config/packages/prod/monolog.yaml new file mode 100644 index 0000000..2c02ad8 --- /dev/null +++ b/config/packages/prod/monolog.yaml @@ -0,0 +1,17 @@ +monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + buffer_size: 50 # How many messages should be saved? Prevent memory leaks + nested: + type: stream + path: php://stderr + level: debug + formatter: monolog.formatter.json + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml new file mode 100644 index 0000000..796ff0c --- /dev/null +++ b/config/packages/reset_password.yaml @@ -0,0 +1,2 @@ +symfonycasts_reset_password: + request_password_repository: App\Repository\ResetPasswordRequestRepository diff --git a/config/packages/test/monolog.yaml b/config/packages/test/monolog.yaml new file mode 100644 index 0000000..fc40641 --- /dev/null +++ b/config/packages/test/monolog.yaml @@ -0,0 +1,12 @@ +monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index b3cdf30..90c5321 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,2 +1,3 @@ twig: default_path: '%kernel.project_dir%/templates' + form_themes: ['bootstrap_4_layout.html.twig'] diff --git a/config/services.yaml b/config/services.yaml index c7296dd..61a226d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,5 +27,9 @@ services: resource: '../src/Controller/' tags: ['controller.service_arguments'] + + Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: + arguments: + - '%env(DATABASE_URL)%' # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/migrations/Version20210609175005.php b/migrations/Version20210609175005.php new file mode 100644 index 0000000..c163145 --- /dev/null +++ b/migrations/Version20210609175005.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE reset_password_request'); + } +} diff --git a/package.json b/package.json index 4e51378..b00bc49 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,11 @@ "devDependencies": { "@symfony/stimulus-bridge": "^2.0.0", "@symfony/webpack-encore": "^1.0.0", + "bootstrap": "^5.0.1", "core-js": "^3.0.0", "file-loader": "^6.0.0", + "jquery": "^3.6.0", + "popper.js": "^1.16.1", "regenerator-runtime": "^0.13.2", "sass": "^1.34.0", "sass-loader": "^11.0.0", @@ -21,7 +24,8 @@ "dependencies": { "@fortawesome/fontawesome-free": "^5.15.3", "@popperjs/core": "^2.9.2", - "bootstrap": "^5.0.1", - "copy-webpack-plugin": "^9.0.0" - } + "copy-webpack-plugin": "^9.0.0", + "webpack": "^5.38.1" + }, + "peerDependencies": {} } diff --git a/src/Controller/ResetPasswordController.php b/src/Controller/ResetPasswordController.php new file mode 100644 index 0000000..cbdc141 --- /dev/null +++ b/src/Controller/ResetPasswordController.php @@ -0,0 +1,176 @@ +resetPasswordHelper = $resetPasswordHelper; + } + + /** + * Display & process form to request a password reset. + */ + #[Route('', name: 'app_forgot_password_request')] + public function request(Request $request, MailerInterface $mailer): Response + { + $form = $this->createForm(ResetPasswordRequestFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + return $this->processSendingPasswordResetEmail( + $form->get('email')->getData(), + $mailer + ); + } + + return $this->render('security/request.html.twig', [ + 'requestForm' => $form->createView(), + ]); + } + + /** + * Confirmation page after a user has requested a password reset. + */ + #[Route('/check-email', name: 'app_check_email')] + public function checkEmail(): Response + { + // Generate a fake token if the user does not exist or someone hit this page directly. + // This prevents exposing whether or not a user was found with the given email address or not + if (null === ($resetToken = $this->getTokenObjectFromSession())) { + $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); + } + + return $this->render('security/check_email.html.twig', [ + 'resetToken' => $resetToken, + ]); + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + #[Route('/reset/{token}', name: 'app_reset_password')] + public function reset(Request $request, UserPasswordEncoderInterface $passwordEncoder, string $token = null): Response + { + if ($token) { + // We store the token in session and remove it from the URL, to avoid the URL being + // loaded in a browser and potentially leaking the token to 3rd party JavaScript. + $this->storeTokenInSession($token); + + return $this->redirectToRoute('app_reset_password'); + } + + $token = $this->getTokenFromSession(); + if ($token === null) { + throw $this->createNotFoundException('No reset password token found in the URL or in the session.'); + } + + try { + $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('reset_password_error', sprintf( + 'There was a problem validating your reset request - %s', + $e->getReason() + )); + + return $this->redirectToRoute('app_forgot_password_request'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + $this->resetPasswordHelper->removeResetRequest($token); + + // Encode the plain password, and set it. + $encodedPassword = $passwordEncoder->encodePassword( + $user, + $form->get('plainPassword')->getData() + ); + + $user->setPassword($encodedPassword); + $this->getDoctrine()->getManager()->flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + return $this->redirectToRoute('blogs'); + } + + return $this->render('security/reset.html.twig', [ + 'resetForm' => $form->createView(), + ]); + } + + private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): RedirectResponse + { + $user = $this->getDoctrine()->getRepository(User::class)->findOneBy([ + 'email' => $emailFormData, + ]); + + // Do not reveal whether a user account was found or not. + if (!$user) { + return $this->redirectToRoute('app_check_email'); + } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (ResetPasswordExceptionInterface $e) { + // If you want to tell the user why a reset email was not sent, uncomment + // the lines below and change the redirect to 'app_forgot_password_request'. + // Caution: This may reveal if a user is registered or not. + // + // $this->addFlash('reset_password_error', sprintf( + // 'There was a problem handling your password reset request - %s', + // $e->getReason() + // )); + + return $this->redirectToRoute('app_check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('tracer@24unix.net', '24unix.net')) + ->to($user->getEmail()) + ->subject('Your password reset request') + ->htmlTemplate('security/email.html.twig') + ->context([ + 'resetToken' => $resetToken, + ]) + ; + + $mailer->send($email); + + // Store the token object in session for retrieval in check-email route. + $this->setTokenObjectInSession($resetToken); + + return $this->redirectToRoute('app_check_email'); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index ed5a595..2a792e9 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,35 +2,42 @@ namespace App\Controller; +use App\Entity\User; +use App\Form\RegistrationFormType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +/** + * Class SecurityController + * @package App\Controller + */ class SecurityController extends AbstractController { - /** - * @Route("/login", name="app_login") - */ - public function login(AuthenticationUtils $authenticationUtils): Response - { - // if ($this->getUser()) { - // return $this->redirectToRoute('target_path'); - // } - - // get the login error if there is one - $error = $authenticationUtils->getLastAuthenticationError(); - // last username entered by the user - $lastUsername = $authenticationUtils->getLastUsername(); - - return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); - } - - /** - * @Route("/logout", name="app_logout") - */ - public function logout() - { - throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); - } + /** + * @Route("/login", name="app_login") + */ + public function login( AuthenticationUtils $authenticationUtils): Response + { + // if ($this->getUser()) { + // return $this->redirectToRoute('target_path'); + // } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + /** + * @Route("/logout", name="app_logout") + */ + public function logout() + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..d293f10 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,18 @@ +render('user/index.html.twig', [ + 'controller_name' => 'UserController', + ]); + } +} diff --git a/src/Entity/Blog.php b/src/Entity/Blog.php index 63d8632..2bebcc9 100644 --- a/src/Entity/Blog.php +++ b/src/Entity/Blog.php @@ -15,266 +15,266 @@ use App\Repository\SectionRepository; */ class Blog { - /** - * @ORM\Id - * @ORM\GeneratedValue - * @ORM\Column(type="integer") - */ - private $id; - - /** - * @ORM\Column(type="string", length=255) - */ - private ?string $title; - - /** - * @ORM\Column(type="text", nullable=true) - */ - private ?string $teaser; - - /** - * @ORM\Column(type="string", length=255, nullable=true) - */ - private ?string $teaserImage; - - /** - * @ORM\Column(type="text") - */ - private ?string $content; - - /** - * @ORM\ManyToOne(targetEntity=User::class, inversedBy="blogs") - * @ORM\JoinColumn(nullable=false) - */ - private ?User $author; - - /** - * @ORM\ManyToMany(targetEntity=Section::class, inversedBy="blogs") - */ - private $section; - - /** - * @ORM\Column(type="datetime") - */ - private ?\DateTimeInterface $createdAt; - - /** - * @ORM\Column(type="datetime", nullable=true) - */ - private ?\DateTimeInterface $editedAt; - - /** - * @ORM\ManyToOne(targetEntity=User::class) - */ - private ?User $editedBy; - - /** - * @ORM\Column(type="string", length=255, nullable=true) - */ - private ?string $editReason; - - /** - * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="blog") - */ - private $comments; - - /** - * @ORM\Column(type="string", length=255) - */ - private $slug; - - #[Pure] - public function __construct() - { - $this->section = new ArrayCollection(); - $this->comments = new ArrayCollection(); - } + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255) + */ + private ?string $title; + + /** + * @ORM\Column(type="text", nullable=true) + */ + private ?string $teaser; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private ?string $teaserImage; + + /** + * @ORM\Column(type="text") + */ + private ?string $content; + + /** + * @ORM\ManyToOne(targetEntity=User::class, inversedBy="blogs") + * @ORM\JoinColumn(nullable=false) + */ + private ?User $author; + + /** + * @ORM\ManyToMany(targetEntity=Section::class, inversedBy="blogs") + */ + private $section; + + /** + * @ORM\Column(type="datetime") + */ + private ?\DateTimeInterface $createdAt; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private ?\DateTimeInterface $editedAt; + + /** + * @ORM\ManyToOne(targetEntity=User::class) + */ + private ?User $editedBy; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private ?string $editReason; + + /** + * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="blog") + */ + private $comments; + + /** + * @ORM\Column(type="string", length=255) + */ + private $slug; + + #[Pure] + public function __construct() + { + $this->section = new ArrayCollection(); + $this->comments = new ArrayCollection(); + } /** * @return null|string */ public function __toString() - { - return $this->title; - } + { + return $this->title; + } - public function getId(): ?int - { - return $this->id; - } - - public function getTitle(): ?string - { - return $this->title; - } - - public function setTitle(string $title): self - { - $this->title = $title; - - return $this; - } - - public function getTeaser(): ?string - { - return $this->teaser; - } - - public function setTeaser(?string $teaser): self - { - $this->teaser = $teaser; - - return $this; - } - - public function getTeaserImage(): ?string - { - return $this->teaserImage; - } - - public function setTeaserImage(?string $teaserImage): self - { - $this->teaserImage = $teaserImage; - - return $this; - } - - public function getContent(): ?string - { - return $this->content; - } - - public function setContent(string $content): self - { - $this->content = $content; - - return $this; - } - - public function getAuthor(): ?User - { - return $this->author; - } - - public function setAuthor(?User $author): self - { - $this->author = $author; - - return $this; - } - - /** - * @return Collection|Section[] - */ - public function getSection(): Collection - { - return $this->section; - } - - public function addSection(Section $section): self - { - if (!$this->section->contains($section)) { - $this->section[] = $section; - } - - return $this; - } - - public function removeSection(Section $section): self - { - $this->section->removeElement($section); - - return $this; - } - - public function getCreatedAt(): ?\DateTimeInterface - { - return $this->createdAt; - } - - public function setCreatedAt(\DateTimeInterface $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } - - public function getEditedAt(): ?\DateTimeInterface - { - return $this->editedAt; - } - - public function setEditedAt(?\DateTimeInterface $editedAt): self - { - $this->editedAt = $editedAt; - - return $this; - } - - public function getEditedBy(): ?User - { - return $this->editedBy; - } - - public function setEditedBy(?User $editedBy): self - { - $this->editedBy = $editedBy; - - return $this; - } - - public function getEditReason(): ?string - { - return $this->editReason; - } - - public function setEditReason(?string $editReason): self - { - $this->editReason = $editReason; - - return $this; - } - - /** - * @return Collection|Comment[] - */ - public function getComments(): Collection - { - return $this->comments; - } - - public function addComment(Comment $comment): self - { - if (!$this->comments->contains($comment)) { - $this->comments[] = $comment; - $comment->setBlog($this); - } - - return $this; - } - - public function removeComment(Comment $comment): self - { - if ($this->comments->removeElement($comment)) { - // set the owning side to null (unless already changed) - if ($comment->getBlog() === $this) { - $comment->setBlog(null); - } - } - - return $this; - } - - public function getSlug(): ?string - { - return $this->slug; - } - - public function setSlug(string $slug): self - { - $this->slug = $slug; - - return $this; - } + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getTeaser(): ?string + { + return $this->teaser; + } + + public function setTeaser(?string $teaser): self + { + $this->teaser = $teaser; + + return $this; + } + + public function getTeaserImage(): ?string + { + return $this->teaserImage; + } + + public function setTeaserImage(?string $teaserImage): self + { + $this->teaserImage = $teaserImage; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + + return $this; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): self + { + $this->author = $author; + + return $this; + } + + /** + * @return Collection|Section[] + */ + public function getSection(): Collection + { + return $this->section; + } + + public function addSection(Section $section): self + { + if (!$this->section->contains($section)) { + $this->section[] = $section; + } + + return $this; + } + + public function removeSection(Section $section): self + { + $this->section->removeElement($section); + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getEditedAt(): ?\DateTimeInterface + { + return $this->editedAt; + } + + public function setEditedAt(?\DateTimeInterface $editedAt): self + { + $this->editedAt = $editedAt; + + return $this; + } + + public function getEditedBy(): ?User + { + return $this->editedBy; + } + + public function setEditedBy(?User $editedBy): self + { + $this->editedBy = $editedBy; + + return $this; + } + + public function getEditReason(): ?string + { + return $this->editReason; + } + + public function setEditReason(?string $editReason): self + { + $this->editReason = $editReason; + + return $this; + } + + /** + * @return Collection|Comment[] + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(Comment $comment): self + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setBlog($this); + } + + return $this; + } + + public function removeComment(Comment $comment): self + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getBlog() === $this) { + $comment->setBlog(null); + } + } + + return $this; + } + + public function getSlug(): ?string + { + return $this->slug; + } + + public function setSlug(string $slug): self + { + $this->slug = $slug; + + return $this; + } } diff --git a/src/Entity/ResetPasswordRequest.php b/src/Entity/ResetPasswordRequest.php new file mode 100644 index 0000000..6f7faed --- /dev/null +++ b/src/Entity/ResetPasswordRequest.php @@ -0,0 +1,45 @@ +user = $user; + $this->initialize($expiresAt, $selector, $hashedToken); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): object + { + return $this->user; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index b6e95aa..767e18e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,13 +8,16 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use JetBrains\PhpStorm\Pure; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; /** * @ORM\Entity(repositoryClass=UserRepository::class) * @UniqueEntity(fields={"username"}, message="There is already an account with this username") + * @ORM\HasLifecycleCallbacks + * @method string getUserIdentifier() */ -class User implements UserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface { /** * @ORM\Id @@ -73,27 +76,27 @@ class User implements UserInterface * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="author") */ private $comments; - - /** - * @ORM\Column(type="boolean") - */ - private $isVerified = false; + + /** + * @ORM\Column(type="boolean") + */ + private $isVerified = false; #[Pure] public function __construct() - { - $this->blogs = new ArrayCollection(); - $this->comments = new ArrayCollection(); - } + { + $this->blogs = new ArrayCollection(); + $this->comments = new ArrayCollection(); + } public function __toString() - { - return $this->username; - } + { + return $this->username; + } public function getId(): ?int - { - return $this->id; - } + { + return $this->id; + } /** * A visual identifier that represents this user. @@ -101,50 +104,50 @@ class User implements UserInterface * @see UserInterface */ public function getUsername(): string - { - return (string)$this->username; - } + { + return (string)$this->username; + } public function setUsername(string $username): self - { - $this->username = $username; - - return $this; - } + { + $this->username = $username; + + return $this; + } /** * @see UserInterface */ public function getRoles(): array - { - $roles = $this->roles; - // guarantee every user at least has ROLE_USER - $roles[] = 'ROLE_USER'; - - return array_unique($roles); - } + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } public function setRoles(array $roles): self - { - $this->roles = $roles; - - return $this; - } + { + $this->roles = $roles; + + return $this; + } /** * @see UserInterface */ public function getPassword(): string - { - return $this->password; - } + { + return $this->password; + } public function setPassword(string $password): self - { - $this->password = $password; - - return $this; - } + { + $this->password = $password; + + return $this; + } /** * Returning a salt is only needed, if you are not using a modern @@ -153,148 +156,164 @@ class User implements UserInterface * @see UserInterface */ public function getSalt(): ?string - { - return null; - } + { + return null; + } /** * @see UserInterface */ public function eraseCredentials() - { - // If you store any temporary, sensitive data on the user, clear it here - // $this->plainPassword = null; - } + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } public function getFirstName(): ?string - { - return $this->firstName; - } + { + return $this->firstName; + } public function setFirstName(?string $firstName): self - { - $this->firstName = $firstName; - - return $this; - } + { + $this->firstName = $firstName; + + return $this; + } public function getLastName(): ?string - { - return $this->lastName; - } + { + return $this->lastName; + } public function setLastName(?string $lastName): self - { - $this->lastName = $lastName; - - return $this; - } + { + $this->lastName = $lastName; + + return $this; + } public function getEmail(): ?string - { - return $this->email; - } + { + return $this->email; + } public function setEmail(string $email): self - { - $this->email = $email; - - return $this; - } + { + $this->email = $email; + + return $this; + } public function getCreatedAt(): ?\DateTimeInterface - { - return $this->createdAt; - } + { + return $this->createdAt; + } public function setCreatedAt(\DateTimeInterface $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } + { + $this->createdAt = $createdAt; + + return $this; + } public function getLastLoginAt(): ?\DateTimeInterface - { - return $this->lastLoginAt; - } + { + return $this->lastLoginAt; + } public function setLastLoginAt(?\DateTimeInterface $lastLoginAt): self - { - $this->lastLoginAt = $lastLoginAt; - - return $this; - } + { + $this->lastLoginAt = $lastLoginAt; + + return $this; + } /** * @return Collection|Blog[] */ public function getBlogs(): Collection - { - return $this->blogs; - } + { + return $this->blogs; + } public function addBlog(Blog $blog): self - { - if (!$this->blogs->contains($blog)) { - $this->blogs[] = $blog; - $blog->setAuthor($this); - } - - return $this; - } + { + if (!$this->blogs->contains($blog)) { + $this->blogs[] = $blog; + $blog->setAuthor($this); + } + + return $this; + } public function removeBlog(Blog $blog): self - { - if ($this->blogs->removeElement($blog)) { - // set the owning side to null (unless already changed) - if ($blog->getAuthor() === $this) { - $blog->setAuthor(null); - } - } - - return $this; - } + { + if ($this->blogs->removeElement($blog)) { + // set the owning side to null (unless already changed) + if ($blog->getAuthor() === $this) { + $blog->setAuthor(null); + } + } + + return $this; + } /** * @return Collection|Comment[] */ public function getComments(): Collection - { - return $this->comments; - } + { + return $this->comments; + } public function addComment(Comment $comment): self - { - if (!$this->comments->contains($comment)) { - $this->comments[] = $comment; - $comment->setAuthor($this); - } - - return $this; - } + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setAuthor($this); + } + + return $this; + } public function removeComment(Comment $comment): self - { - if ($this->comments->removeElement($comment)) { - // set the owning side to null (unless already changed) - if ($comment->getAuthor() === $this) { - $comment->setAuthor(null); - } - } - - return $this; - } - - public function isVerified(): bool - { - return $this->isVerified; - } - - public function setIsVerified(bool $isVerified): self - { - $this->isVerified = $isVerified; - - return $this; - } + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getAuthor() === $this) { + $comment->setAuthor(null); + } + } + + return $this; + } + + public function isVerified(): bool + { + return $this->isVerified; + } + + public function setIsVerified(bool $isVerified): self + { + $this->isVerified = $isVerified; + + return $this; + } + + public function __call(string $name, array $arguments) + { + // TODO: Implement @method string getUserIdentifier() + } + + /** + * Gets triggered only on insert + + * @ORM\PrePersist + */ + public function onPrePersist() + { + $this->createdAt = new \DateTime(); + } + } diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php new file mode 100644 index 0000000..419fe5e --- /dev/null +++ b/src/Form/ChangePasswordFormType.php @@ -0,0 +1,51 @@ +add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => [ + 'attr' => ['autocomplete' => 'new-password'], + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 6, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + ], + 'label' => 'New password', + ], + 'second_options' => [ + 'attr' => ['autocomplete' => 'new-password'], + 'label' => 'Repeat Password', + ], + 'invalid_message' => 'The password fields must match.', + // Instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php index e2633b8..ac201e0 100644 --- a/src/Form/RegistrationFormType.php +++ b/src/Form/RegistrationFormType.php @@ -5,6 +5,7 @@ namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -14,42 +15,44 @@ use Symfony\Component\Validator\Constraints\NotBlank; class RegistrationFormType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder - ->add('username') - ->add('agreeTerms', CheckboxType::class, [ - 'mapped' => false, - 'constraints' => [ - new IsTrue([ - 'message' => 'You should agree to our terms.', - ]), - ], - ]) - ->add('plainPassword', PasswordType::class, [ - // instead of being set onto the object directly, - // this is read and encoded in the controller - 'mapped' => false, - 'attr' => ['autocomplete' => 'new-password'], - 'constraints' => [ - new NotBlank([ - 'message' => 'Please enter a password', - ]), - new Length([ - 'min' => 6, - 'minMessage' => 'Your password should be at least {{ limit }} characters', - // max length allowed by Symfony for security reasons - 'max' => 4096, - ]), - ], - ]) - ; - } - - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'data_class' => User::class, - ]); - } + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('username') + ->add('agreeTerms', CheckboxType::class, [ + 'mapped' => false, + 'constraints' => [ + new IsTrue([ + 'message' => 'You should agree to our terms.', + ]), + ], + ]) + ->add('plainPassword', PasswordType::class, [ + // instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, + 'attr' => ['autocomplete' => 'new-password'], + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 6, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + ], + ]) + ->add('firstName') + ->add('lastName') + ->add('email', EmailType::class); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } } diff --git a/src/Form/ResetPasswordRequestFormType.php b/src/Form/ResetPasswordRequestFormType.php new file mode 100644 index 0000000..939ea5f --- /dev/null +++ b/src/Form/ResetPasswordRequestFormType.php @@ -0,0 +1,31 @@ +add('email', EmailType::class, [ + 'attr' => ['autocomplete' => 'email'], + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter your email', + ]), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Repository/ResetPasswordRequestRepository.php b/src/Repository/ResetPasswordRequestRepository.php new file mode 100644 index 0000000..5a428b7 --- /dev/null +++ b/src/Repository/ResetPasswordRequestRepository.php @@ -0,0 +1,31 @@ +passwordEncoder->isPasswordValid($user, $credentials['password']); diff --git a/symfony.lock b/symfony.lock index b0092c6..9422eef 100644 --- a/symfony.lock +++ b/symfony.lock @@ -109,6 +109,9 @@ "laminas/laminas-zendframework-bridge": { "version": "1.2.0" }, + "monolog/monolog": { + "version": "2.2.0" + }, "nikic/php-parser": { "version": "v4.10.5" }, @@ -246,6 +249,24 @@ "symfony/mime": { "version": "v5.2.9" }, + "symfony/monolog-bridge": { + "version": "v5.3.0" + }, + "symfony/monolog-bundle": { + "version": "3.7", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "3.7", + "ref": "329f6a5ef2e7aa033f802be833ef8d1268dd0848" + }, + "files": [ + "config/packages/dev/monolog.yaml", + "config/packages/prod/deprecations.yaml", + "config/packages/prod/monolog.yaml", + "config/packages/test/monolog.yaml" + ] + }, "symfony/options-resolver": { "version": "v5.2.4" }, @@ -438,6 +459,18 @@ "symfony/yaml": { "version": "v5.2.9" }, + "symfonycasts/reset-password-bundle": { + "version": "1.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "1.0", + "ref": "97c1627c0384534997ae1047b93be517ca16de43" + }, + "files": [ + "config/packages/reset_password.yaml" + ] + }, "symfonycasts/verify-email-bundle": { "version": "v1.5.0" }, diff --git a/templates/_header.html.twig b/templates/_header.html.twig index e6e35f6..84985ed 100644 --- a/templates/_header.html.twig +++ b/templates/_header.html.twig @@ -9,41 +9,46 @@