245 lines
8.1 KiB
PHP
245 lines
8.1 KiB
PHP
<?php
|
|
namespace Incoviba\Service;
|
|
|
|
use DateTimeInterface;
|
|
use DateTimeImmutable;
|
|
use DateInterval;
|
|
use Exception;
|
|
use PDOException;
|
|
use Incoviba\Common\Implement\Exception\EmptyResult;
|
|
use Incoviba\Repository;
|
|
use Incoviba\Model;
|
|
use function random_bytes;
|
|
use function password_hash;
|
|
use function setcookie;
|
|
|
|
class Login
|
|
{
|
|
public function __construct(protected Repository\Login $repository, protected Repository\User $userRepository,
|
|
protected string $cookie_name,
|
|
protected int $max_login_time, protected string $domain = '',
|
|
protected string $path = '', protected string $cookie_separator = ':')
|
|
{
|
|
$this->loadCookie();
|
|
}
|
|
|
|
protected string $selector = '';
|
|
protected string $token = '';
|
|
|
|
public function isIn(?string $selector = null, ?string $sentToken = null): bool
|
|
{
|
|
try {
|
|
$login = $this->repository->fetchActiveBySelector($selector ?? $this->selector);
|
|
if (!$this->validToken($login, $sentToken)) {
|
|
return false;
|
|
}
|
|
$now = new DateTimeImmutable();
|
|
if ($login->dateTime->add(new DateInterval("PT{$this->max_login_time}H")) > $now) {
|
|
return true;
|
|
}
|
|
} catch (PDOException|EmptyResult) {}
|
|
return false;
|
|
}
|
|
public function getUser(): Model\User
|
|
{
|
|
$login = $this->repository->fetchActiveBySelector($this->selector);
|
|
if (!$this->validToken($login)) {
|
|
throw new Exception('User not found');
|
|
}
|
|
return $login->user;
|
|
}
|
|
public function getToken(): string
|
|
{
|
|
return implode($this->cookie_separator, [$this->selector, $this->token]);
|
|
}
|
|
public function getSeparator(): string
|
|
{
|
|
return $this->cookie_separator;
|
|
}
|
|
|
|
public function addUser(array $data): Model\User
|
|
{
|
|
try {
|
|
return $this->userRepository->fetchByName($data['name']);
|
|
} catch (EmptyResult) {
|
|
list($passphrase, $encrypted) = $this->splitPassword($data['password']);
|
|
$password = $this->cryptoJs_aes_decrypt($encrypted, $passphrase);
|
|
$password = password_hash($password, PASSWORD_DEFAULT);
|
|
$user = $this->userRepository->create([
|
|
'name' => $data['name'],
|
|
'password' => $password,
|
|
'enabled' => $data['enabled'] ?? 1
|
|
]);
|
|
return $this->userRepository->save($user);
|
|
}
|
|
}
|
|
public function validateUser(Model\User $user, string $encryptedPassword): bool
|
|
{
|
|
list($passphrase, $encrypted) = $this->splitPassword($encryptedPassword);
|
|
try {
|
|
$password = $this->cryptoJs_aes_decrypt($encrypted, $passphrase);
|
|
} catch (Exception) {
|
|
return false;
|
|
}
|
|
return $user->validate($password);
|
|
}
|
|
public function login(Model\User $user): bool
|
|
{
|
|
try {
|
|
$login = $this->repository->fetchActiveByUser($user->id);
|
|
$this->logout($login->user);
|
|
} catch (PDOException|EmptyResult) {
|
|
}
|
|
|
|
try {
|
|
$now = new DateTimeImmutable();
|
|
$login = $this->repository->create([
|
|
'user_id' => $user->id,
|
|
'time' => $now->format('Y-m-d H:i:s'),
|
|
'status' => 1
|
|
]);
|
|
list('selector' => $selector, 'token' => $token) = $this->generateToken($login);
|
|
$login->selector = $selector;
|
|
$login->token = password_hash($token, PASSWORD_DEFAULT);
|
|
$this->repository->save($login);
|
|
$this->saveCookie($selector, $token, $login->dateTime->add(new DateInterval("PT{$this->max_login_time}H")));
|
|
return true;
|
|
} catch (PDOException|Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
public function logout(Model\User $user): bool
|
|
{
|
|
$this->removeCookie();
|
|
try {
|
|
$logins = $this->repository->fetchByUser($user->id);
|
|
} catch (PDOException) {
|
|
return true;
|
|
}
|
|
try {
|
|
foreach ($logins as $login) {
|
|
$this->repository->edit($login, ['status' => 0]);
|
|
}
|
|
return true;
|
|
} catch (PDOException) {
|
|
return false;
|
|
}
|
|
}
|
|
public function parseToken(Model\Login $login): string
|
|
{
|
|
return implode($this->cookie_separator, [$login->selector, $login->token]);
|
|
}
|
|
|
|
protected function loadCookie(): void
|
|
{
|
|
if (!isset($_COOKIE[$this->cookie_name])) {
|
|
return;
|
|
}
|
|
$cookie = $_COOKIE[$this->cookie_name];
|
|
if (!str_contains($cookie, $this->cookie_separator)) {
|
|
$this->removeCookie();
|
|
return;
|
|
}
|
|
list($this->selector, $this->token) = explode($this->cookie_separator, $cookie);
|
|
}
|
|
protected function saveCookie(string $selector, string $token, DateTimeInterface $expires): void
|
|
{
|
|
setcookie(
|
|
$this->cookie_name,
|
|
implode($this->cookie_separator, [$selector, $token]),
|
|
[
|
|
'expires' => $expires->getTimestamp(),
|
|
'path' => $this->path,
|
|
'domain' => $this->domain,
|
|
'samesite' => 'Strict'
|
|
]
|
|
);
|
|
$this->selector = $selector;
|
|
$this->token = $token;
|
|
}
|
|
protected function removeCookie(): void
|
|
{
|
|
setcookie(
|
|
$this->cookie_name,
|
|
'',
|
|
(new DateTimeImmutable())->getTimestamp(),
|
|
$this->path,
|
|
$this->domain
|
|
);
|
|
}
|
|
|
|
protected function validToken(Model\Login $login, ?string $sentToken = null): bool
|
|
{
|
|
if ($sentToken !== null) {
|
|
return password_verify($sentToken, $login->token);
|
|
}
|
|
return password_verify($this->token, $login->token);
|
|
}
|
|
protected function generateToken(Model\Login $login): array
|
|
{
|
|
$selector = bin2hex(random_bytes(12));
|
|
$token = bin2hex(random_bytes(20));
|
|
return ['selector' => $selector, 'token' => $token];
|
|
}
|
|
|
|
protected function splitPassword(string $input): array
|
|
{
|
|
$ini = strpos($input, 'U');
|
|
$passphrase = substr($input, 0, $ini);
|
|
$message = substr($input, $ini);
|
|
return [$passphrase, $message];
|
|
}
|
|
protected function cryptoJs_aes_decrypt($data, $key): string
|
|
{
|
|
$data = base64_decode($data);
|
|
if (!str_starts_with($data, "Salted__")) {
|
|
return false;
|
|
}
|
|
$salt = substr($data, 8, 8);
|
|
$keyAndIV = $this->aes_evpKDF($key, $salt);
|
|
$decrypted = openssl_decrypt(
|
|
substr($data, 16),
|
|
"aes-256-cbc",
|
|
$keyAndIV["key"],
|
|
OPENSSL_RAW_DATA, // base64 was already decoded
|
|
$keyAndIV["iv"]
|
|
);
|
|
if ($decrypted === false) {
|
|
throw new Exception();
|
|
}
|
|
return $decrypted;
|
|
}
|
|
protected function aes_evpKDF($password, $salt, $keySize = 8, $ivSize = 4, $iterations = 1, $hashAlgorithm = "md5"): array
|
|
{
|
|
$targetKeySize = $keySize + $ivSize;
|
|
$derivedBytes = "";
|
|
$numberOfDerivedWords = 0;
|
|
$block = NULL;
|
|
$hasher = hash_init($hashAlgorithm);
|
|
while ($numberOfDerivedWords < $targetKeySize) {
|
|
if ($block != NULL) {
|
|
hash_update($hasher, $block);
|
|
}
|
|
hash_update($hasher, $password);
|
|
hash_update($hasher, $salt);
|
|
$block = hash_final($hasher, TRUE);
|
|
$hasher = hash_init($hashAlgorithm);
|
|
|
|
// Iterations
|
|
for ($i = 1; $i < $iterations; $i++) {
|
|
hash_update($hasher, $block);
|
|
$block = hash_final($hasher, TRUE);
|
|
$hasher = hash_init($hashAlgorithm);
|
|
}
|
|
|
|
$derivedBytes .= substr($block, 0, min(strlen($block), ($targetKeySize - $numberOfDerivedWords) * 4));
|
|
|
|
$numberOfDerivedWords += strlen($block) / 4;
|
|
}
|
|
return array(
|
|
"key" => substr($derivedBytes, 0, $keySize * 4),
|
|
"iv" => substr($derivedBytes, $keySize * 4, $ivSize * 4)
|
|
);
|
|
}
|
|
}
|