diff --git a/README.md b/README.md index b24ffabb655089f92c7ac210511edb8ca04531c1..462ca07ab2fd345a295176f54a21d473458c9174 100644 --- a/README.md +++ b/README.md @@ -128,3 +128,13 @@ Create the logs directory and assign ownership to the Apache user (usually www-d ## Additional information and developer guide See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home + +## Troubleshooting + +### Class not found while developing + +If you see a message like this: + + PHP Fatal error: Uncaught Error: Class 'RAP\\[...]' not found + +probably you have to regenerate the PHP autoload calling `composer dumpautoload` in RAP root directory. diff --git a/classes/IdTokenBuilder.php b/classes/IdTokenBuilder.php deleted file mode 100644 index 0f9ea1719f69b34ccc9e03630e09ce24db52b186..0000000000000000000000000000000000000000 --- a/classes/IdTokenBuilder.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php - -namespace RAP; - -use \Firebase\JWT\JWT; - -class IdTokenBuilder { - - private $locator; - - public function __construct(Locator $locator) { - $this->locator = $locator; - } - - public function getIdToken(AccessToken $accessToken, string $nonce = null): string { - - $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); - - $payload = $this->createPayloadArray($accessToken, $nonce); - - return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); - } - - private function createPayloadArray(AccessToken $accessToken, string $nonce = null) { - - $user = $this->locator->getUserDAO()->findUserById($accessToken->userId); - - $payloadArr = array( - 'iss' => $this->locator->config->jwtIssuer, - 'sub' => strval($user->id), - 'iat' => intval($accessToken->creationTime), - 'exp' => intval($accessToken->expirationTime), - 'name' => $user->getCompleteName(), - 'aud' => $accessToken->clientId - ); - - if ($nonce !== null) { - $payloadArr['nonce'] = $nonce; - } - - if (in_array("email", $accessToken->scope)) { - $payloadArr['email'] = $user->getPrimaryEmail(); - } - if (in_array("profile", $accessToken->scope)) { - $payloadArr['given_name'] = $user->getName(); - $payloadArr['family_name'] = $user->getSurname(); - if ($user->getInstitution() !== null) { - $payloadArr['org'] = $user->getInstitution(); - } - } - - if ($accessToken->joinUser !== null) { - $payloadArr['alt_sub'] = strval($accessToken->joinUser); - } - - return $payloadArr; - } - - /** - * @param int $lifespan in hours - * @param string $audit target service - */ - public function generateNewToken(int $lifespan, string $audit) { - $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), - 'iat' => $iat, - 'exp' => $exp, - 'aud' => $audit - ); - - return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); - } - -} diff --git a/classes/Locator.php b/classes/Locator.php index f2ea9da0c6346e197508c0901f0dd017e43f4916..6cc736c8a6869cfc0ebf91da009113642c3f7f8f 100644 --- a/classes/Locator.php +++ b/classes/Locator.php @@ -98,8 +98,8 @@ class Locator { return new OAuth2RequestHandler($this); } - public function getIdTokenBuilder(): IdTokenBuilder { - return new IdTokenBuilder($this); + public function getTokenBuilder(): TokenBuilder { + return new TokenBuilder($this); } /** diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 4d4c1243db2e5a1bd1e64dea88bd90f5b6baf623..c656d120194e79498da8bff092944e99bb2fd94c 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -43,7 +43,7 @@ class OAuth2RequestHandler { } // Storing OAuth2 data in session - $oauth2Data = new \RAP\OAuth2Data(); + $oauth2Data = new OAuth2RequestData(); $oauth2Data->clientId = $client->client; $oauth2Data->redirectUrl = $client->redirectUrl; $oauth2Data->state = $state; @@ -55,34 +55,37 @@ class OAuth2RequestHandler { } $session = $this->locator->getSession(); - $session->setOAuth2Data($oauth2Data); + $session->setOAuth2RequestData($oauth2Data); } public function getRedirectResponseUrl(): string { $session = $this->locator->getSession(); - $accessToken = new \RAP\AccessToken(); - $accessToken->code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64))); - $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); - $accessToken->userId = $session->getUser()->id; - $accessToken->clientId = $session->getOAuth2Data()->clientId; - $accessToken->redirectUri = $session->getOAuth2Data()->redirectUrl; - $accessToken->scope = $session->getOAuth2Data()->scope; + $code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64))); + + $tokenData = new AccessTokenData(); + // Code is stored in hashed format inside the database, as a basic + // security measure in order to prevent issues in case of data breach. + $tokenData->codeHash = hash('sha256', $code); + $tokenData->userId = $session->getUser()->id; + $tokenData->clientId = $session->getOAuth2RequestData()->clientId; + $tokenData->redirectUri = $session->getOAuth2RequestData()->redirectUrl; + $tokenData->scope = $session->getOAuth2RequestData()->scope; - $this->locator->getAccessTokenDAO()->createAccessToken($accessToken); + $this->locator->getAccessTokenDAO()->createTokenData($tokenData); - $state = $session->getOAuth2Data()->state; - $nonce = $session->getOAuth2Data()->nonce; + $state = $session->getOAuth2RequestData()->state; + $nonce = $session->getOAuth2RequestData()->nonce; if ($state !== null) { // Authorization code grant flow - $redirectUrl = $session->getOAuth2Data()->redirectUrl - . '?code=' . $accessToken->code . '&scope=profile&state=' . $state; + $redirectUrl = $session->getOAuth2RequestData()->redirectUrl + . '?code=' . $code . '&scope=profile&state=' . $state; } else { // Implicit grant flow - $idToken = $this->locator->getIdTokenBuilder()->getIdToken($accessToken, $nonce); - $redirectUrl = $session->getOAuth2Data()->redirectUrl . "#id_token=" . $idToken; + $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, $nonce); + $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken; } return $redirectUrl; @@ -99,19 +102,23 @@ class OAuth2RequestHandler { } // Note: theorically the standard wants also the client_id here, - // however some clients don't send it + // however some clients don't send it (e.g. Spring Security library) + // + $codeHash = hash('sha256', $params['code']); - $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']); + $tokenData = $this->locator->getAccessTokenDAO()->retrieveTokenDataFromCode($codeHash); - if ($accessToken === null) { + if ($tokenData === null) { throw new BadRequestException("No token for given code"); } - if ($accessToken->redirectUri !== $params['redirect_uri']) { + if ($tokenData->redirectUri !== $params['redirect_uri']) { throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']); } - return $this->getAccessTokenResponse($accessToken); + $response = $this->getAccessTokenResponse($tokenData); + $this->locator->getAccessTokenDAO()->deleteTokenData($codeHash); + return $response; } public function handleRefreshTokenRequest($params): array { @@ -120,7 +127,7 @@ class OAuth2RequestHandler { throw new BadRequestException("refresh_token is required"); } - $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshToken($params['refresh_token']); + $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshTokenData($params['refresh_token']); if ($refreshToken === null || $refreshToken->isExpired()) { throw new UnauthorizedException("Invalid refresh token"); @@ -129,7 +136,7 @@ class OAuth2RequestHandler { $scope = $this->getScope($params, $refreshToken); // Generating a new access token - $accessToken = new AccessToken(); + $accessToken = new AccessTokenData(); $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); $accessToken->clientId = $refreshToken->clientId; $accessToken->userId = $refreshToken->userId; @@ -167,40 +174,49 @@ class OAuth2RequestHandler { $scope = $newScopeValues; } - + return $scope; } - private function getAccessTokenResponse(AccessToken $accessToken) { + private function getAccessTokenResponse(AccessTokenData $tokenData) { $result = []; - $result['access_token'] = $accessToken->token; + $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData); $result['token_type'] = 'Bearer'; - $result['expires_in'] = $accessToken->expirationTime - time(); - $result['refresh_token'] = $this->getNewRefreshToken($accessToken); + $result['expires_in'] = $tokenData->expirationTime - time(); - if ($accessToken->scope !== null && in_array('openid', $accessToken->scope)) { - $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken); + $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); + $refreshTokenHash = hash('sha256', $refreshToken); + $this->storeRefreshTokenData($tokenData, $refreshTokenHash); + $result['refresh_token'] = $refreshToken; + + if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) { + $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); } return $result; } - private function getNewRefreshToken(AccessToken $accessToken): string { - - $refreshToken = new RefreshToken(); - $refreshToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); - $refreshToken->clientId = $accessToken->clientId; - $refreshToken->userId = $accessToken->userId; - $refreshToken->scope = $accessToken->scope; + private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void { - $this->locator->getRefreshTokenDAO()->createRefreshToken($refreshToken); + $refreshToken = new RefreshTokenData(); + $refreshToken->tokenHash = $refreshTokenHash; + $refreshToken->clientId = $accessTokenData->clientId; + $refreshToken->userId = $accessTokenData->userId; + $refreshToken->scope = $accessTokenData->scope; - return $refreshToken->token; + $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 { + // TODO: validate the token and expose data $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($token); if ($accessToken === null) { throw new UnauthorizedException("Invalid access token"); @@ -212,12 +228,12 @@ class OAuth2RequestHandler { $result['exp'] = $accessToken->expirationTime - time(); $result['user_name'] = $user->id; $result['client_id'] = $accessToken->clientId; - $result['refresh_token'] = $this->getNewRefreshToken($accessToken); + $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->getIdTokenBuilder()->getIdToken($accessToken); + $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($accessToken); } } diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..75c6a04e6b36892634ff127c225729389a8f9aa2 --- /dev/null +++ b/classes/TokenBuilder.php @@ -0,0 +1,122 @@ +<?php + +namespace RAP; + +use \Firebase\JWT\JWT; + +class TokenBuilder { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function getIdToken(AccessTokenData $tokenData, string $nonce = null): string { + + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + + $payload = $this->createIdTokenPayloadArray($tokenData, $nonce); + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + + private function createIdTokenPayloadArray(AccessTokenData $tokenData, string $nonce = null) { + + $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); + + $payloadArr = array( + 'iss' => $this->locator->config->jwtIssuer, + 'sub' => strval($user->id), + 'iat' => intval($tokenData->creationTime), + 'exp' => intval($tokenData->expirationTime), + 'name' => $user->getCompleteName(), + 'aud' => $tokenData->clientId + ); + + if ($nonce !== null) { + $payloadArr['nonce'] = $nonce; + } + + if (in_array("email", $tokenData->scope)) { + $payloadArr['email'] = $user->getPrimaryEmail(); + } + if (in_array("profile", $tokenData->scope)) { + $payloadArr['given_name'] = $user->getName(); + $payloadArr['family_name'] = $user->getSurname(); + if ($user->getInstitution() !== null) { + $payloadArr['org'] = $user->getInstitution(); + } + } + + /*if ($tokenData->joinUser !== null) { + $payloadArr['alt_sub'] = strval($tokenData->joinUser); + }*/ + + return $payloadArr; + } + + public function getAccessToken(AccessTokenData $tokenData) { + + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + + $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); + + $payload = array( + 'iss' => $this->locator->config->jwtIssuer, + 'sub' => strval($user->id), + 'iat' => intval($tokenData->creationTime), + 'exp' => intval($tokenData->expirationTime), + 'aud' => $this->getAudience($tokenData) + ); + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + + private function getAudience(AccessTokenData $tokenData) { + + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($tokenData->clientId); + + $audiences = [$tokenData->clientId]; + error_log(json_encode($client->scopeAudienceMap)); + + foreach ($tokenData->scope as $scope) { + if (array_key_exists($scope, $client->scopeAudienceMap)) { + $audience = $client->scopeAudienceMap[$scope]; + if (!in_array($audience, $audiences)) { + array_push($audiences, $audience); + } + } + } + + if (count($audiences) === 1) { + // according to RFC 7519 audience can be a single value or an array + return $audiences[0]; + } + return $audiences; + } + + /** + * @param int $lifespan in hours + * @param string $audience target service + */ + public function generateNewToken(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), + 'iat' => $iat, + 'exp' => $exp, + 'aud' => $audience + ); + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + +} diff --git a/classes/UserHandler.php b/classes/UserHandler.php index 318f5230ade99769f1a87ecb28d9a8466e739484..86facfecee0e1e29f45bd79bdb057ef0b9f8b6fe 100644 --- a/classes/UserHandler.php +++ b/classes/UserHandler.php @@ -125,7 +125,7 @@ class UserHandler { $accessToken->expirationTime = $accessToken->creationTime + 100; $accessToken->scope = ['openid']; - return $this->locator->getIdTokenBuilder()->getIdToken($accessToken); + return $this->locator->getTokenBuilder()->getIdToken($accessToken); } } diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php index 34a25b0a040b1f7ae6c72c395d8c10b9a587b652..a7d6090c50aa72d1f008ba3aa650fabddea73184 100644 --- a/classes/datalayer/AccessTokenDAO.php +++ b/classes/datalayer/AccessTokenDAO.php @@ -9,11 +9,9 @@ interface AccessTokenDAO { * @param type $token login token * @param type $userId */ - function createAccessToken(AccessToken $accessToken): AccessToken; + function createTokenData(AccessTokenData $tokenData): AccessTokenData; - function retrieveAccessTokenFromCode(string $code): ?AccessToken; + function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData; - function getAccessToken(string $token): ?AccessToken; - - function deleteAccessToken(string $token): void; + function deleteTokenData(string $codeHash): void; } diff --git a/classes/datalayer/RefreshTokenDAO.php b/classes/datalayer/RefreshTokenDAO.php index e8196cbe7d583417945ef99b1a4c49808995382c..8124e96618bf56bb041006813d99fba55dd4bf5f 100644 --- a/classes/datalayer/RefreshTokenDAO.php +++ b/classes/datalayer/RefreshTokenDAO.php @@ -4,9 +4,9 @@ namespace RAP; interface RefreshTokenDAO { - function createRefreshToken(RefreshToken $refreshToken): RefreshToken; + function createRefreshTokenData(RefreshTokenData $refreshToken): RefreshTokenData; - function getRefreshToken(string $token): ?RefreshToken; + function getRefreshTokenData(string $tokenHash): ?RefreshTokenData; - function deleteRefreshToken(string $token): void; + function deleteRefreshTokenData(string $tokenHash): void; } diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php index 446be56f0d31c8f8872d82868c85add01e333c17..00b5954cb3602ed47bda60615f55160c58b7c437 100644 --- a/classes/datalayer/mysql/MySQLAccessTokenDAO.php +++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php @@ -8,44 +8,43 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { parent::__construct($locator); } - public function createAccessToken(AccessToken $accessToken): AccessToken { + public function createTokenData(AccessTokenData $tokenData): AccessTokenData { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)" - . " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)"); + $stmt = $dbh->prepare("INSERT INTO access_token (code_hash, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)" + . " VALUES(:code_hash, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)"); $scope = null; - if ($accessToken->scope !== null) { - $scope = join(' ', $accessToken->scope); + if ($tokenData->scope !== null) { + $scope = join(' ', $tokenData->scope); } $params = array( - ':token' => $accessToken->token, - ':code' => $accessToken->code, - ':user_id' => $accessToken->userId, - ':redirect_uri' => $accessToken->redirectUri, - ':client_id' => $accessToken->clientId, + ':code_hash' => $tokenData->codeHash, + ':user_id' => $tokenData->userId, + ':redirect_uri' => $tokenData->redirectUri, + ':client_id' => $tokenData->clientId, ':scope' => $scope, - ':creation_time' => $accessToken->creationTime, - ':expiration_time' => $accessToken->expirationTime + ':creation_time' => $tokenData->creationTime, + ':expiration_time' => $tokenData->expirationTime ); if ($stmt->execute($params)) { - return $accessToken; + return $tokenData; } else { error_log($stmt->errorInfo()[2]); throw new \Exception("SQL error while storing user token"); } } - public function retrieveAccessTokenFromCode(string $code): ?AccessToken { + public function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData { $dbh = $this->getDBHandler(); // Access token can be retrieved from code in 1 minute from the creation - $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope " - . " FROM access_token WHERE code = :code AND UNIX_TIMESTAMP() < (creation_time + 60)"); - $stmt->bindParam(':code', $code); + $stmt = $dbh->prepare("SELECT user_id, redirect_uri, client_id, creation_time, expiration_time, scope " + . " FROM access_token WHERE code_hash = :code_hash AND UNIX_TIMESTAMP() < (creation_time + 60)"); + $stmt->bindParam(':code_hash', $codeHash); $stmt->execute(); @@ -54,32 +53,12 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { return null; } - return $this->getAccessTokenFromRow($row); + return $this->getTokenDataFromRow($row); } - public function getAccessToken(string $token): ?AccessToken { + private function getTokenDataFromRow(array $row): AccessTokenData { - $dbh = $this->getDBHandler(); - - $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope " - . " FROM access_token WHERE token = :token"); - $stmt->bindParam(':token', $token); - - $stmt->execute(); - - $row = $stmt->fetch(); - if (!$row) { - return null; - } - - return $this->getAccessTokenFromRow($row); - } - - private function getAccessTokenFromRow(array $row): ?AccessToken { - - $token = new AccessToken(); - $token->token = $row['token']; - $token->code = $row['code']; + $token = new AccessTokenData(); $token->userId = $row['user_id']; $token->redirectUri = $row['redirect_uri']; $token->clientId = $row['client_id']; @@ -97,12 +76,13 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { return $token; } - public function deleteAccessToken($token): void { + function deleteTokenData(string $codeHash): void { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("DELETE FROM access_token WHERE token = :token"); - $stmt->bindParam(':token', $token); + $stmt = $dbh->prepare("DELETE FROM access_token WHERE code_hash = :code_hash"); + $stmt->bindParam(':code_hash', $codeHash); + $stmt->execute(); } diff --git a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php index ca23c39a28073951eb28b5d041a17caa83b62350..b63f94ba1d7b6f25bb40e2030b2e5aeba372dead 100644 --- a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php +++ b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php @@ -8,9 +8,24 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { parent::__construct($config); } - function getOAuth2Clients(): array { + public function getOAuth2Clients(): array { + $dbh = $this->getDBHandler(); + $clientsMap = $this->getClientsMap($dbh); + $this->loadAuthenticationMethods($dbh, $clientsMap); + $this->loadScopeAudienceMapping($dbh, $clientsMap); + + $clients = []; + foreach ($clientsMap as $id => $client) { + array_push($clients, $client); + } + + return $clients; + } + + 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"; $stmtClients = $dbh->prepare($queryClient); @@ -32,7 +47,11 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { $clientsMap[$client->id] = $client; } - // Load authentication methods info + return $clientsMap; + } + + private function loadAuthenticationMethods(PDO $dbh, array $clientsMap): void { + $queryAuthNMethods = "SELECT client_id, auth_method FROM oauth2_client_auth_methods"; $stmtAuthNMethods = $dbh->prepare($queryAuthNMethods); @@ -42,13 +61,24 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { $id = $row['client_id']; array_push($clientsMap[$id]->authMethods, $row['auth_method']); } + } - $clients = []; - foreach ($clientsMap as $id => $client) { - array_push($clients, $client); - } + private function loadScopeAudienceMapping(PDO $dbh, array $clientsMap): void { - return $clients; + $query = "SELECT client_id, scope, audience FROM oauth2_client_scope_audience_mapping"; + + $stmt = $dbh->prepare($query); + + foreach ($stmt->fetchAll() as $row) { + $id = $row['client_id']; + + if (array_key_exists($id, $clientsMap)) { + $client = $clientsMap[$id]; + $client->scopeAudienceMap[$row['scope']] = $row['audience']; + } + + array_push($clientsMap[$id]->authMethods, $row['auth_method']); + } } function createOAuth2Client(OAuth2Client $client): OAuth2Client { @@ -203,6 +233,16 @@ class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { array_push($client->authMethods, $row['auth_method']); } + // Load scope-audience mapping + $queryAudienceMapping = "SELECT scope, audience FROM oauth2_client_scope_audience_mapping WHERE client_id = :id"; + $stmtAudienceMapping = $dbh->prepare($queryAudienceMapping); + $stmtAudienceMapping->bindParam(':id', $client->id); + $stmtAudienceMapping->execute(); + + foreach ($stmtAudienceMapping->fetchAll() as $row) { + $client->scopeAudienceMap[$row['scope']] = $row['audience']; + } + return $client; } diff --git a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php index f316aad55fcb14f6bdaf9f77292fdb592fff0a41..a99864e98ea88f4466ff27a6732c7b42f8109d7d 100644 --- a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php +++ b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php @@ -8,42 +8,42 @@ class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO { parent::__construct($locator); } - function createRefreshToken(RefreshToken $refreshToken): RefreshToken { + function createRefreshTokenData(RefreshTokenData $refreshTokenData): RefreshTokenData { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("INSERT INTO refresh_token (token, user_id, client_id, scope, creation_time, expiration_time)" - . " VALUES(:token, :user_id, :client_id, :scope, :creation_time, :expiration_time)"); + $stmt = $dbh->prepare("INSERT INTO refresh_token (token_hash, user_id, client_id, scope, creation_time, expiration_time)" + . " VALUES(:token_hash, :user_id, :client_id, :scope, :creation_time, :expiration_time)"); $scope = null; - if ($refreshToken->scope !== null) { - $scope = join(' ', $refreshToken->scope); + if ($refreshTokenData->scope !== null) { + $scope = join(' ', $refreshTokenData->scope); } $params = array( - ':token' => $refreshToken->token, - ':user_id' => $refreshToken->userId, - ':client_id' => $refreshToken->clientId, + ':token_hash' => $refreshTokenData->tokenHash, + ':user_id' => $refreshTokenData->userId, + ':client_id' => $refreshTokenData->clientId, ':scope' => $scope, - ':creation_time' => $refreshToken->creationTime, - ':expiration_time' => $refreshToken->expirationTime + ':creation_time' => $refreshTokenData->creationTime, + ':expiration_time' => $refreshTokenData->expirationTime ); if ($stmt->execute($params)) { - return $refreshToken; + return $refreshTokenData; } else { error_log($stmt->errorInfo()[2]); throw new \Exception("SQL error while storing user token"); } } - function getRefreshToken(string $tokenValue): ?RefreshToken { + function getRefreshTokenData(string $tokenHash): ?RefreshTokenData { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("SELECT token, user_id, client_id, creation_time, expiration_time, scope " - . " FROM refresh_token WHERE token = :token"); + $stmt = $dbh->prepare("SELECT user_id, client_id, creation_time, expiration_time, scope " + . " FROM refresh_token WHERE token_hash = :token_hash"); - $stmt->bindParam(':token', $tokenValue); + $stmt->bindParam(':token', $tokenHash); $stmt->execute(); @@ -52,8 +52,8 @@ class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO { return null; } - $token = new RefreshToken(); - $token->token = $row['token']; + $token = new RefreshTokenData(); + $token->tokenHash = $tokenHash; $token->userId = $row['user_id']; $token->clientId = $row['client_id']; $token->creationTime = $row['creation_time']; @@ -70,12 +70,12 @@ class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO { return $token; } - function deleteRefreshToken(string $token): void { + function deleteRefreshTokenData(string $tokenHash): void { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token = :token"); - $stmt->bindParam(':token', $token); + $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token_hash = :token_hash"); + $stmt->bindParam(':token_hash', $tokenHash); $stmt->execute(); } diff --git a/classes/login/LoginHandler.php b/classes/login/LoginHandler.php index 198e140aa78b1cc55f8155097416ec6dd856a9a5..fc2531d186007782c802be6f7075120403c48551 100644 --- a/classes/login/LoginHandler.php +++ b/classes/login/LoginHandler.php @@ -73,7 +73,7 @@ class LoginHandler { $session = $this->locator->getSession(); $this->locator->getAuditLogger()->info("LOGIN," . $this->identityType . "," . $user->id); - if ($session->getOAuth2Data() !== null) { + if ($session->getOAuth2RequestData() !== null) { $session->setUser($user); $redirectUrl = $this->locator->getOAuth2RequestHandler()->getRedirectResponseUrl(); session_destroy(); diff --git a/classes/model/AccessToken.php b/classes/model/AccessTokenData.php similarity index 60% rename from classes/model/AccessToken.php rename to classes/model/AccessTokenData.php index 43527ac2960aa25c1d854f8d9350f48cd8ff7f3d..3826e22a14a040dceb6464a8b2969f58c891e9e8 100644 --- a/classes/model/AccessToken.php +++ b/classes/model/AccessTokenData.php @@ -2,24 +2,28 @@ namespace RAP; -class AccessToken { +/** + * Data related to access tokens stored into the database. This object is + * retrieved from the database (using the code or the refresh token hashes) and + * it is used to generate OAuth2 tokens. + */ +class AccessTokenData { private const TOKEN_LIFESPAN = 3600; - public function __construct() { - $this->creationTime = time(); - $this->expirationTime = $this->creationTime + AccessToken::TOKEN_LIFESPAN; - } - - public $token; - public $code; + public $id; + public $codeHash; public $userId; public $creationTime; public $expirationTime; public $redirectUri; public $clientId; public $scope; - public $joinUser; + + public function __construct() { + $this->creationTime = time(); + $this->expirationTime = $this->creationTime + AccessTokenData::TOKEN_LIFESPAN; + } public function isExpired(): bool { return $this->expirationTime < time(); diff --git a/classes/model/OAuth2Client.php b/classes/model/OAuth2Client.php index 254fefed6efde4f8d78f921a79ae5257a59323ae..acad698f384be71d3ae51df48f1d7389e514dd99 100644 --- a/classes/model/OAuth2Client.php +++ b/classes/model/OAuth2Client.php @@ -36,6 +36,7 @@ class OAuth2Client extends RAPClient { public $scope; public $homePage; public $showInHome; + public $scopeAudienceMap = []; public function getIconBasePath() { return 'client-icons/'; diff --git a/classes/model/OAuth2Data.php b/classes/model/OAuth2RequestData.php similarity index 65% rename from classes/model/OAuth2Data.php rename to classes/model/OAuth2RequestData.php index 9716301cddf32d11ca4664d931e88056df1ce1d4..bac7778be8a6f9e72bba1c5ed1f071535ed20c6f 100644 --- a/classes/model/OAuth2Data.php +++ b/classes/model/OAuth2RequestData.php @@ -2,7 +2,10 @@ namespace RAP; -class OAuth2Data { +/** + * Data model for OAuth2 request. + */ +class OAuth2RequestData { public $clientId; public $redirectUrl; diff --git a/classes/model/RefreshToken.php b/classes/model/RefreshTokenData.php similarity index 84% rename from classes/model/RefreshToken.php rename to classes/model/RefreshTokenData.php index a0525294da66f0f59b80aa480eac558f4142543c..22e18d18f30a6658f66ce07b2d18c36b86dfd685 100644 --- a/classes/model/RefreshToken.php +++ b/classes/model/RefreshTokenData.php @@ -2,20 +2,19 @@ namespace RAP; -class RefreshToken { +class RefreshTokenData { private const TOKEN_LIFESPAN = 2 * 3600; public function __construct() { $this->creationTime = time(); - $this->expirationTime = $this->creationTime + RefreshToken::TOKEN_LIFESPAN; + $this->expirationTime = $this->creationTime + RefreshTokenData::TOKEN_LIFESPAN; } - public $token; + public $tokenHash; public $userId; public $creationTime; public $expirationTime; - public $expired; public $clientId; public $scope; diff --git a/classes/model/SessionData.php b/classes/model/SessionData.php index 1473ce824695b2bc5e6ec34f6c7f28977bae4a1c..a560b6a03214691bcf9e194714372e5c0889f29c 100644 --- a/classes/model/SessionData.php +++ b/classes/model/SessionData.php @@ -34,7 +34,7 @@ class SessionData { private $user; private $x509DataToRegister; - private $oauth2Data; + private $oauth2RequestData; private $action; public function setUser(?User $user): void { @@ -68,13 +68,13 @@ class SessionData { return $this->x509DataToRegister; } - public function setOAuth2Data(?OAuth2Data $oauth2Data): void { - $this->oauth2Data = $oauth2Data; + public function setOAuth2RequestData(?OAuth2RequestData $oauth2RequestData): void { + $this->oauth2RequestData = $oauth2RequestData; $this->save(); } - public function getOAuth2Data(): ?OAuth2Data { - return $this->oauth2Data; + public function getOAuth2RequestData(): ?OAuth2RequestData { + return $this->oauth2RequestData; } public function setAction(?string $action): void { diff --git a/exec/join.php b/exec/join.php index 8a3689f2d5ec5d46e6238d7ef35c7514cdebff28..0f42b1bacb9cdfb64fa262951dde10b1c6509db5 100644 --- a/exec/join.php +++ b/exec/join.php @@ -12,7 +12,7 @@ include '../include/init.php'; $dao = $locator->getUserDAO(); $handler = $locator->getUserHandler(); -$tokenBuilder = $locator->getIdTokenBuilder(); +$tokenBuilder = $locator->getTokenBuilder(); $user1 = $dao->findUserById((int) $argv[1]); if($user1 === null) { diff --git a/include/front-controller.php b/include/front-controller.php index 4df876eabd279b985dca7f5487f90e3936332578..a3010bc5fc3aff6a7c258c54cd1e90ee8c1aa286 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -30,8 +30,8 @@ Flight::route('/', function() { $locator->getSession()->setAction($action); switch ($action) { - case "oaut2client": - $clientId = $locator->getSession()->getOAuth2Data()->clientId; + case "oauth2client": + $clientId = $locator->getSession()->getOAuth2RequestData()->clientId; $client = $locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId); $authPageModel = new \RAP\AuthPageModel($locator, $client); renderMainPage($authPageModel); @@ -49,11 +49,11 @@ Flight::route('/', function() { $authPageModel = new \RAP\AuthPageModel($locator, $client); renderMainPage($authPageModel); break; - case "admin": + /*case "admin": $client = new \RAP\InternalClient('admin'); $authPageModel = new \RAP\AuthPageModel($locator, $client); renderMainPage($authPageModel); - break; + break;*/ default: session_destroy(); $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients(); @@ -88,7 +88,7 @@ Flight::route('GET /auth/oauth2/authorize', function() { $requestHandler = new \RAP\OAuth2RequestHandler($locator); $requestHandler->handleAuthorizeRequest($params); - Flight::redirect('/?action=oaut2client'); + Flight::redirect('/?action=oauth2client'); }); Flight::route('POST /auth/oauth2/token', function() { @@ -391,7 +391,7 @@ Flight::route('POST /token-issuer', function () { throw new \RAP\BadRequestException("Missing form parameter"); } - $tokenBuilder = $locator->getIdTokenBuilder(); + $tokenBuilder = $locator->getTokenBuilder(); $token = $tokenBuilder->generateNewToken($postData['lifespan'], $postData['audit']); header('Content-Type: text/plain'); diff --git a/sql/setup-database.sql b/sql/setup-database.sql index 11e1b68b9248fb63e9bcd247306ce80795e7d83c..2d46eaa0ca2af1912ff5edee9eabc30bfee173d4 100644 --- a/sql/setup-database.sql +++ b/sql/setup-database.sql @@ -16,7 +16,15 @@ CREATE TABLE `oauth2_client_auth_methods` ( `auth_method` varchar(50) NOT NULL, PRIMARY KEY (`client_id`, `auth_method`), FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `oauth2_client_scope_audience_mapping` ( + `client_id` int NOT NULL, + `scope` varchar(255) NOT NULL, + `audience` text NOT NULL, + PRIMARY KEY (`client_id`, `scope`), + FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, @@ -45,9 +53,8 @@ SET FOREIGN_KEY_CHECKS=1; CREATE TABLE `access_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, - `token` text NOT NULL, - `user_id` text NOT NULL, - `code` text DEFAULT NULL, + `user_id` varchar(255) NOT NULL, + `code_hash` varchar(255) DEFAULT NULL, `creation_time` bigint(20) NOT NULL, `expiration_time` bigint(20) DEFAULT NULL, `redirect_uri` text DEFAULT NULL, @@ -58,7 +65,7 @@ CREATE TABLE `access_token` ( CREATE TABLE `refresh_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, - `token` text NOT NULL, + `token_hash` varchar(255) NOT NULL, `user_id` text NOT NULL, `creation_time` BIGINT NOT NULL, `expiration_time` BIGINT NOT NULL, @@ -74,13 +81,13 @@ CREATE TABLE `rsa_keypairs` ( `alg` varchar(255), `creation_time` BIGINT NOT NULL, PRIMARY KEY (`id`) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `rap_permissions` ( `user_id` bigint NOT NULL, `permission` varchar(255) NOT NULL, PRIMARY KEY (`user_id`, `permission`) -); +) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE EVENT login_tokens_cleanup ON SCHEDULE diff --git a/tests/IdTokenBuilderTest.php b/tests/IdTokenBuilderTest.php index 67a2596bf4f98a63bf97d4ebff53f5d5efc6a151..2328f2c7bcc72080a617297fb20d01fea8d95c70 100644 --- a/tests/IdTokenBuilderTest.php +++ b/tests/IdTokenBuilderTest.php @@ -3,7 +3,7 @@ use PHPUnit\Framework\TestCase; use \Firebase\JWT\JWT; -final class IdTokenBuilderTest extends TestCase { +final class TokenBuilderTest extends TestCase { public function testJWTCreation() { @@ -38,7 +38,7 @@ final class IdTokenBuilderTest extends TestCase { $accessToken->scope = ["email", "profile"]; $accessToken->userId = "user_id"; - $tokenBuilder = new \RAP\IdTokenBuilder($locatorStub); + $tokenBuilder = new \RAP\TokenBuilder($locatorStub); $jwt = $tokenBuilder->getIdToken($accessToken); $this->assertNotNull($jwt); diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php index 2efed06a18487180934599b4799433eaf06ab3cf..e24171a449a595b89b83a976c6197098ecb7d90e 100644 --- a/tests/OAuth2RequestHandlerTest.php +++ b/tests/OAuth2RequestHandlerTest.php @@ -63,7 +63,7 @@ final class OAuth2RequestHandlerTest extends TestCase { $locatorStub->method('getSession')->willReturn($sessionStub); $sessionStub->expects($this->once()) - ->method('setOAuth2Data')->with($this->anything()); + ->method('setOAuth2RequestData')->with($this->anything()); $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); $requestHandler->handleAuthorizeRequest($params); @@ -85,13 +85,13 @@ final class OAuth2RequestHandlerTest extends TestCase { $userDaoStub = $this->createMock(\RAP\UserDAO::class); $userDaoStub->method('findUserById')->willReturn($user); - $idTokenBuilderStub = $this->createMock(\RAP\IdTokenBuilder::class); - $idTokenBuilderStub->method('getIdToken')->willReturn('id-token'); + $tokenBuilderStub = $this->createMock(\RAP\TokenBuilder::class); + $tokenBuilderStub->method('getIdToken')->willReturn('id-token'); $locatorStub = $this->createMock(\RAP\Locator::class); $locatorStub->method('getAccessTokenDAO')->willReturn($tokenDaoStub); $locatorStub->method('getUserDAO')->willReturn($userDaoStub); - $locatorStub->method('getIdTokenBuilder')->willReturn($idTokenBuilderStub); + $locatorStub->method('getIdTokenBuilder')->willReturn($tokenBuilderStub); $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);