<?php
/**
 * Class LDAPGateway
 *
 * Works within the LDAP domain model to provide basic operations.
 * These are exclusively used in LDAPService for constructing more complex operations.
 */
class LDAPGateway extends Object
{
    /**
     * @var array
     * @config
     */
    private static $options = [];

    /**
     * @var Zend\Ldap\Ldap
     */
    private $ldap;

    public function __construct()
    {
        parent::__construct();
        // due to dependency injection this class can be created without any LDAP options set
        // and \Zend\Ldap\Ldap will throw a warning with an empty array
        if (count($this->config()->options)) {
            $this->ldap = new Zend\Ldap\Ldap($this->config()->options);
        }
    }

    /**
     * Query the LDAP directory with the given filter.
     *
     * @param string $filter The string to filter by, e.g. (objectClass=user)
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @param string $sort Sort results by this attribute if given
     * @return array
     */
    protected function search($filter, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
    {
        $records = $this->ldap->search($filter, $baseDn, $scope, $attributes, $sort);

        $results = [];
        foreach ($records as $record) {
            foreach ($record as $attribute => $value) {
                // if the value is an array with a single value, e.g. 'samaccountname' => array(0 => 'myusername')
                // then make sure it's just set in the results as 'samaccountname' => 'myusername' so that it
                // can be used directly by ArrayData
                if (is_array($value) && count($value) == 1) {
                    $value = $value[0];
                }

                // ObjectGUID and ObjectSID attributes are in binary, we need to convert those to strings
                if ($attribute == 'objectguid') {
                    $value = LDAPUtil::bin_to_str_guid($value);
                }
                if ($attribute == 'objectsid') {
                    $value = LDAPUtil::bin_to_str_sid($value);
                }

                $record[$attribute] = $value;
            }

            $results[] = $record;
        }

        return $results;
    }

    /**
     * Authenticate the given username and password with LDAP.
     *
     * @param string $username
     * @param string $password
     * @return \Zend\Authentication\Result
     */
    public function authenticate($username, $password)
    {
        $auth = new Zend\Authentication\AuthenticationService();
        $adapter = new Zend\Authentication\Adapter\Ldap([$this->config()->options], $username, $password);
        return $auth->authenticate($adapter);
    }

    /**
     * Query for LDAP nodes (organizational units, containers, and domains).
     *
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @param string $sort Sort results by this attribute if given
     * @return array
     */
    public function getNodes($baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
    {
        return $this->search('(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=domain))', $baseDn, $scope, $attributes, $sort);
    }

    /**
     * Query for LDAP groups.
     *
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @param string $sort Sort results by this attribute if given
     * @return array
     */
    public function getGroups($baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
    {
        return $this->search('(objectClass=group)', $baseDn, $scope, $attributes, $sort);
    }

    /**
     * Return all nested AD groups underneath a specific DN
     *
     * @param string $dn
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @return array
     */
    public function getNestedGroups($dn, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=group)(memberOf:1.2.840.113556.1.4.1941:=%s))', $dn),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Return a particular LDAP group by objectGUID value.
     *
     * @param string $guid
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @return array
     */
    public function getGroupByGUID($guid, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=group)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Return a particular LDAP group by DN value.
     *
     * @param string $dn
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @return array
     */
    public function getGroupByDN($dn, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=group)(distinguishedname=%s))', $dn),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Query for LDAP users, but don't include built-in user accounts.
     *
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @param string $sort Sort results by this attribute if given
     * @return array
     */
    public function getUsers($baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
    {
        return $this->search(
            '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))',
            $baseDn,
            $scope,
            $attributes,
            $sort
        );
    }

    /**
     * Return a particular LDAP user by objectGUID value.
     *
     * @param string $guid
     * @return array
     */
    public function getUserByGUID($guid, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=user)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Return a particular LDAP user by DN value.
     *
     * @param string $dn
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @return array
     */
    public function getUserByDN($dn, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=user)(distinguishedname=%s))', $dn),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Return a particular LDAP user by mail value.
     *
     * @param string $email
     * @return array
     */
    public function getUserByEmail($email, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        return $this->search(
            sprintf('(&(objectClass=user)(mail=%s))', Zend\Ldap\Filter\AbstractFilter::escapeValue($email)),
            $baseDn,
            $scope,
            $attributes
        );
    }

    /**
     * Get a specific user's data from LDAP
     *
     * @param string $username
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE. Default is Zend_Ldap::SEARCH_SCOPE_SUB
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
     * @return array
     * @throws Exception
     */
    public function getUserByUsername($username, $baseDn = null, $scope = Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes = [])
    {
        $options = $this->config()->options;
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;

        // will translate the username to username@foo.bar, username or foo\user depending on the
        // $options['accountCanonicalForm']
        $username = $this->ldap->getCanonicalAccountName($username, $option);
        switch ($option) {
            case Zend\Ldap\Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
                $filter = sprintf('(&(objectClass=user)(samaccountname=%s))', Zend\Ldap\Filter\AbstractFilter::escapeValue($username));
                break;
            case Zend\Ldap\Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
                // @todo Not supported yet!
                throw new Exception('Backslash style not supported in LDAPGateway::getUserByUsername()!');
                break;
            case Zend\Ldap\Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. alice@foo.com
                $filter = sprintf('(&(objectClass=user)(userprincipalname=%s))', Zend\Ldap\Filter\AbstractFilter::escapeValue($username));
                break;
            case Zend\Ldap\Ldap::ACCTNAME_FORM_DN: // distinguished name, e.g. CN=someone,DC=example,DC=co,DC=nz
                // @todo Not supported yet!
                throw new Exception('DN style not supported in LDAPGateway::getUserByUsername()!');
                break;
            default: // default to principal style
                $filter = sprintf('(&(objectClass=user)(userprincipalname=%s))', Zend\Ldap\Filter\AbstractFilter::escapeValue($username));
        }

        return $this->search($filter, $baseDn, $scope, $attributes);
    }

    /**
     * Get a canonical username from the record based on the connection settings.
     *
     * @param array $data
     * @return string
     */
    public function getCanonicalUsername($data)
    {
        $options = $this->config()->options;
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;

        switch ($option) {
            case Zend\Ldap\Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
                if (empty($data['samaccountname'])) {
                    throw new \Exception('Could not extract canonical username: samaccountname field missing');
                }
                return $data['samaccountname'];
            case Zend\Ldap\Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
                // @todo Not supported yet!
                throw new Exception('Backslash style not supported in LDAPGateway::getUsernameByEmail()!');
            case Zend\Ldap\Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. alice@foo.com
                if (empty($data['userprincipalname'])) {
                    throw new \Exception('Could not extract canonical username: userprincipalname field missing');
                }
                return $data['userprincipalname'];
            default: // default to principal style
                if (empty($data['userprincipalname'])) {
                    throw new \Exception('Could not extract canonical username: userprincipalname field missing');
                }
                return $data['userprincipalname'];
        }
    }

    /**
     * Changes user password via LDAP.
     *
     * Change password is different to administrative password reset in that it will respect the password
     * history policy. This is achieved by sending a remove followed by an add in one batch (which is different
     * to an ordinary attribute modification operation).
     *
     * @param string $dn Location to update the entry at.
     * @param string $password New password to set.
     * @param string $oldPassword Old password is needed to trigger a password change.
     * @throws \Zend\Ldap\Exception\LdapException
     */
    public function changePassword($dn, $password, $oldPassword)
    {
        if (!function_exists('ldap_modify_batch')) {
            // Password change is unsupported - missing PHP API method.
            $this->resetPassword($dn, $password);
            return;
        }

        $modifications = [
            [
                "attrib"  => "unicodePwd",
                "modtype" => LDAP_MODIFY_BATCH_REMOVE,
                "values"  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $oldPassword))],
            ],
            [
                "attrib"  => "unicodePwd",
                "modtype" => LDAP_MODIFY_BATCH_ADD,
                "values"  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))],
            ],
        ];
        // Batch attribute operations are not supported by Zend_Ldap, use raw resource.
        $ldapConn = $this->ldap->getResource();
        \Zend\Stdlib\ErrorHandler::start(E_WARNING);
        $succeeded = ldap_modify_batch($ldapConn, $dn, $modifications);
        \Zend\Stdlib\ErrorHandler::stop();
        if (!$succeeded) {
            throw new Exception($this->getLastPasswordError());
        }
    }

    /**
     * Administrative password reset.
     *
     * This is different to password change - it does not require old password, but also
     * it does not respect password history policy setting.
     *
     * @param string $dn Location to update the entry at.
     * @param string $password New password to set.
     * @throws \Zend\Ldap\Exception\LdapException
     */
    public function resetPassword($dn, $password)
    {
        try {
            $this->update(
                $dn,
                ['unicodePwd' => iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))]
            );
        } catch(\Zend\Ldap\Exception\LdapException $e) {
            throw new Exception($this->getLastPasswordError());
        }
    }

    /**
     * Updates an LDAP object.
     *
     * For this work you might need that LDAP connection is bind:ed with a user with
     * enough permissions to change attributes and that the LDAP connection is using
     * SSL/TLS. It depends on the server setup.
     *
     * If there are some errors, the underlying LDAP library should throw an Exception
     *
     * @param string $dn Location to update the entry at.
     * @param array $attributes An associative array of attributes.
     * @throws \Zend\Ldap\Exception\LdapException
     */
    public function update($dn, array $attributes)
    {
        $this->ldap->update($dn, $attributes);
    }

    /**
     * Deletes an LDAP object.
     *
     * @param string $dn Location of object to delete
     * @param bool $recursively Recursively delete nested objects?
     * @throws \Zend\Ldap\Exception\LdapException
     */
    public function delete($dn, $recursively = false)
    {
        $this->ldap->delete($dn, $recursively);
    }

    /**
     * Move an LDAP object from it's original DN location to another.
     * This effectively copies the object then deletes the original one.
     *
     * @param string $fromDn
     * @param string $toDn
     * @param bool $recursively Recursively move nested objects?
     */
    public function move($fromDn, $toDn, $recursively = false)
    {
        $this->ldap->move($fromDn, $toDn, $recursively);
    }

    /**
     * Add an LDAP object.
     *
     * For this work you might need that LDAP connection is bind:ed with a user with
     * enough permissions to change attributes and that the LDAP connection is using
     * SSL/TLS. It depends on the server setup.
     *
     * @param string $dn Location to add the entry at.
     * @param array $attributes An associative array of attributes.
     * @throws \Zend\Ldap\Exception\LdapException
     */
    public function add($dn, array $attributes)
    {
        $this->ldap->add($dn, $attributes);
    }

    /**
     * @param \Zend\Ldap\Exception\LdapException
     * @return string
     */
    private function getLastPasswordError() {
        $defaultError = _t('LDAPAuthenticator.CANTCHANGEPASSWORD', 'We couldn\'t change your password, please contact an administrator.');
        $error = '';
        ldap_get_option($this->ldap->getResource(), LDAP_OPT_ERROR_STRING, $error);

        if (!$error) {
            return $defaultError;
        }

        // Try to parse the exception to get the error message to display to user, eg:
        // 0000052D: Constraint violation - check_password_restrictions: the password does not meet the complexity criteria!)
        // 0000052D: Constraint violation - check_password_restrictions: the password was already used (in history)!)

        // We are only interested in the explanatory message after the last colon.
        $message = preg_replace('/.*:/', '', $error);
        if ($error) {
            return ucfirst(trim($message));
        }

        return $defaultError;
    }
}


