mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
6bb7d93d1d
Closes a security flaw where the server trusted any pubkey the client sent. The frontend now signs a per-session, time-bound challenge (kind-22242 event) that the backend verifies with swentel/nostr-php before establishing the session. - NostrAuth: issueChallenge() + loginWithSignedEvent() with full schnorr/id verification, TTL window, and idempotent re-entry for concurrent Livewire listeners. - auth-button: mounts a fresh challenge, exposes it via data-attribute + requestNostrChallenge() fallback, renders a full-viewport AAA-style loading overlay while the wallet signs. - NostrSessionGuard: override logout() to drop the cookie-jar dep so programmatic logout works in any context.
127 lines
5.0 KiB
JavaScript
127 lines
5.0 KiB
JavaScript
export default () => ({
|
|
// Toggled while window.nostr.signEvent is awaiting the wallet so the
|
|
// button disables itself, the loading overlay renders, and downstream
|
|
// wire:poll consumers can pause if needed. Stays true through the
|
|
// backend round-trip — the auth-button component reloads the page on
|
|
// success, so resetting the flag here would just flicker the overlay
|
|
// away while navigation is already underway.
|
|
nostrLoginInProgress: false,
|
|
|
|
/**
|
|
* Resolve the active NIP-42 login challenge issued by the auth-button
|
|
* Volt component. Falls back to the Livewire $wire proxy and then to a
|
|
* server-side re-issue so the user does not have to reload if the
|
|
* rendered snapshot drifted out of sync with the session (e.g. a tab
|
|
* left open past the challenge TTL).
|
|
*/
|
|
async resolveChallenge() {
|
|
const fromDataset = this.$root?.dataset?.nostrChallenge;
|
|
if (typeof fromDataset === 'string' && fromDataset !== '') {
|
|
return fromDataset;
|
|
}
|
|
|
|
const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire;
|
|
const fromWire = livewireComponent?.$wire?.nostrChallenge;
|
|
if (typeof fromWire === 'string' && fromWire !== '') {
|
|
return fromWire;
|
|
}
|
|
|
|
if (livewireComponent?.$wire?.requestNostrChallenge) {
|
|
try {
|
|
const refreshed = await livewireComponent.$wire.requestNostrChallenge();
|
|
if (typeof refreshed === 'string' && refreshed !== '') {
|
|
return refreshed;
|
|
}
|
|
} catch (error) {
|
|
console.error('requestNostrChallenge failed:', error);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
async openNostrLogin() {
|
|
this.nostrLoginInProgress = true;
|
|
|
|
try {
|
|
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
|
|
this.showAuthError('Keine Nostr-Erweiterung gefunden. Bitte installiere einen Nostr-Signer (z.B. nos2x, Alby).');
|
|
this.nostrLoginInProgress = false;
|
|
return;
|
|
}
|
|
|
|
const challenge = await this.resolveChallenge();
|
|
if (!challenge) {
|
|
this.showAuthError('Login-Challenge fehlt. Bitte lade die Seite neu und versuche es erneut.');
|
|
this.nostrLoginInProgress = false;
|
|
return;
|
|
}
|
|
|
|
const pubkey = await window.nostr.getPublicKey();
|
|
|
|
const event = {
|
|
kind: 22242,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [['challenge', challenge]],
|
|
content: '',
|
|
};
|
|
|
|
let signedEvent;
|
|
try {
|
|
signedEvent = await window.nostr.signEvent(event);
|
|
} catch (error) {
|
|
console.error('Nostr signEvent failed:', error);
|
|
this.showAuthError('Signatur abgebrochen oder fehlgeschlagen. Bitte versuche es erneut.');
|
|
this.nostrLoginInProgress = false;
|
|
return;
|
|
}
|
|
|
|
// Some Nostr extensions return objects wrapped in extension-specific
|
|
// proxies (e.g. cloneInto results) that Livewire cannot serialize.
|
|
// Round-trip through JSON to guarantee a plain, cloneable object.
|
|
let plainSignedEvent;
|
|
try {
|
|
plainSignedEvent = JSON.parse(JSON.stringify(signedEvent));
|
|
} catch (error) {
|
|
console.error('Nostr signedEvent serialization failed:', error);
|
|
this.showAuthError('Wallet-Signatur konnte nicht verarbeitet werden. Bitte versuche eine andere Erweiterung.');
|
|
this.nostrLoginInProgress = false;
|
|
return;
|
|
}
|
|
|
|
// Pre-fetch the profile so it lands in the Alpine store before the
|
|
// reload completes. Non-critical: failures are logged but ignored.
|
|
try {
|
|
const response = await fetch('/api/nostr/profile/' + pubkey);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
Alpine.store('nostr', {user: data});
|
|
}
|
|
} catch (error) {
|
|
console.warn('Profile prefetch failed:', error);
|
|
}
|
|
|
|
// Leave nostrLoginInProgress = true: the auth-button listener
|
|
// will trigger a full page reload on success.
|
|
this.$dispatch('nostrLoggedIn', {signedEvent: plainSignedEvent});
|
|
} catch (error) {
|
|
console.error('openNostrLogin unexpected error:', error);
|
|
this.showAuthError('Authentifizierung fehlgeschlagen. Bitte versuche es erneut.');
|
|
this.nostrLoginInProgress = false;
|
|
}
|
|
},
|
|
|
|
showAuthError(message) {
|
|
if (window.Flux && window.Flux.toast) {
|
|
window.Flux.toast({
|
|
heading: 'Authentifizierung fehlgeschlagen',
|
|
text: message,
|
|
variant: 'danger',
|
|
duration: 8000,
|
|
});
|
|
} else {
|
|
console.error(message);
|
|
}
|
|
},
|
|
});
|