Use jbtronics/2fa-webauthn for u2f two factor authentication

This commit is contained in:
Jan Böhmer 2022-10-03 23:09:50 +02:00
parent 03aaff3c79
commit 068daeda75
18 changed files with 1389 additions and 604 deletions

View file

@ -1,134 +0,0 @@
import u2fApi from 'u2f-api'
'use strict'
window.u2fauth = window.u2fauth || {}
u2fauth.formId = 'u2fForm'
u2fauth.authCodeId = '_auth_code'
u2fauth.keynameId = 'u2fkeyname'
u2fauth.pressButtonId = 'u2fpressbutton'
u2fauth.errorId = 'u2fError'
u2fauth.timeout = 30
u2fauth.errorTranslation = {
1: 'Unknown Error',
2: 'Bad Request',
3: 'Client configuration not supported',
4: 'Device already registered or ineligible',
5: 'Timeout. Click to retry'
}
u2fauth.ready = function (fn) {
if (document.readyState !== 'loading') {
fn()
} else if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', fn)
} else {
document.attachEvent('onreadystatechange', function () {
if (document.readyState !== 'loading') { fn() }
})
}
}
u2fauth.authenticate = function () {
u2fauth.clearError()
u2fauth.showPressButton()
var form = document.getElementById(u2fauth.formId)
var request = JSON.parse(form.dataset.request)
u2fApi.isSupported()
.then(function (supported) {
if (supported) {
return u2fApi.sign(request, u2fauth.timeout)
.then(response => {
u2fauth.hidePressButton()
u2fauth.submit(form, response)
})
} else {
alert('Browser not supported')
}
})
.catch(data => {
u2fauth.hidePressButton()
u2fauth.showError(data.metaData.code, u2fauth.authenticate)
})
}
u2fauth.register = function () {
u2fauth.clearError()
u2fauth.hideKeyname()
u2fauth.showPressButton()
var form = document.getElementById(u2fauth.formId)
var request = JSON.parse(form.dataset.request)
u2fApi.isSupported()
.then(function (supported) {
if (supported) {
return u2fApi.register(request[0], request[1], u2fauth.timeout)
.then(response => {
u2fauth.hidePressButton()
u2fauth.submit(form, response)
})
} else {
alert('Browser not supported')
}
})
.catch(data => {
console.info(data)
u2fauth.hidePressButton()
u2fauth.showError(data.metaData.code, u2fauth.register)
})
}
u2fauth.submit = function (form, data) {
var codeField = document.getElementById(u2fauth.authCodeId)
codeField.value = JSON.stringify(data)
form.submit()
}
u2fauth.hideKeyname = function () {
var keyname = document.getElementById(u2fauth.keynameId)
keyname.style.display = 'none'
}
u2fauth.hidePressButton = function () {
var pressButton = document.getElementById(u2fauth.pressButtonId)
pressButton.style.display = 'none'
}
u2fauth.showPressButton = function () {
var pressButton = document.getElementById(u2fauth.pressButtonId)
pressButton.style.display = 'block'
}
u2fauth.clearError = function () {
var errorDisplay = document.getElementById(u2fauth.errorId)
errorDisplay.style.display = 'none'
errorDisplay.innerText = ''
}
u2fauth.showError = function (error, callback) {
var errorDisplay = document.getElementById(u2fauth.errorId)
errorDisplay.style.display = 'block'
errorDisplay.innerText = u2fauth.errorTranslation[error]
errorDisplay.onclick = callback
}
u2fauth.ready(function () {
const form = document.getElementById('u2fForm')
if (!form) {
return
}
const type = form.dataset.action
if (type === 'auth') {
u2fauth.authenticate()
} else if (type === 'reg' && form.addEventListener) {
form.addEventListener('submit', function (event) {
event.preventDefault()
u2fauth.register()
}, false)
}
})

218
assets/js/webauthn_tfa.js Normal file
View file

