ROACONT refreshToken trimite refresh_token prin POST (anaf_efactura.prg:719), iar originalul citea parametrii cu $_REQUEST (GET+POST). Citirea doar din $_GET rupea butonul: cererea cadea pe redirectul authorize, logincert.anaf.ro cerea certificat client si msxml6 dadea "Access is denied" (1429 nehandled). Deploy-uit si verificat pe productie (POST si GET -> passthrough JSON ANAF). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
388 lines
17 KiB
PHP
388 lines
17 KiB
PHP
<?php
|
|
// index.php — OAuth2 ANAF eFactura: obtinere token automat + compatibilitate retro
|
|
// FR-1 (propagare state), FR-2 (sesiune primara), FR-3 (stocare tokenuri),
|
|
// FR-4 (retro-compat fara state), FR-10 (securitate), FR-11 (protectie stocare)
|
|
//
|
|
// Modificat: 2026-07-07 — implementare flux automat (stocare fisier + pagina succes)
|
|
|
|
// === Credentiale ANAF (pastrate din versiunea anterioara) ===
|
|
$client_id = 'f4c59081e488ac326f32f8ab52620023e996dd82d7d01163';
|
|
$client_secret = 'e65b9127acac254c5b9bfbbe68b9a6ca07b0d6df6dbb0023e996dd82d7d01163';
|
|
$redirect_uri = 'https://romfast.ro/oauth2/';
|
|
|
|
// === Director tokenuri (FR-11) ===
|
|
// oauth2/tokens/ — protejat explicit cu .htaccess (Require all denied), deoarece
|
|
// se afla in interiorul docroot-ului (public_html/oauth2/tokens), nu deasupra lui.
|
|
// Codul creaza directorul si .htaccess-ul daca lipsesc (ex. prima instalare)
|
|
$tokens_dir = __DIR__ . '/tokens';
|
|
if (!is_dir($tokens_dir)) {
|
|
@mkdir($tokens_dir, 0700, true);
|
|
}
|
|
$tokens_htaccess = $tokens_dir . '/.htaccess';
|
|
if (!file_exists($tokens_htaccess)) {
|
|
@file_put_contents($tokens_htaccess, "Require all denied\n");
|
|
}
|
|
|
|
// ===================================================================
|
|
// Functii helper
|
|
// ===================================================================
|
|
|
|
// Validare format state: lungime fixa 64, doar [A-Za-z0-9] (protectie path traversal - NFR-1)
|
|
function validState($state) {
|
|
return is_string($state)
|
|
&& strlen($state) === 64
|
|
&& (bool)preg_match('/^[A-Za-z0-9]+$/', $state);
|
|
}
|
|
|
|
// Afisare pagina HTML simpla (§3.3: UTF-8 cu diacritice, H1 mare scanabil,
|
|
// font >=16px, contrast inalt, un singur job per pagina, fara linkuri/butoane)
|
|
function showPage($page_title, $h1_text, $h1_color, $body_text) {
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo '<!DOCTYPE html>' . "\n";
|
|
echo '<html lang="ro"><head>' . "\n";
|
|
echo '<meta charset="utf-8">' . "\n";
|
|
echo '<meta name="viewport" content="width=device-width,initial-scale=1">' . "\n";
|
|
echo '<title>' . htmlspecialchars($page_title, ENT_QUOTES, 'UTF-8') . '</title>' . "\n";
|
|
echo '<style>' . "\n";
|
|
echo 'body{font-family:Arial,Helvetica,sans-serif;font-size:18px;margin:3em auto;' . "\n";
|
|
echo ' max-width:640px;padding:0 1.5em;background:#f7f7f7;color:#222}' . "\n";
|
|
echo 'h1{font-size:2em;color:' . $h1_color . ';margin-bottom:0.5em}' . "\n";
|
|
echo 'p{font-size:1em;line-height:1.7;margin:0}' . "\n";
|
|
echo '.brand{font-size:0.8em;color:#999;margin-bottom:2.5em}' . "\n";
|
|
echo '</style>' . "\n";
|
|
echo '</head><body>' . "\n";
|
|
echo '<p class="brand">ROA Romfast</p>' . "\n";
|
|
echo '<h1>' . htmlspecialchars($h1_text, ENT_QUOTES, 'UTF-8') . '</h1>' . "\n";
|
|
echo '<p>' . htmlspecialchars($body_text, ENT_QUOTES, 'UTF-8') . '</p>' . "\n";
|
|
echo '</body></html>' . "\n";
|
|
}
|
|
|
|
// ===================================================================
|
|
// Citire explicita parametri GET (FR-10: elimina parse_str care injecta variabile)
|
|
// ===================================================================
|
|
// refresh_token vine din ROA prin POST body (refreshToken - anaf_efactura.prg) sau GET;
|
|
// originalul folosea $_REQUEST, deci accepta ambele - citirea doar din $_GET rupe butonul Actualizare
|
|
$refresh_token = isset($_POST['refresh_token']) ? (string)$_POST['refresh_token']
|
|
: (isset($_GET['refresh_token']) ? (string)$_GET['refresh_token'] : '');
|
|
$code = isset($_GET['code']) ? (string)$_GET['code'] : '';
|
|
$state_param = isset($_GET['state']) ? (string)$_GET['state'] : '';
|
|
$error_param = isset($_GET['error']) ? (string)$_GET['error'] : '';
|
|
$error_description = isset($_GET['error_description']) ? (string)$_GET['error_description'] : '';
|
|
|
|
// ===================================================================
|
|
// Ramura REFRESH TOKEN — neatinsa functional (FR-4)
|
|
// Apelata din ROA direct cu ?refresh_token=... pentru prelungirea tokenului
|
|
// ===================================================================
|
|
if (!empty($refresh_token)) {
|
|
$url = 'https://logincert.anaf.ro/anaf-oauth2/v1/token';
|
|
$fields = [
|
|
'client_id' => $client_id,
|
|
'client_secret' => $client_secret,
|
|
'refresh_token' => $refresh_token,
|
|
'redirect_uri' => $redirect_uri,
|
|
'grant_type' => 'refresh_token',
|
|
];
|
|
$fields_string = http_build_query($fields);
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields_string);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
|
|
|
$jsonobj = curl_exec($ch);
|
|
curl_close($ch);
|
|
|
|
header('Content-Type: application/json');
|
|
// La eroare curl: corp gol, identic cu comportamentul vechi (FR-4: ramura neatinsa)
|
|
echo ($jsonobj !== false) ? $jsonobj : '';
|
|
exit();
|
|
}
|
|
|
|
// ===================================================================
|
|
// Configurare cookie de sesiune PHP (FR-2)
|
|
// session_set_cookie_params TREBUIE sa preceada session_start pe AMBELE intrari:
|
|
// - intrarea initiala (fara ?code): browser deschis de ROACONT
|
|
// - callback-ul ANAF (cu ?code): navigare top-level cross-site
|
|
//
|
|
// SameSite=Lax este obligatoriu: callback-ul ANAF este o navigare top-level dintr-un
|
|
// alt domeniu (logincert.anaf.ro -> romfast.ro) — cu SameSite=Strict cookie-ul nu ar
|
|
// fi trimis in callback si legarea sesiune ar esua silentios.
|
|
// ===================================================================
|
|
if (PHP_VERSION_ID >= 70300) {
|
|
// Sintaxa cu array: disponibila incepand cu PHP 7.3
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'secure' => true,
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
} else {
|
|
// Fallback PHP < 7.3: SameSite injectat ca sufix al parametrului path
|
|
session_set_cookie_params(0, '/; SameSite=Lax', '', true, true);
|
|
}
|
|
session_start();
|
|
|
|
// ===================================================================
|
|
// Callback de eroare ANAF (?error=...) — FR-10
|
|
// Tratare explicita; nu se re-redirecteaza la authorize (ar produce bucla)
|
|
// ===================================================================
|
|
if (!empty($error_param)) {
|
|
// Extrage state-ul din sesiune pentru a scrie fisierul de eroare
|
|
$sess_state = null;
|
|
if (!empty($_SESSION['roa_state']) && is_array($_SESSION['roa_state'])) {
|
|
$sess = $_SESSION['roa_state'];
|
|
if (!empty($sess['value']) && (time() - (int)$sess['ts']) < 900) {
|
|
$sess_state = $sess['value'];
|
|
}
|
|
}
|
|
unset($_SESSION['roa_state']);
|
|
session_write_close();
|
|
|
|
// Scrie fisier de eroare pentru state-ul din sesiune (daca exista si valid)
|
|
if ($sess_state !== null && validState($sess_state)) {
|
|
$fname = hash('sha256', $sess_state);
|
|
$token_file = $tokens_dir . '/' . $fname . '.json';
|
|
$tmp_file = $tokens_dir . '/' . $fname . '.tmp';
|
|
// Anti-suprascriere: nu suprascrie un fisier de succes existent (FR-10)
|
|
if (!file_exists($token_file)) {
|
|
$err_data = json_encode([
|
|
'error' => $error_param,
|
|
'error_description' => $error_description,
|
|
]);
|
|
if ($err_data !== false && file_put_contents($tmp_file, $err_data) !== false) {
|
|
@rename($tmp_file, $token_file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pagina de eroare (§3.3: un job, fara retry link, fara re-redirect)
|
|
$cause = !empty($error_description) ? $error_description : $error_param;
|
|
showPage(
|
|
'Autorizare nereușită',
|
|
'Autorizarea nu a reușit',
|
|
'#c0392b',
|
|
$cause . ' Reveniți în aplicația ROA — de acolo puteți relua operația sau folosi completarea manuală.'
|
|
);
|
|
exit();
|
|
}
|
|
|
|
// ===================================================================
|
|
// Intrare INITIALA — nicio sesiune, fara ?code: redirect catre ANAF authorize
|
|
// ===================================================================
|
|
if (empty($code)) {
|
|
if (!empty($state_param)) {
|
|
// Validare format state inainte de orice operatie (protectie path traversal)
|
|
if (!validState($state_param)) {
|
|
session_write_close();
|
|
// L1: H1 conform §3.3 ("Autorizarea nu a reușit" pentru toate paginile de eroare)
|
|
showPage(
|
|
'Autorizare nereușită',
|
|
'Autorizarea nu a reușit',
|
|
'#c0392b',
|
|
'Parametru de sesiune invalid. Reluați operația din aplicația ROA.'
|
|
);
|
|
exit();
|
|
}
|
|
|
|
// Salveaza state in sesiune cu timestamp (mecanism PRIMAR de legare)
|
|
$_SESSION['roa_state'] = ['value' => $state_param, 'ts' => time()];
|
|
session_write_close();
|
|
|
|
} else {
|
|
// FR-2 igienă sesiune: fara state in URL = flux manual/retro-compat
|
|
// Curata orice roa_state ramas dintr-o tentativa automata anterioara esuata
|
|
// (altfel, la callback, am afisa pagina de succes fara tokenuri = fund de sac)
|
|
unset($_SESSION['roa_state']);
|
|
session_write_close();
|
|
}
|
|
|
|
// URL authorize ANAF — client_secret NU se trimite la authorize (FR-10)
|
|
// client_secret apartine doar schimbului code->token (POST, fara URL)
|
|
$url_auth = 'https://logincert.anaf.ro/anaf-oauth2/v1/authorize';
|
|
$url_auth .= '?client_id=' . urlencode($client_id);
|
|
$url_auth .= '&response_type=code';
|
|
$url_auth .= '&redirect_uri=' . urlencode($redirect_uri);
|
|
$url_auth .= '&token_content_type=jwt';
|
|
// FR-1: propagare state la ANAF daca exista
|
|
// Nota: doc ANAF (pag.23) spune "State se lasa necompletat" — propagarea e incerta
|
|
// dar trimitem oricum; legarea primara ramane sesiunea PHP
|
|
if (!empty($state_param)) {
|
|
$url_auth .= '&state=' . urlencode($state_param);
|
|
}
|
|
|
|
header('Location: ' . $url_auth);
|
|
exit();
|
|
}
|
|
|
|
// ===================================================================
|
|
// Callback cu cod de autorizare (?code=...) — schimb code -> token
|
|
// ===================================================================
|
|
|
|
// FR-2: identifica state-ul din sesiune (mecanismul PRIMAR de legare)
|
|
// state-ul din sesiune = cel generat de ROACONT si salvat la intrarea initiala
|
|
$sess_state = null;
|
|
if (!empty($_SESSION['roa_state']) && is_array($_SESSION['roa_state'])) {
|
|
$sess = $_SESSION['roa_state'];
|
|
if (!empty($sess['value']) && (time() - (int)$sess['ts']) < 900) {
|
|
// roa_state valid (sub 15 minute)
|
|
$sess_state = $sess['value'];
|
|
}
|
|
// roa_state mai vechi de 15 minute se ignora (si se sterge mai jos)
|
|
}
|
|
// Consuma state-ul din sesiune (indiferent de validitate — evita re-utilizare)
|
|
unset($_SESSION['roa_state']);
|
|
|
|
// FR-2: validare CSRF oportunistica
|
|
// Daca ANAF a propagat state in callback (comportament incert), trebuie sa coincida cu sesiunea
|
|
// Nepotrivire = posibil CSRF sau sesiune gresita -> eroare, fara stocare tokenuri
|
|
if (!empty($state_param) && $sess_state !== null) {
|
|
if ($state_param !== $sess_state) {
|
|
session_write_close();
|
|
showPage(
|
|
'Eroare de securitate',
|
|
'Autorizarea nu a reușit',
|
|
'#c0392b',
|
|
'Identificatorul de sesiune nu corespunde. Operația a fost anulată din motive de securitate. ' .
|
|
'Reveniți în aplicația ROA și reluați de acolo.'
|
|
);
|
|
exit();
|
|
}
|
|
}
|
|
|
|
// FR-11 / C10: elibereaza lock-ul de sesiune inainte de curl
|
|
// ANAF poate raspunde lent; nu tinem lock-ul de sesiune blocat in acest timp
|
|
session_write_close();
|
|
|
|
// Schimb cod de autorizare -> token (FR-10: cu timeouts + tratare erori)
|
|
$url_token = 'https://logincert.anaf.ro/anaf-oauth2/v1/token';
|
|
$fields_token = [
|
|
'client_id' => $client_id,
|
|
'client_secret' => $client_secret,
|
|
'code' => $code,
|
|
'redirect_uri' => $redirect_uri,
|
|
'grant_type' => 'authorization_code',
|
|
'token_content_type' => 'jwt',
|
|
];
|
|
$fields_string = http_build_query($fields_token);
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, $url_token);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields_string);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // FR-10: timeout conectare
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 30); // FR-10: timeout total
|
|
|
|
$jsonobj = curl_exec($ch);
|
|
$curl_errno = curl_errno($ch);
|
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
// FR-10: tratare explicita erori curl / HTTP != 200 / JSON de eroare
|
|
// (inainte: un esec afisa tokenuri goale)
|
|
$error_msg = null; // text uman (pagina browser + error_description)
|
|
$err_code = null; // cod scurt ASCII citit de ROACONT (loJson.error -> M12)
|
|
$token_data = null;
|
|
|
|
if ($curl_errno !== 0) {
|
|
$err_code = 'curl_error';
|
|
$error_msg = 'Eroare de rețea la contactarea ANAF (curl errno: ' . (int)$curl_errno . ').';
|
|
} elseif ($http_code !== 200) {
|
|
$err_code = 'http_error';
|
|
$error_msg = 'ANAF a răspuns cu codul HTTP ' . (int)$http_code . '.';
|
|
} else {
|
|
$token_data = json_decode((string)$jsonobj, true);
|
|
if ($token_data === null) {
|
|
$err_code = 'json_invalid';
|
|
$error_msg = 'Răspunsul ANAF nu a putut fi decodificat (JSON invalid).';
|
|
} elseif (!empty($token_data['error'])) {
|
|
$err_code = $token_data['error']; // cod brut ANAF: invalid_grant, access_denied etc.
|
|
$err_desc = !empty($token_data['error_description']) ? $token_data['error_description'] : '';
|
|
$error_msg = 'ANAF: ' . $err_code . ($err_desc ? ' — ' . $err_desc : '') . '.';
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// Scriere fisier tokenuri (FR-3, FR-11)
|
|
// Se efectueaza DOAR daca sesiunea avea un state valid (flux automat)
|
|
// ===================================================================
|
|
if ($sess_state !== null && validState($sess_state)) {
|
|
$fname = hash('sha256', $sess_state);
|
|
$token_file = $tokens_dir . '/' . $fname . '.json';
|
|
$tmp_file = $tokens_dir . '/' . $fname . '.tmp';
|
|
|
|
if ($error_msg !== null) {
|
|
// Eroare: scrie fisier de eroare
|
|
// FR-10 anti-suprascriere: nu suprascrie un token de succes existent
|
|
// (ex. refresh pe pagina callback dupa succes: code expirat -> eroare -> nu stergem tokenul bun)
|
|
if (!file_exists($token_file)) {
|
|
$err_data = json_encode([
|
|
'error' => $err_code, // cod scurt ASCII citit de ROACONT (fara mojibake)
|
|
'error_description' => $error_msg, // text uman UTF-8 (diagnostic)
|
|
]);
|
|
if ($err_data !== false && file_put_contents($tmp_file, $err_data) !== false) {
|
|
@rename($tmp_file, $token_file);
|
|
}
|
|
}
|
|
} else {
|
|
// Succes: scriere atomica a tokenurilor
|
|
$json_out = json_encode($token_data, JSON_UNESCAPED_SLASHES);
|
|
$write_ok = false;
|
|
if ($json_out !== false && file_put_contents($tmp_file, $json_out) !== false) {
|
|
// @rename() pe acelasi filesystem = atomic (pick.php nu poate citi JSON partial)
|
|
if (@rename($tmp_file, $token_file)) {
|
|
$write_ok = true;
|
|
} else {
|
|
@unlink($tmp_file); // curata .tmp la esec rename; TTL il curata oricum
|
|
}
|
|
}
|
|
if (!$write_ok) {
|
|
// Scriere esuata — afisam eroare (nu succes fals); ROACONT va detecta timeout
|
|
$error_msg = 'Tokenul ANAF a fost primit dar nu a putut fi salvat temporar pe server. Reveniți în aplicație și reluați operația.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// Afisare pagina rezultat (§3.3)
|
|
// ===================================================================
|
|
if ($sess_state !== null) {
|
|
// Flux automat (state in sesiune): afiseaza succes sau eroare
|
|
// Tokenurile NU apar in pagina — sunt ridicate de pick.php
|
|
if ($error_msg !== null) {
|
|
showPage(
|
|
'Autorizare nereușită',
|
|
'Autorizarea nu a reușit',
|
|
'#c0392b',
|
|
$error_msg .
|
|
' Reveniți în aplicația ROA — de acolo puteți relua operația sau folosi completarea manuală.'
|
|
);
|
|
} else {
|
|
// §3.3 pagina succes: H1 verde, corp cu instructiunea de revenire in ROA
|
|
showPage(
|
|
'Autorizare reușită',
|
|
'Autorizare reușită',
|
|
'#27ae60',
|
|
'Reveniți în aplicația ROA — tokenul se preia automat. Puteți închide această pagină.'
|
|
);
|
|
}
|
|
} else {
|
|
// FR-4 retro-compat: fara state in sesiune = versiune veche ROACONT sau sesiune expirata
|
|
// Comportamentul ACTUAL ramas neschimbat: tokenurile apar in pagina pentru copiere manuala
|
|
if ($error_msg !== null) {
|
|
echo '<p>Eroare la obtinerea tokenului: ' . htmlspecialchars($error_msg, ENT_QUOTES, 'UTF-8') . '</p>';
|
|
} else {
|
|
echo '<p>Copiati ANAF Access Token si Refresh Token de pe liniile urmatoare si completati-le in aplicatie</p>';
|
|
echo '<p><b>Access Token:</b></p><p>';
|
|
echo htmlspecialchars((string)(isset($token_data['access_token']) ? $token_data['access_token'] : ''), ENT_QUOTES, 'UTF-8');
|
|
echo '</p>';
|
|
echo '<p><b>Refresh Token:</b></p><p>';
|
|
echo htmlspecialchars((string)(isset($token_data['refresh_token']) ? $token_data['refresh_token'] : ''), ENT_QUOTES, 'UTF-8');
|
|
echo '</p>';
|
|
}
|
|
}
|