<?php
/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */

namespace Icinga\Authentication;

use Exception;
use Fiber;
use Icinga\Application\Config;
use Icinga\Application\Hook\AuditHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\User\ExternalBackend;
use Icinga\Authentication\UserGroup\UserGroupBackend;
use Icinga\Data\ConfigObject;
use Icinga\Exception\IcingaException;
use Icinga\Exception\NotReadableError;
use Icinga\User;
use Icinga\User\Preferences;
use Icinga\User\Preferences\PreferencesStore;
use Icinga\Web\Session;
use Icinga\Web\StyleSheet;
use LogicException;

class Auth
{
    /**
     * Singleton instance
     *
     * @var self
     */
    private static $instance;

    /**
     * Request
     *
     * @var \Icinga\Web\Request
     */
    protected $request;

    /**
     * Response
     *
     * @var \Icinga\Web\Response
     */
    protected $response;

    /**
     * Authenticated user
     *
     * @var User|null
     */
    private $user;

    /**
     * Whether the user has been authenticated
     *
     * If true, this does NOT mean authentication was successful.
     *
     * @var bool
     */
    private bool $authenticated = false;

    /**
     * Fibers that were suspended during authentication
     *
     * This is used to resume fibers after authentication has been performed.
     *
     * @var Fiber[]
     */
    private array $suspendedFibers = [];

    /**
     * @see getInstance()
     */
    private function __construct()
    {
    }

    /**
     * Get the authentication manager
     *
     * @return self
     */
    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Get the auth chain
     *
     * @return AuthChain
     */
    public function getAuthChain()
    {
        return new AuthChain();
    }

    /**
     * Get whether the user is authenticated
     *
     * @return bool
     */
    public function isAuthenticated()
    {
        if (! $this->authenticated) {
            $fiber = Fiber::getCurrent();
            if ($fiber !== null) {
                $this->suspendedFibers[] = $fiber;
                Fiber::suspend();
            } else {
                trigger_error('Authentication can no longer be triggered implicitly', E_USER_DEPRECATED);
            }
        }

        return $this->user !== null;
    }

    public function setAuthenticated(User $user, $persist = true)
    {
        $this->setupUser($user);

        // Reload CSS if the theme changed
        $themingConfig = Icinga::app()->getConfig()->getSection('themes');
        $userTheme = $user->getPreferences()->getValue('icingaweb', 'theme');
        if (! (bool) $themingConfig->get('disabled', false) && $userTheme !== null) {
            $defaultTheme = $themingConfig->get('default', StyleSheet::DEFAULT_THEME);
            if ($userTheme !== $defaultTheme) {
                $this->getResponse()->setReloadCss(true);
            }
        }

        // Also reload CSS if the theme mode changed
        $themeMode = $user->getPreferences()->getValue('icingaweb', 'theme_mode');
        if ($themeMode && $themeMode !== StyleSheet::DEFAULT_MODE) {
            $this->getResponse()->setReloadCss(true);
        }

        // Reload entire layout if the locale changed
        if (($locale = $user->getPreferences()->getValue('icingaweb', 'language')) !== null) {
            if (setlocale(LC_ALL, 0) !== $locale && $this->getRequest()->isXmlHttpRequest()) {
                $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes');
            }
        }

        $this->user = $user;
        if ($persist) {
            $this->persistCurrentUser();
        }

        AuditHook::logActivity('login', 'User logged in');
    }

    /**
     * Getter for groups belonged to authenticated user
     *
     * @return  array
     * @see     User::getGroups
     */
    public function getGroups()
    {
        return $this->user->getGroups();
    }

    /**
     * Get the request
     *
     * @return \Icinga\Web\Request
     */
    public function getRequest()
    {
        if ($this->request === null) {
            $this->request = Icinga::app()->getRequest();
        }
        return $this->request;
    }

    /**
     * Get the response
     *
     * @return \Icinga\Web\Response
     */
    public function getResponse()
    {
        if ($this->response === null) {
            $this->response = Icinga::app()->getResponse();
        }
        return $this->response;
    }

