Cea mai mare problemă pe care o ai este modul în care salvezi parolele în baza de date. Din păcate MD5
este un algoritm vechi și nesigur. Dacă cineva va avea acces în viitor la baza ta de date, rămâne doar o problemă de timp (prea puțin timp) până când va “decripta” (între ghilimele) parolele.
Deci, MD5
este o metodă de hash-uire prea simplă și nesigură pentru vremurile noastre (poate așa cum era MD4
pentru MD5
). Asta ca să nu mai zic că rezultatul final, întotdeauna de 32 de caractere, poate identifica în anumite situații chiar două parole (de exemplu: parola “1234” și parola “Hello!”).
Deci, va trebui să folosești un algoritm de hash-uire ca și MD5
, dar ceva mai complex, cum ar fi BCRYPT. În PHP există funcția password_hash
care exact asta face. Astfel, înainte să inserezi parola în baza de date, va trebui să folosești:
$password_for_database = password_hash("parola_setata_de_utilizator", PASSWORD_DEFAULT);
În funcția de mai sus, al doilea parametru trimis, “PASSWORD_DEFAULT
” este o constantă și valoarea ei va fi înlocuită automat cu algoritmul preferat de PHP. În momentul de față algoritmul preferat de PHP este BCRYPT. Asta înseamnă că e ca și cum ai folosi:
$password_for_database = password_hash("parola_setata_de_utilizator", PASSWORD_BCRYPT);
În viitor, dacă PHP decide că BCRYPT nu mai este sigur (sau din alte motive preferențiale), atunci valoarea constantei PASSWORD_DEFAULT
ar putea fi înlocuită cu un alt algoritm (care e existent acum sau va fi în viitor). Paranteză: dacă se întâmplă asta, accesul la conturi nu va fi blocat de faptul că s-a schimbat algoritmul care s-a folosit prima dată. Asta pentru că PHP înțelege cum a fost construit hash-ul (o parte din hash conține identificatorul algoritmului).
Ideal ar fi totuși să nu folosești “PASSWORD_DEFAULT
” și să alegi tu un algoritm pe care să-l foloseșți, ca să-ți fie lucrurile clare.
Atenție. Dacă vei folosi PASSWORD_BCRYPT
, trebuie să nu permiți parole mai mari de 72 de caractere pentru că algoritmul are ceva probleme. Oricum, puțin probabil ca cineva să-și pună o parolă atât de lungă. Există o problemă și cu valoarea null, dar, din nou, cred că e clar că o parolă nu poate fi null. Deci, trebuie să verifici o parolă înainte de a o hash-ui și insera în baza de date.
Trebuie să iei în calcul și că decizia alegerii unui algoritm se va reflecta în numărul de caractere pe care coloana din baza de date o va avea. Folosind PASSWORD_DEFAULT, coloana ar trebui să fie VARCHAR(255)
(pentru că nu se știe câte caractere va avea un hash în viitor). Folosind PASSWORD_BCRYPT
, coloana ar trebui să fie VARCHAR(60)
sau binary(60).
Că tot am vorbit de PASSWORD_DEFAULT
, poate ar fi bine de zis că valoarea constantei PASSWORD_DEFAULT
(algoritmul) poate fi schimbat de PHP numai în viitoare versiuni ale PHP-ului. Deci, dacă folosești (să zicem) PHP 7.1, valoarea PASSWORD_DEFAULT
va fi întotdeauna PASSWORD_BCRYPT
. La un viitor PHP 8.53 PASSWORD_DEFAULT poate fi PASSWORD_ARGON2I
sau altul.
Probabil o să zici “ce-i nebunia asta cu parola atât timp cât parola e în baza de date și doar eu am acces la ea”. Din păcate, răspunsul este că nu se pune problema dacă baza ta de date va fi accesată în viitor de persoane rău-voitoare, ci de momentul în care va fi accesată. That is.
Alte lucruri despre codul tău:
if (empty($UserName) || empty($Password))
va verifica de fapt dacă $_POST["Username"]
sau $_POST["Password"]
sunt goale. Well, și un array poate fi gol.
Imaginează-ți că tu ai codul următor:
<input type="text" name="Username" />
<input type="password" name="Password" />
Rău-făcătorul poate modifica codul cu (da, se poate face asta lejer):
<input type="text" name="Username" />
<input type="password" name="Password[OPS]" />
Având codul de mai sus, dacă utilizatorul trimite orice parolă (chiar și una goală) $Password
(adică $_POST["Password"]
) nu va fi empty pentru că conține deja o cheie “OPS” și o valoare default, goală, adică “”. Asta înseamnă că md5($Password)
va primi un array și nu un string. Treaba asta ar trebui cel puțin să arunce o eroare de tip E_NOTICE. Și nu avem nevoie de ea.
Asta înseamnă că e nevoie să verifici dacă ce a trimis utilizatorul sunt string-uri. Adică:
if (empty($UserName) || empty($Password)) {
Se va transforma în:
if (!is_string($UserName) || !is_string($Password) || empty($UserName) ||
empty($Password)) {
O altă problemă pe care o ai este că verifici doar dacă s-a făcut submit la formular. Nu verifici dacă s-au trimis Username și Password. De exemplu, dacă modific codul tău html cu următorul cod:
<input type="text" name="UsernameOPS" />
<input type="password" name="Password" />
Ar trebui să primesc o eroare. Asta pentru că $_POST["Username"]
nu este setat când spui $UserName = $_POST["Username"];
Asta este una din intențiile rău-făcătorului, să se folosească de vulnerabilitățile codului tău (să-l exploateze), ca să-și îndeplinească mai departe scopul.
Deci:
if (isset($_POST["Submit"])) {
Ar trebui să se transforme în:
if (isset($_POST["Submit"], $_POST["Username"], $_POST["Password"])) {
Ce faci e greșit pentru că nu verifici aproape deloc datele trimise de utilizator. Un utilizator ar putea trimite spații goale în cele două câmpuri. În felul ăsta, utilizatorul ajunge direct la verificarea în baza de date. Ar trebui să existe mai multe verificări pe care le faci pentru câmpurile Username și Password.
În funcția Login_Attempt selectezi utilizatorul din baza de date folosind username-ul și parola. Pentru că am vorbit mai sus despre modul în care stochezi parola în baza de date, credidențialele utilizatorului nu le vei mai putea verifica cu:
$sql = "SELECT * FROM users WHERE username=:userName AND password=:passWord LIMIT 1";
Ci folosind:
$sql = "SELECT * FROM users WHERE username=:userName LIMIT 1";
Asta pentru că parola există acum în baza de date hash-uită. Ca PHP să o poată verifica, trebuie să o și aibe.
Asta înseamnă că ar trebui să ai așa:
<?php
function Login_Attempt($UserName,$Password){
global $ConnectingDB;
$sql = "SELECT * FROM users WHERE username=:userName";
$stmt = $ConnectingDB->prepare($sql);
$stmt->bindValue(':userName',$UserName);
$stmt->execute();
$Result = $stmt->rowcount();
if($Result==1)
{
$Found_Account = $stmt->fetch();
$check_password = password_verify($Password, $Found_Account['passWord']);
if($check_password === true)
{
return $Found_Account;
}
}
}
?>
În codul de mai sus am scos “LIMIT 1
” din query pentru că utilizatorii trebuie să fie unici în tabelul bazei de date. Coloana username trebuie să fie setată ca unică.
Ți-am scos “return null
” din cod pentru că, În PHP, o funcție care nu returnează nimic va returna null. Dar ai putea s-o ți dacă te face să înțelegi codul mai bine.
Mai departe, setezi și verifici dacă un utilizator este autentificat folosind sesiuni ($_SESSION
), ceea ce e greșit și complet vulnerabil.
În array-ul global $_SESSION
nu trebuie să ții valori care să identifice un utilizator. Și, de fapt, nimic care ar trebui să fie un secret pentru aplicația ta.
Pentru ca sesiunile să funcționeze în site-ul tău trebuie să-i spui PHP-ului. Iar ca să faci asta folosești session_start()
. În momentul ăsta un ID care identifică utilizatorul a fost creat și salvat ca și un cookie. Îl găaești în consola developerilor din browser sub denumirea “PHPSESSID”.
Explic:
<?php
# Să pornim sesiunile #
session_start();
# În momentul asta, pentru că am folosit session_start mai sus, mie, ca utilizator al site-ului tău, mi se va atribui un cookie PHPSESSID cu o valoare unică ("aleatorie"). De exemplu: 9nblrhlfon0984lkoss40b909a #
# Mai departe, tu verifici utilizatorul dacă e logat folosind: #
function Confirm_Login(){
if (isset($_SESSION["UserId"])) {
return true;
} else {
$_SESSION["ErrorMessage"]="Login Required !";
Redirect_to("login.php");
}
}
?>
Eu nu pot să-mi setez singur “UserId
”, ca să par autentificat, dar pot să-mi setez singur cookie-ul PHPSESSID
(care implicit îmi va atribui sesiunea UserId
). Îmi rămâne doar să interceptez datele unui utilizator autentificat și să-mi setez PHPSESSID
-ul lui. În condițiile astea, te-ar putea salva, oarecum, directiva session.cookie_secure setată înainte de session_start().
Chiar și așa, folosirea sesiunilor este în continuare o vulnerabilitate, ID-ul sesiunii putând fi transmis mai departe printr-o altă vulnerabilitate pe care o are site-ul tău (XSS, funcții pentru redirecționări vulnerabile).
O să te întrebi acum care este alternativa. Alternativa sunt cookie-urile clasice. Numai că acum vorbim cam despre aceeași metodă de mai sus, numai că avem datele în clar. Nu vei mai putea seta un cookie cu valoarea $Found_Account[“id”], $Found_Account[“username”] șamd, pentru că sunt vizibile în clar în cookie-urile din browser.
Ori dacă cookie-urile sunt vizibile, atunci înseamnă că eu le-aș putea modifica ca să pară că sunt alt utilizator. Asta înseamnă că trebuie să criptezi valorile cookie-urilor înainte de a le seta.
Iar la fiecare accesare a utilizatorului vei decripta cookie-urile ca să poți verifica utilizatorul dacă e logat sau nu.
Reține că hashing și crypting sunt lucruri diferite. Hash-uirea unui string se poate face doar într-un sens. Pe când string-urile care au fost criptate, indiferent de metodă, ele pot fi și decriptate.
Pe lângă ce-am spus mai sus, vulnerabil în aplicația ta este și faptul că nu blochezi utilizatorul la un anumit număr de încercări eșuate. În mod normal, după 5-10 autentificări greșite, trebuie să blochezi contul utilizatorului pentru o perioadă (poate 15-30 de minute). Dacă nu faci asta, vei fi vulnerabil prin cea mai simplă metodă, brute force. Sigur, un server ar putea să te protejeze în sensul ăsta, dar nu poți să-ți lași aplicația nesigură pe principiul că se ocupă altcineva de o problemă pe care chiar o ai.
Închei aici pentru că sunt prea multe probleme în codul tău și pun pariu că toată aplicația e așa. Da, mai sunt și altele, cum ar fi că mesajul care îi spune utilizatorului că datele de autentificare sunt greșite trebuie să fie general. Nu trebuie să existe mai mult de un mesaj pentru a-i spune utilizatorului că a completat greșit datele. Mă rog. Ai putea să faci asta, dar nu în modul prezentat de tine. Nu trebuie să-i spui utilizatorului că a trecut un hop și a ajuns la un altul.
În fine. Dacă faci o aplicație de care nu te interesează prea mult, keep going. E bine.
Dacă o faci, în schimb, cu gândul de a fi o aplicație bine construită și sigură, sorry, dar poți să pui tot ce ai făcut în Recycle Bin.
Nici nu vreau să știu dacă site-ul la care lucrezi urmează să fie accesibil prin protocolul HTTPS. Dacă nu folosești HTTPS, e ca și cum ai căra un sac de cartofi spart.