<?php

/*
 * Copyright (C) 2015-2017 Deciso B.V.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
 * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
 * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

namespace OPNsense\Core;

use OPNsense\Firewall\Util;

/**
 * Class ACL, access control list management
 * @package OPNsense\Core
 */
class ACL
{
    /**
     * @var array user database
     */
    private $userDatabase = [];

    /**
     * @var array privileges per group
     */
    private $allGroupPrivs = [];

    /**
     * @var array page/endpoint mapping structure
     */
    private $ACLtags = [];

    /**
     * @var string location to store serialized acl
     */
    private $aclCacheFilename = null;

    /**
     * @var int time to live for serialized acl
     */
    private $aclCacheTTL = 3600;

    /**
     * ACL to page/endpoint mapping method.
     * Processes all acl tags containing patterns and generates a key/value store acl/pattern.
     * @return array
     */
    private function loadPageMap()
    {
        $pageMap = [];

        foreach ($this->ACLtags as $aclKey => $aclItem) {
            // check if acl item already exists if there's acl content for it
            if (!array_key_exists($aclKey, $pageMap) && (isset($aclItem["match"]) || isset($aclItem["pattern"]))) {
                $pageMap[$aclKey] = [];
            }
            if (isset($aclItem["match"])) {
                foreach ($aclItem['match'] as $matchexpr) {
                    $pageMap[$aclKey][] = trim($matchexpr);
                }
            }
        }
        return $pageMap;
    }

    /**
     * load user and group privileges into $this->userDatabase and $this->allGroupPrivs
     */
    private function loadUserGroupRights()
    {
        $pageMap = $this->loadPageMap();

        // create privilege mappings
        $this->userDatabase = [];
        $this->allGroupPrivs = [];

        $groupmap = [];

        // gather user / group data from config.xml
        $config = Config::getInstance()->object();
        $userUidMap = [];
        if ($config->system->count() > 0) {
            foreach ($config->system->children() as $key => $node) {
                if ($key == 'user') {
                    $username = (string)$node->name;
                    $uid = (string)$node->uid;
                    $userUidMap[$uid] = $username;
                    $this->userDatabase[$username] = [];
                    $this->userDatabase[$username]['uid'] = $uid;
                    $this->userDatabase[$username]['groups'] = [];
                    $this->userDatabase[$username]['gids'] = [];
                    $this->userDatabase[$username]['priv'] = [];
                    if (!empty($node->landing_page)) {
                        $this->userDatabase[$username]['landing_page'] = (string)$node->landing_page;
                    }
                    foreach ($node->priv as $priv) {
                        foreach (array_filter(explode(',', $priv)) as $privname) {
                            if (array_key_exists($privname, $pageMap)) {
                                $this->userDatabase[$username]['priv'][] = $pageMap[$privname];
                            }
                        }
                    }
                } elseif ($key == 'group') {
                    $groupmap[(string)$node->name] = $node;
                }
            }
        }

        // interpret group privilege data and update user data with group information.
        foreach ($groupmap as $groupkey => $groupNode) {
            $this->allGroupPrivs[$groupkey] = [
                'priv' => [],
                'source_networks' => array_filter(explode(',', (string)$groupNode->source_networks))
            ];
            foreach ($groupNode->children() as $itemKey => $node) {
                $node_data = (string)$node;
                if ($itemKey == "member" && $node_data != "") {
                    foreach (explode(',', $node_data) as $member) {
                        if (!isset($userUidMap[$member])) {
                            continue;
                        }
                        $username = $userUidMap[$member];
                        if ($this->userDatabase[$username]["uid"] == $member) {
                            $this->userDatabase[$username]["groups"][] = $groupkey;
                            $this->userDatabase[$username]["gids"][] = (string)$groupNode->gid;
                        }
                    }
                } elseif ($itemKey == "priv") {
                    foreach (array_filter(explode(',', $node_data)) as $privname) {
                        if (array_key_exists($privname, $pageMap)) {
                            $this->allGroupPrivs[$groupkey]['priv'][] = $pageMap[$privname];
                        }
                    }
                }
            }
        }
    }

    /**
     * merge pluggable ACL xml's into $this->ACLtags
     * @throws \Exception
     */
    private function mergePluggableACLs()
    {
        // crawl all vendors and modules and add acl definitions
        foreach (glob(__DIR__ . '/../../*') as $vendor) {
            foreach (glob($vendor . '/*') as $module) {
                // probe for ACL implementation, which should derive from OPNsense\Core\ACL\ACL
                $tmp = explode("/", $module);
                $module_name = array_pop($tmp);
                $vendor_name = array_pop($tmp);
                $classname = "\\{$vendor_name}\\{$module_name}\\ACL\\ACL";
                if (class_exists($classname)) {
                    $acl_rfcls = new \ReflectionClass($classname);
                    $check_derived = $acl_rfcls;
                    while ($check_derived !== false) {
                        if ($check_derived->name == 'OPNsense\Core\ACL\ACL') {
                            break;
                        }
                        $check_derived = $check_derived->getParentClass();
                    }
                    if ($check_derived === false) {
                        throw new \Exception('ACL class ' . $classname . ' seems to be of wrong type');
                    }
                } else {
                    $acl_rfcls = new \ReflectionClass('OPNsense\Core\ACL\ACL');
                }
                // construct new ACL
                $acl = $acl_rfcls->newInstance($module);
                $acl->update($this->ACLtags);
            }
        }
    }

