<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core_cns
 */

/**
 * Create a member login cookie.
 *
 * @param  MEMBER $member_id The member ID
 */
function cns_create_login_cookie(int $member_id)
{
    // User
    cms_setcookie(get_member_cookie(), strval($member_id), false, true);

    // Password
    $login_key = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_login_key_hash');
    if ($login_key == '') {
        require_code('crypt');
        $login_key = get_secure_random_string(32, CRYPT_BASE64); // Needs to be long and complex as it's like a password
        $login_key_hash = ratchet_hash($login_key, get_site_salt() . '_' . get_pass_cookie());
        $GLOBALS['FORUM_DB']->query_update('f_members', ['m_login_key_hash' => $login_key_hash], ['id' => $member_id], '', 1);
    }
    cms_setcookie(get_pass_cookie(), $login_key, false, true);
}

/**
 * Find if the given member ID and password is valid. If username is null, then the member ID is used instead.
 * All authorisation, cookies, and form-logins, are passed through this function.
 *
 * @param  object $this_ref Link to the real forum driver
 * @param  ?SHORT_TEXT $username The member username (null: use $member_id)
 * @param  ?MEMBER $member_id The member ID (null: use $username)
 * @param  string $password_mixed If $cookie_login is true then this is the value of the password cookie, otherwise it's the password the user tried to log in with
 * @param  boolean $cookie_login Whether this is a cookie login, determines how the hashed password is treated for the value passed in
 * @return array A map of 'id' (?MEMBER; member logged in), 'error' (?string; failed login), 'reasoned_ban' (?string; a ban message if banned with a reason), and 'failed_login' (boolean; whether the login should be logged as failed)
 */
