1 |
efrain |
1 |
<?php
|
|
|
2 |
|
|
|
3 |
namespace Packback\Lti1p3;
|
|
|
4 |
|
|
|
5 |
use Exception;
|
|
|
6 |
use Firebase\JWT\JWT;
|
|
|
7 |
use GuzzleHttp\Client;
|
|
|
8 |
use GuzzleHttp\Exception\ClientException;
|
|
|
9 |
use Packback\Lti1p3\Interfaces\ICache;
|
|
|
10 |
use Packback\Lti1p3\Interfaces\ILtiRegistration;
|
|
|
11 |
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
|
|
|
12 |
use Packback\Lti1p3\Interfaces\IServiceRequest;
|
|
|
13 |
use Psr\Http\Message\ResponseInterface;
|
|
|
14 |
|
|
|
15 |
class LtiServiceConnector implements ILtiServiceConnector
|
|
|
16 |
{
|
|
|
17 |
public const NEXT_PAGE_REGEX = '/<([^>]*)>; ?rel="next"/i';
|
|
|
18 |
private bool $debuggingMode = false;
|
|
|
19 |
|
|
|
20 |
public function __construct(
|
|
|
21 |
private ICache $cache,
|
|
|
22 |
private Client $client
|
|
|
23 |
) {
|
|
|
24 |
}
|
|
|
25 |
|
|
|
26 |
public function setDebuggingMode(bool $enable): self
|
|
|
27 |
{
|
|
|
28 |
$this->debuggingMode = $enable;
|
|
|
29 |
|
|
|
30 |
return $this;
|
|
|
31 |
}
|
|
|
32 |
|
|
|
33 |
public function getAccessToken(ILtiRegistration $registration, array $scopes): string
|
|
|
34 |
{
|
|
|
35 |
// Get a unique cache key for the access token
|
|
|
36 |
$accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes);
|
|
|
37 |
// Get access token from cache if it exists
|
|
|
38 |
$accessToken = $this->cache->getAccessToken($accessTokenKey);
|
|
|
39 |
|
|
|
40 |
if (isset($accessToken)) {
|
|
|
41 |
return $accessToken;
|
|
|
42 |
}
|
|
|
43 |
|
|
|
44 |
// Build up JWT to exchange for an auth token
|
|
|
45 |
$clientId = $registration->getClientId();
|
|
|
46 |
$jwtClaim = [
|
|
|
47 |
'iss' => $clientId,
|
|
|
48 |
'sub' => $clientId,
|
|
|
49 |
'aud' => $registration->getAuthServer(),
|
|
|
50 |
'iat' => time() - 5,
|
|
|
51 |
'exp' => time() + 60,
|
|
|
52 |
'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)),
|
|
|
53 |
];
|
|
|
54 |
|
|
|
55 |
// Sign the JWT with our private key (given by the platform on registration)
|
|
|
56 |
$jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid());
|
|
|
57 |
|
|
|
58 |
// Build auth token request headers
|
|
|
59 |
$authRequest = [
|
|
|
60 |
'grant_type' => 'client_credentials',
|
|
|
61 |
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
|
62 |
'client_assertion' => $jwt,
|
|
|
63 |
'scope' => implode(' ', $scopes),
|
|
|
64 |
];
|
|
|
65 |
|
|
|
66 |
// Get Access
|
|
|
67 |
$request = new ServiceRequest(
|
|
|
68 |
ServiceRequest::METHOD_POST,
|
|
|
69 |
$registration->getAuthTokenUrl(),
|
|
|
70 |
ServiceRequest::TYPE_AUTH
|
|
|
71 |
);
|
|
|
72 |
$request->setPayload(['form_params' => $authRequest])
|
|
|
73 |
->setMaskResponseLogs(true);
|
|
|
74 |
$response = $this->makeRequest($request);
|
|
|
75 |
|
|
|
76 |
$tokenData = $this->getResponseBody($response);
|
|
|
77 |
|
|
|
78 |
// Cache access token
|
|
|
79 |
$this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']);
|
|
|
80 |
|
|
|
81 |
return $tokenData['access_token'];
|
|
|
82 |
}
|
|
|
83 |
|
|
|
84 |
public function makeRequest(IServiceRequest $request): ResponseInterface
|
|
|
85 |
{
|
|
|
86 |
$response = $this->client->request(
|
|
|
87 |
$request->getMethod(),
|
|
|
88 |
$request->getUrl(),
|
|
|
89 |
$request->getPayload()
|
|
|
90 |
);
|
|
|
91 |
|
|
|
92 |
if ($this->debuggingMode) {
|
|
|
93 |
$this->logRequest(
|
|
|
94 |
$request,
|
|
|
95 |
$this->getResponseHeaders($response),
|
|
|
96 |
$this->getResponseBody($response)
|
|
|
97 |
);
|
|
|
98 |
}
|
|
|
99 |
|
|
|
100 |
return $response;
|
|
|
101 |
}
|
|
|
102 |
|
|
|
103 |
public function getResponseHeaders(ResponseInterface $response): ?array
|
|
|
104 |
{
|
|
|
105 |
$responseHeaders = $response->getHeaders();
|
|
|
106 |
array_walk($responseHeaders, function (&$value) {
|
|
|
107 |
$value = $value[0];
|
|
|
108 |
});
|
|
|
109 |
|
|
|
110 |
return $responseHeaders;
|
|
|
111 |
}
|
|
|
112 |
|
|
|
113 |
public function getResponseBody(ResponseInterface $response): ?array
|
|
|
114 |
{
|
|
|
115 |
$responseBody = (string) $response->getBody();
|
|
|
116 |
|
|
|
117 |
return json_decode($responseBody, true);
|
|
|
118 |
}
|
|
|
119 |
|
|
|
120 |
public function makeServiceRequest(
|
|
|
121 |
ILtiRegistration $registration,
|
|
|
122 |
array $scopes,
|
|
|
123 |
IServiceRequest $request,
|
|
|
124 |
bool $shouldRetry = true
|
|
|
125 |
): array {
|
|
|
126 |
$request->setAccessToken($this->getAccessToken($registration, $scopes));
|
|
|
127 |
|
|
|
128 |
try {
|
|
|
129 |
$response = $this->makeRequest($request);
|
|
|
130 |
} catch (ClientException $e) {
|
|
|
131 |
$status = $e->getResponse()->getStatusCode();
|
|
|
132 |
|
|
|
133 |
// If the error was due to invalid authentication and the request
|
|
|
134 |
// should be retried, clear the access token and retry it.
|
|
|
135 |
if ($status === 401 && $shouldRetry) {
|
|
|
136 |
$key = $this->getAccessTokenCacheKey($registration, $scopes);
|
|
|
137 |
$this->cache->clearAccessToken($key);
|
|
|
138 |
|
|
|
139 |
return $this->makeServiceRequest($registration, $scopes, $request, false);
|
|
|
140 |
}
|
|
|
141 |
|
|
|
142 |
throw $e;
|
|
|
143 |
}
|
|
|
144 |
|
|
|
145 |
return [
|
|
|
146 |
'headers' => $this->getResponseHeaders($response),
|
|
|
147 |
'body' => $this->getResponseBody($response),
|
|
|
148 |
'status' => $response->getStatusCode(),
|
|
|
149 |
];
|
|
|
150 |
}
|
|
|
151 |
|
|
|
152 |
public function getAll(
|
|
|
153 |
ILtiRegistration $registration,
|
|
|
154 |
array $scopes,
|
|
|
155 |
IServiceRequest $request,
|
|
|
156 |
?string $key = null
|
|
|
157 |
): array {
|
|
|
158 |
if ($request->getMethod() !== ServiceRequest::METHOD_GET) {
|
|
|
159 |
throw new Exception('An invalid method was specified by an LTI service requesting all items.');
|
|
|
160 |
}
|
|
|
161 |
|
|
|
162 |
$results = [];
|
|
|
163 |
$nextUrl = $request->getUrl();
|
|
|
164 |
|
|
|
165 |
while ($nextUrl) {
|
|
|
166 |
$request->setUrl($nextUrl);
|
|
|
167 |
$response = $this->makeServiceRequest($registration, $scopes, $request);
|
|
|
168 |
$pageResults = $this->getResultsFromResponse($response, $key);
|
|
|
169 |
$results = array_merge($results, $pageResults);
|
|
|
170 |
$nextUrl = $this->getNextUrl($response['headers']);
|
|
|
171 |
}
|
|
|
172 |
|
|
|
173 |
return $results;
|
|
|
174 |
}
|
|
|
175 |
|
|
|
176 |
public static function getLogMessage(
|
|
|
177 |
IServiceRequest $request,
|
|
|
178 |
array $responseHeaders,
|
|
|
179 |
?array $responseBody
|
|
|
180 |
): string {
|
|
|
181 |
if ($request->getMaskResponseLogs()) {
|
|
|
182 |
$responseHeaders = self::maskValues($responseHeaders);
|
|
|
183 |
$responseBody = self::maskValues($responseBody);
|
|
|
184 |
}
|
|
|
185 |
|
|
|
186 |
$contextArray = [
|
|
|
187 |
'request_method' => $request->getMethod(),
|
|
|
188 |
'request_url' => $request->getUrl(),
|
|
|
189 |
'response_headers' => $responseHeaders,
|
|
|
190 |
'response_body' => $responseBody,
|
|
|
191 |
];
|
|
|
192 |
|
|
|
193 |
$requestBody = $request->getPayload()['body'] ?? null;
|
|
|
194 |
|
|
|
195 |
if (isset($requestBody)) {
|
|
|
196 |
$contextArray['request_body'] = $requestBody;
|
|
|
197 |
}
|
|
|
198 |
|
|
|
199 |
return implode(' ', array_filter([
|
|
|
200 |
$request->getErrorPrefix(),
|
|
|
201 |
json_decode($requestBody)->userId ?? null,
|
|
|
202 |
json_encode($contextArray),
|
|
|
203 |
]));
|
|
|
204 |
}
|
|
|
205 |
|
|
|
206 |
private function logRequest(
|
|
|
207 |
IServiceRequest $request,
|
|
|
208 |
array $responseHeaders,
|
|
|
209 |
?array $responseBody
|
|
|
210 |
): void {
|
|
|
211 |
error_log(self::getLogMessage($request, $responseHeaders, $responseBody));
|
|
|
212 |
}
|
|
|
213 |
|
|
|
214 |
private static function maskValues(?array $payload): ?array
|
|
|
215 |
{
|
|
|
216 |
if (!isset($payload) || empty($payload)) {
|
|
|
217 |
return $payload;
|
|
|
218 |
}
|
|
|
219 |
|
|
|
220 |
foreach ($payload as $key => $value) {
|
|
|
221 |
$payload[$key] = '***';
|
|
|
222 |
}
|
|
|
223 |
|
|
|
224 |
return $payload;
|
|
|
225 |
}
|
|
|
226 |
|
|
|
227 |
private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes): string
|
|
|
228 |
{
|
|
|
229 |
sort($scopes);
|
|
|
230 |
$scopeKey = md5(implode('|', $scopes));
|
|
|
231 |
|
|
|
232 |
return $registration->getIssuer().$registration->getClientId().$scopeKey;
|
|
|
233 |
}
|
|
|
234 |
|
|
|
235 |
private function getResultsFromResponse(array $response, ?string $key = null): array
|
|
|
236 |
{
|
|
|
237 |
if (isset($key)) {
|
|
|
238 |
return $response['body'][$key] ?? [];
|
|
|
239 |
}
|
|
|
240 |
|
|
|
241 |
return $response['body'] ?? [];
|
|
|
242 |
}
|
|
|
243 |
|
|
|
244 |
private function getNextUrl(array $headers): ?string
|
|
|
245 |
{
|
|
|
246 |
$subject = $headers['Link'] ?? $headers['link'] ?? '';
|
|
|
247 |
preg_match(static::NEXT_PAGE_REGEX, $subject, $matches);
|
|
|
248 |
|
|
|
249 |
return $matches[1] ?? null;
|
|
|
250 |
}
|
|
|
251 |
}
|