Skip to content
Snippets Groups Projects
Commit 9d4ad05d authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Changes for JWT tokens

parent 47a4929d
No related branches found
No related tags found
No related merge requests found
......@@ -6,3 +6,5 @@ vendor/
client-icons/
/nbproject/
*.pem
/build/
.phpunit.result.cache
......@@ -115,6 +115,12 @@ Create the logs directory and assign ownership to the Apache user (usually www-d
mkdir logs
sudo chown www-data logs
### Run Unit Tests and build code coverage report
(XDebug or another code coverage driver needs to be installed; e.g. `sudo apt install php-xdebug`)
./vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-html build/coverage-report tests/
## Additional information and developer guide
See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home
......@@ -12,22 +12,13 @@ class IdTokenBuilder {
$this->locator = $locator;
}
public function getIdToken(AccessToken $accessToken, string $alg): string {
$head = array("alg" => $alg, "typ" => "JWT");
$header = base64_encode(json_encode($head));
public function getIdToken(AccessToken $accessToken): string {
$keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
$payloadArr = $this->createPayloadArray($accessToken);
$payloadArr['kid'] = $keyPair->keyId;
$payload = base64_encode(json_encode($payloadArr));
$payload = $this->createPayloadArray($accessToken);
$token_value = $header . "." . $payload;
return JWT::encode($token_value, $keyPair->privateKey, $alg);
return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
}
private function createPayloadArray(AccessToken $accessToken) {
......@@ -48,8 +39,10 @@ class IdTokenBuilder {
if (in_array("profile", $accessToken->scope)) {
$payloadArr['given_name'] = $user->getName();
$payloadArr['family_name'] = $user->getSurname();
if ($user->getInstitution() !== null) {
$payloadArr['org'] = $user->getInstitution();
}
}
return $payloadArr;
}
......
......@@ -20,7 +20,7 @@ class JWKSHandler {
$rsa = new RSA();
$rsa->setPrivateKeyFormat(RSA::PRIVATE_FORMAT_PKCS1);
$rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS1);
$rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS8);
$result = $rsa->createKey();
$keyPair = new RSAKeyPair();
......@@ -41,25 +41,37 @@ class JWKSHandler {
$keyPairs = $dao->getRSAKeyPairs();
$jwks = [];
$keys = [];
foreach ($keyPairs as $keyPair) {
$publicKey = str_replace("\n", "", $keyPair->publicKey);
$publicKey = str_replace("\r", "", $publicKey);
$publicKey = str_replace('-----BEGIN RSA PUBLIC KEY-----', '', $publicKey);
$publicKey = str_replace('-----END RSA PUBLIC KEY-----', '', $publicKey);
$rsa = new RSA();
$rsa->loadKey($keyPair->publicKey);
$rsa->setPublicKey();
$publicKeyXML = $rsa->getPublicKey(RSA::PUBLIC_FORMAT_XML);
$rsaModulus = $this->getTagContent($publicKeyXML, "Modulus");
$rsaExponent = $this->getTagContent($publicKeyXML, "Exponent");
$jwk = [];
$jwk['kty'] = "RSA";
$jwk['kid'] = $keyPair->id;
$jwk['kid'] = $keyPair->keyId;
$jwk['use'] = "sig";
$jwk['n'] = $publicKey;
$jwk['e'] = "AQAB";
$jwk['n'] = $rsaModulus;
$jwk['e'] = $rsaExponent;
array_push($keys, $jwk);
}
array_push($jwks, $jwk);
return [
"keys" => $keys
];
}
return $jwks;
private function getTagContent(string $publicKeyXML, string $tagname): string {
$matches = [];
$pattern = "#<\s*?$tagname\b[^>]*>(.*?)</$tagname\b[^>]*>#s";
preg_match($pattern, $publicKeyXML, $matches);
return $matches[1];
}
}
......@@ -10,44 +10,31 @@ class OAuth2RequestHandler {
$this->locator = $locator;
}
public function handleAuthorizeRequest() {
public function handleAuthorizeRequest($params) {
if (!isset($_REQUEST['client_id'])) {
if ($params['client_id'] === null) {
throw new BadRequestException("Client id is required");
}
if (!isset($_REQUEST['redirect_uri'])) {
if ($params['redirect_uri'] === null) {
throw new BadRequestException("Redirect URI is required");
}
$clientId = $_REQUEST['client_id'];
$redirectUrl = $_REQUEST['redirect_uri'];
$client = $this->locator->getDAO()->getOAuth2ClientByClientId($clientId);
$client = $this->locator->getDAO()->getOAuth2ClientByClientId($params['client_id']);
if ($client === null) {
throw new BadRequestException("Invalid client id: " . $clientId);
throw new BadRequestException("Invalid client id: " . $params['client_id']);
}
if ($client->redirectUrl !== $redirectUrl) {
throw new BadRequestException("Invalid client redirect URI: " . $redirectUrl);
if ($client->redirectUrl !== $params['redirect_uri']) {
throw new BadRequestException("Invalid client redirect URI: " . $params['redirect_uri']);
}
$alg;
if (isset($_REQUEST['alg'])) {
$alg = $_REQUEST['alg'];
} else {
$alg = $params['alg'];
if ($alg === null) {
$alg = "RS256";
}
if (isset($_GET['code'])) {
} else {
$this->executeStateFlow($client);
}
}
private function executeStateFlow(OAuth2Client $client) {
if (!isset($_REQUEST['state'])) {
$state = $params['state'];
if ($state === null) {
throw new BadRequestException("State is required");
}
......@@ -55,7 +42,12 @@ class OAuth2RequestHandler {
$oauth2Data = new \RAP\OAuth2Data();
$oauth2Data->clientId = $client->client;
$oauth2Data->redirectUrl = $client->redirectUrl;
$oauth2Data->state = $_REQUEST['state'];
$oauth2Data->state = $state;
$scope = $params['scope'];
if ($scope !== null) {
$oauth2Data->scope = explode(' ', $scope);
}
$session = $this->locator->getSession();
$session->setOAuth2Data($oauth2Data);
......@@ -71,7 +63,7 @@ class OAuth2RequestHandler {
$accessToken->userId = $session->user->id;
$accessToken->clientId = $session->getOAuth2Data()->clientId;
$accessToken->redirectUri = $session->getOAuth2Data()->redirectUrl;
//$accessToken->scope =
$accessToken->scope = $session->getOAuth2Data()->scope;
$this->locator->getAccessTokenDAO()->createAccessToken($accessToken);
......@@ -83,46 +75,48 @@ class OAuth2RequestHandler {
return $redirectUrl;
}
public function handleAccessTokenRequest(): array {
public function handleAccessTokenRequest($params): array {
$this->validateAccessTokenRequest();
$this->validateAccessTokenRequest($params);
$code = filter_input(INPUT_POST, 'code', FILTER_SANITIZE_STRING);
$accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($code);
$accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']);
if ($accessToken === null) {
throw new BadRequestException("No token for given code");
}
$this->validateParametersMatching();
if ($accessToken->redirectUri !== $params['redirect_uri']) {
throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']);
}
$token = [];
$token['access_token'] = $accessToken->token;
//$token['access_token'] = $accessToken->token;
$token['token_type'] = 'bearer';
$token['expires_in'] = 300;
error_log($accessToken->creationTime);
error_log($accessToken->expirationTime);
$token['expires_in'] = $this->getExpiresIn($accessToken);
if ($accessToken->scope !== null) {
$token['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken->userId, 'RS256');
$token['access_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
//$token['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
} else {
$token['access_token'] = $accessToken->token;
}
return $token;
}
private function validateAccessTokenRequest() {
private function validateAccessTokenRequest($params) {
if (!isset($_POST['grant_type'])) {
throw new BadRequestException("Client id is required");
} else if ($_POST['grant_type'] !== 'authorization_code') {
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 (!isset($_POST['code'])) {
throw new BadRequestException("Client id is required");
if ($params['code'] === null) {
throw new BadRequestException("code id is required");
}
if (!isset($_POST['redirect_uri'])) {
if ($params['redirect_uri'] === null) {
throw new BadRequestException("Redirect URI is required");
}
......@@ -130,27 +124,32 @@ class OAuth2RequestHandler {
// however some clients don't send it
}
private function validateParametersMatching() {
}
public function handleCheckTokenRequest(): array {
public function handleCheckTokenRequest($token): array {
if (!isset($_POST['token'])) {
throw new BadRequestException("Access token id is required");
}
$accessToken = filter_input(INPUT_POST, 'token', FILTER_SANITIZE_STRING);
//if($accessToken)
$accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($token);
$user = $this->locator->getDAO()->findUserById($accessToken->userId);
$result = [];
$result['exp'] = 3600;
$result['user_name'] = "test";
$result['client_id'] = "gms";
$result['scope'] = "profile";
$result['exp'] = $this->getExpiresIn($accessToken);
$result['user_name'] = $user->id;
$result['client_id'] = $accessToken->clientId;
if ($accessToken->scope !== null) {
$result['scope'] = $accessToken->scope;
$result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
}
return $result;
}
private function getExpiresIn(AccessToken $accessToken) {
$expTime = strtotime($accessToken->expirationTime);
$now = time();
return $expTime - $now;
}
}
......@@ -13,6 +13,8 @@ interface AccessTokenDAO {
function retrieveAccessTokenFromCode(string $code): ?AccessToken;
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
......
......@@ -17,7 +17,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
$scope = null;
if ($accessToken->scope !== null) {
$scope = join(',', $accessToken->scope);
$scope = join(' ', $accessToken->scope);
}
$params = array(
......@@ -42,13 +42,36 @@ 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"
. " FROM access_token WHERE code = :code AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE, 1, creation_time)");
$stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope"
. " FROM access_token WHERE code = :code"); // AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE, 1, creation_time)
$stmt->bindParam(':code', $code);
$stmt->execute();
foreach ($stmt->fetchAll() as $row) {
$row = $stmt->fetch();
return $this->getAccessTokenFromRow($row);
}
public function getAccessToken(string $token): ?AccessToken {
$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();
return $this->getAccessTokenFromRow($row);
}
private function getAccessTokenFromRow(?array $row): ?AccessToken {
if ($row === null) {
return null;
}
$token = new AccessToken();
$token->token = $row['token'];
$token->code = $row['code'];
......@@ -63,15 +86,12 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
$scope = $row['scope'];
}
if ($scope !== null && $scope !== '') {
$token->scope = explode(',', $scope);
$token->scope = explode(' ', $scope);
}
return $token;
}
return null;
}
public function deleteAccessToken($token): void {
$dbh = $this->getDBHandler();
......
......@@ -57,7 +57,7 @@ class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO {
private function getRSAKeyPairFromResultRow(array $row): RSAKeyPair {
$keyPair = new RSAKeyPair();
$keyPair->id = $row['id'];
$keyPair->keyId = $row['id'];
$keyPair->privateKey = $row['private_key'];
$keyPair->publicKey = $row['public_key'];
$keyPair->alg = $row['alg'];
......
......@@ -7,5 +7,6 @@ class OAuth2Data {
public $clientId;
public $redirectUrl;
public $state;
public $scope;
}
......@@ -114,7 +114,6 @@ class User {
*/
public function getInstitution(): ?string {
$institution = null;
foreach ($this->identities as $identity) {
......
......@@ -55,8 +55,16 @@ Flight::route('GET /auth/oauth2/authorize', function() {
session_start();
global $locator;
$params = [
"client_id" => filter_input(INPUT_GET, 'client_id', FILTER_SANITIZE_STRING),
"redirect_uri" => filter_input(INPUT_GET, 'redirect_uri', FILTER_SANITIZE_STRING),
"alg" => filter_input(INPUT_GET, 'alg', FILTER_SANITIZE_STRING),
"state" => filter_input(INPUT_GET, 'state', FILTER_SANITIZE_STRING),
"scope" => filter_input(INPUT_GET, 'scope', FILTER_SANITIZE_STRING)
];
$requestHandler = new \RAP\OAuth2RequestHandler($locator);
$requestHandler->handleAuthorizeRequest();
$requestHandler->handleAuthorizeRequest($params);
Flight::redirect('/?action=oaut2client');
});
......@@ -65,8 +73,14 @@ Flight::route('POST /auth/oauth2/token', function() {
global $locator;
$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)
];
$requestHandler = new \RAP\OAuth2RequestHandler($locator);
$token = $requestHandler->handleAccessTokenRequest();
$token = $requestHandler->handleAccessTokenRequest($params);
Flight::json($token);
});
......@@ -75,10 +89,12 @@ Flight::route('POST /auth/oauth2/check_token', function() {
global $locator;
$token = filter_input(INPUT_POST, 'token', FILTER_SANITIZE_STRING);
$requestHandler = new \RAP\OAuth2RequestHandler($locator);
$token = $requestHandler->handleCheckTokenRequest();
$result = $requestHandler->handleCheckTokenRequest($token);
Flight::json($token);
Flight::json($result);
});
Flight::route('GET /auth/oidc/jwks', function() {
......
<phpunit colors="true">
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./classes/</directory>
</whitelist>
</filter>
</phpunit>
<?php
use PHPUnit\Framework\TestCase;
use \Firebase\JWT\JWT;
final class IdTokenBuilderTest extends TestCase {
......@@ -23,6 +24,7 @@ final class IdTokenBuilderTest extends TestCase {
$identity->name = "Name";
$identity->surname = "Surname";
$identity->primary = true;
$identity->institution = "INAF";
$user->addIdentity($identity);
$daoStub = $this->createMock(\RAP\DAO::class);
......@@ -37,9 +39,18 @@ final class IdTokenBuilderTest extends TestCase {
$accessToken->userId = "user_id";
$tokenBuilder = new \RAP\IdTokenBuilder($locatorStub);
$result = $tokenBuilder->getIdToken($accessToken, 'RS256');
$jwt = $tokenBuilder->getIdToken($accessToken);
$this->assertNotNull($result);
$this->assertNotNull($jwt);
$payload = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]);
$this->assertEquals("issuer", $payload->iss);
$this->assertEquals($user->id, $payload->sub);
$this->assertEquals($user->getCompleteName(), $payload->name);
$this->assertEquals($identity->name, $payload->given_name);
$this->assertEquals($identity->surname, $payload->family_name);
$this->assertEquals($identity->institution, $payload->org);
}
}
......@@ -8,19 +8,27 @@ final class OAuth2RequestHandlerTest extends TestCase {
$this->expectException(\RAP\BadRequestException::class);
$params = [
"client_id" => null
];
$locatorStub = $this->createMock(\RAP\Locator::class);
$requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
$requestHandler->handleAuthorizeRequest();
$requestHandler->handleAuthorizeRequest($params);
}
public function testInvalidClientRedirectURI(): void {
$this->expectException(\RAP\BadRequestException::class);
$_REQUEST['client_id'] = "client_id";
$_REQUEST['redirect_uri'] = "redirect_uri";
$_REQUEST['state'] = "state";
$params = [
"client_id" => "client_id",
"redirect_uri" => "redirect_uri",
"state" => "state",
"alg" => null,
"scope" => "email%20profile"
];
$daoStub = $this->createMock(\RAP\DAO::class);
$daoStub->method('getOAuth2ClientByClientId')->willReturn(new \RAP\OAuth2Client());
......@@ -29,14 +37,18 @@ final class OAuth2RequestHandlerTest extends TestCase {
$locatorStub->method('getDAO')->willReturn($daoStub);
$requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
$requestHandler->handleAuthorizeRequest();
$requestHandler->handleAuthorizeRequest($params);
}
public function testExecuteOAuthStateFlow(): void {
$_REQUEST['client_id'] = "client_id";
$_REQUEST['redirect_uri'] = "redirect_uri";
$_REQUEST['state'] = "state";
$params = [
"client_id" => "client_id",
"redirect_uri" => "redirect_uri",
"state" => "state",
"alg" => null,
"scope" => "email%20profile"
];
$daoStub = $this->createMock(\RAP\DAO::class);
$client = new \RAP\OAuth2Client();
......@@ -44,7 +56,6 @@ final class OAuth2RequestHandlerTest extends TestCase {
$daoStub->method('getOAuth2ClientByClientId')->willReturn($client);
$sessionStub = $this->createMock(\RAP\SessionData::class);
$sessionStub->method('setOAuth2Data'); //->willReturn(new \RAP\OAuth2Data());
$locatorStub = $this->createMock(\RAP\Locator::class);
$locatorStub->method('getDAO')->willReturn($daoStub);
......@@ -54,7 +65,29 @@ final class OAuth2RequestHandlerTest extends TestCase {
->method('setOAuth2Data')->with($this->anything());
$requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
$requestHandler->handleAuthorizeRequest();
$requestHandler->handleAuthorizeRequest($params);
}
public function testExpiresIn(): void {
$locatorStub = $this->createMock(\RAP\Locator::class);
$requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
$accessToken = new \RAP\AccessToken();
$expDate = new \DateTime();
$expDate->add(new \DateInterval('PT1H'));
$accessToken->expirationTime = $expDate->format("Y-m-d H:i:s");
// testing private method using reflection
$reflection = new \ReflectionClass(get_class($requestHandler));
$method = $reflection->getMethod('getExpiresIn');
$method->setAccessible(true);
$exp = $method->invokeArgs($requestHandler, [$accessToken]);
$this->assertEquals(3600, $exp);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment