Add Redis-based permission caching for users and subusers
- Introduced Redis-backed caching for user and subuser permission evaluations in the `route_t` trait, reducing database queries. - Enhanced `Redis` class with methods for permission caching: `cache_permission`, `get_permission`, and `clear_permission`. - Added test coverage for the new caching logic in `PermissionRedisCacheTest.php`. - Implemented Redis caching for authentication sessions with `cache_auth_session`, `get_auth_session`, and `clear_auth_session`. - Improved CORS handling for preflight requests in `index.php`.
This commit is contained in:
@@ -332,6 +332,34 @@ class redis implements redis_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache auth session payload for a token with TTL
|
||||
*/
|
||||
public function cache_auth_session(string $token, array $data, int $ttl = 60): self
|
||||
{
|
||||
$key = 'auth_session_' . $token;
|
||||
$this->set_array($key, $data);
|
||||
$this->expire($key, $ttl);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached auth session payload by token
|
||||
*/
|
||||
public function get_auth_session(string $token): array|null
|
||||
{
|
||||
return $this->get_array('auth_session_' . $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached auth session payload by token
|
||||
*/
|
||||
public function clear_auth_session(string $token): self
|
||||
{
|
||||
$this->delete('auth_session_' . $token);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -427,4 +455,34 @@ class redis implements redis_i
|
||||
// Get multiple keys from Redis
|
||||
return $this->redis->mget($array_map);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_permission(string $cache_key, bool $allowed, int $ttl = 300): self
|
||||
{
|
||||
$this->setEx('perm:' . $cache_key, $allowed ? '1' : '0', $ttl);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_permission(string $cache_key): bool|null
|
||||
{
|
||||
$val = $this->get('perm:' . $cache_key);
|
||||
if ($val === null) {
|
||||
return null;
|
||||
}
|
||||
return $val === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_permission(string $cache_key): self
|
||||
{
|
||||
$this->delete('perm:' . $cache_key);
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,33 @@ ini_set('zlib.output_compression', false);
|
||||
* This is the main entry point to the Truck Wash API.
|
||||
*/
|
||||
const WD = __DIR__;
|
||||
|
||||
/** CORS */
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: *');
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
require_once 'config.php';
|
||||
/** Debug */
|
||||
if ($DEBUG) {
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
} else {
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('display_startup_errors', 0);
|
||||
error_reporting(0);
|
||||
}
|
||||
|
||||
/** CORS */
|
||||
header("Access-Control-Allow-Origin: $CORS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
|
||||
/** Autoload */
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
|
||||
@@ -266,4 +266,50 @@ interface redis_i
|
||||
* @return self
|
||||
*/
|
||||
public function clear_customer_number_from_user_id(int $user_id): self;
|
||||
|
||||
/**
|
||||
* Cache an auth session payload for a given token
|
||||
* @param string $token
|
||||
* @param array $session
|
||||
* @param int $ttl Seconds to keep in cache
|
||||
* @return self
|
||||
*/
|
||||
public function cache_auth_session(string $token, array $session, int $ttl = 60): self;
|
||||
|
||||
/**
|
||||
* Get a cached auth session payload by token
|
||||
* @param string $token
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_auth_session(string $token): array|null;
|
||||
|
||||
/**
|
||||
* Clear a cached auth session payload by token
|
||||
* @param string $token
|
||||
* @return self
|
||||
*/
|
||||
public function clear_auth_session(string $token): self;
|
||||
|
||||
/**
|
||||
* Cache a permission evaluation
|
||||
* @param string $cache_key
|
||||
* @param bool $allowed
|
||||
* @param int $ttl
|
||||
* @return self
|
||||
*/
|
||||
public function cache_permission(string $cache_key, bool $allowed, int $ttl = 300): self;
|
||||
|
||||
/**
|
||||
* Get a cached permission evaluation
|
||||
* @param string $cache_key
|
||||
* @return bool|null
|
||||
*/
|
||||
public function get_permission(string $cache_key): bool|null;
|
||||
|
||||
/**
|
||||
* Clear a cached permission evaluation
|
||||
* @param string $cache_key
|
||||
* @return self
|
||||
*/
|
||||
public function clear_permission(string $cache_key): self;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ class subusers_o extends db
|
||||
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;
|
||||
|
||||
@@ -46,8 +46,21 @@ class authRoute
|
||||
if (!isset($data['password']) || empty($data['password']) || strlen($data['password']) < 1) {
|
||||
$response->error('Password is required', 400);
|
||||
}
|
||||
// Try to log the user in
|
||||
$isCredentialsValid = (new authentication())->authenticate($data['customer_number'], $data['password']);
|
||||
|
||||
// Use Redis-backed user and property caches to validate credentials with a single user load
|
||||
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
|
||||
if (!$user->exists() || !$user->hasPassword()) {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
$isCredentialsValid = false;
|
||||
try {
|
||||
$isCredentialsValid = $user->passwordMatches($data['password']);
|
||||
} catch (Exception $e) {
|
||||
$isCredentialsValid = false;
|
||||
}
|
||||
|
||||
// Log the incident
|
||||
if ($isCredentialsValid) {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
|
||||
@@ -55,14 +68,15 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
// If the credentials are valid, create a token
|
||||
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
|
||||
|
||||
// If the credentials are valid, check 2FA and create a token
|
||||
if ($user->exists() && $user->isTwoFactorEnabled()) {
|
||||
$token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER');
|
||||
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
||||
}
|
||||
|
||||
$token = (new authentication())->create_token($data['customer_number']);
|
||||
// Create the auth token without reloading the user from DB
|
||||
$token = (new authentication())->create_token_by_user_id((int)$user->id);
|
||||
// Return the token
|
||||
$response->success(['token' => $token]);
|
||||
});
|
||||
@@ -79,6 +93,8 @@ class authRoute
|
||||
}
|
||||
// Delete the token
|
||||
(new tokens_o())->delete($token);
|
||||
// Clear any cached session for this token
|
||||
try { redis->clear_auth_session($token); } catch (\Throwable $e) {}
|
||||
// Return a success message
|
||||
$response->success(['message' => 'Logged out']);
|
||||
});
|
||||
@@ -93,6 +109,17 @@ class authRoute
|
||||
if (!(new authentication())->validate_token($token)) {
|
||||
$response->error('Invalid token', 401);
|
||||
}
|
||||
|
||||
// Try Redis cache first for session payload
|
||||
try {
|
||||
$cached = redis->get_auth_session($token);
|
||||
if (is_array($cached)) {
|
||||
$response->success($cached);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore Redis errors and continue to compute session
|
||||
}
|
||||
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the user exists
|
||||
@@ -103,6 +130,13 @@ class authRoute
|
||||
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
|
||||
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
|
||||
|
||||
// Cache the session payload briefly to reduce DB load on hot paths
|
||||
try {
|
||||
redis->cache_auth_session($token, $user_data, 60);
|
||||
} catch (\Throwable $e) {
|
||||
// Best-effort caching only
|
||||
}
|
||||
|
||||
// Return the (session) user object
|
||||
$response->success($user_data);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
namespace {
|
||||
if (!defined('WD')) {
|
||||
define('WD', dirname(__DIR__, 2));
|
||||
}
|
||||
$_SERVER['REQUEST_URI'] = '/test';
|
||||
}
|
||||
|
||||
// Setup mock environment
|
||||
namespace classes {
|
||||
class authentication {
|
||||
public static $user = false;
|
||||
public static $subuser = false;
|
||||
public function get_user() { return self::$user; }
|
||||
public function get_subuser() { return self::$subuser; }
|
||||
}
|
||||
class response {
|
||||
public function error($msg, $code) { throw new \Exception("Response Error ($code): $msg"); }
|
||||
public function add_meta($k, $v) {}
|
||||
}
|
||||
class permission_node {
|
||||
public $permission;
|
||||
public $subusers_node_key;
|
||||
public static function create($p, $k = null) {
|
||||
$n = new self();
|
||||
$n->permission = $p;
|
||||
$n->subusers_node_key = $k;
|
||||
return $n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace objects {
|
||||
class logs_o {
|
||||
public function add($a, $b, $c, $d, $e, $f) {}
|
||||
}
|
||||
class users_o {
|
||||
public $id = 1;
|
||||
public $group_id;
|
||||
public function __construct() {
|
||||
$this->group_id = new class { public function value() { return 2; } };
|
||||
}
|
||||
public function hasPermission($p) {
|
||||
echo "[DB] Checking permission: $p\n";
|
||||
return $p === 'allowed_perm';
|
||||
}
|
||||
}
|
||||
class subusers_o {
|
||||
public $id = 10;
|
||||
public function hasPermission($node) {
|
||||
echo "[DB] Checking subuser node: " . $node->name . "\n";
|
||||
return $node->name === 'ALLOWED_NODE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Include Redis classes first so we can mock REDIS_CONFIG
|
||||
global $REDIS_CONFIG;
|
||||
$REDIS_CONFIG = [
|
||||
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
|
||||
'database' => 0,
|
||||
'password' => ''
|
||||
];
|
||||
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/interfaces/redis_i.php';
|
||||
require_once WD . '/traits/redis_t.php';
|
||||
require_once WD . '/classes/redis.php';
|
||||
require_once WD . '/traits/route_t.php';
|
||||
|
||||
// Set the redis constant
|
||||
global $response;
|
||||
$response = new classes\response();
|
||||
|
||||
try {
|
||||
$redis_instance = (new classes\redis())->connect();
|
||||
if (!defined('redis')) {
|
||||
define("redis", $redis_instance);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die("Could not connect to Redis: " . $e->getMessage());
|
||||
}
|
||||
|
||||
use traits\route_t;
|
||||
use classes\authentication;
|
||||
use classes\permission_node;
|
||||
use objects\users_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class TestRoute {
|
||||
use route_t;
|
||||
public function testEval($p, $c = null, $t = false) {
|
||||
return $this->evaluatePermission($p, $c, $t);
|
||||
}
|
||||
// Mocking resolveCustomerNumberForSubuser as it's private and uses headers
|
||||
private function resolveCustomerNumberForSubuser($auth, ?int $customer_number = null): ?int {
|
||||
return $customer_number ?? 999;
|
||||
}
|
||||
}
|
||||
|
||||
$route = new TestRoute();
|
||||
|
||||
function assertResult($actual, $expected, $msg) {
|
||||
if ($actual === $expected) {
|
||||
echo "\033[32m✔ $msg\033[0m\n";
|
||||
} else {
|
||||
echo "\033[31m✖ $msg (Expected " . var_export($expected, true) . ", got " . var_export($actual, true) . ")\033[0m\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Test classic user permission caching
|
||||
echo "--- Testing Classic User Permission Caching ---\n";
|
||||
$user = new users_o();
|
||||
authentication::$user = $user;
|
||||
|
||||
// Clear previous cache if any
|
||||
redis->clear_permission("user:1:allowed_perm");
|
||||
redis->clear_permission("user:1:denied_perm");
|
||||
|
||||
echo "First call (should hit DB):\n";
|
||||
assertResult($route->testEval('allowed_perm'), true, "User should have allowed_perm");
|
||||
|
||||
echo "Second call (should hit memory cache, no DB output expected):\n";
|
||||
assertResult($route->testEval('allowed_perm'), true, "User should still have allowed_perm (from memory)");
|
||||
|
||||
// Clear memory cache by creating new route object
|
||||
$route = new TestRoute();
|
||||
echo "Third call after new object (should hit Redis, no DB output expected):\n";
|
||||
assertResult($route->testEval('allowed_perm'), true, "User should still have allowed_perm (from Redis)");
|
||||
|
||||
// Test denied perm
|
||||
echo "\nTesting denied perm:\n";
|
||||
assertResult($route->testEval('denied_perm'), false, "User should NOT have denied_perm");
|
||||
$route = new TestRoute();
|
||||
echo "Call after new object (should hit Redis, no DB output expected):\n";
|
||||
assertResult($route->testEval('denied_perm'), false, "User should still NOT have denied_perm (from Redis)");
|
||||
|
||||
// 2. Test subuser node caching
|
||||
echo "\n--- Testing Subuser Node Caching ---\n";
|
||||
$subuser = new subusers_o();
|
||||
authentication::$subuser = $subuser;
|
||||
|
||||
$allowedNode = new stdClass(); $allowedNode->name = 'ALLOWED_NODE';
|
||||
$deniedNode = new stdClass(); $deniedNode->name = 'DENIED_NODE';
|
||||
|
||||
$allowedPermNode = permission_node::create('allowed_node_perm', $allowedNode);
|
||||
$deniedPermNode = permission_node::create('denied_node_perm', $deniedNode);
|
||||
|
||||
redis->clear_permission("subuser:10:999:ALLOWED_NODE");
|
||||
redis->clear_permission("subuser:10:999:DENIED_NODE");
|
||||
|
||||
echo "First call (should hit DB):\n";
|
||||
assertResult($route->testEval($allowedPermNode), true, "Subuser should have ALLOWED_NODE");
|
||||
|
||||
$route = new TestRoute();
|
||||
echo "Second call after new object (should hit Redis, no DB output expected):\n";
|
||||
assertResult($route->testEval($allowedPermNode), true, "Subuser should still have ALLOWED_NODE (from Redis)");
|
||||
|
||||
echo "\nTesting denied node:\n";
|
||||
assertResult($route->testEval($deniedPermNode), false, "Subuser should NOT have DENIED_NODE");
|
||||
$route = new TestRoute();
|
||||
echo "Call after new object (should hit Redis, no DB output expected):\n";
|
||||
assertResult($route->testEval($deniedPermNode), false, "Subuser should still NOT have DENIED_NODE (from Redis)");
|
||||
|
||||
echo "\nPermissionRedisCacheTest completed successfully.\n";
|
||||
}
|
||||
@@ -346,7 +346,25 @@ trait route_t
|
||||
}
|
||||
// Expose resolved target customer in response meta for subuser requests
|
||||
$response->add_meta('target_customer_number', (int)$resolvedCustomer);
|
||||
$subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key);
|
||||
|
||||
$cacheKey = "subuser:{$subuser->id}:{$resolvedCustomer}:{$permission->subusers_node_key->name}";
|
||||
if (!isset(self::$__perm_subuser_grant_cache[$cacheKey])) {
|
||||
$cached = null;
|
||||
if (defined('redis')) {
|
||||
$cached = redis->get_permission($cacheKey);
|
||||
}
|
||||
if ($cached !== null) {
|
||||
self::$__perm_subuser_grant_cache[$cacheKey] = $cached;
|
||||
} else {
|
||||
$subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key);
|
||||
if (defined('redis')) {
|
||||
redis->cache_permission($cacheKey, $subuser_has_permission);
|
||||
}
|
||||
self::$__perm_subuser_grant_cache[$cacheKey] = $subuser_has_permission;
|
||||
}
|
||||
}
|
||||
$subuser_has_permission = self::$__perm_subuser_grant_cache[$cacheKey];
|
||||
|
||||
if (!$subuser_has_permission && $throwOnDeny) {
|
||||
(new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied via subuser node: ' . $permission->permission . ' (Node: ' . $permission->subusers_node_key->name . ', Customer: ' . $resolvedCustomer . ')');
|
||||
$response->error('Permission denied for subuser. Missing permission: ' . $permission->permission . ' (Customer context: ' . $resolvedCustomer . ')', 403);
|
||||
@@ -365,10 +383,25 @@ trait route_t
|
||||
}
|
||||
$perm_string = $permission instanceof permission_node ? $permission->permission : $permission;
|
||||
$userCacheKey = (string)$user->id . ':' . $perm_string;
|
||||
$redisKey = "user:{$user->id}:{$perm_string}";
|
||||
|
||||
if (!isset(self::$__perm_user_cache[$userCacheKey])) {
|
||||
self::$__perm_user_cache[$userCacheKey] = (bool)$user->hasPermission($perm_string);
|
||||
$cached = null;
|
||||
if (defined('redis')) {
|
||||
$cached = redis->get_permission($redisKey);
|
||||
}
|
||||
if ($cached !== null) {
|
||||
self::$__perm_user_cache[$userCacheKey] = $cached;
|
||||
} else {
|
||||
$allowed = (bool)$user->hasPermission($perm_string);
|
||||
if (defined('redis')) {
|
||||
redis->cache_permission($redisKey, $allowed);
|
||||
}
|
||||
self::$__perm_user_cache[$userCacheKey] = $allowed;
|
||||
}
|
||||
}
|
||||
$allowed = (bool)self::$__perm_user_cache[$userCacheKey];
|
||||
|
||||
if (!$allowed && $throwOnDeny) {
|
||||
(new logs_o())->add('global', 'global', 1, $user->id, 'PERMISSION_DENIED', 'Permission denied. Missing permission: ' . $perm_string);
|
||||
$response->error('Permission denied. Missing permission: ' . $perm_string . ' for user: ' . $user->id . ' In group: ' . $user->group_id->value(), 403);
|
||||
|
||||
Reference in New Issue
Block a user