The problem with allowing password reset is that there is a serious security hole in it.
Because in my file reset.php
it is sending the same account activation code that was generated when the user registered.
For an attacker, it would be easy to make several attempts, for example in this way:
example.com/login-system/reset.php?email=ponercualquieremail%key=generarcódigoaleatorio
And if it matches the table records users
, the attacker can change the password and gain access to the access system.
All that security hole due to the same activation code.
So, this link, to reset the password:
http://example.com/login-system/reset.php?email=example%40gmail.com&key=523db8c57a3d17d0860fa705c4c24ec62efc0c68f2f1443e39938361424099f1
It is the same to activate the account:
http://example.com/login-system/verify.php?email=example%40gmail.com&key=523db8c57a3d17d0860fa705c4c24ec62efc0c68f2f1443e39938361424099f1
What's more, I can save that code that I receive to activate the account, to reset the password without having to enter the email in the reset form password
, simply by changing it verify.php?
toreset.php?
Now I have the following structure of my tableusers
+----------+-----------+--------+----------+------------+--------+
| id_user | username | email | password | email_code | active |
+----------+-----------+--------------------------------+--------+
| 1 | karla | karla@ | $2y$10...| 23db8c5... | 1 |
+-------------+-------------+--------------+------------+---------
How can I correct this security problem, sending that activation code along with the user's id to another table and with an expiration time, and that when activating the account that record is deleted, and that when requesting to reset the password create a new verification code with expiration date and when resetting the password that record is deleted again.
My complete code.
register.php
session_start();
include "require.ini.php";
if (isset($_POST['formsubmitted'])) {
$msg = array();
if (empty($_POST['username'])) {
$msg[] = 'Por favor, ingrese un nombre de usuario';
} else {
$username = $_POST['username'];
}
if (empty($_POST['email'])) {
$msg[] = 'Por favor, ingrese su correo electrónico';
} else {
if (preg_match("/^([a-zA-Z0-9])+([a-zA-Z0-9\._-])*@([a-zA-Z0-9_-])+([a-zA-Z0-9\._-]+)+$/", $_POST['email'])) {
$email = $_POST['email'];
} else {
$msg[] = 'Tu dirección de correo electrónico no es válida';
}
}
if (strlen($_POST['password']) <6){
$msg[] = 'Su contraseña debe tener al menos 6 caracteres';
}
if ($_POST['password'] !== $_POST['password_again']){
$msg[] = 'Su contraseña no coincide';
} else {
$password = $_POST['password'];
}
if (empty($_POST['firstname'])) {
$msg[] = 'Por favor, ingrese su nombre';
} else {
$first_name = $_POST['firstname'];
}
if (empty($msg)) {
$stmt = $con->prepare("SELECT * FROM users WHERE email=? OR username=?");
$stmt->bind_param("ss",$email,$username);
$stmt->execute();
$stmt->store_result();
if ($stmt->num_rows>0) {
echo "¡El usuario con este correo electrónico ya existe!";
} else {
$hash_password = password_hash($password, CRYPT_BLOWFISH);
$key = bin2hex(openssl_random_pseudo_bytes(32));
//$key_two = bin2hex(random_bytes(32)); // Disponible apartir de PHP V.7
$active_default = 0;
$stmtA = $con->prepare("INSERT INTO users (username, email, password, first_name, email_code, active) VALUES (?, ?, ?, ?, ?, ?)");
$stmtA->bind_param("sssssi", $username,$email,$hash_password,$first_name,$key,$active_default);
if($stmtA->execute()){
echo 'El enlace de confirmación ha sido enviado por correo electrónico. ¡Por favor, haga clic en el enlace del mensaje para activar su cuenta!';
$to = $email;
$subject = "Por favor, verifique su cuenta.";
$message_body = 'Hola '.$first_name.',
¡Gracias por registrarte!
Estas aún solo paso de ser parte de nuestra comunidad.
Por favor, haga clic en este enlace para activar su cuenta:
http://example.com/login-system/verify.php?email='.urlencode($email).'&key='.$key.'';
mail($to, $subject, $message_body, 'From: [email protected]');
//header("location: index.php");
//exit;
} else {
echo "Ha ocurrido un error internamente, por favor, vuelva intertar enviar su solicitud más tarde";
}
}
} else {
foreach ($msg as $key => $values) {
echo ' <div>'.$values.'</div>';
}
}
}
reset.php
<?php
session_start();
include "require.php";
if (isset($_GET['email']) && preg_match('/^([a-zA-Z0-9])+([a-zA-Z0-9\._-])*@([a-zA-Z0-9_-])+([a-zA-Z0-9\._-]+)+$/', $_GET['email'])) {
$email = $_GET['email'];
}
if (isset($_GET['key']) && (strlen($_GET['key']) == 64)) {
$key = $_GET['key'];
}
if (isset($email) && isset($key)) {
//$email = $con->escape_string($_GET['email']);
//$key = $con->escape_string($_GET['key']);
$active_defaul = 1;
$stmt = $con->prepare("SELECT * FROM users WHERE email=? AND email_code=? AND active=?");
$stmt->bind_param("ssi",$email,$key,$active_defaul);
$stmt->execute();
$stmt->store_result();
//if ($result->num_rows == 0 )
if ($stmt->num_rows==0) {
//if ($stmt->num_rows>0) {
echo "¡Ingresó una URL inválida para restablecer la contraseña!";
} else {
echo '
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<form action="reset_password.php" method="post">
<label>New Password</label>
<input type="password" name="password" autocomplete="off"/>
<label>Confirm New Password</label>
<input type="password" name="password_again" autocomplete="off"/>
<input type="hidden" name="email" value="'.$email.'">
<input type="submit" name="form_reset" value="Guardar contraseña" />
</form>
</body>
</html>';
}
} else {
echo "¡Acceso denegado!";
}
?>
reset_password.php
session_start();
include "require.php";
if (isset($_POST['form_reset'])) {
$email = $_POST['email'];
$password = $_POST['password'];
$hash_password = password_hash($password, CRYPT_BLOWFISH);
$stmt = $con->prepare("UPDATE users SET password= ? WHERE email=? OR username=?");
$stmt->bind_param("sss", $hash_password,$email,$email);
if($stmt->execute()){
header("location: correcto.php");
} else {
header("location: error.php");
}
}
Answers
It's a probability, but considering you're randomly generating 32 bytes. In theory you have a one in 4,294,967,296 chance of getting it right or, which would be the same assuming a network latency of 2 ms and a single simultaneous connection, all combinations could be tested in about 100 days.
Limiting the validity of the token over time is enough to mitigate the effects of a brute force attack (see tips on limiting attempts).
It is right. There are many ways that information can be inadvertently accessed:
?...
).https
).In these cases, limiting the duration of the token's validity also helps.
Limiting the lifetime of the token
Making minor changes to the code, I would recommend adding at least one field
expiracion
to the tableusers
as follows:This field would store the date on which the email verification or reset code expires.
Now it's time to change SQL and PHP code to give security to the process:
register.php
reset.php
Note:
IF(expiracion > NOW(), 0, 1)
returns1
(expired) whenexpiracion
it is worthNULL
.reset_password.php
Note: in your code it seems that you are not taking into account that someone can register with a username that is an email address of another user. That could open the door for that user to change the other user's password.
Limiting the number of failed attempts
To mitigate the effects of a brute force attack (both on the username and password and on the token) you should keep a record of all failed access attempts.
This solution increases the complexity of the code, it requires at least one new table with its maintenance (deleting old records so as not to run out of space in MySQL if you suffer a brute force attack and save millions of attempts) and a correct management of where you should check that the limits have been exceeded and where to add a new failed attempt.
Still, the easiest way to implement this solution is to create a table called, for example,
intentos
:Every time a user/password, email/token, etc. access fails, a record is added as follows:
Assigning
?
the value of$_SERVER['REMOTE_ADDR']
.Now, and most important of all, BEFORE ANY AUTHENTICATION CHECK you check the number of failed attempts coming from the source IP, preventing the check if it has exceeded a threshold.
To add automatic maintenance of the content of the table, prior to said verification, it is possible to delete old records using
DATE_SUB()
:This instruction will delete all attempted logs older than 30 seconds.
This example code is integrated into your application:
Limiting to 10 attempts every 30 seconds increases the time needed to try all combinations of the 32-byte key (4,294,967,296) to over 400 years.
Analyzing the case to begin with you should create a new table that stores the following data:
.
You will need a section where you insert the row to the table
password_resets
, and to create that you should check if the email whose password you want to reset exists.Now once you enter the URL with the reset code you will need to verify:
expire
If the condition is met, you must delete the corresponding record and display the section for entering the new password.
Ese grave problema de seguridad se soluciona, asegurandote de que el usuario nunca accede mediante
GET
, es decir escribiendo la direccion con los parametros. Debes de utilizar el objeto session dePHP
. Es decir coloca los datos de usuario en objeto session, la clave siempre encriptada te aconsejoSHA-256
o similar. Si alguien intenta acceder medianteGET
,PHP
bloquea al usuario.