Browse Source

Use secure tokens for password reset

Alan Hardman 3 years ago
parent
commit
491bbd1845

+ 40 - 26
app/controller/index.php

@@ -184,8 +184,14 @@ class Index extends \Controller {
 				$user = new \Model\User;
 				$user->load(array("email = ?", $f3->get("POST.email")));
 				if($user->id && !$user->deleted_date) {
+					// Re-generate reset token
+					$token = $user->generateResetToken();
+					$user->save();
+
+					// Send notification
 					$notification = \Helper\Notification::instance();
-					$notification->user_reset($user->id);
+					$notification->user_reset($user->id, $token);
+
 					$f3->set("reset.success", "We've sent an email to " . $f3->get("POST.email") . " with a link to reset your password.");
 				} else {
 					$f3->set("reset.error", "No user exists with the email address " . $f3->get("POST.email") . ".");
@@ -197,7 +203,7 @@ class Index extends \Controller {
 	}
 
 	/**
-	 * GET|POST /reset/@hash
+	 * GET|POST /reset/@token
 	 *
 	 * @param \Base $f3
 	 * @param array $params
@@ -206,33 +212,41 @@ class Index extends \Controller {
 	public function reset_complete($f3, $params) {
 		if($f3->get("user.id")) {
 			$f3->reroute("/");
-		} else {
-			$user = new \Model\User;
-			$user->load(array("CONCAT(password, salt) = ?", $params["hash"]));
-			if(!$user->id || !$params["hash"]) {
-				$f3->set("reset.error", "Invalid reset URL.");
-				$this->_render("index/reset.html");
+			return;
+		}
+
+		if (!$params["token"]) {
+			$f3->reroute("/login");
+			return;
+		}
+
+		$user = new \Model\User;
+		$user->load(array("reset_token = ?", hash("sha384", $params["token"])));
+		if(!$user->id || !$user->validateResetToken($params["token"])) {
+			$f3->set("reset.error", "Invalid reset URL.");
+			$this->_render("index/reset.html");
+			return;
+		}
+
+		if($f3->get("POST.password1")) {
+			// Validate new password
+			if($f3->get("POST.password1") != $f3->get("POST.password2")) {
+				$f3->set("reset.error", "The given passwords don't match.");
+			} elseif(strlen($f3->get("POST.password1")) < 6) {
+				$f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters.");
+			} else {
+				// Save new password and redirect to login
+				$security = \Helper\Security::instance();
+				$user->reset_token = null;
+				$user->salt = $security->salt();
+				$user->password = $security->hash($f3->get("POST.password1"), $user->salt);
+				$user->save();
+				$f3->reroute("/login");
 				return;
 			}
-			if($f3->get("POST.password1")) {
-				// Validate new password
-				if($f3->get("POST.password1") != $f3->get("POST.password2")) {
-					$f3->set("reset.error", "The given passwords don't match.");
-				} elseif(strlen($f3->get("POST.password1")) < 6) {
-					$f3->set("reset.error", "The given password is too short. Passwords must be at least 6 characters.");
-				} else {
-					// Save new password and redirect to login
-					$security = \Helper\Security::instance();
-					$user->salt = $security->salt();
-					$user->password = $security->hash($f3->get("POST.password1"), $user->salt);
-					$user->save();
-					$f3->reroute("/login");
-					return;
-				}
-			}
-			$f3->set("resetuser", $user);
-			$this->_render("index/reset_complete.html");
 		}
+		$f3->set("resetuser", $user);
+		$this->_render("index/reset_complete.html");
 	}
 
 	/**

+ 3 - 2
app/helper/notification.php

@@ -250,8 +250,9 @@ class Notification extends \Prefab {
 	/**
 	 * Send a user a password reset email
 	 * @param  int $user_id
+	 * @param  string $token
 	 */
-	public function user_reset($user_id) {
+	public function user_reset($user_id, $token) {
 		$f3 = \Base::instance();
 		if($f3->get("mail.from")) {
 			$user = new \Model\User;
@@ -262,7 +263,7 @@ class Notification extends \Prefab {
 			}
 
 			// Render message body
-			$f3->set("user", $user);
+			$f3->set("token", $token);
 			$text = $this->_render("notification/user_reset.txt");
 			$body = $this->_render("notification/user_reset.html");
 

+ 4 - 4
app/helper/security.php

@@ -27,7 +27,7 @@ class Security extends \Prefab {
 	 * @return string
 	 */
 	public function salt() {
-		return md5($this->rand_bytes(64));
+		return md5($this->randBytes(64));
 	}
 
 	/**
@@ -35,7 +35,7 @@ class Security extends \Prefab {
 	 * @return string
 	 */
 	public function salt_sha1() {
-		return sha1($this->rand_bytes(64));
+		return sha1($this->randBytes(64));
 	}
 
 	/**
@@ -48,7 +48,7 @@ class Security extends \Prefab {
 		if(!in_array($size, $allSizes)) {
 			throw new Exception("Hash size must be one of: " . implode(", ", $allSizes));
 		}
-		return hash("sha$size", $this->rand_bytes(512), false);
+		return hash("sha$size", $this->randBytes(512), false);
 	}
 
 	/**
@@ -94,7 +94,7 @@ class Security extends \Prefab {
 	 * @param  integer $length
 	 * @return binary
 	 */
-	private function rand_bytes($length = 16) {
+	public function randBytes($length = 16) {
 
 		// Use OpenSSL cryptography extension if available
 		if(function_exists("openssl_random_pseudo_bytes")) {

+ 23 - 0
app/model/user.php

@@ -324,4 +324,27 @@ class User extends \Model {
 		return (object) array("language" => $lang, "js" => ($lang != "en"));
 	}
 
+	/**
+	 * Generate a password reset token and store hashed value
+	 * @return string
+	 */
+	public function generateResetToken() {
+		$random = \Helper\Security::instance()->randBytes(512);
+		$token = hash("sha384", $random) . time();
+		$this->reset_token = hash("sha384", $token);
+		return $token;
+	}
+
+	/**
+	 * Validate a plaintext password reset token
+	 * @param  string $token
+	 * @return boolean
+	 */
+	public function validateResetToken($token) {
+		$ttl = \Base::instance()->get("security.reset_ttl");
+		$timestampValid = substr($token, 96) > (time() - 3600*24);
+		$tokenValid = hash("sha384", $token) == $this->reset_token;
+		return $timestampValid && $tokenValid;
+	}
+
 }

+ 1 - 1
app/routes.ini

@@ -5,7 +5,7 @@ GET / = Controller\Index->index
 GET /login = Controller\Index->login
 GET|POST /reset = Controller\Index->reset
 GET|POST /reset/forced = Controller\Index->reset_forced
-GET|POST /reset/@hash = Controller\Index->reset_complete
+GET|POST /reset/@token = Controller\Index->reset_complete
 POST /login = Controller\Index->loginpost
 POST /register = Controller\Index->registerpost
 GET|POST /logout = Controller\Index->logout

+ 1 - 1
app/view/notification/user_reset.html

@@ -81,7 +81,7 @@
 									<tr>
 										<td width="15"></td>
 										<td>
-											<a href="{{ @site.url }}reset/{{ @user.password . @user.salt }}" style="color: #ffffff; text-decoration: none;">
+											<a href="{{ @site.url }}reset/{{ @token }}" style="color: #ffffff; text-decoration: none;">
 												<table bgcolor="#2c3e50" class="btn" style="border-radius: 5px;">
 													<tr>
 														<td height="5"></td>

+ 1 - 1
app/view/notification/user_reset.txt

@@ -2,4 +2,4 @@
 
 Someone requested to reset your password on {{ date("F jS \\a\\t g:ia", $this->utc2local()) }}. If this was you, click the link below to reset your password. Otherwise, you can safely ignore this email.
 
-{{ @site.url }}reset/{{ @user.password . @user.salt }}
+{{ @site.url }}reset/{{ @token }}

+ 9 - 0
db/16.12.29.sql

@@ -0,0 +1,9 @@
+# Add reset_token column to user table
+ALTER TABLE `user`
+	ADD COLUMN `reset_token` CHAR(96) NULL AFTER `salt`;
+
+# Add default config entry for reset TTL
+INSERT INTO `config` (`attribute`,`value`) VALUES ('security.reset_ttl', '86400');
+
+# Update version
+UPDATE `config` SET `value` = '16.12.29' WHERE `attribute` = 'version';