#!/usr/bin/perl use strict; use Getopt::Long; use Pod::Usage; use Lemonldap::NG::Common::Conf; use LWP::UserAgent; use MIME::Base64; use XML::LibXML; sub toEntityIDkey { my ( $prefix, $entityID ) = @_; my $entityIDKey = $entityID; $entityIDKey =~ s/^https?:\/\///; $entityIDKey =~ s/[^a-zA-Z0-9]/-/g; $entityIDKey =~ s/-+$//g; return ( $prefix . $entityIDKey ); } #============================================================================== # Get command line options #============================================================================== my %opts; my $result = GetOptions( \%opts, 'metadata|m=s', 'verbose|v', 'help|h', 'spconfprefix|s=s', 'idpconfprefix|i=s', 'remove|r', 'nagios|a', 'ignore-sp=s@', 'ignore-idp=s@', 'dry-run|n' ); pod2usage(1) if $opts{help}; pod2usage( -message => "Missing metadata URL (-m)", -exitval => 2 ) if !$opts{metadata}; #============================================================================== # Default values #============================================================================== my $spConfKeyPrefix = $opts{spconfprefix} || "sp-"; my $idpConfKeyPrefix = $opts{idpconfprefix} || "idp-"; # Set here attributes that are declared for your SP in the federation # They will be set as exported attributes for all IDP # my $exportedAttributes = { 'cn' => '0;cn', 'eduPersonPrincipalName' => '0;eduPersonAffiliation', 'givenName' => '0;givenName', 'surname' => '0;surname', 'displayName' => '0;displayName', 'eduPersonAffiliation' => '0;eduPersonAffiliation', 'eduPersonPrimaryAffiliation' => '0;eduPersonPrimaryAffiliation', 'mail' => '0;mail', 'supannListeRouge' => '0;supannListeRouge', 'supannEtuCursusAnnee' => '0;supannEtuCursusAnnee', }; # Set here options that are applied on all SP from the federation my $spOptions = { 'samlSPMetaDataOptionsCheckSLOMessageSignature' => 1, 'samlSPMetaDataOptionsCheckSSOMessageSignature' => 1, 'samlSPMetaDataOptionsEnableIDPInitiatedURL' => 0, 'samlSPMetaDataOptionsEncryptionMode' => 'none', 'samlSPMetaDataOptionsForceUTF8' => 1, 'samlSPMetaDataOptionsNameIDFormat' => '', 'samlSPMetaDataOptionsNotOnOrAfterTimeout' => 72000, 'samlSPMetaDataOptionsOneTimeUse' => 0, 'samlSPMetaDataOptionsSessionNotOnOrAfterTimeout' => 72000, 'samlSPMetaDataOptionsSignSLOMessage' => 1, 'samlSPMetaDataOptionsSignSSOMessage' => 1 }; # Set here options that are applied on all IDP from the federation my $idpOptions = { 'samlIDPMetaDataOptionsAdaptSessionUtime' => 0, 'samlIDPMetaDataOptionsAllowLoginFromIDP' => 0, 'samlIDPMetaDataOptionsAllowProxiedAuthn' => 0, 'samlIDPMetaDataOptionsCheckAudience' => 1, 'samlIDPMetaDataOptionsCheckSLOMessageSignature' => 1, 'samlIDPMetaDataOptionsCheckSSOMessageSignature' => 1, 'samlIDPMetaDataOptionsCheckTime' => 1, 'samlIDPMetaDataOptionsEncryptionMode' => 'none', 'samlIDPMetaDataOptionsForceAuthn' => 0, 'samlIDPMetaDataOptionsForceUTF8' => 0, 'samlIDPMetaDataOptionsIsPassive' => 0, 'samlIDPMetaDataOptionsNameIDFormat' => 'transient', 'samlIDPMetaDataOptionsRelayStateURL' => 0, 'samlIDPMetaDataOptionsSignSLOMessage' => -1, 'samlIDPMetaDataOptionsSignSSOMessage' => -1, 'samlIDPMetaDataOptionsStoreSAMLToken' => 0 }; my $idpCounter = { 'found' => 0, 'updated' => 0, 'created' => 0, 'rejected' => 0, 'removed' => 0, 'ignored' => 0 }; my $spCounter = { 'found' => 0, 'updated' => 0, 'created' => 0, 'rejected' => 0, 'removed' => 0, 'ignored' => 0, }; # BlockList initialisation my @spIgnorelist = @{ $opts{'ignore-sp'} || [] }; my @idpIgnorelist = @{ $opts{'ignore-idp'} || [] }; #============================================================================== # Main #============================================================================== my $conf = Lemonldap::NG::Common::Conf->new(); my $lastConf = $conf->getConf(); if ( $opts{verbose} ) { print "Read configuration " . $lastConf->{cfgNum} . "\n"; } # IDP and SP lists my ( $idpList, $spList, $mdIdpList, $mdSpList ); # List current SAML partners foreach my $spConfKey ( keys %{ $lastConf->{samlSPMetaDataXML} } ) { my ( $tmp, $entityID ) = ( $lastConf->{samlSPMetaDataXML}->{$spConfKey}->{samlSPMetaDataXML} =~ /entityID=(['"])(.+?)\1/si ); if ( $spConfKey =~ /^$spConfKeyPrefix/ ) { $spList->{$entityID} = $spConfKey; if ( $opts{verbose} ) { print "Existing SAML partner found: [SP] $entityID ($spConfKey)\n"; } } } foreach my $idpConfKey ( keys %{ $lastConf->{samlIDPMetaDataXML} } ) { my ( $tmp, $entityID ) = ( $lastConf->{samlIDPMetaDataXML}->{$idpConfKey}->{samlIDPMetaDataXML} =~ /entityID=(['"])(.+?)\1/si ); if ( $idpConfKey =~ /^$idpConfKeyPrefix/ ) { $idpList->{$entityID} = $idpConfKey; if ( $opts{verbose} ) { print "Existing SAML partner found: [IDP] $entityID ($idpConfKey)\n"; } } } # Download metadata file my $ua = LWP::UserAgent->new; $ua->timeout(10); $ua->env_proxy; my $metadata_file = $opts{metadata}; if ( $opts{verbose} ) { print "Try to download metadata file at $metadata_file\n"; } my $response = $ua->get($metadata_file); if ( $response->is_success ) { if ( $opts{verbose} ) { print "Metadata file found\n"; } } else { die $response->status_line; } my $dom = XML::LibXML->load_xml( string => $response->decoded_content ); # Remove extensions foreach ( $dom->findnodes('//md:Extensions') ) { $_->unbindNode; } # Browse all partners foreach my $partner ( $dom->findnodes('/md:EntitiesDescriptor/md:EntityDescriptor') ) { my $entityID = $partner->getAttribute('entityID'); # Add required XML namespaces $partner->setNamespace( "urn:oasis:names:tc:SAML:2.0:metadata", "md", 0 ); $partner->setNamespace( "urn:oasis:names:tc:SAML:2.0:assertion", "saml", 0 ); $partner->setNamespace( "http://www.w3.org/2000/09/xmldsig#", "ds", 0 ); # Check IDP or SP if ( my $idp = $partner->findnodes('./md:IDPSSODescriptor') ) { $idpCounter->{found}++; $mdIdpList->{$entityID} = 1; # Check if SAML 2.0 is supported if ( $partner->findnodes( './md:IDPSSODescriptor/md:SingleSignOnService[contains(@Binding,"urn:oasis:names:tc:SAML:2.0:")]' ) ) { # Read metadata my $partner_metadata = $partner->toString; $partner_metadata =~ s/\n//g; # test if IDP entityID is inside the block list if ( grep { $entityID eq $_ } @idpIgnorelist ) { if ( $opts{verbose} ) { print "IDP $entityID won't be update/added \n"; } $idpCounter->{ignored}++; } else { # Check if entityID already in configuration if ( defined $idpList->{$entityID} ) { # Update metadata $lastConf->{samlIDPMetaDataXML}->{ $idpList->{$entityID} } ->{samlIDPMetaDataXML} = $partner_metadata; # Update attributes $lastConf->{samlIDPMetaDataExportedAttributes} ->{ $idpList->{$entityID} } = $exportedAttributes; # Update options $lastConf->{samlIDPMetaDataOptions} ->{ $idpList->{$entityID} } = $idpOptions; if ( $opts{verbose} ) { print "Update IDP $entityID in configuration\n"; } $idpCounter->{updated}++; } else { # Create a new partner my $confKey = toEntityIDkey( $idpConfKeyPrefix, $entityID ); # Metadata $lastConf->{samlIDPMetaDataXML}->{$confKey} ->{samlIDPMetaDataXML} = $partner_metadata; # Attributes $lastConf->{samlIDPMetaDataExportedAttributes}->{$confKey} = $exportedAttributes; # Options $lastConf->{samlIDPMetaDataOptions}->{$confKey} = $idpOptions; if ( $opts{verbose} ) { print "Declare new IDP $entityID (configuration key $confKey)\n"; } $idpCounter->{created}++; } } } else { print STDERR "[WARN] IDP $entityID is not compatible with SAML 2.0, it will not be imported.\n" if $opts{verbose}; $idpCounter->{rejected}++; } } if ( my $sp = $partner->findnodes('./md:SPSSODescriptor') ) { $spCounter->{found}++; $mdSpList->{$entityID} = 1; # Check if SAML 2.0 is supported if ( $partner->findnodes( './md:SPSSODescriptor/md:AssertionConsumerService[contains(@Binding,"urn:oasis:names:tc:SAML:2.0:")]' ) ) { # Read requested attributes my $requestedAttributes = {}; if ( $partner->findnodes( './md:SPSSODescriptor/md:AttributeConsumingService/md:RequestedAttribute' ) ) { foreach my $requestedAttribute ( $partner->findnodes( './md:SPSSODescriptor/md:AttributeConsumingService/md:RequestedAttribute' ) ) { my $name = $requestedAttribute->getAttribute("Name"); my $friendlyname = $requestedAttribute->getAttribute("FriendlyName"); my $nameformat = $requestedAttribute->getAttribute("NameFormat"); my $required = ( $requestedAttribute->getAttribute("isRequired") =~ /true/i ) ? 1 : 0; $requestedAttributes->{$friendlyname} = "$required;$name;$nameformat;$friendlyname"; if ( $opts{verbose} ) { print "Attribute $friendlyname ($name) requested by SP $entityID\n"; } } } else { $requestedAttributes = { 'cn' => '1;cn', 'uid' => '1;uid', 'mail' => '1;mail' }; } # Remove AttributeConsumingService node foreach ( $partner->findnodes( './md:SPSSODescriptor/md:AttributeConsumingService') ) { $_->unbindNode; } # Read metadata my $partner_metadata = $partner->toString; $partner_metadata =~ s/\n//g; # test if IDP entityID is inside the block list if ( grep { $entityID eq $_ } @spIgnorelist ) { if ( $opts{verbose} ) { print "SP $entityID won't be update/added \n"; } $spCounter->{ignored}++; } else { # Check if entityID already in configuration my $confKey; if ( defined $spList->{$entityID} ) { $confKey = $spList->{$entityID}; # Update metadata $lastConf->{samlSPMetaDataXML}->{$confKey} ->{samlSPMetaDataXML} = $partner_metadata; # Update attributes $lastConf->{samlSPMetaDataExportedAttributes} ->{ $spList->{$entityID} } = $requestedAttributes; $lastConf->{samlSPMetaDataOptions}->{$confKey} = { %{$spOptions} }; if ( $opts{verbose} ) { print "Update SP $entityID in configuration\n"; } $spCounter->{updated}++; } else { # Create a new partner $confKey = toEntityIDkey( $spConfKeyPrefix, $entityID ); # Metadata $lastConf->{samlSPMetaDataXML}->{$confKey} ->{samlSPMetaDataXML} = $partner_metadata; # Attributes $lastConf->{samlSPMetaDataExportedAttributes}->{$confKey} = $requestedAttributes; $lastConf->{samlSPMetaDataOptions}->{$confKey} = { %{$spOptions} }; if ( $opts{verbose} ) { print "Declare new SP $entityID (configuration key $confKey)\n"; } $spCounter->{created}++; } # handle eduPersonTargetedID if ( $lastConf->{samlSPMetaDataExportedAttributes}->{$confKey} ->{eduPersonTargetedID} ) { delete $lastConf->{samlSPMetaDataExportedAttributes} ->{$confKey}->{eduPersonTargetedID}; $lastConf->{samlSPMetaDataOptions}->{$confKey} ->{samlSPMetaDataOptionsNameIDFormat} = 'persistent'; } } } else { print STDERR "[WARN] SP $entityID is not compatible with SAML 2.0, it will not be imported.\n" if $opts{verbose}; $spCounter->{rejected}++; } } } # Remove partners if ( $opts{remove} ) { foreach my $entityID ( keys %$idpList ) { my $idpConfKey = $idpList->{$entityID}; unless ( defined $mdIdpList->{$entityID} ) { if ( grep { $entityID eq $_ } @idpIgnorelist ) { $idpCounter->{ignored}++; if ( $opts{verbose} ) { print "IDP $idpConfKey won't be deleted \n"; } } else { delete $lastConf->{samlIDPMetaDataXML}->{$idpConfKey}; delete $lastConf->{samlIDPMetaDataExportedAttributes} ->{$idpConfKey}; delete $lastConf->{samlIDPMetaDataOptions}->{$idpConfKey}; $idpCounter->{removed}++; if ( $opts{verbose} ) { print "Remove IDP $idpConfKey\n"; } } } } foreach my $entityID ( keys %$spList ) { my $spConfKey = $spList->{$entityID}; unless ( defined $mdSpList->{$entityID} ) { if ( grep { $entityID eq $_ } @spIgnorelist ) { $spCounter->{ignored}++; if ( $opts{verbose} ) { print "SP $spConfKey won't be deleted \n"; } } else { delete $lastConf->{samlSPMetaDataXML}->{$spConfKey}; delete $lastConf->{samlSPMetaDataExportedAttributes} ->{$spConfKey}; delete $lastConf->{samlSPMetaDataOptions}->{$spConfKey}; $spCounter->{removed}++; if ( $opts{verbose} ) { print "Remove SP $spConfKey\n"; } } } } } my $numConf = "DRY-RUN"; my $exitCode = 0; if ( !$opts{'dry-run'} ) { # Register configuration if ( $opts{verbose} ) { print "[INFO] run mod EntityID will be inserted\n"; } $numConf = $conf->saveConf( $lastConf, ( cfgNumFixed => 1 ) ); if ( $opts{verbose} ) { print "[OK] Configuration $numConf saved\n"; $exitCode = 0; } unless ($numConf) { print "[ERROR] Unable to save configuration\n"; $exitCode = 1; } } else { if ( $opts{verbose} ) { print "[INFO] Dry-run mod no EntityID inserted\n"; } } if ( $opts{nagios} ) { print "Metadata loaded inside Conf: [" . $numConf . "]|idp_found=" . $idpCounter->{found} . ", idp_updated=" . $idpCounter->{updated} . ", idp_created=" . $idpCounter->{created} . ", idp_removed=" . $idpCounter->{removed} . ", idp_rejected=" . $idpCounter->{rejected} . ", idp_ignored=" . $idpCounter->{ignored} . ", sp_found=" . $spCounter->{found} . ", sp_updated=" . $spCounter->{updated} . ", sp_created=" . $spCounter->{created} . ", sp_removed=" . $spCounter->{removed} . ", sp_rejected=" . $spCounter->{rejected} . ", sp_ignored=" . $spCounter->{ignored} . "\n"; } else { print "[IDP]\tFound: " . $idpCounter->{found} . "\tUpdated: " . $idpCounter->{updated} . "\tCreated: " . $idpCounter->{created} . "\tRemoved: " . $idpCounter->{removed} . "\tRejected: " . $idpCounter->{rejected} . "\tIgnored: " . $idpCounter->{ignored} . "\n"; print "[SP]\tFound: " . $spCounter->{found} . "\tUpdated: " . $spCounter->{updated} . "\tCreated: " . $spCounter->{created} . "\tRemoved: " . $spCounter->{removed} . "\tRejected: " . $spCounter->{rejected} . "\tIgnored: " . $spCounter->{ignored} . "\n"; } exit $exitCode; __END__ Script to import SAML metadata bundle file into LL::NG configuration\n\n"; Usage: $0 -m \n\n"; Options:\n"; =encoding UTF-8 =head1 NAME importMetadata - Script to import SAML federation metadata into LL::NG configuration =head1 SYNOPSIS importMetadata -m [options] Options: -m, --metadata URL of metadata document -i, --idpconfprefix Prefix used to set IDP configuration key -s, --spconfprefix Prefix used to set SP configuration key --ignore-sp ignore SP matching this entityID (can be specified multiple times) --ignore-idp ignore IdP matching this entityID (can be specified multiple times) -a, --nagios output statistics in Nagios format -r, --remove remove provider from LemonLDAP::NG if it does not appear in metadata -n, --dry-run print statistics but do not apply changes -v, --verbose increase verbosity of output -h, --help print full documentation =head1 OPTIONS =over =item B<-m I>, B<--metadata=I> Specifies the of the metadata document to import =item B<-i I>, B<--idpconfprefix=I> Prefix each IDP found the metadata document with the when registring them into LemonLDAP::NG =item B<-s I>, B<--spconfprefix=I> Prefix each SP found the metadata document with the when registring them into LemonLDAP::NG =item B<--ignore-sp=I> Ignore the specified Service Provider . It will not be added, updated or deleted from LemonLDAP::NG configuration =item B<--ignore-idp=I> Ignore the specified Identity Provider . It will not be added, updated or deleted from LemonLDAP::NG configuration =item B<-a>, B<--nagios> After each run, print statistics about added/modified/deleted items in Nagios format =item B<-r>, B<--remove> If this option is used, after a successful import, existing SP/IDPs who match the configuration prefix will be removed from LemonLDAP::NG if they were not present in the imported metadata =item B<-n>, B<--dry-run> This option prevents the modified configuration from being saved. It can be used for testing. =item B<-v>, B<--verbose> Increase verbosity during script execution =item B<-h>, B<--help> Displays the script's documentation =back =head1 SEE ALSO L =head1 AUTHORS =over =item Clement Oudot, Eclement@oodo.netE =back =head1 BUG REPORT Use OW2 system to report bug or ask for features: L =head1 DOWNLOAD Lemonldap::NG is available at L