@ -0,0 +1,218 @@
'use strict'
class WebauthnTFA {
// Decodes a Base64Url string
_base64UrlDecode = (input) => {
input = input
.replace(/-/g, '+')
.replace(/_/g, '/');
const pad = input.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
input += new Array(5-pad).join('=');
}
return window.atob(input);
};
// Converts an array of bytes into a Base64Url string
_arrayToBase64String = (a) => btoa(String.fromCharCode(...a));
// Prepares the public key options object returned by the Webauthn Framework
_preparePublicKeyOptions = publicKey => {
//Convert challenge from Base64Url string to Uint8Array
publicKey.challenge = Uint8Array.from(
this._base64UrlDecode(publicKey.challenge),
c => c.charCodeAt(0)
);
//Convert the user ID from Base64 string to Uint8Array
if (publicKey.user !== undefined) {
publicKey.user = {
...publicKey.user,
id: Uint8Array.from(
window.atob(publicKey.user.id),
c => c.charCodeAt(0)
),
};
}
//If excludeCredentials is defined, we convert all IDs to Uint8Array
if (publicKey.excludeCredentials !== undefined) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(
data => {
return {
...data,
id: Uint8Array.from(
this._base64UrlDecode(data.id),
c => c.charCodeAt(0)
),
};
}
);
}
if (publicKey.allowCredentials !== undefined) {
publicKey.allowCredentials = publicKey.allowCredentials.map(
data => {
return {
...data,
id: Uint8Array.from(
this._base64UrlDecode(data.id),
c => c.charCodeAt(0)
),
};
}
);
}
return publicKey;
};
// Prepares the public key credentials object returned by the authenticator
_preparePublicKeyCredentials = data => {
const publicKeyCredential = {
id: data.id,
type: data.type,
rawId: this._arrayToBase64String(new Uint8Array(data.rawId)),
response: {
clientDataJSON: this._arrayToBase64String(
new Uint8Array(data.response.clientDataJSON)
),
},
};
if (data.response.attestationObject !== undefined) {
publicKeyCredential.response.attestationObject = this._arrayToBase64String(
new Uint8Array(data.response.attestationObject)
);
}
if (data.response.authenticatorData !== undefined) {
publicKeyCredential.response.authenticatorData = this._arrayToBase64String(
new Uint8Array(data.response.authenticatorData)
);
}
if (data.response.signature !== undefined) {
publicKeyCredential.response.signature = this._arrayToBase64String(
new Uint8Array(data.response.signature)
);
}
if (data.response.userHandle !== undefined) {
publicKeyCredential.response.userHandle = this._arrayToBase64String(
new Uint8Array(data.response.userHandle)
);
}
return publicKeyCredential;
};
constructor()
{
const register_dom_ready = (fn) => {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
register_dom_ready(() => {
this.registerForms();
});
}
registerForms()
{
//Find all forms which have an data-webauthn-tfa-action attribute
const forms = document.querySelectorAll('form[data-webauthn-tfa-action]');
forms.forEach((form) => {
console.debug('Found webauthn TFA form with action: ' + form.getAttribute('data-webauthn-tfa-action'), form);
//Ensure that the form has webauthn data
const dataString = form.getAttribute('data-webauthn-tfa-data')
const action = form.getAttribute('data-webauthn-tfa-action');
if (!dataString) {
console.error('Form does not have webauthn data, can not continue!', form);
return;
}
//Convert dataString to the needed dataObject
const dataObject = JSON.parse(dataString);
const options = this._preparePublicKeyOptions(dataObject);
if(action === 'authenticate'){
this.authenticate(form, {publicKey: options});
}
if(action === 'register'){
//Register submit action, so we can do the registration on submit
form.addEventListener('submit', (e) => {
e.preventDefault();
this.register(form, {publicKey: options});
});
}
//Catch submit event and do webauthn stuff
});
}
/**
* Submit the form with the given result data
* @param form
* @param data
* @private
*/
_submit(form, data)
{
const resultField = document.getElementById('_auth_code');
resultField.value = JSON.stringify(data)
form.submit();
}
authenticate(form, authData)
{
navigator.credentials.get(authData)
.then((credential) => {
//Convert our credential to a form which can be JSON encoded
let data = this._preparePublicKeyCredentials(credential);
this._submit(form, data)
})
.catch((error) => {
console.error("WebAuthn Authentication error: ", error);
alert("Error: " + error)
});
}
register(form, authData)
{
navigator.credentials.create(authData)
.then((credential) => {
//Convert our credential to a form which can be JSON encoded
let data = this._preparePublicKeyCredentials(credential);
this._submit(form, data)
})
.catch((error) => {
console.error("WebAuthn Registration error: ", error);
alert("Error: " + error)
});
}
}
window.webauthnTFA = new WebauthnTFA();

