/ src / library / mail / YioopUserAuthenticator.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\mail;

use seekquarry\atto\Authenticator;
use seekquarry\yioop\models\SigninModel;
use seekquarry\yioop\models\UserModel;

/**
 * MailSite authenticator that delegates to Yioop's user model.
 * SMTP AUTH (from external relays) and IMAP LOGIN (from desktop
 * mail clients like Thunderbird) both reach this class via the
 * abstract Authenticator contract MailSite uses; the override of
 * verifyPassword routes the check through SigninModel so the
 * comparison matches the same crawlCrypt-based path the web UI
 * uses, and so any LDAP integration the admin has configured
 * applies to mail logins automatically.
 *
 * Why not the default verifyPassword: the parent class assumes
 * the stored credential is a password_verify-compatible hash
 * (bcrypt and friends), but Yioop's USERS.PASSWORD column is
 * produced by crawlCrypt, which derives its salt from the
 * existing stored value rather than from password_hash's
 * embedded cost/salt format. Calling password_verify on a
 * crawlCrypt output would always fail. Routing through
 * SigninModel keeps a single source of truth for password
 * verification and inherits its timing-attack mitigation
 * (sleep-to-fixed-interval) and LDAP fallback.
 */
class YioopUserAuthenticator extends Authenticator
{
    /**
     * Yioop user model used to look up account existence and
     * the raw PASSWORD column. Instances are created on demand
     * rather than injected so callers don't need to know about
     * Yioop's model autoloader.
     * @var UserModel|null
     */
    protected $user_model;
    /**
     * Yioop signin model used for the actual password check.
     * Lazily instantiated for the same reason as $user_model.
     * @var SigninModel|null
     */
    protected $signin_model;
    /**
     * Constructs the authenticator. Models are instantiated
     * lazily on first use so the cost of building them is not
     * paid for the every-process-start case of MailSite where
     * no AUTH or LOGIN command may arrive at all (a relay-only
     * mode that just accepts inbound SMTP for local delivery
     * without ever asking for credentials).
     *
     * @param UserModel|null $user_model optional preconstructed
     *      user model, useful for tests; production callers
     *      should pass null and let the class construct one
     * @param SigninModel|null $signin_model optional
     *      preconstructed signin model; same rationale
     */
    public function __construct($user_model = null,
        $signin_model = null)
    {
        $this->user_model = $user_model;
        $this->signin_model = $signin_model;
    }
    /**
     * Reports whether a Yioop account with this username exists.
     * Case-insensitive on USER_NAME, matching UserModel::getUser.
     * SMTP AUTH and IMAP LOGIN call this before verifyPassword
     * for early rejection of obvious-bogus usernames; the parent
     * Authenticator's default verifyPassword path also burns a
     * dummy password check when this returns false to keep
     * wall-clock time uniform across known/unknown users.
     *
     * @param string $username the bare username (local part of
     *      an email address or the username typed into a mail
     *      client's account settings)
     * @return bool true when a USERS row matches case-insensitively
     */
    public function userExists($username)
    {
        $row = $this->ensureUserModel()->getUser($username);
        return is_array($row) && !empty($row['USER_NAME']);
    }
    /**
     * Returns the raw PASSWORD column for $username. This method
     * exists because the abstract base class declares it abstract;
     * callers that compare against password_verify will get a
     * meaningless result with Yioop hashes (see class docblock).
     * The verifyPassword override below is the actual check.
     *
     * @param string $username username to look up
     * @return string|false the stored hash, or false when the
     *      user does not exist
     */
    public function getPasswordHash($username)
    {
        $row = $this->ensureUserModel()->getUser($username);
        if (!is_array($row) || empty($row['PASSWORD'])) {
            return false;
        }
        return $row['PASSWORD'];
    }
    /**
     * Validates a login password. The reserved bot account is
     * checked against the short-lived shared secret the site's own
     * mail sender writes just before it logs in (see
     * verifyBotSecret), so the site can relay its own mail without a
     * stored bot password. Every other account is checked against
     * its stored credential by delegating to
     * SigninModel::checkValidSignin, the same routine the web signin
     * form uses. checkValidSignin may rewrite the username it is
     * given (the LDAP path maps a typed name to the Yioop local
     * name); that rewrite is harmless here because the session was
     * already bound to the typed username when the client logged in.
     *
     * @param string $username login name typed by the client
     * @param string $password candidate plaintext password
     * @return bool true on a valid credential
     */
    public function verifyPassword($username, $password)
    {
        if (MailSiteFactory::isBotUsername($username)) {
            return $this->verifyBotSecret($password);
        }
        return (bool) $this->ensureSigninModel()
            ->checkValidSignin($username, $password);
    }
    /**
     * Checks a candidate password against the shared bot secret the
     * site's own mail sender writes just before it logs in. The
     * compare is constant time. On a match the secret file is
     * emptied so the secret is single use and cannot be replayed; a
     * wrong password leaves the file untouched so a bad guess cannot
     * wipe out the secret a legitimate send is about to use. A
     * missing or empty file means no bot login is in progress, so
     * any password fails.
     *
     * @param string $password the candidate password from the client
     * @return bool true when the password matches the current secret
     */
    protected function verifyBotSecret($password)
    {
        $path = MailSiteFactory::botSecretPath();
        if (!is_file($path)) {
            return false;
        }
        $secret = trim((string)file_get_contents($path));
        if ($secret === "") {
            return false;
        }
        if (!hash_equals($secret, (string)$password)) {
            return false;
        }
        file_put_contents($path, "", LOCK_EX);
        return true;
    }
    /**
     * Lazily constructs the Yioop UserModel on first use and
     * caches it. Wrapped in a small helper rather than inlined
     * so the constructor stays free of side effects and the
     * test path that injects a preconstructed model just works.
     *
     * @return UserModel the cached or freshly built user model
     */
    protected function ensureUserModel()
    {
        if ($this->user_model === null) {
            $this->user_model = new UserModel();
        }
        return $this->user_model;
    }
    /**
     * Lazily constructs the Yioop SigninModel on first use and
     * caches it. Counterpart to ensureUserModel.
     *
     * @return SigninModel the cached or freshly built signin model
     */
    protected function ensureSigninModel()
    {
        if ($this->signin_model === null) {
            $this->signin_model = new SigninModel();
        }
        return $this->signin_model;
    }
}
X