    /**
     * Get applied restrictions matching a given restriction name
     *
     * Returns a list of applied restrictions, empty if no user is
     * authenticated
     *
     * @param  string  $restriction  Restriction name
     * @return array
     */
    public function getRestrictions($restriction)
    {
        if (! $this->isAuthenticated()) {
            return array();
        }
        return $this->user->getRestrictions($restriction);
    }

    /**
     * Returns the current user or null if no user is authenticated
     *
     * @return User|null
     */
    public function getUser()
    {
        return $this->user;
    }

    /**
     * Set the authenticated user
     *
     * Note that this method just sets the authenticated user and thus bypasses our default authentication process in
     * {@link setAuthenticated()}.
     *
     * @param User $user
     *
     * @return $this
     */
    public function setUser(User $user)
    {
        $this->user = $user;

        return $this;
    }

    /**
     * Authenticate the user
     *
     * This method will try to authenticate the user using the session first, then external backends and finally HTTP
     * authentication. If authentication has already been performed, an exception will be thrown.
     *
     * @throws LogicException If authentication has already been performed
     *
     * @return $this
     */
    public function authenticate(): static
    {
        if ($this->authenticated) {
            throw new LogicException('Cannot perform authentication more than once');
        }

        $this->authenticateFromSession();
        if ($this->user === null) {
            $this->authExternal();

            if (
                $this->user === null
                && $this->getRequest()->isApiRequest()
                && ! $this->getRequest()->isXmlHttpRequest()
            ) {
                $this->authHttp();
            }
        }

        $this->authenticated = true;

        foreach ($this->suspendedFibers as $fiber) {
            // Resume all suspended fibers
            $fiber->resume();
        }

        $this->suspendedFibers = [];

        return $this;
    }

    /**
     * Try to authenticate the user with the current session
     *
     * Authentication for externally-authenticated users will be revoked if the username changed or external
     * authentication is no longer in effect
     */
    public function authenticateFromSession()
    {
        $this->user = Session::getSession()->get('user');
        if ($this->user !== null && $this->user->isExternalUser()) {
            list($originUsername, $field) = $this->user->getExternalUserInformation();
            $username = ExternalBackend::getRemoteUser($field);
            if ($username === null || $username !== $originUsername) {
                $this->removeAuthorization();
            }
        }
    }

