Biztonságos bejelentkezés

Biztonságos bejelentkezés

Biztonságos bejelentkezés

Egy weboldalnak számos sebezhetőségi pontja van. Nem csak a bejelentkezési űrlapok jelenthetnek kockázatot, de a probléma megelőzésére itt is nagy figyelmet kell fordítani. Legjellemzőbb támadások, hogy egy vagy több bot addig próbálgatja a jelszavakat egy megszerzett felhasználónévvel párosítani, míg a rendszer be nem lépteti. Vagy megpróbálkozik egy SQL injection támadást végrehajtani.

Mik is ezek pontosan és hogyan működnek?

A bot esetén szükség van egy ismert felhasználónévre. Tegyük fel, hogy az adminisztrátor jelszavát szeretnénk megfejteni, akkor csak meg kell néznünk a weboldalon, hogy az adminisztrátor milyen néven publikált tartalmakat. Persze ez csak ott fog működni, ahol a bejelentkezéshez a publikusan látható nevet használja bejelentkezési felhasználónévnek. Fórumokon sok esetben a publikusan látható felhasználónév a bejelentkezésre is használt felhasználónév, tehát ennél az esetnél máris lehet próbálkozni a jelszó megfejtésével.

Az SQL injection támadás lényege pedig az, hogy egy olyan SQL utasítást írjunk be a felhasználónévnek, ami feldolgozáskor le is fut és a mi akaratunk szerint zajlik az adatbázis lekérdezése. Ebben az esetben akkor is be tudunk lépni, ha nem tudjuk se a felhasználónevet, sem pedig a jelszót.

Szerencsére mind a kettő támadásra van védekezési módszer

Vegyünk egy teljesen alap bejelentkezést feldolgozó PHP szkriptet. Megvizsgálja, hogy valóban megnyomták-e a "login" nevű gombot az űrlapon, majd megvizsgálja, hogy minden mező ki van-e töltve. Végezetül pedig maga a lekérdezés, amiből kiderül, hogy ezzel a felhasználónév és jelszó párossal van-e felhasználó az adatbázisban. Ha hibás adatot adnak meg, soha ne írjuk ki, hogy melyik adat volt hibás, mert abból már kikövetkeztethető, hogy valamelyik adatot sikerült kitalálni. Ez egy kisebb biztonsági rést jelenthet.

PHP


<?php
$GLOBALS['connect'] = mysqli_connect('localhost', 'felhasználónév', 'jelszó', 'adatbázis');

if (filter_input(INPUT_POST, 'login'))
	{
	if ((filter_input(INPUT_POST, 'username')) && (filter_input(INPUT_POST, 'password')))
		{
		$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE username = '".$_POST['username']."' AND password = '".md5($_POST['password'])."' LIMIT 1");
		if (mysqli_num_rows($select_user) == 1)
			{
			$data_user = mysqli_fetch_assoc($select_user);
			echo '<p class="alert alert-success">'.$data_user['username'].' sikeresen bejelentkezett!</p>';
			}
		else
			{
			echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
			}
		}
	else
		{
		echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
		}
	}
?>

Az a probléma ezzel a megoldással, hogy ha például a felhasználónév helyére ezt írják be: ' OR 1=1 LIMIT 1--, akkor az alábbi SQL lekérdezést fogjuk kapni, melynek az eredménye mindig találat lesz.

PHP


$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE username = '' OR 1=1 LIMIT 1-- AND password = '".md5($_POST['password'])."' LIMIT 1");

A felhasználónév egyenlő semmivel, vagy 1 egyenlő 1-el. Ez mindig igaz lesz. A 2 darab "-"-jel pedig az SQL-ben a komment nyitása. Tehát a mögötte lévő feltételt nem fogja futtatni a szerver. Ennek a módszernek számos kombinációja létezik még, így nem fogunk rá egyesével megoldást írni, hanem egyszerre próbáljuk megszüntetni a probléma forrását.

Megoldás

Egyik népszerű megoldás, hogy zárójeleket teszünk bele a lekérdezésbe, melyet feltehetően nem fog lezárni a támadó az SQL injection során. Így a szintaktikailag hibás lesz a lekérdezés és nem fog lefutni.

