Sistem de logare securizat

Sunt incepator si incerc sa fac un mic site
Este sigur acest sistem de logare?

if (isset($_POST["Submit"])) {
    $UserName = $_POST["Username"];
    $Password = $_POST["Password"];
    if (empty($UserName) || empty($Password)) {
        $_SESSION["ErrorMessage"] = "All fields must be filled out";
        Redirect_to("login.php");
    } else {
        // code for checking username and password from Database
        $Found_Account = Login_Attempt($UserName, $Password);
        if ($Found_Account) {
            $_SESSION["UserId"] = $Found_Account["id"];
            $_SESSION["UserName"] = $Found_Account["username"];
            $_SESSION["AdminName"] = $Found_Account["aname"];
            $_SESSION["SuccessMessage"] = "Wellcome " . $_SESSION["AdminName"] . "!";
            if (isset($_SESSION["TrackingURL"])) {
                Redirect_to($_SESSION["TrackingURL"]);
            } else {
                Redirect_to("index.php");
            }
        } else {
            $_SESSION["ErrorMessage"] = "Incorrect Username/Password";
            Redirect_to("login.php");
        }
    }
}

function Login_Attempt($UserName,$Password){
  global $ConnectingDB;
  $sql = "SELECT * FROM users WHERE username=:userName AND password=:passWord LIMIT 1";
  $stmt = $ConnectingDB->prepare($sql);
  $stmt->bindValue(':userName',$UserName);
  $stmt->bindValue(':passWord',md5($Password));
  $stmt->execute();
  $Result = $stmt->rowcount();
  if ($Result==1) {
    return $Found_Account=$stmt->fetch();
  }else {
    return null;
  }
}
function Confirm_Login(){
if (isset($_SESSION["UserId"])) {
  return true;
}  else {
  $_SESSION["ErrorMessage"]="Login Required !";
  Redirect_to("login.php");
}
}

Cum as putea sa il fac mai securizat?
As vrea sa sterg sesiunile utilizatorilor daca sterg baza de date, sau daca schimb parola unui utilizator sa ii sterg si sesiunea

Nu ma pricep la securitate, dar cred ca md5 nu ar mai trebui folosit.

Pentru restul de detalli, ii las pe colegii mai experimentati :slight_smile:

1 Like

Niște observații:

Evită notices:

