
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!