<?php
/**
 * Class LDAPAuthenticator
 *
 * Authenticate a user against LDAP, without the single sign-on component.
 *
 * See SAMLAuthenticator for further information.
 */
class LDAPAuthenticator extends Authenticator
{
    /**
     * @var string
     */
    private $name = 'LDAP';

    /**
     * Set to 'yes' to indicate if this module should look up usernames in LDAP by matching the email addresses.
     *
     * CAVEAT #1: only set to 'yes' for systems that enforce email uniqueness.
     * Otherwise only the first LDAP user with matching email will be accessible.
     *
     * CAVEAT #2: this is untested for systems that use LDAP with principal style usernames (i.e. foo@bar.com).
     * The system will misunderstand emails for usernames with uncertain outcome.
     *
     * @var string 'no' or 'yes'
     */
    private static $allow_email_login = 'no';

    /**
     * Set to 'yes' to fallback login attempts to {@link $fallback_authenticator}.
     * This will occur if LDAP fails to authenticate the user.
     *
     * @var string 'no' or 'yes'
     */
    private static $fallback_authenticator = 'no';

    /**
     * The class of {@link Authenticator} to use as the fallback authenticator.
     *
     * @var string
     */
    private static $fallback_authenticator_class = 'MemberAuthenticator';

    /**
     * @return string
     */
    public static function get_name()
    {
        return Config::inst()->get('LDAPAuthenticator', 'name');
    }

    /**
     * @param Controller $controller
     * @return LDAPLoginForm
     */
    public static function get_login_form(Controller $controller)
    {
        return new LDAPLoginForm($controller, 'LoginForm');
    }

    /**
     * Performs the login, but will also create and sync the Member record on-the-fly, if not found.
     *
     * @param array $data
     * @param Form $form
     * @return bool|Member|void
     * @throws SS_HTTPResponse_Exception
     */
    public static function authenticate($data, Form $form = null)
    {
        $service = Injector::inst()->get('LDAPService');
        $login = trim($data['Login']);
        if (Email::validEmailAddress($login)) {
            if (Config::inst()->get('LDAPAuthenticator', 'allow_email_login')!='yes') {
                $form->sessionMessage(
                    _t(
                        'LDAPAuthenticator.PLEASEUSEUSERNAME',
                        'Please enter your username instead of your email to log in.'
                    ),
                    'bad'
                );
                return;
            }

            $username = $service->getUsernameByEmail($login);

            // No user found with this email.
            if (!$username) {
                if (Config::inst()->get('LDAPAuthenticator', 'fallback_authenticator') === 'yes') {
                    $fallbackMember = self::fallback_authenticate($data, $form);
                    if ($fallbackMember) {
                        return $fallbackMember;
                    }
                }

                $form->sessionMessage(_t('LDAPAuthenticator.INVALIDCREDENTIALS', 'Invalid credentials'), 'bad');
                return;
            }
        } else {
            $username = $login;
        }

        $result = $service->authenticate($username, $data['Password']);
        $success = $result['success'] === true;
        if (!$success) {
            if (Config::inst()->get('LDAPAuthenticator', 'fallback_authenticator') === 'yes') {
                $fallbackMember = self::fallback_authenticate($data, $form);
                if ($fallbackMember) {
                    return $fallbackMember;
                }
            }

            if ($form) {
                $form->sessionMessage($result['message'], 'bad');
            }
            return;
        }

        $data = $service->getUserByUsername($result['identity']);
        if (!$data) {
            if ($form) {
                $form->sessionMessage(
                    _t('LDAPAuthenticator.PROBLEMFINDINGDATA', 'There was a problem retrieving your user data'),
                    'bad'
                );
            }
            return;
        }

        // LDAPMemberExtension::memberLoggedIn() will update any other AD attributes mapped to Member fields
        $member = Member::get()->filter('GUID', $data['objectguid'])->limit(1)->first();
        if (!($member && $member->exists())) {
            $member = new Member();
            $member->GUID = $data['objectguid'];
        }

        // Update the users from LDAP so we are sure that the email is correct.
        // This will also write the Member record.
        $service->updateMemberFromLDAP($member);

        Session::clear('BackURL');

        return $member;
    }

    /**
     * Try to authenticate using the fallback authenticator.
     *
     * @param array $data
     * @param null|Form $form
     * @return null|Member
     */
    protected static function fallback_authenticate($data, Form $form = null)
    {
        return call_user_func(
            [Config::inst()->get('LDAPAuthenticator', 'fallback_authenticator_class'), 'authenticate'],
            array_merge($data, ['Email' => $data['Login']]),
            $form
        );
    }

}


<?php
/**
 * Class SAMLController
 *
 * This controller handles serving metadata requests for the IdP, as well as handling
 * creating new users and logging them into SilverStripe after being authenticated at the IdP.
 */
class SAMLController extends Controller
{
    /**
     * @var array
     */
    private static $allowed_actions = [
        'index',
        'login',
        'logout',
        'acs',
        'sls',
        'metadata'
    ];

