<?php

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Authentication;

use Psr\Log\LogLevel;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * Authentication services class
 */
class AuthenticationService extends AbstractAuthenticationService implements MimicServiceInterface
{
    /**
     * Process the submitted credentials. Returns true, if loginData was processed successfully, else false.
     * In addition, extensions overwriting this method or implement the context related methods `processLoginDataFE`
     * or `processLoginDataBE` may also return an int (e.g. >= 200) in order to stop loginData processing of other
     * services in the authentication service chain.
     *
     * @param array $loginData Credentials that are submitted and potentially modified by other services
     */
    public function processLoginData(array &$loginData): bool|int
    {
        $loginData = array_map(trim(...), $loginData);
        $loginData['uident_text'] = $loginData['uident'];
        return true;
    }

    /**
     * Find a user (eg. look up the user record in database when a login is sent)
     *
     * @return array<string, mixed>|false User array or FALSE
     */
    public function getUser()
    {
        if (LoginType::tryFrom($this->login['status'] ?? '') !== LoginType::LOGIN) {
            return false;
        }
        if ((string)$this->login['uident_text'] === '') {
            // Failed Login attempt (no password given)
            $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP### for username \'%s\' with an empty password!', [
                $this->login['uname'],
            ]);
            $this->logger->warning('Login-attempt from {ip}, for username "{username}" with an empty password!', [
                'ip' => $this->authInfo['REMOTE_ADDR'],
                'username' => $this->login['uname'],
            ]);
            return false;
        }

        $user = $this->fetchUserRecord($this->login['uname']);
        if (!is_array($user)) {
            // Failed login attempt (no username found)
            $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, 'Login-attempt from ###IP###, username \'%s\' not found!', [$this->login['uname']]);
            $this->logger->info('Login-attempt from username "{username}" not found!', [
                'username' => $this->login['uname'],
                'REMOTE_ADDR' => $this->authInfo['REMOTE_ADDR'],
            ]);
        } else {
            $this->logger->debug('User found', [
                $this->db_user['userid_column'] => $user[$this->db_user['userid_column']],
                $this->db_user['username_column'] => $user[$this->db_user['username_column']],
            ]);
        }
        return $user;
    }

    /**
     * Authenticate a user: Check submitted user credentials against stored hashed password.
     *
     * Returns one of the following status codes:
     *  >= 200: User authenticated successfully. No more checking is needed by other auth services.
     *  >= 100: User not authenticated; this service is not responsible. Other auth services will be asked.
     *  > 0:    User authenticated successfully. Other auth services will still be asked.
     *  <= 0:   Authentication failed, no more checking needed by other auth services.
     *
     * @param array<string, mixed> $user User data
     * @return int Authentication status code, one of 0, 100, 200
     */
    public function authUser(array $user): int
    {
        // Early 100 "not responsible, check other services" if username or password is empty
        if (!isset($this->login['uident_text']) || (string)$this->login['uident_text'] === ''
            || !isset($this->login['uname']) || (string)$this->login['uname'] === '') {
            return 100;
        }

        if (empty($this->db_user['table'])) {
            throw new \RuntimeException('User database table not set', 1533159150);
        }

        $submittedUsername = (string)$this->login['uname'];
        $submittedPassword = (string)$this->login['uident_text'];
        $passwordHashInDatabase = $user['password'];
        $userDatabaseTable = $this->db_user['table'];

        $isReHashNeeded = false;

        $saltFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);

        // Get a hashed password instance for the hash stored in db of this user
        try {
            $hashInstance = $saltFactory->get($passwordHashInDatabase, $this->pObj->loginType);
        } catch (InvalidPasswordHashException $exception) {
            // Could not find a responsible hash algorithm for given password. This is unusual since other
            // authentication services would usually be called before this one with higher priority. We thus log
            // the failed login but still return '100' to proceed with other services that may follow.
            $message = 'Login-attempt from ###IP###, username \'%s\', no suitable hash method found!';
            $this->writeLogMessage($message, $submittedUsername);
            $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]);
            // Not responsible, check other services
            return 100;
        }

        // An instance of the currently configured salted password mechanism
        // Don't catch InvalidPasswordHashException here: Only install tool should handle those configuration failures
        $defaultHashInstance = $saltFactory->getDefaultHashInstance($this->pObj->loginType);

        // We found a hash class that can handle this type of hash
        $isValidPassword = $hashInstance->checkPassword($submittedPassword, $passwordHashInDatabase);
        if ($isValidPassword) {
            if ($hashInstance->isHashUpdateNeeded($passwordHashInDatabase)
                || $defaultHashInstance != $hashInstance
            ) {
                // Lax object comparison intended: Rehash if old and new salt objects are not
                // instances of the same class.
                $isReHashNeeded = true;
            }
        }

        if (!$isValidPassword) {
            // Failed login attempt - wrong password
            $message = 'Login-attempt from ###IP###, username \'%s\', password not accepted!';
            $this->writeLogMessage($message, $submittedUsername);
            $this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::ATTEMPT, SystemLogErrorClassification::SECURITY_NOTICE, null, $message, [$submittedUsername]);
            // Responsible, authentication failed, do NOT check other services
            return 0;
        }

        if ($isReHashNeeded) {
            // Given password validated but a re-hash is needed. Do so.
            $this->updatePasswordHashInDatabase(
                $userDatabaseTable,
                (int)$user['uid'],
                $defaultHashInstance->getHashedPassword($submittedPassword)
            );
        }

        // Responsible, authentication ok. Log successful login and return 'auth ok, do NOT check other services'
        $this->writeLogMessage($this->pObj->loginType . ' Authentication successful for username \'%s\'', $submittedUsername);
        return 200;
    }

    /**
     * Mimics password hashing for invalid authentication requests to mitigate
     * @link https://cwe.mitre.org/data/definitions/208.html: CWE-208: Observable Timing Discrepancy
     */
    public function mimicAuthUser(): bool
    {
        try {
            $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
            $defaultHashInstance = $hashFactory->getDefaultHashInstance($this->pObj->loginType);
            $defaultHashInstance->getHashedPassword(random_bytes(10));
        } catch (\Exception) {
            // no further processing here
        }
        return false;
    }

    /**
     * Method updates a FE/BE user record - in this case a new password string will be set.
     *
     * @param string $table Database table of this user, usually 'be_users' or 'fe_users'
     * @param int $uid uid of user record that will be updated
     * @param string $newPassword Field values as key=>value pairs to be updated in database
     */
    protected function updatePasswordHashInDatabase(string $table, int $uid, string $newPassword): void
    {
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
        $connection->update(
            $table,
            ['password' => $newPassword],
            ['uid' => $uid]
        );
        $this->logger->notice('Automatic password update for user record in {table} with uid {uid}', [
            'table' => $table,
            'uid' => $uid,
        ]);
    }

    /**
     * Writes log message. Destination log depends on the current system mode.
     *
     * This function accepts variable number of arguments and can format
     * parameters. The syntax is the same as for sprintf()
     * If a marker ###IP### is present in the message, it is automatically replaced with the REMOTE_ADDR
     *
     * @param string $message Message to output
     * @param array<int,string> $params
     */
    protected function writeLogMessage(string $message, ...$params): void
    {
        if (!empty($params)) {
            $message = vsprintf($message, $params);
        }
        $message = str_replace('###IP###', (string)($this->authInfo['REMOTE_ADDR'] ?? ''), $message);
        if ($this->pObj->loginType === 'FE') {
            $timeTracker = GeneralUtility::makeInstance(TimeTracker::class);
            $timeTracker->setTSlogMessage($message, LogLevel::INFO);
        }
        $this->logger->notice($message);
    }
}