PHP


$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((username = '".$_POST['username']."'))) AND (((password = '".md5($_POST['password'])."'))) LIMIT 1");

Azért tettem bele több zárójelet is, mert egyre még talán van esély, hogy kitalálja, több esetén már kisebb a valószínűsége. Sőt akár még kombinálhatjuk is a zárójeleket, hogy biztosabbak legyünk a dolgunkban. 2 zárójelet teszek a 2 paraméter együttes ellenőrzéséhez is.

PHP


$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((((username = '".$_POST['username']."'))) AND (((password = '".md5($_POST['password'])."'))))) LIMIT 1");

A megoldást tovább bővíthetjük, hogy egy függvény segítségével kiszűrjük a kártékony utasításokat az elküldött adatokból.

PHP


<?php
$GLOBALS['connect'] = mysqli_connect('localhost', 'felhasználónév', 'jelszó', 'adatbázis');

function clear_tags($text)
	{
	$text = strip_tags($text);
	$text = htmlspecialchars($text);

	return $text;
	}

function clear_sql_injection($text)
	{
	$text = stripslashes($text);
	$text = mysqli_real_escape_string($GLOBALS['connect'], $text);

	return $text;
	}

if (filter_input(INPUT_POST, 'login'))
	{
	/*
	foreach ($_POST as $index => $value)
		{
		$_POST[$index] = clear_tags($_POST[$index]);
		$_POST[$index] = clear_sql_injection($_POST[$index]);
		}
	*/

	//A fenti kikommentelt kódrész ugyan azt valósítja meg, mint az "array_map" függvény, de a példa kedvéért bemutatom mind a két megoldást.
	$_POST = array_map('clear_tags', $_POST);
	$_POST = array_map('clear_sql_injection', $_POST);

	if ((filter_input(INPUT_POST, 'username')) && (filter_input(INPUT_POST, 'password')))
		{
		$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((((username = '".$_POST['username']."'))) AND (((password = '".md5($_POST['password'])."'))))) LIMIT 1");
		if (mysqli_num_rows($select_user) == 1)
			{
			$data_user = mysqli_fetch_assoc($select_user);
			echo '<p class="alert alert-success">'.$data_user['username'].' sikeresen bejelentkezett!</p>';
			}
		else
			{
			echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
			}
		}
	else
		{
		echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
		}
	}
?>

A "clear_sql_injection" nevű függvény segítségével kiszűrtük az SQL utasítást gátló, illetve megzavaró parancsok bejutását, illetve minden beírt aposztróf elé elhelyeztünk egy "\" jelet. A "clear_tags" függvény pedig kiszűri az XSS (Cross-site scripting) támadásokra használt HTML és JavaScript kódok bejutását. Bár ez ebben a példában közvetlenül nem érint minket, de a bemutatását fontosnak éreztem. Az XSS támadás lényege az, hogy nem az SQL utasítást igyekszik felborítani, hanem, amikor egy lekérdezés megtörténik és annak eredményeit szeretnénk a weboldalunkon megjeleníteni, a támadás az adat kiírásakor fog lefutni. Alkalmazása főleg ott javasolt, ahol a felhasználó olyan adatot írhat be, ami a weboldalon valahol megjelenhet. Ilyen lehet a regisztráció során megadott felhasználónév, vagy egy fórum bejegyzés.

De még ezt is fokozhatjuk azzal, ha nem csak a jelszót, de a felhasználónevet is tároljuk titkosított módon. Tehát az adatbázisban szerepel a felhasználónév olvasható verzióban és titkosított verzióba is és a bejelentkezéskor a titkosított verziót ellenőrizzük. Mutatom, hogy mire gondolok.

PHP


<?php
$GLOBALS['connect'] = mysqli_connect('localhost', 'felhasználónév', 'jelszó', 'adatbázis');

function clear_tags($text)
	{
	$text = strip_tags($text);
	$text = htmlspecialchars($text);

	return $text;
	}

