mirror of
https://github.com/getnamingo/registry.git
synced 2025-06-28 07:03:28 +02:00
Added login via WebAuth; testing needed
This commit is contained in:
parent
63607618e2
commit
68d54f7592
4 changed files with 255 additions and 4 deletions
|
@ -15,6 +15,32 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
*/
|
*/
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
private $webAuthn;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$rpName = 'Namingo';
|
||||||
|
$rpId = envi('APP_DOMAIN');
|
||||||
|
$formats = [
|
||||||
|
'android-key',
|
||||||
|
'android-safetynet',
|
||||||
|
'apple',
|
||||||
|
'fido-u2f',
|
||||||
|
'none',
|
||||||
|
'packed',
|
||||||
|
'tpm'
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->webAuthn = new \lbuchs\WebAuthn\WebAuthn($rpName, $rpId, $formats);
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/solo.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/apple.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/yubico.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/hypersecu.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/globalSign.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/googleHardware.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/microsoftTpmCollection.pem');
|
||||||
|
$this->webAuthn->addRootCertificates(envi('APP_ROOT').'/vendor/lbuchs/webauthn/_test/rootCertificates/mds');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Request $request
|
* @param Request $request
|
||||||
* @param Response $response
|
* @param Response $response
|
||||||
|
@ -57,4 +83,102 @@ class AuthController extends Controller
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
redirect()->route('login');
|
redirect()->route('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLoginChallenge(Request $request, Response $response)
|
||||||
|
{
|
||||||
|
global $container;
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
$rawData = $request->getBody();
|
||||||
|
$data = json_decode($rawData, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = $container->get('db');
|
||||||
|
$user = $db->selectValue('SELECT id FROM users WHERE email = ?', [$data['email']]);
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
// User found, get the user ID
|
||||||
|
$userId = $user;
|
||||||
|
$registrations = $db->select('SELECT id FROM users_webauthn WHERE user_id = ?', [$user]);
|
||||||
|
|
||||||
|
if ($registrations) {
|
||||||
|
foreach ($registrations as $reg) {
|
||||||
|
$ids[] = $reg['credentialId'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($ids) === 0) {
|
||||||
|
$response->getBody()->write(json_encode(['error' => 'no registrations in session for userId ' . $userId]));
|
||||||
|
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$response->getBody()->write(json_encode(['error' => 'No user found with the provided email.']));
|
||||||
|
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||||||
|
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getArgs = $this->webAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, 'required');
|
||||||
|
|
||||||
|
$response->getBody()->write(json_encode($getArgs));
|
||||||
|
$challenge = $this->webAuthn->getChallenge();
|
||||||
|
$_SESSION['challenge_data'] = $challenge->getBinaryString();
|
||||||
|
|
||||||
|
return $response->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyLogin(Request $request, Response $response)
|
||||||
|
{
|
||||||
|
global $container;
|
||||||
|
|
||||||
|
$challengeData = $_SESSION['challenge_data'];
|
||||||
|
$challenge = new \lbuchs\WebAuthn\Binary\ByteBuffer($challengeData);
|
||||||
|
$credentialPublicKey = null;
|
||||||
|
|
||||||
|
$data = json_decode($request->getBody()->getContents(), null, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode the incoming data
|
||||||
|
$clientDataJSON = base64_decode($data->clientDataJSON);
|
||||||
|
$authenticatorData = base64_decode($data->authenticatorData);
|
||||||
|
$signature = base64_decode($data->signature);
|
||||||
|
$userHandle = base64_decode($data->userHandle);
|
||||||
|
$id = base64_decode($data->id);
|
||||||
|
|
||||||
|
$db = $container->get('db');
|
||||||
|
$credential = $db->select('SELECT public_key FROM users_webauthn WHERE user_id = ?', [$id]);
|
||||||
|
|
||||||
|
if ($credential) {
|
||||||
|
foreach ($registrations as $reg) {
|
||||||
|
$credentialPublicKey = $reg['public_key'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($credentialPublicKey === null) {
|
||||||
|
throw new Exception('Public Key for credential ID not found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we have resident key, we have to verify that the userHandle is the provided userId at registration
|
||||||
|
if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
|
||||||
|
throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// process the get request. throws WebAuthnException if it fails
|
||||||
|
$this->webAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');
|
||||||
|
|
||||||
|
$return = new \stdClass();
|
||||||
|
$return->success = true;
|
||||||
|
$return->msg = $msg;
|
||||||
|
|
||||||
|
// Send success response
|
||||||
|
$response->getBody()->write(json_encode($return));
|
||||||
|
return $response->withHeader('Content-Type', 'application/json');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||||||
|
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -201,6 +201,12 @@ $csrfMiddleware = function ($request, $handler) use ($container) {
|
||||||
if ($path && $path === '/webauthn/register/verify') {
|
if ($path && $path === '/webauthn/register/verify') {
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
if ($path && $path === '/webauthn/login/challenge') {
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
if ($path && $path === '/webauthn/login/verify') {
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
// If not skipped, apply the CSRF Guard
|
// If not skipped, apply the CSRF Guard
|
||||||
return $csrf->process($request, $handler);
|
return $csrf->process($request, $handler);
|
||||||
|
|
|
@ -51,16 +51,133 @@
|
||||||
<div class="hr-text">or</div>
|
<div class="hr-text">or</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col"><a href="#" class="btn btn-secondary w-100">
|
<div class="col"><button type="button" id="loginWebAuthnButton" class="btn btn-secondary w-100">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0z" /><path d="M15 9h.01" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0z" /><path d="M15 9h.01" /></svg>
|
||||||
Login with WebAuthn
|
Login with WebAuthn
|
||||||
</a></div>
|
</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>const passwordInput = document.querySelector('input[name="password"]');
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
const loginButton = document.getElementById('loginWebAuthnButton');
|
||||||
|
const emailInput = document.querySelector('input[name="email"]');
|
||||||
|
const passwordInput = document.querySelector('input[name="password"]');
|
||||||
|
const codeInput = document.querySelector('input[name="code"]');
|
||||||
|
|
||||||
|
loginButton.addEventListener('click', async function() {
|
||||||
|
try {
|
||||||
|
// Disable password and 2FA fields
|
||||||
|
passwordInput.disabled = true;
|
||||||
|
codeInput.disabled = true;
|
||||||
|
|
||||||
|
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
|
||||||
|
throw new Error('Browser not supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get check args
|
||||||
|
let rep = await window.fetch('/webauthn/login/challenge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: emailInput.value }),
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
const getArgs = await rep.json();
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
if (getArgs.success === false) {
|
||||||
|
throw new Error(getArgs.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace binary base64 data with ArrayBuffer. a other way to do this
|
||||||
|
// is the reviver function of JSON.parse()
|
||||||
|
recursiveBase64StrToArrayBuffer(getArgs);
|
||||||
|
|
||||||
|
// check credentials with hardware
|
||||||
|
const cred = await navigator.credentials.get(getArgs);
|
||||||
|
|
||||||
|
// create object for transmission to server
|
||||||
|
const authenticatorAttestationResponse = {
|
||||||
|
id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
|
||||||
|
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
|
||||||
|
authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
|
||||||
|
signature: cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null,
|
||||||
|
userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// send to server
|
||||||
|
rep = await window.fetch('/webauthn/login/verify', {
|
||||||
|
method:'POST',
|
||||||
|
body: JSON.stringify(authenticatorAttestationResponse),
|
||||||
|
cache:'no-cache'
|
||||||
|
});
|
||||||
|
const authenticatorAttestationServerResponse = await rep.json();
|
||||||
|
|
||||||
|
// check server response
|
||||||
|
if (authenticatorAttestationServerResponse.success) {
|
||||||
|
//reloadServerPreview();
|
||||||
|
window.alert(authenticatorAttestationServerResponse.msg || 'login success');
|
||||||
|
} else {
|
||||||
|
throw new Error(authenticatorAttestationServerResponse.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
//reloadServerPreview();
|
||||||
|
window.alert(err.message || 'unknown error occured');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convert RFC 1342-like base64 strings to array buffer
|
||||||
|
* @param {mixed} obj
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function recursiveBase64StrToArrayBuffer(obj) {
|
||||||
|
let prefix = '=?BINARY?B?';
|
||||||
|
let suffix = '?=';
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
for (let key in obj) {
|
||||||
|
if (typeof obj[key] === 'string') {
|
||||||
|
let str = obj[key];
|
||||||
|
if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
|
||||||
|
str = str.substring(prefix.length, str.length - suffix.length);
|
||||||
|
|
||||||
|
let binary_string = window.atob(str);
|
||||||
|
let len = binary_string.length;
|
||||||
|
let bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
obj[key] = bytes.buffer;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recursiveBase64StrToArrayBuffer(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a ArrayBuffer to Base64
|
||||||
|
* @param {ArrayBuffer} buffer
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
let binary = '';
|
||||||
|
let bytes = new Uint8Array(buffer);
|
||||||
|
let len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode( bytes[ i ] );
|
||||||
|
}
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
const togglePasswordBtn = document.querySelector('.input-group-text a');
|
const togglePasswordBtn = document.querySelector('.input-group-text a');
|
||||||
|
|
||||||
togglePasswordBtn.addEventListener('click', function() {
|
togglePasswordBtn.addEventListener('click', function() {
|
||||||
|
@ -78,5 +195,7 @@
|
||||||
togglePasswordBtn.setAttribute('title', 'Show password');
|
togglePasswordBtn.setAttribute('title', 'Show password');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -26,6 +26,8 @@ $app->get('/', HomeController::class .':index')->setName('index');
|
||||||
$app->group('', function ($route) {
|
$app->group('', function ($route) {
|
||||||
$route->get('/login', AuthController::class . ':createLogin')->setName('login');
|
$route->get('/login', AuthController::class . ':createLogin')->setName('login');
|
||||||
$route->post('/login', AuthController::class . ':login');
|
$route->post('/login', AuthController::class . ':login');
|
||||||
|
$route->post('/webauthn/login/challenge', AuthController::class . ':getLoginChallenge')->setName('webauthn.login.challenge');
|
||||||
|
$route->post('/webauthn/login/verify', AuthController::class . ':verifyLogin')->setName('webauthn.login.verify');
|
||||||
$route->get('/forgot-password', PasswordController::class . ':createForgotPassword')->setName('forgot.password');
|
$route->get('/forgot-password', PasswordController::class . ':createForgotPassword')->setName('forgot.password');
|
||||||
$route->post('/forgot-password', PasswordController::class . ':forgotPassword');
|
$route->post('/forgot-password', PasswordController::class . ':forgotPassword');
|
||||||
$route->get('/reset-password', PasswordController::class.':resetPassword')->setName('reset.password');
|
$route->get('/reset-password', PasswordController::class.':resetPassword')->setName('reset.password');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue