Skip to content
Snippets Groups Projects
App.pm 20.7 KiB
Newer Older
package AccountManager::App;
use strict;
use warnings;

use CGI;
use English qw(-no_match_vars);
use Log::Any::Adapter;
use List::MoreUtils qw(uniq);
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
use Template;
use Template::Constants qw(:chomp);
use AccountManager::Account;
use AccountManager::Metadata;
use AccountManager::ServiceProvider;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
use AccountManager::Token;
use AccountManager::Tools;
use AccountManager::L10N;
# Format de type URL HTTP ou URN
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
my %patterns = (
    entityid => qr{
        ^
        (?:
            https?://[\w.:/-]+
        |
            urn:[\w.:-]+
        )
        $
    }x
);
    home               => 'req_home',
    select_federation  => 'req_select_federation',
    select_sp          => 'req_select_sp',
    select_email       => 'req_select_email',
    complete_challenge => 'req_complete_challenge',
    create_accounts    => 'req_create_accounts',
    download_accounts  => 'req_download_accounts',
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
my $version = '1.1';
    my ($pkg, %args) = @_;

    my $self = {
        configuration => $args{configuration},
    if ($self->{configuration}->{logger}) {
        Log::Any::Adapter->set(
            'File',
            $self->{configuration}->{logger}->{file},
            log_level => $self->{configuration}->{logger}->{level}
    } else {
        warn "no logger in configuration, logging disabled\n";
    $self->{logger} = Log::Any->get_logger();
    $self->{cgi}    = CGI->new();
    my $lang =
        $self->{cgi}->param('lang') ||
        $self->{cgi}->cookie('lang');
    $self->{lh} = AccountManager::L10N->get_handle($lang ? $lang: ());
    if (!$self->{lh}) {
        $self->{logger}->fatal("Unable to get suitable language handle");
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }

    if (!$self->{configuration}->{mailer}) {
        $self->{logger}->fatal(
            "No mailer defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }

    if (!$self->{configuration}->{idp}) {
        $self->{logger}->fatal(
            "No IDP defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
    if (!$self->{configuration}->{federations}) {
        $self->{logger}->fatal(
            "No federations defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
            data     => {
                errors  => [ 'internal' ]
            }
        );
    }

    if (!$self->{configuration}->{database}) {
        $self->{logger}->fatal(
            "No database defined in configuration, aborting"
        );
        $self->respond(
            template => 'errors.tt2.html',
    } else {
        AccountManager::DB->register_db(
            driver   => $self->{configuration}->{database}->{type},
            database => $self->{configuration}->{database}->{name},
            host     => $self->{configuration}->{database}->{host},
            username => $self->{configuration}->{database}->{username},
            password => $self->{configuration}->{database}->{password},
            options  => $self->{configuration}->{database}->{options} ?
                [ split(/, */, $self->{configuration}->{database}->{options}) ] : undef,
    $self->{db} = AccountManager::DB->new();
    return $self;
sub run {
    my ($self) = @_;
    # process requested action
    my $action = $self->{cgi}->param('action') || 'home';
        $self->{logger}->debug("Processing action '$action'");
        $self->$method();
        $self->abort(
            logs => "Unknown action '$action'",
            user => "Unknown action '$action'"

    return 1;
}

## Return HTML content
sub respond {
    my ($self, %in) = @_;

    $in{data}->{app} = {
        url           => $ENV{SCRIPT_NAME},
        support_email => $self->{configuration}->{app}->{support_email},
        version       => $version,
    $in{data}->{lh} = $self->{lh};
    ## Parse template
    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => $self->{configuration}->{setup}->{templates_dir} . "/web"
    $self->{logger}->debug("Responding with template '$in{template}'");
    binmode(STDOUT, ":utf8");

    my $cookie = $self->{cgi}->cookie(
        -name    => 'lang',
        -value   => $self->{lh}->language_tag(),
        -expires => undef,
    );

    print $self->{cgi}->header(
        -type    => 'text/html',
        -charset => 'utf8',
        -cookie  => [ $cookie ]
    unless ($tt2->process($in{template}, $in{data}, \*STDOUT)) {
        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed

    exit 0;
sub abort {
    my $self = shift;
    my %args = @_;

    $self->{logger}->error($args{log}) if $args{log};

    $self->respond(
        template => 'errors.tt2.html',
        data => {
            errors => [ $args{user} ]
        }
    );
}


    my @federations = keys %{$self->{configuration}->{federations}};
    if (@federations == 1) {
        $self->req_select_sp(federation => $federations[0]);
sub req_select_federation {

    my @federations = keys %{$self->{configuration}->{federations}};

    $self->respond(
        template => 'select_federation.tt2.html',
        data     => {
            action      => 'select_federation',
            federations => \@federations
        }
    );
}

sub req_select_sp {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $federation = $args{federation} ||
                     $self->get_parameter(name => 'federation');
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $metadata_file = $self->get_metadata_file(federation => $federation);
    my $metadata;
        $metadata = AccountManager::Metadata->new(
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            file => $metadata_file
    $self->abort(
        log  => "Failed to load federation metadata: $EVAL_ERROR",
        user => "internal"
    ) if $EVAL_ERROR;
        template => 'select_sp.tt2.html',
            metadata   => $metadata->parse(type => 'sp'),
            federation => $federation,
sub req_select_email {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $federation = $self->get_parameter(name => 'federation');
    my $entityid   = $self->get_parameter(name => 'entityid');
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $metadata_file = $self->get_metadata_file(federation => $federation);
    # Create a persistent service provider object
    my $sp = AccountManager::ServiceProvider->new(
        db       => $self->{db},
    if ($sp->load(speculative => 1)) {
        # already present in DB, nothing todo
    } else {
        # extract information from metadata
        my $metadata;

        eval {
            $metadata = AccountManager::Metadata->new(
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
                file => $metadata_file
        $self->abort(
            log  => "Failed to load federation metadata: $EVAL_ERROR",
            user => "internal"
        ) if $EVAL_ERROR;
        my $entities = $metadata->parse(id => $entityid);
        my $entity = $entities->[0];
        $self->abort(
            log  => "No such SP $entityid in metadata",
            user => "no_such_entity"
        ) if !$entity;
        # complete persistent object
        $sp->displayname($entity->{display_name});
        $sp->contacts(uniq map { $_->{EmailAddress} } @{$entity->{contacts}})
            if $entity->{contacts};
        # save in DB
        $self->abort(
            log  => "Failed to save service provider",
            user => "internal"
        ) if !$sp->save();
    # override metadata contacts if needed
        $self->{configuration}->{$entityid}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $sp->contacts($sp->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
            $sp->contacts(split(/, */, $contacts));
        template => 'select_email.tt2.html',
            action     => 'select_email',
            federation => $federation,
            sp         => $sp,
sub req_complete_challenge {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $federation = $self->get_parameter(name => 'federation');
    my $entityid   = $self->get_parameter(name => 'entityid');
    my $email      = $self->get_parameter(name => 'email');
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $metadata_file = $self->get_metadata_file(federation => $federation);
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $sp = AccountManager::ServiceProvider->new(
        db       => $self->{db},
    $self->abort(
        log  => sprintf("No such SP '%s' in database", $entityid),
        user => "no_such_entity"
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    ) if !$sp->load(speculative => 1);
    # override metadata contacts if needed
        $self->{configuration}->{$entityid}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $sp->contacts($sp->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $sp->contacts(split(/, */, $contacts));
    ## Check that email is a known contact for this SP
    $self->abort(
        log  => "Requested a token for SP $entityid with unautorized address $email",
        user => "internal",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    ) if !$sp->is_contact($email);
    # delete any previous token for the same email/service couple
    my $old_token = AccountManager::Token->new(
        db            => $self->{db},
        email_address => $email,
        sp_entityid   => $entityid,
    if ($old_token->load(speculative => 1)) {
        $self->abort(
            log  => "Failed to delete old authentication token",
            user => "internal"
        ) if !$old_token->delete();
    # compute a new token
    eval "require DateTime";
    my $validity_period =
        $self->{configuration}->{service}->{tokens_validity_period};
    my $token = AccountManager::Token->new(
        email_address   => $email,
        sp_entityid     => $entityid,
        creation_date   => DateTime->now(),
        expiration_date => DateTime->now()->add(hours => $validity_period),
        secret          => AccountManager::Tools::generate_secret(20)
    $self->abort(
        log  => "Failed to save creation authentication token",
        user => "internal"
    ) if !$token->save();
    my $tt2 = Template->new({
        ENCODING     => 'utf8',
        PRE_CHOMP    => CHOMP_ONE,
        INCLUDE_PATH => $self->{configuration}->{setup}->{templates_dir} . "/mail",
    });
    my $data = {
        app => {
            url           => $self->{configuration}->{app}->{url},
            support_email => $self->{configuration}->{app}->{support_email},
            version       => $self->{configuration}->{app}->{version},
        sourceip  => $ENV{REMOTE_ADDR},
        token     => $token->secret(),
        challenge_url => sprintf(
            '%s&action=complete_challenge&federation=%s&entity=%s&email=%s',
            $self->{configuration}->{app}->{url},
        lh        => $self->{lh},
    my $text_content;
    my $html_content;
    $tt2->process('send_authentication_token.tt2.txt',  $data, \$text_content);
    $tt2->process('send_authentication_token.tt2.html', $data, \$html_content);
    eval "require Email::MIME";
    eval "require Email::Sender::Simple";

        header_str => [
            'From'         => sprintf('eduGAIN Access Check <%s>', $self->{configuration}->{mailer}->{from}),
            'Subject'      => sprintf('[eduGAIN Access Check] %s', $self->{lh}->maketext("Test accounts request")),
            'Content-Type' => 'multipart/alternative'
        ],
        parts => [
            Email::MIME->create(
                attributes => {
                    content_type => "text/plain",
                    charset      => 'utf-8',
                    encoding     => 'quoted-printable'
                },
                body_str => $text_content
            ),
            Email::MIME->create(
                attributes => {
                    content_type => "text/html",
                    charset      => 'utf-8',
                    encoding     => 'quoted-printable'
                },
                body_str => $html_content
            ),
        ]
    $self->abort(
        log  => "Mail notification error: $EVAL_ERROR",
        user => "mail_notification_failure"
    ) if $EVAL_ERROR;
    $self->{logger}->infof(
        "Token send to %s for entityid=%s;token=%s",
        template  => 'complete_challenge.tt2.html',
            action     => 'complete_challenge',
            federation => $federation,
sub req_create_accounts {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $entityid = $self->get_parameter(name => 'entityid');
    my $token    = $self->get_parameter(name => 'token');
    my $email    = $self->get_parameter(name => 'email');
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    $self->check_token(token => $token, entityid => $entityid);

    ## create test accounts
    my @accounts;
        $self->{configuration}->{$entityid}->{account_profiles} ||
        $self->{configuration}->{service}->{account_profiles};
    my $validity_period =
        $self->{configuration}->{$entityid}->{account_validity_period} ||
        $self->{configuration}->{service}->{account_validity_period};

    eval "require DateTime";
    my $download_token = AccountManager::Token->new(
        db              => $self->{db},
        email_address   => $email,
        sp_entityid     => $entityid,
        creation_date   => DateTime->now(),
        expiration_date => DateTime->now()->add(hours => $validity_period),
        secret          => AccountManager::Tools::generate_secret(20)
    $self->abort(
        log  => "Failed to save download authentication token",
        user => "internal"
    ) if !$download_token->save();

    my $key = AccountManager::Tools::generate_secret(10);

    foreach my $profile (split(/, */, $profiles)) {
        my $password = AccountManager::Tools::generate_password(10);
        my $account = AccountManager::Account->new(
            db              => $self->{db},
            profile         => $profile,
            scope           => $self->{configuration}->{idp}->{scope},
            password        => $password,
            password_crypt  => AccountManager::Tools::encrypt($password, $key),
            password_hash   => AccountManager::Tools::sha256_hash($password),
            token           => $download_token->secret(),
            creation_date   => DateTime->now(),
            expiration_date => DateTime->now()->add(days => $validity_period)
        next unless $account->save();
        push @accounts, $account;
    $self->abort(
        log  => "Failed to create test accounts for SP $entityid",
        user => "accounts_creation_failure"
    ) if !@accounts;

    ## Update simpleSAMLphp configuration to enable test accounts
    my $accounts = AccountManager::Account->get_accounts(db => $self->{db});
    eval {
        AccountManager::Tools::update_ssp_authsources(
            $self->{configuration}->{setup}->{templates_dir},
            $self->{configuration}->{setup}->{accounts_file},
    $self->abort(
        log  => "Failed to create simpleSAMLphp configuration file: $EVAL_ERROR",
        user => "accounts_creation_failure"
    ) if $EVAL_ERROR;
    $self->{logger}->infof(
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        "Token validated for entityid=%s",
        template => 'create_accounts.tt2.html',
            action   => 'create_accounts',
            accounts => \@accounts,
            token    => $download_token->secret(),
            days     => $validity_period,
sub req_download_accounts {
    my ($self) = @_;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    my $entityid = $self->get_parameter(name => 'entityid');
    my $token    = $self->get_parameter(name => 'token');
    my $key      = $self->get_parameter(name => 'key');
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    $self->check_token(token => $token, entityid => $entityid);
    my $accounts = AccountManager::Account->get_accounts(
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            token => $token
    binmode(STDOUT, ":utf8");

    print $self->{cgi}->header(
        -type                => 'text/csv',
        -content_disposition => 'attachment; filename="accounts.csv"'
    );

    eval "require Text::CSV";
    my $csv = Text::CSV->new ({ binary => 1, eol => "\r\n", quote_space => 0 });
    $csv->print(\*STDOUT, [ qw/
        username
        password
        profile
        cn
        displayName
        givenName
        mail
        eduPersonAffiliation
        eduPersonScopedAffiliation
        eduPersonPrincipalName
        schacHomeOrganization
        schacHomeOrganizationType
    / ]);

    foreach my $account (@$accounts) {
        my $password = AccountManager::Tools::decrypt(
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $account->password_crypt(), $key
        );
        $account->password($password);
        $csv->print(\*STDOUT, [
            $account->internal_uid(),
            $account->password(),
            $account->profile(),
            $account->cn(),
            $account->displayName(),
            $account->givenName(),
            $account->mail(),
            join(', ', $account->eduPersonAffiliation()),
            join(', ', $account->eduPersonScopedAffiliation()),
            $account->eduPersonPrincipalName(),
            $account->schacHomeOrganization(),
            $account->schacHomeOrganizationType(),
        ]);
    }
}

## Return the homepage of the service
sub req_home {
    my ($self) = @_;
        template => 'home.tt2.html',
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
sub get_parameter {
    my ($self, %args) = @_;

    my $name  = $args{name};
    my $value = $self->{cgi}->param($name);

    $self->abort(
        log  => "Missing parameter: $name",
        user => "missing_$name"
    ) if !$value;

    if ($patterns{$name}) {
        $self->abort(
            log  => "Incorrect parameter format: $name",
            user => "format_$name"
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        ) if $value !~ $patterns{$name};
    }

    return $value;
}

sub get_metadata_file {
    my ($self, %args) = @_;

    my $federation = $args{federation};

    my $file = $self->{configuration}->{federations}->{$federation};

    $self->abort(
        log  => "Incorrect parameter: federation",
        user => "invalid_federation"
    ) if !$file;

    return $file;
}

sub check_token {
    my ($self, %args) = @_;

    my $secret = $args{token};

    my $token = AccountManager::Token->new(
        db     => $self->{db},
        secret => $secret
    );

    $self->abort(
        log  => "No such authentication token $secret",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        user => "wrong_token"
    ) if !$token->load(speculative => 1);

    $self->abort(
        log  => "Authentication token $secret cannot be used for SP $args{entityid}",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        user => "wrong_token_for_sp"
    ) if $token->sp_entityid() ne $args{entityid};

    ## delete the token
    unless ($token->delete()) {
        $self->{logger}->errorf(
            "Failed to delete authentication token %s",
            $secret
        );
    }
}