<?php

declare(strict_types=1);

namespace Laminas\Authentication\Adapter;

use Laminas\Authentication\Result as AuthenticationResult;
use Laminas\Ldap as LaminasLdap;
use Laminas\Ldap\Exception\LdapException;
use stdClass;

use function array_diff;
use function array_key_exists;
use function array_keys;
use function array_map;
use function count;
use function in_array;
use function is_array;
use function preg_quote;
use function preg_replace;
use function strcasecmp;
use function substr;
use function trim;

class Ldap extends AbstractAdapter
{
    /**
     * The Laminas\Ldap\Ldap context.
     *
     * @var LaminasLdap\Ldap
     */
    protected $ldap;

    /**
     * The array of arrays of Laminas\Ldap\Ldap options passed to the constructor.
     *
     * @var array
     */
    protected $options;

    /**
     * The DN of the authenticated account. Used to retrieve the account entry on request.
     *
     * @var string
     */
    protected $authenticatedDn;

    /**
     * Constructor
     *
     * @param  array  $options    An array of arrays of Laminas\Ldap\Ldap options
     * @param  string $identity   The username of the account being authenticated
     * @param  string $credential The password of the account being authenticated
     */
    public function __construct(array $options = [], $identity = null, $credential = null)
    {
        $this->setOptions($options);
        if ($identity !== null) {
            $this->setIdentity($identity);
        }
        if ($credential !== null) {
            $this->setCredential($credential);
        }
    }

    /**
     * Returns the array of arrays of Laminas\Ldap\Ldap options of this adapter.
     *
     * @return array|null
     */
    public function getOptions()
    {
        return $this->options;
    }

    /**
     * Sets the array of arrays of Laminas\Ldap\Ldap options to be used by
     * this adapter.
     *
     * @param  array $options The array of arrays of Laminas\Ldap\Ldap options
     * @return self Provides a fluent interface
     */
    public function setOptions($options)
    {
        $this->options = is_array($options) ? $options : [];
        if (array_key_exists('identity', $this->options)) {
            $this->options['username'] = $this->options['identity'];
        }
        if (array_key_exists('credential', $this->options)) {
            $this->options['password'] = $this->options['credential'];
        }
        return $this;
    }

    /**
     * Returns the username of the account being authenticated, or
     * NULL if none is set.
     *
     * @return string|null
     */
    public function getUsername()
    {
        return $this->getIdentity();
    }

    /**
     * Sets the username for binding
     *
     * @param  string $username The username for binding
     * @return self Provides a fluent interface
     */
    public function setUsername($username)
    {
        return $this->setIdentity($username);
    }

    /**
     * Returns the password of the account being authenticated, or
     * NULL if none is set.
     *
     * @return string|null
     */
    public function getPassword()
    {
        return $this->getCredential();
    }

    /**
     * Sets the password for the account
     *
     * @param  string $password The password of the account being authenticated
     * @return self Provides a fluent interface
     */
    public function setPassword($password)
    {
        return $this->setCredential($password);
    }

    /**
     * Returns the LDAP Object
     *
     * @return LaminasLdap\Ldap The Laminas\Ldap\Ldap object used to authenticate the credentials
     */
    public function getLdap()
    {
        if ($this->ldap === null) {
            $this->ldap = new LaminasLdap\Ldap();
        }

        return $this->ldap;
    }

    /**
     * Set an Ldap connection
     *
     * @param  LaminasLdap\Ldap $ldap An existing Ldap object
     * @return self Provides a fluent interface
     */
    public function setLdap(LaminasLdap\Ldap $ldap)
    {
        $this->ldap = $ldap;

        $this->setOptions([$ldap->getOptions()]);

        return $this;
    }

    /**
     * Returns a domain name for the current LDAP options. This is used
     * for skipping redundant operations (e.g. authentications).
     *
     * @return string
     */
    protected function getAuthorityName()
    {
        $options = $this->getLdap()->getOptions();
        $name    = $options['accountDomainName'];
        if (! $name) {
            $name = $options['accountDomainNameShort'];
        }

        return $name ? $name : '';
    }

