<?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;
}
}