    /**
     * Assertion Consumer Service
     *
     * The user gets sent back here after authenticating with the IdP, off-site.
     * The earlier redirection to the IdP can be found in the SAMLAuthenticator::authenticate.
     *
     * After this handler completes, we end up with a rudimentary Member record (which will be created on-the-fly
     * if not existent), with the user already logged in. Login triggers memberLoggedIn hooks, which allows
     * LDAP side of this module to finish off loading Member data.
     *
     * @throws OneLogin_Saml2_Error
     */
    public function acs()
    {
        $auth = Injector::inst()->get('SAMLHelper')->getSAMLAuth();
        $auth->processResponse();

        $error = $auth->getLastErrorReason();
        if (!empty($error)) {
            SS_Log::log($error, SS_Log::ERR);
            Form::messageForForm("SAMLLoginForm_LoginForm", "Authentication error: '{$error}'", 'bad');
            Session::save();
            return $this->getRedirect();
        }

        if (!$auth->isAuthenticated()) {
            Form::messageForForm("SAMLLoginForm_LoginForm", _t('Member.ERRORWRONGCRED'), 'bad');
            Session::save();
            return $this->getRedirect();
        }

        $decodedNameId = base64_decode($auth->getNameId());
        // check that the NameID is a binary string (which signals that it is a guid
        if (ctype_print($decodedNameId)) {
            Form::messageForForm("SAMLLoginForm_LoginForm", "Name ID provided by IdP is not a binary GUID.", 'bad');
            Session::save();
            return $this->getRedirect();
        }

        // transform the NameId to guid
        $guid = LDAPUtil::bin_to_str_guid($decodedNameId);
        if (!LDAPUtil::validGuid($guid)) {
            $errorMessage = "Not a valid GUID '{$guid}' recieved from server.";
            SS_Log::log($errorMessage, SS_Log::ERR);
            Form::messageForForm("SAMLLoginForm_LoginForm", $errorMessage, 'bad');
            Session::save();
            return $this->getRedirect();
        }

        // Write a rudimentary member with basic fields on every login, so that we at least have something
        // if LDAP synchronisation fails.
        $member = Member::get()->filter('GUID', $guid)->limit(1)->first();
        if (!($member && $member->exists())) {
            $member = new Member();
            $member->GUID = $guid;
        }

        $attributes = $auth->getAttributes();

        foreach ($member->config()->claims_field_mappings as $claim => $field) {
            if (!isset($attributes[$claim][0])) {
                SS_Log::log(
                    sprintf(
                        'Claim rule \'%s\' configured in LDAPMember.claims_field_mappings, but wasn\'t passed through. Please check IdP claim rules.',
                        $claim
                    ), SS_Log::WARN
                );

                continue;
            }

            if(count($attributes[$claim]) > 1) {
                $member->$field = $attributes[$claim];
            } else {
                $member->$field = $attributes[$claim][0];
            }
        }

        $member->SAMLSessionIndex = $auth->getSessionIndex();

        // This will trigger LDAP update through LDAPMemberExtension::memberLoggedIn.
        // The LDAP update will also write the Member record. We shouldn't write before
        // calling this, as any onAfterWrite hooks that attempt to update LDAP won't
        // have the Username field available yet for new Member records, and fail.
        // Both SAML and LDAP identify Members by the GUID field.
        $member->logIn();

        return $this->getRedirect();
    }

    /**
     * Generate this SP's metadata. This is needed for intialising the SP-IdP relationship.
     * IdP is instructed to call us back here to establish the relationship. IdP may also be configured
     * to hit this endpoint periodically during normal operation, to check the SP availability.
     */
    public function metadata()
    {
        try {
            $auth = Injector::inst()->get('SAMLHelper')->getSAMLAuth();
            $settings = $auth->getSettings();
            $metadata = $settings->getSPMetadata();
            $errors = $settings->validateMetadata($metadata);
            if (empty($errors)) {
                header('Content-Type: text/xml');
                echo $metadata;
            } else {
                throw new \OneLogin_Saml2_Error(
                    'Invalid SP metadata: ' . implode(', ', $errors),
                    \OneLogin_Saml2_Error::METADATA_SP_INVALID
                );
            }
        } catch (Exception $e) {
            SS_Log::log($e->getMessage(), SS_Log::ERR);
            echo $e->getMessage();
        }
    }

    /**
     * @return SS_HTTPResponse
     */
    protected function getRedirect()
    {
        // Absolute redirection URLs may cause spoofing
        if (Session::get('BackURL') && Director::is_site_url(Session::get('BackURL'))) {
            return $this->redirect(Session::get('BackURL'));
        }

        // Spoofing attack, redirect to homepage instead of spoofing url
        if (Session::get('BackURL') && !Director::is_site_url(Session::get('BackURL'))) {
            return $this->redirect(Director::absoluteBaseURL());
        }

        // If a default login dest has been set, redirect to that.
        if (Security::config()->default_login_dest) {
            return $this->redirect(Director::absoluteBaseURL() . Security::config()->default_login_dest);
        }

        // fallback to redirect back to home page
        return $this->redirect(Director::absoluteBaseURL());
    }
}


<?php
/**
 * Class LDAPUtil
 *
 * Provides some commonly used functions for LDAP and SAML.
 */
class LDAPUtil
{
    /**
     * Checks if the string is a valid guid in the format of A98C5A1E-A742-4808-96FA-6F409E799937
     *
     * @param $guid
     * @return bool
     */
    public static function validGuid($guid)
    {
        if (preg_match('/^[A-Z0-9]{8}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{12}?$/', $guid)) {
            return true;
        }
        return false;
    }

    public static function bin_to_str_guid($object_guid)
    {
        $hex_guid = bin2hex($object_guid);
        $hex_guid_to_guid_str = '';
        for ($k = 1; $k <= 4; ++$k) {
            $hex_guid_to_guid_str .= substr($hex_guid, 8 - 2 * $k, 2);
        }
        $hex_guid_to_guid_str .= '-';
        for ($k = 1; $k <= 2; ++$k) {
            $hex_guid_to_guid_str .= substr($hex_guid, 12 - 2 * $k, 2);
        }
        $hex_guid_to_guid_str .= '-';
        for ($k = 1; $k <= 2; ++$k) {
            $hex_guid_to_guid_str .= substr($hex_guid, 16 - 2 * $k, 2);
        }
        $hex_guid_to_guid_str .= '-' . substr($hex_guid, 16, 4);
        $hex_guid_to_guid_str .= '-' . substr($hex_guid, 20);

        return strtoupper($hex_guid_to_guid_str);
    }

    public static function str_to_hex_guid($str_guid, $escape = false)
    {
        $str_guid = str_replace('-', '', $str_guid);

        $octet_str = substr($str_guid, 6, 2);
        $octet_str .= substr($str_guid, 4, 2);
        $octet_str .= substr($str_guid, 2, 2);
        $octet_str .= substr($str_guid, 0, 2);
        $octet_str .= substr($str_guid, 10, 2);
        $octet_str .= substr($str_guid, 8, 2);
        $octet_str .= substr($str_guid, 14, 2);
        $octet_str .= substr($str_guid, 12, 2);
        $octet_str .= substr($str_guid, 16, strlen($str_guid));

        if ($escape) {
            $escaped = '\\';
            for ($i = 0; $i < strlen($octet_str); $i+=2) {
                $escaped .= substr($octet_str, $i, 2);
                if ($i != strlen($octet_str) - 2) {
                    $escaped .= '\\';
                }
            }
            return $escaped;
        }

        return $octet_str;
    }

    public static function bin_to_str_sid($binsid)
    {
        $hex_sid = bin2hex($binsid);
        $rev = hexdec(substr($hex_sid, 0, 2));
        $subcount = hexdec(substr($hex_sid, 2, 2));
        $auth = hexdec(substr($hex_sid, 4, 12));
        $result = "$rev-$auth";

        for ($x=0;$x < $subcount; $x++) {
            $subauth[$x] = hexdec(self::little_endian(substr($hex_sid, 16 + ($x * 8), 8)));
            $result .= "-" . $subauth[$x];
        }

        // Cheat by tacking on the S-
        return 'S-' . $result;
    }

    // Converts a little-endian hex-number to one, that 'hexdec' can convert
    public static function little_endian($hex)
    {
        $result = '';
        for ($x = strlen($hex) - 2; $x >= 0; $x = $x - 2) {
            $result .= substr($hex, $x, 2);
        }
        return $result;
    }
}


<?php
/**
 * Class SAMLAuthenticator
 *
 * Authenticates the user against a SAML IdP via a single sign-on process.
 * It will create a {@link Member} stub record with rudimentary fields (see {@link SAMLController::acs()})
 * if the Member record was not found.
 *
 * You can either use:
 * - just SAMLAuthenticator (which will trigger LDAP sync anyway, via LDAPMemberExtension::memberLoggedIn)
 * - just LDAPAuthenticator (syncs explicitly, but no single sign-on via IdP done)
 * - both, so people have multiple tabbed options in the login form.
 *
 * Both authenticators understand and collaborate through the GUID field on the Member.
 */
class SAMLAuthenticator extends Authenticator
{
    /**
     * @var string
     */
    private $name = 'SAML';

    /**
     * @return string
     */
    public static function get_name()
    {
        return Config::inst()->get('SAMLAuthenticator', 'name');
    }

    /**
     * @param Controller $controller
     * @return SAMLLoginForm
     */
    public static function get_login_form(Controller $controller)
    {
        return new SAMLLoginForm($controller, 'LoginForm');
    }

    /**
     * Sends the authentication process down the SAML rabbit hole. It will trigger
     * the IdP redirection via the 3rd party implementation, and if successful, the user
     * will be delivered to the SAMLController::acs.
     *
     * @param array $data
     * @param Form $form
     * @return bool|Member|void
     * @throws SS_HTTPResponse_Exception
     */
    public static function authenticate($data, Form $form = null)
    {
        // $data is not used - the form is just one button, with no fields.
        $auth = Injector::inst()->get('SAMLHelper')->getSAMLAuth();
        Session::set('BackURL', isset($data['BackURL']) ? $data['BackURL'] : null);
        Session::save();
        $auth->login(Director::absoluteBaseURL().'saml/');
    }
}


<?php
/**
 * Class SAMLSecurityExtension
 *
 * Extensions to the {@link Security} controller to support {@link SAMLAuthenticator}
 */
class SAMLSecurityExtension extends Extension
{
    /**
     * Will redirect the user directly to the IdP login endpoint if:
     *
     * 1) the 'SAMLAuthenticator' is the default authenticator
     * 2) there isn't a GET param showloginform set to 1
     * 3) the member is not currently logged in
     * 4) there are no form messages (errors or notices)
     *
     * @return void
     */
    public function onBeforeSecurityLogin()
    {
        if (Authenticator::get_default_authenticator() != 'SAMLAuthenticator') {
            return;
        }

        // by going to the URL Security/login?showloginform=1 we bypass the auto sign on
        if ($this->owner->request->getVar('showloginform') == 1) {
            return;
        }

        // if member is already logged in, don't auto-sign-on, this is most likely because
        // of unsufficient permissions.
        $member = Member::currentUser();
        if ($member && $member->exists()) {
            return;
        }

        // if there are form messages, don't auto-sign-on, this is most likely because of
        // login errors / failures or other notices.
        if (Session::get('FormInfo')) {
            // since FormInfo can be a "nulled" array, we have to check
            foreach (Session::get('FormInfo') as $form => $info) {
                foreach ($info as $name => $value) {
                    if ($value !== null) {
                        return;
                    }
                }
            }
        }

        $backURL = Session::get('BackURL');
        if ($this->owner->request->getVar('BackURL')) {
            $backURL = $this->owner->request->getVar('BackURL');
        }

        $authenticator = Injector::inst()->create('SAMLAuthenticator');
        $authenticator->authenticate(["BackURL" => $backURL]);
    }
}


<?php

/*
 * This file is part of HSPHP.
 *
 * (c) Nuzhdin Urii
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace HSPHP;

/**
 * Class for read commands
 *
 * @package HSPHP
 * @author  Nuzhdin Urii <nuzhdin.urii@gmail.com>
 */

class ReadHandler
{
    /**
     * @var ReadSocket|WriteSocket
     */
    protected $io = NULL;

    /**
     * @var integer
     */
    protected $indexId = NULL;

    /**
     * @var array key fields,position matters
     */
    protected $keys = array();

    /**
     * @var string key name
     */
    protected $index = '';

    /**
     * @var array fields to select
     */
    protected $fields = array();

    /**
     * @var string database name
     */
    protected $db = '';

    /**
     * @var string table name
     */
    protected $table = '';

    /**
     * @param ReadCommandsInterface $io
     * @param string                $db     Database name
     * @param string                $table  Table name
     * @param array                 $keys   List of keys
     * @param string                $index  Name of table key
     * @param array                 $fields List of interested fields
     */
    public function __construct(ReadCommandsInterface $io, $db, $table, $keys, $index, $fields)
    {
        $this->db = $db;
        $this->table = $table;
        $this->keys = $keys;
        $this->index = $index;
        $this->fields = $fields;
        $this->io = $io;
        $this->indexId = $this->io->getIndexId($this->db, $this->table, $this->index, $this->fields);
    }

    /**
     * callback for select request
     * @ignore
     */
    public function selectCallback($ret)
    {
        if ($ret instanceof ErrorMessage) {
            return $ret;
        }

        $result = array();
        foreach ($ret as $row) {
            $result[] = array_combine($this->fields, $row);
        }
        return $result;
    }

    /**
     * Selects all rows with key relative as described
     *
     * @param string  $compare
     * @param array   $keys
     * @param integer $limit
     * @param integer $begin
     *
     * @throws ErrorMessage
     *
     * @return array
     */
    public function select($compare, $keys, $limit = 1, $begin = 0)
    {
        $sk = $this->keys;
        if (is_array($keys)) {
            foreach ($sk as &$value) {
                if (!isset($keys[$value])) {
                  break;
                }
                $value = $keys[$value];
            }
            array_slice($sk, 0, count($keys));
        } else {
            $sk = array($keys);
        }
        $this->io->select($this->indexId, $compare, $sk, $limit, $begin);
        $ret = $this->io->registerCallback(array($this, 'selectCallback'));
        if ($ret instanceof ErrorMessage) {
            throw $ret;
        }

        return $ret;
    }
}


<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2014 Vanilla Forums Inc.
 * @license MIT
 */

namespace Garden\Password;

/**
 * Implements the password hashing algorithm of Xenforo.
 */
class XenforoPassword implements IPassword {

    /**
     * @var string The name of the hashing function to use.
     */
    protected $hashFunction;

    /**
     * Initialize an instance of this class.
     *
     * @param string $hashFunction The name of the hash function to use.
     * This is an function name that can be passed to {@link hash()}.
     * @see hash()
     */
    public function __construct($hashFunction = '') {
        if (!$hashFunction) {
            $hashFunction = 'sha256';
        }
        $this->hashFunction = $hashFunction;
    }

    /**
     * {@inheritdoc}
     */
    public function hash($password) {
        $salt = base64_encode(openssl_random_pseudo_bytes(12));
        $result = [
            'hashFunc' => $this->hashFunction,
            'hash' => $this->hashRaw($password, $salt, $this->hashFunction),
            'salt' => $salt
        ];

        return serialize($result);
    }

    /**
     * Hashes a password with a given salt.
     *
     * @param string $password The password to hash.
     * @param string $salt The password salt.
     * @param string $function The hashing function to use.
     * @return string Returns the password hash.
     */
    protected function hashRaw($password, $salt, $function = null) {
        if (!$function) {
            $function = $this->hashFunction;
        }

        $calc_hash = hash($function, hash($function, $password).$salt);

        return $calc_hash;
    }

    /**
     * {@inheritdoc}
     */
    public function needsRehash($hash) {
        list($stored_hash, $stored_salt) = $this->splitHash($hash);

        // Unsalted hashes should be rehashed.
        if ($stored_hash === false || $stored_salt === false) {
            return true;
        }

        return false;
    }

    /**
     * {@inheritdoc}
     */
    public function verify($password, $hash) {
        list($stored_hash, $function, $stored_salt) = $this->splitHash($hash);

        $calc_hash = $this->hashRaw($password, $stored_salt, $function);
        $result = $calc_hash === $stored_hash;

        return $result;
    }

    /**
     * Split the hash into its calculated hash and salt.
     *
     * @param string $hash The hash to split.
     * @return array An array in the form [$hash, $hashFunc, $salt].
     */
    protected function splitHash($hash) {
        $parts = @unserialize($hash);

        if (!is_array($parts)) {
            $result = ['', '', ''];
        } else {
            $parts = array_merge(['hash' => '', 'hashFunc' => '', 'salt' => ''], $parts);

            if (!$parts['hashFunc']) {
                switch (strlen($parts['hash'])) {
                    case 32:
                        $parts['hashFunc'] = 'md5';
                        break;
                    case 40:
                        $parts['hashFunc'] = 'sha1';
                        break;
                }
            }

            $result = [$parts['hash'], $parts['hashFunc'], $parts['salt']];
        }
        return $result;
    }
}


<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2014 Vanilla Forums Inc.
 * @license MIT
 */

namespace Garden\Db;

/**
 * Defines a standard set of methods that all database drivers must conform to.
 */
abstract class Db {
    /// Constants ///

    const QUERY_DEFINE = 'define';
    const QUERY_READ = 'read';
    const QUERY_WRITE = 'write';

    const INDEX_PK = 'primary';
    const INDEX_IX = 'index';
    const INDEX_UNIQUE = 'unique';

    const OPTION_REPLACE = 'replace';
    const OPTION_IGNORE = 'ignore';
    const OPTION_UPSERT = 'upsert';
    const OPTION_TRUNCATE = 'truncate';
    const OPTION_DROP = 'drop';
    const OPTION_MODE = 'mode';

    const OP_EQ = '=';
    const OP_GT = '>';
    const OP_GTE = '>=';
    const OP_IN = 'in';
    const OP_LIKE = 'like';
    const OP_LT = '<';
    const OP_LTE = '<=';
    const OP_NE = '<>';

    const OP_AND = 'and';
    const OP_OR = 'or';

    const ORDER_ASC = 'asc';
    const ORDER_DESC = 'desc';

    const FETCH_TABLENAMES = 0x1;
    const FETCH_COLUMNS = 0x2;
    const FETCH_INDEXES = 0x4;

    const MODE_EXEC = 0x1;
    const MODE_ECHO = 0x2;
    const MODE_SQL = 0x4;
    const MODE_PDO = 0x8;

    /// Properties ///

    /**
     * @var string The database prefix.
     */
    protected $px = 'gdn_';

    /**
     * @var int The query execution mode.
     */
    protected $mode = Db::MODE_EXEC;

    /**
     * @var array A cached copy of the table schemas.
     */
    protected $tables = [];

    /**
     * @var int Whether or not all the tables have been fetched.
     */
    protected $allTablesFetched = 0;

    /**
     * @var int The number of rows that were affected by the last query.
     */
    protected $rowCount;

    /// Methods ///

    /**
     * Create the appropriate db driver given a config.
     *
     * @param array $config The configuration used to initialize the object.
     * The config must have a driver key which names the db class to create.
     * @throws \Exception Throws an exception when the config isn't complete.
     */
    public static function create($config) {
        $driver = val('driver', $config);
        if (!$driver) {
            throw new \Exception('You must specify a driver.', 500);
        }

        if (strpos($driver, '\\') === false) {
            $class = '\Garden\Db\\'.$driver;
        } else {
            // TODO: Check against a white list of db drivers.
            $class = $driver;
        }

        if (!class_exists($class)) {
            throw new \Exception("Class $class does not exist.", 500);
        }

        $db = new $class($config);
        return $db;
    }

    /**
     * Add a table to the database.
     *
     * @param string $tablename The name of the table.
     * @param array $tabledef The table definition.
     * @param array $options An array of additional options when adding the table.
     */
    abstract protected function createTable($tablename, array $tabledef, array $options = []);

    /**
     * Alter a table in the database.
     *
     * When altering a table you pass an array with three optional keys: add, drop, and alter.
     * Each value is consists of a table definition in a format that would be passed to {@link Db::setTableDef()}.
     *
     * @param string $tablename The name of the table.
     * @param array $alterdef The alter definition.
     * @param array $options An array of additional options when adding the table.
     */
    abstract protected function alterTable($tablename, array $alterdef, array $options = []);

    /**
     * Drop a table.
     *
     * @param string $tablename The name of the table to drop.
     * @param array $options An array of additional options when adding the table.
     */
    abstract public function dropTable($tablename, array $options = []);

    /**
     * Get a table definition.
     *
     * @param string $tablename The name of the table.
     * @return array|null Returns the table definition or null if the table does not exist.
     */
    public function getTableDef($tablename) {
        $ltablename = strtolower($tablename);

        // Check to see if the table isn't in the cache first.
        if ($this->allTablesFetched & Db::FETCH_TABLENAMES &&
            !isset($this->tables[$ltablename])) {
            return null;
        }

        if (
            isset($this->tables[$ltablename]) &&
            is_array($this->tables[$ltablename]) &&
            isset($this->tables[$ltablename]['columns'], $this->tables[$ltablename]['indexes'])
        ) {
            return $this->tables[$ltablename];
        }
        return [];
    }

    /**
     * Get all of the tables in the database.
     *
     * @param bool $withDefs Whether or not to return the full table definitions or just the table names.
     * @return array Returns an array of either the table definitions or the table names.
     */
    public function getAllTables($withDefs = false) {
        if ($withDefs && ($this->allTablesFetched & Db::FETCH_COLUMNS)) {
            return $this->tables;
        } elseif (!$withDefs && ($this->allTablesFetched & Db::FETCH_TABLENAMES)) {
            return array_keys($this->tables);
        } else {
            return null;
        }
    }

    /**
     * Set a table definition to the database.
     *
     * @param string $tablename The name of the table.
     * @param array $tableDef The table definition.
     * @param array $options An array of additional options when adding the table.
     */
    public function setTableDef($tablename, array $tableDef, array $options = []) {
        $ltablename = strtolower($tablename);
        $tableDef['name'] = $tablename;
        $drop = val(Db::OPTION_DROP, $options, false);
        $curTable = $this->getTableDef($tablename);

        $this->fixIndexes($tablename, $tableDef, $curTable);

        if (!$curTable) {
            $this->createTable($tablename, $tableDef, $options);
            $this->tables[$ltablename] = $tableDef;
            return;
        }
        // This is the alter statement.
        $alterDef = [];

        // Figure out the columns that have changed.
        $curColumns = (array)val('columns', $curTable, []);
        $newColumns = (array)val('columns', $tableDef, []);

        $alterDef['add']['columns'] = array_diff_key($newColumns, $curColumns);
        $alterDef['alter']['columns'] = array_uintersect_assoc($newColumns, $curColumns, function ($new, $curr) {
            // Return 0 if the values are different, not the same.
            if (val('type', $curr) !== val('type', $new) ||
                val('required', $curr) !== val('required', $new) ||
                val('default', $curr) !== val('required', $new)) {
                return 0;
            }
            return 1;
        });

        // Figure out the indexes that have changed.
        $curIndexes = (array)val('indexes', $curTable, []);
        $newIndexes = (array)val('indexes', $tableDef, []);

        $alterDef['add']['indexes'] = array_udiff($newIndexes, $curIndexes, [$this, 'indexCompare']);

        $dropIndexes = array_udiff($curIndexes, $newIndexes, [$this, 'indexCompare']);
        if ($drop) {
            $alterDef['drop']['columns'] = array_diff_key($curColumns, $newColumns);
            $alterDef['drop']['indexes'] = $dropIndexes;
        } else {
            $alterDef['drop']['columns'] = [];
            $alterDef['drop']['indexes'] = [];

            // If the primary key has changed then the old one needs to be dropped.
            if (isset($dropIndexes[Db::INDEX_PK])) {
                $alterDef['drop']['indexes'][Db::INDEX_PK] = $dropIndexes[Db::INDEX_PK];
            }
        }

        // Check to see if any alterations at all need to be made.
        if (empty($alterDef['add']['columns']) && empty($alterDef['add']['indexes']) &&
            empty($alterDef['drop']['columns']) && empty($alterDef['drop']['indexes']) &&
            empty($alterDef['alter']['columns'])) {
            return;
        }

        $alterDef['def'] = $tableDef;

        // Alter the table.
        $this->alterTable($tablename, $alterDef, $options);

        // Update the cached schema.
        $tableDef['name'] = $tablename;
        $this->tables[$ltablename] = $tableDef;
    }

