You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

543 lines
26 KiB

<?php
/**
* Ajax Library for Register
*
* @package OpenEMR
* @link http://www.open-emr.org
* @author Jerry Padgett <sjpadgett@gmail.com>
* @author Brady Miller <brady.g.miller@gmail.com>
* @copyright Copyright (c) 2017-2019 Jerry Padgett <sjpadgett@gmail.com>
* @copyright Copyright (c) 2019 Brady Miller <brady.g.miller@gmail.com>
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/
/* 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\Twig\TwigContainer;
use OpenEMR\Common\Utils\RandomGenUtils;
use OpenEMR\FHIR\Config\ServerConfig;
function notifyAdmin($pid, $provider): void
{
$note = xlt("New patient registration received from patient portal. Reminder to check for possible new appointment");
$title = xlt("New Patient");
$user = sqlQueryNoLog("SELECT users.username FROM users WHERE authorized = 1 And id = ?", array($provider));
if (empty($user['username'])) {
$user['username'] = "portal-user";
}
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;
}
$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;
}
}
// 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
{
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;
}
$twigContainer = new TwigContainer(null, $GLOBALS['kernel']);
$twig = $twigContainer->getTwig();
$templateData = [];
$template = 'verify-failed';
$emailPrepSend = false;
// check to ensure email not used
$sql = sqlQuery(
"SELECT `pid` FROM `patient_data` WHERE `email` = ? OR `email_direct` = ?",
[
$email,
$email
]
);
if (!empty($sql['pid'])) {
$templateData = ['email' => $email];
(new SystemLogger())->debug("verifyEmail function: the email is already in use, so can not use");
$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
]));
}
$template = 'verify-success';
$templateData['encoded_link'] = $encoded_link;
$emailPrepSend = true;
}
$htmlMessage = $twig->render('emails/patient/verify_email/message-' . $template . '.html.twig', $templateData);
$plainMessage = $twig->render('emails/patient/verify_email/message-' . $template . '.text.twig', $templateData);
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($htmlMessage);
$mail->IsHTML(true);
$mail->AltBody = $plainMessage;
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;
}
// 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
]
);
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)
{
newInsuranceData(
$pid = $pid,
$type = "primary",
$provider = "0",
$policy_number = $_REQUEST['policy_number'],
$group_number = $_REQUEST['group_number'],
$plan_name = $_REQUEST['provider'] . ' ' . $_REQUEST['plan_name'],
$subscriber_lname = "",
$subscriber_mname = "",
$subscriber_fname = "",
$subscriber_relationship = "",
$subscriber_ss = "",
$subscriber_DOB = "",
$subscriber_street = "",
$subscriber_postal_code = "",
$subscriber_city = "",
$subscriber_state = "",
$subscriber_country = "",
$subscriber_phone = "",
$subscriber_employer = "",
$subscriber_employer_street = "",
$subscriber_employer_city = "",
$subscriber_employer_postal_code = "",
$subscriber_employer_state = "",
$subscriber_employer_country = "",
$copay = $_REQUEST['copay'],
$subscriber_sex = "",
$effective_date = DateToYYYYMMDD($_REQUEST['date']),
$accept_assignment = "TRUE",
$policy_type = ""
);
newInsuranceData($pid, "secondary");
newInsuranceData($pid, "tertiary");
}
function validEmail($email)
{
if (preg_match("/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})$/i", $email)) {
return true;
}
return false;
}
// $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)) {
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;
}
}
// Token expiry 1 hour
$expiry = new DateTime('NOW');
$expiry->add(new DateInterval('PT01H'));
$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 = (new CryptoGen())->encryptStandard($token_new);
if (empty($token)) {
// 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'];
if (stripos($site_addr, $site_id) === false) {
$encoded_link = sprintf("%s?%s", attr($site_addr), http_build_query([
'forward' => $token,
'site' => $_SESSION['site_id']
]));
} else {
$encoded_link = sprintf("%s&%s", attr($site_addr), http_build_query([
'forward' => $token
]));
}
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;
}
}
// 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);
}
}
$twigContainer = new TwigContainer(null, $GLOBALS['kernel']);
$twig = $twigContainer->getTwig();
$fhirServerConfig = new ServerConfig();
$data = [
'portal_onsite_two_address' => $GLOBALS['portal_onsite_two_address']
,'pin' => $pin
,'encoded_link' => $encoded_link
,'fhir_address' => $fhirServerConfig->getFhirUrl()
,'fhir_requirements_address' => $fhirServerConfig->getFhir3rdPartyAppRequirementsDocument()
];
$htmlMessage = $twig->render('emails/patient/reset_credentials/message.html.twig', $data);
$plainMessage = $twig->render('emails/patient/reset_credentials/message.text.twig', $data);
$mail = new MyMailer();
$pt_name = text($newpd['fname'] . ' ' . $newpd['lname']);
$pt_email = text($newpd['email']);
$email_subject = xlt('Access Your Patient Portal') . ' / ' . xlt('3rd Party API Access');
$email_sender = $GLOBALS['patient_reminder_sender_email'];
$mail->AddReplyTo($email_sender, $email_sender);
$mail->SetFrom($email_sender, $email_sender);
$mail->AddAddress($pt_email, $pt_name);
$mail->Subject = $email_subject;
$mail->MsgHTML($htmlMessage);
$mail->IsHTML(true);
$mail->AltBody = $plainMessage;
if ($mail->Send()) {
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;
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" . "EMAIL ERROR: " . $email_status;
$admin_msg .= "\n" . xlt("Please follow up.");
// send note
addPnote($pid, $admin_msg, 1, 1, $title, $user['username'], '', 'New');
}
return false;
}
}
// 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();
}
}
}
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();
}