Compare commits

..

2 Commits

Author SHA1 Message Date
Dan Brown
c01217827c MFA: Started exploring hardware key support 2026-06-01 13:08:58 +01:00
Dan Brown
f01bb749ab Workflows: Attempted fixing crowdin files
Currently causing extra files to be created alongside previous files in
crowdin
2026-05-30 13:45:25 +01:00
18 changed files with 1253 additions and 140 deletions

View File

@@ -22,6 +22,7 @@ jobs:
- name: crowdin action
uses: https://github.com/crowdin/github-action@v2
with:
crowdin_branch_name: development
upload_sources: true
upload_translations: false
download_translations: true

6
.gitignore vendored
View File

@@ -9,10 +9,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/public/favicon.ico

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

@@ -1 +1 @@
fc31a87fca03999de11755ea0aa41c1bcd376a271a5ee30277ebe9f68b9c9fab
22e02ee72d21ff719c1073abbec8302f8e2096ba6d072e133051064ed24b45b1

33
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

32
public/dist/code.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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']);

View File

@@ -1 +1 @@
v26.05
v26.05-dev