PageRenderTime 48ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 0ms

/src/JsConnect.php

https://github.com/vanilla/jsConnectPHP
PHP | 433 lines | 192 code | 49 blank | 192 comment | 18 complexity | cb4da39ad04834a51761478f7961389f MD5 | raw file
  1. <?php
  2. /**
  3. * @author Todd Burry <todd@vanillaforums.com>
  4. * @copyright 2009-2020 Vanilla Forums Inc.
  5. * @license MIT
  6. */
  7. namespace Vanilla\JsConnect;
  8. use Exception;
  9. use Firebase\JWT\JWT;
  10. use UnexpectedValueException;
  11. use Vanilla\JsConnect\Exceptions\FieldNotFoundException;
  12. use Vanilla\JsConnect\Exceptions\InvalidValueException;
  13. /**
  14. * Handles the jsConnect protocol v3.x.
  15. */
  16. class JsConnect {
  17. const VERSION = 'php:3';
  18. const FIELD_UNIQUE_ID = 'id';
  19. const FIELD_PHOTO = 'photo';
  20. const FIELD_NAME = 'name';
  21. const FIELD_EMAIL = 'email';
  22. const FIELD_ROLES = 'roles';
  23. const FIELD_JWT = 'jwt';
  24. const TIMEOUT = 10 * 60;
  25. const ALLOWED_ALGORITHMS = [
  26. 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512'
  27. ];
  28. const FIELD_STATE = 'st';
  29. const FIELD_USER = 'u';
  30. const FIELD_REDIRECT_URL = 'rurl';
  31. const FIELD_CLIENT_ID = 'kid';
  32. const FIELD_TARGET = 't';
  33. /**
  34. * @var \ArrayAccess
  35. */
  36. protected $keys;
  37. /**
  38. * @var string string
  39. */
  40. protected $signingClientID = '';
  41. /**
  42. * @var array
  43. */
  44. protected $user = [];
  45. /**
  46. * @var bool
  47. */
  48. protected $guest = false;
  49. /**
  50. * @var string
  51. */
  52. protected $signingAlgorithm;
  53. /**
  54. * @var int
  55. */
  56. protected $timeout = self::TIMEOUT;
  57. /**
  58. * JsConnect constructor.
  59. */
  60. public function __construct() {
  61. $this->keys = new \ArrayObject();
  62. $this->setSigningAlgorithm('HS256');
  63. }
  64. /**
  65. * Validate a value that cannot be empty.
  66. *
  67. * @param mixed $value The value to test.
  68. * @param string $valueName The name of the value for the exception message.
  69. * @throws InvalidValueException Throws an exception when the value is empty.
  70. */
  71. protected static function validateNotEmpty($value, string $valueName): void {
  72. if ($value === null) {
  73. throw new InvalidValueException("$valueName is required.");
  74. }
  75. if (empty($value)) {
  76. throw new InvalidValueException("$valueName cannot be empty.");
  77. }
  78. }
  79. /**
  80. * Set the current user's email address.
  81. *
  82. * @param string $email
  83. * @return $this
  84. */
  85. public function setEmail(string $email) {
  86. return $this->setUserField(self::FIELD_EMAIL, $email);
  87. }
  88. /**
  89. * Set the a field on the current user.
  90. *
  91. * @param string $key The key on the user.
  92. * @param string|int|bool|array|null $value The value to set. This must be a basic type that can be JSON encoded.
  93. * @return $this
  94. */
  95. public function setUserField(string $key, $value) {
  96. $this->user[$key] = $value;
  97. return $this;
  98. }
  99. /**
  100. * Set the current user's username.
  101. *
  102. * @param string $name
  103. * @return $this
  104. */
  105. public function setName(string $name) {
  106. return $this->setUserField(self::FIELD_NAME, $name);
  107. }
  108. /**
  109. * Set the current user's avatar.
  110. *
  111. * @param string $photo
  112. * @return $this
  113. */
  114. public function setPhotoURL(string $photo) {
  115. return $this->setUserField(self::FIELD_PHOTO, $photo);
  116. }
  117. /**
  118. * Set the current user's unique ID.
  119. *
  120. * @param string $id
  121. * @return $this
  122. */
  123. public function setUniqueID(string $id) {
  124. return $this->setUserField(self::FIELD_UNIQUE_ID, $id);
  125. }
  126. /**
  127. * Handle the authentication request and redirect back to Vanilla.
  128. *
  129. * @param array $query
  130. */
  131. public function handleRequest(array $query): void {
  132. try {
  133. $jwt = static::validateFieldExists(self::FIELD_JWT, $query, 'querystring');
  134. $location = $this->generateResponseLocation($jwt);
  135. $this->redirect($location);
  136. } catch (Exception $ex) {
  137. echo htmlspecialchars($ex->getMessage());
  138. }
  139. }
  140. /**
  141. * Validate that a field exists in a collection.
  142. *
  143. * @param string $field The name of the field to validate.
  144. * @param mixed $collection The collection to look at.
  145. * @param string $collectionName The name of the collection.
  146. * @param bool $validateEmpty If true, make sure the value is also not empty.
  147. * @return mixed Returns the field value if there are no errors.
  148. * @throws FieldNotFoundException Throws an exception when the field is not in the array.
  149. * @throws InvalidValueException Throws an exception when the collection isn't an array or the value is empty.
  150. */
  151. protected static function validateFieldExists(string $field, $collection, string $collectionName = 'payload', bool $validateEmpty = true) {
  152. if (!(is_array($collection) || $collection instanceof \ArrayAccess)) {
  153. throw new InvalidValueException("Invalid array: $collectionName");
  154. }
  155. if (!isset($collection[$field])) {
  156. throw new FieldNotFoundException($field, $collectionName);
  157. }
  158. if ($validateEmpty && empty($collection[$field])) {
  159. throw new InvalidValueException("Field cannot be empty: {$collectionName}[{$field}]");
  160. }
  161. return $collection[$field];
  162. }
  163. /**
  164. * Generate the location for an SSO redirect.
  165. *
  166. * @param string $requestJWT
  167. * @return string
  168. */
  169. public function generateResponseLocation(string $requestJWT): string {
  170. // Validate the request token.
  171. $request = $this->jwtDecode($requestJWT);
  172. if ($this->isGuest()) {
  173. $data = [
  174. self::FIELD_USER => new \stdClass(),
  175. self::FIELD_STATE => $request[self::FIELD_STATE] ?? [],
  176. ];
  177. } else {
  178. // Generate the response token.
  179. $data = [
  180. self::FIELD_USER => $this->user,
  181. self::FIELD_STATE => $request[self::FIELD_STATE] ?? [],
  182. ];
  183. }
  184. $response = $this->jwtEncode($data);
  185. $location = $request[self::FIELD_REDIRECT_URL] . '#' . http_build_query(['jwt' => $response]);
  186. return $location;
  187. }
  188. /**
  189. * Decode a JWT with the connection's settings.
  190. *
  191. * @param string $jwt
  192. * @return array
  193. */
  194. public function jwtDecode(string $jwt): array {
  195. /**
  196. * @psalm-suppress InvalidArgument
  197. */
  198. $payload = JWT::decode($jwt, $this->keys, self::ALLOWED_ALGORITHMS);
  199. $payload = $this->stdClassToArray($payload);
  200. return $payload;
  201. }
  202. /**
  203. * Convert an object to an array, recursively.
  204. *
  205. * @param array|object $o
  206. * @return array
  207. */
  208. protected function stdClassToArray($o): array {
  209. if (!is_array($o) && !($o instanceof \stdClass)) {
  210. throw new UnexpectedValueException("JsConnect::stdClassToArray() expects an object or array, scalar given.", 400);
  211. }
  212. $o = (array)$o;
  213. $r = [];
  214. foreach ($o as $key => $value) {
  215. if (is_array($value) || is_object($value)) {
  216. $r[$key] = $this->stdClassToArray($value);
  217. } else {
  218. $r[$key] = $value;
  219. }
  220. }
  221. return $r;
  222. }
  223. /**
  224. * Whether or not the user is signed in.
  225. *
  226. * @return bool
  227. */
  228. public function isGuest(): bool {
  229. return $this->guest;
  230. }
  231. /**
  232. * Set whether or not the user is signed in.
  233. *
  234. * @param bool $isGuest
  235. * @return $this
  236. */
  237. public function setGuest(bool $isGuest) {
  238. $this->guest = $isGuest;
  239. return $this;
  240. }
  241. /**
  242. * Wrap a payload in a JWT.
  243. *
  244. * @param array $payload
  245. * @return string
  246. */
  247. public function jwtEncode(array $payload): string {
  248. $payload += [
  249. 'v' => $this->getVersion(),
  250. 'iat' => $this->getTimestamp(),
  251. 'exp' => $this->getTimestamp() + $this->getTimeout(),
  252. ];
  253. $jwt = JWT::encode($payload, $this->getSigningSecret(), $this->getSigningAlgorithm(), null, [
  254. self::FIELD_CLIENT_ID => $this->getSigningClientID(),
  255. ]);
  256. return $jwt;
  257. }
  258. /**
  259. * Get the current timestamp.
  260. *
  261. * This time is used for signing and verifying tokens.
  262. *
  263. * @return int
  264. */
  265. protected function getTimestamp(): int {
  266. $r = JWT::$timestamp ?: time();
  267. return $r;
  268. }
  269. /**
  270. * Get the secret that is used to sign JWTs.
  271. *
  272. * @return string
  273. */
  274. public function getSigningSecret(): string {
  275. return $this->keys[$this->signingClientID];
  276. }
  277. /**
  278. * Get the algorithm used to sign tokens.
  279. *
  280. * @return string
  281. */
  282. public function getSigningAlgorithm(): string {
  283. return $this->signingAlgorithm;
  284. }
  285. /**
  286. * Set the algorithm used to sign tokens.
  287. *
  288. * @param string $signingAlgorithm
  289. * @return $this
  290. */
  291. public function setSigningAlgorithm(string $signingAlgorithm) {
  292. if (!in_array($signingAlgorithm, static::ALLOWED_ALGORITHMS)) {
  293. throw new UnexpectedValueException('Algorithm not allowed');
  294. }
  295. $this->signingAlgorithm = $signingAlgorithm;
  296. return $this;
  297. }
  298. /**
  299. * Get the client ID that is used to sign JWTs.
  300. *
  301. * @return string
  302. */
  303. public function getSigningClientID(): string {
  304. return $this->signingClientID;
  305. }
  306. /**
  307. * Redirect to a new location.
  308. *
  309. * @param string $location
  310. */
  311. protected function redirect(string $location): void {
  312. header("Location: $location", true, 302);
  313. die();
  314. }
  315. /**
  316. * Set the credentials that will be used to sign requests.
  317. *
  318. * @param string $clientID
  319. * @param string $secret
  320. * @return $this
  321. */
  322. public function setSigningCredentials(string $clientID, string $secret) {
  323. $this->keys[$clientID] = $secret;
  324. $this->signingClientID = $clientID;
  325. return $this;
  326. }
  327. public function getUser(): array {
  328. return $this->user;
  329. }
  330. /**
  331. * Set the roles on the user.
  332. *
  333. * @param array $roles
  334. * @return $this
  335. */
  336. public function setRoles(array $roles) {
  337. $this->setUserField(self::FIELD_ROLES, $roles);
  338. return $this;
  339. }
  340. /**
  341. * Returns a JWT header.
  342. *
  343. * @param string $jwt
  344. *
  345. * @return array|null
  346. */
  347. final public static function decodeJWTHeader(string $jwt): ?array {
  348. $tks = explode('.', $jwt);
  349. if (count($tks) != 3) {
  350. throw new UnexpectedValueException('Wrong number of segments');
  351. }
  352. list($headb64) = $tks;
  353. if (null === ($header = JWT::jsonDecode(JWT::urlsafeB64Decode($headb64)))) {
  354. throw new UnexpectedValueException('Invalid header encoding');
  355. }
  356. return json_decode(json_encode($header), true);
  357. }
  358. /**
  359. * Get the version used to sign requests.
  360. *
  361. * @return string
  362. */
  363. public function getVersion(): string {
  364. return self::VERSION;
  365. }
  366. /**
  367. * Get the JWT expiry timeout.
  368. *
  369. * @return int
  370. */
  371. public function getTimeout(): int {
  372. return $this->timeout;
  373. }
  374. /**
  375. * Set the JWT expiry timeout.
  376. *
  377. * @param int $timeout
  378. * @return $this
  379. */
  380. public function setTimeout(int $timeout) {
  381. $this->timeout = $timeout;
  382. return $this;
  383. }
  384. }