Login - Brute force protection - PDO
Ik ben bezig een loginscript te updaten van mysqli naar PDO. Nu ben ik met behulp van het loginscript van Jeroen VD van 3 jaar geleden een heel eind gekomen, maar ik krijg de inlog-attempt-controle niet aan de praat.
Het script:
Code (php)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php
ini_set('display_errors',1); // 1 == on , 0 == off
error_reporting(E_ALL | E_STRICT);
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$errors = array();
if (trim($_POST['username']) == ''
or (!preg_match('~^[0-9xyzXYZ][0-9]{7}[A-Za-z]$~',($_POST['username']))))
$errors['username'] = 'Rellena número del NIE o NIF';
if (trim($_POST['password']) == ''
or (!preg_match('~^[0-9]{4}$~', $_POST['password'])))
$errors['password'] = 'Rellena número socio.';
if (!empty($_POST['name']))
$errors['name'] = 'No valido';
if (count($errors) == 0) {
//Start procedure
try {
//Set maximum attempts to login om 3 within 5 minutes
$maxAttempts = 3;
$attemptsTime = 5;
//Connect to the dBase
require_once ('sql_link_sherpa.php');
//Make the query
$sql_select = "SELECT id, nombre, pass FROM socios WHERE dni = :dni";
$userStmt = $db->prepare($sql_select);
$userStmt->execute(array(
':dni' => $_POST['username']
));
$results = $userStmt->fetchAll();
//Get logintries for the ultimate 5 minutes
$checkTries = "SELECT username FROM loginfail
WHERE DateAndTime >= NOW() - INTERVAL :attemptsTime MINUTE
AND username = :username
GROUP BY username, IP
HAVING (COUNT(username) = :maxAttempts)";
$triesStmt = $db->prepare($checkTries);
$triesStmt->execute(array(
':username' => $_POST['username'],
':attemptsTime' => $attemptsTime,
':maxAttempts' => $maxAttempts
));
$tries = $triesStmt->fetchAll();
foreach ($results as $result) {
//Check password and set session values
if (count($results) == 1 AND count($tries) <=3) {
if (count($tries) > 3) {
header ('Refresh: 3; url=index.php');
echo 'Too many tries';
exit();
}
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
$insertTry = "INSERT
INTO loginfail (username, IP, DateAndTime)
VALUES ( :username, :IP, NOW())";
$insertStmt = $db->prepare($insertTry);
$insertStmt->execute(array(
':username' => $_POST['username'],
':IP' => $_SERVER['REMOTE_ADDR']
));
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
header("Location: index_socio.php");
exit();
}
}
}
catch(PDOException $e) {
$sMsg = '<p>
Linenumber: '.$e->getLine().'<br />
File: '.$e->getFile().'<br />
Errormessage: '.$e->getMessage().'
</p>';
trigger_error($sMsg);
}
}
}
?>
ini_set('display_errors',1); // 1 == on , 0 == off
error_reporting(E_ALL | E_STRICT);
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$errors = array();
if (trim($_POST['username']) == ''
or (!preg_match('~^[0-9xyzXYZ][0-9]{7}[A-Za-z]$~',($_POST['username']))))
$errors['username'] = 'Rellena número del NIE o NIF';
if (trim($_POST['password']) == ''
or (!preg_match('~^[0-9]{4}$~', $_POST['password'])))
$errors['password'] = 'Rellena número socio.';
if (!empty($_POST['name']))
$errors['name'] = 'No valido';
if (count($errors) == 0) {
//Start procedure
try {
//Set maximum attempts to login om 3 within 5 minutes
$maxAttempts = 3;
$attemptsTime = 5;
//Connect to the dBase
require_once ('sql_link_sherpa.php');
//Make the query
$sql_select = "SELECT id, nombre, pass FROM socios WHERE dni = :dni";
$userStmt = $db->prepare($sql_select);
$userStmt->execute(array(
':dni' => $_POST['username']
));
$results = $userStmt->fetchAll();
//Get logintries for the ultimate 5 minutes
$checkTries = "SELECT username FROM loginfail
WHERE DateAndTime >= NOW() - INTERVAL :attemptsTime MINUTE
AND username = :username
GROUP BY username, IP
HAVING (COUNT(username) = :maxAttempts)";
$triesStmt = $db->prepare($checkTries);
$triesStmt->execute(array(
':username' => $_POST['username'],
':attemptsTime' => $attemptsTime,
':maxAttempts' => $maxAttempts
));
$tries = $triesStmt->fetchAll();
foreach ($results as $result) {
//Check password and set session values
if (count($results) == 1 AND count($tries) <=3) {
if (count($tries) > 3) {
header ('Refresh: 3; url=index.php');
echo 'Too many tries';
exit();
}
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
$insertTry = "INSERT
INTO loginfail (username, IP, DateAndTime)
VALUES ( :username, :IP, NOW())";
$insertStmt = $db->prepare($insertTry);
$insertStmt->execute(array(
':username' => $_POST['username'],
':IP' => $_SERVER['REMOTE_ADDR']
));
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
header("Location: index_socio.php");
exit();
}
}
}
catch(PDOException $e) {
$sMsg = '<p>
Linenumber: '.$e->getLine().'<br />
File: '.$e->getFile().'<br />
Errormessage: '.$e->getMessage().'
</p>';
trigger_error($sMsg);
}
}
}
?>
Het script werkt voorzover als het de passwordcontrole betreft, de meldingen etc. worden correct weergegeven. Is het password fout, dan wordt dit in de database weggeschreven, dus ook hier geen probleem. Alleen krijg ik het niet werkend dat je na 3 pogingen binnen 5 minuten, geen inlogpoging meer kunt doen.
Iemand enig idee?
Excuus dat het script een beetje rommelig eruitziet wat betreft inspringingen. Dat komt door het knippen en plakken.
Gewijzigd op 09/08/2014 20:13:22 door Tortuga web
De tweede query levert volgens mij ook veel teveel rijen op. Het enige wat je volgens mij wilt weten is of er binnen de afgelopen tijd een bepaald aantal log in pogingen is geweest. Je kan eventueel meerdere verschillende pogingen tellen (bijvoorbeeld zelfde ip, zelfde username, of ip en username), maar je eindresultaat van die query zou gewoon 1 getal moeten zijn, namelijk het aantal pogingen. Met 1 simpele if kan je bepalen of er een brute force poging gedaan wordt.
Let overigens op dat dit een zeer gevaarlijke manier van checken is. Aan de ene kant is de kans klein dat je echt brute force pogingen detercteert, aan de andere kan is de kans groot dat je anderen uitsluit.
Dank voor je reactie, maar kun je misschien wat meer uitleg geven?
En waarom is dit geen veilige manier en hoe kun je een brute-force aanval wel het beste tegengaan?
Een alternatief is om brute force te frustreren. Veel wordt er gebruik gemaakt van langzame encryptie methodes, maar bijvoorbeeld ook gewoon een extra wachttijd inbouwen (middels sleep). Een extra wachttijd van bijvoorbeeld 2 seconde is niet echt merkbaar voor een normale gebruiker, maar voor iemand die duizenden inlogpogingen op je wil afvuren begint dat al snel heel vervelend te worden.
Ik begrijp uit jouw reactie dat er veel slimmere en makkelijkere wegen zijn om dit te bereiken, heb je toevallig enig idee waar ik daarvan een voorbeeld, tutorial of zoiets kan vinden?
Ik zou willen dat er slimmere en makkelijkere wegen zijn.... Een echt effectieve manier is er helaas niet. Je kan het proces alleen proberen te frustreren.
Code (php)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
foreach ($results as $result) {
//Check password and set session values
if (count($results) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(2);
$insertTry = "INSERT INTO loginfail (username, IP, DateAndTime)
VALUES ( :username, :IP, NOW())";
$insertStmt = $db->prepare($insertTry);
$insertStmt->execute(array(
':username' => $_POST['username'],
':IP' => $_SERVER['REMOTE_ADDR']
));
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'DNI desconocido';
exit();
}
}
//Check password and set session values
if (count($results) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(2);
$insertTry = "INSERT INTO loginfail (username, IP, DateAndTime)
VALUES ( :username, :IP, NOW())";
$insertStmt = $db->prepare($insertTry);
$insertStmt->execute(array(
':username' => $_POST['username'],
':IP' => $_SERVER['REMOTE_ADDR']
));
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'DNI desconocido';
exit();
}
}
Dan is het script vertraagd als het wachtwoord onjuist is.
Heb alleen nog wel een andere vraag, want de check functioneert wel helemaal goed als het wachtwoord niet klopt, maar als de DNI niet klopt, en dus (count($result) != 1), dan zou er de foutmelding moeten komen 'DNI desconocido', zoals de melding bij een onjuist wachtwoord, maar dit werkt niet en ik weet niet waarom niet.
if (count($results) == 1) {
doet en niet:
if (count($result) == 1) {
Voor de zekerheid getest of de count dan niet met (count($result) == 1) zou moeten zijn, en vreemd genoeg krijg ik dan wel de melding 'DNI desconocido', OOK als de login gegevens juist ingevoerd worden, dus dat werkt ook niet.
sleep() of een vergelijkbare oplossing op zijn plaats is.
Met header('Refresh: 3; url=login.php') bereik je overigens ook een van de ongewenste effecten die Erwin eerder noemde: alle gewone gebruikers wachten de aangegeven 3 seconden, maar iemand die een aanval uitvoert, lapt die instructie "wacht 3 seconden tot de volgende inlogpoging" aan de laars. Dit zijn dus de punten waar En blijft het probleem dat als er geen resultaat uit de database teruggegeven wordt, er schijnbaar niks gebeurt, terwijl ik dan dus die laatste melding wil zien, maar dat werkt niet.
Het is niet ongebruikelijk om de vertraging steeds langer te maken, bijvoorbeeld met 2 ^ x voor x inlogpogingen:
2 ^ 0 = 1 s
2 ^ 1 = 2 s
2 ^ 2 = 4 s
2 ^ 3 = 8 s
2 ^ 4 = 16 s
...
Verder moet je niet slechts naar losse inlogpogingen kijken, maar naar het grotere geheel. Daarvoor moet je de omvang van de site weten (of meten). Als je normaal gesproken bijvoorbeeld gemiddeld 1 login per 10 minuten hebt, weet je dat er iets loos is wanneer er plotseling 20 mislukte logins in 1 minuut zijn.
Tortuga web op 10/08/2014 08:47:23:
Ja, want de $results (met s) is alles wat er uit de database opgehaald wordt, en omdat username uniek is, behoort, indien juist ingegeven, er 1 resultaat teruggegeven te worden.
Zoals ik al eerder zei, ik vind die query om te beginnen vreemd, ik kan niet doorgronden waarom die nuttig is. Verder vind ik het ook vreemd dat je binnen een foreach loop, waarin je over de $results array loopt, een check op het aantal elementen in $results plaatst.
Ik zeg niet dat het allemaal fout is, maar ik begrijp je logica in elk geval niet.
Code (php)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
ini_set('display_errors',1); // 1 == on , 0 == off
error_reporting(E_ALL | E_STRICT);
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$errors = array();
if (trim($_POST['username']) == '' or (!preg_match('~^[0-9xyzXYZ][0-9]{7}[A-Za-z]$~',($_POST['username']))))
$errors['username'] = 'Rellena número del NIE o NIF';
if (trim($_POST['password']) == '' or (!preg_match('~^[0-9]{4}$~', $_POST['password'])))
$errors['password'] = 'Rellena número socio.<br>Un número menos que 1000, empieza con 0 hasta que complete 4 dígitos.';
if (!empty($_POST['name']))
$errors['name'] = 'No valido';
if (count($errors) == 0) {
//Start procedure
try {
//Connect to the dBase
require_once ('sql_link_sherpa.php');
//Make the query
$sql_select = "SELECT id, nombre, pass FROM socios WHERE dni = :dni";
$userStmt = $db->prepare($sql_select);
$userStmt->execute(array(
':dni' => $_POST['username']
));
$results = $userStmt->fetchAll();
$db = NULL;
foreach ($results as $result) {
//Check password and set session values
if (count($results) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(3);
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
$_SESSION['start'] = time(); // Taking now logged in time.
// Ending a session in 30 minutes from the starting time.
$_SESSION['expire'] = $_SESSION['start'] + (30 * 60);
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'NIF/NIE desconocido';
exit();
}
}
}
catch(PDOException $e) {
$sMsg = '<p>
Linenumber: '.$e->getLine().'<br />
File: '.$e->getFile().'<br />
Errormessage: '.$e->getMessage().'
</p>';
trigger_error($sMsg);
}
}
}
?>
ini_set('display_errors',1); // 1 == on , 0 == off
error_reporting(E_ALL | E_STRICT);
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$errors = array();
if (trim($_POST['username']) == '' or (!preg_match('~^[0-9xyzXYZ][0-9]{7}[A-Za-z]$~',($_POST['username']))))
$errors['username'] = 'Rellena número del NIE o NIF';
if (trim($_POST['password']) == '' or (!preg_match('~^[0-9]{4}$~', $_POST['password'])))
$errors['password'] = 'Rellena número socio.<br>Un número menos que 1000, empieza con 0 hasta que complete 4 dígitos.';
if (!empty($_POST['name']))
$errors['name'] = 'No valido';
if (count($errors) == 0) {
//Start procedure
try {
//Connect to the dBase
require_once ('sql_link_sherpa.php');
//Make the query
$sql_select = "SELECT id, nombre, pass FROM socios WHERE dni = :dni";
$userStmt = $db->prepare($sql_select);
$userStmt->execute(array(
':dni' => $_POST['username']
));
$results = $userStmt->fetchAll();
$db = NULL;
foreach ($results as $result) {
//Check password and set session values
if (count($results) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(3);
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
$_SESSION['start'] = time(); // Taking now logged in time.
// Ending a session in 30 minutes from the starting time.
$_SESSION['expire'] = $_SESSION['start'] + (30 * 60);
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'NIF/NIE desconocido';
exit();
}
}
}
catch(PDOException $e) {
$sMsg = '<p>
Linenumber: '.$e->getLine().'<br />
File: '.$e->getFile().'<br />
Errormessage: '.$e->getMessage().'
</p>';
trigger_error($sMsg);
}
}
}
?>
@Ward, kun je misschien een tip van de sluier oplichten van hoe ik dat in mijn script implementeer?
Gewijzigd op 10/08/2014 11:08:51 door tortuga web
Ook wordt het dan tijd om serieus naar het commentaar in je script te kijken. Het enige wat je nu hebt is commentaar over wat je doet, maar dat is overbodig. Wat het doet is wel te zien, niet waarom je het doet. Commentaar is nodig om juist dat uit te leggen, en nu blijkt waarom. Ga ook niet denken 'dat komt later wel, ik wil het eerst werkend hebben'. Want dan ben je te laat. In de eerste plaats heb je er op dat moment geen zin meer in om het te doen, maar ook heb je al de helft van de momenten waarop je het commentaar juist nodig had al gehad. Dat blijkt nu maar weer.
Dus mijn tip: haal het hele stuk uit je script, ga opnieuw de logica bedenken, schrijf het opnieuw en bouw vanaf nu altijd direct commentaar in. Je hebt nu een duidelijk voorbeeld waarom je dat commentaar nodig hebt, dus elk argument dat je nu bedenkt om dit niet te doen is een slap excuus.
Dus: Als ik de foreach-loop niet gebruik, dus zo:
Code (php)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
//Get the results from the database and close the connection
$result = $userStmt->fetchAll();
$db = NULL;
//Check the result from the database
if (count($result) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(3);
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
$_SESSION['start'] = time();
$_SESSION['expire'] = $_SESSION['start'] + (30 * 60);
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'Username desconocido';
exit();
}
?>
//Get the results from the database and close the connection
$result = $userStmt->fetchAll();
$db = NULL;
//Check the result from the database
if (count($result) == 1) {
$hash = $result['pass'];
if (!password_verify(($_POST['password']), $hash)) {
sleep(3);
header ('Refresh: 3; url=login.php');
echo 'Numero incorrecto';
exit();
}
else {
$_SESSION['logged_in'] = TRUE;
$_SESSION['id'] = $result['id'];
$_SESSION['user'] = $result['nombre'];
$_SESSION['start'] = time();
$_SESSION['expire'] = $_SESSION['start'] + (30 * 60);
header("Location: index_socio.php");
exit();
}
}
else {
header ('Refresh: 3; url=login.php');
echo 'Username desconocido';
exit();
}
?>
Dan wordt er (bij juiste inloggegevens) het password gecontroleerd en foutgemeld. Terwijl als ik de loop er wel in heb zitten, dan wordt de juiste controle uitgevoerd en foutgemeld als het password fout is en ingelogged als het goed is.
Wat dan niet werkt is de check op de username.
$hash = $result['pass']; moet dus worden $hash = $result[0]['pass'];
Wel jammer dat je niet mijn advies uit de vorige post eerst hebt overgenomen.....
Heb het in mijn script aangepast en nu werkt het wel goed. Dank je wel.
Wat denk je van de, wel heel simpele, toevoeging van de vertraging op een foute login, met de sleep() functie dus?
Ik zou die sleep veel eerder doen. Nu doe je het zo te zien alleen op een specifiek punt. Ik zou het gewoon aan het begin doen, zodat elke login poging wordt vertraagd. 2 of 3 seconden wachten is voor een gewone gebruiker geen probleem.
Of zie ik iets helemaal over het hoofd?
Ja en nee. Als élke login, ook een geldige, 3 seconde op zich laat wachten, gaat daarvan al op voorhand een signaal uit: bij deze site gaat een brute force-aanval héél lang duren.