View file

@ -10,7 +10,7 @@
"ext-json": "*",
"ext-mbstring": "*",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.10.2",
"brick/math": "^0.8.15",
"composer/package-versions-deprecated": "1.11.99.4",
"doctrine/annotations": "^1.6",
"doctrine/doctrine-bundle": "^2.0",
@ -70,7 +70,7 @@
"twig/intl-extra": "^3.0",
"twig/markdown-extra": "^3.0",
"webmozart/assert": "^1.4",
"r/u2f-two-factor-bundle": "dev-scheb/2fa-support"
"jbtronics/2fa-webauthn": "dev-master"
},
"require-dev": {
"dama/doctrine-test-bundle": "^7.0",
@ -147,6 +147,10 @@
{
"type": "vcs",
"url": "https://github.com/jbtronics/u2f-two-factor-bundle.git"
},
{
"type": "path",
"url": "../2fa-webauthn"
}
]
}

1465
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,6 @@ return [
Florianv\SwapBundle\FlorianvSwapBundle::class => ['all' => true],
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Jbtronics\TFAWebauthn\TFAWebauthnBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
R\U2FTwoFactorBundle\RU2FTwoFactorBundle::class => ['all' => true],
];

View file

@ -1,4 +0,0 @@
ru2_f_two_factor:
formTemplate: "/security/U2F/u2f_login.html.twig"
registerTemplate: "/security/U2F/u2f_register.html.twig"
authCodeParameter: _auth_code

View file

@ -25,4 +25,4 @@ scheb_two_factor:
# If you're using guard-based authentication, you have to use this one:
# - Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken
# If you're using authenticator-based security (introduced in Symfony 5.1), you have to use this one:
# - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
# - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

View file

@ -0,0 +1,5 @@
tfa_webauthn:
enabled: true
template: 'Security/Webauthn/webauthn_login.html.twig'
rpName: '%partdb.title%'

View file

@ -4,8 +4,4 @@
_controller: "scheb_two_factor.form_controller::form"
2fa_login_check:
path: /{_locale}/2fa_check
r_u2f_register:
resource: "@RU2FTwoFactorBundle/Resources/config/routing.yml"
prefix: /{_locale}/user
path: /{_locale}/2fa_check

View file

@ -0,0 +1,41 @@
<?php
namespace App\Controller;
use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class WebauthnKeyRegistrationController extends AbstractController
{
/**
* @Route("/webauthn/register", name="webauthn_register")
*/
public function register(Request $request, TFAWebauthnRegistrationHelper $registrationHelper)
{
//If form was submitted, check the auth response
if ($request->getMethod() === 'POST') {
$webauthnResponse = $request->request->get('_auth_code');
//Retrieve other data from the form, that you want to store with the key
$keyName = $request->request->get('keyName');
//Check the response
$new_key = $registrationHelper->checkRegistrationResponse($webauthnResponse);
dump($new_key);
$this->addFlash('success', 'Key registered successfully');
}
return $this->render(
'Security/U2F/u2f_register.html.twig',
[
'registrationRequest' => $registrationHelper->generateRegistrationRequestAsJSON(),
]
);
}
}

