Files
api/services/nginx/app/objects/subusers_o.php
T

387 lines
15 KiB
PHP

<?php
namespace objects;
use classes\authentication;
use classes\db;
use classes\object_property;
use Exception;
use modules\subusers\classes\subuser_user_grant;
use modules\subusers\helpers\subusers_permission_node_key;
use Random\RandomException;
use traits\db_object_t;
class subusers_o extends db
{
use db_object_t;
public const PASSWORD_MIN_LENGTH = 8;
public const PASSWORD_MAX_LENGTH = 255;
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
public object_property $username;
public object_property $password;
public object_property $name;
public object_property $email;
public object_property $phone_country_code;
public object_property $phone;
public object_property $two_factor_secret;
public object_property $two_factor_enabled;
public object_property $created_at;
public object_property $updated_at;
public object_property $suspended_at;
public function structure(): void
{
$this->setTable('subusers');
}
public function objectChanged(): void
{
// No need to invalidate the cache, since the plate_scans object is not cached
}
public function getObjectProperties(): void
{
$this->username = new object_property($this->table, $this->id, 'username', 'string');
$this->password = new object_property($this->table, $this->id, 'password', 'string');
$this->name = new object_property($this->table, $this->id, 'name', 'string');
$this->email = new object_property($this->table, $this->id, 'email', 'string');
$this->phone_country_code = new object_property($this->table, $this->id, 'phone_country_code', 'int');
$this->phone = new object_property($this->table, $this->id, 'phone', 'int');
$this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false);
$this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp');
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp');
$this->suspended_at = new object_property($this->table, $this->id, 'suspended_at', 'timestamp');
}
/**
* @throws Exception
*/
public function isTwoFactorEnabled(): bool
{
self::requireSelected();
return (bool)$this->two_factor_enabled->value();
}
/**
* @throws Exception
*/
public static function assertValidPassword(string $password): void
{
if (
strlen($password) < self::PASSWORD_MIN_LENGTH
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|| !preg_match(self::PASSWORD_PATTERN, $password)
) {
throw new Exception(
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
);
}
}
/**
* @throws Exception
*/
public function getTwoFactorSecret(): string|null
{
self::requireSelected();
return $this->two_factor_secret->value();
}
/**
* @throws Exception
*/
public function setTwoFactorSecret(string|null $secret): void
{
self::requireSelected();
$this->two_factor_secret->set($secret);
}
/**
* @throws Exception
*/
public function setTwoFactorEnabled(bool $enabled): void
{
self::requireSelected();
$this->two_factor_enabled->set($enabled);
}
/**
* Add a new subuser to the database. The password is optional, but if it is set, it must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number. The password will be hashed before being stored in the database.
* @param string|null $username
* @param string|null $password
* @param string|null $name
* @param string|null $email
* @param int $phone_country_code
* @param int $phone
* @return $this
*/
public function add(?string $username, ?string $password, ?string $name, ?string $email, int $phone_country_code, int $phone): subusers_o
{
global $db, $response;
try {
$passwordWasProvided = !empty($password);
if ($passwordWasProvided) {
self::assertValidPassword($password);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
}
if (!empty($email)) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email address.');
}
$email = $db->escape_string($email);
}
if (!empty($username)) {
if (strlen($username) < 3 || strlen($username) > 50) {
throw new Exception('Username must be between 3 and 50 characters long.');
}
$username = $db->escape_string($username);
}
if (!empty($name)) {
if (strlen($name) < 2 || strlen($name) > 100) {
throw new Exception('Name must be between 2 and 100 characters long.');
}
$name = $db->escape_string($name);
}
// Escape the other parameters
$phone_country_code = $db->escape_string($phone_country_code);
$phone = $db->escape_string($phone);
// Create a new record in the database
$tmp = $this->add_object([
...(!empty($username) ? ['username' => $username] : []),
...(!empty($name) ? ['name' => $name] : []),
...(!empty($email) ? ['email' => $email] : []),
'phone_country_code' => (int)$phone_country_code,
'phone' => (int)$phone,
// Add the password only if it is set
...(!empty($password) ? ['password' => (string)$password] : []),
]);
// Get the id of the new record
$this->id = (int)$tmp;
// Set the values of the object properties
$this->getObjectProperties();
return $this;
} catch (Exception $e) {
$response->error($e->getMessage());
throw $e;
}
}
/**
* Set the password for the subuser. The password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number. The password will be hashed before being stored in the database.
* @param string $password
* @return self
* @throws Exception If the password does not meet the requirements or if no subuser is selected.
*/
public function setPassword(string $password): self
{
self::requireSelected();
self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this;
}
public function getSubuserByPhone(int $phone_country_code, int $phone): ?subusers_o
{
$tmp = self::getFieldsWhere([
'phone_country_code' => $phone_country_code,
'phone' => $phone,
], ['id']);
if (count($tmp) === 0) {
return null;
}
$subuser = (new subusers_o())->select((int)$tmp[0]['id']);
$subuser->getObjectProperties();
return $subuser;
}
/**
* Generate a setup token for the subuser. The setup token is a random string that can be used to link the subuser to a company user. The setup token is valid for 24 hours and can only be used once.
* @return string The setup token
* @throws RandomException If there was an error generating the random bytes for the token
* @throws Exception If no subuser is selected
*/
public function generateSetupToken(): string
{
self::requireSelected();
try {
$token = bin2hex(random_bytes(16));
} catch (Exception $e) {
throw new RandomException('Error generating random bytes for setup token', 0, $e);
}
$cache_key = 'setup_token:' . $token;
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
$cache_object_id = 'subuser_setup_token';
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
if (is_string($existingToken) && $existingToken !== '') {
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
}
$this->cache($cache_key, $this->id, $cache_object_id);
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
$this->cache($reverse_cache_key, $token, $cache_object_id);
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
return $token;
}
/**
* Get the subuser id by the setup token. If the token is valid, it will return the subuser id and delete the token from the cache. If the token is invalid or expired, it will return null.
* @param string $token The setup token
* @return int|null The subuser id or null if the token is invalid or expired
* @throws Exception If there was an error getting the subuser id from the cache
*/
public function getSubuserIdBySetupToken(string $token): ?int
{
$cache_key = 'setup_token:' . $token;
$cache_object_id = 'subuser_setup_token';
$subuser_id = $this->getCached($cache_key, $cache_object_id);
if ($subuser_id === null) {
return null;
}
return $subuser_id;
}
/**
* Get the subuser by the setup token. If the token is valid, it will return the subuser object and delete the token from the cache. If the token is invalid or expired, it will return null.
* @param string $token The setup token
* @return subusers_o|null The subuser object or null if the token is invalid or expired
* @throws Exception If there was an error getting the subuser object from the cache
*/
public function getSubuserBySetupToken(string $token): ?subusers_o
{
$subuser_id = $this->getSubuserIdBySetupToken($token);
if ($subuser_id === null) {
return null;
}
$subuser = (new subusers_o())->select((int)$subuser_id);
$subuser->getObjectProperties();
return $subuser;
}
public function invalidateSetupToken(string $token): void
{
$object_id = 'subuser_setup_token';
$subuser_id = $this->getSubuserIdBySetupToken($token);
$cache_key = 'setup_token:' . $token;
$this->deleteCached($cache_key, $object_id);
if ($subuser_id !== null) {
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
$currentToken = $this->getCached($reverse_cache_key, $object_id);
if ((string)$currentToken === $token) {
$this->deleteCached($reverse_cache_key, $object_id);
}
}
}
/**
* @throws Exception
*/
public function getSubuserByUsername(string $username): ?subusers_o
{
global $db;
$username = $db->escape_string($username);
$tmp = self::getFieldsWhere([
'username' => $username,
], ['id']);
if (count($tmp) === 0) {
return null;
}
$subuser = (new subusers_o())->select((int)$tmp[0]['id']);
$subuser->getObjectProperties();
return $subuser;
}
/**
* @throws Exception
*/
public function getSubuserByEmail(string $email): ?subusers_o
{
global $db;
$email = $db->escape_string($email);
$tmp = self::getFieldsWhere([
'email' => $email,
], ['id']);
if (count($tmp) === 0) {
return null;
}
$subuser = (new subusers_o())->select((int)$tmp[0]['id']);
$subuser->getObjectProperties();
return $subuser;
}
/**
* @throws Exception
*/
public function requiresSetup(): bool
{
self::requireSelected();
$password = $this->password->value();
return !is_string($password) || trim($password) === '';
}
/**
* @throws RandomException
* @throws Exception
*/
public function generateSession(): string
{
self::requireSelected();
$session_token = bin2hex(random_bytes(32));
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
return $session_token;
}
public function invalidateSessionToken(string $token): void
{
$this->deleteCached('session_token:' . $token, 'subuser_sessions');
}
/**
* @param string $token The session token
* @return subusers_o|null The subuser object or null if the token is invalid or expired
* @throws Exception
*/
public function getSubuserBySessionToken(string $token): ?subusers_o
{
$cache_key = 'session_token:' . $token;
$cache_object_id = 'subuser_sessions';
$subuser_id = $this->getCached($cache_key, $cache_object_id);
if ($subuser_id !== null) {
$subuser = (new subusers_o())->select((int)$subuser_id);
$subuser->getObjectProperties();
return $subuser;
}
return null;
}
/**
* Check if the selected subuser has a permission related to a specific customer number.
* @param subusers_permission_node_key $permission_node_key The permission node key to check
* @param int|null $customer_number The customer number to check the permission for
* @return bool True if the subuser has the permission, false otherwise
* @throws Exception
*/
public function hasPermission(subusers_permission_node_key $permission_node_key, ?int $customer_number = null): bool
{
// Ensure a subuser is selected
self::requireSelected();
// If the customer number is not provided, we should get it from the request context.
$customer_number = $customer_number ?? (new authentication())->get_subuser_customer_number_target();
// If the customer number is still null, we cannot evaluate permissions, so we throw an exception.
if ($customer_number === null) {
throw new Exception('X-Customer-Number is required to evaluate permissions');
}
// Evaluate permission using the subusers grant system
$grant = new subuser_user_grant((int)$this->id, (int)$customer_number);
return $grant->hasNode($permission_node_key);
}
}