    /**
     * Authenticate the user
     *
     * @return AuthenticationResult
     * @throws Exception\ExceptionInterface
     */
    public function authenticate()
    {
        $messages    = [];
        $messages[0] = ''; // reserved
        $messages[1] = ''; // reserved

        $username = $this->identity;
        $password = $this->credential;

        if (! $username) {
            $code        = AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND;
            $messages[0] = 'A username is required';
            return new AuthenticationResult($code, '', $messages);
        }
        if (! $password) {
            /* A password is required because some servers will
             * treat an empty password as an anonymous bind.
             */
            $code        = AuthenticationResult::FAILURE_CREDENTIAL_INVALID;
            $messages[0] = 'A password is required';
            return new AuthenticationResult($code, '', $messages);
        }

        $ldap = $this->getLdap();

        $code              = AuthenticationResult::FAILURE;
        $messages[0]       = "Authority not found: $username";
        $failedAuthorities = [];

        /* Iterate through each server and try to authenticate the supplied
         * credentials against it.
         */
        foreach ($this->options as $options) {
            if (! is_array($options)) {
                throw new Exception\InvalidArgumentException('Adapter options array not an array');
            }
            $adapterOptions = $this->prepareOptions($ldap, $options);
            $dname          = '';

            try {
                if ($messages[1]) {
                    $messages[] = $messages[1];
                }

                $messages[1] = '';
                $messages[]  = $this->optionsToString($options);

                $dname = $this->getAuthorityName();
                if (isset($failedAuthorities[$dname])) {
                    /* If multiple sets of server options for the same domain
                     * are supplied, we want to skip redundant authentications
                     * where the identity or credentials where found to be
                     * invalid with another server for the same domain. The
                     * $failedAuthorities array tracks this condition (and also
                     * serves to supply the original error message).
                     * This fixes issue Laminas-4093.
                     */
                    $messages[1] = $failedAuthorities[$dname];
                    $messages[]  = "Skipping previously failed authority: $dname";
                    continue;
                }

                $canonicalName = $ldap->getCanonicalAccountName($username);
                $ldap->bind($canonicalName, $password);
                /*
                 * Fixes problem when authenticated user is not allowed to retrieve
                 * group-membership information or own account.
                 * This requires that the user specified with "username" and optionally
                 * "password" in the Laminas\Ldap\Ldap options is able to retrieve the required
                 * information.
                 */
                $requireRebind = false;
                if (isset($options['username'])) {
                    $ldap->bind();
                    $requireRebind = true;
                }
                $dn = $ldap->getCanonicalAccountName($canonicalName, LaminasLdap\Ldap::ACCTNAME_FORM_DN);

                $groupResult = $this->checkGroupMembership($ldap, $canonicalName, $dn, $adapterOptions);
                if ($groupResult === true) {
                    $this->authenticatedDn = $dn;
                    $messages[0]           = '';
                    $messages[1]           = '';
                    $messages[]            = "$canonicalName authentication successful";
                    if ($requireRebind === true) {
                        // rebinding with authenticated user
                        $ldap->bind($dn, $password);
                    }
                    return new AuthenticationResult(AuthenticationResult::SUCCESS, $canonicalName, $messages);
                } else {
                    $messages[0]               = 'Account is not a member of the specified group';
                    $messages[1]               = $groupResult;
                    $failedAuthorities[$dname] = $groupResult;
                }
            } catch (LdapException $zle) {
                /* LDAP based authentication is notoriously difficult to diagnose. Therefore
                 * we bend over backwards to capture and record every possible bit of
                 * information when something goes wrong.
                 */

                $err = $zle->getCode();

                if ($err === LdapException::LDAP_X_DOMAIN_MISMATCH) {
                    /* This error indicates that the domain supplied in the
                     * username did not match the domains in the server options
                     * and therefore we should just skip to the next set of
                     * server options.
                     */
                    continue;
                } elseif ($err === LdapException::LDAP_NO_SUCH_OBJECT) {
                    $code                      = AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND;
                    $messages[0]               = "Account not found: $username";
                    $failedAuthorities[$dname] = $zle->getMessage();
                } elseif ($err === LdapException::LDAP_INVALID_CREDENTIALS) {
                    $code                      = AuthenticationResult::FAILURE_CREDENTIAL_INVALID;
                    $messages[0]               = 'Invalid credentials';
                    $failedAuthorities[$dname] = $zle->getMessage();
                } else {
                    $line        = $zle->getLine();
                    $messages[]  = $zle->getFile() . "($line): " . $zle->getMessage();
                    $messages[]  = preg_replace(
                        '/\b' . preg_quote(substr($password, 0, 15), '/') . '\b/',
                        '*****',
                        $zle->getTraceAsString()
                    );
                    $messages[0] = 'An unexpected failure occurred';
                }
                $messages[1] = $zle->getMessage();
            }
        }

        $msg        = $messages[1] ?? $messages[0];
        $messages[] = "$username authentication failed: $msg";

        return new AuthenticationResult($code, $username, $messages);
    }

