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 requested action my $action = $self->{cgi}->param('action') || 'home'; if ($actions{$action}) { $self->{logger}->debug("Processing action '$action'"); my $method = $actions{$action}; $self->$method(); } else { $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()); } 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} ] } ); } sub req_start { my ($self, %args) = @_; my @federations = keys %{$self->{configuration}->{federations}}; if (@federations == 1) { $self->req_select_sp(federation => $federations[0]); } else { $self->req_select_federation(); } } sub req_select_federation { my ($self, %args) = @_; 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, %args) = @_; my $federation = $args{federation} || $self->{cgi}->param('federation'); $self->abort( log => "Missing parameter: federation", user => "missing_federation" ) if !$federation; my $file = $self->{configuration}->{federations}->{$federation}; $self->abort( log => "Incorrect parameter: federation", user => "invalid_federation" ) if !$file; my $metadata; eval { $metadata = AccountManager::Metadata->new( file => $file ); }; $self->abort( log => "Failed to load federation metadata: $EVAL_ERROR", user => "internal" ) if $EVAL_ERROR; $self->respond( template => 'select_sp.tt2.html', data => { action => 'select_sp', metadata => $metadata->parse(type => 'sp'), federation => $federation, } ); } sub req_select_email { my ($self, %args) = @_; my $federation = $self->{cgi}->param('federation'); $self->abort( log => "Missing parameter: federation", user => "missing_federation" ) if !$federation; my $file = $self->{configuration}->{federations}->{$federation}; $self->abort( log => "Incorrect parameter: federation", user => "invalid_federation" ) if !$file; my $entityid = $self->{cgi}->param('entityid'); $self->abort( log => "Missing parameter: entityid", user => "missing_entityid" ) if !$entityid; $self->abort( log => "Incorrect parameter format: entityid", user => "format_entityid" ) if $entityid !~ $entity_id_pattern; # Create a persistent service provider object my $sp = AccountManager::Service->new( db => $self->{db}, entityid => $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 ); }; $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 => sprintf("No such SP '%s' in metadata", $entityid), 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 object", user => "internal" ) if !$sp->save(); } # override metadata contacts if needed my $contacts = $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)); } } $self->respond( template => 'select_email.tt2.html', data => { action => 'select_email', federation => $federation, sp => $sp, entityid => $entityid, } ); } sub req_complete_challenge { my ($self, %args) = @_; my $federation = $self->{cgi}->param('federation'); $self->abort( log => "Missing parameter: federation", user => "missing_federation" ) if !$federation; my $file = $self->{configuration}->{federations}->{$federation}; $self->abort( log => "Incorrect parameter: federation", user => "invalid_federation" ) if !$file; my $entityid = $self->{cgi}->param('entityid'); $self->abort( log => "Missing parameter: entityid", user => "missing_entityid" ) if !$entityid; $self->abort( log => "Incorrect parameter format: entityid", user => "format_entityid" ) if $entityid !~ $entity_id_pattern; my $email = $self->{cgi}->param('email'); $self->abort( log => "Missing parameter: email", user => "missing_email" ) if !$email; my $provider = AccountManager::Service->new( db => $self->{db}, entityid => $entityid, ); $self->abort( log => sprintf("No such SP '%s' in database", $entityid), user => "no_such_entity" ) if !$provider->load(speculative => 1); # override metadata contacts if needed my $contacts = $self->{configuration}->{$entityid}->{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 $self->abort( log => sprintf( "Requested a token for %s for an unautorized address '%s'", $entityid, $email ), user => "internal", ) if !$provider->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 => sprintf("Failed to delete previous authentication token with ID %s", $old_token->id()), 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( db => $self->{db}, email_address => $email, sp_entityid => $entityid, creation_date => DateTime->now(), expiration_date => DateTime->now()->add(hours => $validity_period), token => AccountManager::Tools::generate_secret(20) ); $self->abort( log => "Failed to save service authentication token", user => "internal" ) if !$token->save(); # 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 => $email, entityid => $entityid, token => $token->token(), challenge_url => sprintf( '%s&action=complete_challenge&federation=%s&entity=%s&email=%s', $self->{configuration}->{app}->{url}, $federation, $entityid, $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 $message = Email::MIME->create( header_str => [ 'From' => sprintf('eduGAIN Access Check <%s>', $self->{configuration}->{mailer}->{from}), 'To' => $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($message); }; $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", $email, $entityid, $token->token(), ); $self->respond( template => 'complete_challenge.tt2.html', data => { action => 'complete_challenge', federation => $federation, entityid => $entityid, email => $email, } ); } sub req_create_accounts { my ($self, %args) = @_; my $entityid = $self->{cgi}->param('entityid'); $self->abort( log => "Missing parameter: entityid", user => "missing_entityid" ) if !$entityid; $self->abort( log => "Incorrect parameter format: entityid", user => "format_entityid" ) if $entityid !~ $entity_id_pattern; my $token_secret = $self->{cgi}->param('token'); $self->abort( log => "Missing parameter: token", user => "missing_token" ) if !$token_secret; my $email = $self->{cgi}->param('email'); $self->abort( log => "Missing parameter: email", user => "missing_email" ) if !$email; my $token = AccountManager::Token->new( db => $self->{db}, token => $token_secret ); $self->abort( log => sprintf( "Failed to validate authentication token %s for entityid %s", $token_secret, $entityid ), user => "wrong_token" ) if !$token->load(speculative => 1); $self->abort( log => sprintf( "Authentication token %s cannot be used for SP with entityid %s", $token_secret, $entityid ), user => "wrong_token_for_sp" ) if $token->sp_entityid() ne $entityid; ## delete the token unless ($token->delete()) { $self->{logger}->errorf( "Failed to delete authentication token %s", $token_secret ); } ## create test accounts my @accounts; my $profiles = $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), token => 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, sp_entityid => $entityid, 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; } $self->abort( log => sprintf( "Failed to create test accounts for SP with entityid %s", $entityid ), user => "accounts_creation_failure" ) if !@accounts; ## 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 ); }; $self->abort( log => sprintf( "Failed to create simpleSAMLphp configuration file: %s", $EVAL_ERROR ), user => "accounts_creation_failed" ) if $EVAL_ERROR; $self->{logger}->infof( "Token validated for entityid=%s;token=%s", $entityid, $token_secret ); $self->respond( template => 'create_accounts.tt2.html', data => { action => 'create_accounts', accounts => \@accounts, entityid => $entityid, email => $email, key => $key, token => $download_token->token(), days => $validity_period, } ); } sub req_download_accounts { my ($self) = @_; my $entityid = $self->{cgi}->param('entityid'); $self->abort( log => "Missing parameter: entityid", user => "missing_entityid" ) if !$entityid; $self->abort( log => "Incorrect parameter format: entityid", user => "format_entityid" ) if $entityid !~ $entity_id_pattern; my $token_secret = $self->{cgi}->param('token'); $self->abort( log => "Missing parameter: token", user => "missing_token" ) if !$token_secret; my $key = $self->{cgi}->param('key'); $self->abort( log => "Missing parameter: key", user => "missing_key" ) if !$key; my $token = AccountManager::Token->new( db => $self->{db}, token => $token_secret ); $self->abort( log => sprintf( "Failed to validate authentication token %s for entityid %s", $token_secret, $entityid ), user => "wrong_token" ) if !$token->load(speculative => 1); $self->abort( log => sprintf( "Authentication token %s cannot be used for SP with entityid %s", $token_secret, $entityid ), user => "wrong_token_for_sp" ) if $token->sp_entityid() ne $self->{in}->{entityid}; # delete the token unless ($token->delete()) { $self->{logger}->errorf( "Failed to delete authentication token %s", $token_secret ); } # 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;