From 87d3e349be8404121ff62b02b0a27711b0aaa2ad Mon Sep 17 00:00:00 2001 From: Guillaume Rousse <guillaume.rousse@renater.fr> Date: Tue, 1 Feb 2022 10:09:24 +0100 Subject: [PATCH] convert to Mojolicious app --- lib/AccountManager/App.pm | 857 ++------------------------- lib/AccountManager/App/Controller.pm | 143 +++++ lib/AccountManager/App/Home.pm | 24 + lib/AccountManager/App/Status.pm | 67 +++ lib/AccountManager/App/Step1.pm | 55 ++ lib/AccountManager/App/Step2.pm | 59 ++ lib/AccountManager/App/Step3.pm | 206 +++++++ lib/AccountManager/App/Step4.pm | 141 +++++ lib/AccountManager/App/Step5.pm | 66 +++ lib/Makefile.am | 14 +- 10 files changed, 828 insertions(+), 804 deletions(-) create mode 100644 lib/AccountManager/App/Controller.pm create mode 100644 lib/AccountManager/App/Home.pm create mode 100644 lib/AccountManager/App/Status.pm create mode 100644 lib/AccountManager/App/Step1.pm create mode 100644 lib/AccountManager/App/Step2.pm create mode 100644 lib/AccountManager/App/Step3.pm create mode 100644 lib/AccountManager/App/Step4.pm create mode 100644 lib/AccountManager/App/Step5.pm diff --git a/lib/AccountManager/App.pm b/lib/AccountManager/App.pm index 96bebd2..903c262 100644 --- a/lib/AccountManager/App.pm +++ b/lib/AccountManager/App.pm @@ -1,834 +1,89 @@ package AccountManager::App; -use strict; -use warnings; +use Mojo::Base qw(Mojolicious); -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 Mojolicious::Plugin::TemplateToolkit; +use Mojolicious::Plugin::ClientIP; use Template::Constants qw(:chomp); +use Syntax::Keyword::Try; 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; +use constant { + ACCESSCHECK_VERSION => '1.2.0' +}; -# 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 { +sub startup { 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, + $self->plugin('INIConfig', { file => $ENV{MOJO_CONFIG} || 'conf/manager.conf' }); + + $self->plugin( + 'TemplateToolkit', + { + name => 'tt2', + template => { + ABSOLUTE => 1, + ENCODING => 'utf8', + PRE_CHOMP => CHOMP_ONE, + PLUGIN_BASE => 'AccountManager::Template::Plugin', + } } ); -} - -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, - ); + $self->plugin('ClientIP'); - if ($old_token->load(speculative => 1)) { - $self->abort( - log => "Failed to delete old authentication token", - user => "internal" - ) if !$old_token->delete(); - } + my $config = $self->config(); - # 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->log( + Mojo::Log->new( + path => $config->{logger}->{file}, + level => ($config->{logger}->{level} || 'trace') + ) ); - $self->abort( - log => "Failed to save creation authentication token", - user => "internal" - ) if !$token->save(); + my $theme = $config->{setup}->{templates_theme} || 'default'; + my $base_templates_dir = $self->home()->child('templates'); - 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; + my $renderer = $self->renderer(); + $renderer->default_handler('tt2'); + $renderer->paths([ + $base_templates_dir->child('web', $theme), + $base_templates_dir->child('web'), + $base_templates_dir->child('accounts'), + ]); - # 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 = { + $self->defaults( 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, + support_url => $config->{app}->{support_url}, + support_email => $config->{app}->{support_email}, + login_url => $config->{app}->{login_url}, + logout_url => $config->{app}->{logout_url}, + name => $config->{app}->{name}, + version => ACCESSCHECK_VERSION }, - 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(), - ); - - my @profiles = - map { m/([^\/]+).tt2$/ } - glob($self->{configuration}->{setup}->{templates_dir} . '/accounts/*.tt2' ); - - $self->respond( - template => 'complete_challenge.tt2.html', - data => { - action => 'complete_challenge', - entityid => $entityid, - email => $email, - validity => $self->{configuration}->{service}->{account_validity_period}, - profiles => \@profiles - } - ); -} - -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'); - my $validity = $self->get_parameter(name => 'validity'); - my @profiles = $self->get_multivalued_parameter(name => 'profiles'); - - $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 $creation_date = DateTime->now(); - my $token_expiration_date = DateTime->now()->add( - hours => $self->{configuration}->{service}->{tokens_validity_period} - ); - my $account_expiration_date = DateTime->now()->add( - days => $validity - ); - - my $download_token = AccountManager::Token->new( - db => $self->{db}, - email_address => $email, - entityid => $entityid, - creation_date => $creation_date, - expiration_date => $token_expiration_date, - 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 (@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 => $creation_date, - expiration_date => $account_expiration_date, - ); - 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, - } - ); -} - -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), - ], - }); + my $routes = $self->routes(); - 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()); - } -} + $routes->get('/')->to(controller => 'home', action => 'run')->name('home'); + $routes->get('/status')->to(controller => 'status', action => 'run')->name('status'); + $routes->get('/step1')->to(controller => 'step1', action => 'run')->name('step1'); + $routes->get('/step2')->to(controller => 'step2', action => 'run')->name('step2'); + $routes->get('/step3')->to(controller => 'step3', action => 'run')->name('step3'); + $routes->get('/step4')->to(controller => 'step4', action => 'run')->name('step4'); + $routes->get('/step5')->to(controller => 'step5', action => 'run')->name('step5'); -## Return the homepage of the service -sub req_home { - my ($self) = @_; + $self->helper( + string_to_list => sub { + my $self = shift; + my $value = shift; - $self->respond( - template => 'home.tt2.html', - data => { - action => 'home' + return defined $value ? split(/, */, $value) : (); } ); -} - -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_multivalued_parameter { - my ($self, %args) = @_; - - my $name = $args{name}; - my @values = $self->{cgi}->param($name); - - $self->abort( - log => "Missing parameter: $name", - user => "missing_$name" - ) if !@values; - - if ($patterns{$name}) { - $self->abort( - log => "Incorrect parameter format: $name", - user => "format_$name" - ) if any { $_ !~ $patterns{$name} } @values; - } - - return @values; -} - -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; diff --git a/lib/AccountManager/App/Controller.pm b/lib/AccountManager/App/Controller.pm new file mode 100644 index 0000000..6b40e97 --- /dev/null +++ b/lib/AccountManager/App/Controller.pm @@ -0,0 +1,143 @@ +package AccountManager::App::Controller; + +use Mojo::Base qw(Mojolicious::Controller); + +use English qw(-no_match_vars); +use Syntax::Keyword::Try; + +use AccountManager::DB; +use AccountManager::L10N; +use AccountManager::Token; + +sub init_l10n { + my $self = shift; + + my $log = $self->app()->log(); + + # lang identification first, as needed for any further error message + my ($l10n, $lang); + if ($self->param('lang')) { + $lang = $self->param('lang'); + $l10n = AccountManager::L10N->get_handle($lang); + $log->debug(sprintf("setting language from parameter: %s", $lang)); + } elsif ($self->session('lang')) { + $lang = $self->session('lang'); + $l10n = AccountManager::L10N->get_handle($lang); + $log->debug(sprintf("setting language from session: %s", $lang)); + } elsif ($self->req()->headers->header('Accept-Language')) { + $l10n = AccountManager::L10N->get_handle(); + $lang = $l10n->language_tag(); + $log->debug(sprintf("setting language from Accept-Language header: %s", $lang)); + } else { + $lang = 'en'; + $l10n = AccountManager::L10N->get_handle($lang); + } + + $self->session(lang => $lang); + $self->stash(lang => $lang); + $self->stash(l10n => $l10n); + + return $l10n; +} + +sub init_db { + my $self = shift; + + my $config = $self->app()->config(); + + AccountManager::DB->register_db( + driver => $config->{database}->{type}, + database => $config->{database}->{name}, + host => $config->{database}->{host}, + password => $config->{database}->{password}, + username => $config->{database}->{username}, + options => [ $self->string_to_list($config->{database}->{options}) ] + ); + + my $db; + try { + $db = AccountManager::DB->new(); + } catch { + } + + $self->stash(db => $db); + + return $db; +} + +sub check_authentication { + my $self = shift; + + my $idp = + $ENV{'Shib_Identity_Provider'} || # local SP + $self->req()->headers()->header('Shib-Identity-Provider'); # remote SP + + return $self->abort( + status => 401, + log_message => sprintf("unauthenticated user for action %s", $self->current_route()), + user_message => Registry::Error::AuthenticationRequired->new() + ) if !$idp; + + return 1; + +} + +sub check_token { + my ($self, %args) = @_; + + my $secret = $args{token}; + + my $token = AccountManager::Token->new( + db => $self->{db}, + secret => $secret + ); + + return $self->abort( + status => 400, + log_message => "No such authentication token $secret", + user_message => "wrong_token" + ) if !$token->load(speculative => 1); + + return $self->abort( + status => 400, + log_message => "Authentication token $secret cannot be used for SP $args{entityid}", + user_message => "wrong_token_for_sp" + ) if $token->entityid() ne $args{entityid}; + + ## delete the token + try { + $token->delete(); + } catch { + $self->app()->log()->error( + sprintf("Failed to delete authentication token %s", $secret) + ); + } + + return 1; +} + +sub abort { + my $self = shift; + my %args = @_; + + my $status = $args{status} || 200; + my $format = $args{format} || 'html'; + + my $db = $self->stash('db'); + $db->rollback() if $db && $db->in_transaction(); + + $self->app()->log()->error($args{log_message}) if $args{log_message}; + + $self->stash(error => $args{user_message}); + $self->render(status => $status, template => 'errors', format => 'html'); + + return; +} + +sub loc { + my $self = shift; + + return $self->stash('l10n')->maketext(@_); +} + +1; diff --git a/lib/AccountManager/App/Home.pm b/lib/AccountManager/App/Home.pm new file mode 100644 index 0000000..40f479c --- /dev/null +++ b/lib/AccountManager/App/Home.pm @@ -0,0 +1,24 @@ +package AccountManager::App::Home; + +=head1 NAME + +AccountManager::App::Home - Home page controller + +=head1 DESCRIPTION + +=cut + +use Mojo::Base qw(AccountManager::App::Controller); + +use English qw(-no_match_vars); +use Syntax::Keyword::Try; + +sub run { + my $self = shift; + + $self->init_l10n(); + + $self->render(status => 200, template => 'home', format => 'html'); +} + +1; diff --git a/lib/AccountManager/App/Status.pm b/lib/AccountManager/App/Status.pm new file mode 100644 index 0000000..38d755c --- /dev/null +++ b/lib/AccountManager/App/Status.pm @@ -0,0 +1,67 @@ +package AccountManager::App::Status; + +=head1 NAME + +AccountManager::App::Status - Health monitoring controller + +=head1 DESCRIPTION + +Health monitoring page + +Access: restricted by IP address + +=cut + +use Mojo::Base qw(AccountManager::App::Controller); + +use English qw(-no_match_vars); +use List::MoreUtils qw(none); +use Net::IP; +use Sys::Hostname; +use Syntax::Keyword::Try; + +=head1 INSTANCE METHODS + +=head2 run() + +Return the health status of the frontend. + +=cut + +sub run { + my $self = shift; + + my $config = $self->app()->config(); + + if (!$config->{status}) { + $self->render( + status => 403, + test => "unauthorized access" + ); + return; + } + + my $client_ip = Net::IP->new($self->client_ip()); + my @allowed_ips = + map { Net::IP->new($_) } + $self->string_to_list($config->{status}->{allowed}); + + if (none { $_->overlaps($client_ip) } @allowed_ips) { + $self->render( + status => 403, + test => "unauthorized access" + ); + return; + } + + my $status = $config->{status}->{disabled} ? 'disabled' : 'available'; + + my $health = { + status => $status, + host => hostname(), + }; + + $self->render(status => 200, json => $health); +} + +1; diff --git a/lib/AccountManager/App/Step1.pm b/lib/AccountManager/App/Step1.pm new file mode 100644 index 0000000..7345756 --- /dev/null +++ b/lib/AccountManager/App/Step1.pm @@ -0,0 +1,55 @@ +package AccountManager::App::Step1; + +use Mojo::Base qw(AccountManager::App::Controller); + +use English qw(-no_match_vars); +use Syntax::Keyword::Try; + +use AccountManager::Entity; + +sub run { + my $self = shift; + + my $config = $self->app()->config(); + my $log = $self->app()->log(); + + $self->init_db(); + $self->init_l10n(); + + if ($config->{app}->{login_url}) { + return if !$self->check_authentication(); + } + + my $db = $self->stash('db'); + + my $sps = AccountManager::Entity->get_entities( + db => $db, + query => [ + type => 'sp', + ], + sort_by => 'display_name' + ); + + my $idp; + if ($ENV{HTTP_SHIB_IDENTITY_PROVIDER}) { + my $idps = AccountManager::Entity->get_entities( + db => $db, + query => [ + type => 'idp', + entityid => $ENV{HTTP_SHIB_IDENTITY_PROVIDER}, + ] + ); + $idp = $idps->[0]; + } + + $self->stash(sps => $sps); + $self->stash(idp => $idp); + + $self->render( + status => 200, + template => 'select_sp', + format => 'html' + ); +} + +1; diff --git a/lib/AccountManager/App/Step2.pm b/lib/AccountManager/App/Step2.pm new file mode 100644 index 0000000..78d7601 --- /dev/null +++ b/lib/AccountManager/App/Step2.pm @@ -0,0 +1,59 @@ +package AccountManager::App::Step2; + +use Mojo::Base qw(AccountManager::App::Controller); + +use English qw(-no_match_vars); +use Syntax::Keyword::Try; + +use AccountManager::Entity; + +sub run { + my $self = shift; + + my $config = $self->app()->config(); + my $log = $self->app()->log(); + + $self->init_db(); + $self->init_l10n(); + + if ($config->{app}->{login_url}) { + return if !$self->check_authentication(); + } + + my $entityid = $self->param('entityid'); + my $db = $self->stash('db'); + + my $sp = AccountManager::Entity->new( + db => $db, + entityid => $entityid + ); + return $self->abort( + log_message => sprintf("No such SP '%s' in database", $entityid), + user_message => "no_such_entity" + ) if !$sp->load(speculative => 1); + + # override metadata contacts if needed + my $contacts = + $config->{$entityid}->{contacts} || + $config->{service}->{contacts}; + if ($contacts) { + if ($contacts =~ /^\+(.+)/) { + # complement original contacts + $sp->contacts($sp->contacts(), split(/, */, $1)); + } else { + # replace original contacts + $sp->contacts(split(/, */, $contacts)); + } + } + + $self->stash(sp => $sp); + $self->stash(entityid => $entityid); + + $self->render( + status => 200, + template => 'select_email', + format => 'html' + ); +} + +1; diff --git a/lib/AccountManager/App/Step3.pm b/lib/AccountManager/App/Step3.pm new file mode 100644 index 0000000..cbf2af7 --- /dev/null +++ b/lib/AccountManager/App/Step3.pm @@ -0,0 +1,206 @@ +package AccountManager::App::Step3; + +use Mojo::Base qw(AccountManager::App::Controller); + +use DateTime; +use Email::MIME; +use Email::Sender::Simple; +use English qw(-no_match_vars); +use Syntax::Keyword::Try; +use Template::Constants qw(:chomp); + +use AccountManager::Entity; +use AccountManager::Token; +use AccountManager::Tools; + +sub run { + my $self = shift; + + my $config = $self->app()->config(); + my $log = $self->app()->log(); + + $self->init_db(); + $self->init_l10n(); + + if ($config->{app}->{login_url}) { + return if !$self->check_authentication(); + } + + my $entityid = $self->param('entityid'); + my $email = $self->param('email'); + my $db = $self->stash('db'); + my $l10n = $self->stash('l10n'); + + my $sp = AccountManager::Entity->new( + db => $db, + entityid => $entityid + ); + return $self->abort( + log_message => sprintf("No such SP '%s' in database", $entityid), + user_message => "no_such_entity" + ) if !$sp->load(speculative => 1); + + # override metadata contacts if needed + my $contacts = + $config->{$entityid}->{contacts} || + $config->{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 + return $self->abort( + log_message => "Requested a token for SP $entityid with unautorized address $email", + user_message => "internal", + ) if !$sp->is_contact($email); + + # delete any previous token for the same email/service couple + my $old_token = AccountManager::Token->new( + db => $db, + email_address => $email, + entityid => $entityid, + ); + + if ($old_token->load(speculative => 1)) { + try { + $old_token->delete(); + } catch { + return $self->abort( + log_message => "Failed to delete old authentication token", + user_message => "internal" + ); + } + } + + # compute a new token + my $validity_period = + $config->{service}->{tokens_validity_period}; + my $token = AccountManager::Token->new( + db => $db, + email_address => $email, + entityid => $entityid, + creation_date => DateTime->now(), + expiration_date => DateTime->now()->add(hours => $validity_period), + secret => AccountManager::Tools::generate_secret(20) + ); + + try { + $token->save(); + } catch { + return $self->abort( + log_message => "Failed to save creation authentication token", + user_message => "internal" + ); + } + + # build content + my $theme = $config->{setup}->{templates_theme} || 'default'; + my $base_templates_dir = $self->app()->home()->child('templates'); + my $tt2 = Template->new({ + ENCODING => 'utf8', + PRE_CHOMP => CHOMP_ONE, + INCLUDE_PATH => [ + $base_templates_dir->child('mail', $theme), + $base_templates_dir->child('mail'), + ] + }); + 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 => $config->{app}->{url}, + support_email => $config->{app}->{support_email}, + version => $config->{app}->{version}, + name => $config->{app}->{name}, + }, + user => $user, + source_ip => $self->client_ip(), + idp => { entityid => $idp, }, + sp => { entityid => $entityid, }, + to => $email, + token => $token->secret(), + challenge_url => $self->url_for('step3')->query(entityid => $entityid, email => $email)->to_abs(), + lh => $l10n + }; + 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); + + my $message = Email::MIME->create( + header_str => [ + 'From' => sprintf('%s <%s>', $config->{app}->{name}, $config->{mailer}->{from}), + 'To' => $email, + 'Subject' => sprintf('[%s] %s', $config->{app}->{name}, $l10n->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 + ), + ] + ); + + try { + local $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin'; + Email::Sender::Simple->send($message); + } catch($error) { + return $self->abort( + log_message => "Mail notification error: $error", + user_message => "mail_notification_failure" + ); + } + + $log->info( + sprintf( + "Token send to %s for entityid=%s;token=%s", + $email, + $entityid, + $token->secret(), + ) + ); + + my $profiles = $base_templates_dir + ->child('accounts') + ->list() + ->map(sub { m/([^\/]+).tt2$/}) + ->to_array(); + + $self->stash(entityid => $entityid); + $self->stash(email => $email); + $self->stash(validity => $config->{service}->{account_validity_period}); + $self->stash(profiles => $profiles); + + $self->render( + status => 200, + template => 'complete_challenge', + format => 'html' + ); +} + +1; diff --git a/lib/AccountManager/App/Step4.pm b/lib/AccountManager/App/Step4.pm new file mode 100644 index 0000000..bb68968 --- /dev/null +++ b/lib/AccountManager/App/Step4.pm @@ -0,0 +1,141 @@ +package AccountManager::App::Step4; + +use Mojo::Base qw(AccountManager::App::Controller); + +use DateTime; +use Email::MIME; +use Email::Sender::Simple; +use English qw(-no_match_vars); +use Syntax::Keyword::Try; +use Template::Constants qw(:chomp); + +use AccountManager::Account; +use AccountManager::Entity; +use AccountManager::Token; +use AccountManager::Tools; + +sub run { + my $self = shift; + + my $config = $self->app()->config(); + my $log = $self->app()->log(); + + $self->init_db(); + $self->init_l10n(); + + if ($config->{app}->{login_url}) { + return if !$self->check_authentication(); + } + + my $entityid = $self->param('entityid'); + my $email = $self->param('email'); + my $token = $self->param('token'); + my $validity = $self->param('validity'); + my $profiles = $self->every_param('profiles'); + my $db = $self->stash('db'); + my $l10n = $self->stash('l10n'); + + return if !$self->check_token(token => $token, entityid => $entityid); + + my $sp = AccountManager::Entity->new( + db => $db, + entityid => $entityid, + ); + return $self->abort( + log_message => sprintf("No such SP '%s' in database", $entityid), + user_message => "no_such_entity" + ) if !$sp->load(speculative => 1); + + ## create test accounts + my @accounts; + + my $creation_date = DateTime->now(); + my $token_expiration_date = DateTime->now()->add( + hours => $config->{service}->{tokens_validity_period} + ); + my $account_expiration_date = DateTime->now()->add( + days => $validity + ); + + my $download_token = AccountManager::Token->new( + db => $db, + email_address => $email, + entityid => $entityid, + creation_date => $creation_date, + expiration_date => $token_expiration_date, + secret => AccountManager::Tools::generate_secret(20) + ); + + try { + $download_token->save(); + } catch { + return $self->abort( + log_message => "Failed to save download authentication token", + user_message => "internal" + ); + } + + my $key = AccountManager::Tools::generate_secret(10); + + foreach my $profile (@$profiles) { + my $password = AccountManager::Tools::generate_password(10); + my $account = AccountManager::Account->new( + db => $db, + profile => $profile, + entityid => $entityid, + scope => $config->{idp}->{scope}, + password => $password, + password_crypt => AccountManager::Tools::encrypt($password, $key), + password_hash => AccountManager::Tools::sha256_hash($password), + token => $download_token->secret(), + creation_date => $creation_date, + expiration_date => $account_expiration_date, + ); + next unless $account->save(); + push @accounts, $account; + } + + return $self->abort( + log_message => "Failed to create test accounts for SP $entityid", + user_message => "accounts_creation_failure" + ) if !@accounts; + + ## Update simpleSAMLphp configuration to enable test accounts + my $accounts = AccountManager::Account->get_accounts(db => $db); + + try { + AccountManager::Tools::update_ssp_authsources( + $self->app()->home()->child('templates'), + $config->{setup}->{accounts_file}, + $accounts + ); + } catch($error) { + return $self->abort( + log_message => "Failed to create simpleSAMLphp configuration file: $error", + user_message => "accounts_creation_failure" + ); + } + + $log->info(sprintf("Token validated for entityid=%s", $entityid)); + + $self->stash(accounts => \@accounts); + $self->stash(idp => { name => $config->{idp}->{name} }); + $self->stash(sp => { entityid => $entityid, url => $sp->information_url() }); + $self->stash(email => $email); + $self->stash(days => $validity); + $self->stash( + download_url => $self->url_for('step5')->query( + entityid => $entityid, + token => $download_token->secret(), + key => $key + ) + ); + + $self->render( + status => 200, + template => 'create_accounts', + format => 'html' + ); +} + +1; diff --git a/lib/AccountManager/App/Step5.pm b/lib/AccountManager/App/Step5.pm new file mode 100644 index 0000000..120d7ad --- /dev/null +++ b/lib/AccountManager/App/Step5.pm @@ -0,0 +1,66 @@ +package AccountManager::App::Step5; + +use Mojo::Base qw(AccountManager::App::Controller); + +use DateTime; +use Email::MIME; +use Email::Sender::Simple; +use English qw(-no_match_vars); +use Syntax::Keyword::Try; +use Template::Constants qw(:chomp); + +use AccountManager::Account; +use AccountManager::Tools; + +sub run { + my $self = shift; + + my $app = $self->app(); + my $config = $app->config(); + my $log = $app->log(); + + $self->init_db(); + $self->init_l10n(); + + if ($config->{app}->{login_url}) { + return if !$self->check_authentication(); + } + + my $entityid = $self->param('entityid'); + my $token = $self->param('token'); + my $key = $self->param('key'); + my $db = $self->stash('db'); + + return if !$self->check_token(token => $token, entityid => $entityid); + + # load accounts from database + my $accounts = AccountManager::Account->get_accounts( + db => $db, + query => [ + token => $token + ], + ); + + foreach my $account (@$accounts) { + my $password = AccountManager::Tools::decrypt( + $account->password_crypt(), $key + ); + $account->password($password); + } + + $app->types()->type(csv => 'text/csv'); + $app->renderer()->paths([ + $app->home()->child('templates', 'other'), + $app->home()->child('templates', 'accounts'), + ]); + + $self->stash(accounts => $accounts); + + $self->render( + status => 200, + template => 'accounts', + format => 'csv' + ); +} + +1; diff --git a/lib/Makefile.am b/lib/Makefile.am index 64a8c99..b41a761 100644 --- a/lib/Makefile.am +++ b/lib/Makefile.am @@ -1,6 +1,6 @@ -modulesdir = $(datadir)/access-check/lib +applibdir = $(pkgdatadir)/lib -nobase_modules_DATA = \ +nobase_applib_DATA = \ AccountManager/Token.pm \ AccountManager/DB.pm \ AccountManager/DB/Object.pm \ @@ -12,6 +12,14 @@ nobase_modules_DATA = \ AccountManager/L10N/en.pm \ AccountManager/L10N/fr.pm \ AccountManager/App.pm \ + AccountManager/App/Home.pm \ + AccountManager/App/Controller.pm \ + AccountManager/App/Status.pm \ + AccountManager/App/Step1.pm \ + AccountManager/App/Step2.pm \ + AccountManager/App/Step3.pm \ + AccountManager/App/Step4.pm \ + AccountManager/App/Step5.pm \ AccountManager/Template/Plugin/Quote.pm -EXTRA_DIST = $(nobase_modules_DATA) +EXTRA_DIST = $(nobase_applib_DATA) -- GitLab