mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 04:30:31 +00:00
Make the mobile login page Lightning-only
The Nostr login is now driven entirely by the app (it launches the NIP-55 signer via an ACTION_VIEW intent and posts the signed event to /auth/mobile/signed), so the portal page no longer needs window.nostr or an Amber button — it only renders the Lightning QR. The path-based signer callback and token exchange endpoints remain server-side.
This commit is contained in:
@@ -1,19 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\MobileAuthController;
|
||||
use App\Jobs\FetchNostrProfileJob;
|
||||
use App\Models\LoginKey;
|
||||
use App\Support\NostrLogin;
|
||||
use App\Traits\SeoTrait;
|
||||
use eza\lnurl;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
/**
|
||||
* Lightning-only login page for the mobile app.
|
||||
*
|
||||
* The app opens this page in an in-app browser for the Lightning flow.
|
||||
* The Nostr flow is driven entirely by the app itself (it launches the
|
||||
* NIP-55 signer via an ACTION_VIEW intent and the signed event is posted
|
||||
* back to /auth/mobile/signed), so there is no Nostr UI here.
|
||||
*/
|
||||
new #[Layout('components.layouts.auth')]
|
||||
class extends Component {
|
||||
use SeoTrait;
|
||||
@@ -27,9 +30,6 @@ class extends Component {
|
||||
#[Locked]
|
||||
public ?string $deviceName = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $nostrChallenge = null;
|
||||
|
||||
public ?string $lnurl = null;
|
||||
|
||||
public ?string $qrCode = null;
|
||||
@@ -50,9 +50,9 @@ class extends Component {
|
||||
->whenEmpty(fn () => str(MobileAuthController::DEFAULT_DEVICE_NAME))
|
||||
->value();
|
||||
|
||||
// The completion/confirm controller reads the flow state from the
|
||||
// session — the wallet callback arrives outside this session, so it
|
||||
// can't carry the redirect target itself.
|
||||
// The completion controller reads the flow state from the session —
|
||||
// the wallet callback arrives outside this session, so it can't
|
||||
// carry the redirect target itself.
|
||||
session([
|
||||
'mobile_auth' => [
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
@@ -64,15 +64,13 @@ class extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->issueNostrChallenge();
|
||||
$this->initChallenge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh k1 challenge for the LNURL flow. The Lightning
|
||||
* wallet signs it via LNURL-auth; the resulting LoginKey row is picked
|
||||
* up by checkAuth() below. The Nostr flow reuses the same row keyed by
|
||||
* the same k1 (see loginListener).
|
||||
* up by checkAuth() below.
|
||||
*/
|
||||
protected function initChallenge(): void
|
||||
{
|
||||
@@ -98,65 +96,6 @@ class extends Component {
|
||||
->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
|
||||
{
|
||||
$loginKey = LoginKey::query()
|
||||
@@ -168,9 +107,9 @@ class extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same handoff pattern as the Lightning login: navigate via the
|
||||
// client instead of redirecting from inside wire:poll, so a stray
|
||||
// poll tick can't race the completion request.
|
||||
// Same handoff pattern as the desktop Lightning login: navigate via
|
||||
// the client instead of redirecting from inside wire:poll, so a
|
||||
// stray poll tick can't race the completion request.
|
||||
$this->dispatch(
|
||||
'mobile-login-ready',
|
||||
url: route('auth.mobile.complete', ['k1' => $this->k1]),
|
||||
@@ -179,7 +118,6 @@ class extends Component {
|
||||
|
||||
public function resetAuth(): void
|
||||
{
|
||||
$this->issueNostrChallenge();
|
||||
$this->initChallenge();
|
||||
}
|
||||
|
||||
@@ -198,9 +136,8 @@ class extends Component {
|
||||
?>
|
||||
|
||||
<div class="flex min-h-screen justify-center"
|
||||
x-data="nostrLogin"
|
||||
data-nostr-challenge="{{ $nostrChallenge ?? '' }}"
|
||||
@mobile-login-ready.window="lightningLoginInProgress = true; window.location.href = $event.detail.url">
|
||||
x-data="{ loginInProgress: false }"
|
||||
@mobile-login-ready.window="loginInProgress = true; window.location.href = $event.detail.url">
|
||||
<div class="flex justify-center items-center px-4 py-8">
|
||||
<div class="w-80 max-w-80 space-y-6">
|
||||
<div class="flex justify-center">
|
||||
@@ -230,27 +167,11 @@ class extends Component {
|
||||
</flux:button>
|
||||
</div>
|
||||
@else
|
||||
<flux:heading class="text-center" size="xl">{{ __('Anmelden für die App') }}</flux:heading>
|
||||
<flux:heading class="text-center" size="xl">{{ __('Login mit Lightning ⚡') }}</flux:heading>
|
||||
|
||||
{{-- Nostr via window.nostr: extension or NIP-46 remote signer
|
||||
(Amber via bunker/nostrconnect — the window.nostr.js
|
||||
widget handles pairing and persists the connection). --}}
|
||||
<flux:button variant="primary"
|
||||
@click="openNostrLogin"
|
||||
icon="cursor-arrow-ripple"
|
||||
x-bind:disabled="nostrLoginInProgress"
|
||||
x-bind:aria-busy="nostrLoginInProgress"
|
||||
class="w-full cursor-pointer">
|
||||
<span x-show="!nostrLoginInProgress">{{ __('Log in mit Nostr') }}</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>
|
||||
|
||||
<div class="text-center text-2xl text-gray-80 dark:text-gray-2000 mt-2">
|
||||
{{ __('Login with lightning ⚡') }}
|
||||
</div>
|
||||
<flux:text class="text-center">
|
||||
{{ __('Scanne den Code mit deiner Lightning-Wallet oder öffne sie direkt.') }}
|
||||
</flux:text>
|
||||
|
||||
<div class="flex justify-center" wire:key="qrcode">
|
||||
<a href="lightning:{{ $this->lnurl }}">
|
||||
@@ -274,12 +195,12 @@ class extends Component {
|
||||
</div>
|
||||
|
||||
{{-- Poll for the LoginKey row written by the LNURL callback.
|
||||
Paused while a login round-trip or navigation is in flight. --}}
|
||||
<template x-if="!nostrLoginInProgress && !lightningLoginInProgress">
|
||||
Paused while the completion navigation is in flight. --}}
|
||||
<template x-if="!loginInProgress">
|
||||
<div wire:poll.4s="checkAuth" wire:key="checkAuth"></div>
|
||||
</template>
|
||||
|
||||
<div x-show="lightningLoginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
||||
<div x-show="loginInProgress" x-cloak class="flex items-center justify-center gap-2">
|
||||
<flux:icon.arrow-path class="animate-spin size-4" aria-hidden="true"/>
|
||||
<flux:text>{{ __('Anmeldung wird abgeschlossen…') }}</flux:text>
|
||||
</div>
|
||||
|
||||
@@ -201,27 +201,6 @@ it('lets an already authenticated user connect the app and replaces tokens of th
|
||||
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 () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user