Add SportPath auth APIs and training content foundation
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
$auth = ApiAuth::bearerClaims();
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE session_uuid = ?');
|
||||
$stmt->bind_param('s', $auth['session_id']);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
api_json(['status' => 'signed_out']);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Session revocation failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$refreshToken = is_array($body) ? ($body['refresh_token'] ?? null) : null;
|
||||
if (!is_string($refreshToken) || !preg_match('/^[a-f0-9]{96}$/', $refreshToken)) {
|
||||
api_json(['error' => 'invalid_refresh_token'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$digest = hash('sha256', $refreshToken);
|
||||
$query = $db->prepare('SELECT s.session_uuid, s.family_uuid, s.user_id, s.device_uuid, s.replaced_by, s.revoked_at, s.expires_at, u.uuid, u.phone_e164, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_token_digest = ? FOR UPDATE');
|
||||
$query->bind_param('s', $digest);
|
||||
$query->execute();
|
||||
$session = $query->get_result()->fetch_assoc();
|
||||
$query->close();
|
||||
if (!$session) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_refresh_token'], 401);
|
||||
}
|
||||
if ($session['replaced_by'] !== null) {
|
||||
$revoke = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE family_uuid = ?');
|
||||
$revoke->bind_param('s', $session['family_uuid']);
|
||||
$revoke->execute();
|
||||
$revoke->close();
|
||||
$connection->commit();
|
||||
api_json(['error' => 'refresh_token_reuse_detected'], 401);
|
||||
}
|
||||
if ($session['revoked_at'] !== null || $session['expires_at'] <= gmdate('Y-m-d H:i:s') || !(bool) $session['is_active']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'session_expired'], 401);
|
||||
}
|
||||
|
||||
$newSessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$newRefresh = bin2hex(random_bytes(48));
|
||||
$newDigest = hash('sha256', $newRefresh);
|
||||
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||
$insert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||
$insert->bind_param('ssissi', $newSessionId, $session['family_uuid'], $session['user_id'], $session['device_uuid'], $newDigest, $refreshDays);
|
||||
$insert->execute();
|
||||
$insert->close();
|
||||
$replace = $db->prepare('UPDATE auth_sessions SET last_used_at = UTC_TIMESTAMP(), replaced_by = ? WHERE session_uuid = ? AND replaced_by IS NULL');
|
||||
$replace->bind_param('ss', $newSessionId, $session['session_uuid']);
|
||||
$replace->execute();
|
||||
$replace->close();
|
||||
$connection->commit();
|
||||
|
||||
$ttl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900)));
|
||||
api_json([
|
||||
'user' => ['id' => (int) $session['user_id'], 'uuid' => $session['uuid'], 'phone_e164' => $session['phone_e164'], 'account_role' => $session['account_role']],
|
||||
'access_token' => JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl),
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in_seconds' => $ttl,
|
||||
'refresh_token' => $newRefresh,
|
||||
'refresh_expires_in_days' => $refreshDays,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('Session refresh failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$phone = is_array($body) ? ($body['phone_e164'] ?? null) : null;
|
||||
$purpose = is_array($body) ? ($body['purpose'] ?? 'login') : 'login';
|
||||
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||
if (!is_string($phone) || !preg_match('/^\+[1-9][0-9]{7,14}$/', $phone)) {
|
||||
api_json(['error' => 'invalid_phone_e164'], 400);
|
||||
}
|
||||
if (!in_array($purpose, ['register', 'login'], true)) {
|
||||
api_json(['error' => 'invalid_purpose'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
AppConfig::loadEnvironment();
|
||||
if (getenv('OTP_ENABLED') !== 'true') {
|
||||
api_json(['error' => 'otp_provider_not_configured'], 503);
|
||||
}
|
||||
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||
if (strlen($hashKey) < 32) {
|
||||
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||
}
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
|
||||
$rateBuckets = [
|
||||
['phone', hash_hmac('sha256', 'phone:' . $phone, $hashKey), 5, 3600],
|
||||
['ip', hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey), 20, 3600],
|
||||
];
|
||||
if ($deviceUuid !== null) {
|
||||
$rateBuckets[] = ['device', hash_hmac('sha256', 'device:' . $deviceUuid, $hashKey), 10, 3600];
|
||||
}
|
||||
foreach ($rateBuckets as [$bucketType, $digest, $limit, $windowSeconds]) {
|
||||
$stmt = $db->prepare('INSERT INTO auth_rate_limit_buckets (bucket_digest, bucket_type, window_started_at, request_count) VALUES (?, ?, UTC_TIMESTAMP(), 1) ON DUPLICATE KEY UPDATE request_count = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, 1, request_count + 1), window_started_at = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, UTC_TIMESTAMP(), window_started_at)');
|
||||
$stmt->bind_param('ssii', $digest, $bucketType, $windowSeconds, $windowSeconds);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
$check = $db->prepare('SELECT request_count FROM auth_rate_limit_buckets WHERE bucket_digest = ?');
|
||||
$check->bind_param('s', $digest);
|
||||
$check->execute();
|
||||
$count = (int) $check->get_result()->fetch_assoc()['request_count'];
|
||||
$check->close();
|
||||
if ($count > $limit) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'rate_limited'], 429);
|
||||
}
|
||||
}
|
||||
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$challengeUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$codeDigest = hash_hmac('sha256', $challengeUuid . '|' . $phone . '|' . $code, $hashKey);
|
||||
$ipDigest = hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey);
|
||||
$insert = $db->prepare('INSERT INTO otp_challenges (challenge_uuid, phone_e164, purpose, code_digest, request_ip_digest, device_uuid, expires_at) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL 5 MINUTE)');
|
||||
$insert->bind_param('ssssss', $challengeUuid, $phone, $purpose, $codeDigest, $ipDigest, $deviceUuid);
|
||||
$insert->execute();
|
||||
$insert->close();
|
||||
$connection->commit();
|
||||
|
||||
(new ConfiguredHttpOtpProvider())->send($phone, $code);
|
||||
api_json(['challenge_id' => $challengeUuid, 'expires_in_seconds' => 300]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli && $connection->errno === 0) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('OTP request failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'otp_delivery_failed'], 503);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$challengeId = is_array($body) ? ($body['challenge_id'] ?? null) : null;
|
||||
$code = is_array($body) ? ($body['code'] ?? null) : null;
|
||||
$displayName = is_array($body) ? ($body['display_name'] ?? null) : null;
|
||||
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||
$platform = is_array($body) ? ($body['platform'] ?? null) : null;
|
||||
if (!is_string($challengeId) || !preg_match('/^[0-9a-f-]{36}$/i', $challengeId) || !is_string($code) || !preg_match('/^[0-9]{6}$/', $code)) {
|
||||
api_json(['error' => 'invalid_verification_payload'], 400);
|
||||
}
|
||||
if ($displayName !== null && (!is_string($displayName) || mb_strlen($displayName) > 100)) {
|
||||
api_json(['error' => 'invalid_display_name'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||
}
|
||||
if ($platform !== null && !in_array($platform, ['ios', 'android', 'web'], true)) {
|
||||
api_json(['error' => 'invalid_platform'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
AppConfig::loadEnvironment();
|
||||
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||
if (strlen($hashKey) < 32) {
|
||||
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||
}
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$challengeQuery = $db->prepare('SELECT challenge_uuid, phone_e164, purpose, code_digest, attempt_count, max_attempts, device_uuid FROM otp_challenges WHERE challenge_uuid = ? AND consumed_at IS NULL AND expires_at > UTC_TIMESTAMP() FOR UPDATE');
|
||||
$challengeQuery->bind_param('s', $challengeId);
|
||||
$challengeQuery->execute();
|
||||
$challenge = $challengeQuery->get_result()->fetch_assoc();
|
||||
$challengeQuery->close();
|
||||
if (!$challenge || (int) $challenge['attempt_count'] >= (int) $challenge['max_attempts']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && $challenge['device_uuid'] !== null && !hash_equals($challenge['device_uuid'], $deviceUuid)) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_verification_payload'], 400);
|
||||
}
|
||||
|
||||
$expectedDigest = hash_hmac('sha256', $challengeId . '|' . $challenge['phone_e164'] . '|' . $code, $hashKey);
|
||||
if (!hash_equals($challenge['code_digest'], $expectedDigest)) {
|
||||
$fail = $db->prepare('UPDATE otp_challenges SET attempt_count = attempt_count + 1 WHERE challenge_uuid = ?');
|
||||
$fail->bind_param('s', $challengeId);
|
||||
$fail->execute();
|
||||
$fail->close();
|
||||
$connection->commit();
|
||||
api_json(['error' => 'invalid_code'], 400);
|
||||
}
|
||||
|
||||
$consume = $db->prepare('UPDATE otp_challenges SET consumed_at = UTC_TIMESTAMP() WHERE challenge_uuid = ? AND consumed_at IS NULL');
|
||||
$consume->bind_param('s', $challengeId);
|
||||
$consume->execute();
|
||||
if ($consume->affected_rows !== 1) {
|
||||
$consume->close();
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||
}
|
||||
$consume->close();
|
||||
|
||||
$userQuery = $db->prepare('SELECT id, uuid, account_role, is_active FROM users WHERE phone_e164 = ? LIMIT 1 FOR UPDATE');
|
||||
$userQuery->bind_param('s', $challenge['phone_e164']);
|
||||
$userQuery->execute();
|
||||
$user = $userQuery->get_result()->fetch_assoc();
|
||||
$userQuery->close();
|
||||
if (!$user && $challenge['purpose'] === 'login') {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'verification_failed'], 400);
|
||||
}
|
||||
if ($user && !(bool) $user['is_active']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'account_inactive'], 403);
|
||||
}
|
||||
if (!$user) {
|
||||
$userUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$userInsert = $db->prepare('INSERT INTO users (uuid, phone_e164, phone_verified_at, full_name, account_role, is_active) VALUES (?, ?, UTC_TIMESTAMP(), ?, \'member\', 1)');
|
||||
$fullName = $displayName ?: 'مستخدم SportPath';
|
||||
$userInsert->bind_param('sss', $userUuid, $challenge['phone_e164'], $fullName);
|
||||
$userInsert->execute();
|
||||
$userId = (int) $connection->insert_id;
|
||||
$userInsert->close();
|
||||
$user = ['id' => $userId, 'uuid' => $userUuid, 'account_role' => 'member'];
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
$verified = $db->prepare('UPDATE users SET phone_verified_at = COALESCE(phone_verified_at, UTC_TIMESTAMP()) WHERE id = ?');
|
||||
$verified->bind_param('i', $userId);
|
||||
$verified->execute();
|
||||
$verified->close();
|
||||
}
|
||||
|
||||
$resolvedDevice = $deviceUuid ?: ($challenge['device_uuid'] ?: null);
|
||||
if ($resolvedDevice !== null) {
|
||||
$devicePlatform = $platform ?: 'web';
|
||||
$deviceInsert = $db->prepare('INSERT INTO user_devices (user_id, device_uuid, platform, display_name, last_seen_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE platform = VALUES(platform), revoked_at = NULL, last_seen_at = UTC_TIMESTAMP()');
|
||||
$deviceName = $displayName ?: null;
|
||||
$deviceInsert->bind_param('isss', $userId, $resolvedDevice, $devicePlatform, $deviceName);
|
||||
$deviceInsert->execute();
|
||||
$deviceInsert->close();
|
||||
}
|
||||
|
||||
$sessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$familyId = $sessionId;
|
||||
$refreshToken = bin2hex(random_bytes(48));
|
||||
$refreshDigest = hash('sha256', $refreshToken);
|
||||
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||
$sessionInsert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||
$sessionInsert->bind_param('ssissi', $sessionId, $familyId, $userId, $resolvedDevice, $refreshDigest, $refreshDays);
|
||||
$sessionInsert->execute();
|
||||
$sessionInsert->close();
|
||||
$connection->commit();
|
||||
|
||||
$accessTtl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900)));
|
||||
api_json([
|
||||
'user' => ['id' => $userId, 'uuid' => $user['uuid'], 'phone_e164' => $challenge['phone_e164'], 'account_role' => $user['account_role']],
|
||||
'access_token' => JwtToken::issue($userId, $sessionId, $accessTtl),
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in_seconds' => $accessTtl,
|
||||
'refresh_token' => $refreshToken,
|
||||
'refresh_expires_in_days' => $refreshDays,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('OTP verification failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
Reference in New Issue
Block a user