diff --git a/library/globals.inc.php b/library/globals.inc.php
index 25c9baf31b6..02fdbbdb963 100644
--- a/library/globals.inc.php
+++ b/library/globals.inc.php
@@ -2888,6 +2888,20 @@ function gblTimeZones()
xl('Patient is required to enter their contact e-mail if present in Demographics Contact.')
),
+ 'google_recaptcha_site_key' => array(
+ xl('Google reCAPTCHA V2 site key'),
+ 'text',
+ '',
+ xl('Google reCAPTCHA V2 site key')
+ ),
+
+ 'google_recaptcha_secret_key' => array(
+ xl('Google reCAPTCHA V2 secret key'),
+ 'encrypted',
+ '',
+ xl('Google reCAPTCHA V2 secret key')
+ ),
+
'portal_onsite_two_register' => array(
xl('Allow New Patient Registration Widget'),
'bool', // data type
diff --git a/library/js/xl/jquery-datetimepicker-2-5-4-alternate.js.php b/library/js/xl/jquery-datetimepicker-2-5-4-alternate.js.php
index 08ab4979013..f6375b92687 100644
--- a/library/js/xl/jquery-datetimepicker-2-5-4-alternate.js.php
+++ b/library/js/xl/jquery-datetimepicker-2-5-4-alternate.js.php
@@ -55,7 +55,7 @@
var datepicker_xlMonths = [, , , , , , , , , , , ];
var datepicker_xlDayofwkshort= [, , , , , , ];
var datepicker_xlDayofwk= [, , , , , , ];
-var datepicker_rtl = ;
+var datepicker_rtl = ;
var datepicker_yearStart = '1900';
var datepicker_format = 'Y-m-d';
var datepicker_scrollInput = false;
@@ -65,7 +65,7 @@
var datetimepicker_xlMonths = [, , , , , , , , , , , ];
var datetimepicker_xlDayofwkshort= [, , , , , , ];
var datetimepicker_xlDayofwk= [, , , , , , ];
-var datetimepicker_rtl = ;
+var datetimepicker_rtl = ;
var datetimepicker_yearStart = '1900';
var datetimepicker_format = 'Y-m-d H:i:s';
var datetimepicker_step = '30';
diff --git a/library/js/xl/jquery-datetimepicker-2-5-4.js.php b/library/js/xl/jquery-datetimepicker-2-5-4.js.php
index 680d970e57d..713be9306f2 100644
--- a/library/js/xl/jquery-datetimepicker-2-5-4.js.php
+++ b/library/js/xl/jquery-datetimepicker-2-5-4.js.php
@@ -54,7 +54,7 @@
]
},
},
-
+
/**
* In RTL languages a datepicker popup is opened in left and it's cutted by the edge of the window
* This patch resolves that and moves a datepicker popup to right side.
@@ -76,7 +76,7 @@
yearStart: '1900',
scrollInput: false,
scrollMonth: false,
- rtl: ,
+ rtl: ,
minDate: '',
diff --git a/portal/account/account.lib.php b/portal/account/account.lib.php
index 063627ca167..40cc68bdc31 100644
--- a/portal/account/account.lib.php
+++ b/portal/account/account.lib.php
@@ -14,11 +14,14 @@
/* Library functions for register*/
+use GuzzleHttp\Client;
use OpenEMR\Common\Auth\AuthHash;
use OpenEMR\Common\Crypto\CryptoGen;
+use OpenEMR\Common\Logging\EventAuditLogger;
+use OpenEMR\Common\Logging\SystemLogger;
use OpenEMR\Common\Utils\RandomGenUtils;
-function notifyAdmin($pid, $provider)
+function notifyAdmin($pid, $provider): void
{
$note = xlt("New patient registration received from patient portal. Reminder to check for possible new appointment");
@@ -29,60 +32,273 @@ function notifyAdmin($pid, $provider)
$user['username'] = "portal-user";
}
- $rtn = addPnote($pid, $note, 1, 1, $title, $user['username'], '', 'New');
+ addPnote($pid, $note, 1, 1, $title, $user['username'], '', 'New');
+}
+
+function processRecaptcha($gRecaptchaResponse): bool
+{
+ if (empty($gRecaptchaResponse)) {
+ (new SystemLogger())->error("processRecaptcha function: gRecaptchaResponse is empty, so unable to verify recaptcha");
+ return false;
+ }
+ if (empty($GLOBALS['google_recaptcha_site_key'])) {
+ (new SystemLogger())->error("processRecaptcha function: google_recaptcha_site_key is empty, so unable to verify recaptcha");
+ return false;
+ }
+ if (empty($GLOBALS['google_recaptcha_secret_key'])) {
+ (new SystemLogger())->error("processRecaptcha function: google_recaptcha_secret_key is empty, so unable to verify recaptcha");
+ return false;
+ }
+ $googleRecaptchaSecretKey = (new CryptoGen())->decryptStandard($GLOBALS['google_recaptcha_secret_key']);
+ if (empty($googleRecaptchaSecretKey)) {
+ (new SystemLogger())->error("processRecaptcha function: decrypted google_recaptcha_secret_key global is empty, so unable to verify recaptcha");
+ return false;
+ }
- return $rtn;
+ $client = new Client([
+ 'base_uri' => 'https://www.google.com/recaptcha/api/',
+ 'timeout' => 2.0
+ ]);
+ $response = $client->request('POST', 'siteverify', [
+ 'query' => [
+ 'secret' => $googleRecaptchaSecretKey,
+ 'response' => $gRecaptchaResponse
+ ]
+ ]);
+ $responseArray = json_decode($response->getBody(), true);
+ (new SystemLogger())->debug("processRecaptcha function: recaptcha verification returned following", ['returnJson' => $responseArray]);
+ if (empty($responseArray)) {
+ (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was unsuccessful since empty response from google");
+ return false;
+ }
+ if (empty($responseArray['success'])) {
+ (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was unsuccessful since empty success status from google");
+ return false;
+ }
+ if ($responseArray['success'] === true) {
+ (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was successful from host " . ($responseArray['hostname'] ?? ''));
+ return true;
+ } else {
+ (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was not successful from host " . ($responseArray['hostname'] ?? ''), ['errorCodes' => ($responseArray['error-codes'] ?? '')]);
+ return false;
+ }
}
-function isNew($dob = '', $lname = '', $fname = '', $email = '')
+
+// note function only returns false when there is an error in something and does not flag if a email exists or not
+// (this is done so a bad actor can not see if certain patients exist in the instance)
+function verifyEmail(string $languageChoice, string $fname, string $mname, string $lname, string $dob, string $email): bool
{
- // no sense doing a weighted search because we want specific criteria
- // for new patients. Mainly catch those trying to just get a new password.
- // or change email.
- $last = trim(urldecode($lname));
- $first = trim(urldecode($fname));
- $dob = trim(urldecode($dob));
- $semail = trim(urldecode($email));
- // first check email both contact and secure
- if ($email) {
- $sql = "select pid from patient_data " .
- "Where (patient_data.email LIKE ? OR patient_data.email_direct LIKE ?) " .
- "And patient_data.DOB LIKE ? order by date limit 0,1";
- $data = array(
- $semail,
- $semail,
- $dob
- );
- $tier1 = sqlQuery($sql, $data);
- if (!empty($tier1['pid'])) {
- // email with this dob already on file so, skedaddle ...
- return (int)$tier1['pid'];
- }
+ if (empty($languageChoice) || empty($fname) || empty($lname) || empty($dob) || empty($email)) {
+ // only optional setting is the mname
+ (new SystemLogger())->error("a required verifyEmail function parameter is empty");
+ return false;
+ }
+
+ if (!validEmail($email)) {
+ (new SystemLogger())->debug("verifyEmail function is using a email that failed validEmail test, so can not use");
+ return true;
}
- // fully matched for our purposes
- $sql = "select pid from patient_data Where patient_data.lname LIKE ? And patient_data.fname LIKE ? And patient_data.DOB LIKE ? And (patient_data.email LIKE ? OR patient_data.email_direct LIKE ?) order by date limit 0,1";
- $data = array(
- $last,
- $first,
- $dob,
- $semail,
- $semail
+
+ $emailPrepSend = false;
+
+ // check to ensure email not used
+ $sql = sqlQuery(
+ "SELECT `pid` FROM `patient_data` WHERE `email` = ? OR `email_direct` = ?",
+ [
+ $email,
+ $email
+ ]
);
- $tier2 = sqlQuery($sql, $data);
- if (!empty($tier2['pid'])) {
- return (int)$tier2['pid'];
- }
- // name and dob match. Most likely trying to change email!
- // too much of a coincidence...
- $sql = "select pid from patient_data Where patient_data.lname LIKE ? And patient_data.fname LIKE ? And patient_data.DOB LIKE ? order by date limit 0,1";
- $data = array(
- $last,
- $first,
- $dob
+
+ if (!empty($sql['pid'])) {
+ (new SystemLogger())->debug("verifyEmail function: the email is already in use, so can not use");
+ $message = '
' . xlt("We received a patient registration email verification request from this email address at") . ' ' . text($email) . '
';
+ $message .= '
' . xlt("However, you can not use this email for patient registration since it is already being used.") . '
';
+ $message .= '
' . xlt("Recommend contacting the clinic if need guidance.") . "
";
+ $message .= '
' . xlt("Please ignore this email if you did not make this request.") . '
';
+ $message .= "
" . xlt("Thank You.") . "
";
+ $emailPrepSend = true;
+ } else {
+ (new SystemLogger())->debug("verifyEmail function: the email will be used to register the patient");
+
+ // create token (1 hour expiry) and ensure the token is unique
+ $unique = false;
+ for ($i = 1; $i <= 10; $i++) {
+ $expiry = new DateTime('NOW');
+ $expiry->add(new DateInterval('PT01H'));
+ $token_raw = RandomGenUtils::createUniqueToken(32);
+ $token_encrypt = (new CryptoGen())->encryptStandard($token_raw);
+ if (empty($token_encrypt)) {
+ // Serious issue if this is case, so return that something bad happened.
+ (new SystemLogger())->error("OpenEMR Error : Portal email verification token encryption broken - exiting");
+ return false;
+ }
+ $token_database = $token_raw . bin2hex($expiry->format('U'));
+
+ $sqlVerify = sqlQueryNoLog("SELECT `id` FROM `verify_email` WHERE `token_onetime` LIKE BINARY ?", [$token_raw . '%']);
+ if (empty($sqlVerify['id'])) {
+ $unique = true;
+ break;
+ } else {
+ (new SystemLogger())->error("was unable to create a unique token in verifyEmail function, which is very odd, so will try again (will try up to 10 times)");
+ }
+ }
+ if (!$unique) {
+ (new SystemLogger())->error("was unable to create a unique token in verifyEmail function, so failed");
+ return false;
+ }
+
+ // place/replace database entry
+ $sql = sqlQuery("SELECT `id` FROM `verify_email` WHERE `email` = ?", [$email]);
+ if (empty($sql['id'])) {
+ sqlStatementNoLog(
+ "INSERT INTO `verify_email` (`email`, `language`, `fname`, `mname`, `lname`, `dob`, `token_onetime`, `active`, `pid_holder`) VALUES (?, ?, ?, ?, ?, ?, ?, 1, null)",
+ [
+ $email,
+ $languageChoice,
+ $fname,
+ ($mname ?? ''),
+ $lname,
+ $dob,
+ $token_database
+ ]
+ );
+ } else {
+ sqlStatementNoLog(
+ "UPDATE `verify_email` SET `language` = ?, `fname` = ?, `mname` = ?, `lname` = ?, `dob` = ?, `token_onetime` = ?, `active` = 1, `pid_holder` = null WHERE `email` = ?",
+ [
+ $languageChoice,
+ $fname,
+ ($mname ?? ''),
+ $lname,
+ $dob,
+ $token_database,
+ $email
+ ]
+ );
+ }
+
+ // create $encoded_link
+ $site_addr = $GLOBALS['portal_onsite_two_address'];
+ $site_id = $_SESSION['site_id'];
+ if (stripos($site_addr, $site_id) === false) {
+ $encoded_link = sprintf("%s?%s", attr($site_addr), http_build_query([
+ 'forward_email_verify' => $token_encrypt,
+ 'site' => $_SESSION['site_id']
+ ]));
+ } else {
+ $encoded_link = sprintf("%s&%s", attr($site_addr), http_build_query([
+ 'forward_email_verify' => $token_encrypt
+ ]));
+ }
+
+ // create message with messageCreateVerifyEmail (no pin code)
+ $message = messageCreateVerifyEmail($encoded_link);
+ $emailPrepSend = true;
+ }
+
+ if ($emailPrepSend) {
+ // send email
+ $mail = new MyMailer();
+ $email_sender = $GLOBALS['patient_reminder_sender_email'];
+ $mail->AddReplyTo($email_sender, $email_sender);
+ $mail->SetFrom($email_sender, $email_sender);
+ $mail->AddAddress($email, ($fname . ' ' . $lname));
+ $mail->Subject = xlt('Verify your email for patient portal registration');
+ $mail->MsgHTML("
" . $message . "
");
+ $mail->IsHTML(true);
+ $mail->AltBody = $message;
+
+ if ($mail->Send()) {
+ EventAuditLogger::instance()->newEvent('patient-reg-email-verify', '', '', 1, "The patient registration verification email was successfully sent to " . $email);
+ (new SystemLogger())->debug("The patient registration verification email was successfully sent to " . $email);
+ return true;
+ } else {
+ $email_status = $mail->ErrorInfo;
+ EventAuditLogger::instance()->newEvent('patient-reg-email-verify', '', '', 0, "The patient registration verification email was not successfully sent to " . $email . " because of following issue: " . $email_status);
+ (new SystemLogger())->error("The patient registration verification email was not successfully sent to " . $email . " because of following issue: " . $email_status);
+ return false;
+ }
+ }
+
+ // should never get to below
+ return true;
+}
+
+function messageCreateVerifyEmail($encoded_link = '')
+{
+ $message = '
' . xlt("We received a patient registration email verification request. The link to verify your email is below.") . '
';
+ $message .= '
' . xlt("Please ignore this email if you did not make this request") . '
';
+ $message .= '
' . xlt("Below link is only valid for one hour.") . ":
";
+
+ return $message;
+}
+
+// note function only returns 0 when there is an error in something and does not flag if a patient exists or not
+// (this is done so a bad actor can not see if certain patients exist in the instance)
+function resetPassword(string $dob, string $lname, string $fname, string $email): int
+{
+ if (empty($dob) || empty($lname) || empty($fname) || empty($email)) {
+ (new SystemLogger())->error("a resetPassword function parameter is empty");
+ return 0;
+ }
+
+ $sql = sqlStatement(
+ "SELECT `pid` FROM `patient_data` WHERE `dob` = ? AND `lname` = ? AND `fname` = ? AND (`email` = ? OR `email_direct` = ?)",
+ [
+ $dob,
+ $lname,
+ $fname,
+ $email,
+ $email
+ ]
);
- $tier3 = sqlQuery($sql, $data);
- return (int)$tier3['pid'] ?: 0;
+ if (sqlNumRows($sql) > 1) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: Multiple patients were found in patient_data for search of: " . $fname . " " . $lname . " " . $dob . " " . $email);
+ (new SystemLogger())->error("resetPassword function selected more than 1 patient from patient_data, so was unable to reset the password");
+ return 1;
+ }
+ if (!sqlNumRows($sql)) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_data for search of: " . $fname . " " . $lname . " " . $dob . " " . $email);
+ (new SystemLogger())->debug("resetPassword function found no patient in patient_data, so was unable to reset the password");
+ return 1;
+ }
+ $row = sqlFetchArray($sql);
+ if (empty($row['pid'])) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_data for search of: " . $fname . " " . $lname . " " . $dob . " " . $email);
+ (new SystemLogger())->debug("resetPassword function found no patient in patient_data, so was unable to reset the password");
+ return 1;
+ }
+ $tempPid = $row['pid'];
+
+ $sql = sqlStatement("SELECT `pid` FROM `patient_access_onsite` WHERE `pid`=?", [$tempPid]);
+ if (sqlNumRows($sql) > 1) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: Multiple patients were found in patient_access_onsite for search of pid " . $tempPid);
+ (new SystemLogger())->error("resetPassword function selected more than 1 patient from patient_access_onsite, so was unable to reset the password");
+ return 1;
+ }
+ if (!sqlNumRows($sql)) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_access_onsite for search of pid " . $tempPid);
+ (new SystemLogger())->debug("resetPassword function found no patient in patient_access_onsite, so was unable to reset the password");
+ return 1;
+ }
+ $row = sqlFetchArray($sql);
+ if (empty($row['pid'])) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_access_onsite for search of pid " . $tempPid);
+ (new SystemLogger())->debug("resetPassword function found no patient in patient_access_onsite, so was unable to reset the password");
+ return 1;
+ }
+
+ $rtn = doCredentials($row['pid'], true, $email);
+ if ($rtn) {
+ return 1;
+ } else {
+ return 0;
+ }
}
function saveInsurance($pid)
@@ -122,19 +338,6 @@ function saveInsurance($pid)
newInsuranceData($pid, "tertiary");
}
-function getNewPid()
-{
- $result = sqlQuery("select max(pid)+1 as pid from patient_data");
- $newpid = 1;
- if ($result['pid'] > 1) {
- $newpid = $result['pid'];
- }
- if ($newpid == null) {
- $newpid = 0;
- }
- return (int)$newpid;
-}
-
function validEmail($email)
{
if (preg_match("/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})$/i", $email)) {
@@ -144,7 +347,7 @@ function validEmail($email)
return false;
}
-function messageCreate($uname, $pass, $encoded_link = '')
+function messageCreate($pass, $encoded_link = '')
{
$message = '
' . xlt("We received a credentials reset request. The link to reset your credentials is below.") . '
';
$message .= '
' . xlt("Please ignore this email if you did not make this request") . '
';
@@ -158,30 +361,69 @@ function messageCreate($uname, $pass, $encoded_link = '')
return $message;
}
-function doCredentials($pid)
+// $resetPass mode return false when something breaks (although returns true if related to a patient existing or not to prevent fishing for patients)
+// !$resetPass mode return false when something breaks (no need to protect against from fishing since can't do from registration workflow)
+function doCredentials($pid, $resetPass = false, $resetPassEmail = ''): bool
{
$newpd = sqlQuery("SELECT id,fname,mname,lname,email,email_direct, providerID FROM `patient_data` WHERE `pid` = ?", array($pid));
$user = sqlQueryNoLog("SELECT users.username FROM users WHERE authorized = 1 And id = ?", array($newpd['providerID']));
+
+ // ensure pid exists
if (empty($newpd)) {
- error_log('OpenEMR Error : Portal credential retrieve error. Record not found.');
- return xl("ERROR Portal credentials retrieve error. Record not found.");
+ if ($resetPass) {
+ (new SystemLogger())->error("doCredentials function did not find a patient from patient_data for " . $pid . " (this should never happen since checked in resetPassword function), so was unable to reset the password");
+ return true;
+ } else { // !$resetPass
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation failure: Following pid did not exist: " . $pid);
+ (new SystemLogger())->error("doCredentials function did not find a patient from patient_data for " . $pid . " , so was unable to create credentials");
+ return false;
+ }
+ }
+
+ // ensure email is valid
+ if ($resetPass) {
+ if ((empty($resetPassEmail)) || ((($newpd['email'] ?? '') != $resetPassEmail) && (($newpd['email_direct'] ?? '') != $resetPassEmail))) {
+ (new SystemLogger())->error("doCredentials function with empty email or unable to find correct email " . $resetPassEmail . " in patient from patient_data for pid " . $pid . " (this should never happen since checked in resetPassword function), so was unable to reset the password");
+ return true;
+ }
+ if (!validEmail($resetPassEmail)) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: Email " . $resetPassEmail . " was not considered valid for pid: " . $pid);
+ (new SystemLogger())->error("doCredentials function with email " . $resetPassEmail . " for pid " . $pid . " that was not valid per validEmail function, so was unable to reset the password");
+ return false;
+ }
+ $newpd['email'] = $resetPassEmail;
+ } else { // !$resetPass
+ if (!validEmail($newpd['email'])) {
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "Patient password reset failure: Email " . $newpd['email'] . " was not considered valid for pid: " . $pid);
+ (new SystemLogger())->error("doCredentials function with email " . $newpd['email'] . " for pid " . $pid . " was not valid per validEmail function, so was unable to complete the registration");
+ return false;
+ }
}
- $crypto = new CryptoGen();
- $uname = $newpd['fname'] . $newpd['id'];
+
// Token expiry 1 hour
$expiry = new DateTime('NOW');
$expiry->add(new DateInterval('PT01H'));
- $clear_pass = RandomGenUtils::generatePortalPassword();
$token_new = RandomGenUtils::createUniqueToken(32);
$pin = RandomGenUtils::createUniqueToken(6);
+ if (!$resetPass) {
+ $clear_pass = RandomGenUtils::generatePortalPassword();
+ $uname = $newpd['fname'] . $newpd['id'];
+ }
// Will send a link to user with encrypted token
- $token = $crypto->encryptStandard($token_new);
+ $token = (new CryptoGen())->encryptStandard($token_new);
if (empty($token)) {
- // Serious issue if this is case, so die.
- error_log('OpenEMR Error : Portal token encryption broken - exiting');
- die('Error : Token encryption failed - exiting');
+ // Serious issue if this is case, so exit.
+ if ($resetPass) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure secondary critical encryption error for email " . $newpd['email'] . " and pid: " . $pid);
+ (new SystemLogger())->error("Error : Token encryption failed during patient password reset - exiting");
+ return false;
+ } else { // !$resetPass
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary critical encryption error for email " . $newpd['email'] . " and pid: " . $pid);
+ (new SystemLogger())->error("Error : Token encryption failed during patient registration - exiting");
+ return false;
+ }
}
$site_addr = $GLOBALS['portal_onsite_two_address'];
$site_id = $_SESSION['site_id'];
@@ -196,35 +438,42 @@ function doCredentials($pid)
]));
}
- // Will store unencrypted token in database with the pin and expiration date
- $one_time = $token_new . $pin . bin2hex($expiry->format('U'));
- $res = sqlStatement("SELECT * FROM patient_access_onsite WHERE pid=?", array($pid));
- $query_parameters = array($uname, $one_time);
- $newHash = (new AuthHash('auth'))->passwordHash($clear_pass);
- if (empty($newHash)) {
- // Something is seriously wrong
- error_log('OpenEMR Error : OpenEMR is not working because unable to create a hash.');
- die("OpenEMR Error : OpenEMR is not working because unable to create a hash.");
- }
- $query_parameters[] = $newHash;
- $query_parameters[] = $pid;
- if (sqlNumRows($res)) {
- sqlStatementNoLog("UPDATE patient_access_onsite SET portal_username=?,portal_onetime=?,portal_pwd=?,portal_pwd_status=0 WHERE pid=?", $query_parameters);
- } else {
- sqlStatementNoLog("INSERT INTO patient_access_onsite SET portal_username=?,portal_onetime=?,portal_pwd=?,portal_pwd_status=0,pid=?", $query_parameters);
+ if (!$resetPass) {
+ $newHash = (new AuthHash('auth'))->passwordHash($clear_pass);
+ if (empty($newHash)) {
+ // Serious issue if this is case, so exit.
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary critical hashing error for email " . $newpd['email'] . " and pid: " . $pid);
+ (new SystemLogger())->error("Error : Hashing failed during patient registration - exiting");
+ return false;
+ }
}
- if (!validEmail($newpd['email_direct'])) {
- if (validEmail($newpd['email'])) {
- $newpd['email_direct'] = $newpd['email'];
+ // Will store unencrypted token in database with the pin and expiration date
+ $one_time = $token_new . $pin . bin2hex($expiry->format('U'));
+ if ($resetPass) {
+ // already confirmed there is an entry in patient_access_onsite in previously called resetPassword function
+ // (note that portal_username, portal_pwd_status and portal_pwd are not touched here since password needs to remain valid until patient
+ // actually changes the password)
+ $query_parameters = [$one_time, $pid];
+ sqlStatementNoLog("UPDATE `patient_access_onsite` SET `portal_onetime` = ? WHERE `pid` = ?", $query_parameters);
+ } else { // !$resetPass
+ $query_parameters = [$uname, $one_time, $newHash, $pid];
+ $res = sqlStatement("SELECT `id` FROM `patient_access_onsite` WHERE `pid` = ?", [$pid]);
+ if (sqlNumRows($res)) {
+ // this should never happen in current use case where these credentials are created after a new patient registers, so will return error
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary to credentials already existing for email " . $newpd['email'] . " and pid: " . $pid);
+ (new SystemLogger())->error("OpenEMR Error : doCredentials for registration - already credentials exists, so unable to create new credentials.");
+ return false;
+ } else {
+ sqlStatementNoLog("INSERT INTO patient_access_onsite SET portal_username=?,portal_onetime=?,portal_pwd=?,portal_pwd_status=0,pid=?", $query_parameters);
}
}
- $message = messageCreate($uname, $pin, $encoded_link);
+ $message = messageCreate($pin, $encoded_link);
$mail = new MyMailer();
$pt_name = text($newpd['fname'] . ' ' . $newpd['lname']);
- $pt_email = text($newpd['email_direct']);
+ $pt_email = text($newpd['email']);
$email_subject = xlt('Access Your Patient Portal');
$email_sender = $GLOBALS['patient_reminder_sender_email'];
$mail->AddReplyTo($email_sender, $email_sender);
@@ -236,31 +485,69 @@ function doCredentials($pid)
$mail->AltBody = $message;
if ($mail->Send()) {
- $sent = 1;
+ if ($resetPass) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 1, "The patient reset email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
+ (new SystemLogger())->debug("The patient reset email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
+ } else { // !$resetPass
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 1, "The patient registration credentials email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
+ (new SystemLogger())->debug("The patient registration credentials email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
+ }
+ return true;
} else {
$email_status = $mail->ErrorInfo;
- $errorMsg = "EMAIL ERROR: " . errorLogEscape($email_status) . ' ';
- if ($newpd['id']) {
- $errorMsg .= xlt("Your account has been successfully created, however; we were unable to send your new account information.");
- $errorMsg .= " " . xlt("Please contact your providers office with the following account information") . ": ";
- $errorMsg1 = xlt("Account Id") . ": " . $uname . " " . xlt("MRN Reference") . ": " . $pid;
- $errorMsg .= $errorMsg1;
- $errorMsg .= "
" . xlt("The providers office has been notified. Thank you.") . " ";
+ if ($resetPass) {
+ EventAuditLogger::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: The reset email to " . $newpd['email'] . " for pid " . $pid . " was not successful because of following issue: " . $email_status);
+ (new SystemLogger())->error("Patient password reset failure: The reset email to " . $newpd['email'] . " for pid " . $pid . " was not successful because of following issue: " . $email_status);
+ } else { // !$resetPass
+ EventAuditLogger::instance()->newEvent('patient-registration', '', '', 0, "The patient registration credentials email was not successfully sent to " . $newpd['email'] . " for pid " . $pid . " because of following issue: " . $email_status);
+ (new SystemLogger())->error("The patient registration credentials email was not successfully sent to " . $newpd['email'] . " for pid " . $pid . " because of following issue: " . $email_status);
// notify admin of failure.
$title = xlt("Failed Registration");
$admin_msg = "\n" . xlt("A new patients credentials could not be sent after portal registration.");
- $admin_msg .= "\n" . $errorMsg1;
+ $admin_msg .= "\n" . "EMAIL ERROR: " . $email_status;
$admin_msg .= "\n" . xlt("Please follow up.");
// send note
addPnote($pid, $admin_msg, 1, 1, $title, $user['username'], '', 'New');
- } else {
- $errorMsg .= " " . xlt("We were unable to create an account.") . " ";
- $errorMsg .= xlt("Please try again or contact the providers office for further assistance.");
}
- error_log("Portal Registration error: " . errorLogEscape($errorMsg), 0);
+ return false;
+ }
+}
- return $errorMsg;
+// the race condition can happen in registration since basically submitting patient info and insurance info at same time
+// where the pid is created and stored by the patient info. so, will sleep 1 seconds prior first attempt and then 5 seconds
+// prior second attempt to allow things to work out. Can make this mechanism more sophisticated in future if needed. In the
+// case of insurance, if it does fail getting the pid for some reason then the registration will still happen (and will
+// just not store the insurance info in worst case scenario).
+function getPidHolder($preventRaceCondition = false): int
+{
+ if (empty($_SESSION['token_id_holder'])) {
+ (new SystemLogger())->debug("getPidHolder function failed because token_id_holder session variable was not set");
+ return 0;
}
+ if ($preventRaceCondition) {
+ sleep(1);
+ }
+ $sql = sqlQueryNoLog("SELECT `pid_holder` FROM `verify_email` WHERE `id` = ?", [$_SESSION['token_id_holder']]);
+ if (!empty($sql['pid_holder'])) {
+ return $sql['pid_holder'];
+ } else {
+ if (!$preventRaceCondition) {
+ return 0;
+ } else { // $preventRaceCondition
+ (new SystemLogger())->debug("getPidHolder function sleeping fo 5 seconds to deal with race condition");
+ sleep(5);
+ return getPidHolder();
+ }
+ }
+}
- return $sent;
+function cleanupRegistrationSession()
+{
+ unset($_SESSION['patient_portal_onsite_two']);
+ unset($_SESSION['authUser']);
+ unset($_SESSION['pid']);
+ unset($_SESSION['site_id']);
+ unset($_SESSION['register']);
+ unset($_SESSION['register_silo_ajax']);
+ OpenEMR\Common\Session\SessionUtil::portalSessionCookieDestroy();
}
diff --git a/portal/account/account.php b/portal/account/account.php
index 9263dd16a90..af98cb01b60 100644
--- a/portal/account/account.php
+++ b/portal/account/account.php
@@ -17,6 +17,7 @@
OpenEMR\Common\Session\SessionUtil::portalSessionStart();
if (
+ (!empty($_SESSION['verifyPortalEmail']) && ($_SESSION['verifyPortalEmail'] === true)) ||
(($_SESSION['register'] ?? null) === true && isset($_SESSION['pid'])) ||
(($_SESSION['credentials_update'] ?? null) === 1 && isset($_SESSION['pid'])) ||
(($_SESSION['itsme'] ?? null) === 1 && isset($_SESSION['password_update']))
@@ -29,11 +30,41 @@
require_once(__DIR__ . "/../lib/portal_mail.inc");
require_once("$srcdir/pnotes.inc");
require_once("./account.lib.php");
+
+use OpenEMR\Common\Csrf\CsrfUtils;
+use OpenEMR\Common\Logging\SystemLogger;
+use OpenEMR\Core\Header;
+
$action = $_REQUEST['action'] ?? '';
-if ($action == 'set_lang') {
- $_SESSION['language_choice'] = (int)$_REQUEST['value'];
- echo 'okay';
- exit();
+
+if ($action == 'verify_email') {
+ if (!empty($_SESSION['verifyPortalEmail']) && ($_SESSION['verifyPortalEmail'] === true)) {
+ if (!empty($GLOBALS['portal_onsite_two_register']) && !empty($GLOBALS['google_recaptcha_site_key']) && !empty($GLOBALS['google_recaptcha_secret_key'])) {
+ // check csrf
+ if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token_form"], 'verifyEmailCsrf')) {
+ CsrfUtils::csrfNotVerified(true, true, false);
+ cleanupRegistrationSession();
+ exit;
+ }
+ // check recaptcha
+ $recaptcha = processRecaptcha($_POST['g-recaptcha-response'] ?? '');
+ if (!$recaptcha) {
+ echo xlt("Something went wrong. Recommend contacting the clinic.");
+ cleanupRegistrationSession();
+ exit;
+ }
+ // process
+ $rtn = verifyEmail($_POST['languageChoice'] ?? '', $_POST['fname'] ?? '', $_POST['mname'] ?? '', $_POST['lname'] ?? '', $_POST['dob'] ?? '', $_POST['email'] ?? '');
+ if ($rtn) {
+ Header::setupHeader();
+ echo '
' . xlt("Check your email inbox (and possibly your spam folder) for further instructions to register. If you have not received an email, then recommend contacting the clinic.") . '
';
+ } else {
+ echo xlt("Something went wrong. Recommend contacting the clinic.");
+ }
+ }
+ }
+ cleanupRegistrationSession();
+ exit;
}
if ($action == 'userIsUnique') {
@@ -66,58 +97,85 @@
exit;
}
-if ($action == 'get_newpid') {
- $email = $_REQUEST['email'] ?? '';
- $rtn = isNew($_REQUEST['dob'], $_REQUEST['last'], $_REQUEST['first'], $email);
- if ((int)$rtn != 0) {
- echo xlt("This account already exists.") . "\r\n\r\n" .
- xlt("We are sorry you are having troubles with your account.") . "\r\n" .
- xlt("Please contact your provider.") . "\r\n" .
- xlt("Reference this Account Number") . " " . $rtn;
+if ($action == 'reset_password') {
+ if (($_SESSION['register'] ?? null) === true && isset($_SESSION['pid'])) {
+ $rtn = 0;
+ if (!empty($GLOBALS['portal_two_pass_reset']) && !empty($GLOBALS['google_recaptcha_site_key']) && !empty($GLOBALS['google_recaptcha_secret_key'])) {
+ // check csrf
+ if (!CsrfUtils::verifyCsrfToken($_GET["csrf_token_form"], 'passwordResetCsrf')) {
+ CsrfUtils::csrfNotVerified(true, true, false);
+ cleanupRegistrationSession();
+ exit;
+ }
+ // check recaptcha
+ $recaptcha = processRecaptcha($_GET['g-recaptcha-response'] ?? '');
+ if ($recaptcha) {
+ // Allow Patients to Reset Credentials setting is turned on
+ $rtn = resetPassword($_GET['dob'] ?? '', $_GET['last'] ?? '', $_GET['first'] ?? '', $_GET['email'] ?? '');
+ }
+ }
+ echo js_escape($rtn);
+ exit();
+ } else {
+ cleanupRegistrationSession();
exit();
}
- $rtn = getNewPid();
- echo js_escape($rtn);
- exit();
-}
-
-if ($action == 'is_new') {
- $email = isset($_REQUEST['email']) ? $_REQUEST['email'] : '';
- $rtn = isNew($_REQUEST['dob'], $_REQUEST['last'], $_REQUEST['first'], $email);
- echo js_escape($rtn);
- exit();
}
if ($action == 'do_signup') {
- $rtn = doCredentials($_REQUEST['pid']);
- echo js_escape($rtn);
+ if (($_SESSION['register_silo_ajax'] ?? null) === true && ($_SESSION['register'] ?? null) === true && isset($_SESSION['pid'])) {
+ if (!empty($GLOBALS['portal_onsite_two_register']) && !empty($GLOBALS['google_recaptcha_site_key']) && !empty($GLOBALS['google_recaptcha_secret_key'])) {
+ $pidHolder = getPidHolder();
+ if ($pidHolder == 0) {
+ (new SystemLogger())->error("account.php action do_signup failed because unable to collect pid from pid_holder");
+ cleanupRegistrationSession();
+ exit();
+ }
+ $rtn = doCredentials($pidHolder);
+ if ($rtn) {
+ (new SystemLogger())->debug("account.php action do_signup apparently successful");
+ if (!empty($_GET['provider'])) {
+ notifyAdmin($pidHolder, $_GET['provider']);
+ (new SystemLogger())->debug("account.php action do_signup apparently successful, so sent a pnote to the provider");
+ }
+ Header::setupHeader();
+ echo '
' . xlt("Your new credentials have been sent. Check your email inbox and also possibly your spam folder. Once you log into your patient portal feel free to make an appointment or send us a secure message. We look forward to seeing you soon.") . '
';
+ } else {
+ (new SystemLogger())->debug("account.php action do_signup apparently not successful");
+ Header::setupHeader();
+ echo '
' . xlt("There was a problem registering you. Recommend contacting clinic for assistance.") . '
';
+ }
+ } else {
+ (new SystemLogger())->error("account.php action do_signup attempted without registration module on, so failed");
+ }
+ }
+ cleanupRegistrationSession();
exit();
}
if ($action == 'new_insurance') {
- $pid = $_REQUEST['pid'];
- saveInsurance($pid);
- exit();
-}
-
-if ($action == 'notify_admin') {
- $pid = $_REQUEST['pid'];
- $provider = $_REQUEST['provider'];
- $rtn = notifyAdmin($pid, $provider);
- echo js_escape($rtn);
- exit();
+ if (($_SESSION['register_silo_ajax'] ?? null) === true && ($_SESSION['register'] ?? null) === true && isset($_SESSION['pid'])) {
+ if (!empty($GLOBALS['portal_onsite_two_register']) && !empty($GLOBALS['google_recaptcha_site_key']) && !empty($GLOBALS['google_recaptcha_secret_key'])) {
+ $pidHolder = getPidHolder(true);
+ if ($pidHolder == 0) {
+ (new SystemLogger())->error("account.php action new_insurance was not successful because unable to collect pid from pid_holder. will still complete registration process, which will not include insurance.");
+ exit();
+ }
+ saveInsurance($pidHolder);
+ (new SystemLogger())->debug("account.php action new_insurance was apparently successful");
+ exit();
+ } else {
+ (new SystemLogger())->error("account.php action new_insurance attempted without registration module on, so failed");
+ cleanupRegistrationSession();
+ exit();
+ }
+ } else {
+ cleanupRegistrationSession();
+ exit();
+ }
}
if ($action == 'cleanup') {
- unset($_SESSION['patient_portal_onsite_two']);
- unset($_SESSION['authUser']);
- unset($_SESSION['pid']);
- unset($_SESSION['site_id']);
- unset($_SESSION['register']);
- echo 'gone';
- OpenEMR\Common\Session\SessionUtil::portalSessionCookieDestroy();
-// I know, makes little sense.
-} else {
+ cleanupRegistrationSession();
exit();
}
-die(); //too be sure
diff --git a/portal/account/register.php b/portal/account/register.php
index 8014cb6e6bb..ae102456e8c 100644
--- a/portal/account/register.php
+++ b/portal/account/register.php
@@ -12,73 +12,33 @@
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/
-use OpenEMR\Core\Header;
-
-// Will start the (patient) portal OpenEMR session/cookie.
-require_once(dirname(__FILE__) . "/../../src/Common/Session/SessionUtil.php");
-OpenEMR\Common\Session\SessionUtil::portalSessionStart();
-session_regenerate_id(true);
-
-unset($_SESSION['itsme']);
-$_SESSION['authUser'] = 'portal-user';
-$_SESSION['pid'] = true;
-$_SESSION['register'] = true;
+// script is brought in as require_once in index.php when applicable
-$_SESSION['site_id'] = $_SESSION['site_id'] ?? 'default';
-$landingpage = "index.php?site=" . urlencode($_SESSION['site_id']);
-
-$ignoreAuth_onsite_portal = true;
+use OpenEMR\Core\Header;
-require_once("../../interface/globals.php");
-if (!$GLOBALS['portal_onsite_two_register']) {
+if ($portalRegistrationAuthorization !== true) {
+ (new SystemLogger())->debug("attempted to use register.php directly, so failed");
OpenEMR\Common\Session\SessionUtil::portalSessionCookieDestroy();
echo xlt("Not Authorized");
- @header('HTTP/1.1 401 Unauthorized');
+ header('HTTP/1.1 401 Unauthorized');
die();
}
-$res2 = sqlStatement("select * from lang_languages where lang_description = ?", array(
- $GLOBALS['language_default']
-));
-for ($iter = 0; $row = sqlFetchArray($res2); $iter++) {
- $result2[$iter] = $row;
-}
-if (count($result2) == 1) {
- $defaultLangID = $result2[0]["lang_id"];
- $defaultLangName = $result2[0]["lang_description"];
-} else {
- // default to english if any problems
- $defaultLangID = 1;
- $defaultLangName = "English";
+if (empty($GLOBALS['portal_onsite_two_register']) || empty($GLOBALS['google_recaptcha_site_key']) || empty($GLOBALS['google_recaptcha_secret_key'])) {
+ (new SystemLogger())->debug("attempted to use register.php despite register feature being turned off, so failed");
+ OpenEMR\Common\Session\SessionUtil::portalSessionCookieDestroy();
+ echo xlt("Not Authorized");
+ header('HTTP/1.1 401 Unauthorized');
+ die();
}
-if (!isset($_SESSION['language_choice'])) {
- $_SESSION['language_choice'] = $defaultLangID;
-}
-// collect languages if showing language menu
-if ($GLOBALS['language_menu_login']) {
- // sorting order of language titles depends on language translation options.
- $mainLangID = empty($_SESSION['language_choice']) ? '1' : $_SESSION['language_choice'];
- // Use and sort by the translated language name.
- $sql = "SELECT ll.lang_id, " . "IF(LENGTH(ld.definition),ld.definition,ll.lang_description) AS trans_lang_description, " . "ll.lang_description " .
- "FROM lang_languages AS ll " . "LEFT JOIN lang_constants AS lc ON lc.constant_name = ll.lang_description " .
- "LEFT JOIN lang_definitions AS ld ON ld.cons_id = lc.cons_id AND " . "ld.lang_id = ? " .
- "ORDER BY IF(LENGTH(ld.definition),ld.definition,ll.lang_description), ll.lang_id";
- $res3 = SqlStatement($sql, array(
- $mainLangID
- ));
-
- for ($iter = 0; $row = sqlFetchArray($res3); $iter++) {
- $result3[$iter] = $row;
- }
-
- if (count($result3) == 1) {
- // default to english if only return one language
- $hiddenLanguageField = "\n";
- }
-} else {
- $hiddenLanguageField = "\n";
-}
+unset($_SESSION['itsme']);
+$_SESSION['authUser'] = 'portal-user';
+$_SESSION['pid'] = true;
+$_SESSION['register'] = true;
+$_SESSION['register_silo_ajax'] = true;
+
+$landingpage = "index.php?site=" . urlencode($_SESSION['site_id']);
?>
@@ -92,8 +52,6 @@
@@ -405,7 +265,7 @@ function callServerAction(data) {