From 0517db8c387a0f925f4392c22e5ecaeaa3a4aa18 Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Fri, 21 Aug 2020 18:24:27 +0200 Subject: [PATCH] Implemented client credentials grant type --- classes/ClientAuthChecker.php | 40 ++++++++++++++----- classes/JWKSHandler.php | 6 +-- classes/OAuth2RequestHandler.php | 21 ++++++++-- classes/TokenBuilder.php | 20 +++++++++- classes/datalayer/OAuth2ClientDAO.php | 2 + .../datalayer/mysql/MySQLOAuth2ClientDAO.php | 37 +++++++++++++++++ classes/model/AccessTokenData.php | 1 + classes/model/CliClient.php | 11 +++++ include/front-controller.php | 5 ++- sql/setup-database.sql | 8 ++++ 10 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 classes/model/CliClient.php diff --git a/classes/ClientAuthChecker.php b/classes/ClientAuthChecker.php index cacb8e3..8bb33e2 100644 --- a/classes/ClientAuthChecker.php +++ b/classes/ClientAuthChecker.php @@ -17,6 +17,35 @@ class ClientAuthChecker { public function validateClientAuth(): void { + $basic = $this->getBasicAuthArray(); + + $clientId = $basic[0]; + $clientSecret = $basic[1]; + + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId); + if ($client === null) { + throw new UnauthorizedException("Client '$clientId' not configured"); + } + if ($clientSecret !== $client->secret) { + throw new UnauthorizedException("Invalid client secret"); + } + } + + public function validateCliClientAuth(): CliClient { + + $basic = $this->getBasicAuthArray(); + + $clientId = $basic[0]; + $clientSecret = $basic[1]; + + $client = $this->locator->getOAuth2ClientDAO()->getCliClient($clientId, $clientSecret); + if ($client === null) { + throw new UnauthorizedException("Client '$clientId' not configured or wrong password"); + } + return $client; + } + + private function getBasicAuthArray(): array { $headers = apache_request_headers(); if (!isset($headers['Authorization'])) { @@ -29,16 +58,7 @@ class ClientAuthChecker { if (count($basic) !== 2) { throw new BadRequestException("Malformed Basic-Auth header"); } - $clientId = $basic[0]; - $clientSecret = $basic[1]; - - $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId); - if ($client === null) { - throw new UnauthorizedException("Client '$clientId' not configured"); - } - if ($clientSecret !== $client->secret) { - throw new UnauthorizedException("Invalid client secret"); - } + return $basic; } else { throw new UnauthorizedException("Expected Basic authorization header"); } diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php index 966873f..d759816 100644 --- a/classes/JWKSHandler.php +++ b/classes/JWKSHandler.php @@ -107,11 +107,7 @@ class JWKSHandler { $dao->updatePublicJWK($jwk); } } else { - $errorMessage = 'Error while retrieving JWKS: ' . curl_error($conn); - error_log($result); - curl_close($conn); - http_response_code(500); - die($errorMessage); + error_log('Error while retrieving JWKS from ' . $url); } curl_close($conn); diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 12da475..8adc551 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -91,7 +91,7 @@ class OAuth2RequestHandler { return $redirectUrl; } - public function handleAccessTokenRequest($params): array { + public function handleGetTokenFromCodeRequest($params): array { $this->locator->getClientAuthChecker()->validateClientAuth(); @@ -123,6 +123,19 @@ class OAuth2RequestHandler { return $response; } + public function handleClientCredentialsRequest($params): array { + + $client = $this->locator->getClientAuthChecker()->validateCliClientAuth(); + + $accessTokenData = new AccessTokenData(); + $accessTokenData->clientId = $client->id; + $accessTokenData->userId = $client->id; + $accessTokenData->scope = $client->scope; + $accessTokenData->audience = $client->audience; + + return $this->getAccessTokenResponse($accessTokenData, false); + } + public function handleRefreshTokenRequest($params): array { $this->locator->getClientAuthChecker()->validateClientAuth(); @@ -182,14 +195,16 @@ class OAuth2RequestHandler { return $scope; } - private function getAccessTokenResponse(AccessTokenData $tokenData) { + private function getAccessTokenResponse(AccessTokenData $tokenData, bool $refreshToken = true) { $result = []; $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData); $result['token_type'] = 'Bearer'; $result['expires_in'] = $tokenData->expirationTime - time(); - $result['refresh_token'] = $this->buildRefreshToken($tokenData); + if ($refreshToken) { + $result['refresh_token'] = $this->buildRefreshToken($tokenData); + } if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) { $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php index d42103f..27bf313 100644 --- a/classes/TokenBuilder.php +++ b/classes/TokenBuilder.php @@ -58,10 +58,16 @@ class TokenBuilder { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); + if ($user === null) { + // CLI client + $sub = $tokenData->clientId; + } else { + $sub = $user->id; + } $payload = array( 'iss' => $this->locator->config->jwtIssuer, - 'sub' => strval($user->id), + 'sub' => strval($sub), 'iat' => intval($tokenData->creationTime), 'exp' => intval($tokenData->expirationTime), 'aud' => $this->getAudience($tokenData), @@ -77,7 +83,15 @@ class TokenBuilder { private function getAudience(AccessTokenData $tokenData) { + if ($tokenData->audience !== null) { + return $this->getAudienceClaim($tokenData->audience); + } + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($tokenData->clientId); + if ($client === null) { + // CLI client without audience + return null; + } $audiences = [$tokenData->clientId]; @@ -90,6 +104,10 @@ class TokenBuilder { } } + return $this->getAudienceClaim($audiences); + } + + private function getAudienceClaim($audiences) { if (count($audiences) === 1) { // according to RFC 7519 audience can be a single value or an array return $audiences[0]; diff --git a/classes/datalayer/OAuth2ClientDAO.php b/classes/datalayer/OAuth2ClientDAO.php index ffa4520..944ff6e 100644 --- a/classes/datalayer/OAuth2ClientDAO.php +++ b/classes/datalayer/OAuth2ClientDAO.php @@ -20,4 +20,6 @@ interface OAuth2ClientDAO { * the secret, not the database id). */ function getOAuth2ClientByClientId($clientId): ?OAuth2Client; + + function getCliClient(string $clientId, string $secret): ?CliClient; } diff --git a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php index 7890e95..c005b69 100644 --- a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php +++ b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php @@ -246,4 +246,41 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { return $client; } + function getCliClient(string $clientId, string $secret): ?CliClient { + + $dbh = $this->getDBHandler(); + + // Load clients info + $queryClient = "SELECT scope, audience FROM cli_client WHERE client_id = :client AND client_secret = PASSWORD(:secret)"; + $stmtClient = $dbh->prepare($queryClient); + $stmtClient->bindParam(':client', $clientId); + $stmtClient->bindParam(':secret', $secret); + $stmtClient->execute(); + + $result = $stmtClient->fetchAll(); + + if (count($result) === 0) { + return null; + } + if (count($result) > 1) { + throw new \Exception("Found multiple clients associated to the same client id!"); + } + + $row = $result[0]; + + $client = new CliClient(); + $client->id = $clientId; + if ($row['scope'] !== null) { + $client->scope = explode(' ', $row['scope']); + } else { + $client->scope = []; + } + if ($row['audience'] !== null) { + $client->audience = explode(' ', $row['audience']); + } else { + $client->audience = []; + } + return $client; + } + } diff --git a/classes/model/AccessTokenData.php b/classes/model/AccessTokenData.php index 3826e22..a73d06b 100644 --- a/classes/model/AccessTokenData.php +++ b/classes/model/AccessTokenData.php @@ -19,6 +19,7 @@ class AccessTokenData { public $redirectUri; public $clientId; public $scope; + public $audience; public function __construct() { $this->creationTime = time(); diff --git a/classes/model/CliClient.php b/classes/model/CliClient.php new file mode 100644 index 0000000..d10f719 --- /dev/null +++ b/classes/model/CliClient.php @@ -0,0 +1,11 @@ +<?php + +namespace RAP; + +class CliClient { + + public $id; + public $scope; + public $audience; + +} diff --git a/include/front-controller.php b/include/front-controller.php index 7f5714b..540a16d 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -106,7 +106,10 @@ Flight::route('POST /auth/oauth2/token', function() { switch ($params['grant_type']) { case "authorization_code": - $token = $requestHandler->handleAccessTokenRequest($params); + $token = $requestHandler->handleGetTokenFromCodeRequest($params); + break; + case "client_credentials": + $token = $requestHandler->handleClientCredentialsRequest($params); break; case "refresh_token": $token = $requestHandler->handleRefreshTokenRequest($params); diff --git a/sql/setup-database.sql b/sql/setup-database.sql index 5b3842b..6e8b7f6 100644 --- a/sql/setup-database.sql +++ b/sql/setup-database.sql @@ -26,6 +26,14 @@ CREATE TABLE `oauth2_client_scope_audience_mapping` ( FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `cli_client` ( + `client_id` varchar(255) NOT NULL, + `client_secret` varchar(255) NOT NULL, + `scope` text, + `audience` text, + PRIMARY KEY (`client_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `primary_identity` bigint(20) DEFAULT NULL, -- GitLab