if (isset($_POST["Submit"])) {
-    $UserName = $_POST["Username"];
+    $UserName = $_POST["Username"] ?? null;
-    $Password = $_POST["Password"];
+    $Password = $_POST["Password"] ?? null;

Nu știu cum faci la înregistrare, dar probabil aici ar trebui escape/sanitizare:

$_SESSION["UserName"] = $Found_Account["username"];

Sfaturi ce nu le-ai cerut

Ca idee (cred că am mai zis pe undeva): încearcă să return early cât mai des, vei avea codul mult mai ușor de urmărit. În primul bloc de text, până ajungi la Redirect_to("login.php"); te ia amețeala :slight_smile:

-if (isset($_POST["Submit"])) {
-    $UserName = $_POST["Username"];
-    $Password = $_POST["Password"];
+if (empty($_POST["Submit"])) {
+ throw/die/return

O convenție în PHP este unde pui majuscule:

  • dacă este clasă, începe cu majuscule
  • dacă este constantă, este doar cu majuscule
  • dacă este variabilă/funcție, începe cu minuscule.

md5 este căzut în dizgrație de vreo zece ani, dacă nu mai mult. Best practice este să folosești bcrypt (prin password_hash sau direct). Vezi cum se folosește și salt.


Încearcă tot timpul să ai comparații stricte: if ($Result==1) vs if ($Result===1)

3 Likes

Mama, rulezi query cu datele direct din post?

La o primă vedere pare “sigur”, dar securitatea ține de mai multe chestii și e foarte relativă.

Codul e destul de greu de urmărit (sunt 5 nivele de indentare acolo), dar logica pare OK.

Subliniez “pare”, pentru că nu poate zice nimeni că totul e OK judecând după porțiunea de cod pe care ai expus-o tu. Poate să fie alte chestii în afara codul prezentat care să aibă probleme de securitate.

Exemple:

  • Nu știm sigur dacă Redirect_to() conține exit și dacă nu conține, ce se mai execută după codul prezentat de tine.
  • O problemă ce ține de configurația serverului: unde se salvează sesiunile.
    Default cred că PHP e configurat să creeze fișiere în ceva folder. Dacă cineva are acess la folderul respectiv se poate să fure sesiuni. Asta iar, presupunând că PHP e configurat default să pună sessionId-ul în cookie pe care fiecare poate să-l rescrie în browser.
  • Stocarea datelor: când vine vorba de securitate, trebuie să te gândești la ce se întâmplă dacă cineva are acess la baza de date. Folosind md5 simplu e foarte simplu de ghicit parola cuiva dacă-i găsești hash-ul. Asta pentru că md5-ul e foarte vechi și există deja precalculate “rainbow tables” care să-ți zică ce parolă îți dă un anumit hash (nu e nepărat parola userului, dar poate fi folosită pentru login). Pentru asta trebuie să folosești algoritmi mai noi cum a sugerat @iamntz sau măcar să folosești un salt unic pentru fiecare utilizator.
  • Folosești https sau nu? Fără https cineva poate să vadă parola trimisă ca text către server doar fiind în aceiași rețea cu utilizatorul. Cel mai simplu e să folosești https. Dacă vrei să te complici și să faci secure pe http atunci implementează ceva ca aici: Steam's login method is kinda interesting
  • Există protecție împotriva unui “Brute-force_attack”? În care cineva încearcă să ghicească parola unui user.
  • Există protecție împotriva unei sesiuni furate? Dacă cineva are acess la sesiunea unui utilizator, poate să o refolosească? Asta de obicei se rezolvă destul de greu făcând verificări de genul IP (nu e relevant dacă vrei să meargă și pe mobil site-ul), user-agent (browser-ul), țara de unde IP-ul și unde a fost autentificat ultima oară sau implementând Multi factor authentication.
  • Cross-site request forgery

Pentru asta cel mai simplu ar fi să salvezi sesiunile într-o tabelă în baza de date.
Standardul în industrie momentan e să setezi un cookie pentru utilizator cu un sessionId. Important să fie ceva hash unic și nu un număr pe care-l incrementezi tu că atunci cineva poate să-și genereze singur cookie-ul și fure sesiunile altora. Pentru fiecare pagină care are cookie-ul respectiv setat să încarci sesiunea și datele din tabela respectivă.

După ce faci asta o să fie ușor să revoci sesiuni sau să le ștergi, să faci backup etc…

Concluzia
Teoretic codul tău e ok, dar practic sunt foarte multe lucruri pe care le poți îmbunătăți și sunt multe lucruri care n-au treabă cu codul care să cauzeze probleme de securitate.

În funcție de proiect (cât de importantă e securitatea) și de bugetul (timp/bani) pe care-l ai la dispoziție decizi cât de în detaliu vrei să mergi cu chestiile astea. E greu sau chiar imposibil să faci ceva 100% sigur. Tot timpul o să fie scăpări. Ce poți decide în schimb e cât de departe vrei să mergi. Cât de important este securitatea pentru proiect (de obicei iei în calcul ce se poate întâmpla dacă ceva merge rău și cât de mult ai de suferit comparat cu cât trebuie să investești).

Dacă vrei, putem discuta în detaliu fiecare topic de mai sus.

5 Likes

Folosește PDO prepared statements, ar trebui să fie destul de OK dacă vorbim de SQL Injection.

Even though we’re talking about theoretical threats, non-emulated prepared statements completely eliminate the possibility of an SQL injection attack. The query with the dummy placeholders is sent to the server first, followed by the values to bind — the query and data are completely isolated.

sursa

1 Like

Foloseste PHPTHERIGHTWAY → PHP: The Right Way

Si alta intrebare ar fi unde stochezi sesiunea? Daca o stochezi in cookie pe client plain si mai ales protocolul de transmitere e HTTP si nu e HTTPS, atunci pe backend poti sa faci cate validari vrei, cine isi seteaza acelasi cookie cu cineva care deja s-a logat e identificat la fel.

Fair enough.

1 Like

PASSWORD_ARGON2ID nu bcrypt

Cum as putea salva sesiunea in baza de date mysql?

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.

8 Likes

Daca ajungi sa implementezi resetarea parolei cu un token, vezi sa pui sa expire acel token dupa X minute.

1 Like

Un rezumat legat de securitate:
În codul prezentat lipsește encriptarea.
Parola trebuie să fie encriptată înainte de a fi stocată în baza de date. Adică când își face cont.

Iar verificarea se face în ordinea asta: se encriptează parola trimisă de utilizator, apoi se compară cu ce există în baza de date.

1 Like

Cea mai scurtă implementare necesită să creezi o tabelă cu următoarele câmpuri

  • ssid (varchar) - session id
  • created (integer) - timestamp când a fost creată sesiunea
  • last_access (integer) - timestamp când a fost accesată ultima oară sau modificată
  • data (blob) - datele pe care vrei să le salvezi (serializate)

Te folosești de handlerul default de PHP ca și până acum să-ți genereze el Session ID-ul (și să salveze cookie-ul). Însă apelezi în fiecare pagină următoarele:

// încarcă datele din baza de date în $sessionData
load_session_data();

// înainte de termina scriptul, asigură-te că datele sunt salvate în database
register_shutdown_function('save_session_data');

// Apoi, în loc să folosești `$_SESSION` poți să folosești `$sessionData`:
global $sessionData;
$sessionData["ErrorMessage"] = "All fields must be filled out";

Codul pentru load și save ar trebui să arate cam așa (nu am verificat, e posibil să conțină greșeli):

cod save/load
function load_session_data(): 
{
  global $sessionData;

  // If data was already loaded, end here
  if (isset($sessionData)) {
    return;
  }
  
  // There is no session set yet
  if (session_id() === '') {
    $sessionData = [];
    return;
  }

  global $ConnectingDB;
  $stmt = $ConnectingDB->prepare("SELECT data FROM session WHERE ssid=:ssid LIMIT 1");
  $stmt->bindValue(':ssid', session_id());
  $stmt->execute();

  // nothing for this ssid in database yet
  if ($stmt->rowcount() == 0) {
    $sessionData = []; 
    return;
    
  }
  
  $sessionRow = $stmt->fetch(PDO::FETCH_ASSOC);
  $sessionData = unserialize($sessionRow['data']);

  // in case unserialized failed (E_NOTICE already issued)
  if ($sessionData === false) {
    $sessionData = []; 
  }
}

function save_session_data()
{
  if (session_id() === '') {
    return; // no session set, do not create empty records in database
  }
  
  global $sessionData;
  global $ConnectingDB;
  $stmt = $ConnectingDB->prepare("SELECT 1 FROM session WHERE ssid=:ssid LIMIT 1");
  $stmt->bindValue(':ssid', session_id());
  $stmt->execute();
  
  if ($stmt->rowcount() == 0) { // create new session record
    $stmt = $ConnectingDB->prepare("INSERT INTO session (ssid, created, last_access, data) VALUES (:ssid, :created, :last_access, :data)");
    $stmt->bindValue(':ssid', session_id());
    $stmt->bindValue(':created', time());
    $stmt->bindValue(':last_access', time());
    $stmt->bindValue(':data', serialzied($sessionData));
    $stmt->execute();
    return;
  }

  // update
  $stmt = $ConnectingDB->prepare("UPDATE session SET last_access = :last_access, data = :Data WHERE ssid = :ssid");
  $stmt->bindValue(':ssid', session_id());
  $stmt->bindValue(':created', time());
  $stmt->bindValue(':last_access', time());
  $stmt->bindValue(':data', serialzied($sessionData));
  $stmt->execute();
}

PS: Există și varianta cu session_set_save_handler(), însă din ce am văzut pe StackOverflow are o grămadă de particularități și e ușor de greșit cu ea. Dacă nu folosești un framework care să se ocupe de asta codul de mai sus e mai ușor de implementat.

2 Likes