function clear_sql_injection($text)
	{
	$text = stripslashes($text);
	$text = mysqli_real_escape_string($GLOBALS['connect'], $text);

	return $text;
	}

if (filter_input(INPUT_POST, 'login'))
	{
	$_POST = array_map('clear_tags', $_POST);
	$_POST = array_map('clear_sql_injection', $_POST);

	if ((filter_input(INPUT_POST, 'username')) && (filter_input(INPUT_POST, 'password')))
		{
		$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((((username_hash = '".md5($_POST['username'])."'))) AND (((password = '".md5($_POST['password'])."'))))) LIMIT 1");
		if (mysqli_num_rows($select_user) == 1)
			{
			$data_user = mysqli_fetch_assoc($select_user);
			echo '<p class="alert alert-success">'.$data_user['username'].' sikeresen bejelentkezett!</p>';
			}
		else
			{
			echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
			}
		}
	else
		{
		echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
		}
	}
?>

Nos ebben az esetben bármit is szerettek volna beírni a beviteli mezőbe az garantáltan nem fog lefutni.

De mi van a több próbálkozásos módszerrel?

Ez a megoldás eddig minket csak az SQL injection támadástól védd meg, de van megoldás a több próbálkozásos támadásra is. Abban ne is gondolkodjunk, hogy IP címet szeretnénk szűrni, mert ha a botot okosan írták meg minden támadás esetén más IP címmel fogja végrehajtani a támadást, vagy legalábbis sűrűn fogja változtatni azt.

Az én megoldásom az, hogy egy adatbázis rekordban tároljuk le a bejelentkezés próbálkozásait. Ha túl sűrűn érkeznek a próbálkozások és többnyire hibásak, akkor egyszerűen ne engedjük a bejelentkezést megtörténni.

PHP


<?php
$GLOBALS['connect'] = mysqli_connect('localhost', 'felhasználónév', 'jelszó', 'adatbázis');

function clear_tags($text)
	{
	$text = strip_tags($text);
	$text = htmlspecialchars($text);

	return $text;
	}

function clear_sql_injection($text)
	{
	$text = stripslashes($text);
	$text = mysqli_real_escape_string($GLOBALS['connect'], $text);

	return $text;
	}

if (filter_input(INPUT_POST, 'login'))
	{
	$_POST = array_map('clear_tags', $_POST);
	$_POST = array_map('clear_sql_injection', $_POST);

	if ((filter_input(INPUT_POST, 'username')) && (filter_input(INPUT_POST, 'password')))
		{
		$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((((username_hash = '".md5($_POST['username'])."'))) AND (((password = '".md5($_POST['password'])."'))))) AND login_access <= '".time()."' LIMIT 1");
		if (mysqli_num_rows($select_user) == 1)
			{
			$data_user = mysqli_fetch_assoc($select_user);
			echo '<p class="alert alert-success">'.$data_user['username'].' sikeresen bejelentkezett!</p>';
			}
		else
			{
			echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';

			$select_log = mysqli_query($GLOBALS['connect'], "SELECT * FROM login_log WHERE username_hash = '".md5($_POST['username'])."' AND time >= '".(time() - 360)."' ORDER BY time ASC LIMIT 3");
			if (mysqli_num_rows($select_log) == 3)
				{
				$i = 1;
				$time_interval = 0;
				while ($data_log = mysqli_fetch_assoc($select_log))
					{
					if ($i == 1)
						{
						$time_interval = $data_log['time'];
						}
					elseif ($i == 2)
						{
						$time_interval -= $data_log['time'];
						}

					$i++;
					}

				if ($time_interval < 60)
					{
					mysqli_query($GLOBALS['connect'], "UPDATE users SET login_access = '".(time() + 3600)."' WHERE (((username_hash = '".md5($_POST['username'])."'))) LIMIT 1");
					}
				}
			else
				{
				mysqli_query($GLOBALS['connect'], "INSERT INTO login_log (username, time) VALUES ('".$_POST['username']."', '".time()."')");
				}
			}
		}
	else
		{
		echo '<p class="alert alert-danger">A bejelentkezés nem sikerült. A felhasználónév és jelszó páros nem volt megfelelő!</p>';
		}
	}
