package AccessCheck::App::Controller; use Mojo::Base qw(Mojolicious::Controller); use English qw(-no_match_vars); use HTTP::AcceptLanguage; use Syntax::Keyword::Try; use Template::Constants qw(:chomp); use UNIVERSAL::require; use AccessCheck::Data::Account; use AccessCheck::Data::DB; use AccessCheck::Data::Entity; use AccessCheck::Data::Token; use AccessCheck::L10N; use AccessCheck::Regexp; use AccessCheck::Tools; 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 ($lang = $self->param('lang')) { $log->debug(sprintf("setting language from parameter: %s", $lang)); } elsif ($lang = $self->session('lang')) { $log->debug(sprintf("setting language from session: %s", $lang)); } elsif (my $header = $self->req()->headers->header('Accept-Language')) { $lang = HTTP::AcceptLanguage->new($header)->match(qw/en fr/); $log->debug(sprintf("setting language from Accept-Language header: %s", $lang)); } else { $lang = 'en'; } $l10n = AccessCheck::L10N->get_handle($lang); $self->session(lang => $lang); $self->stash(lang => $lang); $self->stash(l10n => $l10n); return $l10n; } sub init_user { my $self = shift; my $headers = $self->req()->headers(); my $idp = $ENV{'Shib_Identity_Provider'} || # local SP $headers->header('Shib-Identity-Provider'); # remote SP my $name = $ENV{displayName} || # local SP $headers->header('displayName'); # remote SP my $user = { idp => $idp, name => $name }; $self->stash(user => $user); return $user; } sub init_db { my $self = shift; my $db = AccessCheck::Data::DB->new(); $self->stash(db => $db); return $db; } sub check_authentication { my $self = shift; return $self->abort( status => 401, log_message => sprintf("unauthenticated user for action %s", $self->current_route()), user_message => Registry::Error::AuthenticationRequired->new() ) if !$self->stash('user'); return 1; } sub check_token { my ($self, %args) = @_; my $secret = $args{token}; my $db = $self->stash('db'); my $token = AccessCheck::Data::Token->new( db => $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; } =head2 check_csrf_token() Check if provided anti-CSRF token, as I<token> parameter, matches expected one, abort otherwise. =cut sub check_csrf_token { my ($self, %args) = @_; my $provided_token = $self->param('token'); return $self->abort( status => 403, log_message => sprintf("missing anti-CSRF token for action %s", $self->current_route()), user_message => "missing_csrf_token" ) if !$provided_token; my $expected_token = $self->csrf_token(); return $self->abort( status => 403, log_message => sprintf( "invalid anti-CSRF token for action %s: %s instead of expected %s", $self->current_route(), $provided_token, $expected_token, ), user_message => "invalid_csrf_token" ) if $provided_token ne $expected_token; return 1; } sub get_sp { my ($self, %args) = @_; my $entityid = $args{entityid}; my $db = $self->stash('db'); return $self->abort( log_message => "Missing parameter: entityid", user_message => "missing_entityid" ) if !$entityid; return $self->abort( log_message => "Invalid parameter: entityid", user_message => "invalid_entityid" ) if $entityid !~ $AccessCheck::Regexp::entityid; my $sp = AccessCheck::Data::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); return $sp; } 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(@_); } sub mock_contacts { my $self = shift; my $sp = shift; my $config = $self->app()->config(); my $entityid = $sp->entityid(); my $contacts = $config->{$entityid}->{contacts} || $config->{service}->{contacts}; if ($contacts) { if ($contacts =~ /^\+(.+)/) { # complement original contacts $sp->contacts($sp->contacts(), $self->string_to_list($1)); } else { # replace original contacts $sp->contacts($self->string_to_list($contacts)); } } } sub home { my $self = shift; $self->init_l10n(); $self->render(status => 200, template => 'home', format => 'html'); } =head2 status() Return the health status of the frontend. =cut sub status { my $self = shift; my $config = $self->app()->config(); if (!$config->{status}) { $self->render( status => 403, text => "unauthorized access" ); return; } List::MoreUtils->require(); Mojo::Util->require(); my $client_ip = $self->forwarded_for(); my @allowed_ips = $self->string_to_list($config->{status}->{allowed}); if (List::MoreUtils::none { Mojo::Util::network_contains($_, $client_ip) } @allowed_ips) { $self->render( status => 403, text => "unauthorized access" ); return; } Sys::Hostname->require(); my $status = $config->{status}->{disabled} ? 'disabled' : 'available'; my $health = { status => $status, host => hostname(), }; $self->render(status => 200, json => $health); } sub select_entity { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); if ($config->{app}->{login_url}) { return if !$self->check_authentication(); } my $sps = AccessCheck::Data::Entity->get_entities( db => $db, query => [ type => 'sp', ], sort_by => 'display_name' ); my $idp; if ($user) { my $idps = AccessCheck::Data::Entity->get_entities( db => $db, query => [ type => 'idp', entityid => $user->{idp} ] ); $idp = $idps->[0]; } $self->stash(sps => $sps); $self->stash(idp => $idp); $self->render( status => 200, template => 'select_entity', format => 'html' ); } sub select_email { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); if ($config->{app}->{login_url}) { return if !$self->check_authentication(); } my $entityid = $self->param('entityid'); my $sp = $self->get_sp(entityid => $entityid); return if !$sp; # override metadata contacts if needed $self->mock_contacts($sp); $self->stash(sp => $sp); $self->stash(entityid => $entityid); $self->render( status => 200, template => 'select_email', format => 'html' ); } sub send_challenge { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); if ($config->{app}->{login_url}) { return if !$self->check_authentication(); } return if !$self->check_csrf_token(); my $entityid = $self->param('entityid'); my $email = $self->param('email'); my $sp = $self->get_sp(entityid => $entityid); return if !$sp; return $self->abort( log_message => "Missing parameter: email", user_message => "missing_email" ) if !$email; return $self->abort( log_message => "Invalid parameter: email", user_message => "invalid_email" ) if $email !~ $AccessCheck::Regexp::email; # override metadata contacts if needed $self->mock_contacts($sp); ## 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 = AccessCheck::Data::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 DateTime->require(); my $validity_period = $config->{service}->{tokens_validity_period}; my $token = AccessCheck::Data::Token->new( db => $db, email_address => $email, entityid => $entityid, creation_date => DateTime->now(), expiration_date => DateTime->now()->add(hours => $validity_period), secret => AccessCheck::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 $data = { app => { url => $config->{app}->{url}, support_email => $config->{app}->{support_email}, version => $config->{app}->{version}, name => $config->{app}->{name}, }, user => $user->{name}, source_ip => $self->forwarded_for(), idp => { entityid => $user->{idp}, }, sp => { entityid => $entityid, }, to => $email, token => $token->secret(), challenge_url => $self->url_for('validate_challenge')->query(entityid => $entityid, email => $email)->to_abs(), lh => $l10n }; my $text_content; my $html_content; $tt2->process('send_challenge.txt.tt2', $data, \$text_content); $tt2->process('send_challenge.html.tt2', $data, \$html_content); Email::MIME->require(); Email::Sender::Simple->require(); 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 %s send to %s for entity %s", $token->secret(), $email, $entityid, ) ); $self->redirect_to('validate_challenge', email => $email, entityid => $entityid); } sub validate_challenge { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); if ($config->{app}->{login_url}) { return if !$self->check_authentication(); } my $entityid = $self->param('entityid'); my $email = $self->param('email'); my $sp = $self->get_sp(entityid => $entityid); return if !$sp; return $self->abort( log_message => "Missing parameter: email", user_message => "missing_email" ) if !$email; return $self->abort( log_message => "Invalid parameter: email", user_message => "invalid_email" ) if $email !~ $AccessCheck::Regexp::email; my $base_templates_dir = $self->app()->home()->child('templates'); 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 => 'validate_challenge', format => 'html' ); } sub display_accounts_html { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); 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 $sp = $self->get_sp(entityid => $entityid); return if !$sp; return if !$self->check_token(token => $token, entityid => $entityid); ## create test accounts my @accounts; DateTime->require(); 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 = AccessCheck::Data::Token->new( db => $db, email_address => $email, entityid => $entityid, creation_date => $creation_date, expiration_date => $token_expiration_date, secret => AccessCheck::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 = AccessCheck::Tools::generate_secret(10); foreach my $profile (@$profiles) { my $password = AccessCheck::Tools::generate_password(10); my $account = AccessCheck::Data::Account->new( db => $db, profile => $profile, entityid => $entityid, scope => $config->{idp}->{scope}, password => $password, password_crypt => AccessCheck::Tools::encrypt($password, $key), password_hash => AccessCheck::Tools::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 = AccessCheck::Data::Account->get_accounts(db => $db); try { AccessCheck::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('show_accounts_csv')->query( entityid => $entityid, token => $download_token->secret(), key => $key ) ); $self->render( status => 200, template => 'show_accounts', format => 'html' ); } sub display_accounts_csv { my $self = shift; my $app = $self->app(); my $config = $app->config(); my $log = $app->log(); my $l10n = $self->init_l10n(); my $user = $self->init_user(); my $db = $self->init_db(); 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'); return if !$self->check_token(token => $token, entityid => $entityid); # load accounts from database my $accounts = AccessCheck::Data::Account->get_accounts( db => $db, query => [ token => $token ], ); foreach my $account (@$accounts) { my $password = AccessCheck::Tools::decrypt( $account->password_crypt(), $key ); $account->password($password); } $app->types()->type(csv => 'text/csv'); $self->stash(accounts => $accounts); $self->render( status => 200, template => 'accounts', format => 'csv' ); } 1;