diff --git a/src/FileManager.php b/src/FileManager.php index f2d5299f7..c10b4e5a5 100644 --- a/src/FileManager.php +++ b/src/FileManager.php @@ -13,6 +13,7 @@ use Symfony\Bundle\MakerBundle\Util\AutoloaderUtil; use Symfony\Bundle\MakerBundle\Util\MakerFileLinkFormatter; +use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; @@ -86,6 +87,16 @@ public function dumpFile(string $filename, string $content): void } } + /** + * @param callable(array):array $manipulator + */ + public function manipulateYaml(string $filename, callable $manipulator): void + { + $yaml = new YamlSourceManipulator($this->getFileContents($filename)); + $yaml->setData($manipulator($yaml->getData())); + $this->dumpFile($filename, $yaml->getContents()); + } + public function fileExists($path): bool { return file_exists($this->absolutizePath($path)); diff --git a/src/JsPackageManager.php b/src/JsPackageManager.php new file mode 100644 index 000000000..62cac933c --- /dev/null +++ b/src/JsPackageManager.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle; + +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class JsPackageManager +{ + private $executableFinder; + private $files; + + public function __construct(FileManager $fileManager) + { + $this->executableFinder = new ExecutableFinder(); + $this->files = $fileManager; + } + + public function isInstalled(string $package): bool + { + $packageJson = $this->packageJson(); + $deps = array_merge($packageJson['dependencies'] ?? [], $packageJson['devDependencies'] ?? []); + + return \array_key_exists($package, $deps); + } + + public function add(string $package, string $version): void + { + $packageWithVersion = "{$package}@{$version}"; + + if ($yarn = $this->executableFinder->find('yarn')) { + $command = [$yarn, 'add', $packageWithVersion, '--dev']; + } elseif ($npm = $this->executableFinder->find('npm')) { + $command = [$npm, 'install', $packageWithVersion, '--save-dev']; + } else { + $this->addToPackageJson($package, $version); + + return; + } + + (new Process($command, $this->files->getRootDirectory()))->run(); + } + + public function install(): void + { + (new Process([$this->bin(), 'install'], $this->files->getRootDirectory()))->run(); + } + + public function run(string $script): void + { + (new Process([$this->bin(), 'run', $script], $this->files->getRootDirectory()))->run(); + } + + public function isAvailable(): bool + { + try { + $this->bin(); + + return true; + } catch (\RuntimeException $e) { + return false; + } + } + + private function bin(): string + { + if (!$bin = $this->executableFinder->find('yarn') ?? $this->executableFinder->find('npm')) { + throw new \RuntimeException('Unable to find js package manager.'); + } + + return $bin; + } + + private function addToPackageJson(string $package, string $version): void + { + $packageJson = $this->packageJson(); + $devDeps = $packageJson['devDependencies'] ?? []; + $devDeps[$package] = $version; + + ksort($devDeps); + + $packageJson['devDependencies'] = $devDeps; + + $this->files->dumpFile('package.json', json_encode($packageJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); + } + + private function packageJson(): array + { + return json_decode($this->files->getFileContents('package.json'), true); + } +} diff --git a/src/Maker/MakeRegistrationForm.php b/src/Maker/MakeRegistrationForm.php index 55bba0ec8..31efc04ac 100644 --- a/src/Maker/MakeRegistrationForm.php +++ b/src/Maker/MakeRegistrationForm.php @@ -42,6 +42,7 @@ use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -112,6 +113,10 @@ public function configureCommand(Command $command, InputConfiguration $inputConf public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { + if (Kernel::VERSION_ID >= 60000) { + $io->warning('make:registration-form is deprecated in favor of "make:scaffold register".'); + } + $interactiveSecurityHelper = new InteractiveSecurityHelper(); if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) { diff --git a/src/Maker/MakeResetPassword.php b/src/Maker/MakeResetPassword.php index b84a75977..c41c1a0de 100644 --- a/src/Maker/MakeResetPassword.php +++ b/src/Maker/MakeResetPassword.php @@ -39,6 +39,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -127,6 +128,10 @@ public function configureDependencies(DependencyBuilder $dependencies): void public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { + if (Kernel::VERSION_ID >= 60000) { + $io->warning('make:reset-password is deprecated in favor or "make:scaffold reset-password".'); + } + $io->title('Let\'s make a password reset feature!'); $interactiveSecurityHelper = new InteractiveSecurityHelper(); diff --git a/src/Maker/MakeScaffold.php b/src/Maker/MakeScaffold.php new file mode 100644 index 000000000..8bfa3c296 --- /dev/null +++ b/src/Maker/MakeScaffold.php @@ -0,0 +1,288 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Composer\InstalledVersions; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\FileManager; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\JsPackageManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class MakeScaffold extends AbstractMaker +{ + private $fileManager; + private $jsPackageManager; + private $availableScaffolds; + private $composerBin; + private $installedScaffolds = []; + private $installedPackages = []; + private $installedJsPackages = []; + + public function __construct(FileManager $files) + { + $this->fileManager = $files; + $this->jsPackageManager = new JsPackageManager($files); + } + + public static function getCommandName(): string + { + return 'make:scaffold'; + } + + public static function getCommandDescription(): string + { + return 'Create scaffold'; // todo + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('name', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Scaffold name(s) to create') + ; + + $inputConfig->setArgumentAsNonInteractive('name'); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(Process::class, 'process'); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $names = $input->getArgument('name'); + + if (!$names) { + throw new \InvalidArgumentException('You must select at least one scaffold.'); + } + + foreach ($names as $name) { + $this->generateScaffold($name, $io); + } + + if ($this->installedJsPackages) { + if ($this->jsPackageManager->isAvailable()) { + $io->comment('Installing JS packages...'); + $this->jsPackageManager->install(); + + $io->comment('Running Webpack Encore...'); + $this->jsPackageManager->run('dev'); + } else { + $io->warning('Unable to detect JS package manager, you need to run "yarn/npm install".'); + } + } + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if ($input->getArgument('name')) { + return; + } + + $scaffolds = $this->availableScaffolds(); + + while (true) { + $name = $io->choice('Available scaffolds', array_combine( + array_keys($scaffolds), + array_map( + function (array $scaffold) { + return $scaffold['description']; + }, + $scaffolds + ) + )); + $scaffold = $scaffolds[$name]; + + $io->title($name); + $io->text($scaffold['description']); + $io->newLine(); + + if ($scaffold['dependents'] ?? []) { + $io->text('This scaffold will also install the following scaffolds:'); + $io->newLine(); + $io->listing(array_map( + function ($s) use ($scaffolds) { + return sprintf('%s - %s', $s, $scaffolds[$s]['description']); + }, + $scaffold['dependents']) + ); + } + + if ($scaffold['packages'] ?? []) { + $io->text('This scaffold will install the following composer packages:'); + $io->newLine(); + $io->listing(array_keys($scaffold['packages'])); + } + + if ($scaffold['js_packages'] ?? []) { + $io->text('This scaffold will install the following node packages:'); + $io->newLine(); + $io->listing(array_keys($scaffold['js_packages'])); + } + + if (!$io->confirm("Would your like to create the \"{$name}\" scaffold?")) { + $io->text('Going back to main menu...'); + + continue; + } + + $input->setArgument('name', [$name]); + + return; + } + } + + private function generateScaffold(string $name, ConsoleStyle $io): void + { + if ($this->isScaffoldInstalled($name)) { + return; + } + + if (!isset($this->availableScaffolds()[$name])) { + throw new \InvalidArgumentException("Scaffold \"{$name}\" does not exist for your version of Symfony."); + } + + $scaffold = $this->availableScaffolds()[$name]; + + // install dependent scaffolds + foreach ($scaffold['dependents'] ?? [] as $dependent) { + $this->generateScaffold($dependent, $io); + } + + $io->text("Installing {$name} Scaffold..."); + + // install required packages + foreach ($scaffold['packages'] ?? [] as $package => $env) { + if (!$this->isPackageInstalled($package)) { + $io->text("Installing Composer package: {$package}..."); + + $command = [$this->composerBin(), 'require', '--no-scripts', 'dev' === $env ? '--dev' : null, $package]; + $process = new Process(array_filter($command), $this->fileManager->getRootDirectory()); + + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException("Error installing \"{$package}\"."); + } + + $this->installedPackages[] = $package; + } + } + + // install required js packages + foreach ($scaffold['js_packages'] ?? [] as $package => $version) { + if (!$this->isJsPackageInstalled($package)) { + $io->text("Installing JS package: {$package}@{$version}..."); + + $this->jsPackageManager->add($package, $version); + $this->installedJsPackages[] = $package; + } + } + + if (is_dir($scaffold['dir'])) { + $io->text('Copying scaffold files...'); + + foreach (Finder::create()->files()->in($scaffold['dir']) as $file) { + $filename = "{$file->getRelativePath()}/{$file->getFilenameWithoutExtension()}"; + + if (!$this->fileManager->fileExists($filename) || $io->confirm("Overwrite \"{$filename}\"?")) { + $this->fileManager->dumpFile($filename, $file->getContents()); + } + } + } + + if (isset($scaffold['configure'])) { + $io->text('Executing configuration...'); + + $scaffold['configure']($this->fileManager); + } + + $io->text("Successfully installed scaffold {$name}."); + $io->newLine(); + + $this->installedScaffolds[] = $name; + } + + private function availableScaffolds(): array + { + if (\is_array($this->availableScaffolds)) { + return $this->availableScaffolds; + } + + $this->availableScaffolds = []; + $finder = Finder::create() + // todo, improve versioning system + ->in(sprintf('%s/../Resources/scaffolds/%s.0', __DIR__, Kernel::MAJOR_VERSION)) + ->name('*.php') + ->depth(0) + ; + + foreach ($finder as $file) { + $name = $file->getFilenameWithoutExtension(); + + $this->availableScaffolds[$name] = array_merge( + require $file, + ['dir' => \dirname($file->getRealPath()).'/'.$name] + ); + } + + return $this->availableScaffolds; + } + + /** + * Detect if package already installed or installed in this process + * (when installing multiple scaffolds at once). + */ + private function isPackageInstalled(string $package): bool + { + return InstalledVersions::isInstalled($package) || \in_array($package, $this->installedPackages, true); + } + + private function isJsPackageInstalled(string $package): bool + { + return $this->jsPackageManager->isInstalled($package) || \in_array($package, $this->installedJsPackages, true); + } + + /** + * Detect if package is installed in the same process (when installing + * multiple scaffolds at once). + */ + private function isScaffoldInstalled(string $name): bool + { + return \in_array($name, $this->installedScaffolds, true); + } + + private function composerBin(): string + { + if ($this->composerBin) { + return $this->composerBin; + } + + if (!$this->composerBin = (new ExecutableFinder())->find('composer')) { + throw new \RuntimeException('Unable to detect composer binary.'); + } + + return $this->composerBin; + } +} diff --git a/src/Maker/MakeUser.php b/src/Maker/MakeUser.php index 06ad85f57..4267ae659 100644 --- a/src/Maker/MakeUser.php +++ b/src/Maker/MakeUser.php @@ -32,6 +32,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; @@ -88,6 +89,10 @@ public function configureCommand(Command $command, InputConfiguration $inputConf public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { + if (Kernel::VERSION_ID >= 60000) { + $io->warning('make:user is deprecated in favor of "make:scaffold user".'); + } + if (null === $input->getArgument('name')) { $name = $io->ask( $command->getDefinition()->getArgument('name')->getDescription(), diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 624a230e3..f31124892 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -25,6 +25,11 @@ + + + + + diff --git a/src/Resources/scaffolds/6.0/auth.php b/src/Resources/scaffolds/6.0/auth.php new file mode 100644 index 000000000..5240d30e1 --- /dev/null +++ b/src/Resources/scaffolds/6.0/auth.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\MakerBundle\FileManager; + +return [ + 'description' => 'Create login form and tests.', + 'dependents' => [ + 'user', + 'homepage', + ], + 'packages' => [ + 'symfony/web-profiler-bundle' => 'dev', + 'symfony/stopwatch' => 'dev', + ], + 'configure' => function (FileManager $files) { + // add LoginUser service + $files->manipulateYaml('config/services.yaml', function (array $data) { + $data['services']['App\Security\LoginUser']['$authenticator'] = '@security.authenticator.form_login.main'; + + return $data; + }); + + // make security.yaml adjustments + $files->manipulateYaml('config/packages/security.yaml', function (array $data) { + $data['security']['providers'] = [ + 'app_user_provider' => [ + 'entity' => [ + 'class' => 'App\Entity\User', + 'property' => 'email', + ], + ], + ]; + $data['security']['firewalls']['main'] = [ + 'lazy' => true, + 'provider' => 'app_user_provider', + 'form_login' => [ + 'login_path' => 'login', + 'check_path' => 'login', + 'username_parameter' => 'email', + 'password_parameter' => 'password', + 'enable_csrf' => true, + ], + 'logout' => [ + 'path' => 'logout', + 'target' => 'homepage', + ], + 'remember_me' => [ + 'secret' => '%kernel.secret%', + ], + ]; + + return $data; + }); + }, +]; diff --git a/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php.tpl b/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php.tpl new file mode 100644 index 000000000..2e0b2e385 --- /dev/null +++ b/src/Resources/scaffolds/6.0/auth/src/Controller/SecurityController.php.tpl @@ -0,0 +1,35 @@ +isGranted('IS_AUTHENTICATED_FULLY')) { + return new RedirectResponse($request->query->get('target', $this->generateUrl('homepage'))); + } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + + // current user's username (if already logged in) or last username entered by the user + $lastUsername = $this->getUser()?->getUserIdentifier() ?? $authenticationUtils->getLastUsername(); + + return $this->render('login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + #[Route(path: '/logout', name: 'logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php.tpl b/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php.tpl new file mode 100644 index 000000000..7eb4a3a84 --- /dev/null +++ b/src/Resources/scaffolds/6.0/auth/src/Security/LoginUser.php.tpl @@ -0,0 +1,25 @@ +userAuthenticator->authenticateUser($user, $this->authenticator, $request); + } +} diff --git a/src/Resources/scaffolds/6.0/auth/templates/login.html.twig.tpl b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig.tpl new file mode 100644 index 000000000..a37381471 --- /dev/null +++ b/src/Resources/scaffolds/6.0/auth/templates/login.html.twig.tpl @@ -0,0 +1,38 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+
+

Please sign in

+ + {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + +
+ + +
+
+ + +
+
+ +
+ + + {% if app.request.query.has('target') %} + + {% endif %} + + +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php.tpl b/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php.tpl new file mode 100644 index 000000000..243fa69c3 --- /dev/null +++ b/src/Resources/scaffolds/6.0/auth/tests/Functional/AuthenticationTest.php.tpl @@ -0,0 +1,234 @@ + 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->assertNotAuthenticated() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertSuccessful() + ->assertAuthenticated('mary@example.com') + ->visit('/logout') + ->assertOn('/') + ->assertNotAuthenticated() + ; + } + + public function testLoginWithTarget(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->assertNotAuthenticated() + ->visit('/login?target=/some/page') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/some/page') + ->visit('/') + ->assertAuthenticated('mary@example.com') + ; + } + + public function testLoginWithInvalidPassword(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', 'invalid') + ->click('Sign in') + ->assertOn('/login') + ->assertSuccessful() + ->assertFieldEquals('Email', 'mary@example.com') + ->assertSee('Invalid credentials.') + ->assertNotAuthenticated() + ; + } + + public function testLoginWithInvalidEmail(): void + { + $this->browser() + ->visit('/login') + ->fillField('Email', 'invalid@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/login') + ->assertSuccessful() + ->assertFieldEquals('Email', 'invalid@example.com') + ->assertSee('Invalid credentials.') + ->assertNotAuthenticated() + ; + } + + public function testLoginWithInvalidCsrf(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->assertNotAuthenticated() + ->post('/login', ['body' => ['email' => 'mary@example.com', 'password' => '1234']]) + ->assertOn('/login') + ->assertSuccessful() + ->assertSee('Invalid CSRF token.') + ->assertNotAuthenticated() + ; + } + + public function testRememberMeEnabledByDefault(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertSuccessful() + ->assertAuthenticated('mary@example.com') + ->use(function (CookieJar $cookieJar) { + $cookieJar->expire('MOCKSESSID'); + }) + ->withProfiling() + ->visit('/') + ->assertAuthenticated('mary@example.com') + ; + } + + public function testCanDisableRememberMe(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->uncheckField('Remember me') + ->click('Sign in') + ->assertOn('/') + ->assertSuccessful() + ->assertAuthenticated('mary@example.com') + ->use(function (CookieJar $cookieJar) { + $cookieJar->expire('MOCKSESSID'); + }) + ->visit('/') + ->assertNotAuthenticated() + ; + } + + public function testFullyAuthenticatedLoginRedirect(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated() + ->visit('/login') + ->assertOn('/') + ->assertAuthenticated() + ; + } + + public function testFullyAuthenticatedLoginTarget(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated() + ->visit('/login?target=/some/page') + ->assertOn('/some/page') + ->visit('/') + ->assertAuthenticated() + ; + } + + public function testCanFullyAuthenticateIfOnlyRemembered(): void + { + UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + $this->browser() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated('mary@example.com') + ->use(function (CookieJar $cookieJar) { + $cookieJar->expire('MOCKSESSID'); + }) + ->visit('/login') + ->assertOn('/login') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated('mary@example.com') + ; + } + + public function testLegacyPasswordHashIsAutomaticallyMigratedOnLogin(): void + { + $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + + // set the password to a legacy hash (argon2id, 1234) + $user->setPassword('$argon2id$v=19$m=10,t=3,p=1$K9AFR15goJiUD6AdpK0a6Q$RsP6y+FRnYUBovBmhVZO7wN6Caj2eI8dMTnm3+5aTxk'); + $user->save(); + + $this->assertSame(\PASSWORD_ARGON2ID, password_get_info($user->getPassword())['algo']); + + $this->browser() + ->assertNotAuthenticated() + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', '1234') + ->click('Sign in') + ->assertOn('/') + ->assertSuccessful() + ->assertAuthenticated('mary@example.com') + ; + + $this->assertSame(\PASSWORD_DEFAULT, password_get_info($user->getPassword())['algo']); + } + + public function testAutoRedirectedToAuthenticatedResourceAfterLogin(): void + { + // complete this test when you have a page that requires authentication + $this->markTestIncomplete(); + } + + public function testAutoRedirectedToFullyAuthenticatedResourceAfterFullyAuthenticated(): void + { + // complete this test when/if you have a page that requires the user be "fully authenticated" + $this->markTestIncomplete(); + } +} diff --git a/src/Resources/scaffolds/6.0/bootstrapcss.php b/src/Resources/scaffolds/6.0/bootstrapcss.php new file mode 100644 index 000000000..42601232e --- /dev/null +++ b/src/Resources/scaffolds/6.0/bootstrapcss.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\MakerBundle\FileManager; + +return [ + 'description' => 'Add bootstrap css/js.', + 'packages' => [ + 'symfony/twig-bundle' => 'all', + 'symfony/webpack-encore-bundle' => 'all', + ], + 'js_packages' => [ + 'bootstrap' => '^5.0.0', + '@popperjs/core' => '^2.0.0', + ], + 'configure' => function (FileManager $files) { + // add bootstrap form theme + $files->manipulateYaml('config/packages/twig.yaml', function (array $data) { + $data['twig']['form_themes'] = ['bootstrap_5_layout.html.twig']; + + return $data; + }); + + // add bootstrap to app.js + $appJs = $files->getFileContents('assets/app.js'); + + if (str_contains($appJs, "require('bootstrap');")) { + return; + } + + $files->dumpFile('assets/app.js', $appJs."require('bootstrap');\n"); + }, +]; diff --git a/src/Resources/scaffolds/6.0/bootstrapcss/assets/styles/app.css.tpl b/src/Resources/scaffolds/6.0/bootstrapcss/assets/styles/app.css.tpl new file mode 100644 index 000000000..327bd375d --- /dev/null +++ b/src/Resources/scaffolds/6.0/bootstrapcss/assets/styles/app.css.tpl @@ -0,0 +1 @@ +@import "~bootstrap/dist/css/bootstrap.css"; diff --git a/src/Resources/scaffolds/6.0/change-password.php b/src/Resources/scaffolds/6.0/change-password.php new file mode 100644 index 000000000..31acbd56f --- /dev/null +++ b/src/Resources/scaffolds/6.0/change-password.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Create change password form and tests.', + 'dependents' => [ + 'auth', + ], + 'packages' => [ + 'symfony/form' => 'all', + ], +]; diff --git a/src/Resources/scaffolds/6.0/change-password/src/Controller/ChangePasswordController.php.tpl b/src/Resources/scaffolds/6.0/change-password/src/Controller/ChangePasswordController.php.tpl new file mode 100644 index 000000000..d0d4cc9d6 --- /dev/null +++ b/src/Resources/scaffolds/6.0/change-password/src/Controller/ChangePasswordController.php.tpl @@ -0,0 +1,52 @@ +denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); + + $user = $this->getUser(); + + if (!$user instanceof User) { + throw new \LogicException('Invalid user type.'); + } + + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // encode the plain password + $user->setPassword( + $userPasswordHasher->hashPassword( + $user, + $form->get('plainPassword')->getData() + ) + ); + + $userRepository->add($user); + $this->addFlash('success', 'You\'ve successfully changed your password.'); + + return $this->redirectToRoute('homepage'); + } + + return $this->renderForm('user/change_password.html.twig', [ + 'changePasswordForm' => $form, + ]); + } +} diff --git a/src/Resources/scaffolds/6.0/change-password/src/Form/ChangePasswordFormType.php.tpl b/src/Resources/scaffolds/6.0/change-password/src/Form/ChangePasswordFormType.php.tpl new file mode 100644 index 000000000..fb8982ece --- /dev/null +++ b/src/Resources/scaffolds/6.0/change-password/src/Form/ChangePasswordFormType.php.tpl @@ -0,0 +1,43 @@ +add('currentPassword', PasswordType::class, [ + 'constraints' => [ + new UserPassword(['message' => 'This is not your current password.']), + ], + ]) + ->add('plainPassword', PasswordType::class, [ + '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): void + { + } +} diff --git a/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig.tpl b/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig.tpl new file mode 100644 index 000000000..15308beb7 --- /dev/null +++ b/src/Resources/scaffolds/6.0/change-password/templates/user/change_password.html.twig.tpl @@ -0,0 +1,18 @@ +{% extends 'base.html.twig' %} + +{% block title %}Change Password{% endblock %} + +{% block body %} +
+
+

Change Password

+ + {{ form_start(changePasswordForm) }} + {{ form_row(changePasswordForm.currentPassword, {label: 'Current Password'}) }} + {{ form_row(changePasswordForm.plainPassword, {label: 'New Password'}) }} + + + {{ form_end(changePasswordForm) }} +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/change-password/tests/Functional/ChangePasswordTest.php.tpl b/src/Resources/scaffolds/6.0/change-password/tests/Functional/ChangePasswordTest.php.tpl new file mode 100644 index 000000000..bc55c227f --- /dev/null +++ b/src/Resources/scaffolds/6.0/change-password/tests/Functional/ChangePasswordTest.php.tpl @@ -0,0 +1,113 @@ + + */ +final class ChangePasswordTest extends KernelTestCase +{ + use Factories; + use HasBrowser; + use ResetDatabase; + + public function testCanChangePassword(): void + { + $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + $currentPassword = $user->getPassword(); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user/change-password') + ->fillField('Current Password', '1234') + ->fillField('New Password', 'new-password') + ->click('Change Password') + ->assertSuccessful() + ->assertOn('/') + ->assertSeeIn('.alert', 'You\'ve successfully changed your password.') + ->assertAuthenticated('mary@example.com') + ->visit('/logout') + ->visit('/login') + ->fillField('Email', 'mary@example.com') + ->fillField('Password', 'new-password') + ->click('Sign in') + ->assertOn('/') + ->assertSuccessful() + ->assertAuthenticated('mary@example.com') + ; + + $this->assertNotSame($currentPassword, $user->getPassword()); + } + + public function testCurrentPasswordMustBeCorrect(): void + { + $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + $currentPassword = $user->getPassword(); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user/change-password') + ->fillField('Current Password', 'invalid') + ->fillField('New Password', 'new-password') + ->click('Change Password') + ->assertStatus(422) + ->assertOn('/user/change-password') + ->assertSee('This is not your current password.') + ->assertAuthenticated('mary@example.com') + ; + + $this->assertSame($currentPassword, $user->getPassword()); + } + + public function testCurrentPasswordIsRequired(): void + { + $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + $currentPassword = $user->getPassword(); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user/change-password') + ->fillField('New Password', 'new-password') + ->click('Change Password') + ->assertStatus(422) + ->assertOn('/user/change-password') + ->assertSee('This is not your current password.') + ->assertAuthenticated('mary@example.com') + ; + + $this->assertSame($currentPassword, $user->getPassword()); + } + + public function testNewPasswordIsRequired(): void + { + $user = UserFactory::createOne(['email' => 'mary@example.com', 'password' => '1234']); + $currentPassword = $user->getPassword(); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user/change-password') + ->fillField('Current Password', '1234') + ->click('Change Password') + ->assertStatus(422) + ->assertOn('/user/change-password') + ->assertSee('Please enter a password.') + ->assertAuthenticated('mary@example.com') + ; + + $this->assertSame($currentPassword, $user->getPassword()); + } + + public function testCannotAccessChangePasswordPageIfNotLoggedIn(): void + { + $this->browser() + ->visit('/user/change-password') + ->assertOn('/login') + ; + } +} diff --git a/src/Resources/scaffolds/6.0/homepage.php b/src/Resources/scaffolds/6.0/homepage.php new file mode 100644 index 000000000..05abde185 --- /dev/null +++ b/src/Resources/scaffolds/6.0/homepage.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Create a basic homepage controller/template/test.', + 'packages' => [ + 'symfony/twig-bundle' => 'all', + 'phpunit/phpunit' => 'dev', + 'symfony/phpunit-bridge' => 'dev', + 'zenstruck/browser' => 'dev', + ], +]; diff --git a/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php.tpl b/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php.tpl new file mode 100644 index 000000000..ea73c2dbc --- /dev/null +++ b/src/Resources/scaffolds/6.0/homepage/src/Controller/HomepageController.php.tpl @@ -0,0 +1,16 @@ +render('homepage.html.twig'); + } +} diff --git a/src/Resources/scaffolds/6.0/homepage/templates/homepage.html.twig.tpl b/src/Resources/scaffolds/6.0/homepage/templates/homepage.html.twig.tpl new file mode 100644 index 000000000..09d96df90 --- /dev/null +++ b/src/Resources/scaffolds/6.0/homepage/templates/homepage.html.twig.tpl @@ -0,0 +1,15 @@ +{% extends 'base.html.twig' %} + +{% block title %}Home{% endblock %} + +{% block body %} +

Home

+ + {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %} +{% endblock %} diff --git a/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php.tpl b/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php.tpl new file mode 100644 index 000000000..dfd1b0221 --- /dev/null +++ b/src/Resources/scaffolds/6.0/homepage/tests/Functional/HomepageTest.php.tpl @@ -0,0 +1,19 @@ +browser() + ->visit('/') + ->assertSuccessful() + ; + } +} diff --git a/src/Resources/scaffolds/6.0/profile.php b/src/Resources/scaffolds/6.0/profile.php new file mode 100644 index 000000000..20d72f2bb --- /dev/null +++ b/src/Resources/scaffolds/6.0/profile.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Create user profile form and tests.', + 'dependents' => [ + 'auth', + ], + 'packages' => [ + 'symfony/form' => 'all', + ], +]; diff --git a/src/Resources/scaffolds/6.0/profile/src/Controller/ProfileController.php.tpl b/src/Resources/scaffolds/6.0/profile/src/Controller/ProfileController.php.tpl new file mode 100644 index 000000000..01fa8dfee --- /dev/null +++ b/src/Resources/scaffolds/6.0/profile/src/Controller/ProfileController.php.tpl @@ -0,0 +1,41 @@ +createAccessDeniedException(); + } + + if (!$user instanceof User) { + throw new \LogicException('Invalid user type.'); + } + + $form = $this->createForm(ProfileFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $userRepository->add($user); + $this->addFlash('success', 'You\'ve successfully updated your profile.'); + + return $this->redirectToRoute('homepage'); + } + + return $this->render('user/profile.html.twig', [ + 'profileForm' => $form->createView(), + ]); + } +} diff --git a/src/Resources/scaffolds/6.0/profile/src/Form/ProfileFormType.php.tpl b/src/Resources/scaffolds/6.0/profile/src/Form/ProfileFormType.php.tpl new file mode 100644 index 000000000..077166489 --- /dev/null +++ b/src/Resources/scaffolds/6.0/profile/src/Form/ProfileFormType.php.tpl @@ -0,0 +1,25 @@ +add('name') + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig.tpl b/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig.tpl new file mode 100644 index 000000000..98d3f82d1 --- /dev/null +++ b/src/Resources/scaffolds/6.0/profile/templates/user/profile.html.twig.tpl @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block title %}Manage Profile{% endblock %} + +{% block body %} +
+
+

