#!/bin/bash
# vim: expandtab:ts=4:sw=4:syntax=perl

# read rc files if exist
unset PROFILEDOTD
[ -e /etc/thruk/thruk.env  ] && . /etc/thruk/thruk.env
[ -e ~/etc/thruk/thruk.env ] && . ~/etc/thruk/thruk.env
[ -e ~/.thruk              ] && . ~/.thruk
[ -e ~/.profile            ] && . ~/.profile

BASEDIR=$(dirname $0)/..

# git version
if [ -d $BASEDIR/.git -a -e $BASEDIR/lib/Thruk.pm ]; then
  export PERL5LIB="$BASEDIR/lib:$PERL5LIB";
  if [ "$OMD_ROOT" != "" -a "$THRUK_CONFIG" = "" ]; then export THRUK_CONFIG="$OMD_ROOT/etc/thruk"; fi
  if [ "$THRUK_CONFIG" = "" ]; then export THRUK_CONFIG="$BASEDIR/"; fi

# omd
elif [ "$OMD_ROOT" != "" ]; then
  export PERL5LIB=$OMD_ROOT/share/thruk/lib:$PERL5LIB
  if [ "$THRUK_CONFIG" = "" ]; then export THRUK_CONFIG="$OMD_ROOT/etc/thruk"; fi

# pkg installation
else
  export PERL5LIB=$PERL5LIB:@DATADIR@/lib:@THRUKLIBS@;
  if [ "$THRUK_CONFIG" = "" ]; then export THRUK_CONFIG='@SYSCONFDIR@'; fi
fi

eval 'exec perl -x $0 ${1+"$@"} ;'
    if 0;
# / this slash makes vscode syntax highlighting work
#! -*- perl -*-
#line 35

use warnings;
use strict;

BEGIN {
    ## no critic
    $ENV{'THRUK_AUTH_SCRIPT'} = 1;
    ## use critic
}

use Time::HiRes qw( gettimeofday tv_interval );

use Thruk::Config;
use Thruk::Utils::APIKeys ();
use Thruk::Utils::CookieAuth ();
use Thruk::Utils::Log qw/:all/;

$| = 1;

##########################################################
my $config              = Thruk::Config::get_config();
my $verbose             = $ENV{'THRUK_COOKIE_AUTH_VERBOSE'} // $config->{'cookie_auth_verbose'} // 0;
my $guest               = $ENV{'THRUK_COOKIE_AUTH_GUEST_USER'} || undef;
my $secret_file         = $config->{'var_path'}.'/secret.key';
my $urlprefix           = $config->{'url_prefix'};
my $loginurl            = $config->{'cookie_auth_login_url'}       || $urlprefix."cgi-bin/login.cgi";
my $sessiontimeout      = $config->{'cookie_auth_session_timeout'} || 86400;
my $sessioncachetimeout = $config->{'cookie_auth_session_cache_timeout'} // 30;
my $sessionfailtimeout  = $config->{'cookie_auth_session_cache_fail_timeout'} // 30;
my $sessioncache        = {};
my $login_timeout       = $config->{'cookie_auth_login_timeout'} // 10;
$loginurl               = "/redirect/".$loginurl;
$loginurl               =~ s|//|/|gmx;
my $line_regex          = qr|^/(.*?)/(.*?)/____/(.*)$|mx;
my $prefix              = $urlprefix; $prefix =~ s|^/||mx;
# if changed, adjust AddDefaults.pm as well
my $pass_regex          = qr#^$prefix(themes|javascript|cache|vendor|images|usercontent|cgi\-bin/(login|remote|restricted)\.cgi)#mx;
my $bearer_pass_regex   = qr#/grafana/api/#mx;
my $cookie_regex        = qr/thruk_auth=(\w+)/mx;
my $last_cache_clean    = 0;

# initialize stderr logger
if($verbose) {
    ## no critic
    $ENV{'THRUK_VERBOSE'} = $verbose if(!defined $ENV{'THRUK_VERBOSE'} || $ENV{'THRUK_VERBOSE'} < $verbose);
    ## use critic
    Thruk::Utils::Log::set_screen_logger($config, 1, "[thruk_auth] ");
    _info("thruk auth started: sessioncachetime: %ds | sessioncachefail: %ds | sessiontimeout: %ds",
        $sessioncachetimeout,
        $sessionfailtimeout,
        $sessiontimeout,
    );
}

##########################################################
while(1) {
    my $rc;
    eval {
        $rc = read_stdin();
    };
    _error($@) if $@;
    exit($rc) if defined $rc;
    sleep(1);
}
exit(1);

##########################################################
sub read_stdin {
    while (<STDIN>) {
        chomp(my $in = $_);

        # do not accidentally print to stdout
        select STDERR;

        my $t0 = [gettimeofday];
        _debug("url: '%s' ", _clean_credentials($in)) if $verbose > 1;
        eval {
            chomp(my $res = process($in));
            my $elapsed = tv_interval ( $t0 );
            _debug("%s -> %s (took %.3f sec)", _clean_credentials($in), $res, $elapsed) if $verbose;
            print STDOUT $res,"\n";
        };
        my $err = $@;
        if($err) {
            print STDOUT $loginurl."?problem\n";
            _error($err);
        }
    }
    return 0;
}

