package AccountManager::App; use strict; use warnings; use CGI::Simple; use CGI::Simple::Cookie; use DateTime; use English qw(-no_match_vars); use Log::Any::Adapter; use List::MoreUtils qw(any uniq); use Template; use Template::Constants qw(:chomp); use UNIVERSAL::require; use AccountManager::Account; use AccountManager::Metadata; use AccountManager::Entity; use AccountManager::Token; use AccountManager::Tools; use AccountManager::L10N; use AccountManager::Template::Plugin::Quote; # Format de type URL HTTP ou URN my %patterns = ( entityid => qr{ ^ (?: https?://[\w.:/-]+ | urn:[\w.:-]+ ) $ }x ); my %actions = ( home => 'req_home', status => 'req_status', 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.2.0'; 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::Simple->new(); my $lang; my $cookies = CGI::Simple::Cookie->fetch(); if ($self->{cgi}->param('lang')) { $lang = $self->{cgi}->param('lang'); $self->{lh} = AccountManager::L10N->get_handle($lang); $self->{logger}->debugf("setting language from parameter: %s", $lang); } elsif ($cookies->{lang}) { $lang = $cookies->{lang}->value(); $self->{lh} = AccountManager::L10N->get_handle($lang); $self->{logger}->debugf("setting language from cookie: %s", $lang); } elsif ($lang = $ENV{HTTP_ACCEPT_LANGUAGE}) { $lang = I18N::LangTags::Detect::detect(); if ($lang =~ /^(\w\w)-(\w\w)$/) { $lang = $1; } $self->{lh} = AccountManager::L10N->get_handle($lang); $self->{logger}->debugf("setting language from HTTP_ACCEPT_LANGUAGE header: %s", $lang); } else { $self->{lh} = AccountManager::L10N->get_handle('en'); $self->{logger}->debugf("using default language"); } $self->{lh}->load_custom_lexicon($args{custom_l10n}); 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}->{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 => $self->{configuration}->{database}->{options} ? [ split(/, */, $self->{configuration}->{database}->{options}) ] : undef, ); } $self->{db} = AccountManager::DB->new(); return $self; } sub run { my ($self) = @_; if ($self->{logger}->is_debug()) { Data::Dump->require(); my %parameters = $self->{cgi}->Vars(); $self->{logger}->debugf("input parameters: %s", Data::Dump::dump(\%parameters)); } # 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}, login_url => $self->{configuration}->{app}->{login_url}, name => $self->{configuration}->{app}->{name}, support_email => $self->{configuration}->{app}->{support_email}, version => $version, }; $in{data}->{lh} = $self->{lh}; my $templates_dir = $self->{configuration}->{setup}->{templates_dir}; my $templates_theme = $self->{configuration}->{setup}->{templates_theme} || 'default'; my $tt2 = Template->new({ ENCODING => 'utf8', PRE_CHOMP => CHOMP_ONE, INCLUDE_PATH => [ sprintf("%s/web/%s", $templates_dir, $templates_theme), sprintf("%s/web", $templates_dir), sprintf("%s/accounts", $templates_dir) ] }); $self->{logger}->debug("Responding with template '$in{template}'"); binmode(STDOUT, ":encoding(UTF-8)"); my $cookie = CGI::Simple::Cookie->new( -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_select_sp { my ($self, %args) = @_; $self->check_authentication(action => 'select_sp') if $self->{configuration}->{app}->{login_url}; my $sps = AccountManager::Entity->get_entities( db => $self->{db}, query => [ type => 'sp', ], sort_by => 'display_name' ); my $idp; if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) { my $idps = AccountManager::Entity->get_entities( db => $self->{db}, query => [ type => 'idp', entityid => $ENV{HTTP_SHIB_IDENTITY_PROVIDER}, ] ); $idp = $idps->[0]; } $self->respond( template => 'select_sp.tt2.html', data => { action => 'select_sp', sps => $sps, idp => $idp, } ); } sub req_select_email { my ($self, %args) = @_; $self->check_authentication(action => 'select_email') if $self->{configuration}->{app}->{login_url}; my $entityid = $self->get_parameter(name => 'entityid'); my $sp = AccountManager::Entity->new( db => $self->{db}, entityid => $entityid ); $self->abort( log => sprintf("No such SP '%s' in database", $entityid), user => "no_such_entity" ) if !$sp->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 $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', sp => $sp, entityid => $entityid, } ); } sub req_complete_challenge { my ($self, %args) = @_; $self->check_authentication(action => 'complete_challenge') if $self->{configuration}->{app}->{login_url}; my $entityid = $self->get_parameter(name => 'entityid'); my $email = $self->get_parameter(name => 'email'); my $sp = AccountManager::Entity->new( db => $self->{db}, entityid => $entityid, ); $self->abort( log => sprintf("No such SP '%s' in database", $entityid), user => "no_such_entity" ) if !$sp->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 $sp->contacts($sp->contacts(), split(/, */, $1)); } else { # replace original contacts $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", ) 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, 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 my $validity_period = $self->{configuration}->{service}->{tokens_validity_period}; my $token = AccountManager::Token->new( db => $self->{db}, email_address => $email, 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 $theme_templates_dir = sprintf( "%s/mail/%s", $self->{configuration}->{setup}->{templates_dir}, $self->{configuration}->{setup}->{templates_theme} || 'default' ); my $default_templates_dir = sprintf( "%s/mail", $self->{configuration}->{setup}->{templates_dir}, ); my $templates_dir = -d $theme_templates_dir ? $theme_templates_dir : $default_templates_dir; # build content my $tt2 = Template->new({ ENCODING => 'utf8', PRE_CHOMP => CHOMP_ONE, INCLUDE_PATH => $templates_dir }); my $source_ip = get_source_address(); my $user = $ENV{'HTTP_DISPLAYNAME'} ? $ENV{'HTTP_DISPLAYNAME'} : $ENV{'displayName'} ? $ENV{'displayName'} : undef; my $idp = $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} ? $ENV{'HTTP_SHIB_IDENTITY_PROVIDER'} : $ENV{'Shib-Identity-Provider'} ? $ENV{'Shib-Identity-Provider'} : undef; my $data = { app => { url => $self->{configuration}->{app}->{url}, support_email => $self->{configuration}->{app}->{support_email}, version => $self->{configuration}->{app}->{version}, name => $self->{configuration}->{app}->{name}, }, user => $user, source_ip => $source_ip, idp => { entityid => $idp, }, sp => { entityid => $entityid, }, to => $email, token => $token->secret(), challenge_url => sprintf( '%s?action=complete_challenge&entityid=%s&email=%s', $self->{configuration}->{app}->{url}, $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 Email::MIME->require(); Email::Sender::Simple->require(); my $message = Email::MIME->create( header_str => [ 'From' => sprintf( '%s <%s>', $self->{configuration}->{app}->{name}, $self->{configuration}->{mailer}->{from} ), 'To' => $email, 'Subject' => sprintf( '[%s] %s', $self->{configuration}->{app}->{name}, $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 { local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin'; 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->secret(), ); $self->respond( template => 'complete_challenge.tt2.html', data => { action => 'complete_challenge', entityid => $entityid, email => $email, } ); } sub req_create_accounts { my ($self, %args) = @_; $self->check_authentication(action => 'create_accounts') if $self->{configuration}->{app}->{login_url}; my $entityid = $self->get_parameter(name => 'entityid'); my $token = $self->get_parameter(name => 'token'); my $email = $self->get_parameter(name => 'email'); $self->check_token(token => $token, entityid => $entityid); my $sp = AccountManager::Entity->new( db => $self->{db}, entityid => $entityid, ); $self->abort( log => sprintf("No such SP '%s' in database", $entityid), user => "no_such_entity" ) if !$sp->load(speculative => 1); ## 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}; my $download_token = AccountManager::Token->new( db => $self->{db}, email_address => $email, 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, 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->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}, $accounts ); }; $self->abort( log => "Failed to create simpleSAMLphp configuration file: $EVAL_ERROR", user => "accounts_creation_failure" ) if $EVAL_ERROR; $self->{logger}->infof( "Token validated for entityid=%s", $entityid, ); my $download_url = sprintf( "%s?action=download_accounts&entityid=%s&token=%s&key=%s", $self->{configuration}->{app}->{url}, $entityid, $download_token->secret(), $key ); $self->respond( template => 'create_accounts.tt2.html', data => { action => 'create_accounts', accounts => \@accounts, idp => { name => $self->{configuration}->{idp}->{name}, }, sp => { entityid => $entityid, url => $sp->information_url(), }, email => $email, download_url => $download_url, days => $validity_period, } ); } sub req_download_accounts { my ($self) = @_; $self->check_authentication(action => 'download_accounts') if $self->{configuration}->{app}->{login_url}; my $entityid = $self->get_parameter(name => 'entityid'); my $token = $self->get_parameter(name => 'token'); my $key = $self->get_parameter(name => 'key'); $self->check_token(token => $token, entityid => $entityid); # load accounts from database my $accounts = AccountManager::Account->get_accounts( db => $self->{db}, query => [ token => $token ], ); foreach my $account (@$accounts) { my $password = AccountManager::Tools::decrypt( $account->password_crypt(), $key ); $account->password($password); } binmode(STDOUT, ":encoding(UTF-8)"); print $self->{cgi}->header( -type => 'text/csv', -content_disposition => 'attachment; filename="accounts.csv"' ); my $templates_dir = $self->{configuration}->{setup}->{templates_dir}; my $tt2 = Template->new({ ENCODING => 'utf8', PRE_CHOMP => CHOMP_ONE, INCLUDE_PATH => [ sprintf("%s/other", $templates_dir), sprintf("%s/accounts", $templates_dir), ], }); unless ($tt2->process("accounts.csv.tt2", { accounts => $accounts }, \*STDOUT)) { printf "Content-type: text/plain\n\n Error: %s", $tt2->error(); $self->{logger}->errorf("Web parser error : %s", $tt2->error()); } } ## Return the homepage of the service sub req_home { my ($self) = @_; $self->respond( template => 'home.tt2.html', data => { action => 'home' } ); } sub req_status { my ($self) = @_; Net::IP->require(); my $source_ip_string = get_source_address(); my $source_ip = Net::IP->new($source_ip_string); my @allowed_ips_strings = $self->{configuration}->{status}->{allowed} ? split(/, */, $self->{configuration}->{status}->{allowed}) : (); my @allowed_ips = map { Net::IP->new($_) } @allowed_ips_strings; if (any { $_->overlaps($source_ip) } @allowed_ips) { Sys::Hostname->require(); JSON->require(); print $self->{cgi}->header( -type => 'application/json', -charset => 'utf8', ); my $status = $self->{configuration}->{status}->{disabled} ? 'disabled' : 'available'; print JSON->new()->encode({ status => $status, host => Sys::Hostname::hostname() }); } else { $self->{logger}->errorf("Unauthorized access from %s", $source_ip_string); print $self->{cgi}->header( -status => '403 unauthorized', -type => 'text/plain', ); print "Unauthorized access"; } exit 0; } 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" ) if $value !~ $patterns{$name}; } return $value; } sub get_metadata_file { my ($self, %args) = @_; my $federation = $args{federation}; my $file = $self->{configuration}->{$federation}->{metadata}; $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", user => "wrong_token" ) if !$token->load(speculative => 1); $self->abort( log => "Authentication token $secret cannot be used for SP $args{entityid}", user => "wrong_token_for_sp" ) if $token->entityid() ne $args{entityid}; ## delete the token unless ($token->delete()) { $self->{logger}->errorf( "Failed to delete authentication token %s", $secret ); } } sub check_authentication { my $self = shift; my %args = @_; $self->abort( log => "unauthenticated user for action $args{action}", user => "unauthenticated" ) if !$ENV{HTTP_SHIB_IDENTITY_PROVIDER}; $self->abort( log => "no displayName attribute for identity provider $ENV{HTTP_SHIB_IDENTITY_PROVIDER}", user => "no_displayname_attribute" ) if !$ENV{'HTTP_DISPLAYNAME'}; } sub get_source_address { return $ENV{HTTP_X_FORWARDED_FOR} ? (split(/, /, $ENV{HTTP_X_FORWARDED_FOR}))[0] : $ENV{REMOTE_ADDR}; } 1;