Add SportPath auth APIs and training content foundation

This commit is contained in:
Hamza-Ayed
2026-10-04 00:49:48 +03:00
parent e7f3c777d1
commit 5b90da6b18
23 changed files with 1451 additions and 238 deletions
+36
View File
@@ -0,0 +1,36 @@
# مصادقة SportPath — API v1
هذه الواجهات تأسيس أولي لمصادقة رقم الهاتف. لا تشغّل إرسال OTP قبل إعداد مزود حقيقي ومراجعة عقده على بيئة sandbox. لا يخرج الرمز أو المفاتيح في response أو logs.
## إعداد الخادم
ضع القيم في ملف البيئة الخاص خارج مجلد `public/`:
```dotenv
JWT_SIGNING_KEY=<random secret, at least 32 bytes>
JWT_ACCESS_TTL_SECONDS=900
JWT_REFRESH_TTL_DAYS=30
OTP_PROVIDER=generic_json
OTP_API_URL=https://provider.example/api/send
OTP_API_KEY=<server-only token>
OTP_SENDER_ID=<approved sender>
OTP_MESSAGE_TEMPLATE=رمز التحقق الخاص بك هو {code}
OTP_ENABLED=true
OTP_HASH_KEY=<independent random secret, at least 32 bytes>
```
المحول الحالي يرسل JSON بالشكل `{"to":"+...","sender":"...","message":"..."}` مع `Authorization: Bearer ...`، ويعد أي HTTP 2xx نجاحًا. هذا عقد عام مؤقت وليس افتراضًا عن أي مزود؛ عدّل `ConfiguredHttpOtpProvider` ليتوافق مع توثيق المزود الفعلي قبل تفعيل الإنتاج. يرفض endpoint العناوين غير HTTPS. في حال غياب الإعداد يبقى الطلب مغلقًا ويرجع 503.
## المسارات
- `POST /api/v1/auth/request-otp.php`: JSON `{ "phone_e164": "+962…", "purpose": "register|login", "device_uuid": "UUID" }`. يرد `challenge_id` ومدة الصلاحية. حد الإرسال الحالي 5 لكل رقم/ساعة، 20 لكل IP/ساعة، و10 لكل device/ساعة؛ المحاولات لكل تحدٍ 5 خلال 5 دقائق.
- `POST /api/v1/auth/verify-otp.php`: JSON `{ "challenge_id": "UUID", "code": "123456", "display_name": "…", "device_uuid": "UUID", "platform": "ios|android|web" }`. يستهلك التحدي مرة واحدة ويصدر JWT وrefresh token عشوائيًا. يجب حفظ refresh token في مخزن آمن على الجهاز، وعدم تسجيل أي token.
- `POST /api/v1/auth/refresh.php`: JSON `{ "refresh_token": "…" }`. تدوير الرمز يصدر refresh جديدًا؛ إعادة استخدام رمز سبق تدويره تبطل عائلة الجلسة.
- `POST /api/v1/auth/logout.php`: يتطلب `Authorization: Bearer <access-token>` ويبطل الجلسة الحالية.
- `/api/v1/admin/settings.php`: يتطلب bearer token ودور `owner` أو `content_manager`؛ GET للقراءة وPATCH للتعديل مع audit trail.
كل Access Token قصير العمر ويرتبط بجلسة حية في MySQL. الدور وحالة الحساب يعاد التحقق منهما من قاعدة البيانات لكل طلب محمي. لا تُقبل معرفات عتادية ثابتة؛ `device_uuid` معرّف تثبيت عشوائي. ترقية أول مالك تتم يدويًا على قاعدة الخادم بعد التحقق من رقم الهاتف، ولا توجد واجهة bootstrap عامة لمنح دور owner.
## ما لم يكتمل بعد
هذا API غير مربوط بعد بتطبيق Flutter، ونقاط التتبع القديمة ما زالت تستخدم HMAC الانتقالي. قبل الإنتاج يجب نقل التطبيق إلى OTP/refresh الآمن، ثم إيقاف بيانات HMAC القديمة أو حصرها بفترة انتقال معلومة. لم يُختبر اتصال MySQL الفعلي أو إرسال SMS؛ فحوص PHP المتاحة حتى الآن ساكنة فقط. نفّذ migration `001_phone_auth_and_sessions.sql` على قاعدة staging احتياطية أولًا، وتحقق من rate limits والتدوير والإبطال مع مزود OTP في sandbox.
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
final class ApiAuth
{
public static function bearerClaims(): array
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+([A-Za-z0-9_.-]+)$/i', $header, $matches)) {
api_json(['error' => 'unauthorized'], 401);
}
$claims = JwtToken::verify($matches[1]);
if ($claims === null) {
api_json(['error' => 'unauthorized'], 401);
}
try {
$db = Database::getInstance();
$stmt = $db->prepare('SELECT u.id, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_uuid = ? AND s.user_id = ? AND s.revoked_at IS NULL AND s.replaced_by IS NULL AND s.expires_at > UTC_TIMESTAMP() AND u.is_active = 1 LIMIT 1');
$userId = (int) $claims['sub'];
$sessionId = $claims['sid'];
$stmt->bind_param('si', $sessionId, $userId);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
$stmt->close();
if (!$user) {
api_json(['error' => 'unauthorized'], 401);
}
return ['user_id' => (int) $user['id'], 'role' => $user['account_role'], 'session_id' => $sessionId];
} catch (Throwable $exception) {
error_log('Bearer authorization check failed: ' . $exception->getMessage());
api_json(['error' => 'service_unavailable'], 503);
}
}
public static function requireRole(array $auth, array $allowedRoles): void
{
if (!in_array($auth['role'] ?? null, $allowedRoles, true)) {
api_json(['error' => 'forbidden'], 403);
}
}
}
+4 -3
View File
@@ -25,8 +25,7 @@ class Database {
$this->connection->query("SET time_zone = '+00:00'");
} catch (Throwable $e) {
error_log('Database initialization failed: ' . $e->getMessage());
http_response_code(500);
die(json_encode(['error' => 'Database connection failed']));
throw new RuntimeException('Database connection failed', 0, $e);
}
}
@@ -109,6 +108,8 @@ class Database {
// Prevent cloning
final class SingletonDatabase extends Database {
private function __clone() {}
private function __wakeup() {}
public function __wakeup() {
throw new LogicException('Database singleton cannot be unserialized');
}
}
?>
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
final class JwtToken
{
public static function issue(int $userId, string $sessionId, int $ttlSeconds): string
{
$key = AppConfig::required('JWT_SIGNING_KEY');
if (strlen($key) < 32) {
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
}
$now = time();
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = self::base64UrlEncode(json_encode([
'iss' => rtrim((string) (getenv('APP_URL') ?: ''), '/'),
'sub' => (string) $userId,
'sid' => $sessionId,
'iat' => $now,
'exp' => $now + $ttlSeconds,
'jti' => bin2hex(random_bytes(16)),
], JSON_UNESCAPED_SLASHES));
$signingInput = $header . '.' . $payload;
return $signingInput . '.' . self::base64UrlEncode(hash_hmac('sha256', $signingInput, $key, true));
}
public static function verify(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$headerPart, $payloadPart, $signaturePart] = $parts;
$header = json_decode(self::base64UrlDecode($headerPart), true);
$claims = json_decode(self::base64UrlDecode($payloadPart), true);
if (!is_array($header) || ($header['alg'] ?? null) !== 'HS256' || !is_array($claims)) {
return null;
}
try {
$key = AppConfig::required('JWT_SIGNING_KEY');
} catch (Throwable $exception) {
return null;
}
if (strlen($key) < 32) {
return null;
}
$expected = self::base64UrlEncode(hash_hmac('sha256', $headerPart . '.' . $payloadPart, $key, true));
if (!hash_equals($expected, $signaturePart) || (int) ($claims['exp'] ?? 0) <= time()) {
return null;
}
if (!ctype_digit((string) ($claims['sub'] ?? '')) || !is_string($claims['sid'] ?? null)) {
return null;
}
return $claims;
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): string
{
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
return $decoded === false ? '' : $decoded;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
interface OtpProvider
{
public function send(string $phoneE164, string $code): void;
}
/** Minimal HTTP JSON adapter. Map request fields to the selected vendor contract only after confirmation. */
final class ConfiguredHttpOtpProvider implements OtpProvider
{
public function send(string $phoneE164, string $code): void
{
AppConfig::loadEnvironment();
if (getenv('OTP_ENABLED') !== 'true') {
throw new RuntimeException('OTP provider is disabled');
}
if (AppConfig::required('OTP_PROVIDER') !== 'generic_json') {
throw new RuntimeException('Configure a supported OTP provider adapter before enabling delivery');
}
$url = AppConfig::required('OTP_API_URL');
$apiKey = AppConfig::required('OTP_API_KEY');
if (!filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new RuntimeException('OTP endpoint must use HTTPS');
}
if (!function_exists('curl_init')) {
throw new RuntimeException('The cURL extension is required for OTP delivery');
}
$sender = getenv('OTP_SENDER_ID') ?: '';
$message = getenv('OTP_MESSAGE_TEMPLATE') ?: 'رمز التحقق الخاص بك هو {code}';
if (strpos($message, '{code}') === false) {
throw new RuntimeException('OTP message template must include {code}');
}
$payload = json_encode([
'to' => $phoneE164,
'sender' => $sender,
'message' => str_replace('{code}', $code, $message),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
throw new RuntimeException('Unable to encode OTP request');
}
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 12,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($response === false || $status < 200 || $status >= 300) {
error_log('OTP delivery failed; HTTP ' . $status . '; transport error: ' . $error);
throw new RuntimeException('OTP provider rejected the request');
}
}
}
@@ -0,0 +1,74 @@
-- Exercise media library and versioned training-plan content.
-- Apply to an existing database after backing it up.
CREATE TABLE exercises (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
exercise_uuid CHAR(36) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
title_ar VARCHAR(160) NOT NULL,
title_en VARCHAR(160) NULL,
instructions_ar JSON NOT NULL,
target_muscles JSON NOT NULL,
equipment JSON NOT NULL,
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
gif_url VARCHAR(500) NULL,
gif_poster_url VARCHAR(500) NULL,
duration_seconds SMALLINT UNSIGNED NULL,
repetitions VARCHAR(80) NULL,
safety_notes_ar JSON NOT NULL,
alternative_exercise_id BIGINT UNSIGNED NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_exercises_public (is_published, movement_type, difficulty),
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_uuid CHAR(36) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
title_ar VARCHAR(160) NOT NULL,
summary_ar TEXT NOT NULL,
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
weeks_duration TINYINT UNSIGNED NOT NULL,
source_notes JSON NULL,
safety_notes_ar JSON NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_plans_public (is_published, goal, level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_plan_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT UNSIGNED NOT NULL,
week_number TINYINT UNSIGNED NOT NULL,
day_number TINYINT UNSIGNED NOT NULL,
title_ar VARCHAR(160) NOT NULL,
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
notes_ar TEXT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_session_exercises (
session_id BIGINT UNSIGNED NOT NULL,
exercise_id BIGINT UNSIGNED NOT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
sets TINYINT UNSIGNED NULL,
reps VARCHAR(60) NULL,
duration_seconds SMALLINT UNSIGNED NULL,
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
PRIMARY KEY (session_id, exercise_id),
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+73
View File
@@ -51,6 +51,79 @@ CREATE TABLE app_setting_audit (
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Curated workout library. GIFs are optional, versioned media URLs, not uploaded inline.
CREATE TABLE exercises (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
exercise_uuid CHAR(36) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
title_ar VARCHAR(160) NOT NULL,
title_en VARCHAR(160) NULL,
instructions_ar JSON NOT NULL,
target_muscles JSON NOT NULL,
equipment JSON NOT NULL,
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
gif_url VARCHAR(500) NULL,
gif_poster_url VARCHAR(500) NULL,
duration_seconds SMALLINT UNSIGNED NULL,
repetitions VARCHAR(80) NULL,
safety_notes_ar JSON NOT NULL,
alternative_exercise_id BIGINT UNSIGNED NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_exercises_public (is_published, movement_type, difficulty),
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_uuid CHAR(36) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
title_ar VARCHAR(160) NOT NULL,
summary_ar TEXT NOT NULL,
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
weeks_duration TINYINT UNSIGNED NOT NULL,
source_notes JSON NULL,
safety_notes_ar JSON NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_plans_public (is_published, goal, level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_plan_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT UNSIGNED NOT NULL,
week_number TINYINT UNSIGNED NOT NULL,
day_number TINYINT UNSIGNED NOT NULL,
title_ar VARCHAR(160) NOT NULL,
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
notes_ar TEXT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE training_session_exercises (
session_id BIGINT UNSIGNED NOT NULL,
exercise_id BIGINT UNSIGNED NOT NULL,
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
sets TINYINT UNSIGNED NULL,
reps VARCHAR(60) NULL,
duration_seconds SMALLINT UNSIGNED NULL,
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
PRIMARY KEY (session_id, exercise_id),
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Phone OTP challenges contain keyed digests, never the plaintext code.
CREATE TABLE otp_challenges (
challenge_uuid CHAR(36) PRIMARY KEY,