<?php

// easier to rewrite for Active Directory than to bash it into existing LDAP implementation

// disable certificate checking before connect if required

namespace LibreNMS\Authentication;

use App\Facades\LibrenmsConfig;
use LDAP\Connection;
use LibreNMS\Enum\LegacyAuthLevel;
use LibreNMS\Exceptions\AuthenticationException;
use LibreNMS\Exceptions\LdapMissingException;

class ActiveDirectoryAuthorizer extends AuthorizerBase
{
    use ActiveDirectoryCommon;

    protected static $CAN_UPDATE_PASSWORDS = false;

    protected ?Connection $ldap_connection = null;
    protected $is_bound = false; // this variable tracks if bind has been called so we don't call it multiple times

    public function authenticate($credentials)
    {
        $this->connect();

        if ($this->ldap_connection) {
            // bind with sAMAccountName instead of full LDAP DN
            if (! empty($credentials['username']) && ! empty($credentials['password']) && ldap_bind($this->ldap_connection, $credentials['username'] . '@' . LibrenmsConfig::get('auth_ad_domain'), $credentials['password'])) {
                $this->is_bound = true;
                // group membership in one of the configured groups is required
                if (LibrenmsConfig::get('auth_ad_require_groupmembership', true)) {
                    // cycle through defined groups, test for memberOf-ship
                    foreach (LibrenmsConfig::get('auth_ad_groups', []) as $group => $level) {
                        if ($this->userInGroup($credentials['username'], $group)) {
                            return true;
                        }
                    }

                    // failed to find user
                    if (LibrenmsConfig::get('auth_ad_debug', false)) {
                        throw new AuthenticationException('User is not in one of the required groups or user/group is outside the base dn');
                    }

                    throw new AuthenticationException();
                } else {
                    // group membership is not required and user is valid
                    return true;
                }
            }
        }

        if (empty($credentials['password'])) {
            throw new AuthenticationException('A password is required');
        } elseif (LibrenmsConfig::get('auth_ad_debug', false)) {
            ldap_get_option($this->ldap_connection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $extended_error);
            throw new AuthenticationException(ldap_error($this->ldap_connection) . '<br />' . $extended_error);
        }

        throw new AuthenticationException(ldap_error($this->ldap_connection));
    }

    protected function userInGroup($username, $groupname)
    {
        $connection = $this->getConnection();

        // check if user is member of the given group or nested groups
        $ldap_group = ldap_escape($groupname, '', LDAP_ESCAPE_FILTER);
        $search_filter = "(&(objectClass=group)(cn=$ldap_group))";

        // get DN for auth_ad_group
        $search = ldap_search(
            $connection,
            LibrenmsConfig::get('auth_ad_base_dn'),
            $search_filter,
            ['cn']
        );
        $result = ldap_get_entries($connection, $search);

        if ($result == false || $result['count'] !== 1) {
            if (LibrenmsConfig::get('auth_ad_debug', false)) {
                if ($result == false) {
                    throw new AuthenticationException("LDAP query failed for group '$groupname' using filter '$search_filter', last LDAP error: " . ldap_error($connection));
                } elseif ((int) $result['count'] == 0) {
                    throw new AuthenticationException("Failed to find group matching '$groupname' using filter '$search_filter'");
                } elseif ((int) $result['count'] > 1) {
                    throw new AuthenticationException("Multiple groups returned for '$groupname' using filter '$search_filter'");
                }
            }

            throw new AuthenticationException();
        }

        // special character handling
        $group_dn = addcslashes((string) $result[0]['dn'], '()#');

        $search = ldap_search(
            $connection,
            LibrenmsConfig::get('auth_ad_base_dn'),
            // add 'LDAP_MATCHING_RULE_IN_CHAIN to the user filter to search for $username in nested $group_dn
            // limiting to "DN" for shorter array
            '(&' . $this->userFilter($username) . "(memberOf:1.2.840.113556.1.4.1941:=$group_dn))",
            ['DN']
        );
        $entries = ldap_get_entries($connection, $search);

        return (int) $entries['count'] > 0;
    }

    public function userExists($username, $throw_exception = false)
    {
        $connection = $this->getConnection();

        $search = ldap_search(
            $connection,
            LibrenmsConfig::get('auth_ad_base_dn'),
            $this->userFilter($username),
            ['samaccountname']
        );

        if ($search === false) {
            throw new AuthenticationException('User search failed: ' . ldap_error($connection));
        }

        $entries = ldap_get_entries($connection, $search);

        if ($entries['count']) {
            return true;
        }

        return false;
    }

