mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update passkeys support
This commit is contained in:
@@ -2,7 +2,10 @@ import Alpine from '@alpinejs/csp';
|
||||
|
||||
Alpine.data('passkeyLogin', () => ({
|
||||
supported: false,
|
||||
supportsAutofill: false,
|
||||
busy: false,
|
||||
browserPasskeyPending: false,
|
||||
browserPasskeyStarted: false,
|
||||
error: '',
|
||||
remember: false,
|
||||
showCreateHint: false,
|
||||
@@ -11,21 +14,35 @@ Alpine.data('passkeyLogin', () => ({
|
||||
init() {
|
||||
this.supported = typeof window.browserSupportsWebAuthn === 'function'
|
||||
&& window.browserSupportsWebAuthn();
|
||||
|
||||
// If backend already reported an invalid passkey login attempt,
|
||||
// immediately show the "sign in first, then create passkey" guidance.
|
||||
this.showCreateHint = this.$el.dataset.serverPasskeyError === '1';
|
||||
this.remember = this.$el.dataset.rememberDefault === '1';
|
||||
|
||||
const shouldAutoPrompt = this.$el.dataset.autoPrompt === '1';
|
||||
void this.detectAutofillSupport(shouldAutoPrompt);
|
||||
},
|
||||
|
||||
async detectAutofillSupport(shouldAutoPrompt) {
|
||||
this.supportsAutofill = typeof window.browserSupportsWebAuthnAutofill === 'function'
|
||||
&& this.supported
|
||||
&& await window.browserSupportsWebAuthnAutofill();
|
||||
|
||||
if (this.supported && shouldAutoPrompt && !this.showCreateHint) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (this.hasAutoPrompted || this.busy) {
|
||||
if (this.hasAutoPrompted || this.busy || this.browserPasskeyStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasAutoPrompted = true;
|
||||
void this.authenticate();
|
||||
if (this.supportsAutofill) {
|
||||
void this.startBrowserPasskeyFallback({ silent: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldAutoPrompt) {
|
||||
void this.authenticate();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -36,33 +53,22 @@ Alpine.data('passkeyLogin', () => ({
|
||||
this.busy = true;
|
||||
|
||||
try {
|
||||
this.copyCaptchaResponse();
|
||||
|
||||
const rawOptionsUrl = this.$el.dataset.optionsUrl;
|
||||
const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined')
|
||||
? '/passkeys/authentication-options'
|
||||
: rawOptionsUrl;
|
||||
const optionsResponse = await fetch(optionsUrl, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error('Unable to load passkey authentication options.');
|
||||
}
|
||||
|
||||
const optionsJson = await optionsResponse.json();
|
||||
const optionsJson = await this.loadAuthenticationOptions();
|
||||
const startAuthenticationResponse = await window.startAuthentication({
|
||||
optionsJSON: optionsJson,
|
||||
});
|
||||
|
||||
this.$refs.remember.value = this.remember ? '1' : '0';
|
||||
this.$refs.response.value = JSON.stringify(startAuthenticationResponse);
|
||||
document.getElementById('passkey-login-form')?.submit();
|
||||
this.submitAuthentication(startAuthenticationResponse);
|
||||
} catch (error) {
|
||||
if (this.shouldOfferBrowserPasskeyFallback(error)) {
|
||||
this.error = 'Select a saved browser or app passkey from the username field.';
|
||||
this.focusBrowserPasskeyInput();
|
||||
void this.startBrowserPasskeyFallback({ silent: true });
|
||||
this.busy = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = error instanceof Error
|
||||
? error.message
|
||||
: 'Passkey authentication failed.';
|
||||
@@ -76,6 +82,90 @@ Alpine.data('passkeyLogin', () => ({
|
||||
}
|
||||
},
|
||||
|
||||
async startBrowserPasskeyFallback({ silent = false } = {}) {
|
||||
if (!this.supported || !this.supportsAutofill || this.browserPasskeyStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.browserPasskeyStarted = true;
|
||||
this.browserPasskeyPending = true;
|
||||
|
||||
if (!silent) {
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const optionsJson = await this.loadAuthenticationOptions();
|
||||
const startAuthenticationResponse = await window.startAuthentication({
|
||||
optionsJSON: optionsJson,
|
||||
useBrowserAutofill: true,
|
||||
});
|
||||
|
||||
this.submitAuthentication(startAuthenticationResponse);
|
||||
} catch (error) {
|
||||
this.browserPasskeyStarted = false;
|
||||
this.browserPasskeyPending = false;
|
||||
|
||||
if (this.isCeremonyAbort(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
this.error = error instanceof Error
|
||||
? error.message
|
||||
: 'Browser passkey sign-in failed.';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadAuthenticationOptions() {
|
||||
this.copyCaptchaResponse();
|
||||
|
||||
const rawOptionsUrl = this.$el.dataset.optionsUrl;
|
||||
const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined')
|
||||
? '/passkeys/authentication-options'
|
||||
: rawOptionsUrl;
|
||||
const optionsResponse = await fetch(optionsUrl, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error('Unable to load passkey authentication options.');
|
||||
}
|
||||
|
||||
return optionsResponse.json();
|
||||
},
|
||||
|
||||
submitAuthentication(startAuthenticationResponse) {
|
||||
this.$refs.remember.value = this.remember ? '1' : '0';
|
||||
this.$refs.response.value = JSON.stringify(startAuthenticationResponse);
|
||||
document.getElementById('passkey-login-form')?.submit();
|
||||
},
|
||||
|
||||
shouldOfferBrowserPasskeyFallback(error) {
|
||||
if (!this.supportsAutofill || this.browserPasskeyStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error instanceof Error
|
||||
&& ['NotAllowedError', 'InvalidStateError', 'SecurityError', 'UnknownError'].includes(error.name);
|
||||
},
|
||||
|
||||
focusBrowserPasskeyInput() {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.$refs.browserPasskeyInput?.focus();
|
||||
});
|
||||
},
|
||||
|
||||
isCeremonyAbort(error) {
|
||||
return error instanceof Error
|
||||
&& (error.name === 'AbortError' || error.code === 'ERROR_CEREMONY_ABORTED');
|
||||
},
|
||||
|
||||
copyCaptchaResponse() {
|
||||
const turnstileValue = this.getFieldValue('cf-turnstile-response');
|
||||
const recaptchaValue = this.getFieldValue('g-recaptcha-response');
|
||||
|
||||
@@ -60,7 +60,7 @@ Alpine.data('passkeyManage', () => ({
|
||||
});
|
||||
const options = optionsResponse.data?.options;
|
||||
|
||||
const registration = await window.startRegistration({ optionsJSON: options });
|
||||
const registration = await this.startRegistrationWithFallback(options);
|
||||
|
||||
const storeResponse = await window.axios.post(storeUrl, {
|
||||
name: this.name,
|
||||
@@ -118,6 +118,26 @@ Alpine.data('passkeyManage', () => ({
|
||||
}
|
||||
},
|
||||
|
||||
async startRegistrationWithFallback(options) {
|
||||
try {
|
||||
return await window.startRegistration({ optionsJSON: options });
|
||||
} catch (error) {
|
||||
if (!this.shouldTryBrowserPasskeyRegistration(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return window.startRegistration({
|
||||
optionsJSON: options,
|
||||
useAutoRegister: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
shouldTryBrowserPasskeyRegistration(error) {
|
||||
return error instanceof Error
|
||||
&& ['NotAllowedError', 'ConstraintError', 'UnknownError'].includes(error.name);
|
||||
},
|
||||
|
||||
formatDate(value) {
|
||||
if (!value) {
|
||||
return 'Unknown';
|
||||
|
||||
Vendored
+2
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
@@ -9,5 +10,6 @@ window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
|
||||
window.browserSupportsWebAuthnAutofill = browserSupportsWebAuthnAutofill;
|
||||
window.startAuthentication = startAuthentication;
|
||||
window.startRegistration = startRegistration;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="mt-6"
|
||||
>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
On managed/company devices, platform passkeys may be unavailable due to policy. Use a FIDO2 security key if prompted.
|
||||
Saved browser and password-manager passkeys can appear from the username field. Security keys are still supported.
|
||||
</p>
|
||||
|
||||
@if($message = session('authenticatePasskey::message'))
|
||||
@@ -27,6 +27,21 @@
|
||||
<input type="hidden" name="g-recaptcha-response" x-ref="recaptchaResponse" value="">
|
||||
</form>
|
||||
|
||||
<div x-show="supportsAutofill" x-cloak class="mt-4">
|
||||
<label for="passkey-autofill" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Username or email
|
||||
</label>
|
||||
<input
|
||||
id="passkey-autofill"
|
||||
x-ref="browserPasskeyInput"
|
||||
type="text"
|
||||
autocomplete="username webauthn"
|
||||
inputmode="email"
|
||||
class="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-3 text-sm text-gray-900 shadow-sm transition focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||
placeholder="Choose a saved passkey"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center">
|
||||
<input
|
||||
id="passkey-remember"
|
||||
|
||||
Reference in New Issue
Block a user