Skip to content

Commit

Permalink
Security Fix: reset password functionality Merge pull request #981 fr…
Browse files Browse the repository at this point in the history
…om pli888/fix-reset-password

Re-implementation of reset password functionality with improved security based on good standard practices. Essentially, the token consists of 2 parts: a selector and a verifier, and the token is only valid for use for 1 hour. The token validation process involves calculating the hash of the verifier and comparing it with the hash that has been saved in the database when the token was first created. If the hashes match then the token is valid if it has not expired.
  • Loading branch information
rija authored Mar 29, 2022
2 parents cc41b39 + 6e8436e commit 382c470
Show file tree
Hide file tree
Showing 37 changed files with 1,075 additions and 300 deletions.
1 change: 1 addition & 0 deletions data/dev/gigadb_user.csv
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
id,email,password,first_name,last_name,affiliation,role,is_activated,newsletter,previous_newsletter_state,facebook_id,twitter_id,linkedin_id,google_id,username,orcid_id,preferred_link
8,[email protected],5a4f75053077a32e681f81daa8792f95,Guojie,Zhang,BGI,"",f,t,t,,,,,[email protected],,EBI
14,[email protected],5a4f75053077a32e681f81daa8792f95,Shifeng,Cheng,BGI,"",f,t,t,,,,,[email protected],,EBI
22,[email protected],5a4f75053077a32e681f81daa8792f95,John,Doe,BGI,user,true,false,true,,,,,[email protected],,EBI
38,[email protected],bcf839ae349d13cb0c3c4a9ec0cc243e,C,H,BGI,admin,t,t,t,,,,114715812788877222335,[email protected],,EBI
163,[email protected],5a4f75053077a32e681f81daa8792f95,Carson,Chow,National Institutes of Health,user,t,f,t,,,,,[email protected],,EBI
336,[email protected],5a4f75053077a32e681f81daa8792f95,Hugh,Shanahan,"Department of Computer Science, Royal Holloway, University of London, Egham, TW20 0EX, UK","",f,t,f,,,,,[email protected],,EBI
Expand Down
2 changes: 2 additions & 0 deletions data/dev/reset_password_request.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
selector,hashed_token,requested_at,expires_at,gigadb_user_id
6_WVbmz1e-nm6YPm2sTZ,IBDO6gfgkejy3XF9H5lgVqs4pe0oZf1Li1Fm7DaGPvk=,2022-03-01 01:53:23.000000,9999-03-01 02:53:23.000000,22
5 changes: 3 additions & 2 deletions docs/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ the following variables which all projects belonging to developers in the Forks
sub-group can use:

| Variable | Example value | Used in | Comments |
|----------|---------------|---------|----------|
| ANALYTICS_CLIENT_EMAIL | | local.php.dist for Google analytics | Set at Forks level with test Google Analytics account for development work |
|----------|--------------|--------|----------|
| ANALYTICS_CLIENT_EMAIL | | local.php.dist for Google analytics | Set at Forks level with test Google Analytics account for development work |
| ANALYTICS_CLIENT_ID | | local.php.dist | As above |
| ANALYTICS_KEYFILE_PATH | | Cannot find where it is used | As above |
| ANALYTICS_PRIVATE_KEY | | docker-compose.ci.yml | As above |
Expand All @@ -187,6 +187,7 @@ sub-group can use:
| Google_tester_last_name | | Affiliate login tests | As above |
| Google_tester_password | | Affiliate login tests | As above |
| group | Forks | Cannot find where it is used | Kept as a Forks sub-group variable|
| HASH_SECRET_KEY | | local.php.dist | Used as a signing key for creating the hash value of the password reset token verifier |
| LINKEDIN_API_KEY | | main.php.dist, Affiliate login tests | Set at Forks sub-group level so all developers use same test LinkedIn account for development work |
| LINKEDIN_SECRET_KEY | | main.php.dist, Affiliate login tests | As above |
| LinkedIn_tester_email | | Affiliate login tests | As above |
Expand Down
37 changes: 0 additions & 37 deletions features/lost-password-page.feature

This file was deleted.

5 changes: 1 addition & 4 deletions less/current/modules/forms.less
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,5 @@ label {
display: inline-block;
*display: inline;
*zoom: 1;
margin-top: -30px;
margin-bottom: 0;
}
.control-label {
margin-bottom: auto;
}
6 changes: 6 additions & 0 deletions ops/configuration/yii-conf/local.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

