Compare commits

..

2 Commits

Author SHA1 Message Date
milnerTill
844c79ae72 improved error handling and tests added 2026-04-20 13:38:17 -03:00
milnerTill
f3c1fad50a add support to HS256 algorithm 2026-04-20 11:48:04 -03:00
19 changed files with 177 additions and 210 deletions

View File

@@ -55,6 +55,7 @@ class OidcController extends Controller
}
try {
$this->throwIfAuthorizationError($request);
$this->oidcService->processAuthorizeResponse($request->query('code'));
} catch (OidcException $oidcException) {
$this->showErrorNotification($oidcException->getMessage());
@@ -72,4 +73,23 @@ class OidcController extends Controller
{
return redirect($this->oidcService->logout());
}
/**
*
* @throws OidcException
*/
private function throwIfAuthorizationError(Request $request): void
{
$errorCode = $request->query('error');
if (!$errorCode) {
return;
}
$errorDescription = $request->query('error_description');
if ($errorDescription) {
throw new OidcException($errorDescription);
}
throw new OidcException(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace BookStack\Access\Oidc;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
/**
* Option provider that sends credentials via HTTP Basic Auth header
* and also includes client_id in the request body.
*/
class OidcHttpBasicWithClientIdOptionProvider extends HttpBasicAuthOptionProvider
{
public function getAccessTokenOptions($method, array $params)
{
$clientId = $params['client_id'] ?? null;
$options = parent::getAccessTokenOptions($method, $params);
if ($clientId) {
parse_str($options['body'] ?? '', $body);
$body['client_id'] = $clientId;
$options['body'] = http_build_query($body);
}
return $options;
}
}

View File

@@ -9,10 +9,10 @@ class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
public function validate(string $clientId): bool
public function validate(OidcProviderSettings $settings): bool
{
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
parent::validateCommonTokenDetails($settings);
$this->validateTokenClaims($settings->clientId);
return true;
}

View File

@@ -9,7 +9,9 @@ class OidcJwtWithClaims implements ProvidesClaims
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
protected array $acceptedSignatures = [self::hs256Signature, self::rs256Signature];
private const hs256Signature = 'HS256'
, rs256Signature = 'RS256';
/**
* @var array[]|string[]
*/
@@ -59,11 +61,11 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
public function validateCommonTokenDetails(OidcProviderSettings $settings): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
$this->validateTokenSignature($settings);
$this->validateCommonClaims($settings->clientId);
return true;
}
@@ -102,12 +104,12 @@ class OidcJwtWithClaims implements ProvidesClaims
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop)) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature)) {
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
@@ -117,31 +119,42 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
protected function validateTokenSignature(OidcProviderSettings $settings): void {
$validSignatures = implode(', ',$this->acceptedSignatures);
switch ($this->header['alg']) {
case self::rs256Signature:
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
case self::hs256Signature:
$secret = $settings->clientSecret;
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
$expectedSignature = hash_hmac('sha256', $contentToSign, $secret, true);
if (hash_equals($expectedSignature, $this->signature)) {
return;
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided secret');
default:
throw new OidcInvalidTokenException("Only $validSignatures signatures validation are supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**

View File

@@ -74,7 +74,7 @@ class OidcProviderSettings
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
$required = ['tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");

View File

@@ -14,7 +14,6 @@ use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
/**
@@ -140,7 +139,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
'optionProvider' => new OidcHttpBasicWithClientIdOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
@@ -199,7 +198,7 @@ class OidcService
}
try {
$idToken->validate($settings->clientId);
$idToken->validate($settings);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
}

View File

@@ -68,7 +68,7 @@ return [
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory.
@@ -78,7 +78,7 @@ return [
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts/dompdf/cache'),
'font_cache' => storage_path('fonts/'),
/**
* The location of a temporary directory.

View File

@@ -4,8 +4,6 @@ namespace BookStack\Exports;
use BookStack\Exceptions\PdfExportException;
use Dompdf\Dompdf;
use FontLib\Font;
use Illuminate\Support\Str;
use Knp\Snappy\Pdf as SnappyPdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
@@ -62,65 +60,12 @@ class PdfGenerator
$domPdf = new Dompdf($options);
$domPdf->setBasePath(base_path('public'));
$fontMetrics = $domPdf->getFontMetrics();
$userFontfamilies = $this->getUserDomPdfFontFamilies();
foreach ($userFontfamilies as $fontFamily => $fonts) {
try {
$fontMetrics->setFontFamily($fontFamily, $fonts);
} catch (\Exception $exception) {
$expectedPath = storage_path('fonts/dompdf');
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
}
}
$domPdf->loadHTML($this->convertEntities($html));
$domPdf->render();
return (string) $domPdf->output();
}
/**
* @return array<string, array<string, string>>
*/
protected function getUserDomPdfFontFamilies(): array
{
$fontStore = storage_path('fonts/dompdf');
if (!is_dir($fontStore)) {
return [];
}
$fontFamilies = [];
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
foreach ($fontFiles as $fontFile) {
$fontFileName = basename($fontFile, '.ttf');
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
if (!file_exists($expectedUfm)) {
$font = Font::load($fontFile);
$font->parse();
try {
$font->saveAdobeFontMetrics($expectedUfm);
} catch (\Exception $exception) {
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
}
}
$nameParts = explode('-', $fontFileName);
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
$nameParts[1] = 'Normal';
}
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
$variation = Str::snake($nameParts[1]);
if (!isset($fontFamilies[$family])) {
$fontFamilies[$family] = [];
}
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
}
return $fontFamilies;
}
/**
* @throws PdfExportException
*/

View File

@@ -72,7 +72,7 @@ Big thanks to these companies for supporting the project.
<td align="center"><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="240" src="https://www.bookstackapp.com/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>
<td align="center" style="text-align: center"><a href="https://nws.netways.de" target="_blank">
<td align="center" style="text-align: center"><a href="https://nws.netways.de/apps/bookstack/" target="_blank">
<img width="240" src="https://www.bookstackapp.com/images/sponsors/netways.png" alt="NETWAYS Web Services">
</a></td>
</tr>

View File

@@ -94,78 +94,30 @@ const extendedActionsByKeys: Record<string, ShortcutAction> = {
};
function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void {
const baseKeySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
const keySetToUse = extendKeySetWithKeyCodes(baseKeySetToUse);
const keySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
return (event: KeyboardEvent) => {
const comboStrings = keyboardEventToKeyComboStrings(event);
// console.log(comboStrings, event, keySetToUse);
for (const combo of comboStrings) {
if (keySetToUse[combo]) {
const handled = keySetToUse[combo](context.editor, context);
if (handled) {
event.stopPropagation();
event.preventDefault();
}
break;
const combo = keyboardEventToKeyComboString(event);
// console.log(`pressed: ${combo}`);
if (keySetToUse[combo]) {
const handled = keySetToUse[combo](context.editor, context);
if (handled) {
event.stopPropagation();
event.preventDefault();
}
}
};
}
/**
* Takes a shortcut key set and returns a new set with added variations of shortcts where
* they can be sensibly represented as their key code instead of just key, which we can use
* for matching in scenarios where the physical key may be represented of the letter used
* in the shortcut, but produces a different 'key' value.
* Useful for Cyrillic scenarios where the keyboard key would show a latin character
* as an option, and therefore be expected for use for the relevant latin shortcut, but the main
* key output is a Cyrillic character.
*/
function extendKeySetWithKeyCodes(keySet: Record<string, ShortcutAction>): Record<string, ShortcutAction> {
const newKeys: Record<string, ShortcutAction> = {};
const setKeys = Object.keys(keySet);
for (const keyCombo of setKeys) {
const action = keySet[keyCombo];
newKeys[keyCombo] = action;
const comboParts = keyCombo.split('+');
const lastComboPart = comboParts.pop() || '';
if (lastComboPart.match(/^[a-zA-Z]$/)) {
const keyCode = lastComboPart.toUpperCase().charCodeAt(0);
comboParts.push(String(keyCode));
const newCombo = comboParts.join('+');
newKeys[newCombo] = action;
}
}
return newKeys;
}
function keyboardEventToKeyComboStrings(event: KeyboardEvent): string[] {
function keyboardEventToKeyComboString(event: KeyboardEvent): string {
const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey;
const mainParts = [
const parts = [
metaKeyPressed ? 'meta' : '',
event.shiftKey ? 'shift' : '',
event.key,
];
const toReturn = [
mainParts.filter(Boolean).join('+').toLowerCase(),
];
// If ending with a standard latin character, provide an alternative
// keyCode based option for scenarios of dual-language keyboard use.
const keyCode = event.keyCode || 0;
if (keyCode >= 65 && keyCode <= 90) {
const keyCodeParts = [...mainParts];
keyCodeParts.pop();
keyCodeParts.push(String(keyCode));
toReturn.push(keyCodeParts.filter(Boolean).join('+').toLowerCase());
}
return toReturn;
return parts.filter(Boolean).join('+').toLowerCase();
}
function isMac(): boolean {

View File

@@ -1,6 +1,2 @@
# Font cache files have once been stored directly in this folder
# therefore its important the contents non-ignored by git
# are chosen selectively
*
!.gitignore
!dompdf/
!.gitignore

View File

@@ -1,3 +0,0 @@
*
!.gitignore
!cache/

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -79,39 +79,6 @@ class PdfExportTest extends TestCase
$this->assertStringContainsString('<details open="open"', $pdfHtml);
}
public function test_custom_fonts_loaded_for_dom_pdf_when_used()
{
// Set up custom font usage
$page = $this->entities->page()->forceFill([
'html' => '<p><strong>Bold</strong>text</p>',
]);
$page->save();
$this->setSettings([
'app-custom-head' => '<style>* { font-family: "meow words"}</style>'
]);
$normalFont = $this->files->testFilePath('fonts/Cardiff.ttf');
$normalFontTarget = storage_path('fonts/dompdf/MeowWords.ttf');
$boldFont = $this->files->testFilePath('fonts/Cardiff-Bold.ttf');
$boldFontTarget = storage_path('fonts/dompdf/MeowWords-Bold.ttf');
copy($normalFont, $normalFontTarget);
copy($boldFont, $boldFontTarget);
$resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
$resp->assertStatus(200);
// Existance of UFM files indicates the metrics have been generated
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords.ufm'));
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords-Bold.ufm'));
// Existence of cache json files indicates the fonts have been used
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords.ufm.json'));
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords-Bold.ufm.json'));
$filesToCleanUp = [...glob(storage_path('fonts/dompdf/Meow*')), ...glob(storage_path('fonts/dompdf/cache/Meow*'))];
foreach ($filesToCleanUp as $file) {
unlink($file);
}
}
public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
{
$page = $this->entities->page();

View File

@@ -2,6 +2,7 @@
namespace Tests\Helpers;
use BookStack\Access\Oidc\OidcProviderSettings;
use phpseclib3\Crypt\RSA;
/**
@@ -119,6 +120,41 @@ q/1PY4iJviGKddtmfClH3v4=
-----END PRIVATE KEY-----';
}
public static function defaultSecret(): string
{
return 'test-client-secret-for-hs256';
}
/**
* Build a minimal OidcProviderSettings for use in token validation tests.
*/
public static function defaultProviderSettings(array $overrides = []): OidcProviderSettings
{
return new OidcProviderSettings(array_merge([
'issuer' => static::defaultIssuer(),
'clientId' => static::defaultClientId(),
'clientSecret' => static::defaultSecret(),
], $overrides));
}
/**
* Build an HS256-signed ID token using the given secret.
*/
public static function hs256IdToken(string $secret, array $payloadOverrides = []): string
{
$payload = array_merge(static::defaultPayload(), $payloadOverrides);
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$top = implode('.', [
static::base64UrlEncode(json_encode($header)),
static::base64UrlEncode(json_encode($payload)),
]);
$signature = hash_hmac('sha256', $top, $secret, true);
return $top . '.' . static::base64UrlEncode($signature);
}
public static function publicJwkKeyArray(): array
{
return [

View File

@@ -15,7 +15,7 @@ class OidcIdTokenTest extends TestCase
OidcJwtHelper::publicJwkKeyArray(),
]);
$this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
$this->assertTrue($token->validate(OidcJwtHelper::defaultProviderSettings()));
}
public function test_get_claim_returns_value_if_existing()
@@ -56,7 +56,7 @@ class OidcIdTokenTest extends TestCase
$err = null;
try {
$token->validate('abc');
$token->validate(OidcJwtHelper::defaultProviderSettings());
} catch (\Exception $exception) {
$err = $exception;
}
@@ -71,7 +71,7 @@ class OidcIdTokenTest extends TestCase
$token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
$this->expectException(OidcInvalidTokenException::class);
$this->expectExceptionMessage('Token signature could not be validated using the provided keys');
$token->validate('abc');
$token->validate(OidcJwtHelper::defaultProviderSettings());
}
public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key()
@@ -83,7 +83,7 @@ class OidcIdTokenTest extends TestCase
]);
$this->expectException(OidcInvalidTokenException::class);
$this->expectExceptionMessage('Token signature could not be validated using the provided keys');
$token->validate('abc');
$token->validate(OidcJwtHelper::defaultProviderSettings());
}
public function test_error_thrown_if_invalid_key_provided()
@@ -91,15 +91,34 @@ class OidcIdTokenTest extends TestCase
$token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']);
$this->expectException(OidcInvalidTokenException::class);
$this->expectExceptionMessage('Unexpected type of key value provided');
$token->validate('abc');
$token->validate(OidcJwtHelper::defaultProviderSettings());
}
public function test_error_thrown_if_token_algorithm_is_not_rs256()
public function test_error_thrown_if_token_algorithm_is_not_supported()
{
$token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []);
$token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'ES256']), OidcJwtHelper::defaultIssuer(), []);
$this->expectException(OidcInvalidTokenException::class);
$this->expectExceptionMessage('Only RS256 signature validation is supported. Token reports using HS256');
$token->validate('abc');
$this->expectExceptionMessage('Only HS256, RS256 signatures validation are supported. Token reports using ES256');
$token->validate(OidcJwtHelper::defaultProviderSettings());
}
public function test_hs256_token_passes_validation_with_correct_secret()
{
$secret = OidcJwtHelper::defaultSecret();
$token = new OidcIdToken(OidcJwtHelper::hs256IdToken($secret), OidcJwtHelper::defaultIssuer(), []);
$settings = OidcJwtHelper::defaultProviderSettings(['clientSecret' => $secret]);
$this->assertTrue($token->validate($settings));
}
public function test_hs256_token_fails_validation_with_wrong_secret()
{
$token = new OidcIdToken(OidcJwtHelper::hs256IdToken('correct-secret'), OidcJwtHelper::defaultIssuer(), []);
$settings = OidcJwtHelper::defaultProviderSettings(['clientSecret' => 'wrong-secret']);
$this->expectException(OidcInvalidTokenException::class);
$this->expectExceptionMessage('Token signature could not be validated using the provided secret');
$token->validate($settings);
}
public function test_token_claim_error_cases()
@@ -141,7 +160,7 @@ class OidcIdTokenTest extends TestCase
$err = null;
try {
$token->validate('xxyyzz.aaa.bbccdd.123');
$token->validate(OidcJwtHelper::defaultProviderSettings());
} catch (\Exception $exception) {
$err = $exception;
}
@@ -160,7 +179,7 @@ class OidcIdTokenTest extends TestCase
$testFilePath,
]);
$this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
$this->assertTrue($token->validate(OidcJwtHelper::defaultProviderSettings()));
unlink($testFilePath);
}
}

Binary file not shown.

View File

@@ -1,2 +0,0 @@
Font files by Roger White, in public domain.
https://web.archive.org/web/20110609213636/http://www.rogersfonts.org.uk/