222 lines
7.6 KiB
PHP
222 lines
7.6 KiB
PHP
<?php
|
|
// /var/www/html/common.php
|
|
// Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt).
|
|
|
|
/**
|
|
* Load KEY=value pairs from .env in the project root.
|
|
* Does not override variables already set in the real environment.
|
|
*/
|
|
function qdb_load_dotenv(string $path): void {
|
|
if (!is_readable($path)) {
|
|
return;
|
|
}
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
|
if ($lines === false) {
|
|
return;
|
|
}
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if ($line === '' || $line[0] === '#') {
|
|
continue;
|
|
}
|
|
$eq = strpos($line, '=');
|
|
if ($eq === false) {
|
|
continue;
|
|
}
|
|
$name = trim(substr($line, 0, $eq));
|
|
$value = trim(substr($line, $eq + 1));
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$len = strlen($value);
|
|
if ($len >= 2) {
|
|
$q = $value[0];
|
|
if (($q === '"' && $value[$len - 1] === '"') || ($q === "'" && $value[$len - 1] === "'")) {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
}
|
|
$existing = getenv($name);
|
|
if ($existing !== false && $existing !== '') {
|
|
continue;
|
|
}
|
|
putenv("$name=$value");
|
|
$_ENV[$name] = $value;
|
|
}
|
|
}
|
|
|
|
qdb_load_dotenv(__DIR__ . '/.env');
|
|
|
|
// --- MASTER-KEY (Server-seitig zum Speichern der DB) ---
|
|
// Per ENV 'QDB_MASTER_KEY' (Base64- oder ASCII-String) oder Fallback auf bisherigen Wert.
|
|
function get_master_key_bytes(): string {
|
|
$env = getenv('QDB_MASTER_KEY');
|
|
if (!$env || $env === '') {
|
|
throw new RuntimeException('QDB_MASTER_KEY environment variable is not set. Cannot proceed without a master key.');
|
|
}
|
|
$b = base64_decode($env, true);
|
|
if ($b !== false && strlen($b) > 0) {
|
|
return str_pad(substr($b, 0, 32), 32, "\0");
|
|
}
|
|
return str_pad(substr($env, 0, 32), 32, "\0");
|
|
}
|
|
|
|
// --- HKDF (SHA-256) über Token -> Session-Key (32 Byte) ---
|
|
// Kompatibel zu Android-Implementierung: salt = leer, info = "qdb-aes"
|
|
function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes', int $len = 32): string {
|
|
$ikm = @hex2bin(trim($tokenHex));
|
|
if ($ikm === false) $ikm = $tokenHex; // Falls bereits binär
|
|
if (function_exists('hash_hkdf')) {
|
|
return hash_hkdf('sha256', $ikm, $len, $info, '');
|
|
}
|
|
// Fallback: eigene HKDF-Implementierung
|
|
$hashLen = 32;
|
|
$salt = str_repeat("\0", $hashLen);
|
|
$prk = hash_hmac('sha256', $ikm, $salt, true);
|
|
$okm = '';
|
|
$t = '';
|
|
$n = (int)ceil($len / $hashLen);
|
|
for ($i = 1; $i <= $n; $i++) {
|
|
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
|
|
$okm .= $t;
|
|
}
|
|
return substr($okm, 0, $len);
|
|
}
|
|
|
|
// --- Token-Storage (DB-backed via session table) ---
|
|
|
|
function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$now = time();
|
|
$pdo->prepare(
|
|
"INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
|
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
|
|
)->execute([
|
|
':t' => $token,
|
|
':uid' => $extra['userID'] ?? '',
|
|
':role' => $extra['role'] ?? '',
|
|
':eid' => $extra['entityID'] ?? '',
|
|
':ca' => $now,
|
|
':ea' => $now + $ttlSeconds,
|
|
':tmp' => (int)(!empty($extra['temp'])),
|
|
]);
|
|
// Opportunistic cleanup: remove expired sessions
|
|
$pdo->prepare("DELETE FROM session WHERE expiresAt < :now")->execute([':now' => $now]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
function token_get_record(string $token): ?array {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
|
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
|
|
$stmt->execute([':t' => $token, ':now' => time()]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
qdb_discard($tmpDb, $lockFp);
|
|
if (!$row) return null;
|
|
// Map DB columns to the keys the rest of the codebase expects
|
|
return [
|
|
'token' => $row['token'],
|
|
'role' => $row['role'],
|
|
'entityID' => $row['entityID'],
|
|
'userID' => $row['userID'],
|
|
'created' => (int)$row['createdAt'],
|
|
'exp' => (int)$row['expiresAt'],
|
|
'temp' => (int)$row['temp'],
|
|
];
|
|
}
|
|
|
|
function token_revoke(string $token): void {
|
|
require_once __DIR__ . '/db_init.php';
|
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
|
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => $token]);
|
|
qdb_save($tmpDb, $lockFp);
|
|
}
|
|
|
|
// --- Auth helpers for endpoints ---
|
|
function get_bearer_token(): ?string {
|
|
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
|
if (stripos($auth, 'Bearer ') === 0) {
|
|
return substr($auth, 7);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function require_valid_token(): array {
|
|
$token = get_bearer_token();
|
|
if (!$token) {
|
|
http_response_code(401);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Missing Bearer token"]);
|
|
exit;
|
|
}
|
|
$rec = token_get_record($token);
|
|
if (!$rec) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Invalid or expired token"]);
|
|
exit;
|
|
}
|
|
if (!empty($rec['temp'])) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Password change required before access"]);
|
|
exit;
|
|
}
|
|
$rec['_token'] = $token;
|
|
return $rec;
|
|
}
|
|
|
|
function require_role(array $allowed, array $tokenRecord): void {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
if (!in_array($role, $allowed, true)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode(["error" => "Insufficient permissions"]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a SQL WHERE clause fragment that restricts client visibility by role.
|
|
* Returns [string $clause, array $params].
|
|
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client').
|
|
*/
|
|
function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array {
|
|
$role = $tokenRecord['role'] ?? '';
|
|
$entityID = $tokenRecord['entityID'] ?? '';
|
|
switch ($role) {
|
|
case 'admin':
|
|
return ['1=1', []];
|
|
case 'supervisor':
|
|
return [
|
|
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
case 'coach':
|
|
return [
|
|
"$clientAlias.coachID = :rbac_eid",
|
|
[':rbac_eid' => $entityID]
|
|
];
|
|
default:
|
|
return ['0=1', []];
|
|
}
|
|
}
|
|
|
|
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
|
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = random_bytes(16);
|
|
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($cipher === false) throw new Exception('openssl_encrypt failed');
|
|
return $iv . $cipher;
|
|
}
|
|
function aes256_cbc_decrypt_bytes(string $data, string $key): string {
|
|
if (strlen($data) < 16) throw new Exception('cipher too short');
|
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
|
$iv = substr($data, 0, 16);
|
|
$ct = substr($data, 16);
|
|
$plain = openssl_decrypt($ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($plain === false) throw new Exception('openssl_decrypt failed');
|
|
return $plain;
|
|
}
|