diff --git a/app/controllers/course/lti.php b/app/controllers/course/lti.php index e0ca2cf29129f9e18933be76ae134fb1dadc17f4..4db777661b5a3792cecd3d794658a272e884a2dc 100644 --- a/app/controllers/course/lti.php +++ b/app/controllers/course/lti.php @@ -1,4 +1,7 @@ <?php + +use Studip\OAuth2\NegotiatesWithPsr7; + /** * course/lti.php - LTI consumer API for Stud.IP * @@ -13,6 +16,8 @@ class Course_LtiController extends StudipController { + use NegotiatesWithPsr7; + /** * Callback function being called before an action is executed. */ @@ -268,22 +273,15 @@ class Course_LtiController extends StudipController */ public function save_link_action($tool_id) { - require_once 'vendor/oauth-php/library/OAuthRequestVerifier.php'; - $tool = LtiTool::find($tool_id); $lti_msg = Request::get('lti_msg'); $lti_errormsg = Request::get('lti_errormsg'); $content_items = Request::get('content_items'); $content_items = json_decode($content_items, true); - OAuthStore::instance('PDO', [ - 'dsn' => 'mysql:host=' . $GLOBALS['DB_STUDIP_HOST'] . ';dbname=' . $GLOBALS['DB_STUDIP_DATABASE'], - 'username' => $GLOBALS['DB_STUDIP_USER'], - 'password' => $GLOBALS['DB_STUDIP_PASSWORD'] - ]); - - $oarv = new OAuthRequestVerifier(); - $oarv->verifySignature($tool->consumer_secret, false, false); + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $tool->consumer_secret, '')) { + throw new Exception('Could not verify request.'); + } if (is_array($content_items) && count($content_items['@graph'])) { // we only support selecting a single content item @@ -452,18 +450,11 @@ class Course_LtiController extends StudipController */ public function outcome_action($id) { - require_once 'vendor/oauth-php/library/OAuthRequestVerifier.php'; - $lti_data = LtiData::find($id); - OAuthStore::instance('PDO', [ - 'dsn' => 'mysql:host=' . $GLOBALS['DB_STUDIP_HOST'] . ';dbname=' . $GLOBALS['DB_STUDIP_DATABASE'], - 'username' => $GLOBALS['DB_STUDIP_USER'], - 'password' => $GLOBALS['DB_STUDIP_PASSWORD'] - ]); - - $oarv = new OAuthRequestVerifier(); - $oarv->verifySignature($lti_data->getConsumerSecret(), false, false); + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $lti_data->getConsumerSecret(), '')) { + throw new Exception('Could not verify request.'); + } // fetch and parse POST data $message = file_get_contents('php://input'); diff --git a/lib/classes/LtiLink.php b/lib/classes/LtiLink.php index 801b6c0035cdb4d23d0d92d26286ea49d461919a..1423cefaa307a2df97b9c9b6eca9feb41e8f4f81 100644 --- a/lib/classes/LtiLink.php +++ b/lib/classes/LtiLink.php @@ -310,12 +310,14 @@ class LtiLink // posted form data will always use CR LF $launch_params = preg_replace("/\r?\n/", "\r\n", $launch_params); - // In OAuth, request parameters must be sorted by name - ksort($launch_params); - $launch_params = http_build_query($launch_params, '', '&', PHP_QUERY_RFC3986); - $base_string = 'POST&' . rawurlencode($launch_url) . '&' . rawurlencode($launch_params); - $secret = rawurlencode($this->consumer_secret) . '&'; - - return base64_encode(hash_hmac($this->oauth_signature_method, $base_string, $secret, true)); + return Studip\OAuth1::signRequest( + (new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest( + 'POST', + $launch_url + )->withQueryParams($launch_params), + $this->consumer_secret, + '', + $this->oauth_signature_method + ); } } diff --git a/lib/classes/OAuth1.php b/lib/classes/OAuth1.php new file mode 100644 index 0000000000000000000000000000000000000000..1695f9f0d200dbef746550852ad50de8c4a8c030 --- /dev/null +++ b/lib/classes/OAuth1.php @@ -0,0 +1,167 @@ +<?php +namespace Studip; + +use Psr\Http\Message\ServerRequestInterface as Request; +use RuntimeException; + +/** + * Basic oauth1 request handling for Stud.IP using PSR-7 http messages. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @license GPL2 or any later version + * @since Stud.IP 6.0 + */ +final class OAuth1 +{ + /** + * Signs a given request. + * + * @throws RuntimeException if a request for any other oauth version then + * 1.0 shall be signed + */ + public static function signRequest( + Request $request, + string $consumerSecret, + string $tokenSecret, + string $method + ): string { + if ( + isset($request->getQueryParams()['oauth_version']) + && $request->getQueryParams()['oauth_version'] !== '1.0' + ) { + throw new RuntimeException(self::class . ' only supports OAuth 1.0 requests'); + } + + return self::hash( + $method, + self::getSignatureBaseString($request), + self::urlencode($consumerSecret) . '&' . self::urlencode($tokenSecret) + ); + } + + /** + * Verifies an oauth request. + * + * @throws RuntimeException if any necessary oauth parameter is missing + */ + public static function verifyRequest( + Request $request, + string $consumerSecret, + string $tokenSecret + ): bool { + $parameters = self::extractParameters($request); + + $required = [ + 'oauth_consumer_key', + 'oauth_nonce', + 'oauth_signature', + 'oauth_signature_method', + 'oauth_timestamp', + ]; + + $missing = array_diff($required, array_keys($parameters)); + if (count($missing) > 0) { + throw new RuntimeException('Missing oauth parameters ' . implode(', ', $missing)); + } + + $signatureToVerify = $parameters['oauth_signature']; + unset($parameters['oauth_signature']); + + $signature = self::signRequest( + $request->withQueryParams($parameters), + $consumerSecret, + $tokenSecret, + $parameters['oauth_signature_method'] + ); + + return $signature === $signatureToVerify; + } + + /** + * Extracts the oauth parameters either from the Authorization header or + * from the query string. + */ + public static function extractParameters(Request $request): array + { + $parameters = $request->getQueryParams(); + + $header = $request->getHeaderLine('Authorization'); + if ($header && str_starts_with($header, 'OAuth ')) { + $temp = substr($header, 6); + $chunks = explode(',', $temp); + + foreach ($chunks as $chunk) { + [$key, $value] = explode('=', $chunk, 2); + $value = trim($value, '"'); + $parameters[$key] = self::urldecode($value); + } + } + + return $parameters; + } + + /** + * Creates the base string for the signature. It consists of: + * + * - The uppercase request method + * - The request URL + * - the sorted and urlencoded parameters of the request + * + * The urlencoded parts are concatenated together into a single string + * separated by the '&' character. + * + * + */ + public static function getSignatureBaseString(Request $request): string + { + $parameters = $request->getQueryParams(); + ksort($parameters); + + return implode('&', array_map( + self::urlencode(...), + [ + strtoupper($request->getMethod()), + (string) $request->getUri()->withQuery(''), + http_build_query($parameters, '', '&', PHP_QUERY_RFC3986), + ] + )); + } + + /** + * Hashes a given text with a given key by the given method. + * + * @throws RuntimeException if the given hash method is not supported + */ + public static function hash(string $method, string $text, string $key): string + { + $method = strtolower($method); + return match ($method) { + 'hmac-sha1', 'sha1' => base64_encode(hash_hmac('sha1', $text, $key, true)), + 'hmac-sha256', 'sha256' => base64_encode(hash_hmac('sha256', $text, $key, true)), + 'hmac-sha512', 'sha512' => base64_encode(hash_hmac('sha512', $text, $key, true)), + + 'plaintext' => $key, + + default => throw new RuntimeException('Unsupported sigature method "' . $method . '"'), + }; + } + + /** + * Urlencodes a given input + */ + public static function urldecode(string $input): string + { + return rawurldecode($input); + } + + /** + * Urldecodes a given input + */ + public static function urlencode(string $input): string + { + $encoded = rawurlencode($input); + return str_starts_with($encoded, '/%7E') + ? str_replace('/%7E', '/~', $encoded) + : $encoded; + } +} diff --git a/lib/classes/auth_plugins/StudipAuthLTI.class.php b/lib/classes/auth_plugins/StudipAuthLTI.class.php index e8c316fe4996a5d4f08df679017d0f177077e432..07ab8c378c98af523821b386afb3020f4f4e759d 100644 --- a/lib/classes/auth_plugins/StudipAuthLTI.class.php +++ b/lib/classes/auth_plugins/StudipAuthLTI.class.php @@ -9,8 +9,12 @@ * the License, or (at your option) any later version. */ +use Studip\OAuth2\NegotiatesWithPsr7; + class StudipAuthLTI extends StudipAuthSSO { + use NegotiatesWithPsr7; + public $consumer_keys; public $username; public $domain; @@ -62,24 +66,15 @@ class StudipAuthLTI extends StudipAuthSSO * * @return bool true if authentication succeeds * - * @throws OAuthException2 if the signature verification failed - * */ public function isAuthenticated($username, $password) { - require_once 'vendor/oauth-php/library/OAuthRequestVerifier.php'; - - OAuthStore::instance('PDO', [ - 'dsn' => 'mysql:host=' . $GLOBALS['DB_STUDIP_HOST'] . ';dbname=' . $GLOBALS['DB_STUDIP_DATABASE'], - 'username' => $GLOBALS['DB_STUDIP_USER'], - 'password' => $GLOBALS['DB_STUDIP_PASSWORD'] - ]); - $consumer_key = Request::get('oauth_consumer_key'); $consumer_secret = $this->consumer_keys[$consumer_key]['consumer_secret']; - $oarv = new OAuthRequestVerifier(); - $oarv->verifySignature($consumer_secret, false, false); + if (!Studip\OAuth1::verifyRequest($this->getPsrRequest(), $consumer_secret, '')) { + return false; + } return parent::isAuthenticated($username, $password); } @@ -93,8 +88,6 @@ class StudipAuthLTI extends StudipAuthSSO * @param string $password the password (ignored) * * @return mixed if authentication succeeds: the Stud.IP user, else false - * - * @throws OAuthException2 if the signature verification failed */ public function authenticateUser($username, $password) { diff --git a/tests/unit/lib/classes/OAuth1Test.php b/tests/unit/lib/classes/OAuth1Test.php new file mode 100644 index 0000000000000000000000000000000000000000..84d4fb204426f711ffa6f28857f4f8026dbcbf50 --- /dev/null +++ b/tests/unit/lib/classes/OAuth1Test.php @@ -0,0 +1,118 @@ +<?php + +use Psr\Http\Message\ServerRequestInterface; +use Studip\OAuth1; + +/** + * All values are from the OAuth 1.0 Authentication Sandbox (using the example + * used in the OAuth Specification). + * + * @see http://lti.tools/oauth/ + */ +final class OAuth1Test extends \Codeception\Test\Unit +{ + /** + * @covers OAuth1::getSignatureBaseString + */ + public function testCreationOfBaseString(): void + { + $this->assertEquals( + 'GET&http%3A%2F%2Fphotos.example.net%2Fphotos&file%3Dvacation.jpg%26oauth_consumer_key%3Ddpf43f3p2l4k3l03%26oauth_nonce%3Dkllo9940pd9333jh%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1191242096%26oauth_token%3Dnnch734d00sl2jdk%26oauth_version%3D1.0%26size%3Doriginal', + OAuth1::getSignatureBaseString($this->getDefaultTestRequest()) + ); + } + + /** + * @covers OAuth1::signRequest + */ + public function testSigningARequest(): void + { + $this->assertEquals( + 'tR3+Ty81lMeYAr/Fid0kMTYa/WM=', + OAuth1::signRequest( + $this->getDefaultTestRequest(), + 'kd94hf93k423kf44', + 'pfkkdhi9sl3r4s00', + 'HMAC-SHA1' + ) + ); + } + + /** + * @covers OAuth1::verifyRequest + */ + public function testVerifyingARequest(): void + { + $this->assertTrue( + OAuth1::verifyRequest( + $this->getDefaultTestRequest(['oauth_signature' => 'tR3+Ty81lMeYAr/Fid0kMTYa/WM=']), + 'kd94hf93k423kf44', + 'pfkkdhi9sl3r4s00' + ) + ); + } + + /** + * @covers OAuth1::verifyRequest + * @covers OAuth1::extractParameters + */ + public function testVerifyingARequestFromAuthorizationHeader(): void + { + $parameters = [ + ...$this->getDefaultParameters(), + 'oauth_signature' => 'tR3+Ty81lMeYAr/Fid0kMTYa/WM=' + ]; + + + $request = $this->getTestRequest()->withHeader( + 'Authorization', + 'OAuth ' . implode(',', array_map( + fn($key, $value) => sprintf('%s="%s"', $key, $value), + array_keys($parameters), + array_values($parameters) + )) + ); + + $this->assertTrue( + OAuth1::verifyRequest( + $request, + 'kd94hf93k423kf44', + 'pfkkdhi9sl3r4s00' + ) + ); + } + + private function getTestRequest(): ServerRequestInterface + { + $factory = new Slim\Psr7\Factory\ServerRequestFactory(); + return $factory->createServerRequest( + 'GET', + 'http://photos.example.net/photos' + )->withQueryParams([ + 'size' => 'original', + 'file' => 'vacation.jpg', + ]); + } + + private function getDefaultTestRequest(array $parameters = []): ServerRequestInterface + { + $request = $this->getTestRequest(); + return $request->withQueryParams([ + ...$request->getQueryParams(), + ...$this->getDefaultParameters(), + ...$parameters, + ]); + } + + private function getDefaultParameters(): array + { + return [ + 'oauth_consumer_key' => 'dpf43f3p2l4k3l03', + 'oauth_token' => 'nnch734d00sl2jdk', + 'oauth_nonce' => 'kllo9940pd9333jh', + 'oauth_timestamp' => '1191242096', + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_version' => '1.0', + ]; + } +}