    /**
     * Move the primary key index into the correct place for database drivers.
     *
     * @param string $tablename The name of the table.
     * @param array &$tableDef The table definition.
     * @param array|null $curTableDef The current database table def used to resolve conflicts in some names.
     * @throws \Exception Throws an exception when there is a mismatch between the primary index and the primary key
     * defined on the columns themselves.
     */
    protected function fixIndexes($tablename, array &$tableDef, $curTableDef = null) {
        // Loop through the columns and add get the primary key index.
        $primaryColumns = [];
        foreach ($tableDef['columns'] as $cname => $cdef) {
            if (val('primary', $cdef)) {
                $primaryColumns[] = $cname;
            }
        }

        // Massage the primary key index.
        $primaryFound = false;
        array_touch('indexes', $tableDef, []);
        foreach ($tableDef['indexes'] as &$indexDef) {
            array_touch('name', $indexDef, $this->buildIndexName($tablename, $indexDef));

            if (val('type', $indexDef) === Db::INDEX_PK) {
                $primaryFound = true;

                if (empty($primaryColumns)) {
                    foreach ($indexDef['columns'] as $cname) {
                        $tableDef['columns'][$cname]['primary'] = true;
                    }
                } elseif (array_diff($primaryColumns, $indexDef['columns'])) {
                    throw new \Exception("There is a mismatch in the primary key index and primary key columns.", 500);
                }
            } elseif (isset($curTableDef['indexes'])) {
                $curIndexDef = array_usearch($indexDef, $curTableDef['indexes'], [$this, 'indexCompare']);
                if ($curIndexDef && isset($curIndexDef['name'])) {
                    $indexDef['name'] = $curIndexDef['name'];
                }
            }
        }

        if (!$primaryFound && !empty($primaryColumns)) {
            $tableDef['indexes'][db::INDEX_PK] = [
                'columns' => $primaryColumns,
                'type' => Db::INDEX_PK
            ];
        }
    }

    /**
     * Get the database prefix.
     *
     * @return string Returns the current db prefix.
     */
    public function getPx() {
        return $this->px;
    }

    /**
     * Set the database prefix.
     *
     * @param string $px The new database prefix.
     */
    public function setPx($px) {
        $this->px = $px;
    }

    /**
     * Compare two index definitions to see if they have the same columns and same type.
     *
     * @param array $a The first index.
     * @param array $b The second index.
     * @return int Returns an integer less than, equal to, or greater than zero if {@link $a} is
     * considered to be respectively less than, equal to, or greater than {@link $b}.
     */
    public function indexCompare(array $a, array $b) {
        if ($a['columns'] > $b['columns']) {
            return 1;
        } elseif ($a['columns'] < $b['columns']) {
            return -1;
        }

        return strcmp(val('type', $a, ''), val('type', $b, ''));
    }

    /**
     * Get data from the database.
     *
     * @param string $tablename The name of the table to get the data from.
     * @param array $where An array of where conditions.
     * @param array $options An array of additional options.
     * @return mixed Returns the result set.
     */
    abstract public function get($tablename, array $where, array $options = []);

    /**
     * Get a single row from the database.
     *
     * This is a conveinience method that calls {@link Db::get()} and shifts off the first row.
     *
     * @param string $tablename The name of the table to get the data from.
     * @param array $where An array of where conditions.
     * @param array $options An array of additional options.
     * @return array|false Returns the row or false if there is no row.
     */
    public function getOne($tablename, array $where, array $options = []) {
        $options['limit'] = 1;
        $rows = $this->get($tablename, $where, $options);
        return array_shift($rows);
    }

    /**
     * Insert a row into a table.
     *
     * @param string $tablename The name of the table to insert into.
     * @param array $row The row of data to insert.
     * @param array $options An array of options for the insert.
     *
     * Db::OPTION_IGNORE
     * : Whether or not to ignore inserts that lead to a duplicate key. *default false*
     * Db::OPTION_REPLACE
     * : Whether or not to replace duplicate keys. *default false*
     * Db::OPTION_UPSERT
     * : Whether or not to update the existing data when duplicate keys exist.
     *
     * @return mixed Should return the id of the inserted record.
     * @see Db::load()
     */
    abstract public function insert($tablename, array $row, array $options = []);

    /**
     * Load many rows into a table.
     *
     * @param string $tablename The name of the table to insert into.
     * @param \Traversable|array $rows A dataset to insert.
     * Note that all rows must contain the same columns.
     * The first row will be looked at for the structure of the insert and the rest of the rows will use this structure.
     * @param array $options An array of options for the inserts. See {@link Db::insert()} for details.
     * @return mixed
     * @see Db::insert()
     */
    abstract public function load($tablename, $rows, array $options = []);

    /**
     * Update a row or rows in a table.
     *
     * @param string $tablename The name of the table to update.
     * @param array $set The values to set.
     * @param array $where The where filter for the update.
     * @param array $options An array of options for the update.
     * @return mixed
     */
    abstract public function update($tablename, array $set, array $where, array $options = []);

    /**
     * Delete rows from a table.
     *
     * @param string $tablename The name of the table to delete from.
     * @param array $where The where filter of the delete.
     * @param array $options An array of options.
     *
     * Db:OPTION_TRUNCATE
     * : Truncate the table instead of deleting rows. In this case {@link $where} must be blank.
     * @return mixed
     */
    abstract public function delete($tablename, array $where, array $options = []);

    /**
     * Reset the internal table definition cache.
     *
     * @return Db Returns $this for fluent calls.
     */
    public function reset() {
        $this->tables = [];
        $this->allTablesFetched = 0;
        $this->rowCount = 0;
        return $this;
    }

    /**
     * Build a standardized index name from an index definition.
     *
     * @param string $tablename The name of the table the index is in.
     * @param array $indexDef The index definition.
     * @return string Returns the index name.
     */
    protected function buildIndexName($tablename, array $indexDef) {
        $type = val('type', $indexDef, Db::INDEX_IX);

        if ($type === Db::INDEX_PK) {
            return 'primary';
        }
        $px = val($type, [Db::INDEX_IX => 'ix_', Db::INDEX_UNIQUE => 'ux_'], 'ix_');
        $sx = val('suffix', $indexDef);
        $result = $px.$tablename.'_'.($sx ?: implode('', $indexDef['columns']));
        return $result;
    }
}


<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2014 Vanilla Forums Inc.
 * @license MIT
 */

namespace Garden;

/**
 * A class that provides functionality for encrypting and signing strings and then decoding those secure strings.
 */
class SecureString {
    const SEP = '.';
    const EOS = '-';
    const STRICT = 'strict';

    protected $timestampExpiry;

    /**
     * Initialize an instance of the {@link SecureString} class.
     */
    public function __construct() {
        $this->timestampExpiry = strtotime('1 day', 0);
    }

    /**
     * Get or set the timestamp expiry in seconds.
     *
     * @param int $value Set a new expiry value in seconds.
     * @return int Returns the current timestamp expiry.
     */
    public function timestampExpiry($value = 0) {
        if ($value) {
            $this->timestampExpiry = $value;
        }
        return $this->timestampExpiry;
    }

