From 8f640f47ec629a7eb343dd108f62ad363f4a41e9 Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 6 Feb 2025 17:37:28 +0100 Subject: [PATCH] Add in flight authentication limit https://github.com/OpenConext/OpenConext-engineblock/issues/1345 --- app/config/parameters.yml.dist | 1 + ci/qa/behat.sh | 2 +- languages/messages.en.php | 2 + languages/messages.nl.php | 2 + languages/messages.pt.php | 2 + .../AuthenticationLoopGuard.php | 34 +++++++--- .../AuthenticationLoopGuardInterface.php | 11 ++-- .../Authentication/AuthenticationState.php | 18 ++++++ .../Controller/FeedbackController.php | 8 +++ ...edirectToFeedbackPageExceptionListener.php | 10 ++- ...nticationSessionLimitExceededException.php | 25 ++++++++ .../Resources/config/routing/feedback.yml | 5 ++ .../Resources/config/services.yml | 1 + .../Features/AuthenticationLoop.feature | 37 ++++++++--- .../Features/Context/EngineBlockContext.php | 13 ++-- ...nctionalTestingAuthenticationLoopGuard.php | 63 ++++++++++++++----- .../authentication-limit-exceeded.html.twig | 8 +++ 17 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 src/OpenConext/EngineBlockBundle/Exception/AuthenticationSessionLimitExceededException.php create mode 100644 theme/base/templates/modules/Authentication/View/Feedback/authentication-limit-exceeded.html.twig diff --git a/app/config/parameters.yml.dist b/app/config/parameters.yml.dist index 2687881fc1..6e9773da7a 100644 --- a/app/config/parameters.yml.dist +++ b/app/config/parameters.yml.dist @@ -193,6 +193,7 @@ parameters: ## Settings for detecting whether the user is stuck in a authentication loop within his session time_frame_for_authentication_loop_in_seconds: 60 maximum_authentication_procedures_allowed: 5 + maximum_authentications_per_session: 20 ## Store attributes with their values, meaning that if an Idp suddenly ## sends a new value (like a new e-mail address) consent has to be diff --git a/ci/qa/behat.sh b/ci/qa/behat.sh index 6330387e4b..d542fa36ba 100755 --- a/ci/qa/behat.sh +++ b/ci/qa/behat.sh @@ -23,7 +23,7 @@ chown -R www-data app/cache/ chmod -R 0777 /tmp/eb-fixtures echo -e "\nRun the Behat tests\n" -./vendor/bin/behat -c ./tests/behat-ci.yml --suite default -vv --format progress --strict +./vendor/bin/behat -c ./tests/behat-ci.yml --suite default -vv --format progress --strict $@ #echo -e "\nBehat tests (with selenium and headless Chrome)\n" #./vendor/bin/behat -c ./tests/behat-ci.yml --suite selenium -vv --format progress --strict diff --git a/languages/messages.en.php b/languages/messages.en.php index 5b335cb160..0961cb487a 100644 --- a/languages/messages.en.php +++ b/languages/messages.en.php @@ -222,6 +222,8 @@ 'error_stuck_in_authentication_loop_desc_no_idp_name' => 'You\'ve successfully authenticated at your %organisationNoun% but %spName% sends you back again to %suiteName%. Because you are already logged in, %suiteName% then sends you back to %spName%, which results in an infinite black hole. Likely, this is caused by an error at %spName%.', 'error_stuck_in_authentication_loop_desc_no_sp_name' => 'You\'ve successfully authenticated at %idpName% but the service you are trying to access sends you back again to %suiteName%. Because you are already logged in, %suiteName% then sends you back to the service, which results in an infinite black hole. Likely, this is caused by an error at the Service Provider.', 'error_stuck_in_authentication_loop_desc_no_name' => 'You\'ve successfully authenticated at your %organisationNoun% but the service you are trying to access sends you back again to %suiteName%. Because you are already logged in, %suiteName% then sends you back to the service, which results in an infinite black hole. Likely, this is caused by an error at the Service Provider.', + 'error_authentication_limit_exceeded' => 'Error - too many authentications in progress', + 'error_authentication_limit_exceeded_desc' => 'Too many authentications in progress', 'error_no_authentication_request_received' => 'Error - No authentication request received.', 'error_authn_context_class_ref_blacklisted' => 'Error - AuthnContextClassRef value is not allowed', 'error_authn_context_class_ref_blacklisted_desc' => 'You cannot login because %idpName% sent a value for AuthnContextClassRef that is not allowed. Please contact the service desk of %idpName% to solve this.', diff --git a/languages/messages.nl.php b/languages/messages.nl.php index aa29bbf6d0..d7e59c386a 100644 --- a/languages/messages.nl.php +++ b/languages/messages.nl.php @@ -219,6 +219,8 @@ 'error_stuck_in_authentication_loop_desc_no_idp_name' => 'Je bent succesvol ingelogd bij je %organisationNoun% maar %spName% stuurt je weer terug naar %suiteName%. Omdat je succesvol bent ingelogd, stuurt %suiteName% je weer naar %spName%, wat resulteert in een oneindig zwart gat. Dit komt waarschijnlijk door een foutje aan de kant van %spName%.', 'error_stuck_in_authentication_loop_desc_no_sp_name' => 'Je bent succesvol ingelogd bij %idpName% maar de dienst waar je naartoe wilt stuurt je weer terug naar %suiteName%. Omdat je succesvol bent ingelogd, stuurt %suiteName% je weer naar de dienst, wat resulteert in een oneindig zwart gat. Dit komt waarschijnlijk door een foutje aan de kant van de dienst.', 'error_stuck_in_authentication_loop_desc_no_name' => 'Je bent succesvol ingelogd bij je %organisationNoun% maar de dienst waar je naartoe wilt stuurt je weer terug naar %suiteName%. Omdat je succesvol bent ingelogd, stuurt %suiteName% je weer naar de dienst, wat resulteert in een oneindig zwart gat. Dit komt waarschijnlijk door een foutje aan de kant van de dienst.', + 'error_authentication_limit_exceeded' => 'Fout - teveeel authenticaties bezig', + 'error_authentication_limit_exceeded_desc' => 'Teveel authenticaties bezig', 'error_no_authentication_request_received' => 'Fout - Geen authenticatie-aanvraag ontvangen.', 'error_authn_context_class_ref_blacklisted' => 'Fout - Waarde van AuthnContextClassRef is niet toegestaan', 'error_authn_context_class_ref_blacklisted_desc' => 'Je kunt niet inloggen omdat %idpName% een waarde stuurde voor AuthnContextClassRef die niet is toegestaan. Neem contact op met de helpdesk van %idpName% om dit op te lossen.', diff --git a/languages/messages.pt.php b/languages/messages.pt.php index f3f68ab93c..cc7c40ee0d 100644 --- a/languages/messages.pt.php +++ b/languages/messages.pt.php @@ -216,6 +216,8 @@ 'error_stuck_in_authentication_loop_desc_no_idp_name' => 'Autenticou-se com sucesso no seu Fornecedor de Identidade, mas o %spName% reencaminhou-o de volta para %suiteName%. Como já está autenticado, o %suiteName% o reencaminha de volta para o %spName%, o que resulta num ciclo infinito. Muito provavelmente, isto é provocado por um erro no %spName%.', 'error_stuck_in_authentication_loop_desc_no_sp_name' => 'Autenticou-se com sucesso no seu %idpName%, mas o serviço reencaminhou-o de volta para %suiteName%. Como já está autenticado, o %suiteName% o reencaminha de volta para o serviço, o que resulta num ciclo infinito. Muito provavelmente, isto é provocado por um erro no Fornecedor de Serviço.', 'error_stuck_in_authentication_loop_desc_no_name' => 'Autenticou-se com sucesso no seu Fornecedor de Identidade, mas o serviço ao qual está a tentar aceder reencaminhou-o de volta para %suiteName%. Como já está autenticado, o %suiteName% o reencaminha de volta para o serviço, o que resulta num ciclo infinito. Muito provavelmente, isto é provocado por um erro no Fornecedor de Serviço.', + 'error_authentication_limit_exceeded' => 'Error - too many authentications in progress', + 'error_authentication_limit_exceeded_desc' => 'Too many authentications in progress', 'error_authn_context_class_ref_blacklisted' => 'Erro - O valor para AuthnContextClassRef não é permitido', 'error_authn_context_class_ref_blacklisted_desc' => '

