package AccountManager::App; use strict; use warnings; use CGI; use English qw(-no_match_vars); use Log::Any::Adapter; use List::MoreUtils qw(uniq); use Template; use Template::Constants qw(:chomp); use AccountManager::Account; use AccountManager::Account::Manager; use AccountManager::Metadata; use AccountManager::Service; use AccountManager::Token; use AccountManager::Tools; use AccountManager::L10N; # Format de type URL HTTP ou URN my $entity_id_pattern = qr{ ^ (?: https?://[\w.:/-]+ | urn:[\w.:-]+ ) $ }x; my %actions = ( home => 'req_home', start => 'req_start', 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', ); my $version = '1.1'; sub new { my ($pkg, %args) = @_; my $self = { configuration => $args{configuration}, }; bless $self, $pkg; 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', data => { errors => [ 'internal' ] } ); } 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', data => { errors => [ 'internal' ] } ); } 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 => [ split(/, */, $self->{configuration}->{database}->{options}) ] ); } $self->{db} = AccountManager::DB->new(); return $self; } sub run { 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 # register needed parameters $self->{in} = { email => $parameters{email}, entityid => $parameters{entityid}, token => $parameters{token}, key => $parameters{key}, federation => $parameters{federation}, }; } # process requested action my $action = $parameters{action} || 'home'; if ($actions{$action}) { $self->{logger}->debug("Processing action '$action'"); my $method = $actions{$action}; $self->$method(); } else { ## unknown action $self->{logger}->error( "Unknown action '$action'"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "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()); } exit 0; } sub req_start { my ($self) = @_; my @federations = keys %{$self->{configuration}->{federations}}; if (@federations == 1) { $self->{in}->{federation} = $federations[0]; $self->req_select_sp(); } else { $self->req_select_federation(); } } sub req_select_federation { my ($self) = @_; my @federations = keys %{$self->{configuration}->{federations}}; $self->respond( template => 'select_federation.tt2.html', data => { action => 'select_federation', federations => \@federations } ); } sub req_select_sp { my ($self) = @_; my $federation = $self->{in}->{federation}; if (!$federation) { $self->{logger}->error("Missing parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_federation" ] } ); } my $file = $self->{configuration}->{federations}->{$federation}; if (!$file) { $self->{logger}->error("Incorrect parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "invalid_federation" ] } ); } my $metadata; eval { $metadata = AccountManager::Metadata->new( file => $file ); }; if ($EVAL_ERROR) { $self->{logger}->error("Failed to load federation metadata: $EVAL_ERROR"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } $self->respond( template => 'select_sp.tt2.html', data => { action => 'select_sp', metadata => $metadata->parse(type => 'sp'), federation => $federation, } ); } sub req_select_email { my ($self) = @_; my $federation = $self->{in}->{federation}; if (!$federation) { $self->{logger}->error("Missing parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_federation" ] } ); } my $file = $self->{configuration}->{federations}->{$federation}; if (!$file) { $self->{logger}->error("Incorrect parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "invalid_federation" ] } ); } if (! $self->{in}->{entityid}) { $self->{logger}->error("Missing parameter: entityid"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_entityid" ] } ); } if ($self->{in}->{entityid} !~ $entity_id_pattern) { $self->{logger}->error("Incorrect parameter format: entityid"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "format_entityid" ] } ); } # Create a persistent service provider object my $sp = AccountManager::Service->new( db => $self->{db}, entityid => $self->{in}->{entityid} ); if ($sp->load(speculative => 1)) { # already present in DB, nothing todo } else { # extract information from metadata my $metadata; eval { $metadata = AccountManager::Metadata->new( file => $file ); }; if ($EVAL_ERROR) { $self->{logger}->error("Failed to load federation metadata: $EVAL_ERROR"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } my $entities = $metadata->parse(id => $self->{in}->{entityid}); if (!@$entities) { $self->{logger}->errorf( "No such SP '%s' in metadata", $self->{in}->{entityid} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "no_such_entity" ] } ); } my $entity = $entities->[0]; # complete persistent object $sp->displayname($entity->{display_name}); $sp->contacts(uniq map { $_->{EmailAddress} } @{$entity->{contacts}}) if $entity->{contacts}; # save in DB unless ($sp->save()) { $self->{logger}->error("Failed to save service provider object"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } } # override metadata contacts if needed my $id = $self->{in}->{entityid}; my $contacts = $self->{configuration}->{$id}->{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)); } } $self->respond( template => 'select_email.tt2.html', data => { action => 'select_email', federation => $federation, sp => $sp, entityid => $sp->entityid(), } ); } sub req_complete_challenge { my ($self) = @_; my $federation = $self->{in}->{federation}; if (!$federation) { $self->{logger}->error("Missing parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_federation" ] } ); } my $file = $self->{configuration}->{federations}->{$federation}; if (!$file) { $self->{logger}->error("Incorrect parameter: federation"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "invalid_federation" ] } ); } unless ($self->{in}->{entityid}) { $self->{logger}->error("Missing parameter entityid"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_entityid" ] } ); } unless ($self->{in}->{email}) { $self->{logger}->error("Missing parameter email"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_email" ] } ); } my $provider = AccountManager::Service->new( db => $self->{db}, entityid => $self->{in}->{entityid}, ); unless ($provider->load(speculative => 1)) { $self->{logger}->errorf("No such SP '%s' in database", $self->{in}->{entityid}); $self->respond( template => 'errors.tt2.html', data => { errors => [ "no_such_entity" ] } ); } # override metadata contacts if needed my $entity = $self->{in}->{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 is a known contact for this SP unless ($provider->is_contact($self->{in}->{email})) { $self->{logger}->errorf( "Requested a token for %s for an unautorized address '%s'", $self->{in}->{entityid}, $self->{in}->{email} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } # delete any previous token for the same email/service couple my $old_token = AccountManager::Token->new( db => $self->{db}, email_address => $self->{in}->{email}, sp_entityid => $self->{in}->{entityid} ); if ($old_token->load(speculative => 1)) { unless ($old_token->delete()) { $self->{logger}->errorf( "Failed to delete previous authentication token with ID %s", $old_token->id() ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } } # compute a new token eval "require DateTime"; my $validity_period = $self->{configuration}->{service}->{tokens_validity_period}; my $token = AccountManager::Token->new( db => $self->{db}, email_address => $self->{in}->{email}, sp_entityid => $self->{in}->{entityid}, creation_date => DateTime->now(), expiration_date => DateTime->now()->add(hours => $validity_period), token => AccountManager::Tools::generate_secret(20) ); unless ($token->save()) { $self->{logger}->error("Failed to save authentication token"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } # build content 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}, to => $self->{in}->{email}, entityid => $self->{in}->{entityid}, token => $token->token(), challenge_url => sprintf( '%s&action=complete_challenge&federation=%s&entity=%s&email=%s', $self->{configuration}->{app}->{url}, $self->{in}->{federation}, $self->{in}->{entityid}, $self->{in}->{email}, ), 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); # wrap in message eval "require Email::MIME"; eval "require Email::Sender::Simple"; my $email = Email::MIME->create( header_str => [ 'From' => sprintf('eduGAIN Access Check <%s>', $self->{configuration}->{mailer}->{from}), 'To' => $self->{in}->{email}, '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 ), ] ); eval { Email::Sender::Simple->send($email); }; if ($EVAL_ERROR) { $self->{logger}->errorf("Mail notification error: %s", $EVAL_ERROR); $self->respond( template => 'errors.tt2.html', data => { errors => [ "mail_notification_failure" ] } ); } $self->{logger}->infof( "Token send to %s for entityid=%s;token=%s", $self->{in}->{email}, $self->{in}->{entityid}, $token->token(), ); $self->respond( template => 'complete_challenge.tt2.html', data => { action => 'complete_challenge', federation => $federation, entityid => $self->{in}->{entityid}, email => $self->{in}->{email}, } ); } sub req_create_accounts { my ($self) = @_; unless ($self->{in}->{entityid}) { $self->{logger}->error("Missing parameter entityid"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_entityid" ] } ); } unless ($self->{in}->{token}) { $self->{logger}->error("Missing parameter token"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_token" ] } ); } unless ($self->{in}->{email}) { $self->{logger}->error("Missing parameter email"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_email" ] } ); } my $token = AccountManager::Token->new( db => $self->{db}, token => $self->{in}->{token} ); if (! $token->load(speculative => 1)) { $self->{logger}->errorf( "Failed to validate authentication token %s for entityid %s", $self->{in}->{token}, $self->{in}->{entityid} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "wrong_token" ] } ); } if (! $token->sp_entityid() eq $self->{in}->{entityid}) { $self->{logger}->errorf( "Authentication token %s cannot be used for SP with entityid %s", $self->{in}->{token}, $self->{in}->{entityid} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "wrong_token_for_sp" ] } ); } ## delete the token unless ($token->delete()) { $self->{logger}->errorf( "Failed to delete authentication token %s", $self->{in}->{token} ); } ## create test accounts my @accounts; my $entity = $self->{in}->{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}; eval "require DateTime"; my $download_token = AccountManager::Token->new( db => $self->{db}, email_address => $self->{in}->{email}, sp_entityid => $self->{in}->{entityid}, creation_date => DateTime->now(), expiration_date => DateTime->now()->add(hours => $validity_period), token => AccountManager::Tools::generate_secret(20) ); unless ($download_token->save()) { $self->{logger}->error("Failed to save authentication token"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "internal" ] } ); } 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, sp_entityid => $entity, scope => $self->{configuration}->{idp}->{scope}, password => $password, password_crypt => AccountManager::Tools::encrypt($password, $key), password_hash => AccountManager::Tools::sha256_hash($password), token => $download_token->token(), creation_date => DateTime->now(), expiration_date => DateTime->now()->add(days => $validity_period) ); next unless $account->save(); push @accounts, $account; } unless (@accounts) { $self->{logger}->errorf( "Failed to create test accounts for SP with entityid %s", $self->{in}->{entityid} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "accounts_creation_failure" ] } ); } ## Update simpleSAMLphp configuration to enable test accounts my $accounts = AccountManager::Account::Manager->get_accounts( db => $self->{db} ); eval { AccountManager::Tools::update_ssp_authsources( $self->{configuration}->{setup}->{templates_dir}, $self->{configuration}->{setup}->{accounts_file}, $accounts ); }; if ($EVAL_ERROR) { $self->{logger}->errorf( "Failed to create simpleSAMLphp configuration file: %s", $EVAL_ERROR ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "accounts_creation_failed" ] } ); } $self->{logger}->infof( "Token validated for entityid=%s;token=%s", $self->{in}->{entityid}, $self->{in}->{token} ); $self->respond( template => 'create_accounts.tt2.html', data => { action => 'create_accounts', accounts => \@accounts, entityid => $self->{in}->{entityid}, email => $self->{in}->{email}, key => $key, token => $download_token->token(), days => $validity_period, } ); } sub req_download_accounts { my ($self) = @_; unless ($self->{in}->{entityid}) { $self->{logger}->error("Missing parameter entityid"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_entityid" ] } ); } unless ($self->{in}->{token}) { $self->{logger}->error("Missing parameter token"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_token" ] } ); } unless ($self->{in}->{key}) { $self->{logger}->error("Missing parameter key"); $self->respond( template => 'errors.tt2.html', data => { errors => [ "missing_key" ] } ); } my $token = AccountManager::Token->new( db => $self->{db}, token => $self->{in}->{token} ); if (! $token->load(speculative => 1)) { $self->{logger}->errorf( "Non-existing authentication token %s", $self->{in}->{token}, ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "wrong_token" ] } ); } if (! $token->sp_entityid() eq $self->{in}->{entityid}) { $self->{logger}->errorf( "Authentication token %s cannot be used for SP %s", $self->{in}->{token}, $self->{in}->{entityid} ); $self->respond( template => 'errors.tt2.html', data => { errors => [ "wrong_token_for_sp" ] } ); } # delete the token unless ($token->delete()) { $self->{logger}->errorf( "Failed to delete authentication token %s", $self->{in}->{token} ); } # load accounts from database my $accounts = AccountManager::Account::Manager->get_accounts( db => $self->{db}, query => [ token => $self->{in}->{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( $account->password_crypt(), $self->{in}->{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) = @_; $self->respond( template => 'home.tt2.html', data => { action => 'home' } ); } 1;