    /**
     * Encode a string using a secure specification.
     *
     * @param mixed $data The data to encode. This must be data that supports json serialization.
     * @param array $spec An array specifying how the string should be encoded.
     * The array should be in the form ['spec1' => 'password', 'spec2' => 'password'].
     * @param bool $throw Whether or not to throw an exception on error.
     * @return string|null Returns the encoded string or null if there is an error.
     */
    public function encode($data, array $spec, $throw = false) {
        $str = json_encode($data, JSON_UNESCAPED_SLASHES);

        $first = true;
        foreach ($spec as $name => $password) {
            if ($name === self::STRICT) {
                // Strict is just an option so continue.
                continue;
            }

            $supported = $this->supportedInfo($name, $throw);
            if ($supported === null) {
                return null;
            }

            list($encode,, $method, $encodeFirst) = $supported;

            if ($first) {
                if ($encodeFirst) {
                    $str = static::base64urlEncode($str);
                }
                $this->pushString($str, self::EOS);
            }

            switch ($encode) {
                case 'encrypt':
                    $str = $this->encrypt($str, $method, $password, '', $throw);
                    break;
                case 'hmac':
                    $str = $this->hmac($str, $method, $password, 0, $throw);
                    break;
                default:
                    return $this->exception($throw, "Invalid method $encode.", 500);
            }
            // Return on error.
            if (!$str) {
                return null;
            }

            $this->pushString($str, $name);

            $first = false;
        }

        return $str;
    }

    /**
     * Decode a string that was encoded using {@link SecureString::encode()}.
     *
     * @param string $str The encoded string.
     * @param array $spec An array specifying how the string should be encoded.
     * @param bool $throw Whether or not to throw an exception on error.
     * @return string|null Returns the decoded string or null of there was a problem.
     */
    public function decode($str, array $spec, $throw = false) {
        $decodeFirst = false;
        $wstr = $str; // wstr means working string
        $used = [];

        $strict = true;
        if (array_key_exists(self::STRICT, $spec)) {
            $strict = (bool)$spec[self::STRICT];
            unset($spec[self::STRICT]);
        }

        while ($token = $this->popString($wstr)) {
            if ($token === self::EOS) {
                if ($decodeFirst) {
                    $wstr = static::base64urlDecode($wstr);
                }
                break;
            }

            try {
                $supported = $this->supportedInfo($token, true);
            } catch (\Exception $ex) {
                return $this->exception($throw, $ex->getMessage(), 403);
            }

            if (!isset($spec[$token])) {
                return $this->exception($throw, "You did not provide a password for $token.", 403);
            }
            $password = $spec[$token];
            array_unshift($used, $token);

            list(, $decode, $method, $decodeFirst) = $supported;

            switch ($decode) {
                case 'decrypt':
                    $wstr = $this->decrypt($wstr, $method, $password, $throw);
                    break;
                case 'verifyHmac':
                    $wstr = $this->verifyHmac($wstr, $method, $password, $throw);
                    break;
                default:
                    return $this->exception($throw, "Invalid method $decode.", 500);
            }

            if ($wstr === null) {
                return null;
            }
        }

        // Make sure that the string was secured at all.
        if ($strict && array_keys($spec) != $used) {
            return $this->exception($throw, 'The string is not fully secure.', 403);
        } elseif (empty($used)) {
            return $this->exception($throw, 'The string is not secure.', 403);
        }

        $data = json_decode($wstr, true);
        if ($data === null) {
            return $this->exception($throw, 'The final string is not valid json.', 403);
        }

        return $data;
    }

    /**
     * Generate a random string suitable for use as an encryption or signature key.
     *
     * @param int $len The number of characters in the key.
     * @return string Returns a base64url encoded string representing the random key.
     */
    public static function generateRandomKey($len = 32) {
        $bytes = ceil($len * 3 / 4);

        return substr(self::base64urlEncode(openssl_random_pseudo_bytes($bytes)), 0, $len);
    }

    /**
     * Base64 Encode a string, but make it suitable to be passed in a url.
     *
     * @param string $str The string to encode.
     * @return string The encoded string.
     */
    protected static function base64urlEncode($str) {
        return trim(strtr(base64_encode($str), '+/', '-_'), '=');
    }

    /**
     * Decode a string that was encoded using base64UrlEncode().
     *
     * @param string $str The encoded string.
     * @return string The decoded string.
     */
    protected static function base64urlDecode($str) {
        return base64_decode(strtr($str, '-_', '+/'));
    }

    /**
     * Encrypt a string with {@link openssl_encrypt()}.
     *
     * @param string $str The string to encrypt.
     * @param string $method The encryption cipher.
     * @param string $password The encryption password.
     * @param string $iv The input vector.
     * @param bool $throw Whether or not to throw an exception on error.
     * @return string Returns the encrypted string.
     */
    protected function encrypt($str, $method, $password, $iv = '', $throw = false) {
        if ($iv === '') {
            $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($method));
        }
        // Encrypt the string.
        $encrypted = openssl_encrypt($str, $method, $password, OPENSSL_RAW_DATA, $iv);

        if ($encrypted === false) {
            return $this->exception($throw, "Error encrypting the string.", 400);
        }

        $str = static::base64urlEncode($encrypted);
        $this->pushString($str, static::base64urlEncode($iv));

        return $str;
    }

    /**
     * Decrypt a string with {@link openssl_decrypt()}.
     *
     * @param string $str The base64 url encoded encrypted string.
     * @param string $method The encryption cipher.
     * @param string $password The password to decyrpt the string.
     * @param bool $throw Whether or not to decode the string on exception.
     * @return string|null Returns the decrypted string or null on error.
     */
    protected function decrypt($str, $method, $password, $throw = false) {
        $iv = static::base64urlDecode($this->popString($str));
        $encrypted = static::base64urlDecode($this->popString($str));

        try {
            $decrypted = openssl_decrypt($encrypted, $method, $password, true, $iv);
        } catch (\Exception $ex) {
            return $this->exception($throw, "Error decrypting the string.", 403);
        }

        if ($decrypted === false) {
            return $this->exception($throw, "Error decrypting the string.", 403);
        }

        return $decrypted;
    }

    /**
     * Get information about a supported spec name.
     *
     * @param string $name The name of the spec.
     * @param bool $throw Whether or not throw an exception on error.
     * @return array|null Returns an array in the form [encode, decode, method, encodeFirst].
     * Returns null on error.
     */
    protected function supportedInfo($name, $throw = false) {
        switch ($name) {
            case 'aes128':
            case 'aes256':
                $cipher = 'aes-'.substr($name, 3).'-cbc';
                return ['encrypt', 'decrypt', $cipher, false];
            case 'hsha1':
            case 'hsha256':
                $hash = substr($name, 1);
                return ['hmac', 'verifyHmac', $hash, true];
        }
        return $this->exception($throw, "Spec $name not supported.", 400);
    }

    /**
     * Sign a string with hmac and a hash method.
     *
     * @param string $str The string to sign.
     * @param string $method The hash method used to sign the string.
     * @param string $password The password used to hmac hash the string with.
     * @param int $timestamp The timestamp used to sign the string with or 0 to use the current time.
     * @param bool $throw Whether or not the throw an exception on error.
     * @return string Returns the string with signing information or null on error.
     */
    protected function hmac($str, $method, $password, $timestamp = 0, $throw = false) {
        if ($timestamp === 0) {
            $timestamp = time();
        }
        // Add the timestamp to the string.
        static::pushString($str, $timestamp);

        // Sign the string.
        $signature = hash_hmac($method, $str, $password, true);
        if ($signature === false) {
            return $this->exception($throw, "Invalid hash method $method.", 400);
        }

        // Add the signature to the string.
        static::pushString($str, static::base64urlEncode($signature));

        return $str;
    }

    /**
     * Verify the signature on a secure string.
     *
     * @param string $str The string to verify.
     * @param string $method The hashing algorithm that the string was signed with.
     * @param string $password The password used to sign the string.
     * @param bool $throw Whether or not to throw an exeptio on error.
     * @return bool Returns the string without the signing information or null on error.
     */
    protected function verifyHmac($str, $method, $password, $throw = false) {
        // Grab the signature from the string.
        $signature = $this->popString($str);
        if (!$signature) {
            return $this->exception($throw, "The signature is missing.", 403);
        }
        $signature = static::base64urlDecode($signature);

        // Recalculate the signature to compare.
        $calcSignature = hash_hmac($method, $str, $password, true);

        if (strlen($signature) !== strlen($calcSignature)) {
            return $this->exception($throw, "The signature is invalid.", 403);
        }

        // Do a double hmac comparison to prevent timing attacks.
        // https://www.isecpartners.com/blog/2011/february/double-hmac-verification.aspx
        $dblSignature = hash_hmac($method, $signature, $password, true);
        $dblCalcSignature = hash_hmac($method, $calcSignature, $password, true);

        if ($dblSignature !== $dblCalcSignature) {
            return $this->exception($throw, "The signature is invalid.", 403);
        }

        // Grab the timestamp and verify it.
        $timestamp = $this->popString($str);
        if (!$this->verifyTimestamp($timestamp, $throw)) {
            return null;
        }

        return $str;
    }

    /**
     * Verify that a timestamp hasn't expired.
     *
     * @param string $timestamp The unix timestamp to verify.
     * @param bool $throw Whether or not to throw an exception on error.
     * @return bool Returns true if the timestamp is valid or false otherwise.
     */
    protected function verifyTimestamp($timestamp, $throw = false) {
        if (!is_numeric($timestamp)) {
            return (bool)$this->exception($throw, "Invalid timestamp.", 403);
        }
        $intTimestamp = (int)$timestamp;
        $now = time();
        if ($intTimestamp + $this->timestampExpiry <= $now) {
            return (bool)$this->exception($throw, "The timestamp has expired.", 403);
        }

        return true;
    }

    /**
     * Pop a string off of the end of an encoded secure string.
     *
     * @param string &$str The main string to pop.
     * @return string|null Returns the popped string or null if {@link $str} is empty.
     */
    protected function popString(&$str) {
        if ($str === '') {
            return null;
        }

        $pos = strrpos($str, '.');
        if ($pos !== false) {
            $result = substr($str, $pos + 1);
            $str = substr($str, 0, $pos);
        } else {
            $result = $str;
            $str = '';
        }
        return $result;
    }

    /**
     * Pushes a string on to the end of an encoded secure string.
     *
     * @param string &$str The main string to push to.
     * @param string|array $item The string or array of strings to push on to the end of {@link $str}.
     */
    protected function pushString(&$str, $item) {
        if ($str) {
            $str .= static::SEP;
        }
        $str .= implode(static::SEP, (array)$item);
    }

    /**
     * Throw an exception or return false.
     *
     * @param bool $throw Whether or not to throw an exception.
     * @param string $message The exception message.
     * @param int $code The exception code.
     * @return bool Returns false if {@link $throw} is false.
     * @throws \Exception Throws an exception with {@link $message} and {@link $code}.
     */
    protected function exception($throw, $message, $code) {
        if ($throw) {
            throw new \Exception($message, $code);
        }
        return null;
    }

    /**
     * Twiddle a value in an encoded secure string to another value.
     *
     * This method is mainly for testing so that an invalid string can be created.
     *
     * @param string $string A valid cookie to twiddle.
     * @param int $index The index of the new value.
     * @param string $value The new value. This will be base64url encoded.
     * @param bool $encode Whether or not to base64 url encode the value.
     * @return string Returns the new encoded cookie.
     */
    public function twiddle($string, $index, $value, $encode = false) {
        $parts = explode(static::SEP, $string);

        if ($encode) {
            $value = static::base64urlEncode($value);
        }
        $parts[$index] = $value;

        return implode(static::SEP, $parts);
    }
}


