-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathTribeHRPanelClient.class.php
More file actions
285 lines (245 loc) · 9.77 KB
/
TribeHRPanelClient.class.php
File metadata and controls
285 lines (245 loc) · 9.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
<?php
/**
* This class represents a basic client for the TribeHR panel, and is used
* on behalf of the panel specified by the Integration ID and secret key found in config.php.
* Here you will find any methods related to making API Lookup calls or decoding and validating
* any panels-related requests that come in from TribeHR.
*
* While many of the concepts found in this class would more properly be classes in their own right,
* here we've done as much inline as possible to make the example easy to follow and completely transparent.
*/
class TribeHRPanelClient {
const REQUEST_LOG = '../logs/request.log';
const INCOMING_NONCE = 'incoming';
const GENERATED_NONCE = 'outgoing';
private $tokenError = '';
private $db = null;
/**
* Instantiating our app.
* Note that in this simple example, while a DB engine is injected, all calls are still
* made using basic SQL. We did not want to introduce an abstraction layer - we wanted it
* to be completely clear what was happening in each step.
*
* @param PDO $db
* @return void
*/
function __construct($db) {
$this->db = $db;
}
/**
* A basic logger that can be enabled/disabled through the configuration file.
* When active, will write a timestamped entry to a flat logfile.
*
* @param string $content
* @param string $logfile (default: self::REQUEST_LOG) By default, writes to /logs/request.log. Provide a valid file path to override.
* @return bool True if the operation did not fail
*/
public function log($content, $logfile = null)
{
// This define exists in config.php; you can enable/disable logging there
if (!REQUEST_LOGGING_ENABLED) {
return true;
}
if (empty($logfile)) {
$logfile = self::REQUEST_LOG;
}
return file_put_contents($logfile, sprintf("[%s] %s\n", date('Y-m-d H:i:s'), $content), FILE_APPEND);
}
/**
* Retrieve the last error generated while working with JWTs (JSON Web Tokens)
*
* @return string
*/
public function tokenError()
{
return $this->tokenError;
}
/**
* Use the TribeHR Lookup API to get identifying information about an account.
* Simple helper wrapper around request()
*
* @param string $accountIdentifier Globally-unique identifier for a TribeHR account; usually found in the 'account' claim of a JWT sent by TribeHR
* @return mixed The response as returned from request()
*/
public function accountLookup($accountIdentifier) {
$url = TRIBEHR_LOOKUP_API_ENDPOINT . "account/" . $accountIdentifier . ".json";
return $this->request($url);
}
/**
* Use the TribeHR Lookup API to get identifying information about an user in a specific account.
* Simple helper wrapper around request()
*
* @param string $accountIdentifier Globally-unique identifier for a TribeHR account; usually found in the 'account' claim of a JWT sent by TribeHR
* @param string $userIdentifier Globally-unique identifier for a TribeHR user; usually found in the 'aud' or 'sub' claim of a content request JWT sent by TribeHR
* @return mixed The response as returned from request()
*/
public function userLookup($accountIdentifier, $userIdentifier) {
$url = TRIBEHR_LOOKUP_API_ENDPOINT . "account/" . $accountIdentifier . "/users/" . $userIdentifier . ".json";
return $this->request($url);
}
/**
* Use the TribeHR Lookup API to get identifying information about a all users in a specific account.
* Simple helper wrapper around request()
*
* @param string $accountIdentifier Globally-unique identifier for a TribeHR account; usually found in the 'account' claim of a JWT sent by TribeHR
* @return mixed The response as returned from request()
*/
public function bulkUserLookup($accountIdentifier) {
$url = TRIBEHR_LOOKUP_API_ENDPOINT . "account/" . $accountIdentifier . "/users.json";
return $this->request($url);
}
/**
* Make a request against TribeHR's Lookup API, and decode the response.
* With this method, the caller must build the URL itself; using the appropriate
* helper wrapper is suggested.
* See: accountLookup(), userLookup(), bulkUserLookup()
*
* @param string $url Full URL to issue the request against
* @return mixed False if the request failed or was invalid; array representing Lookup API results if successful
*/
public function request($url)
{
$this->log('Issuing TribeHR Lookup API reqeust to URL: '. $url);
// Generate a nonce that we haven't sent to TribeHR within the last 12 hours
$validNonce = false;
$nonce = '';
while (!$validNonce) {
$nonce = bin2hex(openssl_random_pseudo_bytes(16));
$validNonce = $this->validNonce($nonce, self::GENERATED_NONCE);
}
$claims = array(
'iss' => INTEGRATION_ID,
'iat' => time(),
'jti' => $nonce,
'exp' => strtotime('+5 minutes')
);
$headers = array('Authorization: Bearer '. JWT::encode($claims, SECRET_SHARED_KEY));
$this->log('Request headers: '. print_r($headers, true));
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 0);
$response = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($info['http_code'] != 200) {
$this->log('Request failed with code: '. $info['http_code'] .'. Response body: '. $response);
return false;
}
$jsonResponse = json_decode($response, true);
$this->log('Request decoded JSON response: '. print_r($jsonResponse, true));
return $jsonResponse;
}
/**
* Decode and validate a JWT sent by TribeHR.
* If any errors are encountered, will set a message into $this->tokenError
*
* @param string $rawJWT string representing a signed JWT
* @return mixed false if the JWT was incomplete or validation failed; array of claim => value pairs otherwise
*/
public function decodeAndValidateToken($rawJWT)
{
if (empty($rawJWT)) {
$this->tokenError = "Missing JWT";
return false;
}
try {
$decodedJWT = JWT::decode($rawJWT, SECRET_SHARED_KEY, $verifySignature = true);
} catch (UnexpectedValueException $e) {
$this->tokenError = "JWT Invalid: ". $e->getMessage();
return false;
}
// TribeHR says that their 'iss' claim will *always* be "http://www.tribehr.com"
if ($decodedJWT->iss != 'http://www.tribehr.com') {
$this->tokenError = "Invalid 'iss' claim: ". $decodedJWT->iss;
return false;
}
// 'exp' must be a UNIX timestamp that is in the future from 'now'
if (!is_numeric($decodedJWT->exp) || $decodedJWT->exp < time()) {
$this->tokenError = "Expired JWT";
return false;
}
// 'iat' must be a UNIX timestamp, reperesenting the time at which the claim was issued
// This must be verified to be a a valid UNIX timestamp and before the 'exp' time
if (!is_numeric($decodedJWT->iat) || $decodedJWT->iat >= $decodedJWT->exp) {
$this->tokenError = "Missing or invalid 'iat' claim";
return false;
}
// 'jti' is a nonce that won't be reused by TribeHR more than once every 12 hours.
// If a jti is re-used, this could be a malicious replay attack, or a client that is caching inappropriately.
if (!$this->validNonce($decodedJWT->jti, self::INCOMING_NONCE)) {
$this->tokenError = "Missing, duplicated or invalid 'jti' claim";
return false;
}
return $decodedJWT;
}
/**
* Determine if a given nonce is currently valid or not.
* A nonce is valid for TribeHR panels if it has not been seen in the last 12 hours.
*
* @return boolean true if the nonce is valid, false otherwise.
*/
private function validNonce($nonce, $nonceType) {
$nonceTable = 'nonces_'. $nonceType;
// If you need to test by copy/pasting repeated requests, you can turn off nonce validation in config.php
if (!ENFORCE_NONCE) {
return true;
}
if (empty($nonce)) {
return false;
}
// Delete any nonces that are over 12h old; they're all valid again
$query = $this->db->prepare('DELETE FROM '. $nonceTable .' WHERE time < ?;');
$query->execute(array(strtotime('-12 hours')));
// Check if the nonce value exists; everything left is within 12 hours
$query = $this->db->prepare('SELECT nonce FROM '. $nonceTable .' WHERE nonce = ?');
$query->execute(array($nonce));
$result = $query->fetch(PDO::FETCH_ASSOC);
if (!empty($result)) {
return false;
}
// The nonce is valid, but now that we've seen it we need to record the fact
$query = $this->db->prepare('INSERT INTO '. $nonceTable .' (nonce, time) VALUES (?, ?)');
$query->execute(array($nonce, time()));
return true;
}
/**
* Helper to build an appropriate failure response to an activation request.
* Echoes the response directly to the buffer.
*
* Ensures that the header is set to "401 Unauthorized", and that the message is formatted as expected.
* Reminder: Message returned is seen by the TribeHR administrator, and should be useful to them.
*
* @param string $message Message to relay to the user when refusing the activation
* @return void
*/
public function sendActivationErrorResponse($message)
{
header($_SERVER['SERVER_PROTOCOL'] .' 401 Unauthorized');
echo json_encode(array(
'error' => array(
'message' => $message
)
));
}
/**
* Helper to build an appropriate failure response to a content request.
* Echoes the response directly to the buffer.
*
* Builds some (very) basic display HTML around the given error message.
* Your application will probably want this to look nicer and/or more consistent with regular content.
* Since this application is just an example, we wanted the error state to be very starkly visible for you.
*
* @param string $message Message to relay to the user when something has gone wrong with a content request
* @return void
*/
public function sendHtmlErrorResponse($message)
{
echo '
<div style="border:1px solid brown; padding:20px; overflow:hidden;">
<p>' . $message . '</p>
</div>
';
}
}