    public function getRoles(string $username): array|false
    {
        $roles = [];
        if (! LibrenmsConfig::get('auth_ad_require_groupmembership', true)) {
            if (LibrenmsConfig::get('auth_ad_global_read', false)) {
                $roles[] = 'global-read';
            }
        }

        // cycle through defined groups, test for memberOf-ship
        foreach (LibrenmsConfig::get('auth_ad_groups', []) as $group => $data) {
            try {
                if ($this->userInGroup($username, $group)) {
                    if (isset($data['roles']) && is_array($data['roles'])) {
                        $roles = array_merge($roles, $data['roles']);
                    } elseif (isset($data['level'])) {
                        $role = LegacyAuthLevel::tryFrom($data['level'])?->getName();
                        if ($role) {
                            $roles[] = $role;
                        }
                    }
                }
            } catch (AuthenticationException) {
            }
        }

        return array_unique($roles);
    }

    public function getUserid($username)
    {
        $connection = $this->getConnection();

        $attributes = ['objectsid'];
        $search = ldap_search(
            $connection,
            LibrenmsConfig::get('auth_ad_base_dn'),
            $this->userFilter($username),
            $attributes
        );

        if ($search !== false) {
            $entries = ldap_get_entries($connection, $search);

            if ($entries !== false && $entries['count']) {
                return $this->getUseridFromSid($this->sidFromLdap($entries[0]['objectsid'][0]));
            }
        }

        return -1;
    }

    /**
     * Bind to AD with the bind user if available, otherwise anonymous bind
     */
    protected function init()
    {
        if ($this->ldap_connection) {
            return;
        }

        $this->connect();
        $this->bind();
    }

    protected function connect()
    {
        if ($this->ldap_connection) {
            // no need to re-connect
            return;
        }

        if (! function_exists('ldap_connect')) {
            throw new LdapMissingException();
        }

        if (LibrenmsConfig::has('auth_ad_check_certificates') &&
            ! LibrenmsConfig::get('auth_ad_check_certificates')) {
            putenv('LDAPTLS_REQCERT=never');
        }

        if (LibrenmsConfig::has('auth_ad_check_certificates') && LibrenmsConfig::get('auth_ad_debug')) {
            ldap_set_option(null, LDAP_OPT_DEBUG_LEVEL, 7);
        }

        $this->ldap_connection = ldap_connect(LibrenmsConfig::get('auth_ad_url'));
        if (empty($this->ldap_connection)) {
            throw new AuthenticationException('Fatal error while connecting to AD, uri not valid: ' . LibrenmsConfig::get('auth_ad_url'));
        }

        // disable referrals and force ldap version to 3
        ldap_set_option($this->ldap_connection, LDAP_OPT_REFERRALS, 0);
        ldap_set_option($this->ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3);

        $starttls = LibrenmsConfig::get('auth_ad_starttls');
        if ($starttls == 'optional' || $starttls == 'required') {
            $tls = ldap_start_tls($this->ldap_connection);
            if ($starttls == 'required' && $tls === false) {
                throw new AuthenticationException('Fatal error: LDAP TLS required but not successfully negotiated:' . ldap_error($this->ldap_connection));
            }
        }
    }

    public function bind($credentials = [])
    {
        if (! $this->ldap_connection) {
            $this->connect();
        }

        $username = $credentials['username'] ?? null;
        $password = $credentials['password'] ?? null;

        if (LibrenmsConfig::has('auth_ad_binduser') && LibrenmsConfig::has('auth_ad_bindpassword')) {
            $username = LibrenmsConfig::get('auth_ad_binduser');
            $password = LibrenmsConfig::get('auth_ad_bindpassword');
        }
        $username .= '@' . LibrenmsConfig::get('auth_ad_domain');

        ldap_set_option($this->ldap_connection, LDAP_OPT_NETWORK_TIMEOUT, LibrenmsConfig::get('auth_ad_timeout', 5));
        $bind_result = ldap_bind($this->ldap_connection, $username, $password);
        ldap_set_option($this->ldap_connection, LDAP_OPT_NETWORK_TIMEOUT, -1); // restore timeout

        if ($bind_result) {
            return $bind_result;
        }

        ldap_set_option($this->ldap_connection, LDAP_OPT_NETWORK_TIMEOUT, LibrenmsConfig::get('auth_ad_timeout', 5));
        ldap_bind($this->ldap_connection);
        ldap_set_option($this->ldap_connection, LDAP_OPT_NETWORK_TIMEOUT, -1); // restore timeout
    }

    protected function getConnection(): ?Connection
    {
        $this->init(); // make sure connected and bound

        return $this->ldap_connection;
    }
}
