mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
52cf81abca
The previous commit only updated auth-button + the WithNostrAuth trait, but six Volt pages (profile, benefits, election/*, members/admin) carry their own handleNostrLoggedIn(string $pubkey) handlers. The dispatched payload is now an array, so Livewire's container could not resolve the string parameter and threw BindingResolutionException on every login. - All six per-page handlers now accept the signed event and route it through NostrAuth::loginWithSignedEvent() like the trait does. - NostrAuth: add currentOrIssueChallenge() so the sidebar + navbar auth-button mounts share one live session challenge instead of overwriting each other. - verifySignedEvent: pass a normalized stdClass to swentel's verify() directly, skipping an unnecessary json_encode + json_decode round-trip. - auth-button: gate the global Escape/Tab capture so it only intercepts keys while the overlay is actually visible. - Update three test files that still called handleNostrLoggedIn with a raw pubkey to authenticate via NostrAuth::login() instead.
194 lines
6.6 KiB
PHP
194 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Auth\NostrUser;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Session;
|
|
use Illuminate\Validation\ValidationException;
|
|
use swentel\nostr\Event\Event as NostrEvent;
|
|
|
|
class NostrAuth
|
|
{
|
|
private const CHALLENGE_SESSION_KEY = 'nostr_login_challenge';
|
|
|
|
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
|
|
{
|
|
Auth::guard('nostr')->loginByPubkey($pubkey);
|
|
Session::regenerate();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Return the active session challenge if still valid, otherwise issue a
|
|
* fresh one. Keeps the sidebar + navbar auth-button mounts in sync —
|
|
* without this, the second mount would overwrite the first in the
|
|
* session and leave the first instance's rendered data-attribute
|
|
* pointing at a stale challenge.
|
|
*/
|
|
public static function currentOrIssueChallenge(): string
|
|
{
|
|
$existing = Session::get(self::CHALLENGE_SESSION_KEY);
|
|
$expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0);
|
|
|
|
if (is_string($existing) && $existing !== '' && $expiresAt >= now()->timestamp) {
|
|
return $existing;
|
|
}
|
|
|
|
return self::issueChallenge();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// swentel's verify() accepts an object directly, so we pass a normalized
|
|
// stdClass and skip an unnecessary json_encode + json_decode round-trip.
|
|
// Casts here enforce the property types swentel checks against.
|
|
$normalizedEvent = (object) [
|
|
'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'],
|
|
];
|
|
|
|
$sigValid = false;
|
|
try {
|
|
$sigValid = (new NostrEvent)->verify($normalizedEvent);
|
|
} 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
|
|
{
|
|
if (Auth::guard('nostr')->check()) {
|
|
Auth::guard('nostr')->logout();
|
|
Session::flush();
|
|
}
|
|
}
|
|
|
|
public static function user(): ?NostrUser
|
|
{
|
|
return Auth::guard('nostr')->user();
|
|
}
|
|
|
|
public static function check(): bool
|
|
{
|
|
return Auth::guard('nostr')->check();
|
|
}
|
|
|
|
public static function pubkey(): ?string
|
|
{
|
|
return self::user()?->getPubkey();
|
|
}
|
|
}
|