Add SportPath auth APIs and training content foundation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user