mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
feat(auth): require signed NIP-42 event for Nostr login
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.
This commit is contained in:
+110
-33
@@ -1,49 +1,126 @@
|
||||
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,
|
||||
|
||||
async init() {
|
||||
/**
|
||||
* 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() {
|
||||
console.log('Starting Nostr login...');
|
||||
console.log('window.nostr available:', !!window.nostr);
|
||||
this.nostrLoginInProgress = true;
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
console.log('Fetched pubkey:', pubkey);
|
||||
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;
|
||||
}
|
||||
|
||||
// fetch profile from /api/nostr/profile/{publicKey}
|
||||
const url = '/api/nostr/profile/' + pubkey;
|
||||
console.log('Fetching profile from:', url);
|
||||
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;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
console.log('Response status:', response.status);
|
||||
console.log('Response ok:', response.ok);
|
||||
console.log('Response headers:', response.headers);
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const event = {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', challenge]],
|
||||
content: '',
|
||||
};
|
||||
|
||||
return response.text();
|
||||
})
|
||||
.then(text => {
|
||||
console.log('Response text:', text);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
console.log('Profile fetched', data);
|
||||
// store in AlpineJS store
|
||||
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});
|
||||
// dispatch Livewire event
|
||||
this.$dispatch('nostrLoggedIn', {pubkey: pubkey});
|
||||
} catch (e) {
|
||||
console.error('JSON parse error:', e);
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during Nostr login:', error);
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@fluxAppearance
|
||||
</head>
|
||||
<body class="min-h-screen bg-bg-page antialiased"
|
||||
x-data="nostrLogin"
|
||||
>
|
||||
<body class="min-h-screen bg-bg-page antialiased">
|
||||
<flux:header sticky class="bg-bg-surface h-18">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left"/>
|
||||
|
||||
|
||||
@@ -13,15 +13,36 @@ new class extends Component
|
||||
#[Locked]
|
||||
public string $location = 'sidebar'; // 'sidebar' or 'navbar'
|
||||
|
||||
#[Locked]
|
||||
public ?string $nostrChallenge = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->isLoggedIn = NostrAuth::check();
|
||||
|
||||
if (! $this->isLoggedIn) {
|
||||
$this->nostrChallenge = NostrAuth::issueChallenge();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JS-driven fallback: re-issue the challenge if the client cannot find
|
||||
* one in the rendered snapshot (e.g. after a long-lived tab where the
|
||||
* Volt component snapshot drifted out of sync with the session).
|
||||
*/
|
||||
public function requestNostrChallenge(): string
|
||||
{
|
||||
$challenge = NostrAuth::issueChallenge();
|
||||
$this->nostrChallenge = $challenge;
|
||||
|
||||
return $challenge;
|
||||
}
|
||||
|
||||
#[On('nostrLoggedIn')]
|
||||
public function handleNostrLoggedIn(string $pubkey): void
|
||||
public function handleNostrLoggedIn($signedEvent = null): void
|
||||
{
|
||||
NostrAuth::login($pubkey);
|
||||
NostrAuth::loginWithSignedEvent($signedEvent);
|
||||
|
||||
$this->js('window.location.reload(true);');
|
||||
}
|
||||
|
||||
@@ -33,24 +54,63 @@ new class extends Component
|
||||
}
|
||||
?>
|
||||
|
||||
<div x-data="nostrLogin">
|
||||
<div x-data="nostrLogin" data-nostr-challenge="{{ $nostrChallenge ?? '' }}">
|
||||
@if($isLoggedIn)
|
||||
@if($location === 'sidebar')
|
||||
<form method="post" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<flux:button variant="ghost" icon="arrow-right-start-on-rectangle" type="submit" wire:click="$dispatch('nostrLoggedOut')">Logout</flux:button>
|
||||
</form>
|
||||
@else
|
||||
<form method="post" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<flux:button variant="ghost" icon="arrow-right-start-on-rectangle" type="submit" wire:click="$dispatch('nostrLoggedOut')">Logout</flux:button>
|
||||
</form>
|
||||
@endif
|
||||
<form method="post" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<flux:button variant="ghost" icon="arrow-right-start-on-rectangle" type="submit" wire:click="$dispatch('nostrLoggedOut')">Logout</flux:button>
|
||||
</form>
|
||||
@else
|
||||
@if($location === 'sidebar')
|
||||
<flux:button variant="primary" icon="user" @click="openNostrLogin">Mit Nostr verbinden</flux:button>
|
||||
@else
|
||||
<flux:button variant="primary" icon="user" @click="openNostrLogin">Mit Nostr verbinden</flux:button>
|
||||
@endif
|
||||
<flux:button variant="primary"
|
||||
icon="user"
|
||||
@click="openNostrLogin"
|
||||
x-bind:disabled="nostrLoginInProgress"
|
||||
x-bind:aria-busy="nostrLoginInProgress"
|
||||
class="cursor-pointer">
|
||||
<span x-show="!nostrLoginInProgress">Mit Nostr verbinden</span>
|
||||
<span x-show="nostrLoginInProgress" x-cloak class="inline-flex items-center gap-2">
|
||||
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||
Signiere…
|
||||
</span>
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
{{-- Full-viewport progress overlay. Visible while the wallet-signing
|
||||
round-trip is running. Locks input by capturing pointer events and
|
||||
intercepting Escape/Tab so the user cannot interact with anything
|
||||
underneath until the redirect resolves (or the flow errors out). --}}
|
||||
<div x-show="nostrLoginInProgress"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.150ms
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
x-bind:aria-busy="nostrLoginInProgress"
|
||||
aria-labelledby="nostr-login-progress-heading-{{ $location }}"
|
||||
aria-describedby="nostr-login-progress-description-{{ $location }}"
|
||||
@keydown.window.escape.prevent.stop
|
||||
@keydown.window.tab.prevent.stop
|
||||
x-effect="document.body.style.overflow = nostrLoginInProgress ? 'hidden' : ''"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950/70 backdrop-blur-md">
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl bg-white px-8 py-10 text-center shadow-2xl ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="relative mx-auto flex size-20 items-center justify-center">
|
||||
<span class="absolute inset-0 animate-ping rounded-full bg-amber-500/20" aria-hidden="true"></span>
|
||||
<span class="absolute inset-2 rounded-full bg-amber-500/10" aria-hidden="true"></span>
|
||||
<flux:icon.arrow-path class="relative size-10 animate-spin text-amber-600 dark:text-amber-400"
|
||||
aria-hidden="true"/>
|
||||
</div>
|
||||
|
||||
<flux:heading id="nostr-login-progress-heading-{{ $location }}" size="lg" class="mt-6">
|
||||
Signiere mit deinem Nostr-Wallet
|
||||
</flux:heading>
|
||||
|
||||
<flux:text id="nostr-login-progress-description-{{ $location }}" class="mt-3 text-zinc-600 dark:text-zinc-400">
|
||||
Bitte bestätige die Login-Anfrage in deiner Browser-Extension.
|
||||
Du wirst gleich automatisch weitergeleitet.
|
||||
</flux:text>
|
||||
|
||||
<flux:text size="sm" class="mt-6 text-zinc-500 dark:text-zinc-500">
|
||||
Schließe dieses Fenster nicht.
|
||||
</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user