first commit
This commit is contained in:
96
backend/includes/Auth.php
Normal file
96
backend/includes/Auth.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentication — App Key Validation
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
class Auth
|
||||
{
|
||||
/**
|
||||
* Validate the app_key from request.
|
||||
* Supports both Flutter app key and Caller device key.
|
||||
*
|
||||
* @param string|null $key The key provided in request
|
||||
* @param string $required Which key type is required: 'app' | 'device' | 'any'
|
||||
* @return bool
|
||||
*/
|
||||
public static function validate(?string $key, string $required = 'any'): bool
|
||||
{
|
||||
if ($key === null || $key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($required) {
|
||||
case 'app':
|
||||
return hash_equals(APP_KEY, $key);
|
||||
case 'device':
|
||||
return hash_equals(DEVICE_KEY, $key);
|
||||
case 'any':
|
||||
return hash_equals(APP_KEY, $key) || hash_equals(DEVICE_KEY, $key);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract app_key from request (header or body).
|
||||
*/
|
||||
public static function getKeyFromRequest(): ?string
|
||||
{
|
||||
// Check header first
|
||||
$headerKey = $_SERVER['HTTP_X_APP_KEY']
|
||||
?? $_SERVER['HTTP_APP_KEY']
|
||||
?? null;
|
||||
|
||||
if ($headerKey !== null) {
|
||||
return $headerKey;
|
||||
}
|
||||
|
||||
// Check JSON body
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (is_array($body) && isset($body['app_key'])) {
|
||||
return $body['app_key'];
|
||||
}
|
||||
|
||||
// Check POST data
|
||||
if (isset($_POST['app_key'])) {
|
||||
return $_POST['app_key'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication — sends 401 and exits on failure.
|
||||
*/
|
||||
public static function requireAuth(string $required = 'any'): void
|
||||
{
|
||||
$key = self::getKeyFromRequest();
|
||||
if (!self::validate($key, $required)) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'invalid_app_key',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the provided key is the device key.
|
||||
*/
|
||||
public static function isDeviceKey(?string $key): bool
|
||||
{
|
||||
return $key !== null && hash_equals(DEVICE_KEY, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the provided key is the app key.
|
||||
*/
|
||||
public static function isAppKey(?string $key): bool
|
||||
{
|
||||
return $key !== null && hash_equals(APP_KEY, $key);
|
||||
}
|
||||
}
|
||||
49
backend/includes/Database.php
Normal file
49
backend/includes/Database.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Singleton — PDO Connection
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=utf8mb4',
|
||||
DB_HOST,
|
||||
DB_NAME
|
||||
);
|
||||
|
||||
self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+03:00'",
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('Database connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'database_error']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/** Prevent cloning */
|
||||
private function __clone() {}
|
||||
public function __wakeup()
|
||||
{
|
||||
throw new \Exception("Cannot unserialize singleton");
|
||||
}
|
||||
}
|
||||
61
backend/includes/Logger.php
Normal file
61
backend/includes/Logger.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* Request Logger — Logs all API requests to MySQL
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/Database.php';
|
||||
|
||||
class RequestLogger
|
||||
{
|
||||
/**
|
||||
* Log an API request.
|
||||
*/
|
||||
public static function log(
|
||||
string $endpoint,
|
||||
string $method,
|
||||
?array $requestBody = null,
|
||||
int $responseCode = 200,
|
||||
?string $error = null
|
||||
): void {
|
||||
if (!LOG_REQUESTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
|
||||
$body = $requestBody ? json_encode($requestBody) : null;
|
||||
|
||||
// Mask sensitive fields
|
||||
if ($body) {
|
||||
$body = self::maskSensitive($body);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO api_logs (endpoint, method, ip_address, user_agent, request_body, response_code, error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())"
|
||||
);
|
||||
$stmt->execute([$endpoint, $method, $ip, $userAgent, $body, $responseCode, $error]);
|
||||
} catch (\Throwable $e) {
|
||||
// Logging should never break the app
|
||||
error_log("RequestLogger error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive fields in request body.
|
||||
*/
|
||||
private static function maskSensitive(string $body): string
|
||||
{
|
||||
$sensitive = ['app_key', 'password', 'otp', 'otp_code'];
|
||||
foreach ($sensitive as $field) {
|
||||
$body = preg_replace(
|
||||
'/"' . $field . '"\s*:\s*"[^"]*"/',
|
||||
'"' . $field . '":"***"',
|
||||
$body
|
||||
);
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
62
backend/includes/RateLimit.php
Normal file
62
backend/includes/RateLimit.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiting via Redis
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/Redis.php';
|
||||
|
||||
class RateLimit
|
||||
{
|
||||
private \Redis $redis;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->redis = RedisClient::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and increment rate limit counter.
|
||||
*
|
||||
* @param string $key Identifier (e.g. "otp:+9627XXXXXXXX")
|
||||
* @param int $limit Max requests allowed
|
||||
* @param int $window Time window in seconds
|
||||
* @return bool true = allowed, false = rate limited
|
||||
*/
|
||||
public function check(string $key, int $limit = RATE_LIMIT_MAX, int $window = RATE_LIMIT_WINDOW): bool
|
||||
{
|
||||
return true; // Disabled for stress testing
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for a key.
|
||||
*/
|
||||
public function remaining(string $key, int $limit = RATE_LIMIT_MAX): int
|
||||
{
|
||||
$redisKey = "rate_limit:{$key}";
|
||||
$current = (int) $this->redis->get($redisKey);
|
||||
return max(0, $limit - $current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTL of rate limit key.
|
||||
*/
|
||||
public function ttl(string $key): int
|
||||
{
|
||||
$redisKey = "rate_limit:{$key}";
|
||||
return max(0, (int) $this->redis->ttl($redisKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* General IP-based rate limiting for API endpoints.
|
||||
*
|
||||
* @param string $ip Client IP
|
||||
* @param string $endpoint Endpoint name
|
||||
* @param int $limit Max requests
|
||||
* @param int $window Time window in seconds
|
||||
* @return bool
|
||||
*/
|
||||
public function checkIp(string $ip, string $endpoint, int $limit = 60, int $window = 60): bool
|
||||
{
|
||||
return $this->check("ip:{$endpoint}:{$ip}", $limit, $window);
|
||||
}
|
||||
}
|
||||
55
backend/includes/Redis.php
Normal file
55
backend/includes/Redis.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* Redis Singleton — phpredis Extension
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
class RedisClient
|
||||
{
|
||||
private static ?\Redis $instance = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
try {
|
||||
self::$instance = new \Redis();
|
||||
self::$instance->connect(REDIS_HOST, REDIS_PORT, 2.0);
|
||||
|
||||
if (REDIS_PASSWORD !== null) {
|
||||
self::$instance->auth(REDIS_PASSWORD);
|
||||
}
|
||||
|
||||
if (REDIS_DB > 0) {
|
||||
self::$instance->select(REDIS_DB);
|
||||
}
|
||||
|
||||
self::$instance->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_JSON);
|
||||
} catch (\RedisException $e) {
|
||||
$errorMsg = $e->getMessage();
|
||||
error_log('Redis connection failed: ' . $errorMsg);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]);
|
||||
exit;
|
||||
} catch (\Exception $e) {
|
||||
$errorMsg = $e->getMessage();
|
||||
error_log('Unexpected error in Redis: ' . $errorMsg);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): \Redis
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __clone() {}
|
||||
public function __wakeup()
|
||||
{
|
||||
throw new \Exception("Cannot unserialize singleton");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user