Manage Profile

+ + {{ form_start(profileForm) }} + {{ form_row(profileForm.name) }} + + + {{ form_end(profileForm) }} +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/profile/tests/Functional/ProfileTest.php.tpl b/src/Resources/scaffolds/6.0/profile/tests/Functional/ProfileTest.php.tpl new file mode 100644 index 000000000..84f17b3a6 --- /dev/null +++ b/src/Resources/scaffolds/6.0/profile/tests/Functional/ProfileTest.php.tpl @@ -0,0 +1,66 @@ + + */ +final class ProfileTest extends KernelTestCase +{ + use Factories; + use HasBrowser; + use ResetDatabase; + + public function testCanUpdateProfile(): void + { + $user = UserFactory::createOne(['name' => 'Mary Edwards']); + + $this->assertSame('Mary Edwards', $user->getName()); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user') + ->assertFieldEquals('Name', 'Mary Edwards') + ->fillField('Name', 'John Smith') + ->click('Save') + ->assertOn('/') + ->assertSuccessful() + ->assertSeeIn('.alert', 'You\'ve successfully updated your profile.') + ; + + $this->assertSame('John Smith', $user->getName()); + } + + public function testValidation(): void + { + $user = UserFactory::createOne(['name' => 'Mary Edwards']); + + UserFactory::assert()->exists(['name' => 'Mary Edwards']); + + $this->browser() + ->actingAs($user->object()) + ->visit('/user') + ->fillField('Name', '') + ->click('Save') + ->assertOn('/user') + ->assertSuccessful() + ->assertSee('Name is required') + ; + + UserFactory::assert()->exists(['name' => 'Mary Edwards']); + } + + public function testCannotAccessProfilePageIfNotLoggedIn(): void + { + $this->browser() + ->visit('/user') + ->assertOn('/login') + ; + } +} diff --git a/src/Resources/scaffolds/6.0/register.php b/src/Resources/scaffolds/6.0/register.php new file mode 100644 index 000000000..cdc8f50f6 --- /dev/null +++ b/src/Resources/scaffolds/6.0/register.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Create registration form and tests.', + 'dependents' => [ + 'auth', + ], + 'packages' => [ + 'symfony/form' => 'all', + ], +]; diff --git a/src/Resources/scaffolds/6.0/register/src/Controller/RegisterController.php.tpl b/src/Resources/scaffolds/6.0/register/src/Controller/RegisterController.php.tpl new file mode 100644 index 000000000..32cebb31d --- /dev/null +++ b/src/Resources/scaffolds/6.0/register/src/Controller/RegisterController.php.tpl @@ -0,0 +1,51 @@ +createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // encode the plain password + $user->setPassword( + $userPasswordHasher->hashPassword( + $user, + $form->get('plainPassword')->getData() + ) + ); + + $userRepository->add($user); + // do anything else you need here, like send an email + + // authenticate the user + $loginUser($user, $request); + $this->addFlash('success', 'You\'ve successfully registered and are now logged in.'); + + return $this->redirectToRoute('homepage'); + } + + return $this->render('user/register.html.twig', [ + 'registrationForm' => $form->createView(), + ]); + } +} diff --git a/src/Resources/scaffolds/6.0/register/src/Form/RegistrationFormType.php.tpl b/src/Resources/scaffolds/6.0/register/src/Form/RegistrationFormType.php.tpl new file mode 100644 index 000000000..5b792cf0a --- /dev/null +++ b/src/Resources/scaffolds/6.0/register/src/Form/RegistrationFormType.php.tpl @@ -0,0 +1,47 @@ +add('name') + ->add('email', EmailType::class) + ->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): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig.tpl b/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig.tpl new file mode 100644 index 000000000..584fd2716 --- /dev/null +++ b/src/Resources/scaffolds/6.0/register/templates/user/register.html.twig.tpl @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{% block title %}Register{% endblock %} + +{% block body %} +
+
+