View file

@ -44,8 +44,7 @@ namespace App\Entity\UserSystem;
use App\Entity\Base\TimestampTrait;
use Doctrine\ORM\Mapping as ORM;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorKeyInterface;
use u2flib_server\Registration;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
/**
* @ORM\Entity
@ -56,7 +55,7 @@ use u2flib_server\Registration;
* })
* @ORM\HasLifecycleCallbacks()
*/
class U2FKey implements TwoFactorKeyInterface
class U2FKey implements LegacyU2FKeyInterface
{
use TimestampTrait;
@ -110,14 +109,6 @@ class U2FKey implements TwoFactorKeyInterface
**/
protected ?User $user = null;
public function fromRegistrationData(Registration $data): void
{
$this->keyHandle = $data->keyHandle;
$this->publicKey = $data->publicKey;
$this->certificate = $data->certificate;
$this->counter = $data->counter;
}
public function getKeyHandle(): string
{
return $this->keyHandle;

View file

@ -58,6 +58,7 @@ use App\Security\Interfaces\HasPermissionsInterface;
use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPermission;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Webauthn\PublicKeyCredentialUserEntity;
use function count;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
@ -65,8 +66,6 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use function in_array;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorInterface as U2FTwoFactorInterface;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorKeyInterface;
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Model\PreferredProviderInterface;
@ -74,6 +73,7 @@ use Scheb\TwoFactorBundle\Model\TrustedDeviceInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface;
/**
* This entity represents a user, which can log in and have permissions.
@ -86,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
* @UniqueEntity("name", message="validator.user.username_already_used")
*/
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, U2FTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
{
//use MasterAttachmentTrait;
@ -838,48 +838,38 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
++$this->trustedDeviceCookieVersion;
}
/**
* Check if U2F is enabled.
*/
public function isU2FAuthEnabled(): bool
{
return count($this->u2fKeys) > 0;
}
/**
* Get all U2F Keys that are associated with this user.
*
* @psalm-return Collection<int, TwoFactorKeyInterface>
*/
public function getU2FKeys(): Collection
{
return $this->u2fKeys;
}
/**
* Add a U2F key to this user.
*/
public function addU2FKey(TwoFactorKeyInterface $key): void
{
$this->u2fKeys->add($key);
}
/**
* Remove a U2F key from this user.
*/
public function removeU2FKey(TwoFactorKeyInterface $key): void
{
$this->u2fKeys->removeElement($key);
}
public function getPreferredTwoFactorProvider(): ?string
{
//If U2F is available then prefer it
if ($this->isU2FAuthEnabled()) {
return 'u2f_two_factor';
}
//if ($this->isU2FAuthEnabled()) {
// return 'u2f_two_factor';
//}
//Otherwise use other methods
return null;
}
public function isWebAuthnAuthenticatorEnabled(): bool
{
return count($this->u2fKeys) > 0;
}
public function getLegacyU2FKeys(): iterable
{
return $this->u2fKeys;
}
public function getWebAuthnUser(): PublicKeyCredentialUserEntity
{
return new PublicKeyCredentialUserEntity(
$this->getUsername(),
(string) $this->getId(),
$this->getFullName(),
);
}
public function getWebauthnKeys(): iterable
{
return [];
}
}

View file

@ -146,7 +146,7 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
*/
public static function TFARedirectNeeded(User $user): bool
{
$tfa_enabled = $user->isU2FAuthEnabled() || $user->isGoogleAuthenticatorEnabled();
$tfa_enabled = $user->isWebAuthnAuthenticatorEnabled() || $user->isGoogleAuthenticatorEnabled();
return null !== $user->getGroup() && $user->getGroup()->isEnforce2FA() && !$tfa_enabled;
}

View file

@ -176,6 +176,9 @@
"imagine/imagine": {
"version": "1.2.2"
},
"jbtronics/2fa-webauthn": {
"version": "dev-master"
},
"laminas/laminas-code": {
"version": "3.4.1"
},
@ -252,9 +255,6 @@
"openlss/lib-array2xml": {
"version": "1.0.0"
},
"paragonie/random_compat": {
"version": "v9.99.99"
},
"phenx/php-font-lib": {
"version": "0.5.1"
},
@ -348,9 +348,6 @@
"psr/simple-cache": {
"version": "1.0.1"
},
"r/u2f-two-factor-bundle": {
"version": "dev-scheb/2fa-support"
},
"roave/security-advisories": {
"version": "dev-master"
},

View file

@ -52,7 +52,7 @@
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{{ encore_entry_script_tags('ru2ftwofactor') }}
{{ encore_entry_script_tags('webauthn_tfa') }}
{# load translation files for ckeditor #}
{% set two_chars_locale = app.request.locale|default("en")|slice(0,2) %}

View file

@ -10,13 +10,13 @@
<p class="text-warning"><b><i class="fas fa-exclamation-triangle fa-fw"></i> {% trans %}tfa_u2f.http_warning{% endtrans %}</b></p>
{% endif %}
<form method="post" class="form" action="{{ path('club_base_register_u2f') }}" id="u2fForm" data-action="reg" data-request='{{ registrationRequest|raw }}'>
<form method="post" class="form" action="{{ path('webauthn_register') }}" data-webauthn-tfa-action="register" data-webauthn-tfa-data='{{ registrationRequest|raw }}'>
<div id="u2fkeyname" class="form-group row">
<div class="col-9">
<input type="text" class="form-control " name="keyName" id="keyName" placeholder="{{ 'r_u2f_two_factor.name'|trans }}"/>
<input type="text" class="form-control " name="keyName" id="keyName" placeholder="Shown key name"/>
</div>
<div class="col-3">
<button type="button" class="btn btn-success" {{ stimulus_controller('pages/u2f_register') }}>{% trans %}tfa_u2f.add_key.add_button{% endtrans %}</button>
<button type="submit" class="btn btn-success">{% trans %}tfa_u2f.add_key.add_button{% endtrans %}</button>
</div>
</div>
<div id="u2fpressbutton" style="display: none;" class="text-center h4">{{ 'r_u2f_two_factor.pressbutton'|trans }}</div>

View file

@ -1,14 +1,12 @@
{% extends "security/2fa_base_form.html.twig" %}
{% block form_attributes %}id="u2fForm" data-action="auth" data-request='{{ authenticationData|raw }}'{% endblock %}
{% block form_attributes %} data-webauthn-tfa-action="authenticate" data-webauthn-tfa-data='{{ webauthn_request_data|raw }}'{% endblock %}
{% block form %}
{% if not app.request.secure %}
<p class="text-warning"><b><i class="fas fa-exclamation-triangle fa-fw"></i> {% trans %}tfa_u2f.http_warning{% endtrans %}</b></p>
{% endif %}
<div id="u2fpressbutton" style="display: none;" class="h4 text-center">{{ 'r_u2f_two_factor.pressbutton'|trans }}</div>
<div id="u2fError"></div>
<p class="widget"><input id="_auth_code" type="hidden" autocomplete="off" name="_auth_code" /></p>
<a class="ms-2" href="{{ logoutPath }}">{% trans %}user.logout{% endtrans %}</a>
{% endblock %}

View file

@ -59,7 +59,7 @@ Encore
* and one CSS file (e.g. app.css) if you JavaScript imports CSS.
*/
.addEntry('app', './assets/js/app.js')
.addEntry('ru2ftwofactor', './assets/js/u2f_auth.js')
.addEntry('webauthn_tfa', './assets/js/webauthn_tfa.js')
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
.enableStimulusBridge('./assets/controllers.json')