Fix invalid CSRF exception class. (#7833)
[openemr.git] / _rest_config.php
blobd931bb969e9a9a526e0767d2e3e10f87afd26ee2
1 <?php
3 /**
4 * Useful globals class for Rest
6 * @package OpenEMR
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) 2018-2020 Jerry Padgett <sjpadgett@gmail.com>
11 * @copyright Copyright (c) 2019 Brady Miller <brady.g.miller@gmail.com>
12 * @copyright Copyright (c) 2024 Care Management Solutions, Inc. <stephen.waite@cmsvt.com>
13 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
16 require_once __DIR__ . '/vendor/autoload.php';
18 use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
19 use League\OAuth2\Server\Exception\OAuthServerException;
20 use League\OAuth2\Server\ResourceServer;
21 use Nyholm\Psr7\Factory\Psr17Factory;
22 use Nyholm\Psr7Server\ServerRequestCreator;
23 use OpenEMR\Common\Acl\AclMain;
24 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AccessTokenRepository;
25 use OpenEMR\Common\Http\HttpRestRequest;
26 use OpenEMR\Common\Logging\EventAuditLogger;
27 use OpenEMR\Common\Logging\SystemLogger;
28 use OpenEMR\Common\Session\SessionUtil;
29 use OpenEMR\FHIR\Config\ServerConfig;
30 use OpenEMR\Services\TrustedUserService;
31 use Psr\Http\Message\ResponseInterface;
32 use Psr\Http\Message\ServerRequestInterface;
35 // also a handy place to add utility methods
36 // TODO before v6 release: refactor http_response_code(); for psr responses.
38 class RestConfig
40 /** @var routemap is an array of patterns and routes */
41 public static $ROUTE_MAP;
43 /** @var fhir routemap is an of patterns and routes */
44 public static $FHIR_ROUTE_MAP;
46 /** @var portal routemap is an of patterns and routes */
47 public static $PORTAL_ROUTE_MAP;
49 /** @var app root is the root directory of the application */
50 public static $APP_ROOT;
52 /** @var root url of the application */
53 public static $ROOT_URL;
54 // you can guess what the rest are!
55 public static $VENDOR_DIR;
56 public static $SITE;
57 public static $apisBaseFullUrl;
58 public static $webserver_root;
59 public static $web_root;
60 public static $server_document_root;
61 public static $publicKey;
62 private static $INSTANCE;
63 private static $IS_INITIALIZED = false;
64 /** @var set to true if local api call */
65 private static $localCall = false;
66 /** @var set to true if not rest call */
67 private static $notRestCall = false;
69 /** prevents external construction */
70 private function __construct()
74 /**
75 * Returns an instance of the RestConfig singleton
77 * @return RestConfig
79 public static function GetInstance(): \RestConfig
81 if (!self::$IS_INITIALIZED) {
82 self::Init();
85 if (!self::$INSTANCE instanceof self) {
86 self::$INSTANCE = new self();
89 return self::$INSTANCE;
92 /**
93 * Initialize the RestConfig object
95 public static function Init(): void
97 if (self::$IS_INITIALIZED) {
98 return;
100 // The busy stuff.
101 self::setPaths();
102 self::setSiteFromEndpoint();
103 $serverConfig = new ServerConfig();
104 $serverConfig->setWebServerRoot(self::$webserver_root);
105 $serverConfig->setSiteId(self::$SITE);
106 self::$ROOT_URL = self::$web_root . "/apis";
107 self::$VENDOR_DIR = self::$webserver_root . "/vendor";
108 self::$publicKey = $serverConfig->getPublicRestKey();
109 self::$IS_INITIALIZED = true;
113 * Basic paths when GLOBALS are not yet available.
115 * @return void
117 private static function SetPaths(): void
119 $isWindows = (stripos(PHP_OS_FAMILY, 'WIN') === 0);
120 // careful if moving this class to modify where's root.
121 self::$webserver_root = __DIR__;
122 if ($isWindows) {
123 //convert windows path separators
124 self::$webserver_root = str_replace("\\", "/", self::$webserver_root);
126 // Collect the apache server document root (and convert to windows slashes, if needed)
127 self::$server_document_root = realpath($_SERVER['DOCUMENT_ROOT']);
128 if ($isWindows) {
129 //convert windows path separators
130 self::$server_document_root = str_replace("\\", "/", self::$server_document_root);
132 self::$web_root = substr(self::$webserver_root, strspn(self::$webserver_root ^ self::$server_document_root, "\0"));
133 // Ensure web_root starts with a path separator
134 if (preg_match("/^[^\/]/", self::$web_root)) {
135 self::$web_root = "/" . self::$web_root;
137 // Will need these occasionally. sql init comes to mind!
138 $GLOBALS['rootdir'] = self::$web_root . "/interface";
139 // Absolute path to the source code include and headers file directory (Full path):
140 $GLOBALS['srcdir'] = self::$webserver_root . "/library";
141 // Absolute path to the location of documentroot directory for use with include statements:
142 $GLOBALS['fileroot'] = self::$webserver_root;
143 // Absolute path to the location of interface directory for use with include statements:
144 $GLOBALS['incdir'] = self::$webserver_root . "/interface";
145 // Absolute path to the location of documentroot directory for use with include statements:
146 $GLOBALS['webroot'] = self::$web_root;
147 // Static assets directory, relative to the webserver root.
148 $GLOBALS['assets_static_relative'] = self::$web_root . "/public/assets";
149 // Relative themes directory, relative to the webserver root.
150 $GLOBALS['themes_static_relative'] = self::$web_root . "/public/themes";
151 // Relative images directory, relative to the webserver root.
152 $GLOBALS['images_static_relative'] = self::$web_root . "/public/images";
153 // Static images directory, absolute to the webserver root.
154 $GLOBALS['images_static_absolute'] = self::$webserver_root . "/public/images";
155 //Composer vendor directory, absolute to the webserver root.
156 $GLOBALS['vendor_dir'] = self::$webserver_root . "/vendor";
159 private static function setSiteFromEndpoint(): void
161 // Get site from endpoint if available. Unsure about this though!
162 // Will fail during sql init otherwise.
163 $endPointParts = self::parseEndPoint(self::getRequestEndPoint());
164 if (count($endPointParts) > 1) {
165 $site_id = $endPointParts[0] ?? '';
166 if ($site_id) {
167 self::$SITE = $site_id;
172 public static function parseEndPoint($resource): array
174 if ($resource[0] === '/') {
175 $resource = substr($resource, 1);
177 return explode('/', $resource);
180 public static function getRequestEndPoint(): string
182 $resource = null;
183 if (!empty($_REQUEST['_REWRITE_COMMAND'])) {
184 $resource = "/" . $_REQUEST['_REWRITE_COMMAND'];
185 } elseif (!empty($_SERVER['REDIRECT_QUERY_STRING'])) {
186 $resource = str_replace('_REWRITE_COMMAND=', '/', $_SERVER['REDIRECT_QUERY_STRING']);
187 } else {
188 if (!empty($_SERVER['REQUEST_URI'])) {
189 if (strpos($_SERVER['REQUEST_URI'], '?') > 0) {
190 $resource = strstr($_SERVER['REQUEST_URI'], '?', true);
191 } else {
192 $resource = str_replace(self::$ROOT_URL ?? '', '', $_SERVER['REQUEST_URI']);
197 return $resource;
200 public static function verifyAccessToken()
202 $logger = new SystemLogger();
203 $response = self::createServerResponse();
204 $request = self::createServerRequest();
205 try {
206 // if we there's a key problem need to catch the exception
207 $server = new ResourceServer(
208 new AccessTokenRepository(),
209 self::$publicKey
211 $raw = $server->validateAuthenticatedRequest($request);
212 } catch (OAuthServerException $exception) {
213 $logger->error("RestConfig->verifyAccessToken() OAuthServerException", ["message" => $exception->getMessage()]);
214 return $exception->generateHttpResponse($response);
215 } catch (\Exception $exception) {
216 if ($exception instanceof LogicException) {
217 $logger->error(
218 "RestConfig->verifyAccessToken() LogicException, likely oauth2 public key is missing, corrupted, or misconfigured",
219 ["message" => $exception->getMessage()]
221 return (new OAuthServerException("Invalid access token", 0, 'invalid_token', 401))
222 ->generateHttpResponse($response);
223 } else {
224 $logger->error("RestConfig->verifyAccessToken() Exception", ["message" => $exception->getMessage()]);
225 // do NOT reveal what happened at the server level if we have a server exception
226 return (new OAuthServerException("Server Error", 0, 'unknown_error', 500))
227 ->generateHttpResponse($response);
231 return $raw;
235 * Returns true if the access token for the given token id is valid. Otherwise returns the access denied response.
236 * @param $tokenId
237 * @return bool|ResponseInterface
239 public static function validateAccessTokenRevoked($tokenId)
241 $repository = new AccessTokenRepository();
242 if ($repository->isAccessTokenRevokedInDatabase($tokenId)) {
243 $response = self::createServerResponse();
244 return OAuthServerException::accessDenied('Access token has been revoked')->generateHttpResponse($response);
246 return true;
249 public static function isTrustedUser($clientId, $userId)
251 $trustedUserService = new TrustedUserService();
252 $response = self::createServerResponse();
253 try {
254 if (!$trustedUserService->isTrustedUser($clientId, $userId)) {
255 (new SystemLogger())->debug(
256 "invalid Trusted User. Refresh Token revoked or logged out",
257 ['clientId' => $clientId, 'userId' => $userId]
259 throw new OAuthServerException('Refresh Token revoked or logged out', 0, 'invalid _request', 400);
261 return $trustedUserService->getTrustedUser($clientId, $userId);
262 } catch (OAuthServerException $exception) {
263 return $exception->generateHttpResponse($response);
264 } catch (\Exception $exception) {
265 return (new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500))
266 ->generateHttpResponse($response);
270 public static function createServerResponse(): ResponseInterface
272 $psr17Factory = new Psr17Factory();
274 return $psr17Factory->createResponse();
277 public static function createServerRequest(): ServerRequestInterface
279 $psr17Factory = new Psr17Factory();
280 $creator = new ServerRequestCreator(
281 $psr17Factory, // ServerRequestFactory
282 $psr17Factory, // UriFactory
283 $psr17Factory, // UploadedFileFactory
284 $psr17Factory // StreamFactory
287 return $creator->fromGlobals();
290 public static function destroySession(): void
292 SessionUtil::apiSessionCookieDestroy();
295 public static function getPostData($data)
297 if (count($_POST)) {
298 return $_POST;
301 if ($post_data = file_get_contents('php://input')) {
302 if ($post_json = json_decode($post_data, true)) {
303 return $post_json;
305 parse_str($post_data, $post_variables);
306 if (count($post_variables)) {
307 return $post_variables;
311 return null;
314 public static function authorization_check($section, $value, $user = '', $aclPermission = ''): void
316 $result = AclMain::aclCheckCore($section, $value, $user, $aclPermission);
317 if (!$result) {
318 if (!self::$notRestCall) {
319 http_response_code(401);
321 exit();
325 // Main function to check scope
326 // Use cases:
327 // Only sending $scopeType would be for something like 'openid'
328 // For using all 3 parameters would be for something like 'user/Organization.write'
329 // $scopeType = 'user', $resource = 'Organization', $permission = 'write'
330 public static function scope_check($scopeType, $resource = null, $permission = null): void
332 if (!empty($GLOBALS['oauth_scopes'])) {
333 // Need to ensure has scope
334 if (empty($resource)) {
335 // Simply check to see if $scopeType is an allowed scope
336 $scope = $scopeType;
337 } else {
338 // Resource scope check
339 $scope = $scopeType . '/' . $resource . '.' . $permission;
341 if (!in_array($scope, $GLOBALS['oauth_scopes'])) {
342 (new SystemLogger())->debug("RestConfig::scope_check scope not in access token", ['scope' => $scope, 'scopes_granted' => $GLOBALS['oauth_scopes']]);
343 http_response_code(401);
344 exit;
346 } else {
347 (new SystemLogger())->error("RestConfig::scope_check global scope array is empty");
348 http_response_code(401);
349 exit;
353 public static function setLocalCall(): void
355 self::$localCall = true;
358 public static function setNotRestCall(): void
360 self::$notRestCall = true;
363 public static function is_fhir_request($resource): bool
365 return stripos(strtolower($resource), "/fhir/") !== false;
368 public static function is_portal_request($resource): bool
370 return stripos(strtolower($resource), "/portal/") !== false;
373 public static function is_api_request($resource): bool
375 return stripos(strtolower($resource), "/api/") !== false;
378 public static function skipApiAuth($resource): bool
380 if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
381 // we don't authenticate OPTIONS requests
382 return true;
385 // ensure 1) sane site and 2) ensure the site exists on filesystem before even considering for skip api auth
386 if (empty(self::$SITE) || preg_match('/[^A-Za-z0-9\\-.]/', self::$SITE) || !file_exists(__DIR__ . '/sites/' . self::$SITE)) {
387 error_log("OpenEMR Error - api site error, so forced exit");
388 http_response_code(400);
389 exit();
391 // let the capability statement for FHIR or the SMART-on-FHIR through
392 $resource = str_replace('/' . self::$SITE, '', $resource);
393 if (
394 // TODO: @adunsulag we need to centralize our auth skipping logic... as we have this duplicated in HttpRestRouteHandler
395 // however, at the point of this method we don't have the resource identified and haven't gone through our parsing
396 // routine to handle that logic...
397 $resource === ("/fhir/metadata") ||
398 $resource === ("/fhir/.well-known/smart-configuration") ||
399 // skip list and single instance routes
400 0 === strpos("/fhir/OperationDefinition", $resource)
402 return true;
403 } else {
404 return false;
408 public static function apiLog($response = '', $requestBody = ''): void
410 $logResponse = $response;
412 // only log when using standard api calls (skip when using local api calls from within OpenEMR)
413 // and when api log option is set
414 if (!$GLOBALS['is_local_api'] && !self::$notRestCall && $GLOBALS['api_log_option']) {
415 if ($GLOBALS['api_log_option'] == 1) {
416 // Do not log the response and requestBody
417 $logResponse = '';
418 $requestBody = '';
420 if ($response instanceof ResponseInterface) {
421 if (self::shouldLogResponse($response)) {
422 $body = $response->getBody();
423 $logResponse = $body->getContents();
424 $body->rewind();
425 } else {
426 $logResponse = 'Content not application/json - Skip binary data';
428 } else {
429 $logResponse = (!empty($logResponse)) ? json_encode($response) : '';
432 // convert pertinent elements to json
433 $requestBody = (!empty($requestBody)) ? json_encode($requestBody) : '';
435 // prepare values and call the log function
436 $event = 'api';
437 $category = 'api';
438 $method = $_SERVER['REQUEST_METHOD'];
439 $url = $_SERVER['REQUEST_URI'];
440 $patientId = (int)($_SESSION['pid'] ?? 0);
441 $userId = (int)($_SESSION['authUserID'] ?? 0);
442 $api = [
443 'user_id' => $userId,
444 'patient_id' => $patientId,
445 'method' => $method,
446 'request' => $GLOBALS['resource'],
447 'request_url' => $url,
448 'request_body' => $requestBody,
449 'response' => $logResponse
451 if ($patientId === 0) {
452 $patientId = null; //entries in log table are blank for no patient_id, whereas in api_log are 0, which is why above $api value uses 0 when empty
454 EventAuditLogger::instance()->recordLogItem(1, $event, ($_SESSION['authUser'] ?? ''), ($_SESSION['authProvider'] ?? ''), 'api log', $patientId, $category, 'open-emr', null, null, '', $api);
458 public static function emitResponse($response, $build = false): void
460 if (headers_sent()) {
461 throw new RuntimeException('Headers already sent.');
463 $statusLine = sprintf(
464 'HTTP/%s %s %s',
465 $response->getProtocolVersion(),
466 $response->getStatusCode(),
467 $response->getReasonPhrase()
469 header($statusLine, true);
470 foreach ($response->getHeaders() as $name => $values) {
471 $responseHeader = sprintf('%s: %s', $name, $response->getHeaderLine($name));
472 header($responseHeader, false);
474 echo $response->getBody();
478 * If the FHIR System scopes enabled or not. True if its turned on, false otherwise.
479 * @return bool
481 public static function areSystemScopesEnabled()
483 return $GLOBALS['rest_system_scopes_api'] === '1';
486 public function authenticateUserToken($tokenId, $clientId, $userId): bool
488 $ip = collectIpAddresses();
490 // check for token
491 $accessTokenRepo = new AccessTokenRepository();
492 $authTokenExpiration = $accessTokenRepo->getTokenExpiration($tokenId, $clientId, $userId);
494 if (empty($authTokenExpiration)) {
495 EventAuditLogger::instance()->newEvent('api', '', '', 0, "API failure: " . $ip['ip_string'] . ". Token not found for client[" . $clientId . "] and user " . $userId . ".");
496 return false;
499 // Ensure token not expired (note an expired token should have already been caught by oauth2, however will also check here)
500 $currentDateTime = date("Y-m-d H:i:s");
501 $expiryDateTime = date("Y-m-d H:i:s", strtotime($authTokenExpiration));
502 if ($expiryDateTime <= $currentDateTime) {
503 EventAuditLogger::instance()->newEvent('api', '', '', 0, "API failure: " . $ip['ip_string'] . ". Token expired for client[" . $clientId . "] and user " . $userId . ".");
504 return false;
507 // Token authentication passed
508 EventAuditLogger::instance()->newEvent('api', '', '', 1, "API success: " . $ip['ip_string'] . ". Token successfully used for client[" . $clientId . "] and user " . $userId . ".");
509 return true;
513 * Checks if we should log the response interface (we don't want to log binary documents or anything like that)
514 * We only log requests with a content-type of any form of json fhir+application/json or application/json
515 * @param ResponseInterface $response
516 * @return bool If the request should be logged, false otherwise
518 private static function shouldLogResponse(ResponseInterface $response)
520 if ($response->hasHeader("Content-Type")) {
521 $contentType = $response->getHeaderLine("Content-Type");
522 if ($contentType === 'application/json') {
523 return true;
527 return false;
531 * Grabs all of the context information for the request's access token and populates any context variables the
532 * request needs (such as patient binding information). Returns the populated request
533 * @param HttpRestRequest $restRequest
534 * @return HttpRestRequest
536 public function populateTokenContextForRequest(HttpRestRequest $restRequest)
539 $context = $this->getTokenContextForRequest($restRequest);
540 // note that the context here is the SMART value that is returned in the response for an AccessToken in this
541 // case it is the patient value which is the logical id (ie uuid) of the patient.
542 $patientUuid = $context['patient'] ?? null;
543 if (!empty($patientUuid)) {
544 // we only set the bound patient access if the underlying user can still access the patient
545 if ($this->checkUserHasAccessToPatient($restRequest->getRequestUserId(), $patientUuid)) {
546 $restRequest->setPatientUuidString($patientUuid);
547 } else {
548 (new SystemLogger())->error("OpenEMR Error: api had patient launch scope but user did not have access to patient uuid."
549 . " Resources restricted with patient scopes will not return results");
551 } else {
552 (new SystemLogger())->error("OpenEMR Error: api had patient launch scope but no patient was set in the "
553 . " session cache. Resources restricted with patient scopes will not return results");
555 return $restRequest;
558 public function getTokenContextForRequest(HttpRestRequest $restRequest)
560 $accessTokenRepo = new AccessTokenRepository();
561 // note this is pretty confusing as getAccessTokenId comes from the oauth_access_id which is the token NOT
562 // the database id even though this is called accessTokenId....
563 $token = $accessTokenRepo->getTokenByToken($restRequest->getAccessTokenId());
564 $context = $token['context'] ?? "{}"; // if there is no populated context we just return an empty return
565 try {
566 return json_decode($context, true);
567 } catch (\Exception $exception) {
568 (new SystemLogger())->error("OpenEMR Error: failed to decode token context json", ['exception' => $exception->getMessage()
569 , 'tokenId' => $restRequest->getAccessTokenId()]);
571 return [];
576 * Checks whether a user has access to the patient. Returns true if the user can access the given patient, false otherwise
577 * @param $userId The id from the users table that represents the user
578 * @param $patientUuid The uuid from the patient_data table that represents the patient
579 * @return bool True if has access, false otherwise
581 private function checkUserHasAccessToPatient($userId, $patientUuid)
583 // TODO: the session should never be populated with the pid from the access token unless the user had access to
584 // it. However, if we wanted an additional check or if we wanted to fire off any kind of event that does
585 // patient filtering by provider / clinic we would handle that here.
586 return true;
590 /** prevents external cloning */
591 private function __clone()
596 // Include our routes and init routes global
598 require_once(__DIR__ . "/_rest_routes.inc.php");