diff --git a/classes/IdTokenBuilder.php b/classes/IdTokenBuilder.php index a198208ae65a44d5abd44645b20340b909f91ee0..e5eb200b5b58f28db6edf9df1967b12f25876318 100644 --- a/classes/IdTokenBuilder.php +++ b/classes/IdTokenBuilder.php @@ -12,7 +12,7 @@ class IdTokenBuilder { $this->locator = $locator; } - public function getIdToken(AccessToken $accessToken, $nonce = null): string { + public function getIdToken(AccessToken $accessToken, string $nonce = null): string { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); @@ -21,15 +21,15 @@ class IdTokenBuilder { return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); } - private function createPayloadArray(AccessToken $accessToken, $nonce = null) { + private function createPayloadArray(AccessToken $accessToken, string $nonce = null) { $user = $this->locator->getUserDAO()->findUserById($accessToken->userId); $payloadArr = array( 'iss' => $this->locator->config->jwtIssuer, 'sub' => $user->id, - 'iat' => time(), - 'exp' => time() + 3600, + 'iat' => $accessToken->creationTime, + 'exp' => $accessToken->expirationTime, 'name' => $user->getCompleteName(), 'aud' => $accessToken->clientId ); diff --git a/classes/Locator.php b/classes/Locator.php index 826afd3694968d5b21776ed023a7c92d8d329808..f2ea9da0c6346e197508c0901f0dd017e43f4916 100644 --- a/classes/Locator.php +++ b/classes/Locator.php @@ -72,6 +72,16 @@ class Locator { } } + public function getRefreshTokenDAO(): RefreshTokenDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLRefreshTokenDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + public function getCallbackHandler(): CallbackHandler { return new CallbackHandler($this); } diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 44175c438bf76b0347beb58d1773b1817348541f..895752532af055fb221cd85289ef9edd7684fcca 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -90,7 +90,16 @@ class OAuth2RequestHandler { public function handleAccessTokenRequest($params): array { - $this->validateAccessTokenRequest($params); + if ($params['code'] === null) { + throw new BadRequestException("code id is required"); + } + + if ($params['redirect_uri'] === null) { + throw new BadRequestException("Redirect URI is required"); + } + + // Note: theorically the standard wants also the client_id here, + // however some clients don't send it $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']); @@ -102,10 +111,73 @@ class OAuth2RequestHandler { throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']); } + return $this->getAccessTokenResponse($accessToken); + } + + public function handleRefreshTokenRequest($params): array { + + if ($params['refresh_token'] === null) { + throw new BadRequestException("refresh_token is required"); + } + + $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshToken($params['refresh_token']); + + if ($refreshToken === null || $refreshToken->isExpired()) { + throw new UnauthorizedException("Invalid refresh token"); + } + + $scope = $this->getScope($params, $refreshToken); + + // Generating a new access token + $accessToken = new AccessToken(); + $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); + $accessToken->clientId = $refreshToken->clientId; + $accessToken->userId = $refreshToken->userId; + $accessToken->scope = $scope; + + $accessToken = $this->locator->getAccessTokenDAO()->createAccessToken($accessToken); + + return $this->getAccessTokenResponse($accessToken); + } + + /** + * We can request a new access token with a scope that is a subset (or the + * same set) of the scope defined for the refresh token. + */ + private function getScope(array $params, RefreshToken $refreshToken): ?array { + + $scope = $refreshToken->scope; + + if ($params['scope'] !== null) { + + $newScopeValues = explode(' ', $params['scope']); + + foreach ($newScopeValues as $newScopeValue) { + $found = false; + foreach ($scope as $oldScopeValue) { + if ($oldScopeValue === $newScopeValue) { + $found = true; + break; + } + } + if (!$found) { + throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token"); + } + } + + $scope = $newScopeValues; + } + + return $scope; + } + + private function getAccessTokenResponse(AccessToken $accessToken) { + $result = []; $result['access_token'] = $accessToken->token; $result['token_type'] = 'Bearer'; $result['expires_in'] = $accessToken->expirationTime - time(); + $result['refresh_token'] = $this->getNewRefreshToken($accessToken); if ($accessToken->scope !== null && in_array('openid', $accessToken->scope)) { $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken); @@ -114,24 +186,17 @@ class OAuth2RequestHandler { return $result; } - private function validateAccessTokenRequest($params) { + private function getNewRefreshToken(AccessToken $accessToken): string { - if ($params['grant_type'] === null) { - throw new BadRequestException("grant_type is required"); - } else if ($params['grant_type'] !== 'authorization_code') { - throw new BadRequestException("grant_type must be authorization_code"); - } - - if ($params['code'] === null) { - throw new BadRequestException("code id is required"); - } + $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; - if ($params['redirect_uri'] === null) { - throw new BadRequestException("Redirect URI is required"); - } + $this->locator->getRefreshTokenDAO()->createRefreshToken($refreshToken); - // Note: theorically the standard wants also the client_id here, - // however some clients don't send it + return $refreshToken->token; } public function handleCheckTokenRequest($token): array { @@ -171,7 +236,7 @@ class OAuth2RequestHandler { $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($bearer_token); if ($accessToken === null) { $this->attemptJWTTokenValidation($bearer_token); - } else if ($accessToken->expired) { + } else if ($accessToken->isExpired()) { throw new UnauthorizedException("Access token is expired"); } } diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php index 9525d1c77a41984120fb2632fbe53139f8aa16a3..34a25b0a040b1f7ae6c72c395d8c10b9a587b652 100644 --- a/classes/datalayer/AccessTokenDAO.php +++ b/classes/datalayer/AccessTokenDAO.php @@ -15,11 +15,5 @@ interface AccessTokenDAO { function getAccessToken(string $token): ?AccessToken; - /** - * Delete an access token from the database. This happens when the caller - * application has received the token and used it for retrieving user - * information from the token using the RAP REST web service. - * @param type $token login token - */ function deleteAccessToken(string $token): void; } diff --git a/classes/datalayer/RefreshTokenDAO.php b/classes/datalayer/RefreshTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..e8196cbe7d583417945ef99b1a4c49808995382c --- /dev/null +++ b/classes/datalayer/RefreshTokenDAO.php @@ -0,0 +1,12 @@ +<?php + +namespace RAP; + +interface RefreshTokenDAO { + + function createRefreshToken(RefreshToken $refreshToken): RefreshToken; + + function getRefreshToken(string $token): ?RefreshToken; + + function deleteRefreshToken(string $token): void; +} diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php index 28c4fad2bdcaac49d065472a3db20848cd05eba2..446be56f0d31c8f8872d82868c85add01e333c17 100644 --- a/classes/datalayer/mysql/MySQLAccessTokenDAO.php +++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php @@ -11,9 +11,8 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { public function createAccessToken(AccessToken $accessToken): AccessToken { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, expiration_time)" - . " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, " - . " UNIX_TIMESTAMP(TIMESTAMPADD(HOUR, 1, CURRENT_TIMESTAMP)))"); + $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)"); $scope = null; if ($accessToken->scope !== null) { @@ -26,7 +25,9 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { ':user_id' => $accessToken->userId, ':redirect_uri' => $accessToken->redirectUri, ':client_id' => $accessToken->clientId, - ':scope' => $scope + ':scope' => $scope, + ':creation_time' => $accessToken->creationTime, + ':expiration_time' => $accessToken->expirationTime ); if ($stmt->execute($params)) { @@ -42,8 +43,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { $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," - . " (expiration_time < UNIX_TIMESTAMP()) AS expired " + $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); @@ -61,8 +61,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope," - . " (expiration_time < UNIX_TIMESTAMP()) AS expired " + $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); @@ -86,7 +85,6 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { $token->clientId = $row['client_id']; $token->creationTime = $row['creation_time']; $token->expirationTime = $row['expiration_time']; - $token->expired = $row['expired'] === "1"; $scope = null; if (isset($row['scope'])) { diff --git a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..f316aad55fcb14f6bdaf9f77292fdb592fff0a41 --- /dev/null +++ b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php @@ -0,0 +1,82 @@ +<?php + +namespace RAP; + +class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO { + + public function __construct(Locator $locator) { + parent::__construct($locator); + } + + function createRefreshToken(RefreshToken $refreshToken): RefreshToken { + + $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)"); + + $scope = null; + if ($refreshToken->scope !== null) { + $scope = join(' ', $refreshToken->scope); + } + + $params = array( + ':token' => $refreshToken->token, + ':user_id' => $refreshToken->userId, + ':client_id' => $refreshToken->clientId, + ':scope' => $scope, + ':creation_time' => $refreshToken->creationTime, + ':expiration_time' => $refreshToken->expirationTime + ); + + if ($stmt->execute($params)) { + return $refreshToken; + } else { + error_log($stmt->errorInfo()[2]); + throw new \Exception("SQL error while storing user token"); + } + } + + function getRefreshToken(string $tokenValue): ?RefreshToken { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("SELECT token, user_id, client_id, creation_time, expiration_time, scope " + . " FROM refresh_token WHERE token = :token"); + + $stmt->bindParam(':token', $tokenValue); + + $stmt->execute(); + + $row = $stmt->fetch(); + if (!$row) { + return null; + } + + $token = new RefreshToken(); + $token->token = $row['token']; + $token->userId = $row['user_id']; + $token->clientId = $row['client_id']; + $token->creationTime = $row['creation_time']; + $token->expirationTime = $row['expiration_time']; + + $scope = null; + if (isset($row['scope'])) { + $scope = $row['scope']; + } + if ($scope !== null && $scope !== '') { + $token->scope = explode(' ', $scope); + } + + return $token; + } + + function deleteRefreshToken(string $token): void { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token = :token"); + $stmt->bindParam(':token', $token); + $stmt->execute(); + } + +} diff --git a/classes/model/AccessToken.php b/classes/model/AccessToken.php index 746848bd8aa793ddb310cbcb71b0d94584d4c404..9e6da47103792e9c8028dd853daa5caee0011b3a 100644 --- a/classes/model/AccessToken.php +++ b/classes/model/AccessToken.php @@ -4,14 +4,24 @@ namespace RAP; class AccessToken { + private const TOKEN_LIFESPAN = 3600; + + public function __construct() { + $this->creationTime = time(); + $this->expirationTime = $this->creationTime + AccessToken::TOKEN_LIFESPAN; + } + public $token; public $code; public $userId; public $creationTime; public $expirationTime; - public $expired; public $redirectUri; public $clientId; public $scope; + public function isExpired(): bool { + return $this->expirationTime < time(); + } + } diff --git a/classes/model/RefreshToken.php b/classes/model/RefreshToken.php new file mode 100644 index 0000000000000000000000000000000000000000..a0525294da66f0f59b80aa480eac558f4142543c --- /dev/null +++ b/classes/model/RefreshToken.php @@ -0,0 +1,26 @@ +<?php + +namespace RAP; + +class RefreshToken { + + private const TOKEN_LIFESPAN = 2 * 3600; + + public function __construct() { + $this->creationTime = time(); + $this->expirationTime = $this->creationTime + RefreshToken::TOKEN_LIFESPAN; + } + + public $token; + public $userId; + public $creationTime; + public $expirationTime; + public $expired; + public $clientId; + public $scope; + + public function isExpired(): bool { + return $this->expirationTime < time(); + } + +} diff --git a/exec/.htaccess b/exec/.htaccess new file mode 100644 index 0000000000000000000000000000000000000000..0819c675015a39eeb64ced3cc76d4cb17c3e37f8 --- /dev/null +++ b/exec/.htaccess @@ -0,0 +1,4 @@ +# This directory is for programmatical execution of php scripts +# e.g. generating new keypairs from a cron job + +Deny from all diff --git a/exec/generate-keypair.php b/exec/generate-keypair.php new file mode 100644 index 0000000000000000000000000000000000000000..beb7663f9979d5d7f1475d15f7a45e76bdf0c7e7 --- /dev/null +++ b/exec/generate-keypair.php @@ -0,0 +1,10 @@ +<?php + +chdir(dirname(__FILE__)); + +include '../include/init.php'; + +$handler = new \RAP\JWKSHandler($locator); +$handler->generateKeyPair(); + +echo "OK"; \ No newline at end of file diff --git a/include/front-controller.php b/include/front-controller.php index fc172e27eebb57d8445a0ad556654a4814605924..f90840e403c50a5f31e19e7b387e2c82e8446763 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -98,11 +98,27 @@ Flight::route('POST /auth/oauth2/token', function() { $params = [ "grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING), "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING), - "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING) + "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING), + "refresh_token" => filter_input(INPUT_POST, "refresh_token", FILTER_SANITIZE_STRING), + "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING) ]; + if ($params['grant_type'] === null) { + throw new \RAP\BadRequestException("grant_type is required"); + } + $requestHandler = new \RAP\OAuth2RequestHandler($locator); - $token = $requestHandler->handleAccessTokenRequest($params); + + switch ($params['grant_type']) { + case "authorization_code": + $token = $requestHandler->handleAccessTokenRequest($params); + break; + case "refresh_token": + $token = $requestHandler->handleRefreshTokenRequest($params); + break; + default: + throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']); + } Flight::json($token); }); diff --git a/sql/setup-database.sql b/sql/setup-database.sql index af10551eb6282a8ef44a99598693c718b373d2f2..937698883d0e904e610233befa04659d62b6007e 100644 --- a/sql/setup-database.sql +++ b/sql/setup-database.sql @@ -48,11 +48,22 @@ CREATE TABLE `access_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `token` text NOT NULL, `user_id` text NOT NULL, - `code` text NOT NULL, + `code` text, `creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(), - `expiration_time` BIGINT, + `expiration_time` BIGINT NOT NULL, `redirect_uri` text, - `client_id` varchar(255), + `client_id` varchar(255) NOT NULL, + `scope` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `refresh_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `token` text NOT NULL, + `user_id` text NOT NULL, + `creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(), + `expiration_time` BIGINT NOT NULL, + `client_id` varchar(255) NOT NULL, `scope` text, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php index ec58c2fd8db410c554fcbd0e65b43a38e7382970..2efed06a18487180934599b4799433eaf06ab3cf 100644 --- a/tests/OAuth2RequestHandlerTest.php +++ b/tests/OAuth2RequestHandlerTest.php @@ -47,6 +47,7 @@ final class OAuth2RequestHandlerTest extends TestCase { "redirect_uri" => "redirect_uri", "state" => "state", "alg" => null, + "nonce" => null, "scope" => "email%20profile" ];