Não pode autenticar-se porque a %idpName% enviou um valor para AuthnContextClassRef que não é permitido.

', 'error_authn_context_class_ref_blacklisted_desc_no_idp_name' => '

Não pode autenticar-se porque a sua %organisationNoun% enviou um valor para AuthnContextClassRef que não é permitido.

', diff --git a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuard.php b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuard.php index f5292dd5ff..410820869c 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuard.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuard.php @@ -34,9 +34,15 @@ final class AuthenticationLoopGuard implements AuthenticationLoopGuardInterface */ private $timeFrameForAuthenticationLoopInSeconds; + /** + * @var int + */ + private $maximumAuthenticationsPerSession; + public function __construct( $maximumAuthenticationProceduresAllowed, - $timeFrameForAuthenticationLoopInSeconds + $timeFrameForAuthenticationLoopInSeconds, + $maximumAuthenticationsPerSession ) { Assertion::integer( $maximumAuthenticationProceduresAllowed, @@ -46,20 +52,20 @@ public function __construct( $timeFrameForAuthenticationLoopInSeconds, 'Expected time frame for determining authentication loop in seconds to be an integer, got "%s"' ); + Assertion::integer( + $maximumAuthenticationsPerSession, + 'Expected maximum authentication per session to be an integer, got "%s"' + ); $this->maximumAuthenticationProceduresAllowed = $maximumAuthenticationProceduresAllowed; $this->timeFrameForAuthenticationLoopInSeconds = $timeFrameForAuthenticationLoopInSeconds; + $this->maximumAuthenticationsPerSession = $maximumAuthenticationsPerSession; } - /** - * @param Entity $serviceProvider - * @param AuthenticationProcedureMap $pastAuthenticationProcedures - * @return bool - */ public function detectsAuthenticationLoop( Entity $serviceProvider, AuthenticationProcedureMap $pastAuthenticationProcedures - ) { + ): bool { $now = new DateTimeImmutable; $startDate = $now->modify(sprintf('-%d seconds', $this->timeFrameForAuthenticationLoopInSeconds)); @@ -70,4 +76,18 @@ public function detectsAuthenticationLoop( return $processedLoginProcedures >= $this->maximumAuthenticationProceduresAllowed; } + + + /** + * @param AuthenticationProcedureMap $pastAuthenticationProcedures + * @return bool + */ + public function detectsAuthenticationLimit( + AuthenticationProcedureMap $pastAuthenticationProcedures + ): bool { + $processedLoginProcedures = $pastAuthenticationProcedures + ->count(); + + return $processedLoginProcedures >= $this->maximumAuthenticationsPerSession; + } } diff --git a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuardInterface.php b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuardInterface.php index e7ea6cb38a..503247f175 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuardInterface.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationLoopGuardInterface.php @@ -22,13 +22,12 @@ interface AuthenticationLoopGuardInterface { - /** - * @param Entity $serviceProvider - * @param AuthenticationProcedureMap $pastAuthenticationProcedures - * @return - */ public function detectsAuthenticationLoop( Entity $serviceProvider, AuthenticationProcedureMap $pastAuthenticationProcedures - ); + ): bool; + + public function detectsAuthenticationLimit( + AuthenticationProcedureMap $pastAuthenticationProcedures + ): bool; } diff --git a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationState.php b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationState.php index eb5aba1a98..acd3fa2b62 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationState.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/AuthenticationState.php @@ -21,6 +21,7 @@ use Assert\AssertionFailedException; use DateTimeImmutable; use OpenConext\EngineBlock\Assert\Assertion; +use OpenConext\EngineBlockBundle\Exception\AuthenticationSessionLimitExceededException; use OpenConext\EngineBlockBundle\Exception\LogicException; use OpenConext\EngineBlockBundle\Exception\StuckInAuthenticationLoopException; use OpenConext\Value\Saml\Entity; @@ -54,6 +55,23 @@ public function startAuthenticationOnBehalfOf(string $requestId, Entity $service Assertion::string($requestId, 'The requestId must be a string (XML ID) value'); $currentAuthenticationProcedure = AuthenticationProcedure::onBehalfOf($serviceProvider); + // Validate if the processed authentications this session do not exceed the configured maximum of authentications + $authenticationLimitExceeded = $this->authenticationLoopGuard->detectsAuthenticationLimit( + $this->authenticationProcedures + ); + + if ($authenticationLimitExceeded) { + session_destroy(); + + throw new AuthenticationSessionLimitExceededException( + 'More than the configured maximum authentication procedures for this session' + . ' the user seems to have started too much authentications this session. ' + . ' Resetting the session.' + ); + } + + // Validate if the processed authentications for the service provider for this session do not exceed + // the configured maximum authentications in a configured time frame. $inAuthenticationLoop = $this->authenticationLoopGuard->detectsAuthenticationLoop( $serviceProvider, $this->authenticationProcedures diff --git a/src/OpenConext/EngineBlockBundle/Controller/FeedbackController.php b/src/OpenConext/EngineBlockBundle/Controller/FeedbackController.php index a41437ff95..d23687a2e9 100644 --- a/src/OpenConext/EngineBlockBundle/Controller/FeedbackController.php +++ b/src/OpenConext/EngineBlockBundle/Controller/FeedbackController.php @@ -454,6 +454,14 @@ public function stuckInAuthenticationLoopAction() ); } + public function authenticationLimitExceededAction() + { + return new Response( + $this->twig->render('@theme/Authentication/View/Feedback/authentication-limit-exceeded.html.twig'), + 429 + ); + } + /** * @return Response */ diff --git a/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php b/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php index aa17af6f18..c6cad97041 100644 --- a/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php +++ b/src/OpenConext/EngineBlockBundle/EventListener/RedirectToFeedbackPageExceptionListener.php @@ -47,6 +47,7 @@ use OpenConext\EngineBlock\Exception\InvalidRequestMethodException; use OpenConext\EngineBlock\Exception\MissingParameterException; use OpenConext\EngineBlockBridge\ErrorReporter; +use OpenConext\EngineBlockBundle\Exception\AuthenticationSessionLimitExceededException; use OpenConext\EngineBlockBundle\Exception\EntityCanNotBeFoundException; use OpenConext\EngineBlockBundle\Exception\StuckInAuthenticationLoopException; use OpenConext\EngineBlockBundle\Exception\UnknownKeyIdException; @@ -192,6 +193,9 @@ public function onKernelException(GetResponseForExceptionEvent $event) } elseif ($exception instanceof StuckInAuthenticationLoopException) { $message = 'Stuck in authentication loop'; $redirectToRoute = 'authentication_feedback_stuck_in_authentication_loop'; + } elseif ($exception instanceof AuthenticationSessionLimitExceededException) { + $message = 'Authentication procedure limit exceeded'; + $redirectToRoute = 'authentication_feedback_authentication_limit_exceeded'; } elseif ($exception instanceof InvalidRequestMethodException || $exception instanceof InvalidBindingException || $exception instanceof MissingParameterException @@ -205,13 +209,13 @@ public function onKernelException(GetResponseForExceptionEvent $event) } elseif ($exception instanceof EngineBlock_Corto_Exception_UserCancelledStepupCallout) { $message = $exception->getMessage(); $redirectToRoute = 'authentication_feedback_stepup_callout_user_cancelled'; - } else if ($exception instanceof EngineBlock_Corto_Exception_InvalidStepupLoaLevel) { + } elseif ($exception instanceof EngineBlock_Corto_Exception_InvalidStepupLoaLevel) { $message = $exception->getMessage(); $redirectToRoute = 'authentication_feedback_stepup_callout_unmet_loa'; - } else if ($exception instanceof EngineBlock_Corto_Exception_InvalidStepupCalloutResponse) { + } elseif ($exception instanceof EngineBlock_Corto_Exception_InvalidStepupCalloutResponse) { $message = $exception->getMessage(); $redirectToRoute = 'authentication_feedback_stepup_callout_unknown'; - } else if ($exception instanceof EntityCanNotBeFoundException) { + } elseif ($exception instanceof EntityCanNotBeFoundException) { $event->getRequest()->getSession()->set('feedback_custom', $exception->getMessage()); $redirectToRoute = 'authentication_feedback_metadata_entity_not_found'; } else { diff --git a/src/OpenConext/EngineBlockBundle/Exception/AuthenticationSessionLimitExceededException.php b/src/OpenConext/EngineBlockBundle/Exception/AuthenticationSessionLimitExceededException.php new file mode 100644 index 0000000000..729f337277 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Exception/AuthenticationSessionLimitExceededException.php @@ -0,0 +1,25 @@ +authenticationLoopGuard->saveAuthenticationLoopGuardConfiguration( (int) $maximumAuthenticationProceduresAllowed, - (int) $timeFrameForAuthenticationLoopInSeconds + (int) $timeFrameForAuthenticationLoopInSeconds, + (int) $maximumAuthenticationsPerSession ); $this->usingAuthenticationLoopGuard = true; } diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingAuthenticationLoopGuard.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingAuthenticationLoopGuard.php index 395fe095ba..b40aa0ae23 100644 --- a/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingAuthenticationLoopGuard.php +++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/FunctionalTestingAuthenticationLoopGuard.php @@ -21,6 +21,7 @@ use OpenConext\EngineBlockBundle\Authentication\AuthenticationLoopGuard; use OpenConext\EngineBlockBundle\Authentication\AuthenticationLoopGuardInterface; use OpenConext\EngineBlockBundle\Authentication\AuthenticationProcedureMap; +use OpenConext\EngineBlockBundle\Exception\AuthenticationSessionLimitExceededException; use OpenConext\EngineBlockBundle\Exception\StuckInAuthenticationLoopException; use OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\DataStore\AbstractDataStore; use OpenConext\Value\Saml\Entity; @@ -30,7 +31,12 @@ final class FunctionalTestingAuthenticationLoopGuard implements AuthenticationLo /** * @var AuthenticationLoopGuardInterface */ - private $authenticationLoopGuard; + private $originalAuthenticationLoopGuard; + + /** + * @var AuthenticationLoopGuardInterface + */ + private $activeAuthenticationLoopGuard; /** * @var AbstractDataStore @@ -47,7 +53,7 @@ public function __construct( AuthenticationLoopGuardInterface $authenticationLoopGuard, AbstractDataStore $dataStore ) { - $this->authenticationLoopGuard = $authenticationLoopGuard; + $this->originalAuthenticationLoopGuard = $authenticationLoopGuard; $this->dataStore = $dataStore; $this->authenticationGuardFixture = $dataStore->load(false); @@ -56,14 +62,16 @@ public function __construct( /** * @param int $maximumAuthenticationProceduresAllowed * @param int $timeFrameForAuthenticationLoopInSeconds + * @param int $maximumAuthenticationsPerSession */ public function saveAuthenticationLoopGuardConfiguration( - $maximumAuthenticationProceduresAllowed, - $timeFrameForAuthenticationLoopInSeconds + int $maximumAuthenticationProceduresAllowed, + int $timeFrameForAuthenticationLoopInSeconds, + int $maximumAuthenticationsPerSession ) { $this->authenticationGuardFixture['maximumAuthenticationProceduresAllowed'] = $maximumAuthenticationProceduresAllowed; - $this->authenticationGuardFixture['timeFrameForAuthenticationLoopInSeconds'] - = $timeFrameForAuthenticationLoopInSeconds; + $this->authenticationGuardFixture['timeFrameForAuthenticationLoopInSeconds'] = $timeFrameForAuthenticationLoopInSeconds; + $this->authenticationGuardFixture['maximumAuthenticationsPerSession'] = $maximumAuthenticationsPerSession; $this->dataStore->save($this->authenticationGuardFixture); } @@ -75,17 +83,10 @@ public function saveAuthenticationLoopGuardConfiguration( public function detectsAuthenticationLoop( Entity $serviceProvider, AuthenticationProcedureMap $pastAuthenticationProcedures - ) { - if ($this->authenticationGuardFixture === false) { - $authenticationLoopGuard = $this->authenticationLoopGuard; - } else { - $authenticationLoopGuard = new AuthenticationLoopGuard( - $this->authenticationGuardFixture['maximumAuthenticationProceduresAllowed'], - $this->authenticationGuardFixture['timeFrameForAuthenticationLoopInSeconds'] - ); - } + ): bool { + $this->initAuthenticationLoopGuard(); - if ($authenticationLoopGuard->detectsAuthenticationLoop($serviceProvider, $pastAuthenticationProcedures)) { + if ($this->activeAuthenticationLoopGuard->detectsAuthenticationLoop($serviceProvider, $pastAuthenticationProcedures)) { throw new StuckInAuthenticationLoopException( sprintf( 'More than the configured maximum authentication procedures for the current user from SP "%s"' @@ -96,11 +97,41 @@ public function detectsAuthenticationLoop( ) ); } + + return false; + } + + public function detectsAuthenticationLimit(AuthenticationProcedureMap $pastAuthenticationProcedures): bool + { + $this->initAuthenticationLoopGuard(); + + if ($this->activeAuthenticationLoopGuard->detectsAuthenticationLimit($pastAuthenticationProcedures)) { + throw new AuthenticationSessionLimitExceededException( + 'More than the configured maximum authentication procedures for the current user' + . ' the user seems to be stuck in an authentication loop. ' + . ' Aborting the current authentication procedure.' + ); + } + + return false; } public function cleanUp() { $this->authenticationGuardFixture = []; + $this->activeAuthenticationLoopGuard = $this->originalAuthenticationLoopGuard; $this->dataStore->save($this->authenticationGuardFixture); } + + private function initAuthenticationLoopGuard(): void { + if (!$this->authenticationGuardFixture) { + $this->activeAuthenticationLoopGuard = $this->originalAuthenticationLoopGuard; + } + + $this->activeAuthenticationLoopGuard = new AuthenticationLoopGuard( + $this->authenticationGuardFixture['maximumAuthenticationProceduresAllowed'], + $this->authenticationGuardFixture['timeFrameForAuthenticationLoopInSeconds'], + $this->authenticationGuardFixture['maximumAuthenticationsPerSession'] + ); + } } diff --git a/theme/base/templates/modules/Authentication/View/Feedback/authentication-limit-exceeded.html.twig b/theme/base/templates/modules/Authentication/View/Feedback/authentication-limit-exceeded.html.twig new file mode 100644 index 0000000000..cbb1c57e93 --- /dev/null +++ b/theme/base/templates/modules/Authentication/View/Feedback/authentication-limit-exceeded.html.twig @@ -0,0 +1,8 @@ +{% extends '@theme/Default/View/Error/error.html.twig' %} + +{% set pageTitle = 'error_authentication_limit_exceeded'|trans %} +{% block pageTitle %}{{ pageTitle }}{% endblock %} +{% block title %}{{ parent() }}{% endblock %} +{% block pageHeading %}{{ pageTitle }}{% endblock %} + +{% block errorMessage %}{{ 'error_authentication_limit_exceeded_desc'|trans }}{% endblock %}