diff --git a/.devcontainer/docker-compose.mysql.yml b/.devcontainer/docker-compose.mysql.yml index 55cb7ccc2..bd5b56fd1 100644 --- a/.devcontainer/docker-compose.mysql.yml +++ b/.devcontainer/docker-compose.mysql.yml @@ -24,6 +24,7 @@ services: # and the value of "workspaceFolder" in .devcontainer/devcontainer.json - ..:/var/www/html - hashtopolis-server-dev:/usr/local/share/hashtopolis:Z + # - ./jwks.json:/keys/jwks.json:ro networks: - hashtopolis_dev hashtopolis-db-dev: diff --git a/.devcontainer/docker-compose.postgres.yml b/.devcontainer/docker-compose.postgres.yml index 0a7747910..4ba0f1a4b 100644 --- a/.devcontainer/docker-compose.postgres.yml +++ b/.devcontainer/docker-compose.postgres.yml @@ -24,6 +24,7 @@ services: # and the value of "workspaceFolder" in .devcontainer/devcontainer.json - ..:/var/www/html - hashtopolis-server-dev:/usr/local/share/hashtopolis:Z + # - ./jwks.json:/keys/jwks.json:ro networks: - hashtopolis_dev hashtopolis-db-dev: diff --git a/.gitignore b/.gitignore index 8ffd0597b..3efd096c0 100755 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ src/files/* *.phpproj.user *.lock* +# the public keys for oauth +jwks.json + # dynamically created by installer src/install/.htaccess diff --git a/docker-compose.mysql.yml b/docker-compose.mysql.yml index 03010d489..bf95d225f 100644 --- a/docker-compose.mysql.yml +++ b/docker-compose.mysql.yml @@ -7,6 +7,7 @@ services: volumes: - hashtopolis:/usr/local/share/hashtopolis:Z # - ./ssmtp.conf:/etc/ssmtp/ssmtp.conf + # - ./jwks.json:/keys/jwks.json:ro environment: HASHTOPOLIS_DB_TYPE: mysql HASHTOPOLIS_DB_USER: $MYSQL_USER diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index a7553595e..2b5bdc56d 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -7,6 +7,7 @@ services: volumes: - hashtopolis:/usr/local/share/hashtopolis:Z # - ./ssmtp.conf:/etc/ssmtp/ssmtp.conf + # - ./jwks.json:/keys/jwks.json:ro environment: HASHTOPOLIS_DB_TYPE: postgres HASHTOPOLIS_DB_USER: $POSTGRES_USER diff --git a/jwks.json.example b/jwks.json.example new file mode 100644 index 000000000..a83e3a989 --- /dev/null +++ b/jwks.json.example @@ -0,0 +1,31 @@ +# Example jwks file for the keys that are needed for Open ID Connect +{ + "keys": [ + { + "kid": "3VcAf_wFO6KQz4RiKowja6IW35QJ40RkXSBgkgcfTfw", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "qvuxqeloNtBwAwIOlfu48bd9-VnELl2D0DdGfUGKh_0_5gFgbXiGytGG11a_IC6qqlmmIWU4xuy-2Q2uytrQAkrZMTPmsT88ZrT84HCMUlxgqU5QWUPRGmlwGDuuPNXyeYDPbEtX9du8PQb6DNuu2kWMLmm_xjYwQzIzMPxR49xsR9h0N-wMHwc-fmSgkR02Io96I1NkQX3DHCuVvPFBp5cUhfb5lXwHGe1cdx3D4koA0y0NJ1EsOjfuMDv4AUtZFqqUKDEbg-ADoYA4HtfHOjcciMYXkEbb5FejlVCsppF_HMWuFtNqF6_V0FOfKvmNnJ0WzUD9NR6BJ_VqDxGNGQ", + "e": "AQAB", + "x5c": [ + "MIICmzCCAYMCBgGbjmGlkjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMTA1MTMzNzAyWhcNMzYwMTA1MTMzODQyWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq+7Gp6Wg20HADAg6V+7jxt335WcQuXYPQN0Z9QYqH/T/mAWBteIbK0YbXVr8gLqqqWaYhZTjG7L7ZDa7K2tACStkxM+axPzxmtPzgcIxSXGCpTlBZQ9EaaXAYO6481fJ5gM9sS1f127w9BvoM267aRYwuab/GNjBDMjMw/FHj3GxH2HQ37AwfBz5+ZKCRHTYij3ojU2RBfcMcK5W88UGnlxSF9vmVfAcZ7Vx3HcPiSgDTLQ0nUSw6N+4wO/gBS1kWqpQoMRuD4AOhgDge18c6NxyIxheQRtvkV6OVUKymkX8cxa4W02oXr9XQU58q+Y2cnRbNQP01HoEn9WoPEY0ZAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABLaxd6HGIbKKd3hU56XbQHMcwbFTpPtL/P56JBUhcCVG1sCoLSE4csVW0o9Si1toKqh8hAtWDYA0/tm/IEjkwt3TcXdWjA/XoKGDbkz508aLDM2ni2CXq1p3wXwesCiGhkblg+liiNPyb2wU9RpmYRKkn16Qsb2qEw6AS1uaph0/+XLAPWENr5/pjJOgXQqok2VIOiAcrsnayE6zPDFQ2d2uYAKLOKNFgUKZ7K92DGH+qD8IheV8F6Wjs7cea7LZcgq0dlV85lmIQ8dZyQunL2QwGewIGVSShvT97vivk/xS86Zf9qQcaANAuvff/g1lCdxg2bFr8PewOXp3zQlqdU=" + ], + "x5t": "e0BNs1RTbxcrnk9Rnjs1n4pxcuY", + "x5t#S256": "wCTM8mdXANWTaYDj3w5cRm0yD5ybINNlULtxpAuA7gw" + }, + { + "kid": "UzDpZMBnNvfqtOmhny4gqZdjQLGWYuy2gAN3Wk_hm4Y", + "kty": "RSA", + "alg": "RSA-OAEP", + "use": "enc", + "n": "xGHOeXyy6B8_V1BsELJb5XWHfWJHS4w45oxvYbT--Dl6miixwrRizOCnpGz81JIPJZ_Dg-qGi372tQo10xegeg71h7GRMeCcGDA7QN7PXSLnwphkBQvV_uBzYnxDm98ZRLsBmyMnLRCEPdVxJX1_nxaqCk3-KbZxLVuEJM-AAMPlA0TcC5ZIB8pSWeYA_DhGswRb_t67GMEyXHKzNAvA_Bhc7E-FZ-C66WLH5e0bv47W2KSzCtJZNpRHGb-CdeJYzg7rR4M9PzGnCmtc1YEocWsgqHJ0lDO3Sl1g4XtIW84UPi2wBEvoBgQuT2UPhni9TqVd62yJ1F8F_MC4ZEIgpw", + "e": "AQAB", + "x5c": [ + "MIICmzCCAYMCBgGbjmGl7DANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMTA1MTMzNzAyWhcNMzYwMTA1MTMzODQyWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEYc55fLLoHz9XUGwQslvldYd9YkdLjDjmjG9htP74OXqaKLHCtGLM4KekbPzUkg8ln8OD6oaLfva1CjXTF6B6DvWHsZEx4JwYMDtA3s9dIufCmGQFC9X+4HNifEOb3xlEuwGbIyctEIQ91XElfX+fFqoKTf4ptnEtW4Qkz4AAw+UDRNwLlkgHylJZ5gD8OEazBFv+3rsYwTJccrM0C8D8GFzsT4Vn4LrpYsfl7Ru/jtbYpLMK0lk2lEcZv4J14ljODutHgz0/MacKa1zVgShxayCocnSUM7dKXWDhe0hbzhQ+LbAES+gGBC5PZQ+GeL1OpV3rbInUXwX8wLhkQiCnAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAMLa57gi0EgnVStdw6JGbjOYURhto8Gfahs/STDrpkmkMuvAh0bRzwi8yiJs75jX7ykBFNrAslsdnohMicXyjrHaMNbMwPeip9/XMISS7kR5sDqyz1AA+s28oyWAB9HWu5ntiD93LlJj+UU4qZ5+SpmKzRDs2MMhL8aWozuOwABGI4VrfvFRJL8O3J6ewxUeCikpEfB9UWkE+B+N/q8Wsn92n76z8UhqsdLOJVp1LwIuwOIcK9oCFZnSwfiGXSfK4e2QfxF6hWVAEdkQaKXsNZmxrqWE9CxdQp6ouOGaqiplzWUBDuWptNoaM57tLNo0jl0d6C1XPFUlzO9TfQHilEU=" + ], + "x5t": "HlsH_q2fqXiyZrxi4iWlbXoO51w", + "x5t#S256": "ByDXBjBIXVPzmIEts-GeqhlxhMQL1S2tM-8npsv2-jo" + } + ] +} \ No newline at end of file diff --git a/src/api/v2/index.php b/src/api/v2/index.php index 535c5e919..087faeec9 100644 --- a/src/api/v2/index.php +++ b/src/api/v2/index.php @@ -143,7 +143,7 @@ public function get($key): string { include(dirname(__FILE__) . '/../../inc/confv2.php'); $decoder = new FirebaseDecoder( - new Secret($PEPPER[0], 'HS256') + new Secret($PEPPER[0], 'HS256', hash("sha256", $PEPPER[0])) ); $options = new Options( @@ -153,7 +153,7 @@ public function get($key): string { ); $rules = [ - new RequestPathRule(ignore: ["/api/v2/auth/token", "/api/v2/helper/resetUserPassword", "/api/v2/openapi.json"]), + new RequestPathRule(ignore: ["/api/v2/auth/token", "/api/v2/auth/oauth-token", "/api/v2/helper/resetUserPassword", "/api/v2/openapi.json"]), new RequestMethodRule(ignore: ["OPTIONS"]) ]; return new JwtAuthentication($options, $decoder, $rules); diff --git a/src/inc/apiv2/auth/token.routes.php b/src/inc/apiv2/auth/token.routes.php index 7f1f01317..c86a2f6ac 100644 --- a/src/inc/apiv2/auth/token.routes.php +++ b/src/inc/apiv2/auth/token.routes.php @@ -10,9 +10,108 @@ use DBA\QueryFilter; use DBA\User; use DBA\Factory; +use Firebase\JWT\JWK; require_once(dirname(__FILE__) . "/../../load.php"); +function generateTokenForUser(Request $request, string $userName, int $expires) { + include(dirname(__FILE__) . '/../../confv2.php'); + $jti = bin2hex(random_bytes(16)); + + $requested_scopes = $request->getParsedBody() ?: ["todo.all"]; + + $valid_scopes = [ + "todo.create", + "todo.read", + "todo.update", + "todo.delete", + "todo.list", + "todo.all" + ]; + + $scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) { + return in_array($needle, $valid_scopes); + }); + // FIXME: This is duplicated and should be passed by HttpBasicMiddleware + $filter = new QueryFilter(User::USERNAME, $userName, "="); + $check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]); + $user = $check[0]; + + if (empty($user)) { + throw new HttpError("No user with this userName in the database"); + } + + $secret = $PEPPER[0]; + $now = new DateTime(); + + $payload = [ + "iat" => $now->getTimeStamp(), + "exp" => $expires, + "jti" => $jti, + "userId" => $user->getId(), + "scope" => $scopes, + "iss" => "Hashtopolis", + "kid" => hash("sha256", $secret) + ]; + + $token = JWT::encode($payload, $secret, "HS256"); + + return $token; +} + +function extractBearerToken(Request $request): ?string { + $header = $request->getHeaderLine('Authorization'); + + if (!$header) { + return null; + } + + if (!preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) { + return null; + } + + return trim($matches[1]); +} + +// Exchanges an oauth token for a application JWT token +$app->group("/api/v2/auth/oauth-token", function (RouteCollectorProxy $group) { + + $group->post('', function (Request $request, Response $response, array $args): Response { + $jwks_file = file_get_contents("/keys/jwks.json"); + if (!$jwks_file) { + throw new HttpError("No jwks.json found, upload the jwks public keys to /keys/jwks.json to use OIDC authentication"); + } + $jwks = json_decode($jwks_file, true); + + if ($jwks === null) { + throw new HttpError("Incorrect json inside jwks.json, make sure to upload a valid json file"); + } + $keys = JWK::parseKeySet($jwks); + $jwt = extractBearerToken($request); + if ($jwt === null) { + throw new HttpError("No jwt Token found in the Bearer header"); + } + $decoded_jwt = JWT::decode($jwt, $keys); + + if(!property_exists($decoded_jwt, "preferred_username")) { + throw new HttpError("The OAUTH token doesnt have a 'preferred_username' claim, which is needed to validate the user"); + } + $userName = $decoded_jwt->preferred_username; + + $future = new DateTime("now +2 hours"); + $token = generateTokenForUser($request, $userName, $future->getTimestamp()); + $data["token"] = $token; + $data["expires"] = $future->getTimestamp(); + + $body = $response->getBody(); + $body->write(json_encode($data, JSON_UNESCAPED_SLASHES)); + + return $response->withStatus(201) + ->withHeader("Content-Type", "application/json"); + }); +}); + +// This routes needs to be protected by httpbasicauthentication middleware $app->group("/api/v2/auth/token", function (RouteCollectorProxy $group) { /* Allow preflight requests */ $group->options('', function (Request $request, Response $response, array $args): Response { @@ -20,50 +119,15 @@ }); $group->post('', function (Request $request, Response $response, array $args): Response { - include(dirname(__FILE__) . '/../../confv2.php'); - - $requested_scopes = $request->getParsedBody() ?: ["todo.all"]; - - $valid_scopes = [ - "todo.create", - "todo.read", - "todo.update", - "todo.delete", - "todo.list", - "todo.all" - ]; - - $scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) { - return in_array($needle, $valid_scopes); - }); - $now = new DateTime(); $future = new DateTime("now +2 hours"); - $server = $request->getServerParams(); - - $jti = bin2hex(random_bytes(16)); - - // FIXME: This is duplicated and should be passed by HttpBasicMiddleware - $filter = new QueryFilter(User::USERNAME, $request->getAttribute('user'), "="); - $check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]); - $user = $check[0]; - - $payload = [ - "iat" => $now->getTimeStamp(), - "exp" => $future->getTimeStamp(), - "jti" => $jti, - "userId" => $user->getId(), - "scope" => $scopes - ]; - - $secret = $PEPPER[0]; - $token = JWT::encode($payload, $secret, "HS256"); + $token = generateTokenForUser($request, $request->getAttribute('user'), $future->getTimestamp()); $data["token"] = $token; - $data["expires"] = $future->getTimeStamp(); + $data["expires"] = $future->getTimestamp(); $body = $response->getBody(); - $body->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $body->write(json_encode($data, JSON_UNESCAPED_SLASHES)); return $response->withStatus(201) ->withHeader("Content-Type", "application/json"); @@ -84,22 +148,24 @@ $jti = bin2hex(random_bytes(16)); + $secret = $PEPPER[0]; $payload = [ "iat" => $now->getTimeStamp(), "exp" => $future->getTimeStamp(), "jti" => $jti, "userId" => $request->getAttribute(('userId')), - "scope" => $request->getAttribute("scope") + "scope" => $request->getAttribute("scope"), + "iss" => "Hashtopolis", + "kid" => hash("sha256", $secret) ]; - $secret = $PEPPER[0]; $token = JWT::encode($payload, $secret, "HS256"); $data["token"] = $token; $data["expires"] = $future->getTimeStamp(); $body = $response->getBody(); - $body->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $body->write(json_encode($data, JSON_UNESCAPED_SLASHES)); return $response->withStatus(201) ->withHeader("Content-Type", "application/json");