diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php index feffd906d1ac26d6c27e3ae528ffcf8461773efb..966873fe7cfc8cd27225f19d1218c0cf5c874b2b 100644 --- a/classes/JWKSHandler.php +++ b/classes/JWKSHandler.php @@ -77,4 +77,67 @@ class JWKSHandler { return $matches[1]; } + public function loadAllJWKS(): array { + + foreach ($this->locator->config->jwksUrls as $url) { + $this->loadJWKS($url); + } + + $dao = $this->locator->getJWKSDAO(); + return $dao->getAllPublicJWK(); + } + + private function loadJWKS($url) { + + $dao = $this->locator->getJWKSDAO(); + + $conn = curl_init($url); + curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1); + curl_setopt($conn, CURLOPT_RETURNTRANSFER, true); + + $result = curl_exec($conn); + $info = curl_getinfo($conn); + + if ($info['http_code'] === 200) { + $jwks = json_decode($result, TRUE); + + foreach ($jwks['keys'] as $key) { + $key['url'] = $url; + $jwk = $this->getPublicJWK($key); + $dao->updatePublicJWK($jwk); + } + } else { + $errorMessage = 'Error while retrieving JWKS: ' . curl_error($conn); + error_log($result); + curl_close($conn); + http_response_code(500); + die($errorMessage); + } + + curl_close($conn); + } + + private function getPublicJWK($data): PublicJWK { + + // Convert Base64 uri-safe variant to default (needed for JWKS) + $n = strtr($data['n'], '-_', '+/'); + + $rsa = new RSA(); + + $key = "<RSAKeyPair>" + . "<Modulus>" . $n . "</Modulus>" + . "<Exponent>" . $data['e'] . "</Exponent>" + . "</RSAKeyPair>"; + + $rsa->loadKey($key, RSA::PUBLIC_FORMAT_XML); + + $jwk = new PublicJWK(); + $jwk->kid = $data['kid']; + $jwk->key = $rsa; + $jwk->url = $data['url']; + $jwk->updateTime = time(); + + return $jwk; + } + } diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php index f848112593eee27e38199afc482e056985f64508..d42103f813787875793ad5bf4e8eac05a2602766 100644 --- a/classes/TokenBuilder.php +++ b/classes/TokenBuilder.php @@ -101,17 +101,15 @@ class TokenBuilder { * @param int $lifespan in hours * @param string $audience target service */ - public function generateNewToken(int $lifespan, string $audience) { + public function generateNewToken(string $subject, int $lifespan, string $audience) { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); - $user = $this->locator->getSession()->getUser(); - $iat = time(); $exp = $iat + $lifespan * 3600; $payload = array( 'iss' => $this->locator->config->jwtIssuer, - 'sub' => strval($user->id), + 'sub' => strval($subject), 'iat' => $iat, 'exp' => $exp, 'aud' => $audience diff --git a/classes/TokenExchanger.php b/classes/TokenExchanger.php new file mode 100644 index 0000000000000000000000000000000000000000..a397478b8146cf3db6bc4b644618968084779fd1 --- /dev/null +++ b/classes/TokenExchanger.php @@ -0,0 +1,86 @@ +<?php + +/* ---------------------------------------------------------------------------- + * INAF - National Institute for Astrophysics + * IRA - Radioastronomical Institute - Bologna + * OATS - Astronomical Observatory - Trieste + * ---------------------------------------------------------------------------- + * + * Copyright (C) 2016 Istituto Nazionale di Astrofisica + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License Version 3 as published by the + * Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +namespace RAP; + +use \Firebase\JWT\JWT; + +class TokenExchanger { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function exchangeToken(string $token) { + + $key = $this->getKeyForToken($token); + $decoded = JWT::decode($token, $key->key, ['RS256']); + + $subject = $decoded->sub; + $lifespan = ($decoded->exp - time()); + + $data = []; + + $data['access_token'] = $this->locator->getTokenBuilder()->generateNewToken($subject, $lifespan / 3600, "gms"); + $data['issued_token_type'] = "urn:ietf:params:oauth:token-type:access_token"; + $data['token_type'] = 'Bearer'; + $data['expires_in'] = $lifespan; + + return $data; + } + + private function getKeyForToken(string $token): PublicJWK { + + $keys = $this->locator->getJWKSDAO()->getAllPublicJWK(); + + $parts = explode('.', $token); + $head = JWT::jsonDecode(JWT::urlsafeB64Decode($parts[0])); + + $kid = $head->kid; + + $key = $this->getKeyByKid($keys, $kid); + if ($key === null) { + $keys = $this->locator->getJWKSHandler()->loadAllJWKS(); + } + $key = $this->getKeyByKid($keys, $kid); + + if ($key !== null) { + return $key; + } + + throw new \Exception("Invalid kid"); + } + + private function getKeyByKid(array $keys, string $kid): ?PublicJWK { + foreach ($keys as $key) { + if ($key->kid === $kid) { + return $key; + } + } + return null; + } + +} diff --git a/classes/datalayer/JWKSDAO.php b/classes/datalayer/JWKSDAO.php index 9cd83f49900a9cfccd8cbadc77caa4116da2e0f2..a5b52a612b834e745e1396ab8ca3c1ac81a3387f 100644 --- a/classes/datalayer/JWKSDAO.php +++ b/classes/datalayer/JWKSDAO.php @@ -11,4 +11,8 @@ interface JWKSDAO { public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair; public function getNewestKeyPair(): ?RSAKeyPair; + + public function getAllPublicJWK(): array; + + public function updatePublicJWK(PublicJWK $jwk); } diff --git a/classes/datalayer/mysql/MySQLJWKSDAO.php b/classes/datalayer/mysql/MySQLJWKSDAO.php index 8e7a83ad188340b257950396055f12ceee49ec17..5611db62ab69e85ec700c51bf174be86f98376e8 100644 --- a/classes/datalayer/mysql/MySQLJWKSDAO.php +++ b/classes/datalayer/mysql/MySQLJWKSDAO.php @@ -85,4 +85,48 @@ class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO { return $keyPair; } + public function getAllPublicJWK(): array { + + $dbh = $this->getDBHandler(); + + $query = "SELECT `kid`, `key`, `url`, `update_time` FROM public_jwk"; + + $stmt = $dbh->prepare($query); + $stmt->execute(); + + $keys = []; + + foreach ($stmt->fetchAll() as $row) { + array_push($keys, $this->getPublicJWKFromResultRow($row)); + } + + return $keys; + } + + private function getPublicJWKFromResultRow($row): PublicJWK { + + $jwk = new PublicJWK (); + $jwk->key = $row['key']; + $jwk->kid = $row['kid']; + $jwk->url = $row['url']; + $jwk->updateTime = $row['update_time']; + return $jwk; + } + + public function updatePublicJWK(PublicJWK $jwk) { + + $dbh = $this->getDBHandler(); + + $query = "INSERT INTO public_jwk(kid, `key`, `url`, update_time) VALUES (:kid, :key, :url, :update_time)" + . " ON DUPLICATE KEY UPDATE `key`=:key, `url`=:url, update_time=:update_time"; + + $stmt = $dbh->prepare($query); + $stmt->bindParam(':kid', $jwk->kid); + $stmt->bindParam(':key', $jwk->key); + $stmt->bindParam(':url', $jwk->url); + $stmt->bindParam(':update_time', $jwk->updateTime); + + $stmt->execute(); + } + } diff --git a/classes/model/PublicJWK.php b/classes/model/PublicJWK.php new file mode 100644 index 0000000000000000000000000000000000000000..c883bf5edd7e79d36b4b6da59b214b730ba0147f --- /dev/null +++ b/classes/model/PublicJWK.php @@ -0,0 +1,12 @@ +<?php + +namespace RAP; + +class PublicJWK { + + public $kid; + public $key; + public $url; + public $updateTime; + +} diff --git a/config-example.json b/config-example.json index 14f3e739a3ecc97f3e888a0fe1b1b2444d9c8a23..adc90f42e72e9efc7a17b8463401905abccf4446 100644 --- a/config-example.json +++ b/config-example.json @@ -56,5 +56,6 @@ "scope": "read:fileserver read:rap" }], "lifespans": [1, 6, 12, 24] - } + }, + "jwksUrls": ["http://service/jwks"] } diff --git a/include/front-controller.php b/include/front-controller.php index 019da569d30f1d9816dacf29f49226663f6a204f..41c49f41f20f8fd1cd5a95e86527e37b360e55ad 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -370,7 +370,8 @@ Flight::route('POST /token-issuer', function () { } $tokenBuilder = $locator->getTokenBuilder(); - $token = $tokenBuilder->generateNewToken($postData['lifespan'], $postData['audit']); + $userId = $this->locator->getSession()->getUser()->id; + $token = $tokenBuilder->generateNewToken($userId, $postData['lifespan'], $postData['audit']); header('Content-Type: text/plain'); header("Content-disposition: attachment; filename=\"token.txt\""); diff --git a/include/rest-web-service.php b/include/rest-web-service.php index ac213cf92bb7716bd1bcd1f11508744da3f69d5b..c4e78543b83082f7a3758a46622cec2d89c3dd68 100644 --- a/include/rest-web-service.php +++ b/include/rest-web-service.php @@ -89,3 +89,14 @@ Flight::route('POST ' . $WS_PREFIX . '/user', function() { Flight::json($user); }); + +Flight::route('POST ' . $WS_PREFIX . '/exchange', function() { + + global $locator; + + $subjectToken = Flight::request()->data['subject_token']; + + $exchanger = new \RAP\TokenExchanger($locator); + + Flight::json($exchanger->exchangeToken($subjectToken)); +}); diff --git a/sql/setup-database.sql b/sql/setup-database.sql index 2d46eaa0ca2af1912ff5edee9eabc30bfee173d4..5b3842b0e1b0db1004bbb5b76e743c1eb605e5f8 100644 --- a/sql/setup-database.sql +++ b/sql/setup-database.sql @@ -83,6 +83,14 @@ CREATE TABLE `rsa_keypairs` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `public_jwk` ( + `kid` varchar(255) NOT NULL, + `key` text, + `url` text, + `update_time` BIGINT NOT NULL, + PRIMARY KEY (`kid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE `rap_permissions` ( `user_id` bigint NOT NULL, `permission` varchar(255) NOT NULL,