diff --git a/src/Maker/Security/MakeFormLogin.php b/src/Maker/Security/MakeFormLogin.php new file mode 100644 index 000000000..b20a62d1d --- /dev/null +++ b/src/Maker/Security/MakeFormLogin.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker\Security; + +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\FileManager; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Maker\AbstractMaker; +use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper; +use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater; +use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; +use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +use Symfony\Component\Yaml\Yaml; + +/** + * Generate Form Login Security using SecurityBundle's Authenticator. + * + * @see https://symfony.com/doc/current/security.html#form-login + * + * @author Jesse Rushlow + * + * @internal + */ +final class MakeFormLogin extends AbstractMaker +{ + private const SECURITY_CONFIG_PATH = 'config/packages/security.yaml'; + private YamlSourceManipulator $ysm; + private string $controllerName; + private string $firewallToUpdate; + private string $userNameField; + private bool $willLogout; + + public function __construct( + private FileManager $fileManager, + private SecurityConfigUpdater $securityConfigUpdater, + private SecurityControllerBuilder $securityControllerBuilder, + ) { + } + + public static function getCommandName(): string + { + return 'make:security:form-login'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command->setHelp(file_get_contents(\dirname(__DIR__, 2).'/Resources/help/security/MakeFormLogin.txt')); + } + + public static function getCommandDescription(): string + { + return 'Generate the code needed for the form_login authenticator'; + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency( + SecurityBundle::class, + 'security' + ); + + $dependencies->addClassDependency(TwigBundle::class, 'twig'); + + // needed to update the YAML files + $dependencies->addClassDependency( + Yaml::class, + 'yaml' + ); + + $dependencies->addClassDependency(DoctrineBundle::class, 'orm'); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if (!$this->fileManager->fileExists(self::SECURITY_CONFIG_PATH)) { + throw new RuntimeCommandException(sprintf('The file "%s" does not exist. PHP & XML configuration formats are currently not supported.', self::SECURITY_CONFIG_PATH)); + } + + $this->ysm = new YamlSourceManipulator($this->fileManager->getFileContents(self::SECURITY_CONFIG_PATH)); + $securityData = $this->ysm->getData(); + + if (!isset($securityData['security']['providers']) || !$securityData['security']['providers']) { + throw new RuntimeCommandException('To generate a form login authentication, you must configure at least one entry under "providers" in "security.yaml".'); + } + + $this->controllerName = $io->ask( + 'Choose a name for the controller class (e.g. SecurityController)', + 'SecurityController', + [Validator::class, 'validateClassName'] + ); + + $securityHelper = new InteractiveSecurityHelper(); + $this->firewallToUpdate = $securityHelper->guessFirewallName($io, $securityData); + $userClass = $securityHelper->guessUserClass($io, $securityData['security']['providers']); + $this->userNameField = $securityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers']); + $this->willLogout = $io->confirm('Do you want to generate a \'/logout\' URL?'); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $useStatements = new UseStatementGenerator([ + AbstractController::class, + Response::class, + Route::class, + AuthenticationUtils::class, + ]); + + $controllerNameDetails = $generator->createClassNameDetails($this->controllerName, 'Controller\\', 'Controller'); + $templatePath = strtolower($controllerNameDetails->getRelativeNameWithoutSuffix()); + + $controllerPath = $generator->generateController( + $controllerNameDetails->getFullName(), + 'security/formLogin/LoginController.tpl.php', + [ + 'use_statements' => $useStatements, + 'controller_name' => $controllerNameDetails->getShortName(), + 'template_path' => $templatePath, + ] + ); + + if ($this->willLogout) { + $manipulator = new ClassSourceManipulator($generator->getFileContentsForPendingOperation($controllerPath)); + + $this->securityControllerBuilder->addLogoutMethod($manipulator); + + $generator->dumpFile($controllerPath, $manipulator->getSourceCode()); + } + + $generator->generateTemplate( + sprintf('%s/login.html.twig', $templatePath), + 'security/formLogin/login_form.tpl.php', + [ + 'logout_setup' => $this->willLogout, + 'username_label' => Str::asHumanWords($this->userNameField), + 'username_is_email' => false !== stripos($this->userNameField, 'email'), + ] + ); + + $securityData = $this->securityConfigUpdater->updateForFormLogin($this->ysm->getContents(), $this->firewallToUpdate, 'app_login', 'app_login'); + + if ($this->willLogout) { + $securityData = $this->securityConfigUpdater->updateForLogout($securityData, $this->firewallToUpdate); + } + + $generator->dumpFile(self::SECURITY_CONFIG_PATH, $securityData); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + + $io->text([ + sprintf('Next: Review and adapt the login template: %s/login.html.twig to suit your needs.', $templatePath), + ]); + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index 8ea8f6ba9..93f321e9b 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -139,5 +139,12 @@ + + + + + + + diff --git a/src/Resources/help/security/MakeFormLogin.txt b/src/Resources/help/security/MakeFormLogin.txt new file mode 100644 index 000000000..08ce3592a --- /dev/null +++ b/src/Resources/help/security/MakeFormLogin.txt @@ -0,0 +1,9 @@ +The %command.name% command generates a controller and twig template +to allow users to login using the form_login authenticator. + +The controller name, and logout ability can be customized by answering the +questions asked when running %command.name%. + +This will also update your security.yaml for the new authenticator. + +php %command.full_name% diff --git a/src/Resources/skeleton/security/formLogin/LoginController.tpl.php b/src/Resources/skeleton/security/formLogin/LoginController.tpl.php new file mode 100644 index 000000000..734db0b02 --- /dev/null +++ b/src/Resources/skeleton/security/formLogin/LoginController.tpl.php @@ -0,0 +1,23 @@ + + +namespace ; + + + +class extends AbstractController +{ + #[Route(path: '/login', name: 'app_login')] + public function login(AuthenticationUtils $authenticationUtils): Response + { + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } +} diff --git a/src/Resources/skeleton/security/formLogin/login_form.tpl.php b/src/Resources/skeleton/security/formLogin/login_form.tpl.php new file mode 100644 index 000000000..cc5bc49a5 --- /dev/null +++ b/src/Resources/skeleton/security/formLogin/login_form.tpl.php @@ -0,0 +1,44 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + + + {% if app.user %} +
+ You are logged in as {{ app.user.userIdentifier }}, Logout +
+ {% endif %} + + +

Please sign in

+ + + + + + + + {# + Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. + See https://symfony.com/doc/current/security/remember_me.html + +
+ +
+ #} + + +
+{% endblock %} diff --git a/src/Security/SecurityConfigUpdater.php b/src/Security/SecurityConfigUpdater.php index bc064a7dd..faf5866d6 100644 --- a/src/Security/SecurityConfigUpdater.php +++ b/src/Security/SecurityConfigUpdater.php @@ -30,10 +30,7 @@ public function __construct( ) { } - /** - * Updates security.yaml contents based on a new User class. - */ - public function updateForUserClass(string $yamlSource, UserClassConfiguration $userConfig, string $userClass): string + public function updateForFormLogin(string $yamlSource, string $firewallToUpdate, string $loginPath, string $checkPath): string { $this->manipulator = new YamlSourceManipulator($yamlSource); @@ -43,6 +40,24 @@ public function updateForUserClass(string $yamlSource, UserClassConfiguration $u $this->normalizeSecurityYamlFile(); + $newData = $this->manipulator->getData(); + + $newData['security']['firewalls'][$firewallToUpdate]['form_login']['login_path'] = $loginPath; + $newData['security']['firewalls'][$firewallToUpdate]['form_login']['check_path'] = $checkPath; + $newData['security']['firewalls'][$firewallToUpdate]['form_login']['enable_csrf'] = true; + + $this->manipulator->setData($newData); + + return $this->manipulator->getContents(); + } + + /** + * Updates security.yaml contents based on a new User class. + */ + public function updateForUserClass(string $yamlSource, UserClassConfiguration $userConfig, string $userClass): string + { + $this->createYamlSourceManipulator($yamlSource); + $this->updateProviders($userConfig, $userClass); if ($userConfig->hasPassword()) { @@ -57,13 +72,7 @@ public function updateForUserClass(string $yamlSource, UserClassConfiguration $u public function updateForAuthenticator(string $yamlSource, string $firewallName, $chosenEntryPoint, string $authenticatorClass, bool $logoutSetup): string { - $this->manipulator = new YamlSourceManipulator($yamlSource); - - if (null !== $this->ysmLogger) { - $this->manipulator->setLogger($this->ysmLogger); - } - - $this->normalizeSecurityYamlFile(); + $this->createYamlSourceManipulator($yamlSource); $newData = $this->manipulator->getData(); @@ -102,23 +111,55 @@ public function updateForAuthenticator(string $yamlSource, string $firewallName, $firewall['entry_point'] = $authenticatorClass; } + $newData['security']['firewalls'][$firewallName] = $firewall; + if (!isset($firewall['logout']) && $logoutSetup) { - $firewall['logout'] = ['path' => 'app_logout']; - $firewall['logout'][] = $this->manipulator->createCommentLine( - ' where to redirect after logout' - ); - $firewall['logout'][] = $this->manipulator->createCommentLine( - ' target: app_any_route' - ); - } + $this->configureLogout($newData, $firewallName); - $newData['security']['firewalls'][$firewallName] = $firewall; + return $this->manipulator->getContents(); + } $this->manipulator->setData($newData); return $this->manipulator->getContents(); } + public function updateForLogout(string $yamlSource, string $firewallName): string + { + $this->createYamlSourceManipulator($yamlSource); + + $this->configureLogout($this->manipulator->getData(), $firewallName); + + return $this->manipulator->getContents(); + } + + /** + * @legacy This can be removed once we deprecate/remove `make:auth` + */ + private function configureLogout(array $securityData, string $firewallName): void + { + $securityData['security']['firewalls'][$firewallName]['logout'] = ['path' => 'app_logout']; + $securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine( + ' where to redirect after logout' + ); + $securityData['security']['firewalls'][$firewallName]['logout'][] = $this->manipulator->createCommentLine( + ' target: app_any_route' + ); + + $this->manipulator->setData($securityData); + } + + private function createYamlSourceManipulator(string $yamlSource): void + { + $this->manipulator = new YamlSourceManipulator($yamlSource); + + if (null !== $this->ysmLogger) { + $this->manipulator->setLogger($this->ysmLogger); + } + + $this->normalizeSecurityYamlFile(); + } + private function normalizeSecurityYamlFile(): void { if (!isset($this->manipulator->getData()['security'])) { diff --git a/tests/Maker/FunctionalTest.php b/tests/Maker/FunctionalTest.php index 3202585fc..cc5f5ba18 100644 --- a/tests/Maker/FunctionalTest.php +++ b/tests/Maker/FunctionalTest.php @@ -37,7 +37,13 @@ public function testWiring() $application = new Application($kernel); foreach ($finder as $file) { - $maker = new \ReflectionClass(sprintf('Symfony\Bundle\MakerBundle\Maker\%s', $file->getBasename('.php'))); + $classNameFromPath = str_replace( + ['/', '.php'], // We need to flip and "/" to "\" and remove ".php" + ['\\', ''], + sprintf('Symfony\Bundle\MakerBundle\Maker\%s', $file->getRelativePathname()) + ); + + $maker = new \ReflectionClass($classNameFromPath); if ($maker->isAbstract()) { continue; diff --git a/tests/Maker/Security/MakeFormLoginTest.php b/tests/Maker/Security/MakeFormLoginTest.php new file mode 100644 index 000000000..4e1c90c21 --- /dev/null +++ b/tests/Maker/Security/MakeFormLoginTest.php @@ -0,0 +1,135 @@ + + * + * 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\Security; + +use Symfony\Bundle\MakerBundle\Maker\Security\MakeFormLogin; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +/** + * @author Jesse Rushlow + */ +class MakeFormLoginTest extends MakerTestCase +{ + protected function getMakerClass(): string + { + return MakeFormLogin::class; + } + + public function getTestDetails(): \Generator + { + yield 'generates_form_login_using_defaults' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'SecurityController', // Controller Name + 'y', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-form-login/expected'; + + $this->assertFileEquals($fixturePath.'/SecurityController.php', $runner->getPath('src/Controller/SecurityController.php')); + $this->assertFileEquals($fixturePath.'/login.html.twig', $runner->getPath('templates/security/login.html.twig')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['login_path']); + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['check_path']); + $this->assertTrue($securityConfig['security']['firewalls']['main']['form_login']['enable_csrf']); + $this->assertSame('app_logout', $securityConfig['security']['firewalls']['main']['logout']['path']); + + $this->runLoginTest($runner); + }), + ]; + + yield 'generates_form_login_without_logout' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'SecurityController', // Controller Name + 'n', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-form-login/expected'; + + $this->assertFileEquals($fixturePath.'/SecurityControllerWithoutLogout.php', $runner->getPath('src/Controller/SecurityController.php')); + $this->assertFileEquals($fixturePath.'/login_no_logout.html.twig', $runner->getPath('templates/security/login.html.twig')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['login_path']); + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['check_path']); + $this->assertFalse(isset($securityConfig['security']['firewalls']['main']['logout']['path'])); + }), + ]; + + yield 'generates_form_login_with_custom_controller_name' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'LoginController', // Controller Name + 'y', // Generate Logout + ]); + + $this->assertStringContainsString('Success', $output); + $fixturePath = \dirname(__DIR__, 2).'/fixtures/security/make-form-login/expected'; + + $this->assertFileEquals($fixturePath.'/LoginController.php', $runner->getPath('src/Controller/LoginController.php')); + $this->assertFileEquals($fixturePath.'/login.html.twig', $runner->getPath('templates/login/login.html.twig')); + + $securityConfig = $runner->readYaml('config/packages/security.yaml'); + + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['login_path']); + $this->assertSame('app_login', $securityConfig['security']['firewalls']['main']['form_login']['check_path']); + $this->assertSame('app_logout', $securityConfig['security']['firewalls']['main']['logout']['path']); + }), + ]; + } + + private function runLoginTest(MakerTestRunner $runner): void + { + $fixturePath = 'security/make-form-login/'; + + $runner->renderTemplateFile($fixturePath.'/LoginTest.php', 'tests/LoginTest.php', []); + + // plaintext password: needed for entities, simplifies overall + $runner->modifyYamlFile('config/packages/security.yaml', function (array $config) { + if (isset($config['when@test']['security']['password_hashers'])) { + $config['when@test']['security']['password_hashers'] = [PasswordAuthenticatedUserInterface::class => 'plaintext']; + + return $config; + } + + return $config; + }); + + $runner->configureDatabase(); + + $runner->runTests(); + } + + private function makeUser(MakerTestRunner $runner, string $identifier = 'email'): void + { + $runner->runConsole('make:user', [ + 'User', // Class Name + 'y', // Create as Entity + $identifier, // Property used to identify the user, + 'y', // Uses a password + ]); + } +} diff --git a/tests/fixtures/security/make-form-login/LoginTest.php b/tests/fixtures/security/make-form-login/LoginTest.php new file mode 100644 index 000000000..beeae4ac8 --- /dev/null +++ b/tests/fixtures/security/make-form-login/LoginTest.php @@ -0,0 +1,63 @@ +request('GET', '/login'); + + self::assertResponseStatusCodeSame(200, 'Failed to get \'/login\'.'); + + $em = static::getContainer()->get('doctrine')->getManager(); + + $user = (new User()) + ->setEmail('jr@rushlow.dev') + ->setPassword('wordpass') + ; + + $em->persist($user); + $em->flush(); + + $form = $crawler->filter('form')->form(); + + $form->setValues([ + '_username' => 'jr@rushlow.dev', + '_password' => 'badPass', + ]); + + + $client->submit($form); + + self::assertResponseStatusCodeSame(302, 'Login with bad password is not status 302'); + $client->followRedirect(); + self::assertResponseStatusCodeSame(200, 'Problem with redirect after entering a bad password.'); + self::assertStringContainsString('Invalid credentials', $client->getResponse()->getContent()); + + $form->setValues([ + '_username' => 'jr@rushlow.dev', + '_password' => 'wordpass', + ]); + + $client->submit($form); + + self::assertInstanceOf(User::class, $this->getToken()->getUser()); + + $client->request('GET', '/logout'); + self::assertNull($this->getToken()); + } + + private function getToken(): ?TokenInterface + { + $tokenStorage = static::getContainer()->get('security.token_storage'); + $tokenStorage->disableUsageTracking(); + + return $tokenStorage->getToken(); + } +} diff --git a/tests/fixtures/security/make-form-login/expected/LoginController.php b/tests/fixtures/security/make-form-login/expected/LoginController.php new file mode 100644 index 000000000..5355f737f --- /dev/null +++ b/tests/fixtures/security/make-form-login/expected/LoginController.php @@ -0,0 +1,32 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('login/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } + + #[Route(path: '/logout', name: 'app_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/tests/fixtures/security/make-form-login/expected/SecurityController.php b/tests/fixtures/security/make-form-login/expected/SecurityController.php new file mode 100644 index 000000000..5e4022d14 --- /dev/null +++ b/tests/fixtures/security/make-form-login/expected/SecurityController.php @@ -0,0 +1,32 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } + + #[Route(path: '/logout', name: 'app_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/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php b/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php new file mode 100644 index 000000000..b3640590a --- /dev/null +++ b/tests/fixtures/security/make-form-login/expected/SecurityControllerWithoutLogout.php @@ -0,0 +1,26 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } +} diff --git a/tests/fixtures/security/make-form-login/expected/login.html.twig b/tests/fixtures/security/make-form-login/expected/login.html.twig new file mode 100644 index 000000000..916c1d326 --- /dev/null +++ b/tests/fixtures/security/make-form-login/expected/login.html.twig @@ -0,0 +1,42 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + + {% if app.user %} +
+ You are logged in as {{ app.user.userIdentifier }}, Logout +
+ {% endif %} + +

Please sign in

+ + + + + + + + {# + Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. + See https://symfony.com/doc/current/security/remember_me.html + +
+ +
+ #} + + +
+{% endblock %} diff --git a/tests/fixtures/security/make-form-login/expected/login_no_logout.html.twig b/tests/fixtures/security/make-form-login/expected/login_no_logout.html.twig new file mode 100644 index 000000000..01d2031ae --- /dev/null +++ b/tests/fixtures/security/make-form-login/expected/login_no_logout.html.twig @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + +

Please sign in

+ + + + + + + + {# + Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. + See https://symfony.com/doc/current/security/remember_me.html + +
+ +
+ #} + + +
+{% endblock %}