    /**
     * check url against regex mask
     * @param string $url url to match
     * @param string $urlmask regex mask
     * @return bool url matches mask
     */
    public function urlMatch($url, $urlmask)
    {
        /* "." and "?" have no effect on match, but "*" is a wildcard */
        $match = str_replace(array('.', '*','?'), array('\.', '.*','\?'), $urlmask);
        /* if pattern ends with special markers also match flat URL mask */
        $match = preg_replace('@([/&?])\.\*$@', '($1.*)?', $match);
        /* remove client side pattern from given URL */
        $url = preg_replace('@#.*$@', '', $url);

        $result = preg_match("@^/{$match}$@", "{$url}");
        if ($result) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Construct new ACL object
     */
    public function __construct()
    {
        // set cache location
        $this->aclCacheFilename = (new AppConfig())->application->tempDir . '/opnsense_acl_cache.json';

        // load module ACL's
        if (!$this->isExpired()) {
            $this->ACLtags = json_decode(file_get_contents($this->aclCacheFilename), true);
        }
        if (empty($this->ACLtags)) {
            // (re)generate acl mapping and save to cache
            $this->persist();
        }
        // load user and group rights
        $this->loadUserGroupRights();
    }

    /**
     * @param string $group groupname to check
     * @param string $remote_addr users remote address
     */
    private function isAddressAllowed($group, $remote_addr)
    {
        $nets = $this->allGroupPrivs[$group]['source_networks'];
        if (empty($nets)) {
            return true;
        }
        foreach ($nets as $net) {
            if (Util::isIPInCIDR($remote_addr, $net)) {
                return true;
            }
        }
        return false;
    }

    /**
     * return a list of locations for a user combined with source networks
     * @param string $username user name
     */
    public function userUrlMasks($username)
    {
        foreach ($this->userDatabase[$username]["priv"] as $privset) {
            foreach ($privset as $urlmask) {
                yield [$urlmask, []];
            }
        }
        foreach ($this->userDatabase[$username]["groups"] as $itemkey => $group) {
            if (array_key_exists($group, $this->allGroupPrivs)) {
                foreach ($this->allGroupPrivs[$group]['priv'] as $privset) {
                    foreach ($privset as $urlmask) {
                        yield [$urlmask, $this->allGroupPrivs[$group]['source_networks']];
                    }
                }
            }
        }
    }

    /**
     * iterator to collect all assigned access patterns for this user (optionally combined with location)
     * @param string $username user name
     */
    private function urlMasks($username)
    {
        /* address of the connecting client */
        $remote_addr = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null;
        if (array_key_exists($username, $this->userDatabase)) {
            // fetch masks from user privs
            foreach ($this->userDatabase[$username]["priv"] as $privset) {
                foreach ($privset as $urlmask) {
                    yield $urlmask;
                }
            }
            // fetch masks from assigned groups
            foreach ($this->userDatabase[$username]["groups"] as $itemkey => $group) {
                if (array_key_exists($group, $this->allGroupPrivs)) {
                    if (!$this->isAddressAllowed($group, $remote_addr)) {
                        continue; /* skip not allowed */
                    }
                    foreach ($this->allGroupPrivs[$group]['priv'] as $privset) {
                        foreach ($privset as $urlmask) {
                            yield $urlmask;
                        }
                    }
                }
            }
        }

        /*
         * Always allow logout and menu, should be yielded as final items
         * to prevent redirect to the logout page in case unauthorised
         * pages are tried.
         */
        yield 'index.php?logout';
        yield 'api/core/menu/*';
    }

    /**
     * check if an endpoint url is accessible by the specified user.
     * @param string $username user name
     * @param string $url full url, for example /ui/firewall/filter
     * @return bool
     */
    public function isPageAccessible($username, $url)
    {
        if (!empty($_SESSION['user_shouldChangePassword'])) {
            // when a password change is enforced, lock all other endpoints
            foreach (['system_usermanager_passwordmg.php*', 'ui/user_portal', 'api/user_portal/user/*'] as $pattern) {
                if ($this->urlMatch($url, $pattern)) {
                    return true;
                }
            }
            return false;
        }
        foreach ($this->urlMasks($username) as $urlmask) {
            if ($this->urlMatch($url, $urlmask)) {
                return true;
            }
        }

        return false;
    }

    /**
     * test if a user has a certain privilege set.
     * (transition method, should be replaced by group membership)
     * @param string $username user name
     * @param string $reqpriv privilege name
     * @return bool
     */
    public function hasPrivilege($username, $reqpriv)
    {
        $uid = null;
        $privs = [];
        $groups = [];
        $config = Config::getInstance()->object();
        if ($config->system->count() > 0) {
            foreach ($config->system->children() as $key => $node) {
                if ($key == 'user' && (string)$node->name == $username) {
                    foreach ($node->priv as $priv) {
                        $privs = array_merge($privs, array_filter(explode(',', $priv)));
                    }
                    $uid = (string)$node->uid;
                }
            }
            foreach ($config->system->children() as $key => $groupNode) {
                if ($key == 'group') {
                    $group_privs = [];
                    $userInGrp = false;
                    foreach ($groupNode->children() as $itemKey => $node) {
                        if ($node->getName() == "member" && in_array($uid, explode(',', $node))) {
                            $userInGrp = true;
                        } elseif ($node->getName() == "priv") {
                            $group_privs = array_merge($group_privs, array_filter(explode(',', $node)));
                        }
                    }
                    if ($userInGrp) {
                        $privs = array_merge($privs, $group_privs);
                    }
                }
            }
        }
        return in_array($reqpriv, $privs);
    }

    /**
     * check if user has group membership
     * @param string $username user name
     * @param string $groupname group name
     * @param boolean $byname query by name (or gid)
     * @return bool|null|string|string[]
     */
    public function inGroup($username, $groupname, $byname = true)
    {
        if (!empty($this->userDatabase[$username])) {
            if ($byname) {
                return in_array($groupname, $this->userDatabase[$username]['groups']);
            } else {
                return in_array($groupname, $this->userDatabase[$username]['gids']);
            }
        }
        return false;
    }

    /**
     * get user preferred landing page
     * @param string $username user name
     * @return bool|null|string|string[]
     */
    public function getLandingPage($username)
    {
        if (!empty($_SESSION['user_shouldChangePassword'])) {
            // ACL lock, may only access password page
            return "ui/user_portal";
        } if (!empty($this->userDatabase[$username]['landing_page'])) {
            $landing = ltrim(rawurldecode(trim($this->userDatabase[$username]['landing_page'])), '/\\');
            if (!empty($landing) && $this->isPageAccessible($username, '/' . $landing)) {
                return $landing;
            }
        } if (!empty($this->userDatabase[$username])) {
            // default behaviour, find first accessible location from configured privileges, but prefer /
            if ($this->isPageAccessible($username, '/')) {
                return "index.php";
            }
            foreach ($this->urlMasks($username) as $pattern) {
                if (str_starts_with('api', $pattern) || $pattern == "*") {
                    continue;
                } elseif (!empty($pattern)) {
                    /* remove wildcard and optional trailing slashes or query symbols */
                    return preg_replace('@[/&?]?\*$@', '', $pattern);
                }
                break;
            }
            return null;
        }
    }

    /**
     * return privilege list as array (sorted), only for backward compatibility
     * @return array
     */
    public function getPrivList()
    {
        // convert json priv map to array
        $priv_list = [];
        foreach ($this->ACLtags as $aclKey => $aclItem) {
            $priv_list[$aclKey] = [];
            foreach ($aclItem as $propName => $propValue) {
                if ($propName == 'name') {
                    // translate name tag
                    $priv_list[$aclKey][$propName] = gettext($propValue);
                } else {
                    $priv_list[$aclKey][$propName] = $propValue;
                }
            }
        }

        // sort by name ( case insensitive )
        uasort($priv_list, function ($a, $b) {
            return strcasecmp($a["name"], $b["name"]);
        });

        return $priv_list;
    }

    /**
     * Load and persist ACL configuration to disk.
     * When locked we just load all the module ACL's and continue by default (return false),
     * this has a slight performance impact but is usually better then waiting for likely the same content being
     * written by another session.
     * @param bool $nowait when the cache is locked, skip waiting for it to become available.
     * @return bool has persisted
     */
    public function persist($nowait = true)
    {
        $this->mergePluggableACLs();
        $fp = fopen($this->aclCacheFilename, file_exists($this->aclCacheFilename) ? "r+" : "w+");
        $lockMode = $nowait ? LOCK_EX | LOCK_NB : LOCK_EX;
        if (flock($fp, $lockMode)) {
            ftruncate($fp, 0);
            fwrite($fp, json_encode($this->ACLtags));
            fflush($fp);
            flock($fp, LOCK_UN);
            fclose($fp);
            chmod($this->aclCacheFilename, 0660);
            @chown($this->aclCacheFilename, 'wwwonly'); /* XXX frontend owns file */
            @chgrp($this->aclCacheFilename, 'wheel'); /* XXX backend can work with it */
            return true;
        }
        return false;
    }

    /**
     * invalidate cache, removes cache file from disk if available, which forces the next request to persist() again
     */
    public function invalidateCache()
    {
        @unlink($this->aclCacheFilename);
    }

    /**
     * check if pluggable ACL's are expired
     * @return bool is expired
     */
    public function isExpired()
    {
        if (file_exists($this->aclCacheFilename)) {
            $fstat = stat($this->aclCacheFilename);
            return $this->aclCacheTTL < (time() - $fstat['mtime']);
        }
        return true;
    }
}
