Compare commits

...

1 Commits

Author SHA1 Message Date
Dan Brown
c01217827c MFA: Started exploring hardware key support 2026-06-01 13:08:58 +01:00
7 changed files with 1247 additions and 3 deletions

View File

@@ -0,0 +1,133 @@
<?php
namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\Mfa\BackupCodeService;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Mfa\MfaVerificationLimiter;
use BookStack\Activity\ActivityType;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
use Cose\Algorithms;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Serializer\Encoder\JsonEncode;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;
class MfaHardwareKeyController extends Controller
{
use HandlesPartialLogins;
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-hardware-key-secret';
public function __construct(
protected MfaVerificationLimiter $limiter,
) {
}
/**
* Show a view that allows configuration of a hardware key.
*/
public function generate(BackupCodeService $codeService)
{
$domain = parse_url(url('/'), PHP_URL_HOST);
$rpEntity = PublicKeyCredentialRpEntity::create(
setting('app-name'),
$domain,
);
$user = $this->currentOrLastAttemptedUser();
// TODO - Ensure user ID is somewhat long and random
// See: https://webauthn-doc.spomky-labs.com/prerequisites/user-entity
// Maybe hash of (app-id from settings + user id)
// Or maybe a UUID/random_string which we connect via DB entry?
$userEntity = PublicKeyCredentialUserEntity::create(
$user->slug,
$user->id,
$user->name,
);
$publicKeyCredentialParametersList = [
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256K), // More interesting algorithm
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256), // ||
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS256), // ||
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS256), // \/
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED256), // Less interesting algorithm
];
$credentialOptions = PublicKeyCredentialCreationOptions::create(
$rpEntity,
$userEntity,
random_bytes(32),
$publicKeyCredentialParametersList,
hints: [
PublicKeyCredentialOptions::HINT_SECURITY_KEY,
],
timeout: 60000, // 1 minute
authenticatorSelection: AuthenticatorSelectionCriteria::create(
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
)
);
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
$factory = new WebauthnSerializerFactory($attestationStatementSupportManager);
$serializer = $factory->create();
$jsonOptions = $serializer->serialize(
$credentialOptions,
'json',
[
AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
JsonEncode::OPTIONS => JSON_THROW_ON_ERROR,
]
);
// TODO - Change
$this->setPageTitle(trans('auth.mfa_gen_hardware_key_title'));
return view('mfa.hardware-key-generate', [
'options' => $jsonOptions,
]);
}
/**
* Confirm the setup of a hardware key, storing the key value against the user.
*
* @throws Exception
*/
public function confirm()
{
return redirect('/mfa/setup');
}
/**
* Verify the MFA method submission on check.
*
* @throws NotFoundException
* @throws ValidationException
*/
public function verify(Request $request, MfaSession $mfaSession, LoginService $loginService)
{
$user = $this->currentOrLastAttemptedUser();
$this->limiter->incrementAttempts($user, $request);
if ($this->limiter->hasHitLimit($user, $request)) {
$this->clearLastAttemptedUser();
$this->limiter->throwException();
}
}
}

View File

@@ -23,13 +23,14 @@ class MfaValue extends Model
const METHOD_TOTP = 'totp';
const METHOD_BACKUP_CODES = 'backup_codes';
const METHOD_HARDWARE_KEY = 'hardware_key';
/**
* Get all the MFA methods available.
*/
public static function allMethods(): array
{
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES, self::METHOD_HARDWARE_KEY];
}
/**

View File

@@ -40,6 +40,7 @@
"socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^2.0.0",
"web-auth/webauthn-lib": "^5.3",
"xemlock/htmlpurifier-html5": "^0.1.12"
},
"require-dev": {

1035
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
@extends('layouts.simple')
@section('body')
<div class="container very-small py-xl">
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('auth.mfa_gen_hardware_key_title') }}</h1>
<p>{{ trans('auth.mfa_gen_hardware_key_desc') }}</p>
<div class="text-center mb-xs">
<button type="button" id="setup">setup_key</button>
</div>
<form action="{{ url('/mfa/hardware_key/confirm') }}" method="POST">
{{ csrf_field() }}
<div class="mt-s text-right">
<a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button class="button">{{ trans('auth.mfa_gen_confirm_and_enable') }}</button>
</div>
</form>
</div>
</div>
<script type="module" nonce="{{ $cspNonce }}">
// TODO - Extract to its own compontent
// TODO - Add some guidance and call this on click
// https://webauthn-doc.spomky-labs.com/prerequisites/javascript#using-native-browser-api
const setupButton = document.getElementById('setup');
const options = {!! $options !!};
const publicKeyCredentialCreationOptions = {
...options,
challenge: base64urlDecode(options.challenge),
user: {
...options.user,
id: base64urlDecode(options.user.id)
},
excludeCredentials: options.excludeCredentials?.map(cred => ({
...cred,
id: base64urlDecode(cred.id)
}))
};
setupButton.addEventListener('click', async () => {
console.log(publicKeyCredentialCreationOptions);
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
});
console.log(credential);
});
function base64urlEncode(buffer) {
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function base64urlDecode(base64url) {
const base64 = base64url
.replace(/-/g, '+')
.replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
</script>
@stop

View File

@@ -8,7 +8,7 @@
<p class="mb-none"> {{ trans('auth.mfa_setup_desc') }}</p>
<div class="setting-list">
@foreach(['totp', 'backup_codes'] as $method)
@foreach(\BookStack\Access\Mfa\MfaValue::allMethods() as $method)
@include('mfa.parts.setup-method-row', ['method' => $method])
@endforeach
</div>

View File

@@ -319,6 +319,8 @@ Route::middleware('mfa-setup')->group(function () {
Route::post('/mfa/totp/confirm', [AccessControllers\MfaTotpController::class, 'confirm']);
Route::get('/mfa/backup_codes/generate', [AccessControllers\MfaBackupCodesController::class, 'generate']);
Route::post('/mfa/backup_codes/confirm', [AccessControllers\MfaBackupCodesController::class, 'confirm']);
Route::get('/mfa/hardware_key/generate', [AccessControllers\MfaHardwareKeyController::class, 'generate']);
Route::post('/mfa/hardware_key/confirm', [AccessControllers\MfaHardwareKeyController::class, 'confirm']);
});
Route::middleware('guest')->group(function () {
Route::get('/mfa/verify', [AccessControllers\MfaController::class, 'verify']);