mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-18 04:50:30 +00:00
Use window.nostr (NIP-46/Amber bunker) on the mobile login page
Replaces the fragile NIP-55 intent/callback round-trip with the same mechanism the desktop login uses: openNostrLogin signs the session challenge via window.nostr — provided by an extension or by window.nostr.js over a persistent NIP-46 connection (Amber pairing with permissions). The listener stores a LoginKey for the page's k1 and navigates to the completion route, which issues the token and redirects into the app via the verified App Link handoff.
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\MobileAuthController;
|
use App\Http\Controllers\MobileAuthController;
|
||||||
|
use App\Jobs\FetchNostrProfileJob;
|
||||||
use App\Models\LoginKey;
|
use App\Models\LoginKey;
|
||||||
|
use App\Support\NostrLogin;
|
||||||
use App\Traits\SeoTrait;
|
use App\Traits\SeoTrait;
|
||||||
use eza\lnurl;
|
use eza\lnurl;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
@@ -22,12 +27,13 @@ class extends Component {
|
|||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $deviceName = null;
|
public ?string $deviceName = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $nostrChallenge = null;
|
||||||
|
|
||||||
public ?string $lnurl = null;
|
public ?string $lnurl = null;
|
||||||
|
|
||||||
public ?string $qrCode = null;
|
public ?string $qrCode = null;
|
||||||
|
|
||||||
public ?string $signerCallbackUrl = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$redirectUri = (string) request()->query('redirect_uri', MobileAuthController::ALLOWED_REDIRECT_URIS[0]);
|
$redirectUri = (string) request()->query('redirect_uri', MobileAuthController::ALLOWED_REDIRECT_URIS[0]);
|
||||||
@@ -45,8 +51,8 @@ class extends Component {
|
|||||||
->value();
|
->value();
|
||||||
|
|
||||||
// The completion/confirm controller reads the flow state from the
|
// The completion/confirm controller reads the flow state from the
|
||||||
// session — the wallet/signer callback arrives outside this session,
|
// session — the wallet callback arrives outside this session, so it
|
||||||
// so it can't carry the redirect target itself.
|
// can't carry the redirect target itself.
|
||||||
session([
|
session([
|
||||||
'mobile_auth' => [
|
'mobile_auth' => [
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
@@ -58,14 +64,15 @@ class extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->issueNostrChallenge();
|
||||||
$this->initChallenge();
|
$this->initChallenge();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a fresh k1 challenge shared by both login methods: the
|
* Generate a fresh k1 challenge for the LNURL flow. The Lightning
|
||||||
* Lightning wallet signs it via LNURL-auth, the Nostr signer puts it
|
* wallet signs it via LNURL-auth; the resulting LoginKey row is picked
|
||||||
* into the challenge tag of a kind-22242 event. Whichever callback
|
* up by checkAuth() below. The Nostr flow reuses the same row keyed by
|
||||||
* verifies first stores the LoginKey row that checkAuth polls for.
|
* the same k1 (see loginListener).
|
||||||
*/
|
*/
|
||||||
protected function initChallenge(): void
|
protected function initChallenge(): void
|
||||||
{
|
{
|
||||||
@@ -79,11 +86,6 @@ class extends Component {
|
|||||||
|
|
||||||
$this->lnurl = lnurl\encodeUrl($url);
|
$this->lnurl = lnurl\encodeUrl($url);
|
||||||
|
|
||||||
// NIP-55 signers append the signed event JSON to the callback URL.
|
|
||||||
// Amber strips query strings when rebuilding the URL, so the k1
|
|
||||||
// travels in the path and the event lands after the trailing slash.
|
|
||||||
$this->signerCallbackUrl = url('/auth/mobile/signed').'/'.$this->k1.'/';
|
|
||||||
|
|
||||||
$image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg';
|
$image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg';
|
||||||
if (! file_exists(base_path($image))) {
|
if (! file_exists(base_path($image))) {
|
||||||
$image = 'public/img/domains/de-DE.jpg';
|
$image = 'public/img/domains/de-DE.jpg';
|
||||||
@@ -96,6 +98,65 @@ class extends Component {
|
|||||||
->generate($this->lnurl));
|
->generate($this->lnurl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-bound challenge for the window.nostr (NIP-07/NIP-46) login —
|
||||||
|
* identical mechanism to the regular login component.
|
||||||
|
*/
|
||||||
|
protected function issueNostrChallenge(): string
|
||||||
|
{
|
||||||
|
$challenge = bin2hex(random_bytes(32));
|
||||||
|
$this->nostrChallenge = $challenge;
|
||||||
|
Session::put('nostr_login_challenge', $challenge);
|
||||||
|
Session::put('nostr_login_challenge_expires_at', now()->addSeconds(NostrLogin::CHALLENGE_TTL_SECONDS)->timestamp);
|
||||||
|
|
||||||
|
return $challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestNostrChallenge(): string
|
||||||
|
{
|
||||||
|
return $this->issueNostrChallenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* window.nostr signed the kind-22242 challenge (via extension or a
|
||||||
|
* NIP-46 connection to e.g. Amber). Verify it, store the LoginKey and
|
||||||
|
* hand the navigation to the completion route, which issues the token
|
||||||
|
* and redirects into the app via the verified App Link.
|
||||||
|
*/
|
||||||
|
#[On('nostrLoggedIn')]
|
||||||
|
public function loginListener($signedEvent = null): void
|
||||||
|
{
|
||||||
|
$npub = $this->verifyNostrLoginEvent($signedEvent);
|
||||||
|
|
||||||
|
$user = NostrLogin::findOrCreateUser($npub);
|
||||||
|
|
||||||
|
FetchNostrProfileJob::dispatch($user);
|
||||||
|
|
||||||
|
LoginKey::query()->updateOrCreate(
|
||||||
|
['k1' => $this->k1],
|
||||||
|
['user_id' => $user->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->redirect(route('auth.mobile.complete', ['k1' => $this->k1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function verifyNostrLoginEvent(mixed $signedEvent): string
|
||||||
|
{
|
||||||
|
$expectedChallenge = Session::get('nostr_login_challenge');
|
||||||
|
$expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0);
|
||||||
|
|
||||||
|
if (! is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) {
|
||||||
|
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$npub = NostrLogin::verifyEvent($signedEvent, $expectedChallenge);
|
||||||
|
|
||||||
|
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||||
|
|
||||||
|
return $npub;
|
||||||
|
}
|
||||||
|
|
||||||
public function checkAuth(): void
|
public function checkAuth(): void
|
||||||
{
|
{
|
||||||
$loginKey = LoginKey::query()
|
$loginKey = LoginKey::query()
|
||||||
@@ -118,6 +179,7 @@ class extends Component {
|
|||||||
|
|
||||||
public function resetAuth(): void
|
public function resetAuth(): void
|
||||||
{
|
{
|
||||||
|
$this->issueNostrChallenge();
|
||||||
$this->initChallenge();
|
$this->initChallenge();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +198,9 @@ class extends Component {
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="flex min-h-screen justify-center"
|
<div class="flex min-h-screen justify-center"
|
||||||
x-data="{ loginInProgress: false }"
|
x-data="nostrLogin"
|
||||||
@mobile-login-ready.window="loginInProgress = true; window.location.href = $event.detail.url">
|
data-nostr-challenge="{{ $nostrChallenge ?? '' }}"
|
||||||
|
@mobile-login-ready.window="lightningLoginInProgress = true; window.location.href = $event.detail.url">
|
||||||
<div class="flex justify-center items-center px-4 py-8">
|
<div class="flex justify-center items-center px-4 py-8">
|
||||||
<div class="w-80 max-w-80 space-y-6">
|
<div class="w-80 max-w-80 space-y-6">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
@@ -169,28 +232,21 @@ class extends Component {
|
|||||||
@else
|
@else
|
||||||
<flux:heading class="text-center" size="xl">{{ __('Anmelden für die App') }}</flux:heading>
|
<flux:heading class="text-center" size="xl">{{ __('Anmelden für die App') }}</flux:heading>
|
||||||
|
|
||||||
<!-- Nostr via NIP-55 signer (e.g. Amber) -->
|
{{-- Nostr via window.nostr: extension or NIP-46 remote signer
|
||||||
<div x-data="{
|
(Amber via bunker/nostrconnect — the window.nostr.js
|
||||||
signWithSigner() {
|
widget handles pairing and persists the connection). --}}
|
||||||
const event = {
|
<flux:button variant="primary"
|
||||||
kind: 22242,
|
@click="openNostrLogin"
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
icon="cursor-arrow-ripple"
|
||||||
content: '',
|
x-bind:disabled="nostrLoginInProgress"
|
||||||
tags: [['challenge', '{{ $k1 }}']],
|
x-bind:aria-busy="nostrLoginInProgress"
|
||||||
};
|
class="w-full cursor-pointer">
|
||||||
window.location.href = 'nostrsigner:' + encodeURIComponent(JSON.stringify(event))
|
<span x-show="!nostrLoginInProgress">{{ __('Log in mit Nostr') }}</span>
|
||||||
+ '?compressionType=none&returnType=event&type=sign_event&appName=Einundzwanzig'
|
<span x-show="nostrLoginInProgress" x-cloak class="inline-flex items-center gap-2">
|
||||||
+ '&callbackUrl=' + encodeURIComponent('{{ $signerCallbackUrl }}');
|
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||||
}
|
{{ __('Signiere…') }}
|
||||||
}">
|
</span>
|
||||||
<flux:button variant="primary"
|
</flux:button>
|
||||||
@click="signWithSigner"
|
|
||||||
icon="cursor-arrow-ripple"
|
|
||||||
x-bind:disabled="loginInProgress"
|
|
||||||
class="w-full cursor-pointer">
|
|
||||||
{{ __('Log in mit Nostr (Amber)') }}
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center text-2xl text-gray-80 dark:text-gray-2000 mt-2">
|
<div class="text-center text-2xl text-gray-80 dark:text-gray-2000 mt-2">
|
||||||
{{ __('Login with lightning ⚡') }}
|
{{ __('Login with lightning ⚡') }}
|
||||||
@@ -217,13 +273,13 @@ class extends Component {
|
|||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Poll for the LoginKey row written by either callback.
|
{{-- Poll for the LoginKey row written by the LNURL callback.
|
||||||
Paused while the completion navigation is in flight. --}}
|
Paused while a login round-trip or navigation is in flight. --}}
|
||||||
<template x-if="!loginInProgress">
|
<template x-if="!nostrLoginInProgress && !lightningLoginInProgress">
|
||||||
<div wire:poll.4s="checkAuth" wire:key="checkAuth"></div>
|
<div wire:poll.4s="checkAuth" wire:key="checkAuth"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div x-show="loginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
<div x-show="lightningLoginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
||||||
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||||
<flux:text>{{ __('Anmeldung wird abgeschlossen…') }}</flux:text>
|
<flux:text>{{ __('Anmeldung wird abgeschlossen…') }}</flux:text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -201,6 +201,27 @@ it('lets an already authenticated user connect the app and replaces tokens of th
|
|||||||
expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1);
|
expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('completes a window.nostr login on the mobile page via LoginKey and the completion route', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$component = Livewire\Livewire::withQueryParams(['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10'])
|
||||||
|
->test('auth.mobile-login');
|
||||||
|
|
||||||
|
// Helper from NostrLoginTest: signs the challenge stored in the session.
|
||||||
|
[$signedEvent, $npub] = makeSignedNostrLoginEvent();
|
||||||
|
|
||||||
|
$k1 = $component->get('k1');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->dispatch('nostrLoggedIn', signedEvent: $signedEvent)
|
||||||
|
->assertRedirect(route('auth.mobile.complete', ['k1' => $k1]));
|
||||||
|
|
||||||
|
$user = User::query()->where('nostr', $npub)->first();
|
||||||
|
expect($user)->not->toBeNull();
|
||||||
|
expect(LoginKey::query()->where('k1', $k1)->value('user_id'))->toBe($user->id);
|
||||||
|
Queue::assertPushed(FetchNostrProfileJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the confirmation screen instead of the login methods for authenticated users', function () {
|
it('shows the confirmation screen instead of the login methods for authenticated users', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user