    /**
     * Sets the LDAP specific options on the Laminas\Ldap\Ldap instance
     *
     * @param  array         $options
     * @return array of auth-adapter specific options
     */
    protected function prepareOptions(LaminasLdap\Ldap $ldap, array $options)
    {
        $adapterOptions = [
            'group'       => null,
            'groupDn'     => $ldap->getBaseDn(),
            'groupScope'  => LaminasLdap\Ldap::SEARCH_SCOPE_SUB,
            'groupAttr'   => 'cn',
            'groupFilter' => 'objectClass=groupOfUniqueNames',
            'memberAttr'  => 'uniqueMember',
            'memberIsDn'  => true,
        ];
        foreach (array_keys($adapterOptions) as $key) {
            if (array_key_exists($key, $options)) {
                $value = $options[$key];
                unset($options[$key]);
                switch ($key) {
                    case 'groupScope':
                        $value = (int) $value;
                        if (
                            in_array(
                                $value,
                                [
                                    LaminasLdap\Ldap::SEARCH_SCOPE_BASE,
                                    LaminasLdap\Ldap::SEARCH_SCOPE_ONE,
                                    LaminasLdap\Ldap::SEARCH_SCOPE_SUB,
                                ],
                                true
                            )
                        ) {
                            $adapterOptions[$key] = $value;
                        }
                        break;
                    case 'memberIsDn':
                        $adapterOptions[$key] = $value === true ||
                                $value === '1' || strcasecmp($value, 'true') === 0;
                        break;
                    default:
                        $adapterOptions[$key] = trim($value);
                        break;
                }
            }
        }
        $ldap->setOptions($options);
        return $adapterOptions;
    }

    /**
     * Checks the group membership of the bound user
     *
     * @param  string        $canonicalName
     * @param  string        $dn
     * @param  array         $adapterOptions
     * @return string|true
     */
    protected function checkGroupMembership(LaminasLdap\Ldap $ldap, $canonicalName, $dn, array $adapterOptions)
    {
        if ($adapterOptions['group'] === null) {
            return true;
        }

        if ($adapterOptions['memberIsDn'] === false) {
            $user = $canonicalName;
        } else {
            $user = $dn;
        }

        $groupName   = LaminasLdap\Filter::equals($adapterOptions['groupAttr'], $adapterOptions['group']);
        $membership  = LaminasLdap\Filter::equals($adapterOptions['memberAttr'], $user);
        $group       = LaminasLdap\Filter::andFilter($groupName, $membership);
        $groupFilter = $adapterOptions['groupFilter'];
        if (! empty($groupFilter)) {
            $group = $group->addAnd($groupFilter);
        }

        $result = $ldap->count($group, $adapterOptions['groupDn'], $adapterOptions['groupScope']);

        if ($result === 1) {
            return true;
        }

        return 'Failed to verify group membership with ' . $group->toString();
    }

    /**
     * getAccountObject() - Returns the result entry as a stdClass object
     *
     * This resembles the feature {@see Laminas\Authentication\Adapter\DbTable::getResultRowObject()}.
     * Closes Laminas-6813
     *
     * @param  array $returnAttribs
     * @param  array $omitAttribs
     * @return stdClass|bool
     */
    public function getAccountObject(array $returnAttribs = [], array $omitAttribs = [])
    {
        if (! $this->authenticatedDn) {
            return false;
        }

        $returnObject = new stdClass();

        $returnAttribs = array_map('strtolower', $returnAttribs);
        $omitAttribs   = array_map('strtolower', $omitAttribs);
        $returnAttribs = array_diff($returnAttribs, $omitAttribs);

        $entry = $this->getLdap()->getEntry($this->authenticatedDn, $returnAttribs, true);
        foreach ($entry as $attr => $value) {
            if (in_array($attr, $omitAttribs)) {
                // skip attributes marked to be omitted
                continue;
            }
            if (is_array($value)) {
                $returnObject->$attr = count($value) > 1 ? $value : $value[0];
            } else {
                $returnObject->$attr = $value;
            }
        }
        return $returnObject;
    }

    /**
     * Converts options to string
     *
     * @param  array $options
     * @return string
     */
    private function optionsToString(array $options)
    {
        $str = '';
        foreach ($options as $key => $val) {
            if ($key === 'password' || $key === 'credential') {
                $val = '*****';
            }
            if ($str) {
                $str .= ',';
            }
            $str .= $key . '=' . $val;
        }
        return $str;
    }
}
