MRBS 1.12.2 等保2.0二级整改完整提交
Docker image / push (push) Canceled after 0s

包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
人事系统开发
2026-09-08 21:19:47 +08:00
commit 48092cab42
2221 changed files with 659586 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace MRBS\SessionHandler;
use MRBS\Utf8\Utf8String;
class Cookie
{
/**
* A wrapper for `setcookie()` that uses the same values for `$path`, `$domain`, `$secure`, `$httponly` and, if
* applicable, `$samesite` as for the session cookie.
*/
public static function set(string $name, string $value, int $expires) : bool
{
// Use the same cookie params as for the session cookie.
$cookie_params = session_get_cookie_params();
assert(version_compare(MRBS_MIN_PHP_VERSION, '7.3.0', '<'), "The else block can be removed.");
if (version_compare(PHP_VERSION, '7.3.0', '>='))
{
// The new way, allowing 'samesite' to be set
$result = setcookie($name, $value, [
'expires' => $expires,
'path' => $cookie_params['path'],
'domain' => $cookie_params['domain'],
'secure' => $cookie_params['secure'],
'httponly' => $cookie_params['httponly'],
'samesite' => $cookie_params['samesite']
]);
}
else
{
// The old way. 'samesite' wasn't available until PHP 7.3.0.
$result = setcookie(
$name,
$value,
$expires,
$cookie_params['path'],
$cookie_params['domain'],
$cookie_params['secure'],
$cookie_params['httponly']
);
}
self::checkCookieSizes();
return $result;
}
public static function delete(string $name) : bool
{
return self::set($name, '', time() - 42000);
}
/**
* Check the sizes of the cookies that have been set and trigger a warning if they are too large.
*
* Browsers will reject cookies that are too large, although `setcookie()` will still set them and return TRUE.
* What's considered too large depends on the browser. Some browsers just count the length of the name and value
* of the cookie; others also count the length of the options (expires, path, domain, secure, httponly, samesite).
* To be on the safe side, the options are included in the calculation.
*/
private static function checkCookieSizes() : void
{
$max_size = 4096;
$headers = headers_list();
foreach ($headers as $header)
{
if (preg_match('/^Set-Cookie:\s*([^=]*)=(.*$)/', $header, $matches))
{
if ((string)(new Utf8String($matches[1] . '=' . $matches[2]))->byteCount() > $max_size)
{
trigger_error("Cookie '" . $matches[1] . "' exceeds $max_size bytes", E_USER_WARNING);
}
}
}
}
}
@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace MRBS\SessionHandler;
use MRBS\Errors\Errors;
use SessionHandlerInterface;
use SessionUpdateTimestampHandlerInterface;
use function MRBS\_tbl;
use function MRBS\db;
// Suppress deprecation notices until we get to requiring at least PHP 8
// because union types, needed for the return types of read() and gc(), are
// not supported in PHP 7. Using the #[\ReturnTypeWillChange] attribute
// does not help because that was only introduced in PHP 8.1.
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
{
$old_level = error_reporting();
error_reporting($old_level & ~E_DEPRECATED);
}
else
{
trigger_error("This code can now be removed", E_USER_NOTICE);
}
/**
* A custom session handler that stores session data in a cookie.
*
* Ideally we would encrypt the session data, but most encryption algorithms will increase the length of
* the string. As we will be putting the data in a cookie, whose size is limited to 4096 bytes, there
* would be a real danger of the cookie becoming too large. Even unencrypted, it's quite possible that the
* session data could be too large for a cookie. The cookie-based session handler is therefore not really
* recommended.
*/
class SessionHandlerCookie implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
{
private const DEFAULT_HASH_ALGO = 'sha512';
private $algo;
private $include_ip;
private $secret;
public function __construct(
#[\SensitiveParameter]
string $secret,
string $algo = self::DEFAULT_HASH_ALGO,
bool $include_ip = false
)
{
$this->include_ip = $include_ip;
$this->secret = $secret;
if (in_array($algo, hash_hmac_algos()))
{
$this->algo = $algo;
}
else
{
$this->algo = self::DEFAULT_HASH_ALGO;
$message = "Invalid hash algorithm '$algo' specified, using '" . self::DEFAULT_HASH_ALGO . "'sha512' instead";
trigger_error($message, E_USER_WARNING);
}
}
/**
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
* returned internally to PHP for processing.
*/
public function open($path, $name): bool
{
// Nothing to do here
return true;
}
/**
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
* returned internally to PHP for processing.
*/
public function close(): bool
{
// Nothing to do here
return true;
}
/**
* @return string Returns an encoded string of the read data. If nothing was read, it must
* return an empty string. Note this value is returned internally to PHP for processing.
*/
public function read($id)
{
if (!$this->validateId($id))
{
return '';
}
$exploded = explode('_', $_COOKIE[$id]);
if (count($exploded) !== 2)
{
return '';
}
list($hash, $base64_data) = $exploded;
$data = base64_decode($base64_data);
// Check the hash
if (!hash_equals($hash, self::getHash($this->algo, $data, $this->secret)))
{
trigger_error('Cookie has been tampered with or secret may have changed', E_USER_WARNING);
return '';
}
try
{
// Decode the data so that we can check the expiry time and IP address
session_decode($data);
$this->validateSession();
// Everything looks OK. Clear the internal data keys and return the data.
unset($_SESSION['_ip']);
unset($_SESSION['_expiry']);
if (false === ($data = session_encode()))
{
throw new SessionHandlerCookieException('Failed to encode session data');
}
return $data;
}
catch (SessionHandlerCookieException $e)
{
trigger_error($e->getMessage(), E_USER_WARNING);
// We have to unset the $_SESSION variable as well as return an empty string because
// we have already called session_decode() above.
unset($_SESSION);
return '';
}
}
/**
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
* returned internally to PHP for processing.
*/
public function write($id, $data): bool
{
// Decode the data so that we can set the expiry time and IP address and then encode it again.
session_decode($data);
assert(!isset($_SESSION['_expiry']), "'_expiry' is a reserved data key");
assert(!isset($_SESSION['_ip']), "'_ip' is a reserved data key");
// Set the expiry to be the same as the session cookie expiry, or else 0 for browser close
$expiry = self::getExpiry();
if ($expiry === false)
{
throw new \Exception('Session expiry time not set');
}
$_SESSION['_expiry'] = $expiry;
if ($this->include_ip)
{
$_SESSION['_ip'] = $server['REMOTE_ADDR'] ?? null;
}
$data = session_encode();
$hash = self::getHash($this->algo, $data, $this->secret);
return Cookie::set($id, $hash . '_' . base64_encode($data), $expiry);
}
/**
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
* returned internally to PHP for processing.
*/
public function destroy($id): bool
{
return Cookie::delete($id);
}
/**
* @return bool The return value (usually TRUE on success, FALSE on failure). Note this value is
* returned internally to PHP for processing.
*/
public function gc($max_lifetime)
{
// Garbage collection is not required
return true;
}
public function validateId($id) : bool
{
// Need to provide this method to circumvent a bug in some versions of PHP.
// See https://github.com/php/php-src/issues/9668
return isset($_COOKIE[$id]);
}
public function updateTimestamp($id, $data) : bool
{
// We only need to provide this method because it's part of SessionUpdateTimestampHandlerInterface
// which we are implementing in order to provide validateId().
return $this->write($id, $data);
}
private static function getHash(
string $algo,
string $data,
#[\SensitiveParameter]
string $key
) : string
{
if (!function_exists('hash_hmac'))
{
Errors::fatalError("It appears that your PHP has the hash functions " .
"disabled, which are required for the 'cookie' " .
"session scheme.");
}
return hash_hmac($algo, $data, $key);
}
public static function updateExpiry(string $id, int $expiry) : void
{
$old_id = self::getId();
if (($old_id === false) || ($old_id !== $id))
{
self::setId($id);
self::setExpiry($expiry);
}
}
/**
* Get the session expiry time from the database.
*
* @return false|int Returns the session expiry time in seconds, or FALSE if the session expiry time is not set.
*/
private static function getExpiry()
{
$result = self::getVariable('session_expiry');
return ($result === false) ? false : intval($result);
}
/**
* Get the session ID from the database.
*
* @return false|string Returns the session ID, or FALSE if the session ID is not set.
*/
private static function getId()
{
return self::getVariable('session_id');
}
/**
* Get a variable from the variables table.
*
* @return false|string Returns the variable value, or FALSE if the variable is not set.
*/
private static function getVariable(string $name)
{
$sql_params = [':variable_name' => $name];
$sql = "SELECT variable_content
FROM " . _tbl('variables') . "
WHERE variable_name=:variable_name
LIMIT 1";
return db()->query_scalar_non_bool($sql, $sql_params);
}
private static function setExpiry(int $expiry) : void
{
self::setVariable('session_expiry', (string)$expiry);
}
private static function setId(string $id) : void
{
self::setVariable('session_id', $id);
}
private static function setVariable(string $name, string $value) : void
{
$sql_params = [];
$data = ['variable_name' => $name, 'variable_content' => $value];
$sql = db()->syntax_upsert($data, _tbl('variables'), $sql_params, 'variable_name', ['id'], true);
db()->command($sql, $sql_params);
}
private function validateSession() : void
{
global $server;
// Check expiry time
if (!isset($_SESSION['_expiry']))
{
throw new SessionHandlerCookieException('Cookie expiry time not set');
}
if (($_SESSION['_expiry'] !== 0) && ($_SESSION['_expiry'] <= time()))
{
throw new SessionHandlerCookieException('Cookie has expired');
}
// Check IP address
if ($this->include_ip)
{
if (isset($_SESSION['_ip']))
{
if (!isset($server['REMOTE_ADDR']) || ($_SESSION['_ip'] !== $server['REMOTE_ADDR']))
{
throw new SessionHandlerCookieException('IP address has changed');
}
}
else
{
if (isset($_SESSION['REMOTE_ADDR']))
{
throw new SessionHandlerCookieException('IP address should be NULL');
}
}
}
}
}
// Restore the original error reporting level
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
{
error_reporting($old_level);
}
else
{
trigger_error("This code can now be removed", E_USER_NOTICE);
}
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MRBS\SessionHandler;
class SessionHandlerCookieException extends \Exception
{
}
@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace MRBS\SessionHandler;
use Defuse\Crypto\Crypto;
use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException;
use Defuse\Crypto\Key;
use MRBS\DB\DBException;
use PDOException;
use SessionHandlerInterface;
use SessionUpdateTimestampHandlerInterface;
use function MRBS\_tbl;
use function MRBS\db;
// Suppress deprecation notices until we get to requiring at least PHP 8
// because union types, needed for the return types of read() and gc(), are
// not supported in PHP 7. Using the #[\ReturnTypeWillChange] attribute
// does not help because that was only introduced in PHP 8.1.
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
{
$old_level = error_reporting();
error_reporting($old_level & ~E_DEPRECATED);
}
else
{
trigger_error("This code can now be removed", E_USER_NOTICE);
}
// Use our own PHP session handling by storing sessions in the database. This has three advantages:
// (a) it's more secure, especially on shared servers
// (b) it avoids problems with ordinary sessions not working because the PHP session save
// directory is not writable
// (c) it's more resilient in clustered environments
//
// The class also encrypts the session data, using a random key which is stored in a cookie (based on
// https://github.com/ezimuel/PHP-Secure-Session).
class SessionHandlerDb implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
{
/**
* A random default key to be used if `$auth["session_php"]["store_key_in_cookie"]` is false.
* Not very secure, but it's better than storing session data in plain text.
*/
private const DEFAULT_ASCII_KEY = 'def000005a41b5af1df304e485dee0d01d34eb4b5463333d5ecbe19020220c357745d1864efd58714e4d5d91591df76f228e1268e47f5f07be9336a244fd2fd561dc798a';
private const KEY_COOKIE_PREFIX = 'KEY_';
private $key;
private static $table;
public function __construct()
{
self::$table = _tbl('sessions');
// We need to lock the session data while it is in use in order to prevent problems
// with Ajax calls. This happens with the default file session handler, but
// in order to provide it with the DB session handler we need the ability to set multiple locks.
if (!db()->supportsMultipleLocks())
{
throw new SessionHandlerDbException(
"MRBS: database does not support multiple locks.",
SessionHandlerDbException::NO_MULTIPLE_LOCKS
);
}
if (!db()->table_exists(self::$table))
{
// We throw an exception if the table doesn't exist rather than returning FALSE, because in some
// versions of PHP, eg 7.0.25, session_start() will throw a fatal error if it can't open
// a session, rather than just returning FALSE as the documentation seems to suggest. So
// when a new SessionHandlerDb object is created we do it in a try/catch block. [Note that
// the exception can't be thrown on open() because a try/catch round session_start() won't
// catch the exception - maybe because open() is a callback function??]
throw new SessionHandlerDbException(
"MRBS: session table does not exist",
SessionHandlerDbException::TABLE_NOT_EXISTS
);
}
}
// The return value (usually TRUE on success, FALSE on failure). Note this value is
// returned internally to PHP for processing.
public function open($path, $name): bool
{
try {
$this->key = $this->getKey(self::KEY_COOKIE_PREFIX . $name);
}
catch (\Exception $e) {
trigger_error("Failed to get key: " . $e->getMessage(), E_USER_WARNING);
// The message below is sometimes seen. Log the key cookie value. It's usually because the cookie value is
// 'deleted'. This is because, as the PHP manual says in https://www.php.net/manual/en/function.setcookie.php,
// "Cookies must be deleted with the same parameters as they were set with. If the value argument is an empty
// string, and all other arguments match a previous call to setcookie(), then the cookie with the specified name
// will be deleted from the remote client. This is internally achieved by setting value to 'deleted' and
// expiration time in the past."
// However it's not clear why the cookie is being read, given that it has been deleted. And as it is being read
// can we do some kind of retry and set the key cookie with a proper key so that session_start() does not fail?
if (str_contains($e->getMessage(), 'Encoding::hexToBin() input is not a hex string'))
{
$key_cookie = $_COOKIE[self::KEY_COOKIE_PREFIX . $name];
trigger_error("Key cookie: $key_cookie");
}
return false;
}
return true;
}
// The return value (usually TRUE on success, FALSE on failure). Note this value is
// returned internally to PHP for processing.
public function close(): bool
{
return true;
}
// Returns an encoded string of the read data. If nothing was read, it must
// return an empty string. Note this value is returned internally to PHP for
// processing.
public function read($id)
{
global $dbsys;
// Acquire mutex to lock the session id. When using the default file session handler
// locks are obtained using flock(). We need to do something similar in order to prevent
// problems with multiple Ajax requests writing to the S_SESSION variable while
// another process is still using it.
// Acquire a lock
if (!db()->mutex_lock($id))
{
trigger_error("Failed to acquire a lock", E_USER_WARNING);
return '';
}
try
{
$sql = "SELECT data
FROM " . self::$table . "
WHERE id=:id
LIMIT 1";
$result = db()->query_scalar_non_bool($sql, array(':id' => $id));
}
catch (DBException $e)
{
// If the exception is because the sessions table doesn't exist, then that's
// probably because we're in the middle of the upgrade that creates the
// sessions table, so just ignore it and return ''. Otherwise, re-throw
// the exception.
if (!db()->table_exists(self::$table))
{
return '';
}
throw $e;
}
if (!isset($result) || ($result === false))
{
return '';
}
// TODO: fix this properly
// In PostgreSQL we store the session base64 encoded. That's because the session data string (encoded by PHP)
// can contain NULL bytes when the User object has protected properties. The solution is probably to convert
// the data column in PostgreSQL to be bytea rather than text. However this doesn't seem to work for some reason -
// no doubt soluble - and also upgrading the database is complicated while the roles branch is still under
// development and there are two sets of upgrades to be merged. So for the moment we have this rather inelegant
// workaround.
// NOTE: this step is probably not necessary anymore, now that the session data is encrypted.
if ($dbsys == 'pgsql')
{
$decoded = base64_decode($result, true);
// Test to see if the data is base64 encoded so that we can handle session data written before this change.
if (($decoded !== false) && (base64_encode($decoded) === $result))
{
$result = $decoded;
}
}
try {
$result = Crypto::decrypt($result, $this->key);
}
catch (WrongKeyOrModifiedCiphertextException $e) {
$message = $e->getMessage();
if (!str_contains($message, 'Ciphertext has invalid hex encoding'))
{
// This exception can be caused by (1) the wrong key being used, or (2) the cipher text having
// been modified or truncated. The message will generally be "Integrity check failed". None
// of these should normally happen. If the cipher text has been truncated, because it was too
// long for the database column, then we should have seen an SQL error when the session data
// was written.
//
// Sometimes the integrity check fails because the wrong key is being used. This can happen on
// some servers where access to $_COOKIE has been restricted somehow (though not because 'C' has
// been removed from variables_order in php.ini because then the session cookie doesn't work either).
// If $_COOKIE is not working, then MRBS will generate a new key, which will be different from the
// one used to encrypt the session data. If this is happening then set
// $auth["session_php"]["store_key_in_cookie"] = false;
// See https://github.com/meeting-room-booking-system/mrbs-code/issues/3983
trigger_error(get_class($e) . ': ' . $message, E_USER_WARNING);
$result = '';
}
// Otherwise do nothing. This is to handle the case where we are reading old session data before
// encryption was introduced, when the session data will almost certainly contain non-hex characters.
// So just return the undecrypted data from the database (because it was never encrypted in the first
// place).
}
catch (\Exception $e) {
trigger_error("Failed to decrypt session data: " . $e->getMessage(), E_USER_WARNING);
$result = '';
}
return $result;
}
// The return value (usually TRUE on success, FALSE on failure). Note this value is
// returned internally to PHP for processing.
public function write($id, $data): bool
{
global $dbsys;
try {
$data = Crypto::encrypt($data, $this->key);
}
catch (\Exception $e) {
trigger_error("Failed to encrypt session data: " . $e->getMessage(), E_USER_WARNING);
return false;
}
// See comment in read()
if ($dbsys == 'pgsql')
{
$data = base64_encode($data);
}
$query_data = array(
'id' => $id,
'data' => $data,
'access' => time()
);
$sql_params = array();
$sql = db()->syntax_upsert($query_data, self::$table, $sql_params, 'id');
// From the MySQL manual:
// "With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if the row is inserted as a
// new row, 2 if an existing row is updated, and 0 if an existing row is set to its current values.
// If you specify the CLIENT_FOUND_ROWS flag to the mysql_real_connect() C API function when connecting
// to mysqld, the affected-rows value is 1 (not 0) if an existing row is set to its current values."
return (0 < db()->command($sql, $sql_params));
}
// The return value (usually TRUE on success, FALSE on failure). Note this value is
// returned internally to PHP for processing.
public function destroy($id): bool
{
try
{
$sql = "DELETE FROM " . self::$table . " WHERE id=:id";
db()->command($sql, array(':id' => $id));
return true;
}
catch (\Exception $e)
{
return false;
}
}
// The return value (usually TRUE on success, FALSE on failure). Note this value is
// returned internally to PHP for processing.
public function gc($max_lifetime)
{
$sql = "DELETE FROM " . self::$table . " WHERE access<:old";
db()->command($sql, array(':old' => time() - $max_lifetime));
return true; // An exception will be thrown on error
}
// Need to provide this method to circumvent a bug in some versions of PHP.
// See https://github.com/php/php-src/issues/9668
public function validateId($id) : bool
{
// Acquire a lock
if (!db()->mutex_lock($id))
{
trigger_error("Failed to acquire a lock", E_USER_WARNING);
return false;
}
$sql = "SELECT COUNT(*)
FROM " . self::$table . "
WHERE id=:id
LIMIT 1";
return (db()->query1($sql, array(':id' => $id)) == 1);
}
// We only need to provide this method because it's part of SessionUpdateTimestampHandlerInterface
// which we are implementing in order to provide validateId().
public function updateTimestamp($id, $data) : bool
{
// Acquire a lock
if (!db()->mutex_lock($id))
{
trigger_error("Failed to acquire a lock", E_USER_WARNING);
return false;
}
try
{
$sql = "UPDATE " . self::$table . "
SET access=:access
WHERE id=:id";
$sql_params = array(
':id' => $id,
':access' => time()
);
$result = (1 === db()->command($sql, $sql_params));
}
catch(PDOException $e)
{
trigger_error($e->getMessage(), E_USER_WARNING);
$result = false;
}
// Release the mutex lock
db()->mutex_unlock($id);
return $result;
}
// Delete the key cookie, but only if the expiry is not zero.
public static function deleteKeyCookie() : void
{
if (false === ($name = session_name()))
{
return;
}
if (session_get_cookie_params()['lifetime'] !== 0)
{
$name = self::KEY_COOKIE_PREFIX . $name;
unset($_COOKIE[$name]);
Cookie::delete($name);
}
}
// Regenerate the key cookie, setting its expiry to be the same as the session cookie's.
public static function regenerateKeyCookie() : void
{
// No need to do anything if we can't get a session name, or if the expiry is zero (browser close).
if ((false === ($name = session_name())) || (0 === ($session_lifetime = session_get_cookie_params()['lifetime'])))
{
return;
}
// And no need to do anything if we can't get a cookie value.
$name = self::KEY_COOKIE_PREFIX . $name;
if (!isset($_COOKIE[$name]))
{
return;
}
// But otherwise, set the key cookie lifetime to be the same as the session cookie's.
Cookie::set($name, $_COOKIE[$name], time() + $session_lifetime);
}
private function getKey(string $name) : Key
{
global $auth;
if (!$auth["session_php"]["store_key_in_cookie"])
{
return Key::loadFromAsciiSafeString(self::DEFAULT_ASCII_KEY);
}
// Get the key from the cookie, or if there isn't one create a random key and
// store it in the cookie.
if (empty($_COOKIE[$name]))
{
$key = Key::createNewRandomKey();
$ascii_key = $key->saveToAsciiSafeString();
$session_lifetime = session_get_cookie_params()['lifetime'];
// Set the expiry to be the same as the session cookie expiry, or else 0 for browser close
Cookie::set($name, $ascii_key, ($session_lifetime > 0) ? time() + $session_lifetime : 0);
$_COOKIE[$name] = $ascii_key;
}
else
{
$key = Key::loadFromAsciiSafeString($_COOKIE[$name]);
}
return $key;
}
}
// Restore the original error reporting level
if (version_compare(MRBS_MIN_PHP_VERSION, '8.0.0') < 0)
{
error_reporting($old_level);
}
else
{
trigger_error("This code can now be removed", E_USER_NOTICE);
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MRBS\SessionHandler;
class SessionHandlerDbException extends \Exception
{
const TABLE_NOT_EXISTS = 1;
const NO_MULTIPLE_LOCKS = 2;
}