return array(
'components' => array(
'cryptoService' => array(
'class' => 'application.components.CryptoService',
),
'mailService' => array(
'class' => 'application.components.MailService',
),
Expand Down Expand Up @@ -53,5 +56,8 @@ return array(

// search parameters
'search_result_limit' => '${SEARCH_RESULT_LIMIT}',

// For creating hash for password reset functionality
'signing_key' => '${HASH_SECRET_KEY}',
),
);
3 changes: 3 additions & 0 deletions ops/configuration/yii-conf/main.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ return CMap::mergeArray(array(
'/dataset/<id:\d+>'=>'dataset/view/id/<id>',
'/dataset/<id:\d+>/<slug:.+>'=>'dataset/view/id/<id>',
'.*'=>'site/index',
'site/forgot' => 'resetPasswordRequest/forgot',
'site/thanks' => 'resetPasswordRequest/thanks',
'site/reset' => 'resetPasswordRequest/reset',
),
),
'log' => array(
Expand Down
26 changes: 26 additions & 0 deletions ops/configuration/yii-conf/triple-stash.migration.php.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{{! A handlebars template to generate Yii migration scripts}}
<?php

class {{ class_name }} extends CDbMigration
{
public function safeUp()
{
{{#each safeup_data}}
$this->insert('{{ ../table_name }}', array(
{{#each this}}
{{#if this}}
'{{@key}}' => '{{{this}}}',
{{/if}}
{{/each}}
));
{{/each}}
}

public function safeDown()
{
$ids = array({{#each safedown_data}}'{{this}}',{{/each}});
foreach ($ids as $id) {
$this->delete('{{table_name}}', 'id=:id', array(':id' => $id));
}
}
}
11 changes: 10 additions & 1 deletion ops/scripts/csv_yii_migration.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ let PROJECT_DIR = "/var/www";
let INPUT_CSV_DIR = PROJECT_DIR + "/data/" + CMD_ARGS[0];
let OUTPUT_MIGRATION_SCRIPT_DIR = PROJECT_DIR + "/protected/migrations/data/" + CMD_ARGS[0];
let HANDLEBARS_TEMPLATE_FILE = PROJECT_DIR + "/ops/configuration/yii-conf/migration.php.dist";
let TRIPLE_STASH_HANDLEBARS_TEMPLATE_FILE = PROJECT_DIR + "/ops/configuration/yii-conf/triple-stash.migration.php.dist";


/*
* Returns file name for Yii migration script based on table name.
Expand Down Expand Up @@ -118,6 +120,8 @@ const getMigrationFileName = tableName => {
return "m200529_050470_insert_data_YiiSession_tab";
case "user_command":
return "m200529_050480_insert_data_user_command_tab";
case "reset_password_request":
return "m200529_050490_insert_data_reset_password_request_tab";
default:
throw new Error("No match for table name!");
}
Expand Down Expand Up @@ -166,7 +170,12 @@ for(let a = 0; a < files.length; a ++) {
return !handlebars.Utils.isEmpty(value);
});
// Read handlebars template as string
let template = fs.readFileSync(HANDLEBARS_TEMPLATE_FILE, "utf8");
let template = "";
if (tokens[0] == "reset_password_request") {
template = fs.readFileSync(TRIPLE_STASH_HANDLEBARS_TEMPLATE_FILE, "utf8");
} else {
template = fs.readFileSync(HANDLEBARS_TEMPLATE_FILE, "utf8");
}
const templateScript = handlebars.compile(template);
// Output Yii migration script
fsPath.writeFile(outputMigrationFile, templateScript(context), function (err) {
Expand Down
2 changes: 1 addition & 1 deletion ops/scripts/generate_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ envsubst $VARS < $SOURCE > $TARGET
export SEARCH_RESULT_LIMIT=${SEARCH_RESULT_LIMIT:-"10"}
SOURCE=${APP_SOURCE}/ops/configuration/yii-conf/local.php.dist
TARGET=${APP_SOURCE}/protected/config/local.php
VARS='$MAILCHIMP_API_KEY:$MAILCHIMP_LIST_ID:$ANALYTICS_CLIENT_EMAIL:$ANALYTICS_CLIENT_ID:$ANALYTICS_KEYFILE_PATH:$HOME_URL:$SERVER_EMAIL:$SERVER_EMAIL_PASSWORD:$SERVER_EMAIL_SMTP_HOST:$RECAPTCHA_PUBLICKEY:$RECAPTCHA_PRIVATEKEY:$GOOGLE_ANALYTICS_PROFILE:$MDS_USERNAME:$MDS_PASSWORD:$MDS_PREFIX:$SEARCH_RESULT_LIMIT'
VARS='$MAILCHIMP_API_KEY:$MAILCHIMP_LIST_ID:$ANALYTICS_CLIENT_EMAIL:$ANALYTICS_CLIENT_ID:$ANALYTICS_KEYFILE_PATH:$HOME_URL:$SERVER_EMAIL:$SERVER_EMAIL_PASSWORD:$SERVER_EMAIL_SMTP_HOST:$RECAPTCHA_PUBLICKEY:$RECAPTCHA_PRIVATEKEY:$GOOGLE_ANALYTICS_PROFILE:$MDS_USERNAME:$MDS_PASSWORD:$MDS_PREFIX:$SEARCH_RESULT_LIMIT:$HASH_SECRET_KEY'
envsubst $VARS < $SOURCE > $TARGET

SOURCE=${APP_SOURCE}/ops/configuration/yii-conf/main.php.dist
Expand Down
43 changes: 43 additions & 0 deletions protected/components/CryptoService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/**
* Service to provide tokens for password reset functionality
*/
class CryptoService extends yii\base\Component
{
const RANDOM_STRING_LENGTH = 20;

/**
* Initializes application component.
* This method overrides the parent implementation by setting default cache
* key prefix.
*/
public function init()
{
parent::init();
}

/**
* Returns the hash of a token. Used to generate a hash for the verifier.
*
* @param string $signingKey Unique, random, cryptographically secure string
* @param string $data Token to be hashed
* @return string
*/
public static function getHashedToken(string $signingKey, string $data)
{
$hash = base64_encode(hash_hmac('sha256', $data, $signingKey, true));
return $hash;
}

/**
* Uses method in Yii2 to generate random string
*
* String length is 20 characters
*/
public static function getRandomString()
{
return Yii::$app->security->generateRandomString(self::RANDOM_STRING_LENGTH);
}
}
?>
80 changes: 80 additions & 0 deletions protected/components/PasswordResetTokenUserIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php
/**
* Class to handle the identity of user and how they authenticate
*
* @see https://www.yiiframework.com/doc/guide/1.1/en/topics.auth
*/
class PasswordResetTokenUserIdentity extends UserIdentity {

public $_id;
private $random_string_length;
const ERROR_SELECTOR_NOT_ASSOCIATED_WITH_A_USER = 4;
const ERROR_RECALCULATED_HASH_OF_VERIFIER_DOES_NOT_MATCH_HASH_IN_DATABASE = 5;
const ERROR_TOKEN_HAS_EXPIRED = 6;

/**
* @var string Token from URL sent by email
*/
public $urlToken;

/**
* Constructor
*/
public function __construct($urlToken)
{
$this->urlToken = $urlToken;
$this->random_string_length = CryptoService::RANDOM_STRING_LENGTH;
}

/**
* Provides the authentication process for an anonymous user with a password
* reset token that consists of two parts: a selector and verifier.
*
* @return boolean whether authentication succeeds. True if successful, False otherwise
*/
public function authenticate()
{
Yii::log("[INFO] [".__CLASS__.".php] ".__FUNCTION__.": In PasswordResetTokenUserIdentity::authenticate()", 'info');

// Find user associated with selector part in URL
$selectorFromURL = substr($this->urlToken, 0, $this->random_string_length);
$resetPasswordRequest = ResetPasswordRequest::model()->findByAttributes(array('selector' => $selectorFromURL));
$user = User::model()->findByAttributes(array('id' => $resetPasswordRequest->gigadb_user_id));

if ($user === null) // User not found
{
$this->errorCode = self::ERROR_SELECTOR_NOT_ASSOCIATED_WITH_A_USER;
}
else // User found
{
// Check re-calculated hash from verifier matches hash in reset_password_request database table
$signingKey = Yii::app()->params['signing_key'];
$verifierFromURL = substr($this->urlToken, $this->random_string_length, $this->random_string_length);
$hashedTokenFromURLVerifier = Yii::app()->cryptoService->getHashedToken($signingKey, $verifierFromURL);
if($hashedTokenFromURLVerifier === $resetPasswordRequest->hashed_token)
{
// Check if token has expired
if($resetPasswordRequest->isExpired()) {
Yii::log("[INFO] [".__CLASS__.".php] ".__FUNCTION__.": Token has expired: ", 'info');
$this->errorCode = self::ERROR_TOKEN_HAS_EXPIRED;
}
else {
$this->_id = $user->id;
$this->errorCode = self::ERROR_NONE;
}
}
else
{
$this->errorCode = self::ERROR_RECALCULATED_HASH_OF_VERIFIER_DOES_NOT_MATCH_HASH_IN_DATABASE;
}
}
return !$this->errorCode;
}

public function getId()
{
return $this->_id;
}
}

?>
Loading

0 comments on commit 382c470

Please sign in to comment.