diff --git a/classes/ClientAuthChecker.php b/classes/ClientAuthChecker.php new file mode 100644 index 0000000000000000000000000000000000000000..cacb8e3ceee6adb1e253cdcaaa899a76958c240f --- /dev/null +++ b/classes/ClientAuthChecker.php @@ -0,0 +1,47 @@ +<?php + +namespace RAP; + +/** + * RFC 6749 specify that in some situations the client must send an Authorization + * Basic header containing its credentials (access token in the authorization code + * flow and refresh token requests). + */ +class ClientAuthChecker { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function validateClientAuth(): void { + + $headers = apache_request_headers(); + + if (!isset($headers['Authorization'])) { + throw new UnauthorizedException("Missing Authorization header"); + } + + $authorizationHeader = explode(" ", $headers['Authorization']); + if ($authorizationHeader[0] === "Basic") { + $basic = explode(':', base64_decode($authorizationHeader[1])); + 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"); + } + } else { + throw new UnauthorizedException("Expected Basic authorization header"); + } + } + +} diff --git a/classes/Locator.php b/classes/Locator.php index 6cc736c8a6869cfc0ebf91da009113642c3f7f8f..d032f70b14cbb85d0c17ab8eeed2441df8935fad 100644 --- a/classes/Locator.php +++ b/classes/Locator.php @@ -102,6 +102,14 @@ class Locator { return new TokenBuilder($this); } + public function getTokenChecker(): TokenChecker { + return new TokenChecker($this); + } + + public function getClientAuthChecker(): ClientAuthChecker { + return new ClientAuthChecker($this); + } + /** * Retrieve the SessionData object from the $_SESSION PHP variable. Create a * new one if it is necessary. diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 1ce8a7cf5c6cd61f12a76028aebc5d4d1a726887..12da4751b44284126ad32fe8364d3072984e844d 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -2,8 +2,6 @@ namespace RAP; -use \Firebase\JWT\JWT; - class OAuth2RequestHandler { private $locator; @@ -84,7 +82,9 @@ class OAuth2RequestHandler { . '?code=' . $code . '&scope=profile&state=' . $state; } else { // Implicit grant flow - $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, $nonce); + $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, function(& $jwt) use($nonce) { + $jwt['nonce'] = $nonce; + }); $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken; } @@ -93,6 +93,8 @@ class OAuth2RequestHandler { public function handleAccessTokenRequest($params): array { + $this->locator->getClientAuthChecker()->validateClientAuth(); + if ($params['code'] === null) { throw new BadRequestException("code id is required"); } @@ -123,6 +125,8 @@ class OAuth2RequestHandler { public function handleRefreshTokenRequest($params): array { + $this->locator->getClientAuthChecker()->validateClientAuth(); + if ($params['refresh_token'] === null) { throw new BadRequestException("refresh_token is required"); } @@ -138,7 +142,6 @@ class OAuth2RequestHandler { // Generating a new access token $accessTokenData = new AccessTokenData(); - $accessTokenData->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); $accessTokenData->clientId = $refreshToken->clientId; $accessTokenData->userId = $refreshToken->userId; $accessTokenData->scope = $scope; @@ -186,10 +189,7 @@ class OAuth2RequestHandler { $result['token_type'] = 'Bearer'; $result['expires_in'] = $tokenData->expirationTime - time(); - $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); - $refreshTokenHash = hash('sha256', $refreshToken); - $this->storeRefreshTokenData($tokenData, $refreshTokenHash); - $result['refresh_token'] = $refreshToken; + $result['refresh_token'] = $this->buildRefreshToken($tokenData); if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) { $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); @@ -198,93 +198,82 @@ class OAuth2RequestHandler { return $result; } - private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void { - - $refreshToken = new RefreshTokenData(); - $refreshToken->tokenHash = $refreshTokenHash; - $refreshToken->clientId = $accessTokenData->clientId; - $refreshToken->userId = $accessTokenData->userId; - $refreshToken->scope = $accessTokenData->scope; - - $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken); - } - /** * Token introspection endpoint shouldn't be necessary when using OIDC (since * tokens are self-contained JWT). This function is kept here for compatibility * with some libraries (e.g. Spring Security) but it could be removed in the * future. */ - public function handleCheckTokenRequest($token): array { + public function handleCheckTokenRequest(): array { - // TODO: validate the token and expose data - $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($token); - if ($accessToken === null) { - throw new UnauthorizedException("Invalid access token"); - } - - $user = $this->locator->getUserDAO()->findUserById($accessToken->userId); + $jwt = $this->locator->getTokenChecker()->validateToken(); + $tokenData = $this->getTokenDataFromJwtObject($jwt); $result = []; - $result['exp'] = $accessToken->expirationTime - time(); - $result['user_name'] = $user->id; - $result['client_id'] = $accessToken->clientId; - $result['refresh_token'] = $this->storeRefreshTokenData($accessToken); - - if ($accessToken->scope !== null) { - $result['scope'] = $accessToken->scope; - if (in_array('openid', $accessToken->scope)) { - $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($accessToken); + $result['exp'] = $tokenData->expirationTime - time(); + $result['user_name'] = $tokenData->userId; + $result['client_id'] = $tokenData->clientId; + $result['access_token'] = $this->copyReceivedAccessToken(); + $result['refresh_token'] = $this->buildRefreshToken($tokenData); + + if (isset($tokenData->scope) && count($tokenData->scope) > 0) { + $result['scope'] = $tokenData->scope; + if (in_array('openid', $tokenData->scope)) { + $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); } } return $result; } - public function validateToken(): void { + private function copyReceivedAccessToken(): string { $headers = apache_request_headers(); + return explode(" ", $headers['Authorization'])[1]; + } - if (!isset($headers['Authorization'])) { - throw new BadRequestException("Missing Authorization header"); - } + private function getTokenDataFromJwtObject($jwt): AccessTokenData { - $authorizationHeader = explode(" ", $headers['Authorization']); - if ($authorizationHeader[0] === "Bearer") { - $bearer_token = $authorizationHeader[1]; - } else { - throw new BadRequestException("Invalid token type"); - } - - $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($bearer_token); - if ($accessToken === null) { - $this->attemptJWTTokenValidation($bearer_token); - } else if ($accessToken->isExpired()) { - throw new UnauthorizedException("Access token is expired"); - } + $tokenData = new AccessTokenData(); + $tokenData->clientId = $this->getClientIdFromAudience($jwt); + $tokenData->userId = $jwt->sub; + $tokenData->creationTime = $jwt->iat; + $tokenData->expirationTime = $jwt->exp; + $tokenData->scope = explode(' ', $jwt->scope); + return $tokenData; } - private function attemptJWTTokenValidation($jwt): void { + private function getClientIdFromAudience(object $jwt): string { - $jwtParts = explode('.', $jwt); - if (count($jwtParts) === 0) { - throw new UnauthorizedException("Invalid token"); + if (!(isset($jwt->aud))) { + throw new UnauthorizedException("Missing 'aud' claim in token"); } - $header = JWT::jsonDecode(JWT::urlsafeB64Decode($jwtParts[0])); - if (!isset($header->kid)) { - throw new UnauthorizedException("Invalid token: missing kid in header"); + $audience = $jwt->aud; + if (is_array($audience)) { + if (count($audience) === 0) { + throw new UnauthorizedException("Token has empty audience"); + } + return $audience[0]; } + return $audience; + } - $keyPair = $this->locator->getJWKSDAO()->getRSAKeyPairById($header->kid); - if ($keyPair === null) { - throw new UnauthorizedException("Invalid kid: no key found"); - } + private function buildRefreshToken(AccessTokenData $tokenData): string { + $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); + $refreshTokenHash = hash('sha256', $refreshToken); + $this->storeRefreshTokenData($tokenData, $refreshTokenHash); + return $refreshToken; + } - try { - JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); - } catch (\Firebase\JWT\ExpiredException $ex) { - throw new UnauthorizedException("Access token is expired"); - } + private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void { + + $refreshToken = new RefreshTokenData(); + $refreshToken->tokenHash = $refreshTokenHash; + $refreshToken->clientId = $accessTokenData->clientId; + $refreshToken->userId = $accessTokenData->userId; + $refreshToken->scope = $accessTokenData->scope; + + $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken); } } diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php index b6a53fab3a5232988bb4b99cb009804229edcf54..abdb369f1610fe423232f3d0a217689bf334ab98 100644 --- a/classes/TokenBuilder.php +++ b/classes/TokenBuilder.php @@ -12,16 +12,16 @@ class TokenBuilder { $this->locator = $locator; } - public function getIdToken(AccessTokenData $tokenData, string $nonce = null): string { + public function getIdToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null): string { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); - $payload = $this->createIdTokenPayloadArray($tokenData, $nonce); + $payload = $this->createIdTokenPayloadArray($tokenData, $jwtCustomizer); return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); } - private function createIdTokenPayloadArray(AccessTokenData $tokenData, string $nonce = null) { + private function createIdTokenPayloadArray(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) { $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); @@ -34,10 +34,6 @@ class TokenBuilder { 'aud' => $tokenData->clientId ); - if ($nonce !== null) { - $payloadArr['nonce'] = $nonce; - } - if (in_array("email", $tokenData->scope)) { $payloadArr['email'] = $user->getPrimaryEmail(); } @@ -49,14 +45,15 @@ class TokenBuilder { } } - /*if ($tokenData->joinUser !== null) { - $payloadArr['alt_sub'] = strval($tokenData->joinUser); - }*/ + if ($jwtCustomizer !== null) { + // Add additional custom claims + $jwtCustomizer($payloadArr); + } return $payloadArr; } - public function getAccessToken(AccessTokenData $tokenData) { + public function getAccessToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); @@ -67,8 +64,13 @@ class TokenBuilder { 'sub' => strval($user->id), 'iat' => intval($tokenData->creationTime), 'exp' => intval($tokenData->expirationTime), - 'aud' => $this->getAudience($tokenData) + 'aud' => $this->getAudience($tokenData), + 'scope' => implode(' ', $tokenData->scope) ); + if ($jwtCustomizer !== null) { + // Add additional custom claims + $jwtCustomizer($payload); + } return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); } diff --git a/classes/TokenChecker.php b/classes/TokenChecker.php new file mode 100644 index 0000000000000000000000000000000000000000..370bc8526f6470d145f93e8798ce79d7aed4a74c --- /dev/null +++ b/classes/TokenChecker.php @@ -0,0 +1,73 @@ +<?php + +namespace RAP; + +use \Firebase\JWT\JWT; + +class TokenChecker { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function validateToken(): object { + $headers = apache_request_headers(); + + if (!isset($headers['Authorization'])) { + throw new BadRequestException("Missing Authorization header"); + } + + $authorizationHeader = explode(" ", $headers['Authorization']); + if ($authorizationHeader[0] === "Bearer") { + $token = $authorizationHeader[1]; + } else { + throw new BadRequestException("Invalid token type"); + } + + return $this->attemptJWTTokenValidation($token); + } + + private function attemptJWTTokenValidation($jwt): object { + + $jwtParts = explode('.', $jwt); + if (count($jwtParts) === 0) { + throw new UnauthorizedException("Invalid token"); + } + + $header = JWT::jsonDecode(JWT::urlsafeB64Decode($jwtParts[0])); + if (!isset($header->kid)) { + throw new UnauthorizedException("Invalid token: missing kid in header"); + } + + $keyPair = $this->locator->getJWKSDAO()->getRSAKeyPairById($header->kid); + if ($keyPair === null) { + throw new UnauthorizedException("Invalid kid: no key found"); + } + + try { + return JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + } catch (\Firebase\JWT\ExpiredException $ex) { + throw new UnauthorizedException("Access token is expired"); + } + } + + public function checkScope(object $tokenData, string $desiredScope): void { + + if (!(isset($tokenData->scope))) { + throw new UnauthorizedException("Missing 'scope' claim in access token"); + } + + $scopes = explode(' ', $tokenData->scope); + + foreach ($scopes as $scope) { + if ($scope === $desiredScope) { + return; + } + } + + throw new UnauthorizedException("Scope '$desiredScope' is required for performing this action"); + } + +} diff --git a/classes/UserHandler.php b/classes/UserHandler.php index 86facfecee0e1e29f45bd79bdb057ef0b9f8b6fe..f744588c663e72c52140bd92468c62f2036ff268 100644 --- a/classes/UserHandler.php +++ b/classes/UserHandler.php @@ -71,7 +71,6 @@ class UserHandler { // Call Grouper for moving groups and privileges from one user to the other if (isset($this->locator->config->gms)) { - // TODO: change with new GMS //create cURL connection $conn = curl_init($this->locator->config->gms->joinEndpoint); @@ -81,7 +80,7 @@ class UserHandler { curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1); curl_setopt($conn, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' - . $this->getJoinIdToken($userId1, $userId2)]); + . $this->getJoinAccessToken($userId1, $userId2)]); //set data to be posted curl_setopt($conn, CURLOPT_POST, 1); @@ -113,19 +112,20 @@ class UserHandler { return $user1; } - private function getJoinIdToken(int $userId1, int $userId2): string { + private function getJoinAccessToken(int $userId1, int $userId2): string { $gmsId = $this->locator->config->gms->id; - $accessToken = new AccessToken(); + $accessToken = new AccessTokenData(); $accessToken->clientId = $gmsId; $accessToken->userId = $userId1; - $accessToken->joinUser = $userId2; // shorter expiration $accessToken->expirationTime = $accessToken->creationTime + 100; $accessToken->scope = ['openid']; - return $this->locator->getTokenBuilder()->getIdToken($accessToken); + return $this->locator->getTokenBuilder()->getAccessToken($accessToken, function(& $jwt) use($userId2) { + $jwt['alt_sub'] = strval($userId2); + }); } } diff --git a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php index b63f94ba1d7b6f25bb40e2030b2e5aeba372dead..cbc60f5a1115d837024caa9cfccba4fb50135ffd 100644 --- a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php +++ b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php @@ -24,7 +24,7 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { return $clients; } - private function getClientsMap(PDO $dbh): array { + private function getClientsMap(\PDO $dbh): array { // Load clients info $queryClient = "SELECT id, title, icon, client, secret, redirect_url, scope, home_page, show_in_home FROM oauth2_client"; @@ -50,7 +50,7 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { return $clientsMap; } - private function loadAuthenticationMethods(PDO $dbh, array $clientsMap): void { + private function loadAuthenticationMethods(\PDO $dbh, array $clientsMap): void { $queryAuthNMethods = "SELECT client_id, auth_method FROM oauth2_client_auth_methods"; @@ -63,7 +63,7 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { } } - private function loadScopeAudienceMapping(PDO $dbh, array $clientsMap): void { + private function loadScopeAudienceMapping(\PDO $dbh, array $clientsMap): void { $query = "SELECT client_id, scope, audience FROM oauth2_client_scope_audience_mapping"; diff --git a/classes/login/LoginHandler.php b/classes/login/LoginHandler.php index fc2531d186007782c802be6f7075120403c48551..9ebdf218813fc5c4a75c31556d30e562734fbb1c 100644 --- a/classes/login/LoginHandler.php +++ b/classes/login/LoginHandler.php @@ -85,7 +85,6 @@ class LoginHandler { $action = $session->getAction(); if ($action === 'join') { - if ($session->getUser()->id !== $user->id) { $user = $this->locator->getUserHandler()->joinUsers($session->getUser(), $user); } diff --git a/include/front-controller.php b/include/front-controller.php index a3010bc5fc3aff6a7c258c54cd1e90ee8c1aa286..31ecec6e458aece9f798435de78000d1fbe9b8ee 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -49,11 +49,6 @@ Flight::route('/', function() { $authPageModel = new \RAP\AuthPageModel($locator, $client); renderMainPage($authPageModel); break; - /*case "admin": - $client = new \RAP\InternalClient('admin'); - $authPageModel = new \RAP\AuthPageModel($locator, $client); - renderMainPage($authPageModel); - break;*/ default: session_destroy(); $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients(); @@ -127,25 +122,8 @@ Flight::route('POST /auth/oauth2/check_token', function() { global $locator; - $headers = apache_request_headers(); - - if (!isset($headers['Authorization'])) { - throw new \RAP\BadRequestException("Missing Authorization header"); - } - - $authorizationHeader = explode(" ", $headers['Authorization']); - if ($authorizationHeader[0] === "Bearer") { - $token = $authorizationHeader[1]; - } else { - throw new \RAP\BadRequestException("Invalid token type"); - } - - if ($token === null) { - throw new \RAP\BadRequestException("Access token is required"); - } - $requestHandler = new \RAP\OAuth2RequestHandler($locator); - $result = $requestHandler->handleCheckTokenRequest($token); + $result = $requestHandler->handleCheckTokenRequest(); Flight::json($result); }); diff --git a/include/rest-web-service.php b/include/rest-web-service.php index d67366121d637b1656548286b80797f1b2401055..ac213cf92bb7716bd1bcd1f11508744da3f69d5b 100644 --- a/include/rest-web-service.php +++ b/include/rest-web-service.php @@ -13,7 +13,8 @@ Flight::route('GET ' . $WS_PREFIX . '/user/@userId', function($userId) { global $locator; - $locator->getOAuth2RequestHandler()->validateToken(); + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'read:rap'); $user = $locator->getUserDAO()->findUserById($userId); if ($user !== null) { @@ -31,7 +32,8 @@ Flight::route('GET ' . $WS_PREFIX . '/user', function() { global $locator; - $locator->getOAuth2RequestHandler()->validateToken(); + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'read:rap'); $searchText = Flight::request()->query['search']; if ($searchText !== null) { @@ -51,13 +53,14 @@ Flight::route('GET ' . $WS_PREFIX . '/user', function() { /** * Create new user from identity data. Return the new user encoded in JSON. * This can be used to automatically import users without they explicitly - * register (this is done for INAF eduGAIN users readling directly from LDAP). + * register (this is done for INAF eduGAIN users reading directly from LDAP). */ Flight::route('POST ' . $WS_PREFIX . '/user', function() { global $locator; - $locator->getOAuth2RequestHandler()->validateToken(); + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'write:rap'); $postData = Flight::request()->data; diff --git a/views/account-management.php b/views/account-management.php index ab8db20613d18b22f32e65ed57f20d158c57f335..39712aba8c5797b11af5818f15d1232a6d90a8d6 100644 --- a/views/account-management.php +++ b/views/account-management.php @@ -20,7 +20,7 @@ include 'include/header.php'; <div class="col-sm-2"> <div class="row"> <div class="col-sm-12"> - <a class="btn btn-success disabled" id="join-btn" href="<?php echo $contextRoot; ?>?action=join" title="Perform an additional login to join your identities" data-toggle="tooltip" data-placement="bottom"> + <a class="btn btn-success" id="join-btn" href="<?php echo $contextRoot; ?>?action=join" title="Perform an additional login to join your identities" data-toggle="tooltip" data-placement="bottom"> Join with another identity </a> </div>