##########################################################
sub process {
    my($in) = @_;

    my($cookies, $extra, $path);
    if($in =~ $line_regex) {
        ($cookies, $extra, $path) = ($1, $2, $3);
        $cookies =~ s/^auth://gmx;
    } else {
        # everything else redirects to login
        _debug("unknown url ".$in) if $verbose;
        return $loginurl;
    }

    # clean old session cache every couple of minutes
    my $now = time();
    clean_session_cache() if($last_cache_clean < $now - ($sessioncachetimeout ? $sessioncachetimeout*2 : 120));

    # %{REMOTE_ADDR}~~%{HTTP:Authorization}~~%{HTTP:X-Thruk-Auth-Key}~~%{HTTP:X-Thruk-Auth-User}
    $extra = [split(/~~/mx, $extra)];

    # remove last &
    $path =~ s|/____/|?|mxo;
    $path =~ s|\?$||mxo; # remove trailing ?

    # some urls must pass
    if($path =~ $pass_regex) {
        _debug("pass $path") if $verbose > 1;
        return "/pass/$path";
    }

    # direct login by secret/api key
    if($extra->[2] && $extra->[2] =~ m/[a-zA-Z0-9_]+/mx) {
        my $secret_key  = Thruk::Utils::IO::read($secret_file) if -s $secret_file;
        chomp($secret_key) if $secret_key;
        if($secret_key && $extra->[2] eq $secret_key) {
            if(!$extra->[3]) {
                return "/pass/".$prefix."cgi-bin/error.cgi?error=28";
            }
            my $user = $extra->[3];
            return "/loginok/".$user."/".$path;
        }
        if($config->{'api_keys_enabled'}) {
            my $data = Thruk::Utils::APIKeys::get_key_by_private_key($config, $extra->[2]);
            if($data) {
                my $user = $data->{'user'};
                if($data->{'superuser'}) {
                    $user = (defined $extra->[3] && $extra->[3] ne '') ? $extra->[3] : '(api)';
                }
                Thruk::Utils::IO::json_lock_store($data->{'file'}.'.stats', { last_used => $now, last_from => $extra->[0]//'' }, { pretty => 1 });
                return "/loginok/".$user."/".$path if defined $user;
                return "/pass/".$path;
            }
        }
        return "/pass/".$prefix."cgi-bin/error.cgi?error=27";
    }

    # direct access with basic auth
    if($extra->[1]) {
        my $auth = $extra->[1];
        # passthrough some bearer token urls
        if($auth =~ m|^Bearer\s+|mxi && $path =~ m|$bearer_pass_regex|mx) {
            _debug("pass $path") if $verbose > 1;
            return "/pass/$path";
        }

        # use session cache for a few seconds
        my $cached        = $sessioncache->{$auth};
        my $cache_timeout = (defined $cached && $cached->{'failed'}) ? $sessionfailtimeout : $sessioncachetimeout;
        if(defined $cached && ($cache_timeout == 0 || ($cache_timeout > 0 && $sessioncache->{$auth}->{'time'} >= $now - $cache_timeout))) {
            if($sessioncache->{$auth}->{'failed'}) {
                _debug("basic auth login failed by cache hit: ".substr($auth, 0, 6)."...") if $verbose > 1;
                return $loginurl."?invalid&"._encode_path($path);
            }
            _debug("basic auth login by cache hit: ".$sessioncache->{$auth}->{'login'}) if $verbose > 1;
            return "/loginok/".$sessioncache->{$auth}->{'login'}."/".$path;
        }

        _debug("trying basic auth from auth header for $path") if $verbose > 1;
        my $session = Thruk::Utils::CookieAuth::external_authentication($config, {'Authorization' => $auth}, undef, $extra->[0], undef, 0);
        if(ref $session eq 'HASH' && $session->{'private_key'}) {
            _debug("basic auth login successful for user %s", $session->{'username'}) if $verbose > 1;
            $sessioncache->{$auth} = {
                    'login' => $session->{'username'},
                    'time'  => $now,
            };
            return "/loginok/".$session->{'username'}."/".$path;
        }

        _debug("basic auth login failed") if $verbose;
        # cache failed login as well to prevent ddos attack with wrong credentials
        $sessioncache->{$auth} = {
                'failed' => 1,
                'time'   => $now,
        };
        return $loginurl."?invalid&"._encode_path($path);
    }

    # did we get a cookie
    if($cookies eq '' || $cookies !~ $cookie_regex) {
        _debug("no cookie: $path") if $verbose;
        return "/loginok/".$guest."/".$path if $guest;
        return $loginurl."?nocookie&"._encode_path($path);
    }
    my $auth = $1;

    # use session cache for a few seconds
    my $cache_timeout = (defined $sessioncache->{$auth} && $sessioncache->{$auth}->{'failed'}) ? $sessionfailtimeout : $sessioncachetimeout;
    if(defined $sessioncache->{$auth} && ($cache_timeout == 0 || ($cache_timeout > 0 && $sessioncache->{$auth}->{'time'} >= $now - $cache_timeout))) {
        if($sessioncache->{$auth}->{'failed'}) {
            _debug("login failed by cache hit") if $verbose > 1;
            return $loginurl."?invalid&"._encode_path($path);
        }
        _debug("login by cache hit: ".$sessioncache->{$auth}->{'login'}) if $verbose > 1;
        return "/loginok/".$sessioncache->{$auth}->{'login'}."/".$path;
    }

    # does our sessionfile exist?
    _debug("id => ".$auth) if $verbose > 1;
    my $session = Thruk::Utils::CookieAuth::retrieve_session(config => $config, id => $auth);
    if(!$session) {
        _debug("session expired: ".($sessioncache->{$auth}->{'login'} || '?')) if $verbose;
        delete $sessioncache->{$auth} if defined $sessioncache->{$auth};
        return "/loginok/".$guest."/".$path if $guest;
        return $loginurl."?expired&"._encode_path($path);
    }

    # fill sessioncache, so we don't have to revalidate new sessions immediately
    my $user = $session->{'username'};
    if(!$sessioncache->{$auth}) {
        $sessioncache->{$auth} = {
                'login' => $user,
                'time'  => $now,
        };
    }

    # session timeout reached?
    if(defined $sessioncache->{$auth} && $sessioncache->{$auth}->{'time'} < $now - $sessiontimeout) {
        _debug("session timeout: ".$sessioncache->{$auth}->{'login'}) if $verbose;
        _audit_log("session", "session timeout hit, removing session file", $session->{'username'}//'?', $session->{'file'}, 0);
        unlink($session->{'file'});
        delete $sessioncache->{$auth};
        return "/loginok/".$guest."/".$path if $guest;
        return $loginurl."?expired&"._encode_path($path);
    }

    # revalidation disabled
    my $basicauth = $session->{'hash'};
    if($cache_timeout == 0 || !defined $basicauth || $basicauth eq 'none') {
        # no validation enabled
        if($sessioncache->{$auth}->{'failed'}) {
            _debug("session not ok by cache hit") if $verbose > 1;
            return $loginurl."?invalid&"._encode_path($path);
        }
        _debug("session ok (not revalidated)") if $verbose > 1;
    }
    else {
        # revalidate session data
        my $rc;
        $SIG{'ALRM'} = sub { die("timeout during auth check"); };
        eval {
            alarm($login_timeout);
            $rc = Thruk::Utils::CookieAuth::verify_basic_auth($config, $basicauth, $user, $login_timeout-1, 1);
        };
        my $err = $@;
        alarm(0);
        if($err) {
            _debug("technical problem during login for ".$user) if $verbose;
            _warn($err);
            # do not log out on temporary errors...
        }
        elsif($rc == -1) {
            _debug("technical problem during login for ".$user) if $verbose;
            _audit_log("session", "technical issue during session revalidation, removing session file", $session->{'username'}//'?', $session->{'file'}, 0);
            unlink($session->{'file'});
            delete $sessioncache->{$auth};
            return $loginurl."?problem&"._encode_path($path);
        }
        elsif($rc == 0) {
            _debug("basic auth verify failed for ".$user) if $verbose;
            _audit_log("session", "session revalidation failed, removing session file", $session->{'username'}//'?', $session->{'file'}, 0);
            unlink($session->{'file'});
            delete $sessioncache->{$auth};
            return $loginurl."?invalid&"._encode_path($path);
        }
    }

    # update mtime of sessionfile
    utime($now, $now, $session->{'file'});

    # grant access
    $sessioncache->{$auth} = {
            'login' => $user,
            'time'  => $now,
    };
    _debug(((defined $basicauth && $basicauth ne 'none') ? 'basic ' : '').'auth ok for '.$user) if $verbose > 1;
    return "/loginok/".$user."/".$path;
}

##########################################################
sub clean_session_cache {
    _debug("session cache house keeping") if $verbose > 1;
    $last_cache_clean = time();
    my $clean_before  = time() - $sessiontimeout - $sessioncachetimeout;
    for my $key (keys %{$sessioncache}) {
        delete $sessioncache->{$key} if $sessioncache->{$key}->{'time'} < $clean_before;
    }
    return;
}

##########################################################
sub _clean_credentials {
    my($str) = @_;
    return($str) if $verbose > 2;
    if($str =~ m/~~Basic\s+/mx) {
        $str =~ s/~~Basic\s+([^~]{4})[^~]+~~/~~Basic $1...~~/gmx;
    } else {
        $str =~ s/~~([^~]{4})[^~]+~~/~~$1...~~/gmx;
    }
    return($str);
}

##########################################################
sub _encode_path {
    my($path) = @_;

    # replace first ? by &
    $path =~ s|\?|&|mx;

    # replace first encoded ? by &
    $path =~ s|%3f|&|mx;

    return($path);
}

##########################################################