Register

+ + {{ form_start(registrationForm) }} + {{ form_row(registrationForm.name) }} + {{ form_row(registrationForm.email) }} + {{ form_row(registrationForm.plainPassword, { + label: 'Password' + }) }} + + + {{ form_end(registrationForm) }} +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/register/tests/Functional/RegisterTest.php.tpl b/src/Resources/scaffolds/6.0/register/tests/Functional/RegisterTest.php.tpl new file mode 100644 index 000000000..a3c872b80 --- /dev/null +++ b/src/Resources/scaffolds/6.0/register/tests/Functional/RegisterTest.php.tpl @@ -0,0 +1,93 @@ +empty(); + + $this->browser() + ->visit('/register') + ->assertSuccessful() + ->fillField('Name', 'Madison') + ->fillField('Email', 'madison@example.com') + ->fillField('Password', 'password') + ->click('Register') + ->assertOn('/') + ->assertSeeIn('.alert', 'You\'ve successfully registered and are now logged in.') + ->assertAuthenticated('madison@example.com') + ->visit('/logout') + ->assertNotAuthenticated() + ->visit('/login') + ->fillField('Email', 'madison@example.com') + ->fillField('Password', 'password') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated('madison@example.com') + ; + + UserFactory::assert()->count(1); + UserFactory::assert()->exists(['name' => 'Madison', 'email' => 'madison@example.com']); + } + + public function testValidation(): void + { + $this->browser() + ->throwExceptions() + ->visit('/register') + ->assertSuccessful() + ->click('Register') + ->assertOn('/register') + ->assertSee('Email is required') + ->assertSee('Please enter a password') + ->assertSee('Name is required') + ->assertNotAuthenticated() + ; + } + + public function testEmailMustBeEmailAddress(): void + { + $this->browser() + ->throwExceptions() + ->visit('/register') + ->assertSuccessful() + ->fillField('Name', 'Madison') + ->fillField('Email', 'invalid-email') + ->fillField('Password', 'password') + ->click('Register') + ->assertOn('/register') + ->assertSee('This is not a valid email address') + ->assertNotAuthenticated() + ; + } + + public function testEmailMustBeUnique(): void + { + UserFactory::createOne(['email' => 'madison@example.com']); + + $this->browser() + ->throwExceptions() + ->visit('/register') + ->assertSuccessful() + ->fillField('Name', 'Madison') + ->fillField('Email', 'madison@example.com') + ->fillField('Password', 'password') + ->click('Register') + ->assertOn('/register') + ->assertSee('There is already an account with this email') + ->assertNotAuthenticated() + ; + } +} diff --git a/src/Resources/scaffolds/6.0/reset-password.php b/src/Resources/scaffolds/6.0/reset-password.php new file mode 100644 index 000000000..f8b1f1b47 --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\MakerBundle\FileManager; + +return [ + 'description' => 'Reset password system using symfonycasts/reset-password-bundle with tests.', + 'dependents' => [ + 'auth', + ], + 'packages' => [ + 'symfony/form' => 'all', + 'symfony/mailer' => 'all', + 'symfonycasts/reset-password-bundle' => 'all', + 'zenstruck/mailer-test' => 'dev', + ], + 'configure' => function (FileManager $files) { + $login = $files->getFileContents('templates/login.html.twig'); + $forgotPassword = "\n Forgot your password?"; + + if (str_contains($login, $forgotPassword)) { + return; + } + + $files->dumpFile('templates/login.html.twig', str_replace( + '', + $forgotPassword, + $login + )); + }, +]; diff --git a/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml.tpl b/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml.tpl new file mode 100644 index 000000000..fdc6a4bc4 --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/config/packages/reset_password.yaml.tpl @@ -0,0 +1,4 @@ +symfonycasts_reset_password: + request_password_repository: App\Repository\ResetPasswordRequestRepository + throttle_limit: 900 # 15 minutes + lifetime: 3600 # 1 hour diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php.tpl b/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php.tpl new file mode 100644 index 000000000..f978b85fd --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/src/Controller/ResetPasswordController.php.tpl @@ -0,0 +1,188 @@ +createForm(RequestResetFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + return $this->processSendingPasswordResetEmail( + $form->get('email')->getData(), + $mailer + ); + } + + return $this->renderForm('reset_password/request.html.twig', [ + 'requestForm' => $form, + ]); + } + + /** + * Confirmation page after a user has requested a password reset. + */ + #[Route('/check-email', name: 'reset_password_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('reset_password/check_email.html.twig', [ + 'resetToken' => $resetToken, + ]); + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + #[Route('/reset/{token}', name: 'reset_password')] + public function reset(Request $request, LoginUser $loginUser, UserPasswordHasherInterface $userPasswordHasher, 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('reset_password'); + } + + if (null === $token = $this->getTokenFromSession()) { + throw $this->createNotFoundException('No reset password token found in the URL or in the session.'); + } + + try { + /** @var User $user */ + $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('error', $e->getReason()); + + return $this->redirectToRoute('homepage'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(ResetPasswordFormType::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(hash) the plain password, and set it. + $user->setPassword($userPasswordHasher->hashPassword($user, $form->get('plainPassword')->getData())); + + $this->userRepository->add($user); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + // programmatic user login + $loginUser($user, $request); + + $this->addFlash('success', 'Your password was successfully reset, you are now logged in.'); + + return $this->redirectToRoute('homepage'); + } + + return $this->renderForm('reset_password/reset.html.twig', [ + 'resetForm' => $form, + ]); + } + + private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): RedirectResponse + { + $user = $this->userRepository->findOneBy([ + 'email' => $emailFormData, + ]); + + // Do not reveal whether a user account was found or not. + if (!$user) { + return $this->redirectToRoute('reset_password_check_email'); + } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (TooManyPasswordRequestsException $e) { + $this->addFlash('error', $e->getReason()); + + return $this->redirectToRoute('homepage'); + } 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 'reset_password_request'. + // Caution: This may reveal if a user is registered or not. + // + // $this->addFlash('reset_password_error', sprintf( + // '%s - %s', + // ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, + // $e->getReason() + // )); + + return $this->redirectToRoute('reset_password_check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('webmaster@example.com', 'Webmaster')) // todo change email/name + ->to(new Address($user->getEmail(), $user->getName())) + ->subject('Your password reset request') + ->htmlTemplate('reset_password/email.html.twig') + ->context([ + 'resetToken' => $resetToken, + ]) + ; + + $email->getHeaders() + ->add(new TagHeader('reset-password')) // https://symfony.com/doc/current/mailer.html#adding-tags-and-metadata-to-emails + ->addTextHeader('X-CTA', $this->generateUrl( // helps with testing + 'reset_password', + ['token' => $resetToken->getToken()], + UrlGeneratorInterface::ABSOLUTE_URL + )) + ; + + $mailer->send($email); + + // Store the token object in session for retrieval in check-email route. + $this->setTokenObjectInSession($resetToken); + + return $this->redirectToRoute('reset_password_check_email'); + } +} diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php.tpl b/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php.tpl new file mode 100644 index 000000000..a94bacd4d --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/src/Entity/ResetPasswordRequest.php.tpl @@ -0,0 +1,39 @@ +user = $user; + $this->initialize($expiresAt, $selector, $hashedToken); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Form/RequestResetFormType.php.tpl b/src/Resources/scaffolds/6.0/reset-password/src/Form/RequestResetFormType.php.tpl new file mode 100644 index 000000000..58041d05f --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/src/Form/RequestResetFormType.php.tpl @@ -0,0 +1,31 @@ +add('email', EmailType::class, [ + 'attr' => ['autocomplete' => 'email'], + 'constraints' => [ + new NotBlank(['message' => 'Please enter your email']), + new Email(['message' => 'This is not a valid email address']), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPasswordFormType.php.tpl b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPasswordFormType.php.tpl new file mode 100644 index 000000000..9410d7769 --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/src/Form/ResetPasswordFormType.php.tpl @@ -0,0 +1,37 @@ +add('plainPassword', PasswordType::class, [ + '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): void + { + } +} diff --git a/src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php.tpl b/src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php.tpl new file mode 100644 index 000000000..5a428b78d --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/src/Repository/ResetPasswordRequestRepository.php.tpl @@ -0,0 +1,31 @@ + +
+

