#!/usr/bin/perl use strict; use Getopt::Long; use Lemonldap::NG::Common::Conf; use LWP::UserAgent; use MIME::Base64; use XML::LibXML; #============================================================================== # Get command line options #============================================================================== my %opts; my $result = GetOptions( \%opts, 'metadata|m=s', 'certificate|c=s', 'verbose|v', 'help|h', 'spconfprefix|s=s', 'idpconfprefix|i=s', 'warning|w', 'remove|r' ); #============================================================================== # Help #============================================================================== if ( $opts{help} or !$opts{metadata} ) { print STDERR "\nScript to import SAML metadata bundle file into LL::NG configuration\n\n"; print STDERR "Usage: $0 -m \n\n"; print STDERR "Options:\n"; print STDERR "\t-c (--certificate): URL of certificate, to check metadata document signature\n"; print STDERR "\t-i (--idpconfprefix): Prefix used to set IDP configuration key\n"; print STDERR "\t-h (--help): print this message\n"; print STDERR "\t-m (--metadata): URL of metadata document\n"; print STDERR "\t-s (--spconfprefix): Prefix used to set SP configuration key\n"; print STDERR "\t-w (--warning): print debug messages\n"; exit 1; } #============================================================================== # 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 }; my $spCounter = { 'found' => 0, 'updated' => 0, 'created' => 0, 'rejected' => 0, 'removed' => 0 }; #============================================================================== # 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 ); # Check file signature if ( $opts{certificate} ) { my $certificate_file = $opts{certificate}; if ( $opts{verbose} ) { print "Try to download certificate file at $certificate_file\n"; } my $cert_response = $ua->get($certificate_file); if ( $cert_response->is_success ) { if ( $opts{verbose} ) { print "Certificate file found:\n" . $cert_response->decoded_content . "\n"; } } else { die $cert_response->status_line; } if ( $opts{verbose} ) { print "Check metadata signature with certificate"; } # TODO print STDERR "[WARN] Signature verification not yet implemented\n" if $opts{warning}; } # 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; # 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 $entityIDKey = $entityID; $entityIDKey =~ s/^https?:\/\///; $entityIDKey =~ s/[^a-zA-Z0-9]/-/g; $entityIDKey =~ s/-+$//g; my $confKey = $idpConfKeyPrefix . $entityIDKey; # 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{warning}; $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; # Check if entityID already in configuration if ( defined $spList->{$entityID} ) { # Update metadata $lastConf->{samlSPMetaDataXML}->{ $spList->{$entityID} } ->{samlSPMetaDataXML} = $partner_metadata; # Update attributes $lastConf->{samlSPMetaDataExportedAttributes} ->{ $spList->{$entityID} } = $requestedAttributes; # Update options $lastConf->{samlSPMetaDataOptions}->{ $spList->{$entityID} } = $spOptions; if ( $opts{verbose} ) { print "Update SP $entityID in configuration\n"; } $spCounter->{updated}++; } else { # Create a new partner my $entityIDKey = $entityID; $entityIDKey =~ s/^https?:\/\///; $entityIDKey =~ s/[^a-zA-Z0-9]/-/g; $entityIDKey =~ s/-+$//g; my $confKey = $spConfKeyPrefix . $entityIDKey; # Metadata $lastConf->{samlSPMetaDataXML}->{$confKey}->{samlSPMetaDataXML} = $partner_metadata; # Attributes $lastConf->{samlSPMetaDataExportedAttributes}->{$confKey} = $requestedAttributes; # Options $lastConf->{samlSPMetaDataOptions}->{$confKey} = $spOptions; if ( $opts{verbose} ) { print "Declare new SP $entityID (configuration key $confKey)\n"; } $spCounter->{created}++; } } else { print STDERR "[WARN] SP $entityID is not compatible with SAML 2.0, it will not be imported.\n" if $opts{warning}; $spCounter->{rejected}++; } } } # Remove partners if ( $opts{remove} ) { foreach ( keys %$idpList ) { my $idpConfKey = $idpList->{$_}; unless ( defined $mdIdpList->{$_} ) { delete $lastConf->{samlIDPMetaDataXML}->{$idpConfKey}; delete $lastConf->{samlIDPMetaDataExportedAttributes} ->{$idpConfKey}; delete $lastConf->{samlIDPMetaDataOptions}->{$idpConfKey}; $idpCounter->{removed}++; if ( $opts{verbose} ) { print "Remove IDP $idpConfKey\n"; } } } foreach ( keys %$spList ) { my $spConfKey = $spList->{$_}; unless ( defined $mdSpList->{$_} ) { delete $lastConf->{samlSPMetaDataXML}->{$spConfKey}; delete $lastConf->{samlSPMetaDataExportedAttributes}->{$spConfKey}; delete $lastConf->{samlSPMetaDataOptions}->{$spConfKey}; $spCounter->{removed}++; if ( $opts{verbose} ) { print "Remove SP $spConfKey\n"; } } } } # Register configuration my $numConf = $conf->saveConf( $lastConf, ( cfgNumFixed => 1 ) ); unless ($numConf) { print "[ERROR] Unable to save configuration\n"; exit 1; } print "[IDP]\tFound: " . $idpCounter->{found} . "\tUpdated: " . $idpCounter->{updated} . "\tCreated: " . $idpCounter->{created} . "\tRemoved: " . $idpCounter->{removed} . "\tRejected: " . $idpCounter->{rejected} . "\n"; print "[SP]\tFound: " . $spCounter->{found} . "\tUpdated: " . $spCounter->{updated} . "\tCreated: " . $spCounter->{created} . "\tRemoved: " . $spCounter->{removed} . "\tRejected: " . $spCounter->{rejected} . "\n"; print "[OK] Configuration $numConf saved\n"; exit 0;