Fixes #6444, #6419 oauth2 redirect, imports (#6445)
[openemr.git] / src / RestControllers / SMART / SMARTAuthorizationController.php
blob25a602f183b02b3c08196a7fde3ad9eac0346716
1 <?php
3 /**
4 * SMARTAuthorizationController.php
5 * @package openemr
6 * @link http://www.open-emr.org
7 * @author Stephen Nielson <stephen@nielson.org>
8 * @copyright Copyright (c) 2021 Stephen Nielson <stephen@nielson.org>
9 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
12 namespace OpenEMR\RestControllers\SMART;
14 use League\OAuth2\Server\Exception\OAuthServerException;
15 use League\OAuth2\Server\RedirectUriValidators\RedirectUriValidator;
16 use OpenEMR\Common\Http\Psr17Factory;
17 use OpenEMR\Common\Acl\AccessDeniedException;
18 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ClientRepository;
19 use OpenEMR\Common\Csrf\CsrfUtils;
20 use OpenEMR\Common\Session\SessionUtil;
21 use OpenEMR\FHIR\SMART\SmartLaunchController;
22 use OpenEMR\Services\PatientService;
23 use Psr\Log\LoggerInterface;
24 use RuntimeException;
26 class SMARTAuthorizationController
28 /**
29 * @var LoggerInterface
31 private $logger;
33 /**
34 * The base URL of the oauth2 url
35 * @var string
37 private $authBaseFullURL;
39 /**
40 * The oauth2 endpoint url to send to once smart authorization is complete.
41 * @var string
43 private $smartFinalRedirectURL;
45 /**
46 * The directory that the oauth template files can be included from
47 * @var string
49 private $oauthTemplateDir;
51 const PATIENT_SELECT_PATH = "/smart/patient-select";
53 const PATIENT_SELECT_CONFIRM_ENDPOINT = "/smart/patient-select-confirm";
55 const PATIENT_SEARCH_ENDPOINT = "/smart/patient-search";
57 /**
58 * Maximum number of patients that can be displayed in a search result.
60 const PATIENT_SEARCH_MAX_RESULTS = 100;
63 /**
64 * SMARTAuthorizationController constructor.
65 * @param $authBaseFullURL
66 * @param $smartFinalRedirectURL The URL that should be redirected to once all SMART authorizations are complete.
68 public function __construct(LoggerInterface $logger, $authBaseFullURL, $smartFinalRedirectURL, $oauthTemplateDir)
70 $this->logger = $logger;
71 $this->authBaseFullURL = $authBaseFullURL;
72 $this->smartFinalRedirectURL = $smartFinalRedirectURL;
73 $this->oauthTemplateDir = $oauthTemplateDir;
76 /**
77 * Checks to make sure that the passed in end point points to a valid SMART oauth2 endpoint
78 * @param $end_point string the route url
79 * @return bool true if the route should be handled by this controller, false otherwise
81 public function isValidRoute($end_point)
83 if (false !== stripos($end_point, self::PATIENT_SELECT_PATH)) {
84 return true;
86 if (false !== stripos($end_point, self::PATIENT_SELECT_CONFIRM_ENDPOINT)) {
87 return true;
89 if (false !== stripos($end_point, self::PATIENT_SEARCH_ENDPOINT)) {
90 return true;
92 return false;
95 /**
96 * Handles the route endpoint and terminates the process upon completion.
97 * @param $end_point
99 public function dispatchRoute($end_point)
102 // order here matters
103 if (false !== stripos($end_point, self::PATIENT_SELECT_CONFIRM_ENDPOINT)) {
104 // session is maintained
105 $this->patientSelectConfirm();
106 exit;
107 } else if (false !== stripos($end_point, self::PATIENT_SELECT_PATH)) {
108 // session is maintained
109 $this->patientSelect();
110 exit;
111 } else if (false !== stripos($end_point, self::PATIENT_SEARCH_ENDPOINT)) {
112 // session is maintained
113 $this->patientSearch();
114 exit;
115 } else {
116 $this->logger->error("SMARTAuthorizationController->dispatchRoute() called with invalid route. verify isValidRoute configured properly", ['end_point' => $end_point]);
117 http_response_code(404);
122 * Does the current request and session data require an oauth2 flow to be interrupted and go through the smart
123 * endpoints.
124 * @return bool
126 public function needSMARTAuthorization()
128 if (empty($_SESSION['puuid']) && strpos($_SESSION['scopes'], SmartLaunchController::CLIENT_APP_STANDALONE_LAUNCH_SCOPE) !== false) {
129 $this->logger->debug("AuthorizationController->userLogin() SMART app request for patient context ", ['scopes' => $_SESSION['scopes'], 'puuid' => $_SESSION['puuid'] ?? null]);
130 return true;
132 return false;
136 * Returns the first SMART oauth2 authorization path to start the SMART flow.
137 * @return string
139 public function getSmartAuthorizationPath()
141 // we can extend this to be a bunch of things based on any additional authorization contexts we need
142 // to support things like encounter selection, etc, but for now we only support patient selector launch
143 return self::PATIENT_SELECT_PATH;
147 * Receives the response of the patient selected, sets up the session and redirects back to the oauth2 regular flow
149 public function patientSelectConfirm()
151 $user_uuid = $_SESSION['user_id'];
152 if (!isset($user_uuid)) {
153 $this->logger->error("SMARTAuthorizationController->patientSelect() Unauthorized call, user has not authenticated");
154 http_response_code(401);
155 die(xlt('Invalid Request'));
158 if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token"], 'oauth2')) {
159 $this->logger->error("SMARTAuthorizationController->patientSelect() Invalid CSRF token");
160 CsrfUtils::csrfNotVerified(true, true, true);
161 exit();
164 // set our patient information up in our pid so we can handle our code property...
165 try {
166 $patient_id = $_POST['patient_id']; // this patient_id is actually a uuid.. wierd
167 $searchController = new PatientContextSearchController(new PatientService(), $this->logger);
168 // throws access denied if user doesn't have access
169 $foundPatient = $searchController->getPatientForUser($patient_id, $user_uuid);
170 // put PID in session
171 $_SESSION['puuid'] = $patient_id;
173 // now redirect to our scope-authorize
174 $redirect = $this->smartFinalRedirectURL;
175 header("Location: $redirect");
176 } catch (AccessDeniedException $error) {
177 // or should we present some kind of error display form...
178 $this->logger->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage(), 'userId' => $user_uuid]);
179 // make sure to grab the redirect uri before the session is destroyed
180 $redirectUri = $this->getClientRedirectURI();
181 SessionUtil::oauthSessionCookieDestroy();
182 $error = OAuthServerException::accessDenied("No access to patient data for this user", $redirectUri, $error);
183 $response = (new Psr17Factory())->createResponse();
184 $this->emitResponse($error->generateHttpResponse($response));
185 } catch (\Exception $error) {
186 // error occurred, no patients found just display the screen with an error message
187 $this->logger->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage()]);
188 $errorMessage = "There was a server error in loading patients. Contact your system administrator for assistance";
189 $url = $this->authBaseFullURL . self::PATIENT_SELECT_PATH . "?error=" . urlencode($errorMessage);
190 header("Location: " . $url);
192 exit;
196 * Displays the patient list and let's user's search and choose a patient. If the user doesn't have access to patient
197 * demographics we die on the security piece.
198 * @return false|string
200 public function patientSelect()
202 $user_uuid = $_SESSION['user_id'];
203 if (empty($user_uuid)) {
204 $this->logger->error("SMARTAuthorizationController->patientSelect() Unauthorized call, user has not authenticated");
205 http_response_code(401);
206 die(xlt('Invalid Request'));
209 $hasMore = false;
210 $patients = [];
211 $oauthLogin = true;
212 // handle our patient selected piece, populate the session and now present the authorize piece
213 $redirect = $this->authBaseFullURL . self::PATIENT_SELECT_CONFIRM_ENDPOINT;
214 $searchAction = $this->authBaseFullURL . self::PATIENT_SELECT_PATH;
215 $errorMessage = $_GET['error'] ?? '';
217 try {
218 // we've got a user by their UUID... we need to grab the db user id
219 $searchParams = $_GET['search'] ?? [];
221 // grab our list of patients to select from.
222 $searchController = new PatientContextSearchController(new PatientService(), $this->logger);
223 $patients = $searchController->searchPatients($searchParams, $user_uuid);
224 $hasMore = count($patients) > self::PATIENT_SEARCH_MAX_RESULTS;
225 $patients = $hasMore ? array_slice($patients, 0, self::PATIENT_SEARCH_MAX_RESULTS) : $patients;
227 require_once($this->oauthTemplateDir . "smart/patient-select.php");
228 } catch (AccessDeniedException $error) {
229 // make sure to grab the redirect uri before the session is destroyed
230 $redirectUri = $this->getClientRedirectURI();
231 $this->logger->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage(), 'userId' => $user_uuid]);
232 SessionUtil::oauthSessionCookieDestroy();
233 $error = OAuthServerException::accessDenied("No access to patient data for this user", $redirectUri, $error);
234 $response = (new Psr17Factory())->createResponse();
235 $this->emitResponse($error->generateHttpResponse($response));
236 } catch (\Exception $error) {
237 // error occurred, no patients found just display the screen with an error message
238 $error_message = "There was a server error in loading patients. Contact your system administrator for assistance";
239 $this->logger->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage()]);
240 require_once($this->oauthTemplateDir . "smart/patient-select.php");
244 // TODO: adunsulag should this be moved into a trait so we can share the functionality with AuthorizationController?
245 public function emitResponse($response): void
247 if (headers_sent()) {
248 throw new RuntimeException('Headers already sent.');
250 $statusLine = sprintf(
251 'HTTP/%s %s %s',
252 $response->getProtocolVersion(),
253 $response->getStatusCode(),
254 $response->getReasonPhrase()
256 header($statusLine, true);
257 foreach ($response->getHeaders() as $name => $values) {
258 $responseHeader = sprintf('%s: %s', $name, $response->getHeaderLine($name));
259 header($responseHeader, false);
261 // send it along.
262 echo $response->getBody();
266 * Returns the client redirect URI to send error responses back.
267 * @return string|null
269 private function getClientRedirectURI()
271 $client_id = $_SESSION['client_id'];
272 $repo = new ClientRepository();
273 $client = $repo->getClientEntity($client_id);
274 $uriList = $client->getRedirectUri();
275 $uri = $uriList;
276 if (is_array($uriList) && !empty($uriList)) {
277 $validator = new RedirectUriValidator($uri);
278 $uri = $uriList[0]; // we grab the first one if we don't have one in the session already
280 // this is probably overly paranoid but we want to safeguard against any session tampering and use the same logic
281 // to validate the redirect_uri as we do elsewhere in the system
282 // if we have multiple redirect_uris and we have the redirect uri in our session
283 if (!empty($_SESSION['redirect_uri'])) {
284 if ($validator->validateRedirectUri($_SESSION['redirect_uri'])) {
285 $uri = $_SESSION['redirect_uri'];
289 return $uri;