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

use CGI;
use DateTime;
use English qw(-no_match_vars);
use Template;
use Log::Any::Adapter;
use List::MoreUtils qw(uniq);
use AccountManager::Account;
use AccountManager::Account::Manager;
use AccountManager::Metadata;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
use AccountManager::Service;
use AccountManager::Token;
use AccountManager::Tools;
## Defining parameters format
my $urn_or_url_regex = '(http(s?):\/\/|urn:)[^\\\$\*\"\'\`\^\|\<\>\n\s]+'
  ;    ## Format de type URL HTTP ou URN
my $url_regex     = 'http(s?):\/\/[^\\\$\*\"\'\`\^\|\<\>\n\s]+';
my $email_regex   = '([\w\-\_\.\/\+\=\'\&]+|\".*\")\@[\w\-]+(\.[\w\-]+)+';
my $domains_regex = '[\w\.\-]+(,[\w\.\-]+)*';
my %format        = (
    ## URL
    #'attributeauthority' => $url_regex,
    'sp_entityid' => $urn_or_url_regex,
);

my %actions = (
    select_sp      => 'req_select_sp',
    account_wizard => 'req_account_wizard',
    generate_token => 'req_generate_token',
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    validate_token => 'req_validate_token',
## New web request
sub new {
    my ($pkg, %args) = @_;

    my $self = {
        configuration => $args{configuration},
    Log::Any::Adapter->set(
        'File',
        $self->{configuration}->{log}->{file},
        log_level => $self->{configuration}->{log}->{level}
    $self->{logger} = Log::Any->get_logger();

    AccountManager::DB->register_db(
        driver          => $self->{configuration}->{database}->{type},
        database        => $self->{configuration}->{database}->{name},
        host            => $self->{configuration}->{database}->{host},
        password        => $self->{configuration}->{database}->{password},
        username        => $self->{configuration}->{database}->{user}
    $self->{db} = AccountManager::DB->new();
    $self->{cgi} = CGI->new();
    bless $self, $pkg;
    return $self;
sub run {
    my ($self) = @_;
    $self->execute();
    $self->respond();
}

## Execute a web request
sub execute {
    my ($self) = @_;

    # process input parameters
    my %parameters = $self->{cgi}->Vars();

    foreach my $parameter (keys %parameters) {

        # cleanup
        $parameters{$parameter} =~ s/\r//g;  # remove &0D char
        $parameters{$parameter} =~ s/\s+$//; # remove trailing spaces
        $parameters{$parameter} =~ s/^\s+//; # remove leading spaces

        # format check
        if (defined $format{$parameter}
            && !ref($format{$parameter})) {
            if ($parameters{$parameter} !~ /^$format{$parameter}$/) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
                push @{ $self->{out}->{errors} }, "format_$parameter";
                $self->{logger}->error(
                    "Incorrect parameter format : $parameter"

        # If action_xx parameter is set, set action parameter with value xx
        if ($parameter =~ /^action_(\w+)$/) {
            $parameters{action} = $1;
        }

        # register needed parameters
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        $self->{in} = {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            email_address        => $parameters{email_address},
            style                => $parameters{style},
            sp_entityid          => $parameters{sp_entityid},
            authentication_token => $parameters{authentication_token}
        };
    my $action = $parameters{action} || 'home';
    # initialize output parameters
    $self->{out} = {
        env => {
            REMOTE_HOST => $ENV{REMOTE_HOST},
            REMOTE_ADDR => $ENV{REMOTE_ADDR},
            SCRIPT_NAME => $ENV{SCRIPT_NAME}
        },
        conf => {
            accounts_validity_period => $self->{configuration}->{service}->{account_validity_period},
            app_name                 => $self->{configuration}->{app}->{name},
            app_url                  => $self->{configuration}->{app}->{url},
            idp_scope                => $self->{configuration}->{idp}->{scope},
            idp_displayname          => $self->{configuration}->{idp}->{displayname},
            support_email            => $self->{configuration}->{app}->{support_email},
            version                  => $self->{configuration}->{app}->{version},
        title  => $self->{configuration}->{app}->{name}
    if ($actions{$action}) {
        my $method = $actions{$action};
        $status = $self->$method();
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "unknown_action";
        $self->{logger}->error( "Unknown action '$action'");

    return 1;
}

## Return HTML content
sub respond {
    my ($self) = @_;
    ## Parse template
    my $tt2 = Template->new({
        INCLUDE_PATH => $self->{configuration}->{_}->{templates_dir}
    ## nobanner is used to do AJAX to get only pieces of HTML to load in the web client
    if ($self->{in}->{style} && $self->{in}->{style} eq 'nobanner') {
        $template = 'web/index-nobanner.tt2.html';
        $template = 'web/index.tt2.html';
    binmode(STDOUT, ":utf8");

    unless ($tt2->process($template, $self->{out}, \*STDOUT)) {
        printf "Content-type: text/plain\n\n Error: %s", $tt2->error();
        $self->{logger}->errorf("Web parser error : %s", $tt2->error());
    }

}

## Return the list of known SPs first
sub req_account_wizard {
    my ($self) = @_;
    my $metadata;
        $metadata = AccountManager::Metadata->new(
            file => $self->{configuration}->{_}->{federation_metadata_file}
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "internal";
        $self->{logger}->error("Failed to load federation metadata: $EVAL_ERROR");
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    $self->{out}->{metadata} = $metadata->parse(type => 'sp');
    $self->{out}->{subtitle} = 'Select your Service Provider';

    return 1;
}

## Select a Service Provider and return metadata sctucture for the SP
## Sample URL : https://dev-edugain.renater.fr/accountmanager?action=select_sp&sp_entityid=http%3A%2F%2Fsp.lat.csc.fi
sub req_select_sp {
    my ($self) = @_;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($self->{in}->{sp_entityid}) {
        push @{ $self->{out}->{errors} }, "missing_sp_entityid";
        $self->{logger}->error("Missing parameter sp_entityid");
    # Create a persistent service provider object
    my $provider = AccountManager::Service->new(
        db       => $self->{db},
        entityid => $self->{in}->{sp_entityid}
    if ($provider->load(speculative => 1)) {
        # already present in DB, nothing todo
    } else {
        # extract information from metadata
        my $metadata;

        eval {
            $metadata = AccountManager::Metadata->new(
                file => $self->{configuration}->{_}->{federation_metadata_file}
            );
        };
        if ($EVAL_ERROR) {
            push @{ $self->{out}->{errors} }, "internal";
            $self->{logger}->error("Failed to load federation metadata: $EVAL_ERROR");
        my $sps = $metadata->parse(id => $self->{in}->{sp_entityid});
        if (!@$sps) {
            push @{ $self->{out}->{errors} }, "no_such_entity";
            $self->{logger}->errorf(
                "No such SP '%s' in metadata", $self->{in}->{sp_entityid}
        }
        my $sp = $sps->[0];
        # complete persistent object
        $provider->displayname($sp->{display_name});
        $provider->contacts(uniq map { $_->{EmailAddress} } @{$sp->{contacts}})
            if $sp->{contacts};
        # save in DB
        unless ($provider->save()) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            push @{ $self->{out}->{errors} }, "internal";
            $self->{logger}->error("Failed to save service provider object");
    # override metadata contacts if needed
    my $entity = $self->{in}->{sp_entityid};
    my $contacts =
        $self->{configuration}->{$entity}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $provider->contacts($provider->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
            $provider->contacts(split(/, */, $contacts));
        }
    }
    $self->{out}->{provider} = $provider;
    $self->{out}->{subtitle} = 'Select your Service Provider';

    return 1;
}

## Generate an authentication token to validate an email address
## Sample call : dev-edugain.renater.fr/accountmanager?action=generate_token&style=nobanner&sp_entityid=https%3A%2F%2Fsourcesup.cru.fr%2Fshibboleth&email_address=support%40renater.fr
sub req_generate_token {
    my ($self) = @_;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($self->{in}->{sp_entityid}) {
        push @{ $self->{out}->{errors} }, "missing_sp_entityid";
        $self->{logger}->error("Missing parameter sp_entityid");
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($self->{in}->{email_address}) {
        push @{ $self->{out}->{errors} }, "missing_email_address";
        $self->{logger}->error("Missing parameter email_address");
    my $provider = AccountManager::Service->new(
        db       => $self->{db},
        entityid => $self->{in}->{sp_entityid},
    unless ($provider->load(speculative => 1)) {
        push @{ $self->{out}->{errors} }, "no_such_entity";
        $self->{logger}->errorf("No such SP '%s' in database", $self->{in}->{sp_entityid});
    # override metadata contacts if needed
    my $entity = $self->{in}->{sp_entityid};
    my $contacts =
        $self->{configuration}->{$entity}->{contacts} ||
        $self->{configuration}->{service}->{contacts};
    if ($contacts) {
        if ($contacts =~ /^\+(.+)/) {
            # complement original contacts
            $provider->contacts($provider->contacts(), split(/, */, $1));
        } else {
            # replace original contacts
            $provider->contacts(split(/, */, $contacts));
        }
    }
    ## Check that email_address is a known contact for this SP
    unless ($provider->is_contact($self->{in}->{email_address}))
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "internal";
        $self->{logger}->errorf(
            "Requested a token for %s for an unautorized address '%s'",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $self->{in}->{sp_entityid},
            $self->{in}->{email_address}
    # delete any previous token for the same email/service couple
    my $old_token = AccountManager::Token->new(
        db            => $self->{db},
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        email_address => $self->{in}->{email_address},
        sp_entityid   => $self->{in}->{sp_entityid}
    if ($old_token->load(speculative => 1)) {
        unless ($old_token->delete()) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            push @{ $self->{out}->{errors} }, "internal";
            $self->{logger}->errorf(
                "Failed to delete previous authentication token with ID %s",
                $old_token->id()
    # compute a new token
    my $validity_period =
        $self->{configuration}->{_}->{tokens_validity_period};
    my $token = AccountManager::Token->new(
        db              => $self->{db},
        email_address   => $self->{in}->{email_address},
        sp_entityid     => $self->{in}->{sp_entityid},
        creation_date   => DateTime->now(),
        expiration_date => DateTime->now()->add(hours => $validity_period),
        token           => AccountManager::Tools::generate_token()
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($token->save()) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "internal";
        $self->{logger}->error("Failed to save authentication token");
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    $self->{out}->{email_address} = $self->{in}->{email_address};
    $self->{out}->{sp_entityid}   = $self->{in}->{sp_entityid};
    $self->{out}->{subtitle}      = 'Generate an authentication token';
    my $sender    = $self->{configuration}->{_}->{notice_from};
    my $recipient = $self->{in}->{email_address};
    my $sendmail  = $self->{configuration}->{_}->{sendmail_path} ||
                    '/usr/sbin/sendmail';
    open(my $handle, '|-', "$sendmail -f $sender $recipient") or do {
        push @{ $self->{out}->{errors} }, "mail_notification_error";
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        $self->{logger}->errorf("Unable to run sendmail executable: %s", $ERRNO);

    my $tt2 = Template->new({
        INCLUDE_PATH => $self->{configuration}->{_}->{templates_dir}
    });
    my $template = 'mail/send_authentication_token.tt2.eml';
    my $data = {
        env => {
            REMOTE_HOST => $ENV{REMOTE_HOST},
            REMOTE_ADDR => $ENV{REMOTE_ADDR},
        },
        conf => {
            app_name      => $self->{configuration}->{app}->{name},
            app_url       => $self->{configuration}->{app}->{url},
            support_email => $self->{configuration}->{app}->{support_email},
        },
        from                 => $sender,
        to                   => $recipient,
        sp_entityid          => $self->{in}->{sp_entityid},
        authentication_token => $token->token(),
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($tt2->process($template, $data, $handle)) {
        push @{ $self->{out}->{errors} }, "mail_notification_error";
        $self->{logger}->errorf("Mail notification error: %s", $tt2->error());
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    close $handle;
    $self->{logger}->infof(
        "Token send to %s for sp_entityid=%s;token=%s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        $self->{in}->{email_address},
        $self->{in}->{sp_entityid},
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        $token->token(),
    );

    return 1;
}

## Validate an authentication token
## Test accounts get created
## Sample call : dev-edugain.renater.fr/accountmanager?action=validate_token&style=nobanner&sp_entityid=https%3A%2F%2Fsourcesup.cru.fr%2Fshibboleth&authentication_token=c1cfecb51ea40d39a695
sub req_validate_token {
    my ($self) = @_;
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($self->{in}->{sp_entityid}) {
        push @{ $self->{out}->{errors} }, "missing_sp_entityid";
        $self->{logger}->error("Missing parameter sp_entityid");
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    unless ($self->{in}->{authentication_token}) {
        push @{ $self->{out}->{errors} }, "missing_authentication_token";
        $self->{logger}->error("Missing parameter authentication_token");
    my $token = AccountManager::Token->new(
        db    => $self->{db},
        token => $self->{in}->{authentication_token}
    );
    if (! $token->load(speculative => 1)) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "wrong_token";
        $self->{logger}->errorf(
            "Failed to validate authentication token %s for sp_entityid %s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $self->{in}->{authentication_token},
            $self->{in}->{sp_entityid}
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    if (! $token->sp_entityid() eq $self->{in}->{sp_entityid}) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "wrong_token_for_sp";
        $self->{logger}->errorf(
            "Authentication token %s cannot be used for SP with entityid %s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $self->{in}->{authentication_token},
            $self->{in}->{sp_entityid}
    unless ($token->delete()) {
        $self->{logger}->errorf(
            "Failed to delete authentication token %s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $self->{in}->{authentication_token}
    my @accounts;
    my $entity = $self->{in}->{sp_entityid};
    my $profiles =
        $self->{configuration}->{$entity}->{account_profiles} ||
        $self->{configuration}->{service}->{account_profiles};
    my $validity_period =
        $self->{configuration}->{$entity}->{account_validity_period} ||
        $self->{configuration}->{service}->{account_validity_period};

    foreach my $profile (split(/, */, $profiles)) {
        my $password = AccountManager::Tools::generate_password();
        my $account = AccountManager::Account->new(
            db              => $self->{db},
            profile         => $profile,
            sp_entityid     => $entity,
            scope           => $self->{configuration}->{idp}->{scope},
            password        => $password,
            password_hash   => AccountManager::Tools::sha256_hash($password),
            creation_date   => DateTime->now(),
            expiration_date => DateTime->now()->add(days => $validity_period)
        next unless $account->save();
        push @accounts, $account;
    unless (@accounts) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "accounts_creation_failed";
        $self->{logger}->errorf(
            "Failed to create test accounts for SP with entityid %s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
            $self->{in}->{sp_entityid}
    }

    ## Update simpleSAMLphp configuration to enable test accounts
    my $accounts = AccountManager::Account::Manager->get_accounts(
    eval {
        AccountManager::Tools::update_ssp_authsources(
            $self->{configuration}->{_}->{templates_dir},
            $self->{configuration}->{idp}->{accounts_file},
        );
    };
    if ($EVAL_ERROR) {
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        push @{ $self->{out}->{errors} }, "accounts_creation_failed";
        $self->{logger}->errorf(
            "Failed to create simpleSAMLphp configuration file: %s",
            $EVAL_ERROR
    $self->{logger}->infof(
        "Token validated for sp_entityid=%s;token=%s",
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
        $self->{in}->{sp_entityid},
        $self->{in}->{authentication_token}
Guillaume ROUSSE's avatar
Guillaume ROUSSE committed
    $self->{out}->{sp_entityid}   = $self->{in}->{sp_entityid};
    $self->{out}->{accounts} = \@accounts;
    $self->{out}->{subtitle} = 'Complete Email Challenge';

    return 1;
}

## Return the homepage of the service
sub req_home {
    my ($self) = @_;