mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-17 05:33:47 +03:00
Compare commits
1 Commits
developmen
...
mfa_key
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c01217827c |
133
app/Access/Controllers/MfaHardwareKeyController.php
Normal file
133
app/Access/Controllers/MfaHardwareKeyController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,13 +23,14 @@ class MfaValue extends Model
|
|||||||
|
|
||||||
const METHOD_TOTP = 'totp';
|
const METHOD_TOTP = 'totp';
|
||||||
const METHOD_BACKUP_CODES = 'backup_codes';
|
const METHOD_BACKUP_CODES = 'backup_codes';
|
||||||
|
const METHOD_HARDWARE_KEY = 'hardware_key';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the MFA methods available.
|
* Get all the MFA methods available.
|
||||||
*/
|
*/
|
||||||
public static function allMethods(): array
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"socialiteproviders/okta": "^4.2",
|
"socialiteproviders/okta": "^4.2",
|
||||||
"socialiteproviders/twitch": "^5.3",
|
"socialiteproviders/twitch": "^5.3",
|
||||||
"ssddanbrown/htmldiff": "^2.0.0",
|
"ssddanbrown/htmldiff": "^2.0.0",
|
||||||
|
"web-auth/webauthn-lib": "^5.3",
|
||||||
"xemlock/htmlpurifier-html5": "^0.1.12"
|
"xemlock/htmlpurifier-html5": "^0.1.12"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
1035
composer.lock
generated
1035
composer.lock
generated
File diff suppressed because it is too large
Load Diff
74
resources/views/mfa/hardware-key-generate.blade.php
Normal file
74
resources/views/mfa/hardware-key-generate.blade.php
Normal 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
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<p class="mb-none"> {{ trans('auth.mfa_setup_desc') }}</p>
|
<p class="mb-none"> {{ trans('auth.mfa_setup_desc') }}</p>
|
||||||
|
|
||||||
<div class="setting-list">
|
<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])
|
@include('mfa.parts.setup-method-row', ['method' => $method])
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -319,6 +319,8 @@ Route::middleware('mfa-setup')->group(function () {
|
|||||||
Route::post('/mfa/totp/confirm', [AccessControllers\MfaTotpController::class, 'confirm']);
|
Route::post('/mfa/totp/confirm', [AccessControllers\MfaTotpController::class, 'confirm']);
|
||||||
Route::get('/mfa/backup_codes/generate', [AccessControllers\MfaBackupCodesController::class, 'generate']);
|
Route::get('/mfa/backup_codes/generate', [AccessControllers\MfaBackupCodesController::class, 'generate']);
|
||||||
Route::post('/mfa/backup_codes/confirm', [AccessControllers\MfaBackupCodesController::class, 'confirm']);
|
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::middleware('guest')->group(function () {
|
||||||
Route::get('/mfa/verify', [AccessControllers\MfaController::class, 'verify']);
|
Route::get('/mfa/verify', [AccessControllers\MfaController::class, 'verify']);
|
||||||
|
|||||||
Reference in New Issue
Block a user