function cns_authorise_login(object $this_ref, ?string $username, ?int $member_id, string $password_mixed, bool $cookie_login = false) : array
{
    require_code('cns_forum_driver_helper_auth');

    $out = [];
    $out['id'] = null;
    $out['failed_login'] = true;

    require_code('cns_members');
    require_code('cns_groups');
    if (!function_exists('require_lang')) {
        require_code('lang');
    }
    if (!function_exists('do_lang_tempcode')) {
        require_code('tempcode');
    }
    if (!function_exists('require_lang')) {
        $out['failed_login'] = false; // Internal error, not a failed login
        $out['error'] = 'Internal Error';
        return $out; // Bootstrap got really mixed up, so we need to bomb out
    }
    require_lang('cns');
    require_code('mail');

    if (php_function_allowed('usleep')) {
        usleep(500000); // Wait for half a second, to reduce brute force potential
    }

    $skip_auth = false;

    if ($member_id === null) {
        if (get_option('one_per_email_address') == '2') {
            $rows = [];
        } else {
            $rows = $this_ref->db->query_select('f_members', ['*'], ['m_username' => $username], '', 1);
        }
        if ((!array_key_exists(0, $rows)) && (get_option('one_per_email_address') != '0')) {
            $rows = $this_ref->db->query_select('f_members', ['*'], ['m_email_address' => $username], 'ORDER BY m_join_time ASC', 1);
        }
        if (array_key_exists(0, $rows)) {
            $this_ref->MEMBER_ROWS_CACHED[$rows[0]['id']] = $rows[0];
            $member_id = $rows[0]['id'];
        }
    } else {
        $rows[0] = $this_ref->get_member_row($member_id);
    }

    // LDAP to the rescue if we couldn't get a row
    global $LDAP_CONNECTION;
    if ((!array_key_exists(0, $rows)) && ($LDAP_CONNECTION !== null) && ($member_id === null)) {
        // See if LDAP has it -- if so, we can add
        $test = cns_is_on_ldap($username);
        if (!$test) {
            $out['error'] = ($username === null) ? do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_NO_EXIST') : do_lang_tempcode('_MEMBER_NO_EXIST', escape_html($username));
            return $out;
        }

        $test_auth = cns_ldap_authorise_login($username, $password_mixed);
        if ($test_auth['m_pass_hash_salted'] == '!!!') {
            $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
            return $out;
        }

        if ($test) {
            require_code('cns_members_action');
            require_code('cns_members_action2');
            $completion_form_submitted = (post_param_string('email', '', INPUT_FILTER_POST_IDENTIFIER) != '');
            if ((!$completion_form_submitted) && (get_option('finish_profile') == '1')) { // UI
                require_code('failure');
                if (throwing_errors()) {
                    throw new CMSException(do_lang('ENTER_PROFILE_DETAILS_FINISH'));
                }

                cms_ob_end_clean(); // Emergency output, potentially, so kill off any active buffer
                $middle = cns_member_external_linker_ask($username, 'ldap', cns_ldap_guess_email($username));
                $tpl = globalise($middle, null, '', true);
                $tpl->evaluate_echo();
                exit();
            } else {
                require_code('crypt');
                $member_id = cns_member_external_linker('ldap', $username, get_secure_random_password(null, $username));
                $row = $this_ref->get_member_row($member_id);
            }
        }
    }

    if ((!array_key_exists(0, $rows)) || ($rows[0] === null)) { // All hands to lifeboats
        // Run hooks for other interactive login possibilities, if any exist
        $hooks = find_all_hook_obs('systems', 'login_providers_direct_auth', 'Hook_login_providers_direct_auth_');
        foreach ($hooks as $ob) {
            $try_login = $ob->try_login($username, $member_id, $password_mixed, $cookie_login);
            if ($try_login !== null) {
                return $try_login;
            }
        }

        $out['error'] = ($username === null) ? do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_NO_EXIST') : do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : '_MEMBER_NO_EXIST', escape_html($username));
        return $out;
    }
    $row = $rows[0];

    // Now LDAP can kick in and get the correct hash
    if (cns_is_ldap_member($member_id)) {
        //$rows[0]['m_pass_hash_salted'] = cns_get_ldap_hash($member_id);

        // Doesn't exist any more? This is a special case - the 'LDAP member' exists in our DB, but not LDAP. It has been deleted from LDAP or LDAP server has jumped
        /*if ($rows[0]['m_pass_hash_salted'] === null)
        {
            $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : '_MEMBER_NO_EXIST', $username);
            return $out;
        } No longer appropriate with new authentication mode - instead we just have to give an invalid password message */

        $row = array_merge($row, cns_ldap_authorise_login($username, $password_mixed));
    }

    // Check bans
    $reasoned_ban = null;
    if ($this_ref->is_banned($row['id'], $reasoned_ban)) { // All hands to the guns
        $out['error'] = do_lang_tempcode('YOU_ARE_BANNED');
        $out['reasoned_ban'] = $reasoned_ban;
        return $out;
    }

    // Check valid user
    if (addon_installed('validation')) {
        if ($row['m_validated'] == 0) {
            $out['error'] = do_lang_tempcode('MEMBER_NOT_VALIDATED_STAFF');
            return $out;
        }
    }
    if ($row['m_validated_email_confirm_code'] != '') {
        $out['error'] = do_lang_tempcode('MEMBER_NOT_VALIDATED_EMAIL');
        return $out;
    }

    // Check password
    if (!$skip_auth) {
        $password_compat_scheme = $row['m_password_compat_scheme'];
        $needs_rehash = false;
        if ($cookie_login) {
            require_code('users');
            require_code('users_active_actions');

            switch ($password_compat_scheme) {
                case 'pending_deletion': // Account is actually pending deletion in the task queue; we should bail on error
                    cms_eatcookie(get_member_cookie());
                    cms_eatcookie(get_pass_cookie());
                    require_code('tempcode'); // This can be incidental even in fast AJAX scripts, if an old invalid cookie is present, so we need Tempcode for do_lang_tempcode
                    $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_PENDING_DELETION');
                    return $out;

                case 'none':
                    require_code('crypt');
                    if (!hash_equals(md5(get_site_salt() . $row['m_pass_hash_salted']), $password_mixed)) {
                        cms_eatcookie(get_member_cookie());
                        cms_eatcookie(get_pass_cookie());
                        require_code('tempcode'); // This can be incidental even in fast AJAX scripts, if an old invalid cookie is present, so we need Tempcode for do_lang_tempcode
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    break;

                case 'bcrypt':
                case 'bcrypt_temporary':
                case 'bcrypt_expired':
                    require_code('crypt');
                    if (($row['m_login_key_hash'] == '') || (!ratchet_hash_verify($password_mixed, get_site_salt() . '_' . get_pass_cookie(), $row['m_login_key_hash']))) {
                        cms_eatcookie(get_member_cookie());
                        cms_eatcookie(get_pass_cookie());
                        require_code('tempcode'); // This can be incidental even in fast AJAX scripts, if an old invalid cookie is present, so we need Tempcode for do_lang_tempcode
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    break;

                default: // LEGACY
                    require_code('crypt');
                    if (($row['m_login_key_hash'] == '') || (!ratchet_hash_verify($password_mixed, get_site_salt() . '_' . get_pass_cookie(), $row['m_login_key_hash'], CRYPT_LEGACY_V10))) {
                        cms_eatcookie(get_member_cookie());
                        cms_eatcookie(get_pass_cookie());
                        require_code('tempcode'); // This can be incidental even in fast AJAX scripts, if an old invalid cookie is present, so we need Tempcode for do_lang_tempcode
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    break;
            }
        } else {
            switch ($password_compat_scheme) {
                case 'pending_deletion': // Account is actually pending deletion in the task queue; we should bail on error
                    $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_PENDING_DELETION');
                    return $out;

                case 'bcrypt': // v11 style bcrypt passwords
                case 'bcrypt_temporary': // as above, but forced temporary password
                case 'bcrypt_expired': // as above, but forced temporary password because the previous one expired
                    require_code('crypt');
                    if (!ratchet_hash_verify($password_mixed, $row['m_pass_salt'], $row['m_pass_hash_salted'])) {
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }

                    // Since we verified the password is correct, check if the stored hash is of a different cost than configured. If so, we must re-hash.
                    $this_ratchet = get_ratchet_cost($row['m_pass_hash_salted']);
                    if ($this_ratchet !== null) {
                        $current_ratchet = get_option('crypt_ratchet');
                        if ($current_ratchet != $this_ratchet) {
                            $needs_rehash = true;
                        }
                    }
                    break;

                case '': // LEGACY: v10 style bcrypt passwords (insecure as they use md5)
                case 'temporary': // LEGACY: as above, but forced temporary password
                case 'expired': // LEGACY: as above, but forced temporary password because the previous one expired
                    require_code('crypt');
                    if (!ratchet_hash_verify($password_mixed, $row['m_pass_salt'], $row['m_pass_hash_salted'], CRYPT_LEGACY_V10)) {
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    $needs_rehash = true; // We need to re-hash this password to the updated v11 bcrypt scheme
                    break;

                case 'md5': // Old style plain md5 (very bad idea)
                    if ((!hash_equals($row['m_pass_hash_salted'], md5($password_mixed))) && ($password_mixed !== '!!!')) { // The !!! bit would never be in a hash, but for plain text checks using this same code, we sometimes use '!!!' to mean 'Error'.
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    break;

                case 'plain': // No hashing (extremely, horribly, terribly bad idea)
                    if (!hash_equals($row['m_pass_hash_salted'], $password_mixed)) {
                        $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                        return $out;
                    }
                    break;

                /*
                case 'httpauth':
                    // This is handled in get_member()
                    break;
                */

                default:
                    // Some kind of plugin (probably for logging in to an account that was imported)
                    $path = get_file_base() . '/sources_custom/hooks/systems/cns_auth/' . $password_compat_scheme . '.php';
                    if (!file_exists($path)) {
                        $path = get_file_base() . '/sources/hooks/systems/cns_auth/' . $password_compat_scheme . '.php';
                    }
                    if (!file_exists($path)) {
                        if (function_exists('build_url')) {
                            $reset_url = build_url(['page' => 'lost_password'], get_module_zone('lost_password'));
                            $out['error'] = do_lang_tempcode('UNKNOWN_AUTH_SCHEME_IN_DB', escape_html($reset_url->evaluate()));
                        } else {
                            $out['error'] = do_lang_tempcode('UNKNOWN_AUTH_SCHEME_IN_DB', escape_html(get_base_url() . '/index.php?page=lost_password'));
                        }
                        $out['failed_login'] = false; // Internal error, not a failed login
                        return $out;
                    }
                    require_code('hooks/systems/cns_auth/' . filter_naughty_harsh($password_compat_scheme, true));
                    $ob = object_factory('Hook_cns_auth_' . filter_naughty_harsh($password_compat_scheme, true));
                    $error = $ob->auth($row['m_username'], $row['id'], $password_mixed, $row);
                    if ($error !== null) {
                        $out['error'] = $error;
                        return $out;
                    }
                    break;
            }
        }

        // v10 passwords need re-hashed for v11. Also, v11 passwords hashed with a different ratchet than configured need re-hashed.
        if ($needs_rehash) {
            require_code('crypt');

            // For extra security, let's also change the salt
            $password_salt = get_secure_random_string(32, CRYPT_BASE64);
            $password_salted = ratchet_hash($password_mixed, $password_salt);

            $GLOBALS['FORUM_DB']->query_update('f_members', [
                'm_pass_hash_salted' => $password_salted,
                'm_password_compat_scheme' => (($password_compat_scheme == '') || ($password_compat_scheme == 'bcrypt')) ? 'bcrypt' : ('bcrypt_' . $password_compat_scheme),
                'm_pass_salt' => $password_salt,
            ], ['id' => $member_id]);

            unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);

            // Destroy login cookie; we cannot re-hash them because it might not have been passed in.
            cms_eatcookie(get_member_cookie());
            cms_eatcookie(get_pass_cookie());
        }
    }

    // Ok, authorised basically, but we need to see if this is a valid login IP
    if ((cns_get_best_group_property($this_ref->get_members_groups($row['id']), 'enquire_on_new_ips') == 1)) { // High security usergroup membership
        global $SENT_OUT_VALIDATE_NOTICE, $IN_SELF_ROUTING_SCRIPT;
        $ip = get_ip_address(3);
        $test2 = $this_ref->db->query_select_value_if_there('f_member_known_login_ips', 'i_val_code', ['i_member_id' => $row['id'], 'i_ip_address' => $ip]);
        if ((($test2 === null) || ($test2 != '')) && (!compare_ip_address($ip, $row['m_ip_address']))) { // IP needs validation
            // Eat login cookies; if IP validation is necessary, then the login is invalid
            cms_eatcookie(get_member_cookie());
            unset($_COOKIE[get_member_cookie()]);
            cms_eatcookie(get_pass_cookie());
            unset($_COOKIE[get_pass_cookie()]);
            cms_eatcookie(get_session_cookie());
            unset($_COOKIE[get_session_cookie()]);

            $send_validation_email = true;
            if (!$SENT_OUT_VALIDATE_NOTICE) {
                $test3 = null;
                if ($test2 !== null) {
                    $test3 = $this_ref->db->query_select_value('f_member_known_login_ips', 'i_time', ['i_member_id' => $row['id'], 'i_ip_address' => $ip]);
                    if ($test3 > (time() - (60 * 60))) { // Prevent sending another verification e-mail if this IP record is less than an hour old
                        $send_validation_email = false;
                    }
                    $this_ref->db->query_delete('f_member_known_login_ips', ['i_member_id' => $row['id'], 'i_ip_address' => $ip], '', 1); // Tidy up
                }

                require_code('crypt');
                $code = ($test2 !== null) ? $test2 : get_secure_random_string();
                $this_ref->db->query_insert('f_member_known_login_ips', ['i_val_code' => $code, 'i_member_id' => $row['id'], 'i_ip_address' => $ip, 'i_time' => ($test3 !== null) ? $test3 : time()], false, true); // errors suppressed in case of race condition

                if (($IN_SELF_ROUTING_SCRIPT) && ($send_validation_email)) {
                    require_code('comcode');

                    $url = find_script('approve_ip') . '?code=' . urlencode($code);
                    $url_simple = find_script('approve_ip');
                    $mail = do_lang('IP_VERIFY_MAIL', comcode_escape($url), comcode_escape(get_ip_address()), [$url_simple, $code], get_lang($row['id']));
                    $email_address = $row['m_email_address'];
                    if ($email_address == '') {
                        $email_address = get_option('staff_address');
                    }
                    dispatch_mail(do_lang('IP_VERIFY_MAIL_SUBJECT', null, null, null, get_lang($row['id'])), $mail, do_lang('mail:NO_MAIL_WEB_VERSION__SENSITIVE'), [$email_address], $row['m_username'], '', '', ['priority' => 1, 'require_recipient_valid_since' => $row['m_join_time']]);
                }

                $SENT_OUT_VALIDATE_NOTICE = true;
            }

            $out['failed_login'] = (!$send_validation_email); // Only count as failed login / brute forcing if we tried to log in again after already recently getting an IP validation requirement error
            $out['error'] = do_lang_tempcode('REQUIRES_IP_VALIDATION');
            return $out;
        }
        $this_ref->db->query_update('f_member_known_login_ips', ['i_time' => time()], ['i_member_id' => $row['id'], 'i_ip_address' => $ip], '', 1);
    }

    $this_ref->cns_flood_control($row['id']);

    $out['id'] = $row['id'];
    $out['failed_login'] = false;
    return $out;
}
