Skip to content

Commit

Permalink
Smart standalone patientrole migrate (openemr#4177)
Browse files Browse the repository at this point in the history
* OAUTH2 Standalone Provider Patient Selector

Implemented a patient selector for oauth2 standalone launch.

Also made it so we could have some primitive unit tests on the Gacl
class with resetting the ACL cache.

Wrote a smart auth class for any context / ui needs that are required
as part of the oauth2 flow.

Eventually if ONC requires the context-launch-encounter piece we can
easily add to this class to facilitate additional selector flows.

Implemented ACL checks and a search class to be used as part of the
oauth2 flow for patients.

* Patient Endpoint, SMART Standalone launch changes.

Changed fhirUser to be a Person resource instead of Practitioner as not
all users are clinician staff.  Implemented the Person resource and
their Read only pieces.

Added some logging so we could debug session problems.

Fixed prior commit that was removing the Scope authorization.

* Register App standalone scope selection and ux/ui.

Made the UI look more consistent with the login page and so it follows
other styling of the login pages.

Made it so the app registration can choose what scopes they are wanting
to have with their app.

* migrate patientrole to main fhir api route

* Centralized scope checks for rest api. Perm update

Updated the scope permissions so that only the ones we currently support
for patient/<resource>.* is supported.

Centralized the scope checks in the HttpRestRouteHandler to remove
redundant code from the routes and to make sure all possible routes are
checked against the access code.  This also prevents developers who
extend the api from accidently forgetting to check against the
AccessToken.

For now I've only enabled it on the FHIR api, but if it tests well we
can open it up to the rest of the APIs.

* Fix patient routes to use patient uuid.

* Better error log / debug logs on oauth2 client validation

* Fix fhirUser claim for patient context login.

* Standalone SMART response handler

Fixed some scope permission checks.  Refactored the route parsing
algorithm into its own class that can be unit tested against.  The
parsing logic could then be leveraged in the scope auth check which made
matching against the REST FHIR resource a lot easier.

Added the additional SMART capabilities we now support with patient
standalone and launch standalone.

Fixed the refresh token issues.  We don't send patient context
parameters as part of the refresh_grant oauth2 flow so we only send the
parameters now in the authorization_grant flow inside our SMARTResponse
object.  There may be a better way to make this work, but for now this
is functioning.

* Offline access support, client-public support.

Got the client-public support working by disabling the client_challenge
(PKCE support) on public profile oauth2 clients.  This is required for
ONC inferno testing w/ public clients. V2 of SMART is indicating this
will be required back, so hopefully ONC issues an update at some point.

Also made it so the offline_access works by restricting the
refresh_token issued when offline_access isn't present.

* Fix unit tests and style problems.

* Fix patient context missing for standalone.

Fixing the refresh token issues broke the patient context missing due to
the way the ResponseType object was cloned for the League
AuthorizationController.

* Inferno Limited Scope Authorization

Implemented a logged in user being able to restrict what scopes they are
giving access to for the requesting application.  This allows OpenEMR
users to prevent an application from giving offline_access (credentials
past the 1 hour access token), and other resources to an application
that they don't want to provide.

* Fix style errors.

* Fix translate, escape, and comments.
  • Loading branch information
adunsulag committed Jan 28, 2021
1 parent 0c97c6e commit 23da1b5
Show file tree
Hide file tree
Showing 45 changed files with 2,487 additions and 431 deletions.
16 changes: 7 additions & 9 deletions API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ Finally, APIs which are integrated with the new `handleProcessingResult` method
- [FHIR Location](FHIR_README.md#location-resource)
- [FHIR CareTeam](FHIR_README.md#careTeam-resource)
- [FHIR Provenance](FHIR_README.md#Provenance-resources)
- [Patient Portal FHIR API Endpoints](FHIR_README.md#patient-portal-fhir-endpoints)
- [Patient Portal FHIR Patient](FHIR_README.md#patient-portal-patient-resource)
- [Dev notes](API_README.md#dev-notes)

### Prerequisite
Expand Down Expand Up @@ -160,7 +158,10 @@ This is a listing of scopes:
- `user/surgery.write`
- `user/vital.read`
- `user/vital.write`
- `api:fhir` (user fhir which are the /fhir/ endpoints)
- `api:fhir` (fhir which are the /fhir/ endpoints)
- `patient/AllergyIntolerance.read`
- `patient/Encounter.read`
- `patient/Patient.read`
- `user/AllergyIntolerance.read`
- `user/CareTeam.read`
- `user/Condition.read`
Expand All @@ -182,9 +183,6 @@ This is a listing of scopes:
- `api:port` (patient api which are the /portal/ endpoints) (EXPERIMENTAL)
- `patient/encounter.read`
- `patient/patient.read`
- `api:pofh` (patient fhir which are the /portalfhir/ endpoints) (EXPERIMENTAL)
- `patient/Encounter.read`
- `patient/Patient.read`

#### Registration

Expand All @@ -202,7 +200,7 @@ curl -X POST -k -H 'Content-Type: application/json' -i https://localhost:9300/oa
"client_name": "A Private App",
"token_endpoint_auth_method": "client_secret_post",
"contacts": ["[email protected]", "[email protected]"],
"scope": "openid api:oemr api:fhir api:port api:pofh user/allergy.read user/allergy.write user/appointment.read user/appointment.write user/dental_issue.read user/dental_issue.write user/document.read user/document.write user/drug.read user/encounter.read user/encounter.write user/facility.read user/facility.write user/immunization.read user/insurance.read user/insurance.write user/insurance_company.read user/insurance_company.write user/insurance_type.read user/list.read user/medical_problem.read user/medical_problem.write user/medication.read user/medication.write user/message.write user/patient.read user/patient.write user/practitioner.read user/practitioner.write user/prescription.read user/procedure.read user/soap_note.read user/soap_note.write user/surgery.read user/surgery.write user/vital.read user/vital.write user/AllergyIntolerance.read user/CareTeam.read user/Condition.read user/Coverage.read user/Encounter.read user/Immunization.read user/Location.read user/Medication.read user/MedicationRequest.read user/Observation.read user/Organization.read user/Organization.write user/Patient.read user/Patient.write user/Practitioner.read user/Practitioner.write user/PractitionerRole.read user/Procedure.read patient/encounter.read patient/patient.read patient/Encounter.read patient/Patient.read"
"scope": "openid api:oemr api:fhir api:port user/allergy.read user/allergy.write user/appointment.read user/appointment.write user/dental_issue.read user/dental_issue.write user/document.read user/document.write user/drug.read user/encounter.read user/encounter.write user/facility.read user/facility.write user/immunization.read user/insurance.read user/insurance.write user/insurance_company.read user/insurance_company.write user/insurance_type.read user/list.read user/medical_problem.read user/medical_problem.write user/medication.read user/medication.write user/message.write user/patient.read user/patient.write user/practitioner.read user/practitioner.write user/prescription.read user/procedure.read user/soap_note.read user/soap_note.write user/surgery.read user/surgery.write user/vital.read user/vital.write user/AllergyIntolerance.read user/CareTeam.read user/Condition.read user/Coverage.read user/Encounter.read user/Immunization.read user/Location.read user/Medication.read user/MedicationRequest.read user/Observation.read user/Organization.read user/Organization.write user/Patient.read user/Patient.write user/Practitioner.read user/Practitioner.write user/PractitionerRole.read user/Procedure.read patient/encounter.read patient/patient.read patient/AllergyIntolerance.read patient/Encounter.read patient/Patient.read"
}'
```

Expand All @@ -220,7 +218,7 @@ Response:
"client_name": "A Private App",
"redirect_uris": ["https:\/\/client.example.org\/callback"],
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid api:oemr api:fhir api:port api:pofh user/allergy.read user/allergy.write user/appointment.read user/appointment.write user/dental_issue.read user/dental_issue.write user/document.read user/document.write user/drug.read user/encounter.read user/encounter.write user/facility.read user/facility.write user/immunization.read user/insurance.read user/insurance.write user/insurance_company.read user/insurance_company.write user/insurance_type.read user/list.read user/medical_problem.read user/medical_problem.write user/medication.read user/medication.write user/message.write user/patient.read user/patient.write user/practitioner.read user/practitioner.write user/prescription.read user/procedure.read user/soap_note.read user/soap_note.write user/surgery.read user/surgery.write user/vital.read user/vital.write user/AllergyIntolerance.read user/CareTeam.read user/Condition.read user/Coverage.read user/Encounter.read user/Immunization.read user/Location.read user/Medication.read user/MedicationRequest.read user/Observation.read user/Organization.read user/Organization.write user/Patient.read user/Patient.write user/Practitioner.read user/Practitioner.write user/PractitionerRole.read user/Procedure.read patient/encounter.read patient/patient.read patient/Encounter.read patient/Patient.read"
"scope": "openid api:oemr api:fhir api:port user/allergy.read user/allergy.write user/appointment.read user/appointment.write user/dental_issue.read user/dental_issue.write user/document.read user/document.write user/drug.read user/encounter.read user/encounter.write user/facility.read user/facility.write user/immunization.read user/insurance.read user/insurance.write user/insurance_company.read user/insurance_company.write user/insurance_type.read user/list.read user/medical_problem.read user/medical_problem.write user/medication.read user/medication.write user/message.write user/patient.read user/patient.write user/practitioner.read user/practitioner.write user/prescription.read user/procedure.read user/soap_note.read user/soap_note.write user/surgery.read user/surgery.write user/vital.read user/vital.write user/AllergyIntolerance.read user/CareTeam.read user/Condition.read user/Coverage.read user/Encounter.read user/Immunization.read user/Location.read user/Medication.read user/MedicationRequest.read user/Observation.read user/Organization.read user/Organization.write user/Patient.read user/Patient.write user/Practitioner.read user/Practitioner.write user/PractitionerRole.read user/Procedure.read patient/encounter.read patient/patient.read patient/AllergyIntolerance.read patient/Encounter.read patient/Patient.read"
}
```

Expand Down Expand Up @@ -288,7 +286,7 @@ curl -X POST -k -H 'Content-Type: application/x-www-form-urlencoded'
-i 'https://localhost:9300/oauth2/default/token'
--data 'grant_type=password
&client_id=LnjqojEEjFYe5j2Jp9m9UnmuxOnMg4VodEJj3yE8_OA
&scope=openid%20api%3Aport%20api%3Apofh%20patient%2Fencounter.read%20patient%2Fpatient.read%20patient%2FEncounter.read%20patient%2FPatient.read
&scope=openid%20api%3Aport%20api%3Afhir%20patient%2Fencounter.read%20patient%2Fpatient.read%20patient%2FAllergyIntolerance.read%20patient%2FEncounter.read%20patient%2FPatient.read
&user_role=patient
&username=Phil1
&password=phil
Expand Down
35 changes: 0 additions & 35 deletions FHIR_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ Database Result -> Service Component -> FHIR Service Component -> Parse OpenEMR
- [Location](FHIR_README.md#location-resource)
- [CareTeam](FHIR_README.md#careTeam-resource)
- [Provenance](FHIR_README.md#Provenance-resources)
- [Patient Portal FHIR API Endpoints](FHIR_README.md#patient-portal-fhir-endpoints)
- [Patient](FHIR_README.md#patient-portal-patient-resource)

### Prerequisite

Expand Down Expand Up @@ -605,36 +603,3 @@ Provenance resources are requested by including `_revinclude=Provenance:target`
```sh
curl -X GET 'http:https://localhost:8300/apis/default/fhir/AllergyIntolerance?_revinclude=Provenance:target'
```

## Patient Portal FHIR Endpoints

This is under development and is considered EXPERIMENTAL.

Enable the Patient Portal FHIR service (/portalfhir/ endpoints) in OpenEMR menu: Administration->Globals->Connectors->"Enable OpenEMR Patient Portal FHIR REST API (EXPERIMENTAL)"

OpenEMR patient portal fhir endpoints Use `http:https://localhost:8300/apis/default/portalfhir as base URI.`

Note that the `default` component can be changed to the name of the site when using OpenEMR's multisite feature.

_Example:_ `http:https://localhost:8300/apis/default/portalfhir/Patient` returns a resource of the patient.

The Bearer token is required for each OpenEMR FHIR request (except for the Capability Statement), and is conveyed using an Authorization header. Note that the Bearer token is the access_token that is obtained in the [Authorization](API_README.md#authorization) section.

Request:

```sh
curl -X GET 'http:https://localhost:8300/apis/default/portalfhir/Patient' \
-H 'Authorization: Bearer eyJ0b2tlbiI6IjAwNmZ4TWpsNWhsZmNPelZicXBEdEZVUlNPQUY5KzdzR1Jjejc4WGZyeGFjUjY2QlhaaEs4eThkU3cxbTd5VXFBeTVyeEZpck9mVzBQNWc5dUlidERLZ0trUElCME5wRDVtTVk5bE9WaE5DTHF5RnRnT0Q0OHVuaHRvbXZ6OTEyNmZGUmVPUllSYVJORGoyZTkzTDA5OWZSb0ZRVGViTUtWUFd4ZW5cL1piSzhIWFpJZUxsV3VNcUdjQXR5dmlLQXRXNDAiLCJzaXRlX2lkIjoiZGVmYXVsdCIsImFwaSI6Im9lbXIifQ=='
```

---

### Patient Portal Patient Resource

#### GET /portalfhir/Patient

Request:

```sh
curl -X GET 'http:https://localhost:8300/apis/default/portalfhir/Patient'
```
54 changes: 45 additions & 9 deletions _rest_config.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Nyholm\Psr7Server\ServerRequestCreator;
use OpenEMR\Common\Acl\AclMain;
use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AccessTokenRepository;
use OpenEMR\Common\Http\HttpRestRequest;
use OpenEMR\Common\Logging\EventAuditLogger;
use OpenEMR\Common\Logging\SystemLogger;
use OpenEMR\Common\Session\SessionUtil;
Expand All @@ -42,9 +43,6 @@ class RestConfig
/** @var portal routemap is an of patterns and routes */
public static $PORTAL_ROUTE_MAP;

/** @var portal fhir routemap is an of patterns and routes */
public static $PORTAL_FHIR_ROUTE_MAP;

/** @var app root is the root directory of the application */
public static $APP_ROOT;

Expand Down Expand Up @@ -195,7 +193,7 @@ public static function getRequestEndPoint(): string

public static function verifyAccessToken()
{
$logger = SystemLogger::instance();
$logger = new SystemLogger();
$response = self::createServerResponse();
$request = self::createServerRequest();
$server = new ResourceServer(
Expand Down Expand Up @@ -289,6 +287,29 @@ public static function authorization_check($section, $value): void
}
}

/**
* Checks to make sure the resource the user agent is requesting actually has the right scopes for the api request
* scopes are compared against the user's AccessToken scopes.
* @param HttpRestRequest $request
*/
public static function scope_check_request(HttpRestRequest $request)
{
$scopeType = $request->isPatientRequest() ? "patient" : "user";
$permission = $request->getRequestMethod() == "GET" ? "write" : "read";
$resource = $request->getResource();

if (!$request->isFhir()) {
$resource = strtolower($resource);
}
// Resource scope check
$scope = $scopeType . '/' . $resource . '.' . $permission;

if (!in_array($scope, $request->getAccessTokenScopes())) {
(new SystemLogger())->debug("RestConfig::scope_check_request scope not in access token", ['scope' => $scope]);
throw new \OpenEMR\Common\Acl\AccessDeniedException($scopeType, $resource, "scope not in access token");
}
}

// Main function to check scope
// Use cases:
// Only sending $scopeType would be for something like 'openid'
Expand All @@ -306,12 +327,32 @@ public static function scope_check($scopeType, $resource = null, $permission = n
$scope = $scopeType . '/' . $resource . '.' . $permission;
}
if (!in_array($scope, $GLOBALS['oauth_scopes'])) {
(new SystemLogger())->debug("RestConfig::scope_check scope not in access token", ['scope' => $scope]);
http_response_code(401);
exit;
}
}
}

// Function to check if patient needs to be binded
public static function is_patient_binded()
{
$logger = new SystemLogger();
if (isset($_SESSION['bind_patient_id']) || isset($GLOBALS['bind_patient_id'])) {
$logger->debug("is_patient_binded(): patient is binded to the api call");
// ensure the proper parameters are set for patient binding or die
if (empty($_SESSION['pid']) || empty($_SESSION['puuid']) || empty($_SESSION['puuid_string'])) {
$logger->error("OpenEMR Error: is_patient_binded call failed because critical patient session variables were not set");
http_response_code(401);
exit;
}
return true;
} else {
$logger->debug("is_patient_binded(): patient is not binded to the api call (ie. user or system call with access to all patients)");
return false;
}
}

public static function setLocalCall(): void
{
self::$localCall = true;
Expand All @@ -332,11 +373,6 @@ public static function is_portal_request($resource): bool
return stripos(strtolower($resource), "/portal/") !== false;
}

public static function is_portal_fhir_request($resource): bool
{
return stripos(strtolower($resource), "/portalfhir/") !== false;
}

public static function is_api_request($resource): bool
{
return stripos(strtolower($resource), "/api/") !== false;
Expand Down
Loading

0 comments on commit 23da1b5

Please sign in to comment.