?>

A megoldás lényege, hogy bejelentkezéskor azt is ellenőrizzük, hogy volt-e már támadva a felhasználó. Erre a célra beszúrtam egy új adatbázis rekordot, a "login_access"-t, mely egy időbélyeget tárol. Ha a felhasználónál túl gyakoriak voltak a sikertelen próbálkozások 1 órára letiltja a felhasználó esetén a bejelentkezést. Tehát még sikeres felhasználónév jelszó páros esetén is hibás bejelentkezést fog visszaadni. A tiltás tényéről nem szabad informálni a támadót, hiszen a cél az, hogy ne ismerje fel , ha amúgy véletlen el is találta volna a megfelelő bejelentkezési adatokat. A tényleges felhasználót esetleg lehet egy automata e-mailben értesíteni, hogy egy bizonyos időkorlátig nem fog tudni bejelentkezni. Ha lejárt a tiltási idő, akkor természetesen újra sikeres lesz a bejelentkezés.

Amikor nem sikerül a bejelentkezés, akkor a próbálkozást elmentjük egy logolásra használt táblába. Mi a felhasználónevet és a próbálkozás idejét mentettük le. Megvizsgáljuk, hogy a legutóbbi 3 próbálkozás 120 másodpercen belül volt-e. Ha igen, akkor feltételezzük, hogy támadják a felhasználó belépési adatait és nem is készítjük tovább a log bejegyzéseket, mert feleslegesen kár növelni az adatbázisunk méretét.

Ha biztosak vagyunk abban, hogy volt 3 próbálkozás 120 másodpercen belül, akkor megvizsgáljuk, hogy milyen idő intervallumok teltek el a próbálkozások között. Ezt úgy nézzük meg, hogy a legnagyobb számból kivonjuk a rákövetkező kisebb számot. Ha a kapott időintervallum, nagyon kis időnek tűnik ahhoz, hogy valóban ember gépelje be, akkor egy SQL utasítással megnövelem a bejelentkezés tiltását szolgáló rekord értékét 1 órával. A következő próbálkozás ezen már garantáltan fenn fog akadni.

De itt még nincs vége!

A példát végig felhasználónév jelszó párossal mutattam be, hogy szemléltetni tudjam a különböző védekezési megoldásokat. Gondolom azt már te is észrevetted, hogy az azonosításhoz a legtöbb helyen nem felhasználónevet, hanem e-mail címet kérnek be. Ez levesz még egy gondot a vállunkról. Ugyanis, ha az e-mail cím ellenőrzésekor megbukik a próbálkozás, a támadás el sem jut az SQL utasításig. Ráadásul az e-mail címet viszonylag nehezebb megszerezni, mert ritkábban publikálják weboldalon, mint a felhasználónevet. Az e-mail cím megbízható ellenőrzésére korábban már mutattam be egy leírást, így most is ezt fogom használni. Illusztrálom példával is.

PHP


<?php
$GLOBALS['connect'] = mysqli_connect('localhost', 'felhasználónév', 'jelszó', 'adatbázis');

function clear_tags($text)
	{
	$text = strip_tags($text);
	$text = htmlspecialchars($text);

	return $text;
	}

function clear_sql_injection($text)
	{
	$text = stripslashes($text);
	$text = mysqli_real_escape_string($GLOBALS['connect'], $text);

	return $text;
	}

