Tiempo estimado de lectura:

Recientemente he tenido que implementar un sistema de recuperación de contraseña para una aplicación web programada en Symfony 1.4. Para no reinventar la rueda me he fijado en el algoritmo que utiliza Django y he portado su implementación a PHP.

El código de la clase asume la existencia un objeto que representa un usuario de la aplicación y que se pasa como parámetro. Ese objeto tiene algunos métodos que devuelven atributos del usuario, como getLastLogin(), getPassword() y getId(). Se podría pasar esa información como parámetro, pero lo dejo tal y como está para que se vea dónde se utiliza, y os dejo a vosotros, lectores, la responsabilidad de mejorar el código si os apetece.

He respetado los comentarios importantes de la implementación de Django, pues son muy ilustrativos. Después del tocho seguimos.

  1 <?php
  2 /**
  3  * Token generator/validator.
  4  * Ported from Django: http://git.io/pZjqWA
  5  *
  6  * @author Carlos Escribano Rey
  7  */
  8 class PasswordResetTokenGenerator
  9 {
 10     protected $secret;
 11     protected $timeout;
 12 
 13     /**
 14      * Configure the secret key and token expiration in days.
 15      */
 16     public function __construct($secret, $timeout)
 17     {
 18         $this->secret = $this->forceBytes($secret);
 19         $this->timeout = intval($timeout);
 20     }
 21 
 22     /**
 23      * Create a token for a User.
 24      *
 25      * @param  sfGuardUser $user
 26      * @return string
 27      */
 28     public function makeToken($user)
 29     {
 30         return $this->makeTokenWithTimestamp($user,
 31                                              $this->days($this->today()));
 32     }
 33 
 34     /**
 35      * Validates a token
 36      *
 37      * @param  sfGuardUser $user
 38      * @param  string $token
 39      * @return boolean
 40      */
 41     public function checkToken($user, $token)
 42     {
 43         list($ts_b36, $hash) = explode("-", $token);
 44 
 45         $ts = base_convert($ts_b36, 36, 10);
 46 
 47         if (!$this->tokensAreEqual($token,
 48                 $this->makeTokenWithTimestamp($user, $ts)))
 49         {
 50             return false;
 51         }
 52 
 53         if ( $this->timeout < $this->days($this->today()) - $ts )
 54         {
 55             return false;
 56         }
 57 
 58         return true;
 59     }
 60 
 61     /**
 62      * Generates a token for a user using the number of days since
 63      * 2001-1-1.
 64      *
 65      * @param  sfGuardUser  $user   The user object.
 66      * @param  integer      $ts     Number of days since 2001-1-1.
 67      * @return string               The token
 68      */
 69     protected function makeTokenWithTimestamp($user, $ts)
 70     {
 71         // timestamp is number of days since 2001-1-1.  Converted to
 72         // base 36, this gives us a 3 digit string until about 2121
 73         $ts_b36 = base_convert((string) $ts, 10, 36);
 74 
 75         // By hashing on the internal state of the user and using state
 76         // that is sure to change (the password salt will change as soon as
 77         // the password is set, at least for current Django auth, and
 78         // last_login will also change), we produce a hash that will be
 79         // invalid as soon as it is used.
 80         // We limit the hash to 20 chars to keep URL short
 81         $key_salt = "project.website.lib.PasswordResetTokenGenerator";
 82         $value = sprintf("%d%s%s%s", $user->getId(), $user->getPassword(),
 83                          $user->getLastLogin(), $ts);
 84 
 85         return sprintf("%s-%s", $ts_b36,
 86                                 $this->getHash($key_salt, $value));
 87     }
 88 
 89     /**
 90      * Generates a "short" hash for a value.
 91      *
 92      * @param  string $key_salt
 93      * @param  string $value
 94      * @return string
 95      */
 96     protected function getHash($key_salt, $value)
 97     {
 98         $key_salt = $this->forceBytes($key_salt);
 99 
100         // We need to generate a derived key from our base key.  We can do
101         // this by passing the key_salt and our base key through a
102         // pseudo-random function and SHA1 works nicely.
103         $key = sha1($key_salt . $this->secret);
104 
105         // If len(key_salt + secret) > sha_constructor().block_size, the
106         // above line is redundant and could be replaced by:
107         //        key = key_salt + secret
108         // since the hmac module does the same thing for keys longer than
109         // the block size. However, we need to ensure that we *always* do
110         // this.
111         $hash = hash_hmac('sha1', $this->forceBytes($value), $key);
112 
113         // Filter out even characters
114         $result = array();
115         foreach (str_split($hash) as $index => $character)
116         {
117             if ($index % 2 != 0) continue;
118 
119             $result[] = $character;
120         }
121 
122         return implode('', $result);
123     }
124 
125     protected function forceBytes($value)
126     {
127         return mb_convert_encoding($value, 'ISO-8859-1');
128     }
129 
130     /**
131      * Returns the current date as an integer value
132      * @return integer
133      */
134     protected function today()
135     {
136         $today = new DateTime("now", new DateTimeZone("UTC"));
137         return $today->getTimestamp();
138     }
139 
140     /**
141      * Returns the number of days between the timestamp passed as parameter
142      * and 2001-01-01.
143      *
144      * @param  integer $dt A timestamp
145      * @return integer
146      */
147     protected function days($dt)
148     {
149         $baseDate = new DateTime("2001-01-01T00:00:00",
150                                  new DateTimeZone("UTC"));
151         $dateDiff = $dt - $baseDate->getTimestamp();
152         return floor($dateDiff / (60 * 60 * 24));
153     }
154 
155     /**
156      * Returns true if the two tokens are equal. The comparison is made
157      * at bit level.
158      *
159      * @param  string $token1
160      * @param  string $token2
161      * @return boolean
162      */
163     protected function tokensAreEqual($token1, $token2)
164     {
165         $a_token1 = str_split($token1);
166         $a_token2 = str_split($token2);
167 
168         if ( count($a_token1) != count($a_token2) )
169         {
170             return false;
171         }
172 
173         $result = 0;
174         for ($i = 0, $N = count($a_token1); $i < $N; $i++)
175         {
176             $x = $a_token1[$i];
177             $y = $a_token2[$i];
178 
179             $result |= ord($x) ^ ord($y);
180         }
181 
182         return $result == 0;
183     }
184 }
185 ?>

