81 lines
4.1 KiB
PHP
81 lines
4.1 KiB
PHP
<?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();
|
|
$transactionOpen = true;
|
|
$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();
|
|
$transactionOpen = false;
|
|
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();
|
|
$transactionOpen = false;
|
|
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();
|
|
$transactionOpen = false;
|
|
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();
|
|
$ttl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900)));
|
|
$accessToken = JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl);
|
|
$connection->commit();
|
|
$transactionOpen = false;
|
|
|
|
api_json([
|
|
'user' => ['id' => (int) $session['user_id'], 'uuid' => $session['uuid'], 'phone_e164' => $session['phone_e164'], 'account_role' => $session['account_role']],
|
|
'access_token' => $accessToken,
|
|
'token_type' => 'Bearer',
|
|
'expires_in_seconds' => $ttl,
|
|
'refresh_token' => $newRefresh,
|
|
'refresh_expires_in_days' => $refreshDays,
|
|
]);
|
|
} catch (Throwable $exception) {
|
|
if (!empty($transactionOpen) && isset($connection) && $connection instanceof mysqli) {
|
|
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
|
}
|
|
error_log('Session refresh failed: ' . $exception->getMessage());
|
|
api_json(['error' => 'service_unavailable'], 503);
|
|
}
|