#!/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 <metadata file URL>\n\n";
Options:\n";

=encoding UTF-8

=head1 NAME

importMetadata - Script to import SAML federation metadata into LL::NG configuration

=head1 SYNOPSIS

importMetadata -m <metadata URL> [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<URL>>, B<--metadata=I<URL>>

Specifies the <URL> of the metadata document to import

=item B<-i I<PREFIX>>, B<--idpconfprefix=I<PREFIX>>

Prefix each IDP found the metadata document with the <PREFIX> when registring
them into LemonLDAP::NG

=item B<-s I<PREFIX>>, B<--spconfprefix=I<PREFIX>>

Prefix each SP found the metadata document with the <PREFIX> when registring
them into LemonLDAP::NG

=item B<--ignore-sp=I<ENTITYID>>

Ignore the specified Service Provider <ENTITYID>. It will not be added, updated
or deleted from LemonLDAP::NG configuration

=item B<--ignore-idp=I<ENTITYID>>

Ignore the specified Identity Provider <ENTITYID>. 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<http://lemonldap-ng.org/>

=head1 AUTHORS

=over

=item Clement Oudot, E<lt>clement@oodo.netE<gt>

=back

=head1 BUG REPORT

Use OW2 system to report bug or ask for features:
L<https://gitlab.ow2.org/lemonldap-ng/lemonldap-ng/issues>

=head1 DOWNLOAD

Lemonldap::NG is available at
L<https://lemonldap-ng.org/download>