    /**
     * Attempt to authenticate a user from external user backends
     *
     * @return bool
     */
    protected function authExternal()
    {
        $user = new User('');
        foreach ($this->getAuthChain() as $userBackend) {
            if ($userBackend instanceof ExternalBackend) {
                if ($userBackend->authenticate($user)) {
                    if (! $user->hasDomain()) {
                        $user->setDomain(Config::app()->get('authentication', 'default_domain'));
                    }
                    $this->setAuthenticated($user);
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Attempt to authenticate a user using HTTP authentication on API requests only
     *
     * Supports only the Basic HTTP authentication scheme. XHR will be ignored.
     *
     * @return bool
     */
    public function authHttp()
    {
        $request = $this->getRequest();
        $header = $request->getHeader('Authorization');
        if (empty($header)) {
            return false;
        }
        list($scheme) = explode(' ', $header, 2);
        if ($scheme !== 'Basic') {
            return false;
        }
        $authorization = substr($header, strlen('Basic '));
        $credentials = base64_decode($authorization);
        $credentials = array_filter(explode(':', $credentials, 2));
        if (count($credentials) !== 2) {
            // Deny empty username and/or password
            return false;
        }
        $user = new User($credentials[0]);
        if (! $user->hasDomain()) {
            $user->setDomain(Config::app()->get('authentication', 'default_domain'));
        }
        $password = $credentials[1];
        if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) {
            $this->setAuthenticated($user, false);
            $user->setIsHttpUser(true);
            return true;
        } else {
            return false;
        }
    }

    /**
     * Challenge client immediately for HTTP authentication
     *
     * Sends the response w/ the 401 Unauthorized status code and WWW-Authenticate header.
     */
    public function challengeHttp()
    {
        $response = $this->getResponse();
        $response->setHttpResponseCode(401);
        $response->setHeader('WWW-Authenticate', 'Basic realm="Icinga Web 2"');
        $response->sendHeaders();
        exit();
    }

    /**
     * Whether an authenticated user has a given permission
     *
     * @param  string  $permission  Permission name
     *
     * @return bool                 True if the user owns the given permission, false if not or if not authenticated
     */
    public function hasPermission($permission)
    {
        if (! $this->isAuthenticated()) {
            return false;
        }
        return $this->user->can($permission);
    }

    /**
     * Writes the current user to the session
     */
    public function persistCurrentUser()
    {
        // @TODO(el): https://dev.icinga.com/issues/10646
        $params = session_get_cookie_params();
        setcookie(
            'icingaweb2-session',
            time(),
            0,
            $params['path'],
            $params['domain'],
            $params['secure'],
            $params['httponly']
        );
        Session::getSession()->set('user', $this->user)->refreshId();
    }

    /**
     * Purges the current authorization information and session
     */
    public function removeAuthorization()
    {
        AuditHook::logActivity('logout', 'User logged out');
        $this->user = null;
        Session::getSession()->purge();
    }

    /**
     * Setup the given user
     *
     * This loads preferences, groups and roles.
     *
     * @param User $user
     *
     * @return void
     */
    public function setupUser(User $user)
    {
        // Load the user's preferences

        try {
            $config = Config::app();
        } catch (NotReadableError $e) {
            Logger::error(
                new IcingaException(
                    'Cannot load preferences for user "%s". An exception was thrown: %s',
                    $user->getUsername(),
                    $e
                )
            );
            $config = new Config();
        }

        $preferencesConfig = new ConfigObject([
            'resource'  => $config->get('global', 'config_resource')
        ]);

        try {
            $preferencesStore = PreferencesStore::create($preferencesConfig, $user);
            $preferences = new Preferences($preferencesStore->load());
        } catch (Exception $e) {
            Logger::error(
                new IcingaException(
                    'Cannot load preferences for user "%s". An exception was thrown: %s',
                    $user->getUsername(),
                    $e
                )
            );
            $preferences = new Preferences();
        }

        $user->setPreferences($preferences);

        // Load the user's groups
        $groups = $user->getGroups();
        $userBackendName = $user->getAdditional('backend_name');
        foreach (Config::app('groups') as $name => $config) {
            $groupsUserBackend = $config->user_backend;
            if ($groupsUserBackend
                && $groupsUserBackend !== 'none'
                && $userBackendName !== null
                && $groupsUserBackend !== $userBackendName
            ) {
                // Do not ask for Group membership if a specific User Backend
                // has been assigned to that Group Backend, and the user has
                // been authenticated by another User Backend
                continue;
            }

            try {
                $groupBackend = UserGroupBackend::create($name, $config);
                $groupsFromBackend = $groupBackend->getMemberships($user);
            } catch (Exception $e) {
                Logger::error(
                    'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s',
                    $user->getUsername(),
                    $name,
                    $e
                );
                continue;
            }

            if (empty($groupsFromBackend)) {
                Logger::debug(
                    'No groups found in backend "%s" which the user "%s" is a member of.',
                    $name,
                    $user->getUsername()
                );
                continue;
            }

            $groupsFromBackend = array_values($groupsFromBackend);
            Logger::debug(
                'Groups found in backend "%s" for user "%s": %s',
                $name,
                $user->getUsername(),
                join(', ', $groupsFromBackend)
            );
            $groups = array_merge($groups, array_combine($groupsFromBackend, $groupsFromBackend));
        }

        $user->setGroups($groups);

        // Load the user's roles
        $admissionLoader = new AdmissionLoader();
        $admissionLoader->applyRoles($user);
    }
}