if (filter_input(INPUT_POST, 'login'))
	{
	$_POST = array_map('clear_tags', $_POST);
	$_POST = array_map('clear_sql_injection', $_POST);

	$email_slug_array = explode('@', $_POST['email']);
	$domain = array_pop($email_slug_array);

	if ((filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL)) && ((checkdnsrr($domain, 'MX')) || (checkdnsrr($domain, 'A'))) && (filter_input(INPUT_POST, 'password')))
		{
		$select_user = mysqli_query($GLOBALS['connect'], "SELECT * FROM users WHERE (((((email = '".$_POST['email']."'))) AND (((password = '".md5($_POST['password'])."'))))) AND login_access <= '".time()."' LIMIT 1");
		if (mysqli_num_rows($select_user) == 1)
			{
			$data_user = mysqli_fetch_assoc($select_user);
			echo '<p class="alert alert-success">'.$data_user['username'].' sikeresen bejelentkezett!</p>';
			}
		else
			{
			echo '<p class="alert alert-danger">'A bejelentkezés nem sikerült. Az e-mail cím és jelszó páros nem volt megfelelő!</p>';

			$select_log = mysqli_query($GLOBALS['connect'], "SELECT * FROM login_log WHERE email = '".$_POST['email']."' AND time >= '".(time() - 360)."' ORDER BY time ASC LIMIT 3");
			if (mysqli_num_rows($select_log) == 3)
				{
				$i = 1;
				$time_interval = 0;
				while ($data_log = mysqli_fetch_assoc($select_log))
					{
					if ($i == 1)
						{
						$time_interval = $data_log['time'];
						}
					elseif ($i == 2)
						{
						$time_interval -= $data_log['time'];
						}

					$i++;
					}

				if ($time_interval < 60)
					{
					mysqli_query($GLOBALS['connect'], "UPDATE users SET login_access = '".(time() + 3600)."' WHERE (((email = '".$_POST['email']."'))) LIMIT 1");
					}
				}
			else
				{
				mysqli_query($GLOBALS['connect'], "INSERT INTO login_log (email, time) VALUES ('".$_POST['email']."', '".time()."')");
				}
			}
		}
	else
		{
		echo '<p class="alert alert-danger">'A bejelentkezés nem sikerült. Az e-mail cím és jelszó páros nem volt megfelelő!</p>';
		}
	}
?>

Tovább is van, mondjam még?

Ha azt hinnéd, hogy ezt nem lehet tovább fokozni, akkor tévedsz. Lehet még egy biztonsági szintet tenni erre az egészre azzal, hogy amikor észleltük, hogy egy bot próbálkozik a bejelentkezéssel és lefut az 1 órás tiltást végző kódrészlet, a tiltás helyett egyszerűen csak megváltoztatjuk a felhasználó jelszavát arra a jelszóra, amivel már próbálkozott egyszer a bot. Ebben az esetben az időszakos tiltás elhagyható, mivel a bot feltehetően nem fog még egyszer azzal a jelszóval próbálkozni, ami már egyszer sikertelen volt számára. Ennek a megoldás az egyetlen kényelmi hátulütője, hogy a megváltozott jelszóról értesíteni kell a felhasználót. Ez az ő szemszögéből rendkívül kényelmetlen lehet. Viszont ezzel azt a nagyon minimális esélyt is kizárjuk, hogy a bot pont akkor találja ki a megfelelő jelszót, amikor megtörténik a feloldás és van újabb 3 lehetősége próbálkozni a következő letiltás előtt. Megjegyzem ennek tényleg borzalmasan kicsi az esélye, de megtörténhet. Ezért is írtam le, viszont a használata nem elterjedt és a kényelmetlensége okán nem is ajánlott.

Végezetül ezt még kibővíthetjük az előző leírásban bemutatott titkosítási módszereket, melyek helyettesíthetik az md5 titkosítási eljárását.

A letölthető demo tartalmazza a leírásban olvasott PHP kódot, és a kipróbáláshoz szükséges űrlapot és adatbázist is.

Demo letöltése

Leírásaink azon kezdő és haladó programozóknak nyújtanak segítséget, akik már minimális szinten foglalkoztak weboldalkészítéssel. Ha szeretnél jobban elmélyülni a témában, vagy elsajátítani alapokat, még tovább fejlődni, akkor nézz körbe tanfolyam kínálatunkban, ahol a kezdőtől a profi szintig nyújtunk képzéseket a számodra.

Oszd meg barátaiddal is!

Facebook Twitter Linkedin

Elérhetőségeink

  • Címünk: 1139 Budapest, Frangepán utca 3. (1. emelet)

  • Ügyfélfogadás, beiratkozás: Hétfőtől - péntekig: 09:00-17:00

  • Telefonszámunk: 06 70 604 2060, vagy 06 1 4500 110

  • E-mail címünk:

Közösségünk