diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php index b36adb522..f41570417 100644 --- a/app/Auth/Access/LoginService.php +++ b/app/Auth/Access/LoginService.php @@ -47,7 +47,7 @@ class LoginService // Authenticate on all session guards if a likely admin if ($user->can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2', 'openid']; + $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); } diff --git a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php new file mode 100644 index 000000000..85746cb6a --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] + * @param array|string $jwkOrKeyPath + * @throws InvalidKeyException + */ + public function __construct($jwkOrKeyPath) + { + if (is_array($jwkOrKeyPath)) { + $this->loadFromJwkArray($jwkOrKeyPath); + } + } + + /** + * @throws InvalidKeyException + */ + protected function loadFromJwkArray(array $jwk) + { + if ($jwk['alg'] !== 'RS256') { + throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + } + + if ($jwk['use'] !== 'sig') { + throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['sig']}"); + } + + if (empty($jwk['e'] ?? '')) { + throw new InvalidKeyException('An "e" parameter on the provided key is expected'); + } + + if (empty($jwk['n'] ?? '')) { + throw new InvalidKeyException('A "n" parameter on the provided key is expected'); + } + + $n = strtr($jwk['n'] ?? '', '-_', '+/'); + + try { + /** @var RSA $key */ + $key = PublicKeyLoader::load([ + 'e' => new BigInteger(base64_decode($jwk['e']), 256), + 'n' => new BigInteger(base64_decode($n), 256), + ])->withPadding(RSA::SIGNATURE_PKCS1); + + $this->key = $key; + } catch (\Exception $exception) { + throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + } + } + + /** + * Use this key to sign the given content and return the signature. + */ + public function verify(string $content, string $signature): bool + { + return $this->key->verify($content, $signature); + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php new file mode 100644 index 000000000..6731ec4be --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php @@ -0,0 +1,54 @@ +validate($options); + } + + + /** + * Validate this access token response for OIDC. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ + private function validate(array $options): void + { + // access_token: REQUIRED. Access Token for the UserInfo Endpoint. + // Performed on the extended class + + // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0 + // Bearer Token Usage [RFC6750], for Clients using this subset. + // Note that the token_type value is case-insensitive. + if (strtolower(($options['token_type'] ?? '')) !== 'bearer') { + throw new InvalidArgumentException('The response token type MUST be "Bearer"'); + } + + // id_token: REQUIRED. ID Token. + if (empty($options['id_token'])) { + throw new InvalidArgumentException('An "id_token" property must be provided'); + } + } + + /** + * Get the id token value from this access token response. + */ + public function getIdToken(): string + { + return $this->getValues()['id_token']; + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php new file mode 100644 index 000000000..09527c3ed --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -0,0 +1,143 @@ +keys = $keys; + $this->issuer = $issuer; + $this->parse($token); + } + + /** + * Parse the token content into its components. + */ + protected function parse(string $token): void + { + $this->tokenParts = explode('.', $token); + $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); + $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); + $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; + } + + /** + * Parse a Base64-JSON encoded token part. + * Returns the data as a key-value array or empty array upon error. + */ + protected function parseEncodedTokenPart(string $part): array + { + $json = $this->base64UrlDecode($part) ?: '{}'; + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Base64URL decode. Needs some character conversions to be compatible + * with PHP's default base64 handling. + */ + protected function base64UrlDecode(string $encoded): string + { + return base64_decode(strtr($encoded, '-_', '+/')); + } + + /** + * Validate all possible parts of the id token. + * @throws InvalidTokenException + */ + public function validate() + { + $this->validateTokenStructure(); + $this->validateTokenSignature(); + $this->validateTokenClaims(); + } + + /** + * Validate the structure of the given token and ensure we have the required pieces. + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 + * @throws InvalidTokenException + */ + protected function validateTokenStructure(): void + { + foreach (['header', 'payload'] as $prop) { + if (empty($this->$prop) || !is_array($this->$prop)) { + throw new InvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($this->signature) || !is_string($this->signature)) { + throw new InvalidTokenException("Could not parse out a valid signature within the provided token"); + } + } + + /** + * Validate the signature of the given token and ensure it validates against the provided key. + * @throws InvalidTokenException + */ + protected function validateTokenSignature(): void + { + if ($this->header['alg'] !== 'RS256') { + throw new InvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + } + + $parsedKeys = array_map(function($key) { + try { + return new JwtSigningKey($key); + } catch (InvalidKeyException $e) { + return null; + } + }, $this->keys); + + $parsedKeys = array_filter($parsedKeys); + + $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + foreach ($parsedKeys as $parsedKey) { + if ($parsedKey->verify($contentToSign, $this->signature)) { + return; + } + } + + throw new InvalidTokenException('Token signature could not be validated using the provided keys.'); + } + + /** + * Validate the claims of the token. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + */ + protected function validateTokenClaims(): void + { + // TODO + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnectOAuthProvider.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php similarity index 84% rename from app/Auth/Access/OpenIdConnectOAuthProvider.php rename to app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php index 60ae2aa09..074f463cc 100644 --- a/app/Auth/Access/OpenIdConnectOAuthProvider.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php @@ -1,7 +1,8 @@ getValues()); + $idTokenText = $accessToken->getIdToken(); + $idToken = new OpenIdConnectIdToken( + $idTokenText, + $this->config['issuer'], + [$this->config['jwt_public_key']] + ); + // TODO - Create a class to manage token parsing and validation on this - // Using the config params: - // $this->config['jwt_public_key'] - // $this->config['issuer'] - // // Ensure ID token validation is done: // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation // To full affect and tested + // JWT signature algorthims: + // https://datatracker.ietf.org/doc/html/rfc7518#section-3 $userDetails = $this->getUserDetails($accessToken->getIdToken()); $isLoggedIn = auth()->check(); diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OpenIdConnectController.php index 23cfbbcbe..8156773b4 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OpenIdConnectController.php @@ -2,7 +2,7 @@ namespace BookStack\Http\Controllers\Auth; -use BookStack\Auth\Access\OpenIdConnectService; +use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; diff --git a/composer.json b/composer.json index 288f55991..e53d9d25a 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "league/html-to-markdown": "^5.0.0", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", + "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1.6", "socialiteproviders/discord": "^4.1", diff --git a/composer.lock b/composer.lock index 9355deed3..62b2aa621 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "620412108a5d19ed91d9fe42418b63b5", + "content-hash": "5cbbf417bd19cd2164f91b9b2d38600c", "packages": [ { "name": "aws/aws-crt-php", @@ -3511,6 +3511,117 @@ ], "time": "2021-08-28T21:27:29+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.10", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/62fcc5a94ac83b1506f52d7558d828617fac9187", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "^5.7|^6.0|^9.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.10" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2021-08-16T04:24:45+00:00" + }, { "name": "pragmarx/google2fa", "version": "8.0.0",