El proceso de recuperación de contraseña.

Un usuario de la aplicación que no recuerde su contraseña buscará un enlace que le permita recuperarla. Ese enlace le llevará a un formulario donde podrá introducir su dirección de correo electrónico.

Si el correo electrónico está asociado a un usuario, la aplicación generará una URL especial que permita al usuario cambiar su contraseña. Esta URL tendrá las siguientes características:

  • Debe ser fácilmente generable pero no debe ser fácilmente deducible por fuerza bruta.
  • Debe caducar en algún momento.
  • No debe ser siempre igual para el mismo usuario, debe generarse en base a datos que podamos reutilizar para la comprobación, pero que varíen en el tiempo.

Alguno dirá que por qué no se guarda el token en base de datos. Pues bien, si alguien accede a tu base de datos podría obtener todas las URLs de cambio de password de los usuarios gracias a los token que no hayan caducado en el momento de la intrusión. Para eso no encriptes las contraseñas.

Lo que hace Django es que el token está formado por:

  • Una cadena de texto que representa el punto temporal en que se generó el token. La diferencia entre la fecha de utilización y esa marca de tiempo ha de ser inferior a la validez máxima del token ($timeout).
  • Un hash generado con los datos del usuario antes de cambiar la contraseña.

Los datos del usuario son:

  • Id del usuario en la base de datos.
  • Contraseña encriptada actual. Se supone que el usuario la va a cambiar, ya que no la recuerda, así que podemos suponer que este dato es variable.
  • Fecha del último inicio de sesión. Lo mismo, se supone que el usuario va a identificarse tras cambiar su contraseña, así que este dato también se puede suponer variable.
  • Marca de tiempo del momento de generar el token. Al menos el token cambia cada día.

El token sería algo así:

3rs-a23b7861cfe634171567

Y podríamos utilizarlo en una URL:

http://www.example.com/password-recovery/3rs-a23b7861cfe634171567

Pero aquí se plantea un problemilla, y es cómo identificar al usuario. Se podría convertir el id del usuario en una cadena de texto y añadirlo en la URL, pero eso mejor os lo dejo a vosotros.

1 <?php
2 // ... Get user and so on
3 $secret = "abcdefghijk"; // wow, so difficult to guess!
4 $timeout = 3; // days
5 $generator = new PasswordResetTokenGenerator($secret, $timeout);
6 $token = $generator->makeToken($user);
7 // ... Do something with the token
8 ?>

Tanto para generar el token como para descifrarlo necesitaremos una firma secreta que deberá estar en la configuración de nuestra web, en un fichero no disponible públicamente. Si nuestro sistema se ve comprometido, es algo que tendremos que cambiar enseguida.

Sigamos con nuestro usuario. Una vez que el usuario accede a la URL de recuperación de contraseña deberemos cargar el usuario de alguna forma a un objeto y comprobar que si generamos el token con la marca de tiempo que tiene para el usuario que hemos cargado, el resultado es el mismo que se obtuvo en el momento en que se generó.

1 <?php
2 // ... Get user and so on
3 $secret = "abcdefghijk"; // wow, so difficult to guess!
4 $timeout = 3; // days
5 $generator = new PasswordResetTokenGenerator($secret, $timeout);
6 $isValid = $generator->checkToken($user,
7                                   get_parameter_from_request('token'));
8 // ... Do something if the token is valid
9 ?>

En caso de que el token sea válido, mostraremos un formulario para cambiar la contraseña.

El resto os lo podéis imaginar. ¡Hasta pronto!

Blog Logo

Carlos Escribano

Desarrollador Web desde hace 10 años. Me gusta resolver problemas de forma ingeniosa. Saber más.

Artículos de desarrollo web en español

nettoys.es

Volver al Inicio