4 * Ajax Library for Register
7 * @link http://www.open-emr.org
8 * @author Jerry Padgett <sjpadgett@gmail.com>
9 * @author Brady Miller <brady.g.miller@gmail.com>
10 * @copyright Copyright (c) 2017-2019 Jerry Padgett <sjpadgett@gmail.com>
11 * @copyright Copyright (c) 2019 Brady Miller <brady.g.miller@gmail.com>
12 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
15 /* Library functions for register*/
17 use GuzzleHttp\Client
;
18 use OpenEMR\Common\Auth\AuthHash
;
19 use OpenEMR\Common\Crypto\CryptoGen
;
20 use OpenEMR\Common\Logging\EventAuditLogger
;
21 use OpenEMR\Common\Logging\SystemLogger
;
22 use OpenEMR\Common\Twig\TwigContainer
;
23 use OpenEMR\Common\Utils\RandomGenUtils
;
24 use OpenEMR\FHIR\Config\ServerConfig
;
26 function notifyAdmin($pid, $provider): void
29 $note = xlt("New patient registration received from patient portal. Reminder to check for possible new appointment");
30 $title = xlt("New Patient");
31 $user = sqlQueryNoLog("SELECT users.username FROM users WHERE authorized = 1 And id = ?", array($provider));
33 if (empty($user['username'])) {
34 $user['username'] = "portal-user";
37 addPnote($pid, $note, 1, 1, $title, $user['username'], '', 'New');
40 function processRecaptcha($gRecaptchaResponse): bool
42 if (empty($gRecaptchaResponse)) {
43 (new SystemLogger())->error("processRecaptcha function: gRecaptchaResponse is empty, so unable to verify recaptcha");
46 if (empty($GLOBALS['google_recaptcha_site_key'])) {
47 (new SystemLogger())->error("processRecaptcha function: google_recaptcha_site_key is empty, so unable to verify recaptcha");
50 if (empty($GLOBALS['google_recaptcha_secret_key'])) {
51 (new SystemLogger())->error("processRecaptcha function: google_recaptcha_secret_key is empty, so unable to verify recaptcha");
54 $googleRecaptchaSecretKey = (new CryptoGen())->decryptStandard($GLOBALS['google_recaptcha_secret_key']);
55 if (empty($googleRecaptchaSecretKey)) {
56 (new SystemLogger())->error("processRecaptcha function: decrypted google_recaptcha_secret_key global is empty, so unable to verify recaptcha");
60 $client = new Client([
61 'base_uri' => 'https://www.google.com/recaptcha/api/',
64 $response = $client->request('POST', 'siteverify', [
66 'secret' => $googleRecaptchaSecretKey,
67 'response' => $gRecaptchaResponse
70 $responseArray = json_decode($response->getBody(), true);
71 (new SystemLogger())->debug("processRecaptcha function: recaptcha verification returned following", ['returnJson' => $responseArray]);
72 if (empty($responseArray)) {
73 (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was unsuccessful since empty response from google");
76 if (empty($responseArray['success'])) {
77 (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was unsuccessful since empty success status from google");
80 if ($responseArray['success'] === true) {
81 (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was successful from host " . ($responseArray['hostname'] ??
''));
84 (new SystemLogger())->debug("processRecaptcha function: recaptcha verification was not successful from host " . ($responseArray['hostname'] ??
''), ['errorCodes' => ($responseArray['error-codes'] ??
'')]);
90 // note function only returns false when there is an error in something and does not flag if a email exists or not
91 // (this is done so a bad actor can not see if certain patients exist in the instance)
92 function verifyEmail(string $languageChoice, string $fname, string $mname, string $lname, string $dob, string $email): bool
94 if (empty($languageChoice) ||
empty($fname) ||
empty($lname) ||
empty($dob) ||
empty($email)) {
95 // only optional setting is the mname
96 (new SystemLogger())->error("a required verifyEmail function parameter is empty");
100 if (!validEmail($email)) {
101 (new SystemLogger())->debug("verifyEmail function is using a email that failed validEmail test, so can not use");
104 $twigContainer = new TwigContainer(null, $GLOBALS['kernel']);
105 $twig = $twigContainer->getTwig();
107 $template = 'verify-failed';
108 $emailPrepSend = false;
110 // check to ensure email not used
112 "SELECT `pid` FROM `patient_data` WHERE `email` = ? OR `email_direct` = ?",
119 if (!empty($sql['pid'])) {
120 $templateData = ['email' => $email];
121 (new SystemLogger())->debug("verifyEmail function: the email is already in use, so can not use");
122 $emailPrepSend = true;
124 (new SystemLogger())->debug("verifyEmail function: the email will be used to register the patient");
126 // create token (1 hour expiry) and ensure the token is unique
128 for ($i = 1; $i <= 10; $i++
) {
129 $expiry = new DateTime('NOW');
130 $expiry->add(new DateInterval('PT01H'));
131 $token_raw = RandomGenUtils
::createUniqueToken(32);
132 $token_encrypt = (new CryptoGen())->encryptStandard($token_raw);
133 if (empty($token_encrypt)) {
134 // Serious issue if this is case, so return that something bad happened.
135 (new SystemLogger())->error("OpenEMR Error : Portal email verification token encryption broken - exiting");
138 $token_database = $token_raw . bin2hex($expiry->format('U'));
140 $sqlVerify = sqlQueryNoLog("SELECT `id` FROM `verify_email` WHERE `token_onetime` LIKE BINARY ?", [$token_raw . '%']);
141 if (empty($sqlVerify['id'])) {
145 (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)");
149 (new SystemLogger())->error("was unable to create a unique token in verifyEmail function, so failed");
153 // place/replace database entry
154 $sql = sqlQuery("SELECT `id` FROM `verify_email` WHERE `email` = ?", [$email]);
155 if (empty($sql['id'])) {
157 "INSERT INTO `verify_email` (`email`, `language`, `fname`, `mname`, `lname`, `dob`, `token_onetime`, `active`, `pid_holder`) VALUES (?, ?, ?, ?, ?, ?, ?, 1, null)",
170 "UPDATE `verify_email` SET `language` = ?, `fname` = ?, `mname` = ?, `lname` = ?, `dob` = ?, `token_onetime` = ?, `active` = 1, `pid_holder` = null WHERE `email` = ?",
183 // create $encoded_link
184 $site_addr = $GLOBALS['portal_onsite_two_address'];
185 $site_id = $_SESSION['site_id'];
186 if (stripos($site_addr, $site_id) === false) {
187 $encoded_link = sprintf("%s?%s", attr($site_addr), http_build_query([
188 'forward_email_verify' => $token_encrypt,
189 'site' => $_SESSION['site_id']
192 $encoded_link = sprintf("%s&%s", attr($site_addr), http_build_query([
193 'forward_email_verify' => $token_encrypt
196 $template = 'verify-success';
197 $templateData['encoded_link'] = $encoded_link;
198 $emailPrepSend = true;
201 $htmlMessage = $twig->render('emails/patient/verify_email/message-' . $template . '.html.twig', $templateData);
202 $plainMessage = $twig->render('emails/patient/verify_email/message-' . $template . '.text.twig', $templateData);
204 if ($emailPrepSend) {
206 $mail = new MyMailer();
207 $email_sender = $GLOBALS['patient_reminder_sender_email'];
208 $mail->AddReplyTo($email_sender, $email_sender);
209 $mail->SetFrom($email_sender, $email_sender);
210 $mail->AddAddress($email, ($fname . ' ' . $lname));
211 $mail->Subject
= xlt('Verify your email for patient portal registration');
212 $mail->MsgHTML($htmlMessage);
214 $mail->AltBody
= $plainMessage;
217 EventAuditLogger
::instance()->newEvent('patient-reg-email-verify', '', '', 1, "The patient registration verification email was successfully sent to " . $email);
218 (new SystemLogger())->debug("The patient registration verification email was successfully sent to " . $email);
221 $email_status = $mail->ErrorInfo
;
222 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);
223 (new SystemLogger())->error("The patient registration verification email was not successfully sent to " . $email . " because of following issue: " . $email_status);
228 // should never get to below
232 // note function only returns 0 when there is an error in something and does not flag if a patient exists or not
233 // (this is done so a bad actor can not see if certain patients exist in the instance)
234 function resetPassword(string $dob, string $lname, string $fname, string $email): int
236 if (empty($dob) ||
empty($lname) ||
empty($fname) ||
empty($email)) {
237 (new SystemLogger())->error("a resetPassword function parameter is empty");
242 "SELECT `pid` FROM `patient_data` WHERE `dob` = ? AND `lname` = ? AND `fname` = ? AND (`email` = ? OR `email_direct` = ?)",
252 if (sqlNumRows($sql) > 1) {
253 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);
254 (new SystemLogger())->error("resetPassword function selected more than 1 patient from patient_data, so was unable to reset the password");
257 if (!sqlNumRows($sql)) {
258 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);
259 (new SystemLogger())->debug("resetPassword function found no patient in patient_data, so was unable to reset the password");
262 $row = sqlFetchArray($sql);
263 if (empty($row['pid'])) {
264 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);
265 (new SystemLogger())->debug("resetPassword function found no patient in patient_data, so was unable to reset the password");
268 $tempPid = $row['pid'];
270 $sql = sqlStatement("SELECT `pid` FROM `patient_access_onsite` WHERE `pid`=?", [$tempPid]);
271 if (sqlNumRows($sql) > 1) {
272 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: Multiple patients were found in patient_access_onsite for search of pid " . $tempPid);
273 (new SystemLogger())->error("resetPassword function selected more than 1 patient from patient_access_onsite, so was unable to reset the password");
276 if (!sqlNumRows($sql)) {
277 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_access_onsite for search of pid " . $tempPid);
278 (new SystemLogger())->debug("resetPassword function found no patient in patient_access_onsite, so was unable to reset the password");
281 $row = sqlFetchArray($sql);
282 if (empty($row['pid'])) {
283 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: No patient was found in patient_access_onsite for search of pid " . $tempPid);
284 (new SystemLogger())->debug("resetPassword function found no patient in patient_access_onsite, so was unable to reset the password");
288 $rtn = doCredentials($row['pid'], true, $email);
296 function saveInsurance($pid)
302 $policy_number = $_REQUEST['policy_number'],
303 $group_number = $_REQUEST['group_number'],
304 $plan_name = $_REQUEST['provider'] . ' ' . $_REQUEST['plan_name'],
305 $subscriber_lname = "",
306 $subscriber_mname = "",
307 $subscriber_fname = "",
308 $subscriber_relationship = "",
310 $subscriber_DOB = "",
311 $subscriber_street = "",
312 $subscriber_postal_code = "",
313 $subscriber_city = "",
314 $subscriber_state = "",
315 $subscriber_country = "",
316 $subscriber_phone = "",
317 $subscriber_employer = "",
318 $subscriber_employer_street = "",
319 $subscriber_employer_city = "",
320 $subscriber_employer_postal_code = "",
321 $subscriber_employer_state = "",
322 $subscriber_employer_country = "",
323 $copay = $_REQUEST['copay'],
324 $subscriber_sex = "",
325 $effective_date = DateToYYYYMMDD($_REQUEST['date']),
326 $accept_assignment = "TRUE",
329 newInsuranceData($pid, "secondary");
330 newInsuranceData($pid, "tertiary");
333 function validEmail($email)
335 if (preg_match("/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})$/i", $email)) {
342 // $resetPass mode return false when something breaks (although returns true if related to a patient existing or not to prevent fishing for patients)
343 // !$resetPass mode return false when something breaks (no need to protect against from fishing since can't do from registration workflow)
344 function doCredentials($pid, $resetPass = false, $resetPassEmail = ''): bool
346 $newpd = sqlQuery("SELECT id,fname,mname,lname,email,email_direct, providerID FROM `patient_data` WHERE `pid` = ?", array($pid));
347 $user = sqlQueryNoLog("SELECT users.username FROM users WHERE authorized = 1 And id = ?", array($newpd['providerID']));
352 (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");
354 } else { // !$resetPass
355 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation failure: Following pid did not exist: " . $pid);
356 (new SystemLogger())->error("doCredentials function did not find a patient from patient_data for " . $pid . " , so was unable to create credentials");
361 // ensure email is valid
363 if ((empty($resetPassEmail)) ||
((($newpd['email'] ??
'') != $resetPassEmail) && (($newpd['email_direct'] ??
'') != $resetPassEmail))) {
364 (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");
367 if (!validEmail($resetPassEmail)) {
368 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure: Email " . $resetPassEmail . " was not considered valid for pid: " . $pid);
369 (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");
372 $newpd['email'] = $resetPassEmail;
373 } else { // !$resetPass
374 if (!validEmail($newpd['email'])) {
375 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 0, "Patient password reset failure: Email " . $newpd['email'] . " was not considered valid for pid: " . $pid);
376 (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");
381 // Token expiry 1 hour
382 $expiry = new DateTime('NOW');
383 $expiry->add(new DateInterval('PT01H'));
385 $token_new = RandomGenUtils
::createUniqueToken(32);
386 $pin = RandomGenUtils
::createUniqueToken(6);
388 $clear_pass = RandomGenUtils
::generatePortalPassword();
389 $uname = $newpd['fname'] . $newpd['id'];
392 // Will send a link to user with encrypted token
393 $token = (new CryptoGen())->encryptStandard($token_new);
395 // Serious issue if this is case, so exit.
397 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 0, "Patient password reset failure secondary critical encryption error for email " . $newpd['email'] . " and pid: " . $pid);
398 (new SystemLogger())->error("Error : Token encryption failed during patient password reset - exiting");
400 } else { // !$resetPass
401 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary critical encryption error for email " . $newpd['email'] . " and pid: " . $pid);
402 (new SystemLogger())->error("Error : Token encryption failed during patient registration - exiting");
406 $site_addr = $GLOBALS['portal_onsite_two_address'];
407 $site_id = $_SESSION['site_id'];
408 if (stripos($site_addr, $site_id) === false) {
409 $encoded_link = sprintf("%s?%s", attr($site_addr), http_build_query([
411 'site' => $_SESSION['site_id']
414 $encoded_link = sprintf("%s&%s", attr($site_addr), http_build_query([
420 $newHash = (new AuthHash('auth'))->passwordHash($clear_pass);
421 if (empty($newHash)) {
422 // Serious issue if this is case, so exit.
423 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary critical hashing error for email " . $newpd['email'] . " and pid: " . $pid);
424 (new SystemLogger())->error("Error : Hashing failed during patient registration - exiting");
429 // Will store unencrypted token in database with the pin and expiration date
430 $one_time = $token_new . $pin . bin2hex($expiry->format('U'));
432 // already confirmed there is an entry in patient_access_onsite in previously called resetPassword function
433 // (note that portal_username, portal_pwd_status and portal_pwd are not touched here since password needs to remain valid until patient
434 // actually changes the password)
435 $query_parameters = [$one_time, $pid];
436 sqlStatementNoLog("UPDATE `patient_access_onsite` SET `portal_onetime` = ? WHERE `pid` = ?", $query_parameters);
437 } else { // !$resetPass
438 $query_parameters = [$uname, $one_time, $newHash, $pid];
439 $res = sqlStatement("SELECT `id` FROM `patient_access_onsite` WHERE `pid` = ?", [$pid]);
440 if (sqlNumRows($res)) {
441 // this should never happen in current use case where these credentials are created after a new patient registers, so will return error
442 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 0, "Patient credential creation registration failure secondary to credentials already existing for email " . $newpd['email'] . " and pid: " . $pid);
443 (new SystemLogger())->error("OpenEMR Error : doCredentials for registration - already credentials exists, so unable to create new credentials.");
446 sqlStatementNoLog("INSERT INTO patient_access_onsite SET portal_username=?,portal_onetime=?,portal_pwd=?,portal_pwd_status=0,pid=?", $query_parameters);
450 $twigContainer = new TwigContainer(null, $GLOBALS['kernel']);
451 $twig = $twigContainer->getTwig();
452 $fhirServerConfig = new ServerConfig();
455 'portal_onsite_two_address' => $GLOBALS['portal_onsite_two_address']
457 ,'encoded_link' => $encoded_link
458 ,'fhir_address' => $fhirServerConfig->getFhirUrl()
459 ,'fhir_requirements_address' => $fhirServerConfig->getFhir3rdPartyAppRequirementsDocument()
461 $htmlMessage = $twig->render('emails/patient/reset_credentials/message.html.twig', $data);
462 $plainMessage = $twig->render('emails/patient/reset_credentials/message.text.twig', $data);
464 $mail = new MyMailer();
465 $pt_name = text($newpd['fname'] . ' ' . $newpd['lname']);
466 $pt_email = text($newpd['email']);
467 $email_subject = xlt('Access Your Patient Portal') . ' / ' . xlt('3rd Party API Access');
468 $email_sender = $GLOBALS['patient_reminder_sender_email'];
469 $mail->AddReplyTo($email_sender, $email_sender);
470 $mail->SetFrom($email_sender, $email_sender);
471 $mail->AddAddress($pt_email, $pt_name);
472 $mail->Subject
= $email_subject;
473 $mail->MsgHTML($htmlMessage);
475 $mail->AltBody
= $plainMessage;
479 EventAuditLogger
::instance()->newEvent('patient-password-reset', '', '', 1, "The patient reset email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
480 (new SystemLogger())->debug("The patient reset email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
481 } else { // !$resetPass
482 EventAuditLogger
::instance()->newEvent('patient-registration', '', '', 1, "The patient registration credentials email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
483 (new SystemLogger())->debug("The patient registration credentials email was successfully sent to " . $newpd['email'] . " for pid " . $pid . ".");
487 $email_status = $mail->ErrorInfo
;
489 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);
490 (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);
491 } else { // !$resetPass
492 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);
493 (new SystemLogger())->error("The patient registration credentials email was not successfully sent to " . $newpd['email'] . " for pid " . $pid . " because of following issue: " . $email_status);
494 // notify admin of failure.
495 $title = xlt("Failed Registration");
496 $admin_msg = "\n" . xlt("A new patients credentials could not be sent after portal registration.");
497 $admin_msg .= "\n" . "EMAIL ERROR: " . $email_status;
498 $admin_msg .= "\n" . xlt("Please follow up.");
500 addPnote($pid, $admin_msg, 1, 1, $title, $user['username'], '', 'New');
506 // the race condition can happen in registration since basically submitting patient info and insurance info at same time
507 // where the pid is created and stored by the patient info. so, will sleep 1 seconds prior first attempt and then 5 seconds
508 // prior second attempt to allow things to work out. Can make this mechanism more sophisticated in future if needed. In the
509 // case of insurance, if it does fail getting the pid for some reason then the registration will still happen (and will
510 // just not store the insurance info in worst case scenario).
511 function getPidHolder($preventRaceCondition = false): int
513 if (empty($_SESSION['token_id_holder'])) {
514 (new SystemLogger())->debug("getPidHolder function failed because token_id_holder session variable was not set");
517 if ($preventRaceCondition) {
520 $sql = sqlQueryNoLog("SELECT `pid_holder` FROM `verify_email` WHERE `id` = ?", [$_SESSION['token_id_holder']]);
521 if (!empty($sql['pid_holder'])) {
522 return $sql['pid_holder'];
524 if (!$preventRaceCondition) {
526 } else { // $preventRaceCondition
527 (new SystemLogger())->debug("getPidHolder function sleeping fo 5 seconds to deal with race condition");
529 return getPidHolder();
534 function cleanupRegistrationSession()
536 unset($_SESSION['patient_portal_onsite_two']);
537 unset($_SESSION['authUser']);
538 unset($_SESSION['pid']);
539 unset($_SESSION['site_id']);
540 unset($_SESSION['register']);
541 unset($_SESSION['register_silo_ajax']);
542 OpenEMR\Common\Session\SessionUtil
::portalSessionCookieDestroy();