mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-06 02:35:45 +00:00
353 lines
12 KiB
PHP
353 lines
12 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file adminx/modules/Users/Model.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Adminx\Users;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use DB;
|
|
use App\Common\AuditLog;
|
|
use App\Common\Auth;
|
|
use App\Common\Auth\IdentityLinker;
|
|
use App\Common\SystemTables;
|
|
|
|
/**
|
|
* Доступ к пользователям (таблица {prefix}_users, современная схема:
|
|
* id, name, email, password_hash, role, is_active, created_at, updated_at).
|
|
*/
|
|
class Model
|
|
{
|
|
/** Известные роли (пока без таблицы ролей — фиксированный список). */
|
|
public static function roles()
|
|
{
|
|
return ['admin', 'manager', 'designer', 'user'];
|
|
}
|
|
|
|
protected static function table()
|
|
{
|
|
return SystemTables::table('users');
|
|
}
|
|
|
|
protected static function sessionTable()
|
|
{
|
|
return SystemTables::table('users_session');
|
|
}
|
|
|
|
/** Пустую строку сохраняем как NULL. */
|
|
protected static function nullable(array $data, $key)
|
|
{
|
|
$v = isset($data[$key]) ? trim((string) $data[$key]) : '';
|
|
return $v === '' ? null : $v;
|
|
}
|
|
|
|
/** Список с фильтром по поиску (имя/email/логин) и роли. */
|
|
public static function all($search = '', $role = '')
|
|
{
|
|
$sql = 'SELECT id,name,email,login,phone,role,is_active,created_at,updated_at,last_login_at,'
|
|
. 'password_changed_at,must_change_password FROM ' . self::table() . ' WHERE 1=1';
|
|
$args = [];
|
|
|
|
$search = trim((string) $search);
|
|
if ($search !== '') {
|
|
// email обычно ascii, name/login — utf8mb4: без приведения к
|
|
// charset соединения (utf8) кириллический запрос падает на
|
|
// «illegal mix of collations». Ведущий % и так гасит индекс.
|
|
$sql .= ' AND (CONVERT(name USING utf8) LIKE %ss OR CONVERT(email USING utf8) LIKE %ss OR CONVERT(login USING utf8) LIKE %ss)';
|
|
$args[] = $search;
|
|
$args[] = $search;
|
|
$args[] = $search;
|
|
}
|
|
|
|
$role = trim((string) $role);
|
|
if ($role !== '') {
|
|
$sql .= ' AND role = %s';
|
|
$args[] = $role;
|
|
}
|
|
|
|
$sql .= ' ORDER BY id ASC';
|
|
|
|
return call_user_func_array([DB::class, 'query'], array_merge([$sql], $args))->getAll();
|
|
}
|
|
|
|
public static function find($id)
|
|
{
|
|
return DB::query('SELECT * FROM ' . self::table() . ' WHERE id = %i LIMIT 1', (int) $id)->getObject();
|
|
}
|
|
|
|
/** Занят ли email другим пользователем. */
|
|
public static function emailExists($email, $exceptId = 0)
|
|
{
|
|
return (bool) DB::query(
|
|
'SELECT id FROM ' . self::table() . ' WHERE email = %s AND id != %i LIMIT 1',
|
|
(string) $email, (int) $exceptId
|
|
)->getValue();
|
|
}
|
|
|
|
/** Занят ли логин другим пользователем (пустой логин не проверяем). */
|
|
public static function loginExists($login, $exceptId = 0)
|
|
{
|
|
$login = trim((string) $login);
|
|
if ($login === '') {
|
|
return false;
|
|
}
|
|
|
|
return (bool) DB::query(
|
|
'SELECT id FROM ' . self::table() . ' WHERE login = %s AND id != %i LIMIT 1',
|
|
$login, (int) $exceptId
|
|
)->getValue();
|
|
}
|
|
|
|
/** Найти пользователя по email или логину (для входа). */
|
|
public static function findByIdentifier($identifier)
|
|
{
|
|
return DB::query(
|
|
'SELECT * FROM ' . self::table() . ' WHERE email = %s OR login = %s LIMIT 1',
|
|
(string) $identifier, (string) $identifier
|
|
)->getObject();
|
|
}
|
|
|
|
/** Проставить bcrypt-хеш и очистить legacy-пароль (после первого входа). */
|
|
public static function setPassword($id, $hash, $mustChange = false)
|
|
{
|
|
DB::Update(self::table(), [
|
|
'password_hash' => (string) $hash,
|
|
'legacy_password' => null,
|
|
'legacy_salt' => null,
|
|
'password_changed_at' => date('Y-m-d H:i:s'),
|
|
'must_change_password' => $mustChange ? 1 : 0,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
], 'id = %i', (int) $id);
|
|
}
|
|
|
|
public static function create(array $data)
|
|
{
|
|
$now = date('Y-m-d H:i:s');
|
|
DB::Insert(self::table(), [
|
|
'name' => (string) $data['name'],
|
|
'email' => (string) $data['email'],
|
|
'login' => self::nullable($data, 'login'),
|
|
'phone' => self::nullable($data, 'phone'),
|
|
'password_hash' => password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]),
|
|
'password_changed_at' => $now,
|
|
'must_change_password' => !empty($data['must_change_password']) ? 1 : 0,
|
|
'role' => (string) $data['role'],
|
|
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
$id = (int) DB::insertId();
|
|
IdentityLinker::ensurePublicForSystem($id, (string) $data['password']);
|
|
return $id;
|
|
}
|
|
|
|
public static function update($id, array $data)
|
|
{
|
|
$fields = [
|
|
'name' => (string) $data['name'],
|
|
'email' => (string) $data['email'],
|
|
'login' => self::nullable($data, 'login'),
|
|
'phone' => self::nullable($data, 'phone'),
|
|
'role' => (string) $data['role'],
|
|
'is_active' => !empty($data['is_active']) ? 1 : 0,
|
|
'must_change_password' => !empty($data['must_change_password']) ? 1 : 0,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
//-- Пароль меняем только если задан новый.
|
|
if (!empty($data['password'])) {
|
|
$fields['password_hash'] = password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]);
|
|
$fields['password_changed_at'] = date('Y-m-d H:i:s');
|
|
}
|
|
|
|
DB::Update(self::table(), $fields, 'id = %i', (int) $id);
|
|
if (!empty($data['password'])) {
|
|
self::revokeSessions((int) $id, (int) Auth::id() === (int) $id ? Auth::currentBrowserTokenHash() : '');
|
|
}
|
|
|
|
IdentityLinker::ensurePublicForSystem((int) $id, !empty($data['password']) ? (string) $data['password'] : '');
|
|
}
|
|
|
|
public static function completePasswordChange($id, $password)
|
|
{
|
|
$hash = password_hash((string) $password, PASSWORD_BCRYPT, array('cost' => 12));
|
|
self::setPassword((int) $id, $hash, false);
|
|
self::revokeSessions((int) $id, Auth::currentBrowserTokenHash());
|
|
IdentityLinker::ensurePublicForSystem((int) $id, (string) $password);
|
|
}
|
|
|
|
public static function recordSuccessfulLogin($id)
|
|
{
|
|
DB::Update(self::table(), array(
|
|
'last_login_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
), 'id=%i', (int) $id);
|
|
}
|
|
|
|
public static function toggle($id)
|
|
{
|
|
DB::query(
|
|
'UPDATE ' . self::table() . ' SET is_active = 1 - is_active, updated_at = %s WHERE id = %i',
|
|
date('Y-m-d H:i:s'), (int) $id
|
|
);
|
|
$active = (int) DB::query('SELECT is_active FROM ' . self::table() . ' WHERE id = %i', (int) $id)->getValue();
|
|
if (!$active) { self::revokeSessions((int) $id); }
|
|
return $active;
|
|
}
|
|
|
|
public static function setActive($id, $active)
|
|
{
|
|
DB::Update(self::table(), array(
|
|
'is_active' => $active ? 1 : 0,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
), 'id = %i', (int) $id);
|
|
if (!$active) { self::revokeSessions((int) $id); }
|
|
}
|
|
|
|
public static function delete($id)
|
|
{
|
|
self::revokeSessions((int) $id);
|
|
DB::Delete(self::table(), 'id = %i', (int) $id);
|
|
}
|
|
|
|
public static function security($userId)
|
|
{
|
|
$userId = (int) $userId;
|
|
$currentHash = Auth::currentBrowserTokenHash();
|
|
$ttl = defined('COOKIE_LIFETIME') ? max(3600, (int) COOKIE_LIFETIME) : 1209600;
|
|
$rows = DB::query(
|
|
'SELECT id,user_id,token_hash,agent,ip,created_at,last_active,expires_at FROM ' . self::sessionTable()
|
|
. ' WHERE user_id=%i AND last_active>=%s AND expires_at>=%s ORDER BY last_active DESC,id DESC',
|
|
$userId,
|
|
date('Y-m-d H:i:s', time() - $ttl),
|
|
date('Y-m-d H:i:s')
|
|
)->getAll() ?: array();
|
|
$sessions = array();
|
|
foreach ($rows as $row) {
|
|
$device = self::device((string) $row['agent']);
|
|
$sessions[] = array(
|
|
'id' => (int) $row['id'],
|
|
'ip' => (string) $row['ip'],
|
|
'created_at' => (string) $row['created_at'],
|
|
'last_active' => (string) $row['last_active'],
|
|
'expires_at' => (string) $row['expires_at'],
|
|
'is_current' => $currentHash !== '' && hash_equals($currentHash, (string) $row['token_hash']),
|
|
'browser' => $device['browser'],
|
|
'platform' => $device['platform'],
|
|
'device' => $device['device'],
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'sessions' => $sessions,
|
|
'failed' => self::failedLoginSummary($userId),
|
|
);
|
|
}
|
|
|
|
public static function revokeSession($userId, $sessionId)
|
|
{
|
|
$row = DB::query(
|
|
'SELECT id,token_hash FROM ' . self::sessionTable() . ' WHERE id=%i AND user_id=%i LIMIT 1',
|
|
(int) $sessionId,
|
|
(int) $userId
|
|
)->getAssoc();
|
|
if (!$row) { return array('removed' => false, 'current' => false); }
|
|
$currentHash = Auth::currentBrowserTokenHash();
|
|
$current = $currentHash !== '' && hash_equals($currentHash, (string) $row['token_hash']);
|
|
DB::Delete(self::sessionTable(), 'id=%i AND user_id=%i', (int) $sessionId, (int) $userId);
|
|
return array('removed' => true, 'current' => $current);
|
|
}
|
|
|
|
public static function revokeSessions($userId, $exceptTokenHash = '')
|
|
{
|
|
$exceptTokenHash = trim((string) $exceptTokenHash);
|
|
if (preg_match('/^[a-f0-9]{64}$/', $exceptTokenHash)) {
|
|
DB::Delete(self::sessionTable(), 'user_id=%i AND token_hash!=%s', (int) $userId, $exceptTokenHash);
|
|
} else {
|
|
DB::Delete(self::sessionTable(), 'user_id=%i', (int) $userId);
|
|
}
|
|
|
|
return max(0, (int) DB::affectedRows());
|
|
}
|
|
|
|
public static function failedLoginSummary($userId)
|
|
{
|
|
$since = date('Y-m-d H:i:s', time() - 86400);
|
|
$count = (int) DB::query(
|
|
'SELECT COUNT(*) FROM ' . AuditLog::table()
|
|
. ' WHERE action=%s AND target_id=%i AND created_at>=%s',
|
|
'auth.login_failed',
|
|
(int) $userId,
|
|
$since
|
|
)->getValue();
|
|
$last = DB::query(
|
|
'SELECT created_at,ip FROM ' . AuditLog::table()
|
|
. ' WHERE action=%s AND target_id=%i ORDER BY id DESC LIMIT 1',
|
|
'auth.login_failed',
|
|
(int) $userId
|
|
)->getAssoc();
|
|
|
|
return array(
|
|
'count_24h' => $count,
|
|
'last_at' => $last ? (string) $last['created_at'] : '',
|
|
'last_ip' => $last ? (string) $last['ip'] : '',
|
|
);
|
|
}
|
|
|
|
public static function failedLoginSpike($minutes = 10)
|
|
{
|
|
return (int) DB::query(
|
|
'SELECT COUNT(*) FROM ' . AuditLog::table() . ' WHERE action=%s AND created_at>=%s',
|
|
'auth.login_failed',
|
|
date('Y-m-d H:i:s', time() - max(1, (int) $minutes) * 60)
|
|
)->getValue();
|
|
}
|
|
|
|
protected static function device($agent)
|
|
{
|
|
$agent = (string) $agent;
|
|
$browser = strpos($agent, 'Edg/') !== false ? 'Edge'
|
|
: (strpos($agent, 'OPR/') !== false ? 'Opera'
|
|
: (strpos($agent, 'Firefox/') !== false ? 'Firefox'
|
|
: (strpos($agent, 'Chrome/') !== false ? 'Chrome'
|
|
: (strpos($agent, 'Safari/') !== false ? 'Safari' : ''))));
|
|
$platform = strpos($agent, 'Windows') !== false ? 'Windows'
|
|
: (strpos($agent, 'Android') !== false ? 'Android'
|
|
: (strpos($agent, 'iPhone') !== false || strpos($agent, 'iPad') !== false ? 'iOS'
|
|
: (strpos($agent, 'Macintosh') !== false ? 'macOS'
|
|
: (strpos($agent, 'Linux') !== false ? 'Linux' : ''))));
|
|
$device = strpos($agent, 'Mobile') !== false || strpos($agent, 'Android') !== false ? 'mobile' : 'desktop';
|
|
|
|
return array('browser' => $browser, 'platform' => $platform, 'device' => $device);
|
|
}
|
|
|
|
public static function count()
|
|
{
|
|
return (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue();
|
|
}
|
|
|
|
/** Сводка для карточек: всего / активных / отключённых / ролей. */
|
|
public static function stats()
|
|
{
|
|
$total = self::count();
|
|
$active = (int) DB::query('SELECT COUNT(*) FROM ' . self::table() . ' WHERE is_active = 1')->getValue();
|
|
$roles = (int) DB::query('SELECT COUNT(DISTINCT role) FROM ' . self::table() . ' WHERE role != ""')->getValue();
|
|
|
|
return [
|
|
'total' => $total,
|
|
'active' => $active,
|
|
'inactive' => $total - $active,
|
|
'roles' => $roles,
|
|
];
|
|
}
|
|
}
|