From 5a85165513445534df2a8bb63588a176b75f87de Mon Sep 17 00:00:00 2001 From: "renater.salaun" <renater.salaun@047e039d-479c-447e-8a29-aa6bf4a09bab> Date: Mon, 29 Sep 2014 12:49:40 +0000 Subject: [PATCH] Loading federation SAML metadata to build the drop-down menu for SP selection git-svn-id: https://svn.geant.net/GEANT/edugain_testidp_account_manager/trunk@9 047e039d-479c-447e-8a29-aa6bf4a09bab --- bin/account-manager-client.pl | 23 +- bin/account-manager-web.pl | 56 +++- lib/IdPAccountManager/SAMLMetadata.pm | 300 ++++++++++++++++++ lib/IdPAccountManager/Tools.pm | 29 +- .../mail/notification_generic_error.tt2.eml | 3 +- templates/web/content.tt2.html | 9 +- templates/web/get_sp_list.tt2.html | 17 + templates/web/index.tt2.html | 18 +- 8 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 lib/IdPAccountManager/SAMLMetadata.pm create mode 100644 templates/web/get_sp_list.tt2.html diff --git a/bin/account-manager-client.pl b/bin/account-manager-client.pl index 845ea10..c43dc98 100755 --- a/bin/account-manager-client.pl +++ b/bin/account-manager-client.pl @@ -11,9 +11,10 @@ use Getopt::Long; use POSIX; use IdPAccountManager::TestAccount; +use IdPAccountManager::SAMLMetadata; my %options; -unless (&GetOptions(\%options, 'help', 'create_test_account', 'account_profile=s', 'sp_entityid=s', 'list_test_accounts')) { +unless (&GetOptions(\%options, 'help', 'create_test_account', 'account_profile=s', 'sp_entityid=s', 'list_test_accounts', 'parse_federation_metadata')) { die "Unknown options."; } @@ -62,4 +63,24 @@ if ($options{'create_test_account'}) { foreach my $test_account (@$all) { $test_account->print(); } +}elsif ($options{'parse_federation_metadata'}) { + my $federation_metadata = new IdPAccountManager::SAMLMetadata; + unless ($federation_metadata->load(federation_metadata_file_path => $IdPAccountManager::Conf::global{'federation_metadata_file_path'})) { + die; + } + + unless ($federation_metadata->parse()) { + die; + } + + printf "Document %s parsed\n", $IdPAccountManager::Conf::global{'federation_metadata_file_path'}; + + ## List SAML entities + printf "Hashref representing the metadata:\n"; + &IdPAccountManager::Tools::dump_var($federation_metadata->{'federation_metadata_as_hashref'}, 0, \*STDOUT); + + +}else { + die "Missing arguments"; + } diff --git a/bin/account-manager-web.pl b/bin/account-manager-web.pl index d04e310..6f1b748 100755 --- a/bin/account-manager-web.pl +++ b/bin/account-manager-web.pl @@ -2,9 +2,8 @@ ## 15/09/2014, Olivier Salaün ## Web interface for the eduGAIN Test IdP Account Manager -## TODO : rename %erreurs e -use strict; +use strict vars; use utf8; use lib "/opt/testidp/IdPAccountManager/lib"; @@ -16,6 +15,7 @@ use Template::Constants qw( :debug ); use POSIX; use IdPAccountManager::TestAccount; +use IdPAccountManager::SAMLMetadata; ## Defining parameters format my $urn_or_url_regex = '(http(s?):\/\/|urn:)[^\\\$\*\"\'\`\^\|\<\>\n\s]+'; ## Format de type URL HTTP ou URN @@ -27,7 +27,8 @@ my %format = ( #'attributeauthority' => $url_regex, ); -my %actions = ('select_sp' => {'title_en' => 'Select your Service Provider' } +my %actions = ('select_sp' => {'title_en' => 'Select your Service Provider' }, + 'get_sp_list' => {'title_en' => 'Select your Service Provider' }, ); ## Gives writes for the group @@ -100,7 +101,7 @@ sub new { }else { ## Default action &IdPAccountManager::Tools::do_log('info', "Default action"); - $request->{'action'} = 'help'; + $request->{'action'} = 'get_sp_list'; } bless $request, $pkg; @@ -121,7 +122,7 @@ sub execute { defined $format{$key} && ! ref($format{$key})) { unless ($self->{'param_in'}{$key} =~ /^$format{$key}$/) { - push @{$self->{'param_out'}{'erreurs'}}, "format_$key"; + push @{$self->{'param_out'}{'errors'}}, "format_$key"; &IdPAccountManager::Tools::do_log('error', "Incorrect parameter format : $key"); return undef; } @@ -141,7 +142,7 @@ sub execute { }else { ## Inknown action - push @{$self->{'param_out'}{'erreurs'}}, "unknown_action"; + push @{$self->{'param_out'}{'errors'}}, "unknown_action"; &IdPAccountManager::Tools::do_log('error', "Unknown action '%s'", $self->{'action'}); } @@ -162,6 +163,9 @@ sub respond { ## Dump output data #open TMP, ">/tmp/account_registry.out"; &IdPAccountManager::Tools::dump_var($self->{'param_out'}, 0, \*TMP); close TMP; + ## Enable dumping off all variables in web pages + #$self->{'param_out'}{'dump'} = $self->{'param_out'}; + ## Automatic pass object entries to the output hash foreach my $key (keys %{$self}) { #&IdPAccountManager::Tools::do_log('trace', "Passing $key"); @@ -195,17 +199,37 @@ sub respond { } ## Ignore some type of errors - my @erreurs_admin; - foreach my $id_erreur (@{$self->{'param_out'}{'erreurs'}}) { - unless ($id_erreur =~ /^(error_x)$/) { - push @erreurs_admin, $id_erreur; - } - } + #my @errors_admin; + #foreach my $id_error (@{$self->{'param_out'}{'errors'}}) { + # unless ($id_error =~ /^(error_x)$/) { + # push @errors_admin, $id_error; + # } + #} ## Mail notification of admins about the error - if (@erreurs_admin) { - &IdPAccountManager::Tools::mail_notice('template' => 'templates/mail/notification_generic_error.tt2.eml', + &IdPAccountManager::Tools::mail_notice('template' => 'templates/mail/notification_generic_error.tt2.eml', 'data' => $self->{'param_out'}); - } -} \ No newline at end of file +} + +## Return the list of known SPs +sub req_get_sp_list { + my $self = shift; + + my $federation_metadata = new IdPAccountManager::SAMLMetadata; + unless ($federation_metadata->load(federation_metadata_file_path => $IdPAccountManager::Conf::global{'federation_metadata_file_path'})) { + push @{$self->{'param_out'}{'errors'}}, "internal"; + &IdPAccountManager::Tools::do_log('error', "Failed to load federation metadata : $!"); + return undef; + } + + unless ($federation_metadata->parse()) { + push @{$self->{'param_out'}{'errors'}}, "internal"; + &IdPAccountManager::Tools::do_log('error', "Failed to parse federation metadata : $!"); + return undef; + } + + $self->{'param_out'}{'federation_metadata_as_hashref'} = $federation_metadata->{'federation_metadata_as_hashref'}; + + return 1; +} diff --git a/lib/IdPAccountManager/SAMLMetadata.pm b/lib/IdPAccountManager/SAMLMetadata.pm new file mode 100644 index 0000000..d8ce694 --- /dev/null +++ b/lib/IdPAccountManager/SAMLMetadata.pm @@ -0,0 +1,300 @@ +package IdPAccountManager::SAMLMetadata; + +use strict; + +use IdPAccountManager::Tools; +use IdPAccountManager::Conf; + +use XML::LibXML; + +require Exporter; +my @ISA = qw(Exporter); +my @EXPORT = qw(); + +use Carp; + +sub new { + my ($pkg) = shift; + my %args = @_; + + my $self = {}; + + ## Bless SAMLMetadata object + bless $self, $pkg; + + return $self; +} + +## Load metadata +sub load { + my $self = shift; + my %in = @_; + + unless ($in{'federation_metadata_file_path'}) { + &IdPAccountManager::Tools::do_log('error', "Missing parameter 'federation_metadata_file_path'"); + return undef; + } + + $self->{'federation_metadata_file_path'} = $in{'federation_metadata_file_path'}; + + unless (-r $self->{'federation_metadata_file_path'}) { + &IdPAccountManager::Tools::do_log('error', "Failed to read $in{'federation_metadata_file_path'} : $!"); + return undef; + } + + unless ($self->{'federation_metadata_as_xml'} = &_get_xml_object($in{'federation_metadata_file_path'})) { + &IdPAccountManager::Tools::do_log('error', "Failed to parse file $in{'metadata_file'} : $!"); + return undef; + } + + my $root = $self->{'federation_metadata_as_xml'}->documentElement(); + unless ($root->nodeName() =~ /EntitiesDescriptor$/) { + &IdPAccountManager::Tools::do_log('error', "Root element of file $in{'federation_metadata_file_path'} is of type '%s'; should be 'EntitiesDescriptor'", + $root->nodeName()); + return undef; + } + + return 1; +} + +## Parse XML structure of metadata to fill a hashref +sub parse { + my $self = shift; + + $self->{'federation_metadata_as_hashref'} = &_parse_saml_metadata('metadata_as_xml' => $self->{'federation_metadata_as_xml'}, + 'filter_entity_type' => 'sp'); + unless (defined $self->{'federation_metadata_as_hashref'}) { + &IdPAccountManager::Tools::do_log('error', "Failed to parse federation metadata"); + return undef; + } + + return 1; +} + +## Print the content of a test account +sub print { + my $self = shift; + my $fd = shift || \*STDOUT; + + my $root = $self->{'federation_metadata_as_xml'}->documentElement(); + print $fd $root->toString(); + + return 1. +} + +## Internal function +## returns a Lib::XML représenting an XML file +sub _get_xml_object { + my $metadata_file = shift; + &IdPAccountManager::Tools::do_log('debug', ""); + + unless (-f $metadata_file) { + &IdPAccountManager::Tools::do_log('error', "File $metadata_file not found: $!"); + return undef; + } + + unless (open FH, $metadata_file) { + &IdPAccountManager::Tools::do_log('error', "Failed to open file $metadata_file: $!"); + return undef; + } + + my $parser; + unless ($parser = XML::LibXML->new()) { + &IdPAccountManager::Tools::do_log('error', "Failed to initialize XML parser"); + return undef; + } + + $parser->line_numbers(1); + my $doc; + + ## Eval() prevents the parsing from killing the main process + eval {$doc = $parser->parse_fh(\*FH)}; + if ($@) { + &IdPAccountManager::Tools::do_log('error', "Failed to parse file $metadata_file : $@"); + return undef; + } + + unless ($doc) { + &IdPAccountManager::Tools::do_log('error', "Failed to parse file $metadata_file : $!"); + return undef; + } + + return $doc; +} + +## Parse a SAML federation metadata file +sub _parse_saml_metadata { + my %options = @_; + #&IdPAccountManager::Tools::do_log('trace', "%s", join(',',%options)); + + #unless ($options{'filter_entity_type'}) { + #&IdPAccountManager::Tools::do_log('error', "paramètre entity_type manquant"); + #return undef; + #} + + my $root = $options{'metadata_as_xml'}; + + my @extracted_array; + foreach my $EntityDescriptor (@{$root->getElementsByLocalName('EntityDescriptor')}) { + + my $extracted_data = {}; + + if ($EntityDescriptor->hasAttributes()) { + foreach my $attr ($EntityDescriptor->getAttribute('entityID')) { + $extracted_data->{'entityid'} = $attr; + } + } + + next if ($options{'filter_entity_id'} && ($options{'filter_entity_id'} ne $extracted_data->{'entityid'})); + + #&IdPAccountManager::Tools::do_log('trace', "EntityId: %s - Cherche %s", $extracted_data->{'entityid'}, $options{'filter_entity_id'}); + + $extracted_data->{'xml_md'} = &IdPAccountManager::Tools::escape_xml($EntityDescriptor->toString()); + + #&IdPAccountManager::Tools::do_log('trace', "EntityId: %s", $extracted_data->{'entityid'}); + #&IdPAccountManager::Tools::do_log('trace', "Entity dump: %s", $EntityDescriptor->toString()); + + foreach my $child ($EntityDescriptor->childNodes()) { + + ## Ignoringnodes of type XML::LibXML::Text or XML::LibXML::Comment + next unless (ref($child) =~ /^XML::LibXML::Element/); + + if ($child->nodeName =~ /IDPSSODescriptor$/) { + + $extracted_data->{'type'} = 'idp'; + + foreach my $sso ($child->getElementsByLocalName('SingleSignOnService')) { + + ## On ne prend en compte que les endpoints prévus + #next unless ($sso->getAttribute('Binding') && defined $supported_saml_bindings{$sso->getAttribute('Binding')}); + + ## On extrait les infos sur les endpoints + push @{$extracted_data->{'idp_endpoints'}}, + {'type' => 'SingleSignOnService', + 'binding' => $sso->getAttribute('Binding'), + 'location' => $sso->getAttribute('Location'), + }; + + } + + ## Getting domains declared for scoped attributes + foreach my $scope ($child->getElementsByLocalName('Scope')) { + push @{$extracted_data->{'domain'}}, $scope->textContent(); + } + + }elsif ($child->nodeName =~ /SPSSODescriptor$/) { + + $extracted_data->{'type'} = 'sp'; + + ## We check the Binding of the ACS that should match "urn:oasis:names:tc:SAML:1.0:profiles:browser-post" + ## We also check the index to select the ACS that has the lower index + my ($index_saml1, $index_saml2); + foreach my $sso ($child->getElementsByLocalName('AssertionConsumerService')) { + + ## Extracting endpoints information + push @{$extracted_data->{'sp_endpoints'}}, + {'type' => 'AssertionConsumerService', + 'binding' => $sso->getAttribute('Binding'), + 'location' => $sso->getAttribute('Location'), + 'index' => $sso->getAttribute('index'), + 'isdefault' => &IdPAccountManager::Tools::boolean2integer($sso->getAttribute('isDefault')) + }; + #&IdPAccountManager::Tools::do_log('trace', "Endpoint: type:%s ; binding=%s ; location=%s ; index=%s ; isdefault=%s", 'AssertionConsumerService', $sso->getAttribute('Binding'), $sso->getAttribute('Location'), $sso->getAttribute('index'), $sso->getAttribute('isDefault')); + } + + foreach my $requestedattribute ($child->getElementsByLocalName('RequestedAttribute')) { + + ## Requested attributes information + push @{$extracted_data->{'requested_attribute'}}, + {'friendly_name' => &IdPAccountManager::Tools::encode_utf8($requestedattribute->getAttribute('FriendlyName')), + 'name' => &IdPAccountManager::Tools::encode_utf8($requestedattribute->getAttribute('Name')), + 'is_required' => &IdPAccountManager::Tools::boolean2integer($requestedattribute->getAttribute('isRequired')) + }; + } + + }elsif ($child->nodeName =~ /Extensions$/) { + #&IdPAccountManager::Tools::do_log('trace', "Extensions for %s", $extracted_data->{'entityid'}); + foreach my $registrationinfo ($child->getElementsByLocalName('RegistrationInfo')) { + + $extracted_data->{'registration_info'}{'registration_authority'} = $registrationinfo->getAttribute('registrationAuthority'); + $extracted_data->{'registration_info'}{'registration_instant'} = $registrationinfo->getAttribute('registrationInstant'); + foreach my $registrationpolicy ($registrationinfo->getElementsByLocalName('RegistrationPolicy')) { + if ($registrationpolicy->getAttribute('lang') eq 'en') { + $extracted_data->{'registration_info'}{'registration_policy'} = &IdPAccountManager::Tools::encode_utf8($registrationpolicy->textContent()); + } + } + } + }elsif ($child->nodeName =~ /ContactPerson$/) { + my %contact_details; + $contact_details{'type'} = $child->getAttribute('contactType'); + foreach my $contact_child ($child->childNodes()) { + $contact_details{$contact_child->nodeName} = &IdPAccountManager::Tools::encode_utf8($contact_child->textContent()); + } + push @{$extracted_data->{'contacts'}}, \%contact_details; + } + + + foreach my $displayname ($child->getElementsByLocalName('DisplayName')) { + + $extracted_data->{'display_name'}{$displayname->getAttribute('xml:lang')} = &IdPAccountManager::Tools::encode_utf8($displayname->textContent()); + } + + foreach my $description ($child->getElementsByLocalName('Description')) { + + $extracted_data->{'description'}{$description->getAttribute('xml:lang')} = &IdPAccountManager::Tools::encode_utf8($description->textContent()); + } + + foreach my $contact ($child->getElementsByLocalName('ContactPerson')) { + &IdPAccountManager::Tools::do_log('trace', "ContactPerson"); + + my %contact_details; + $contact_details{'type'} = $contact->getAttribute('contactType'); + foreach my $contact_child ($EntityDescriptor->childNodes()) { + &IdPAccountManager::Tools::do_log('trace', "Contact : %s", $contact_child->nodeName); + $contact_details{$contact_child->nodeName} = &IdPAccountManager::Tools::encode_utf8($contact_child->textContent()); + } + push @{$extracted_data->{'contacts'}}, \%contact_details; + } + + foreach my $sso ($child->getElementsByLocalName('OrganizationDisplayName')) { + $extracted_data->{'organization'} = &IdPAccountManager::Tools::encode_utf8($sso->textContent()); + } + + ## Getting X.509 certificates + foreach my $cert ($child->getElementsByLocalName('X509Certificate')) { + $extracted_data->{'certificate'} = &IdPAccountManager::Tools::encode_utf8($cert->textContent()); + } + } + + ## Filter entities based on type + #&IdPAccountManager::Tools::do_log('trace', "Entity type : %s", $extracted_data->{'type'}); + next if (defined $options{'filter_entity_type'} && + ($options{'filter_entity_type'} ne $extracted_data->{'type'})); + + ## Merge domains in a single string + my $domains = join(',',@{$extracted_data->{'domain'}}) if ($extracted_data->{'domain'}); + $extracted_data->{'domain'} = $domains; + #&IdPAccountManager::Tools::do_log('debug', "Scopes : %s", $domains); + + push @extracted_array, $extracted_data; + } + + return \@extracted_array; +} + +1; # Magic true value required at end of module +__END__ + +=head1 NAME + +SAMLMetadata - Perl module loading SAML federation metadata + +=head1 SYNOPSIS + +=head1 DESCRIPTION + +=head1 SUBROUTINES/METHODS + +=head1 AUTHOR + +Olivier Salaün (olivier.salaun@renater.fr) diff --git a/lib/IdPAccountManager/Tools.pm b/lib/IdPAccountManager/Tools.pm index 98b1a09..275e72a 100644 --- a/lib/IdPAccountManager/Tools.pm +++ b/lib/IdPAccountManager/Tools.pm @@ -2,6 +2,7 @@ package IdPAccountManager::Tools; use Template; use Digest::SHA; +use Encode; my %log_levels = ('debug' => 0, 'info' => 1, 'trace' => 1, 'notice' => 2, 'error' => 3); @@ -168,7 +169,7 @@ sub mail_notice { my $tt2 = Template->new(FILTERS => {qencode => [\&qencode, 0]}); unless ($tt2->process($tt2_file, $mail_data, \*SENDMAIL)) { - &do_log('error', "Erreur TT2 : %s", $tt2->error()); + &do_log('error', "Error TT2 : %s", $tt2->error()); } close SENDMAIL; } @@ -190,6 +191,19 @@ sub encode_utf8 ($) { return Encode::encode('utf8', $string); } +## Escape characters that may interfer in an XML document +sub escape_xml { + my $s = shift; + + $s =~ s/\&/&\;/gm; + $s =~ s/\"/"\;/gm; + $s =~ s/\</<\;/gm; + $s =~ s/\>/>\;/gm; + $s =~ s/\'/'/gm; + + return $s; +} + ## usefull to pass parameters to TT2 sub escape_quotes { my $string = shift; @@ -199,6 +213,19 @@ sub escape_quotes { return $string; } +## returns an integer (0 or 1), given an input string ('true' or 'false') +sub boolean2integer { + my $boolean = shift; + + if ($boolean eq 'true') { + return 1; + }elsif ($boolean eq 'false') { + return 0; + } + + return undef; +} + 1; # Magic true value required at end of module __END__ diff --git a/templates/mail/notification_generic_error.tt2.eml b/templates/mail/notification_generic_error.tt2.eml index 9908628..de75659 100644 --- a/templates/mail/notification_generic_error.tt2.eml +++ b/templates/mail/notification_generic_error.tt2.eml @@ -13,7 +13,8 @@ L'URL des méta-données : [% provider.get('metadataurl') %] Check logs for more details [% ELSE %] -Error: [% error_type %] +Error: [% errors.join(',') %] +@IP : [% env.REMOTE_HOST %] / [% env.REMOTE_ADDR %] Check logs for more details [% END %] diff --git a/templates/web/content.tt2.html b/templates/web/content.tt2.html index 16963a7..2d9058c 100644 --- a/templates/web/content.tt2.html +++ b/templates/web/content.tt2.html @@ -2,11 +2,18 @@ [% IF action == 'select_sp' %] [% TRY %] - [% PROCESS 'templates/select_sp.tt2.html' %] + [% PROCESS 'templates/web/select_sp.tt2.html' %] [% CATCH %] An error occured [% END %] +[% ELSIF action == 'get_sp_list' %] + [% TRY %] + [% PROCESS 'templates/web/get_sp_list.tt2.html' %] + [% CATCH %] + An error occured + [% END %] + [% ELSE %] Error: unknown action diff --git a/templates/web/get_sp_list.tt2.html b/templates/web/get_sp_list.tt2.html new file mode 100644 index 0000000..ba72b3e --- /dev/null +++ b/templates/web/get_sp_list.tt2.html @@ -0,0 +1,17 @@ +<div> +This Test Identity Provider allows you to create test accounts with different profiles to validate the behaviour of your own Service Provider registered in eduGAIN inter-federation. Note that only a Service Provider administrator can create accounts here. +<br/><br/> +Step 1: please select your Service Provider below +<form action="[% env.SCRIPT_NAME %]" + method="POST"> + + <select id="sp_entityid" name="sp_entityid"> + <option value="">Select your Service Provider below</option> + [% FOREACH entity IN federation_metadata_as_hashref %] +<option value="[% entity.entityid %]">[% IF entity.display_name && entity.display_name.en %][% entity.display_name.en %] - [% END %][% entity.entityid %]</option> + [% END %] + </select> + <br/> + <input type="submit" name="action_select_sp" value="Next"> +</form> +</div> diff --git a/templates/web/index.tt2.html b/templates/web/index.tt2.html index 84f8a43..9a7d631 100644 --- a/templates/web/index.tt2.html +++ b/templates/web/index.tt2.html @@ -81,21 +81,21 @@ div.important{border-style:solid;border-color:black;border-width:1px;background- <div id="content"> -[% IF erreurs %] +[% IF errors %] <div class="ui-widget"> - [% FOREACH err IN erreurs %] + [% FOREACH err IN errors %] <p class="ui-state-error ui-corner-all" style="margin-top: 20px; padding: 0 .7em;"><span class="ui-icon ui-icon-info" style="float: left; margin-right: .3em;"></span> - [% IF lang == 'en' %]Error: [% ELSE %]Erreur : [% END %] + Error: [% IF err == 'unknown_action' %] - [% IF lang == 'en' %]Unknown action[% ELSE %]action non supportée.[% END %] + Unknown action [% ELSIF err == 'internal' %] - [% IF lang == 'en' %]internal error; administrators of the federation registry have been notified.[% ELSE %]erreur interne ; les administrateurs du guichet de la fédération ont été notifiés.[% END %] + internal error; administrators of the federation registry have been notified. [% ELSIF (matches = err.match('missing_(\w+)')) %] - [% IF lang == 'en' %]missing parameter '[% matches.0 %]'[% ELSE %]paramètre '[% matches.0 %]' manquant.[% END %] + missing parameter '[% matches.0 %]' [% ELSE %] [% err %] @@ -112,11 +112,11 @@ div.important{border-style:solid;border-color:black;border-width:1px;background- <div class="ui-widget"> [% FOREACH notif IN notifications %] <p class="ui-state-highlight ui-corner-all" style="border: 2px solid #10427a; background: #DFF1EE;padding: 0.7em;"><span class="ui-icon ui-icon-alert" style="float: left; margin-right: .3em;"></span> - [% IF lang == 'en' %]Notice: [% ELSE %]Notification : [% END %] + Notice: [% IF notif == 'done' %] - [% IF lang == 'en' %]Operation has been performed[% ELSE %]L'opération a été effectuée;[%END%] + Operation has been performed [% ELSE %] @@ -130,7 +130,7 @@ div.important{border-style:solid;border-color:black;border-width:1px;background- [% PROCESS 'templates/web/content.tt2.html' %] -[% END %] <!-- IF erreurs --> +[% END %] <!-- IF errors --> <p> -- GitLab