Password Reset Email Sent

+

+ If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password. + This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}. +

+

If you don't receive an email please check your spam folder or try again.

+
+ +{% endblock %} diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig.tpl b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig.tpl new file mode 100644 index 000000000..7acbbdca6 --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/email.html.twig.tpl @@ -0,0 +1,9 @@ +

Hi!

+ +

To reset your password, please visit the following link

+ +{{ url('reset_password', {token: resetToken.token}) }} + +

This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.

+ +

Cheers!

diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig.tpl b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig.tpl new file mode 100644 index 000000000..004df2a79 --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/request.html.twig.tpl @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} +
+
+

Reset your password

+ + {{ form_start(requestForm) }} + {{ form_row(requestForm.email, {help: 'Enter your email address and we will send you a link to reset your password.'}) }} + + + {{ form_end(requestForm) }} +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig.tpl b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig.tpl new file mode 100644 index 000000000..d64046a3b --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/templates/reset_password/reset.html.twig.tpl @@ -0,0 +1,16 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} +
+
+

Reset your password

+ + {{ form_start(resetForm) }} + {{ form_row(resetForm.plainPassword, {label: 'New Password'}) }} + + {{ form_end(resetForm) }} +
+
+{% endblock %} diff --git a/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php.tpl b/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php.tpl new file mode 100644 index 000000000..ec9f7962c --- /dev/null +++ b/src/Resources/scaffolds/6.0/reset-password/tests/Functional/ResetPasswordTest.php.tpl @@ -0,0 +1,68 @@ + + */ +final class ResetPasswordTest extends KernelTestCase +{ + use Factories; + use HasBrowser; + use InteractsWithMailer; + use ResetDatabase; + + public function testCanResetPassword(): void + { + UserFactory::createOne(['email' => 'john@example.com', 'name' => 'John', 'password' => '1234']); + + $this->browser() + ->visit('/reset-password') + ->assertSuccessful() + ->fillField('Email', 'john@example.com') + ->click('Send password reset email') + ->assertOn('/reset-password/check-email') + ->assertSuccessful() + ->assertSee('Password Reset Email Sent') + ; + + $email = $this->mailer() + ->sentEmails() + ->assertCount(1) + ->first() + ; + $resetUrl = $email->getHeaders()->get('X-CTA')?->getBody(); + + $this->assertNotNull($resetUrl, 'The reset url header was not set.'); + + $email + ->assertTo('john@example.com', 'John') + ->assertContains('To reset your password, please visit the following link') + ->assertContains($resetUrl) + ->assertHasTag('reset-password') + ; + + $this->browser() + ->visit($resetUrl) + ->fillField('New Password', 'new-password') + ->click('Reset password') + ->assertOn('/') + ->assertSeeIn('.alert', 'Your password was successfully reset, you are now logged in.') + ->assertAuthenticated('john@example.com') + ->visit('/logout') + ->visit('/login') + ->fillField('Email', 'john@example.com') + ->fillField('Password', 'new-password') + ->click('Sign in') + ->assertOn('/') + ->assertAuthenticated('john@example.com') + ; + } +} diff --git a/src/Resources/scaffolds/6.0/starter-kit.php b/src/Resources/scaffolds/6.0/starter-kit.php new file mode 100644 index 000000000..ac007e5b6 --- /dev/null +++ b/src/Resources/scaffolds/6.0/starter-kit.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Starting kit with authentication, registration, password reset, user profile management with an application shell styled with Bootstrap CSS.', + 'dependents' => [ + 'bootstrapcss', + 'register', + 'reset-password', + 'change-password', + 'profile', + ], +]; diff --git a/src/Resources/scaffolds/6.0/starter-kit/templates/base.html.twig.tpl b/src/Resources/scaffolds/6.0/starter-kit/templates/base.html.twig.tpl new file mode 100644 index 000000000..fe81e352c --- /dev/null +++ b/src/Resources/scaffolds/6.0/starter-kit/templates/base.html.twig.tpl @@ -0,0 +1,53 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #} + {% block stylesheets %} + {{ encore_entry_link_tags('app') }} + {% endblock %} + + {% block javascripts %} + {{ encore_entry_script_tags('app') }} + {% endblock %} + + + +
+ {% block body %}{% endblock %} +
+ + diff --git a/src/Resources/scaffolds/6.0/user.php b/src/Resources/scaffolds/6.0/user.php new file mode 100644 index 000000000..5c662458f --- /dev/null +++ b/src/Resources/scaffolds/6.0/user.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + 'description' => 'Create a basic user and unit test.', + 'packages' => [ + 'doctrine/orm' => 'all', + 'doctrine/doctrine-bundle' => 'all', + 'symfony/security-bundle' => 'all', + 'symfony/validator' => 'all', + 'phpunit/phpunit' => 'dev', + 'symfony/phpunit-bridge' => 'dev', + 'zenstruck/foundry' => 'dev', + ], +]; diff --git a/src/Resources/scaffolds/6.0/user/src/Entity/User.php.tpl b/src/Resources/scaffolds/6.0/user/src/Entity/User.php.tpl new file mode 100644 index 000000000..1b57e0710 --- /dev/null +++ b/src/Resources/scaffolds/6.0/user/src/Entity/User.php.tpl @@ -0,0 +1,117 @@ +id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $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; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php.tpl b/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php.tpl new file mode 100644 index 000000000..f035cd4d4 --- /dev/null +++ b/src/Resources/scaffolds/6.0/user/src/Factory/UserFactory.php.tpl @@ -0,0 +1,61 @@ + + * + * @method static User|Proxy createOne(array $attributes = []) + * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static User|Proxy find(object|array|mixed $criteria) + * @method static User|Proxy findOrCreate(array $attributes) + * @method static User|Proxy first(string $sortedField = 'id') + * @method static User|Proxy last(string $sortedField = 'id') + * @method static User|Proxy random(array $attributes = []) + * @method static User|Proxy randomOrCreate(array $attributes = []) + * @method static User[]|Proxy[] all() + * @method static User[]|Proxy[] findBy(array $attributes) + * @method static User[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static UserRepository|RepositoryProxy repository() + * @method User|Proxy create(array|callable $attributes = []) + */ +final class UserFactory extends ModelFactory +{ + public const DEFAULT_PASSWORD = '1234'; + + public function __construct(private UserPasswordHasherInterface $passwordHasher) + { + parent::__construct(); + } + + protected function getDefaults(): array + { + return [ + 'email' => self::faker()->unique()->safeEmail(), + 'name' => self::faker()->name(), + 'password' => self::DEFAULT_PASSWORD, + ]; + } + + protected function initialize(): self + { + return $this + ->afterInstantiate(function (User $user) { + $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword())); + }) + ; + } + + protected static function getClass(): string + { + return User::class; + } +} diff --git a/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php.tpl b/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php.tpl new file mode 100644 index 000000000..9df0b83cb --- /dev/null +++ b/src/Resources/scaffolds/6.0/user/src/Repository/UserRepository.php.tpl @@ -0,0 +1,64 @@ +_em->persist($entity); + + if ($flush) { + $this->_em->flush(); + } + } + + /** + * @throws ORMException + * @throws OptimisticLockException + */ + public function remove(User $entity, bool $flush = true): void + { + $this->_em->remove($entity); + if ($flush) { + $this->_em->flush(); + } + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user))); + } + + $user->setPassword($newHashedPassword); + $this->add($user); + } +} diff --git a/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php.tpl b/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php.tpl new file mode 100644 index 000000000..a503b269b --- /dev/null +++ b/src/Resources/scaffolds/6.0/user/tests/Unit/Entity/UserTest.php.tpl @@ -0,0 +1,19 @@ +assertSame(['ROLE_USER'], (new User())->getRoles()); + $this->assertSame(['ROLE_ADMIN', 'ROLE_USER'], (new User())->setRoles(['ROLE_ADMIN'])->getRoles()); + $this->assertSame( + ['ROLE_ADMIN', 'ROLE_USER'], + (new User())->setRoles(['ROLE_ADMIN', 'ROLE_USER'])->getRoles() + ); + } +} diff --git a/src/Test/MakerTestEnvironment.php b/src/Test/MakerTestEnvironment.php index 1cd63aa26..59e07b774 100644 --- a/src/Test/MakerTestEnvironment.php +++ b/src/Test/MakerTestEnvironment.php @@ -345,7 +345,7 @@ public function createInteractiveCommandProcess(string $commandName, array $user [ 'SHELL_INTERACTIVE' => '1', ], - 10 + 40 ); if ($userInputs) { diff --git a/tests/Maker/MakeScaffoldTest.php b/tests/Maker/MakeScaffoldTest.php new file mode 100644 index 000000000..c8b5df229 --- /dev/null +++ b/tests/Maker/MakeScaffoldTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeScaffold; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; +use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpKernel\Kernel; + +class MakeScaffoldTest extends MakerTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + if (Kernel::VERSION_ID < 60000) { + $this->markTestSkipped('Only available on Symfony 6+.'); + } + } + + public function getTestDetails(): iterable + { + foreach (self::scaffoldProvider() as $name) { + yield $name => [$this->createMakerTest() + ->preRun(function (MakerTestRunner $runner) { + $runner->writeFile('.env.test.local', implode("\n", [ + 'DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"', + 'MAILER_DSN=null://null', + ])); + }) + ->addExtraDependencies( + 'process' + ) + ->run(function (MakerTestRunner $runner) use ($name) { + $runner->runMaker([$name]); + $runner->runTests(); + + $this->assertTrue(true); // successfully ran tests + }), + ]; + } + } + + protected function getMakerClass(): string + { + return MakeScaffold::class; + } + + private static function scaffoldProvider(): iterable + { + $excluded = ['bootstrapcss']; + + foreach (Finder::create()->in(__DIR__.'/../../src/Resources/scaffolds/6.0')->name('*.php')->depth(0) as $file) { + if (\in_array($name = $file->getFilenameWithoutExtension(), $excluded, true)) { + continue; + } + + yield $name; + } + } +}