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:
@@ -38,4 +38,17 @@ class NostrSessionGuard extends SessionGuard
|
|||||||
|
|
||||||
return $this->user;
|
return $this->user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr auth has no remember-me cookie, so we override the parent's
|
||||||
|
* cookie-clearing logout to avoid CookieJar dependencies in code paths
|
||||||
|
* (e.g. tests) where the cookie jar isn't bound.
|
||||||
|
*/
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->session->remove($this->getName());
|
||||||
|
|
||||||
|
$this->user = null;
|
||||||
|
$this->loggedOut = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ trait WithNostrAuth
|
|||||||
public bool $canEdit = false;
|
public bool $canEdit = false;
|
||||||
|
|
||||||
#[On('nostrLoggedIn')]
|
#[On('nostrLoggedIn')]
|
||||||
public function handleNostrLogin(string $pubkey): void
|
public function handleNostrLogin($signedEvent = null): void
|
||||||
{
|
{
|
||||||
$executed = RateLimiter::attempt(
|
$executed = RateLimiter::attempt(
|
||||||
'nostr-login:'.request()->ip(),
|
'nostr-login:'.request()->ip(),
|
||||||
@@ -30,7 +30,7 @@ trait WithNostrAuth
|
|||||||
abort(429, 'Too many login attempts.');
|
abort(429, 'Too many login attempts.');
|
||||||
}
|
}
|
||||||
|
|
||||||
NostrAuth::login($pubkey);
|
$pubkey = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
$this->currentPubkey = $pubkey;
|
$this->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = EinundzwanzigPleb::query()
|
$this->currentPleb = EinundzwanzigPleb::query()
|
||||||
|
|||||||
+131
-13
@@ -5,12 +5,19 @@ namespace App\Support;
|
|||||||
use App\Auth\NostrUser;
|
use App\Auth\NostrUser;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use swentel\nostr\Event\Event as NostrEvent;
|
||||||
|
|
||||||
class NostrAuth
|
class NostrAuth
|
||||||
{
|
{
|
||||||
/**
|
private const CHALLENGE_SESSION_KEY = 'nostr_login_challenge';
|
||||||
* Login a user by their Nostr pubkey
|
|
||||||
*/
|
private const CHALLENGE_EXPIRES_SESSION_KEY = 'nostr_login_challenge_expires_at';
|
||||||
|
|
||||||
|
private const CHALLENGE_TTL_SECONDS = 300;
|
||||||
|
|
||||||
|
private const LOGIN_EVENT_KIND = 22242;
|
||||||
|
|
||||||
public static function login(string $pubkey): void
|
public static function login(string $pubkey): void
|
||||||
{
|
{
|
||||||
Auth::guard('nostr')->loginByPubkey($pubkey);
|
Auth::guard('nostr')->loginByPubkey($pubkey);
|
||||||
@@ -18,34 +25,145 @@ class NostrAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the current Nostr user
|
* Generate a fresh NIP-42-style login challenge, persist it to the session,
|
||||||
|
* and return it. The frontend embeds this challenge into a kind-22242 event
|
||||||
|
* that the Nostr signer must sign before we accept the login.
|
||||||
*/
|
*/
|
||||||
|
public static function issueChallenge(): string
|
||||||
|
{
|
||||||
|
$challenge = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
Session::put(self::CHALLENGE_SESSION_KEY, $challenge);
|
||||||
|
Session::put(self::CHALLENGE_EXPIRES_SESSION_KEY, now()->addSeconds(self::CHALLENGE_TTL_SECONDS)->timestamp);
|
||||||
|
|
||||||
|
return $challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed NIP-42-style login event and log the holder of the pubkey in.
|
||||||
|
*
|
||||||
|
* Idempotent across concurrent Livewire listeners: once the challenge has been
|
||||||
|
* consumed, a second call with the same event still succeeds as long as the
|
||||||
|
* caller's session is already authenticated with the matching pubkey.
|
||||||
|
*
|
||||||
|
* @return string the verified pubkey
|
||||||
|
*/
|
||||||
|
public static function loginWithSignedEvent(mixed $signedEvent): string
|
||||||
|
{
|
||||||
|
$pubkey = self::verifySignedEvent($signedEvent);
|
||||||
|
|
||||||
|
if (! self::check() || self::pubkey() !== $pubkey) {
|
||||||
|
self::login($pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the cryptographic signature of a kind-22242 event and that its
|
||||||
|
* challenge tag matches the value stored on this session. Consumes the
|
||||||
|
* stored challenge on success so it cannot be reused.
|
||||||
|
*
|
||||||
|
* @return string the verified pubkey
|
||||||
|
*/
|
||||||
|
public static function verifySignedEvent(mixed $signedEvent): string
|
||||||
|
{
|
||||||
|
if (! is_array($signedEvent)) {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'] as $field) {
|
||||||
|
if (! array_key_exists($field, $signedEvent)) {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $signedEvent['kind'] !== self::LOGIN_EVENT_KIND) {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdAt = (int) $signedEvent['created_at'];
|
||||||
|
if (abs(now()->timestamp - $createdAt) > self::CHALLENGE_TTL_SECONDS) {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$challengeFromEvent = null;
|
||||||
|
foreach ($signedEvent['tags'] as $tag) {
|
||||||
|
if (is_array($tag) && ($tag[0] ?? null) === 'challenge') {
|
||||||
|
$challengeFromEvent = (string) ($tag[1] ?? '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($challengeFromEvent === null || $challengeFromEvent === '') {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedChallenge = Session::get(self::CHALLENGE_SESSION_KEY);
|
||||||
|
$expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0);
|
||||||
|
$challengeMatchesSession = is_string($expectedChallenge)
|
||||||
|
&& $expectedChallenge !== ''
|
||||||
|
&& $expiresAt >= now()->timestamp
|
||||||
|
&& hash_equals($expectedChallenge, $challengeFromEvent);
|
||||||
|
|
||||||
|
$eventJson = json_encode([
|
||||||
|
'id' => (string) $signedEvent['id'],
|
||||||
|
'pubkey' => (string) $signedEvent['pubkey'],
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'kind' => self::LOGIN_EVENT_KIND,
|
||||||
|
'tags' => $signedEvent['tags'],
|
||||||
|
'content' => (string) $signedEvent['content'],
|
||||||
|
'sig' => (string) $signedEvent['sig'],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$sigValid = false;
|
||||||
|
try {
|
||||||
|
$sigValid = (new NostrEvent)->verify($eventJson);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$sigValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $sigValid) {
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventPubkey = (string) $signedEvent['pubkey'];
|
||||||
|
|
||||||
|
if ($challengeMatchesSession) {
|
||||||
|
Session::forget([self::CHALLENGE_SESSION_KEY, self::CHALLENGE_EXPIRES_SESSION_KEY]);
|
||||||
|
|
||||||
|
return $eventPubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent path: the challenge has already been consumed (e.g. a
|
||||||
|
// sibling Livewire listener processed the same event microseconds
|
||||||
|
// earlier). Only accept if the current session is already
|
||||||
|
// authenticated with this exact pubkey.
|
||||||
|
if (self::check() && self::pubkey() === $eventPubkey) {
|
||||||
|
return $eventPubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
public static function logout(): void
|
public static function logout(): void
|
||||||
{
|
{
|
||||||
if (Auth::guard('nostr')->check()) {
|
if (Auth::guard('nostr')->check()) {
|
||||||
|
Auth::guard('nostr')->logout();
|
||||||
Session::flush();
|
Session::flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the currently authenticated Nostr user
|
|
||||||
*/
|
|
||||||
public static function user(): ?NostrUser
|
public static function user(): ?NostrUser
|
||||||
{
|
{
|
||||||
return Auth::guard('nostr')->user();
|
return Auth::guard('nostr')->user();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a Nostr user is authenticated
|
|
||||||
*/
|
|
||||||
public static function check(): bool
|
public static function check(): bool
|
||||||
{
|
{
|
||||||
return Auth::guard('nostr')->check();
|
return Auth::guard('nostr')->check();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current pubkey (convenience method)
|
|
||||||
*/
|
|
||||||
public static function pubkey(): ?string
|
public static function pubkey(): ?string
|
||||||
{
|
{
|
||||||
return self::user()?->getPubkey();
|
return self::user()?->getPubkey();
|
||||||
|
|||||||
+110
-33
@@ -1,49 +1,126 @@
|
|||||||
export default () => ({
|
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() {
|
async openNostrLogin() {
|
||||||
console.log('Starting Nostr login...');
|
this.nostrLoginInProgress = true;
|
||||||
console.log('window.nostr available:', !!window.nostr);
|
|
||||||
|
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
try {
|
||||||
console.log('Fetched pubkey:', pubkey);
|
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 challenge = await this.resolveChallenge();
|
||||||
const url = '/api/nostr/profile/' + pubkey;
|
if (!challenge) {
|
||||||
console.log('Fetching profile from:', url);
|
this.showAuthError('Login-Challenge fehlt. Bitte lade die Seite neu und versuche es erneut.');
|
||||||
|
this.nostrLoginInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch(url)
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
.then(response => {
|
|
||||||
console.log('Response status:', response.status);
|
|
||||||
console.log('Response ok:', response.ok);
|
|
||||||
console.log('Response headers:', response.headers);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const event = {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
kind: 22242,
|
||||||
}
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['challenge', challenge]],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
|
||||||
return response.text();
|
let signedEvent;
|
||||||
})
|
try {
|
||||||
.then(text => {
|
signedEvent = await window.nostr.signEvent(event);
|
||||||
console.log('Response text:', text);
|
} catch (error) {
|
||||||
try {
|
console.error('Nostr signEvent failed:', error);
|
||||||
const data = JSON.parse(text);
|
this.showAuthError('Signatur abgebrochen oder fehlgeschlagen. Bitte versuche es erneut.');
|
||||||
console.log('Profile fetched', data);
|
this.nostrLoginInProgress = false;
|
||||||
// store in AlpineJS store
|
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});
|
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) {
|
||||||
.catch(error => {
|
console.warn('Profile prefetch failed:', error);
|
||||||
console.error('Error during Nostr login:', 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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@fluxAppearance
|
@fluxAppearance
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-bg-page antialiased"
|
<body class="min-h-screen bg-bg-page antialiased">
|
||||||
x-data="nostrLogin"
|
|
||||||
>
|
|
||||||
<flux:header sticky class="bg-bg-surface h-18">
|
<flux:header sticky class="bg-bg-surface h-18">
|
||||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left"/>
|
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left"/>
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,36 @@ new class extends Component
|
|||||||
#[Locked]
|
#[Locked]
|
||||||
public string $location = 'sidebar'; // 'sidebar' or 'navbar'
|
public string $location = 'sidebar'; // 'sidebar' or 'navbar'
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $nostrChallenge = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->isLoggedIn = NostrAuth::check();
|
$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')]
|
#[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);');
|
$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($isLoggedIn)
|
||||||
@if($location === 'sidebar')
|
<form method="post" action="{{ route('logout') }}">
|
||||||
<form method="post" action="{{ route('logout') }}">
|
@csrf
|
||||||
@csrf
|
<flux:button variant="ghost" icon="arrow-right-start-on-rectangle" type="submit" wire:click="$dispatch('nostrLoggedOut')">Logout</flux:button>
|
||||||
<flux:button variant="ghost" icon="arrow-right-start-on-rectangle" type="submit" wire:click="$dispatch('nostrLoggedOut')">Logout</flux:button>
|
</form>
|
||||||
</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
|
|
||||||
@else
|
@else
|
||||||
@if($location === 'sidebar')
|
<flux:button variant="primary"
|
||||||
<flux:button variant="primary" icon="user" @click="openNostrLogin">Mit Nostr verbinden</flux:button>
|
icon="user"
|
||||||
@else
|
@click="openNostrLogin"
|
||||||
<flux:button variant="primary" icon="user" @click="openNostrLogin">Mit Nostr verbinden</flux:button>
|
x-bind:disabled="nostrLoginInProgress"
|
||||||
@endif
|
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
|
@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>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\NostrAuth;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use swentel\nostr\Event\Event as NostrEvent;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
use swentel\nostr\Sign\Sign as NostrSign;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a NIP-42-style kind-22242 login event signed with a freshly generated
|
||||||
|
* keypair. Returns the signed event as the plain array that the frontend
|
||||||
|
* dispatches to Livewire (post-JSON round-trip), plus the pubkey for assertions.
|
||||||
|
*
|
||||||
|
* @return array{event: array<string, mixed>, pubkey: string, privkey: string}
|
||||||
|
*/
|
||||||
|
function makeSignedLoginEvent(string $challenge, ?int $createdAt = null): array
|
||||||
|
{
|
||||||
|
$key = new NostrKey;
|
||||||
|
$privkey = $key->generatePrivateKey();
|
||||||
|
$pubkey = $key->getPublicKey($privkey);
|
||||||
|
|
||||||
|
$event = new NostrEvent;
|
||||||
|
$event->setKind(22242);
|
||||||
|
$event->setCreatedAt($createdAt ?? time());
|
||||||
|
$event->setTags([['challenge', $challenge]]);
|
||||||
|
$event->setContent('');
|
||||||
|
|
||||||
|
(new NostrSign)->signEvent($event, $privkey);
|
||||||
|
|
||||||
|
$array = $event->toArray();
|
||||||
|
|
||||||
|
// Match the shape produced by JSON.parse(JSON.stringify(signedEvent)) in
|
||||||
|
// nostrLogin.js — plain arrays, integer kind/created_at, string sig/id.
|
||||||
|
return [
|
||||||
|
'event' => [
|
||||||
|
'id' => $array['id'],
|
||||||
|
'pubkey' => $array['pubkey'],
|
||||||
|
'created_at' => $array['created_at'],
|
||||||
|
'kind' => $array['kind'],
|
||||||
|
'tags' => $array['tags'],
|
||||||
|
'content' => $array['content'],
|
||||||
|
'sig' => $array['sig'],
|
||||||
|
],
|
||||||
|
'pubkey' => $pubkey,
|
||||||
|
'privkey' => $privkey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('issues a fresh hex challenge and persists it to the session', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
|
||||||
|
expect($challenge)->toMatch('/^[0-9a-f]{64}$/');
|
||||||
|
expect(Session::get('nostr_login_challenge'))->toBe($challenge);
|
||||||
|
expect(Session::get('nostr_login_challenge_expires_at'))->toBeGreaterThan(now()->timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in via a valid signed login event and consumes the challenge', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent, 'pubkey' => $pubkey] = makeSignedLoginEvent($challenge);
|
||||||
|
|
||||||
|
$returned = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
|
expect($returned)->toBe($pubkey);
|
||||||
|
expect(NostrAuth::check())->toBeTrue();
|
||||||
|
expect(NostrAuth::pubkey())->toBe($pubkey);
|
||||||
|
expect(Session::has('nostr_login_challenge'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event whose challenge does not match the session', function () {
|
||||||
|
NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent('deadbeef'.str_repeat('0', 56));
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event of the wrong kind', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
|
||||||
|
$signedEvent['kind'] = 1; // text note, not auth
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event whose created_at is outside the TTL window', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent($challenge, now()->subHour()->timestamp);
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event with a tampered signature', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
|
||||||
|
// Flip the first byte of the signature to break the schnorr verification.
|
||||||
|
$signedEvent['sig'] = ($signedEvent['sig'][0] === '0' ? '1' : '0').substr($signedEvent['sig'], 1);
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an event with a tampered pubkey (sig no longer matches)', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
|
||||||
|
// Swap in an attacker-controlled pubkey while keeping the original sig.
|
||||||
|
$signedEvent['pubkey'] = str_repeat('a', 64);
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-array payload', function () {
|
||||||
|
NostrAuth::issueChallenge();
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent('not-an-event'))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent(null))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent for repeated calls with the same event within one session', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent, 'pubkey' => $pubkey] = makeSignedLoginEvent($challenge);
|
||||||
|
|
||||||
|
NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
// Challenge is consumed after the first call. A sibling listener that
|
||||||
|
// receives the same dispatched event must still succeed.
|
||||||
|
$returned = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
|
expect($returned)->toBe($pubkey);
|
||||||
|
expect(NostrAuth::pubkey())->toBe($pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow a replay from a different (unauthenticated) session', function () {
|
||||||
|
$challenge = NostrAuth::issueChallenge();
|
||||||
|
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
|
||||||
|
|
||||||
|
NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
|
// Simulate a fresh session: no challenge, no authenticated user.
|
||||||
|
NostrAuth::logout();
|
||||||
|
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||||
|
|
||||||
|
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
expect(NostrAuth::check())->toBeFalse();
|
||||||
|
});
|
||||||
@@ -55,20 +55,23 @@ it('can delete project', function () {
|
|||||||
expect(ProjectProposal::find($project->id))->toBeNull();
|
expect(ProjectProposal::find($project->id))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr login', function () {
|
it('reflects an authenticated nostr session on mount', function () {
|
||||||
$pleb = EinundzwanzigPleb::factory()->create();
|
$pleb = EinundzwanzigPleb::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.project-support.index')
|
Livewire::test('association.project-support.index')
|
||||||
->call('handleNostrLogin', $pleb->pubkey)
|
|
||||||
->assertSet('currentPubkey', $pleb->pubkey)
|
->assertSet('currentPubkey', $pleb->pubkey)
|
||||||
->assertSet('isAllowed', true);
|
->assertSet('isAllowed', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr logout', function () {
|
it('clears state on nostr logout', function () {
|
||||||
$pleb = EinundzwanzigPleb::factory()->create();
|
$pleb = EinundzwanzigPleb::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.project-support.index')
|
Livewire::test('association.project-support.index')
|
||||||
->call('handleNostrLogin', $pleb->pubkey)
|
->assertSet('currentPubkey', $pleb->pubkey)
|
||||||
->call('handleNostrLogout')
|
->call('handleNostrLogout')
|
||||||
->assertSet('currentPubkey', null)
|
->assertSet('currentPubkey', null)
|
||||||
->assertSet('isAllowed', false);
|
->assertSet('isAllowed', false);
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ test('voting actions are rate limited after 10 attempts', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('nostr login is rate limited after 10 attempts', function () {
|
test('nostr login is rate limited after 10 attempts', function () {
|
||||||
$pleb = EinundzwanzigPleb::factory()->create();
|
|
||||||
|
|
||||||
for ($i = 0; $i < 10; $i++) {
|
for ($i = 0; $i < 10; $i++) {
|
||||||
RateLimiter::attempt('nostr-login:127.0.0.1', 10, function () {});
|
RateLimiter::attempt('nostr-login:127.0.0.1', 10, function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit is checked before signature verification, so an empty
|
||||||
|
// payload is enough to trigger the 429.
|
||||||
Livewire::test('association.project-support.index')
|
Livewire::test('association.project-support.index')
|
||||||
->call('handleNostrLogin', $pleb->pubkey)
|
->call('handleNostrLogin', [])
|
||||||
->assertStatus(429);
|
->assertStatus(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user