<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2014 Vanilla Forums Inc.
 * @license MIT
 */

namespace Garden;

use Garden\Exception\ValidationException;

/**
 * An class for collecting validation errors.
 */
class Validation {
    /// Properties ///

    protected $errors = [];

    protected $mainMessage;

    protected $status;

    /// Methods ///

    /**
     * Initialize an instance of the {@link Validation} class.
     *
     * @param array $errors An array of errors.
     * @param string $mainMessage The main message of the error.
     * @param int $status The http status code of the error or 0 to build the status code from the indivdual errors.
     */
    public function __construct(array $errors = [], $mainMessage = '', $status = 0) {
        $this->errors = $errors;
        $this->mainMessage = $mainMessage;
        $this->status = $status;
    }

    /**
     * Gets the error message from an error.
     *
     * Errors are stored with either a message or a translation code.
     * This method will look at both to determine the full message.
     *
     * @param array $error The error array.
     * @return string Returns the message from the error.
     */
    public static function errorMessage(array $error) {
        if (isset($error['message'])) {
            return $error['message'];
        } else {
            $field = val('field', $error, '*');
            if (is_array($field)) {
                $field = implode(', ', $field);
            }
            return sprintft($error['code'].': %s.', $field);
        }
    }

    /**
     * Add an error.
     *
     * @param string $messageCode The message translation code.
     * If you add a message that starts with "@" then no translation will take place.
     * @param string|array $field The name of the field to add or an array of fields if the error applies to
     * more than one field.
     * @param int|array $options An array of additional information to add to the error entry or a numeric error code.
     * @return Validation Returns $this for fluent calls.
     */
    public function addError($messageCode, $field = '*', $options = []) {
        $error = [];
        if (substr($messageCode, 0, 1) === '@') {
            $error['message'] = substr($messageCode, 1);
        } else {
            $error['code'] = $messageCode;
        }
        if (is_array($field)) {
            $fieldname = array_select(['path', 'name'], $field);

            if ($fieldname) {
                // This is a full field object.
                $fieldKey = $fieldname;
                $error['field'] = $fieldKey;
            } else {
                $fieldKey = '*';
                $error['field'] = $field;
            }
        } else {
            $fieldKey = $field;
            if ($field !== '*') {
                $error['field'] = $field;
            }
        }

        if (is_array($options)) {
            $error += $options;
        } else if (is_int($options)) {
            $error['status'] = $options;
        }

        $this->errors[$fieldKey][] = $error;

        return $this;
    }

    /**
     * Gets the main error message for the validation.
     *
     * @param string|null $value Pass a new main message or null to get the current main message.
     * @return Validation|string Returns the main message or $this for fluent sets.
     */
    public function mainMessage($value = null) {
        if ($value !== null) {
            $this->mainMessage = $value;
            return $this;
        }

        return $this->mainMessage;
    }

    /**
     * Get or set the error status code.
     *
     * The status code is an http resonse code and should be of the 4xx variety.
     *
     * @param int|null $value Pass a new status code or null to get the current code.
     * @return Validation|int Returns the current status code or $this for fluent sets.
     */
    public function status($value = null) {
        if ($value !== null) {
            $this->status = $value;
            return $this;
        }
        if ($this->status) {
            return $this->status;
        }

        // There was no status so loop through the errors and look for the highest one.
        $maxStatus = 400;
        foreach ($this->errors as $field => $errors) {
            foreach ($errors as $error) {
                if (isset($error['status']) && $error['status'] > $maxStatus) {
                    $maxStatus = $error['status'];
                }
            }
        }
        return $maxStatus;
    }

    /**
     * Get the message for this exception.
     *
     * @return string Returns the exception message.
     */
    public function getMessage() {
        if ($this->mainMessage) {
            return $this->mainMessage;
        }

        // Generate the message by concatenating all of the errors together.
        $messages = [];
        foreach ($this->errors as $errors) {
            foreach ($errors as $error) {
                $field = val('field', $error, '*');
                if (is_array($field)) {
                    $field = implode(', ', $field);
                }

                if (isset($error['message'])) {
                    $message = $error['message'];
                } elseif (strpos($error['code'], '%s') === false) {
                    $message = sprintft($error['code'].': %s.', $field);
                } else {
                    $message = sprintft($error['code'], $field);
                }

                $messages[] = $message;
            }
        }
        return implode(' ', $messages);
    }

    /**
     * Gets all of the errors as a flat array.
     *
     * The errors are internally stored indexed by field. This method flattens them for final error returns.
     *
     * @return array Returns all of the errors.
     */
    public function getErrorsFlat() {
        $result = [];
        foreach ($this->errors as $errors) {
            foreach ($errors as $error) {
                $result[] = $error;
            }
        }
        return $result;
    }

    /**
     * Check whether or not the validation is free of errors.
     *
     * @return bool Returns true if there are no errors, false otherwise.
     */
    public function isValid() {
        return count($this->errors) === 0;
    }

    /**
     * Check whether or not a particular field is has errors.
     *
     * @param string $field The name of the field to check for validity.
     * @return bool Returns true if the field has no errors, false otherwise.
     */
    public function fieldValid($field) {
        $result = !isset($this->errors[$field]) || count($this->errors[$field]) === 0;
        return $result;
    }
}