mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 16:40:31 +00:00
Add mobile app auth flow with Sanctum token handoff via deep link
The Einundzwanzig mobile app opens /auth/mobile in an in-app browser. After a Lightning (LNURL) or Nostr login the flow issues a personal access token and hands it back via the einundzwanzig://auth deep link. - New auth.mobile-login Livewire view: Lightning QR (shared k1) plus Nostr signing via NIP-55 Android signers (Amber) with server callback, and a confirmation screen for already authenticated sessions - MobileAuthController: NIP-55 callback verification, completion route issuing the token (replacing same-device tokens), redirect whitelist - Nostr login event verification and npub user resolution extracted to App\Support\NostrLogin, now shared with the interactive login - GET /api/user (auth:sanctum) returns the token owner's profile
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Dedoc\Scramble\Attributes\Group;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
#[Group(name: 'Profil', weight: 8)]
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Eigenes Profil
|
||||||
|
*
|
||||||
|
* Liefert das Profil des authentifizierten Nutzers (Token-Inhaber).
|
||||||
|
* Wird von der Mobile App direkt nach dem Login aufgerufen.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'nostr' => $user->nostr,
|
||||||
|
'is_lecturer' => (bool) $user->is_lecturer,
|
||||||
|
'is_leader' => (bool) $user->is_leader,
|
||||||
|
'avatar' => $user->profile_photo_url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Jobs\FetchNostrProfileJob;
|
||||||
|
use App\Models\LoginKey;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\NostrLogin;
|
||||||
|
use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth flow for the Einundzwanzig mobile app.
|
||||||
|
*
|
||||||
|
* The app opens /auth/mobile in an in-app browser. After a successful
|
||||||
|
* Lightning (LNURL) or Nostr (NIP-55/Amber) login the flow issues a
|
||||||
|
* Sanctum personal access token and hands it back to the app via the
|
||||||
|
* einundzwanzig:// deep link.
|
||||||
|
*/
|
||||||
|
final class MobileAuthController extends Controller
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ALLOWED_REDIRECT_URIS = ['einundzwanzig://auth'];
|
||||||
|
|
||||||
|
public const DEFAULT_DEVICE_NAME = 'Einundzwanzig Mobile App';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the NIP-55 signer callback (e.g. Amber on Android).
|
||||||
|
*
|
||||||
|
* The mobile login page opens a nostrsigner: URL containing an unsigned
|
||||||
|
* kind-22242 event whose challenge tag carries the page's k1. The signer
|
||||||
|
* appends the signed event JSON to this callback URL. On success a
|
||||||
|
* LoginKey row is stored so the polling login page can complete the flow.
|
||||||
|
*/
|
||||||
|
#[ExcludeRouteFromDocs]
|
||||||
|
public function nostrCallback(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$k1 = (string) $request->query('k1', '');
|
||||||
|
|
||||||
|
if (! ctype_xdigit($k1) || strlen($k1) !== 64) {
|
||||||
|
return response()->json(['status' => 'ERROR', 'reason' => 'Invalid k1'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$signedEvent = json_decode((string) $request->query('event', ''), true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$npub = NostrLogin::verifyEvent($signedEvent, $k1);
|
||||||
|
} catch (ValidationException) {
|
||||||
|
Log::warning('Mobile Nostr auth verification failed', [
|
||||||
|
'k1' => $k1,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'ERROR', 'reason' => 'Signature was NOT VERIFIED'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = NostrLogin::findOrCreateUser($npub);
|
||||||
|
FetchNostrProfileJob::dispatch($user);
|
||||||
|
|
||||||
|
LoginKey::query()->updateOrCreate(
|
||||||
|
['k1' => $k1],
|
||||||
|
['user_id' => $user->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('Mobile Nostr auth successful', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'OK']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a mobile login after the wallet/signer callback has stored a
|
||||||
|
* matching LoginKey row. Called as a full-page GET from the mobile login
|
||||||
|
* component once wire:poll detects readiness. Issues the token and
|
||||||
|
* redirects into the app via the deep link.
|
||||||
|
*/
|
||||||
|
public function complete(Request $request, string $k1): RedirectResponse
|
||||||
|
{
|
||||||
|
$loginKey = LoginKey::query()
|
||||||
|
->where('k1', $k1)
|
||||||
|
->where('created_at', '>=', now()->subSeconds(NostrLogin::CHALLENGE_TTL_SECONDS))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $loginKey) {
|
||||||
|
return redirect()->route('auth.mobile');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::find($loginKey->user_id);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return redirect()->route('auth.mobile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->issueTokenAndRedirect($user, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect the app for a user who is already authenticated in the
|
||||||
|
* in-app browser session (confirmation button on /auth/mobile).
|
||||||
|
*/
|
||||||
|
public function confirm(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->issueTokenAndRedirect($request->user(), $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a personal access token named after the device and hand it to
|
||||||
|
* the app via the whitelisted deep link. Existing tokens with the same
|
||||||
|
* device name are replaced so repeated logins don't accumulate tokens.
|
||||||
|
*/
|
||||||
|
private function issueTokenAndRedirect(User $user, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$mobileAuth = (array) $request->session()->pull('mobile_auth', []);
|
||||||
|
|
||||||
|
$redirectUri = $mobileAuth['redirect_uri'] ?? self::ALLOWED_REDIRECT_URIS[0];
|
||||||
|
|
||||||
|
if (! in_array($redirectUri, self::ALLOWED_REDIRECT_URIS, true)) {
|
||||||
|
$redirectUri = self::ALLOWED_REDIRECT_URIS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceName = str((string) ($mobileAuth['device_name'] ?? self::DEFAULT_DEVICE_NAME))
|
||||||
|
->limit(64, '')
|
||||||
|
->whenEmpty(fn () => str(self::DEFAULT_DEVICE_NAME))
|
||||||
|
->value();
|
||||||
|
|
||||||
|
$user->tokens()->where('name', $deviceName)->delete();
|
||||||
|
|
||||||
|
$token = $user->createToken($deviceName);
|
||||||
|
|
||||||
|
Log::info('Mobile app token issued', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'device_name' => $deviceName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->away($redirectUri.'?token='.urlencode($token->plainTextToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use swentel\nostr\Event\Event as NostrEvent;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared verification and user resolution for Nostr-based logins.
|
||||||
|
*
|
||||||
|
* Used by the interactive login component (challenge from the session,
|
||||||
|
* signed via window.nostr) and by the mobile auth flow (challenge from
|
||||||
|
* the k1 URL parameter, signed via a NIP-55 Android signer like Amber).
|
||||||
|
*/
|
||||||
|
final class NostrLogin
|
||||||
|
{
|
||||||
|
public const CHALLENGE_TTL_SECONDS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a NIP-42-style signed login event against an expected challenge
|
||||||
|
* and return the signer's npub.
|
||||||
|
*
|
||||||
|
* Throws ValidationException on any invalid input — never trust client data.
|
||||||
|
*/
|
||||||
|
public static function verifyEvent(mixed $signedEvent, string $expectedChallenge): string
|
||||||
|
{
|
||||||
|
if (! is_array($signedEvent)) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
||||||
|
foreach ($required as $key) {
|
||||||
|
if (! array_key_exists($key, $signedEvent)) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $signedEvent['kind'] !== 22242) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expectedChallenge === '') {
|
||||||
|
throw ValidationException::withMessages(['email' => __('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 || ! hash_equals($expectedChallenge, $challengeFromEvent)) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdAt = (int) $signedEvent['created_at'];
|
||||||
|
if (abs(now()->timestamp - $createdAt) > self::CHALLENGE_TTL_SECONDS) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventJson = json_encode([
|
||||||
|
'id' => (string) $signedEvent['id'],
|
||||||
|
'pubkey' => (string) $signedEvent['pubkey'],
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'kind' => 22242,
|
||||||
|
'tags' => $signedEvent['tags'],
|
||||||
|
'content' => (string) $signedEvent['content'],
|
||||||
|
'sig' => (string) $signedEvent['sig'],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$isValid = false;
|
||||||
|
try {
|
||||||
|
$isValid = (new NostrEvent)->verify($eventJson);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isValid) {
|
||||||
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new NostrKey)->convertPublicKeyToBech32((string) $signedEvent['pubkey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing user by npub or create a fresh account for it,
|
||||||
|
* mirroring the LNURL auto-registration behaviour.
|
||||||
|
*/
|
||||||
|
public static function findOrCreateUser(string $npub): User
|
||||||
|
{
|
||||||
|
$user = User::query()->where('nostr', $npub)->first();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::create([
|
||||||
|
'public_key' => null,
|
||||||
|
'is_lecturer' => true,
|
||||||
|
'name' => str()->random(10),
|
||||||
|
'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space',
|
||||||
|
'nostr' => $npub,
|
||||||
|
'lnbits' => [
|
||||||
|
'read_key' => null,
|
||||||
|
'url' => null,
|
||||||
|
'wallet_id' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
use App\Attributes\SeoDataAttribute;
|
use App\Attributes\SeoDataAttribute;
|
||||||
use App\Jobs\FetchNostrProfileJob;
|
use App\Jobs\FetchNostrProfileJob;
|
||||||
use App\Models\LoginKey;
|
use App\Models\LoginKey;
|
||||||
use App\Models\User;
|
use App\Support\NostrLogin;
|
||||||
use App\Traits\SeoTrait;
|
use App\Traits\SeoTrait;
|
||||||
use eza\lnurl;
|
use eza\lnurl;
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
@@ -18,8 +18,6 @@ use Livewire\Attributes\On;
|
|||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
use swentel\nostr\Event\Event as NostrEvent;
|
|
||||||
use swentel\nostr\Key\Key as NostrKey;
|
|
||||||
|
|
||||||
new #[Layout('components.layouts.auth')]
|
new #[Layout('components.layouts.auth')]
|
||||||
class extends Component {
|
class extends Component {
|
||||||
@@ -48,7 +46,7 @@ class extends Component {
|
|||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $nostrChallenge = null;
|
public ?string $nostrChallenge = null;
|
||||||
|
|
||||||
private const NOSTR_CHALLENGE_TTL_SECONDS = 300;
|
private const NOSTR_CHALLENGE_TTL_SECONDS = NostrLogin::CHALLENGE_TTL_SECONDS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle authError property type conversion.
|
* Handle authError property type conversion.
|
||||||
@@ -120,22 +118,8 @@ class extends Component {
|
|||||||
{
|
{
|
||||||
$npub = $this->verifyNostrLoginEvent($signedEvent);
|
$npub = $this->verifyNostrLoginEvent($signedEvent);
|
||||||
|
|
||||||
$user = User::query()->where('nostr', $npub)->first();
|
$user = NostrLogin::findOrCreateUser($npub);
|
||||||
if (!$user) {
|
|
||||||
$fakeName = str()->random(10);
|
|
||||||
$user = User::create([
|
|
||||||
'public_key' => null,
|
|
||||||
'is_lecturer' => true,
|
|
||||||
'name' => $fakeName,
|
|
||||||
'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space',
|
|
||||||
'nostr' => $npub,
|
|
||||||
'lnbits' => [
|
|
||||||
'read_key' => null,
|
|
||||||
'url' => null,
|
|
||||||
'wallet_id' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
FetchNostrProfileJob::dispatch($user);
|
FetchNostrProfileJob::dispatch($user);
|
||||||
// Auth::loginUsingId() already regenerates the session id (see
|
// Auth::loginUsingId() already regenerates the session id (see
|
||||||
// SessionGuard::updateSession), so an explicit Session::regenerate()
|
// SessionGuard::updateSession), so an explicit Session::regenerate()
|
||||||
@@ -187,21 +171,6 @@ class extends Component {
|
|||||||
*/
|
*/
|
||||||
protected function verifyNostrLoginEvent(mixed $signedEvent): string
|
protected function verifyNostrLoginEvent(mixed $signedEvent): string
|
||||||
{
|
{
|
||||||
if (!is_array($signedEvent)) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
|
||||||
foreach ($required as $key) {
|
|
||||||
if (!array_key_exists($key, $signedEvent)) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $signedEvent['kind'] !== 22242) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$expectedChallenge = Session::get('nostr_login_challenge');
|
$expectedChallenge = Session::get('nostr_login_challenge');
|
||||||
$expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0);
|
$expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0);
|
||||||
|
|
||||||
@@ -210,47 +179,11 @@ class extends Component {
|
|||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$challengeFromEvent = null;
|
$npub = NostrLogin::verifyEvent($signedEvent, $expectedChallenge);
|
||||||
foreach ($signedEvent['tags'] as $tag) {
|
|
||||||
if (is_array($tag) && ($tag[0] ?? null) === 'challenge') {
|
|
||||||
$challengeFromEvent = (string) ($tag[1] ?? '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($challengeFromEvent === null || !hash_equals($expectedChallenge, $challengeFromEvent)) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$createdAt = (int) $signedEvent['created_at'];
|
|
||||||
if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventJson = json_encode([
|
|
||||||
'id' => (string) $signedEvent['id'],
|
|
||||||
'pubkey' => (string) $signedEvent['pubkey'],
|
|
||||||
'created_at' => $createdAt,
|
|
||||||
'kind' => 22242,
|
|
||||||
'tags' => $signedEvent['tags'],
|
|
||||||
'content' => (string) $signedEvent['content'],
|
|
||||||
'sig' => (string) $signedEvent['sig'],
|
|
||||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
|
|
||||||
$isValid = false;
|
|
||||||
try {
|
|
||||||
$isValid = (new NostrEvent())->verify($eventJson);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$isValid) {
|
|
||||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||||
|
|
||||||
return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']);
|
return $npub;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\MobileAuthController;
|
||||||
|
use App\Models\LoginKey;
|
||||||
|
use App\Traits\SeoTrait;
|
||||||
|
use eza\lnurl;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
|
new #[Layout('components.layouts.auth')]
|
||||||
|
class extends Component {
|
||||||
|
use SeoTrait;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $k1 = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $redirectUri = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $deviceName = null;
|
||||||
|
|
||||||
|
public ?string $lnurl = null;
|
||||||
|
|
||||||
|
public ?string $qrCode = null;
|
||||||
|
|
||||||
|
public ?string $signerCallbackUrl = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$redirectUri = (string) request()->query('redirect_uri', MobileAuthController::ALLOWED_REDIRECT_URIS[0]);
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
in_array($redirectUri, MobileAuthController::ALLOWED_REDIRECT_URIS, true),
|
||||||
|
403,
|
||||||
|
'Invalid redirect_uri',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->redirectUri = $redirectUri;
|
||||||
|
$this->deviceName = str((string) request()->query('device_name', MobileAuthController::DEFAULT_DEVICE_NAME))
|
||||||
|
->limit(64, '')
|
||||||
|
->whenEmpty(fn () => str(MobileAuthController::DEFAULT_DEVICE_NAME))
|
||||||
|
->value();
|
||||||
|
|
||||||
|
// The completion/confirm controller reads the flow state from the
|
||||||
|
// session — the wallet/signer callback arrives outside this session,
|
||||||
|
// so it can't carry the redirect target itself.
|
||||||
|
session([
|
||||||
|
'mobile_auth' => [
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'device_name' => $this->deviceName,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (auth()->check()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initChallenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh k1 challenge shared by both login methods: the
|
||||||
|
* Lightning wallet signs it via LNURL-auth, the Nostr signer puts it
|
||||||
|
* into the challenge tag of a kind-22242 event. Whichever callback
|
||||||
|
* verifies first stores the LoginKey row that checkAuth polls for.
|
||||||
|
*/
|
||||||
|
protected function initChallenge(): void
|
||||||
|
{
|
||||||
|
$this->k1 = bin2hex(str()->random(32));
|
||||||
|
|
||||||
|
if (app()->environment('local')) {
|
||||||
|
$url = 'https://mmy4dp8eab.sharedwithexpose.com/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login';
|
||||||
|
} else {
|
||||||
|
$url = url('/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lnurl = lnurl\encodeUrl($url);
|
||||||
|
|
||||||
|
// NIP-55 signers append the signed event JSON directly after "event=".
|
||||||
|
$this->signerCallbackUrl = url('/api/nostr-login-callback').'?k1='.$this->k1.'&event=';
|
||||||
|
|
||||||
|
$image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg';
|
||||||
|
if (! file_exists(base_path($image))) {
|
||||||
|
$image = 'public/img/domains/de-DE.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->qrCode = base64_encode(QrCode::format('png')
|
||||||
|
->size(300)
|
||||||
|
->merge('/'.$image, .3)
|
||||||
|
->errorCorrection('H')
|
||||||
|
->generate($this->lnurl));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkAuth(): void
|
||||||
|
{
|
||||||
|
$loginKey = LoginKey::query()
|
||||||
|
->where('k1', $this->k1)
|
||||||
|
->where('created_at', '>=', now()->subMinutes(5))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $loginKey) {
|
||||||
|
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.
|
||||||
|
$this->dispatch(
|
||||||
|
'mobile-login-ready',
|
||||||
|
url: route('auth.mobile.complete', ['k1' => $this->k1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetAuth(): void
|
||||||
|
{
|
||||||
|
$this->initChallenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function switchAccount(): void
|
||||||
|
{
|
||||||
|
auth()->guard('web')->logout();
|
||||||
|
session()->invalidate();
|
||||||
|
session()->regenerateToken();
|
||||||
|
|
||||||
|
$this->redirect(route('auth.mobile', [
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'device_name' => $this->deviceName,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen justify-center"
|
||||||
|
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">
|
||||||
|
<div class="size-24">
|
||||||
|
<x-app-logo-icon/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@auth
|
||||||
|
<flux:heading class="text-center" size="xl">{{ __('Mit der App verbinden') }}</flux:heading>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 rounded-2xl border border-zinc-200 p-6 dark:border-zinc-700">
|
||||||
|
<flux:avatar src="{{ auth()->user()->profile_photo_url }}" size="xl"/>
|
||||||
|
<flux:text class="text-center">
|
||||||
|
{{ __('Du bist als :name angemeldet. Möchtest du dieses Gerät (:device) mit deinem Konto verbinden?', ['name' => auth()->user()->name, 'device' => $deviceName]) }}
|
||||||
|
</flux:text>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('auth.mobile.confirm') }}" class="w-full">
|
||||||
|
@csrf
|
||||||
|
<flux:button type="submit" variant="primary" class="w-full cursor-pointer">
|
||||||
|
{{ __('Verbinden') }}
|
||||||
|
</flux:button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<flux:button wire:click="switchAccount" variant="ghost" class="w-full cursor-pointer">
|
||||||
|
{{ __('Mit anderem Konto anmelden') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<flux:heading class="text-center" size="xl">{{ __('Anmelden für die App') }}</flux:heading>
|
||||||
|
|
||||||
|
<!-- Nostr via NIP-55 signer (e.g. Amber) -->
|
||||||
|
<div x-data="{
|
||||||
|
signWithSigner() {
|
||||||
|
const event = {
|
||||||
|
kind: 22242,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [['challenge', '{{ $k1 }}']],
|
||||||
|
};
|
||||||
|
window.location.href = 'nostrsigner:' + encodeURIComponent(JSON.stringify(event))
|
||||||
|
+ '?compressionType=none&returnType=event&type=sign_event&appName=Einundzwanzig'
|
||||||
|
+ '&callbackUrl=' + encodeURIComponent('{{ $signerCallbackUrl }}');
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<flux:button variant="primary"
|
||||||
|
@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">
|
||||||
|
{{ __('Login with lightning ⚡') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center" wire:key="qrcode">
|
||||||
|
<a href="lightning:{{ $this->lnurl }}">
|
||||||
|
<div class="bg-white p-4">
|
||||||
|
<img src="{{ 'data:image/png;base64, '. $this->qrCode }}" alt="qrcode">
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-2 justify-between w-full">
|
||||||
|
<flux:button
|
||||||
|
primary
|
||||||
|
href="lightning:{{ $this->lnurl }}"
|
||||||
|
>
|
||||||
|
{{ __('Mit Lightning-Wallet öffnen') }}
|
||||||
|
</flux:button>
|
||||||
|
|
||||||
|
<flux:button wire:click="resetAuth" variant="ghost" class="cursor-pointer">
|
||||||
|
{{ __('Neuen Code erzeugen') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Poll for the LoginKey row written by either callback.
|
||||||
|
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="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>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -10,8 +10,10 @@ use App\Http\Controllers\Api\MeetupController;
|
|||||||
use App\Http\Controllers\Api\MeetupEventController;
|
use App\Http\Controllers\Api\MeetupEventController;
|
||||||
use App\Http\Controllers\Api\MeetupMapController;
|
use App\Http\Controllers\Api\MeetupMapController;
|
||||||
use App\Http\Controllers\Api\NostrPlebController;
|
use App\Http\Controllers\Api\NostrPlebController;
|
||||||
|
use App\Http\Controllers\Api\UserController;
|
||||||
use App\Http\Controllers\Api\VenueController;
|
use App\Http\Controllers\Api\VenueController;
|
||||||
use App\Http\Controllers\LnurlAuthController;
|
use App\Http\Controllers\LnurlAuthController;
|
||||||
|
use App\Http\Controllers\MobileAuthController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['throttle:60,1'])
|
Route::middleware(['throttle:60,1'])
|
||||||
@@ -39,6 +41,8 @@ Route::middleware(['throttle:60,1'])
|
|||||||
Route::middleware('auth:sanctum')
|
Route::middleware('auth:sanctum')
|
||||||
->as('api.')
|
->as('api.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
|
Route::get('user', UserController::class)->name('user');
|
||||||
|
|
||||||
Route::post('courses', [CourseController::class, 'store'])
|
Route::post('courses', [CourseController::class, 'store'])
|
||||||
->name('courses.store');
|
->name('courses.store');
|
||||||
Route::patch('courses/{course}', [CourseController::class, 'update'])
|
Route::patch('courses/{course}', [CourseController::class, 'update'])
|
||||||
@@ -80,5 +84,10 @@ Route::middleware('auth:sanctum')
|
|||||||
Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback'])
|
Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback'])
|
||||||
->name('auth.ln.callback');
|
->name('auth.ln.callback');
|
||||||
|
|
||||||
|
// NIP-55 signer callback (e.g. Amber) for the mobile auth flow.
|
||||||
|
Route::get('/nostr-login-callback', [MobileAuthController::class, 'nostrCallback'])
|
||||||
|
->middleware('throttle:30,1')
|
||||||
|
->name('auth.nostr.callback');
|
||||||
|
|
||||||
Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError'])
|
Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError'])
|
||||||
->name('auth.check-error');
|
->name('auth.check-error');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
use App\Http\Controllers\LnurlAuthController;
|
use App\Http\Controllers\LnurlAuthController;
|
||||||
|
use App\Http\Controllers\MobileAuthController;
|
||||||
use App\Livewire\Actions\Logout;
|
use App\Livewire\Actions\Logout;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -34,5 +35,23 @@ Route::middleware('auth')
|
|||||||
->name('password.confirm');
|
->name('password.confirm');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile app auth flow: works for guests (login via Lightning/Nostr) and
|
||||||
|
* for already authenticated users (confirmation screen), so it lives
|
||||||
|
* outside the guest group.
|
||||||
|
*/
|
||||||
|
Route::livewire('/auth/mobile', 'auth.mobile-login')
|
||||||
|
->middleware('throttle:30,1')
|
||||||
|
->name('auth.mobile');
|
||||||
|
|
||||||
|
Route::get('/auth/mobile/complete/{k1}', [MobileAuthController::class, 'complete'])
|
||||||
|
->where('k1', '[a-f0-9]{64}')
|
||||||
|
->middleware('throttle:30,1')
|
||||||
|
->name('auth.mobile.complete');
|
||||||
|
|
||||||
|
Route::post('/auth/mobile/confirm', [MobileAuthController::class, 'confirm'])
|
||||||
|
->middleware(['auth', 'throttle:30,1'])
|
||||||
|
->name('auth.mobile.confirm');
|
||||||
|
|
||||||
Route::post('logout', Logout::class)
|
Route::post('logout', Logout::class)
|
||||||
->name('logout');
|
->name('logout');
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\FetchNostrProfileJob;
|
||||||
|
use App\Models\LoginKey;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use swentel\nostr\Event\Event as NostrEvent;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
use swentel\nostr\Sign\Sign as NostrSign;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a NIP-55-style signed login event for a given k1 challenge, as an
|
||||||
|
* Android signer (e.g. Amber) would return it to the callback URL.
|
||||||
|
*
|
||||||
|
* @return array{0: array<string, mixed>, 1: string}
|
||||||
|
*/
|
||||||
|
function makeSignedMobileNostrEvent(string $challenge): array
|
||||||
|
{
|
||||||
|
$keyGen = new NostrKey;
|
||||||
|
$privateKey = $keyGen->generatePrivateKey();
|
||||||
|
$publicKey = $keyGen->getPublicKey($privateKey);
|
||||||
|
|
||||||
|
$event = new NostrEvent;
|
||||||
|
$event->setKind(22242)
|
||||||
|
->setCreatedAt(time())
|
||||||
|
->setContent('')
|
||||||
|
->setTags([['challenge', $challenge]]);
|
||||||
|
|
||||||
|
(new NostrSign)->signEvent($event, $privateKey);
|
||||||
|
|
||||||
|
$signed = [
|
||||||
|
'id' => $event->getId(),
|
||||||
|
'pubkey' => $event->getPublicKey(),
|
||||||
|
'created_at' => $event->getCreatedAt(),
|
||||||
|
'kind' => $event->getKind(),
|
||||||
|
'tags' => $event->getTags(),
|
||||||
|
'content' => $event->getContent(),
|
||||||
|
'sig' => $event->getSignature(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [$signed, $keyGen->convertPublicKeyToBech32($publicKey)];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the mobile login page for guests and stores the flow state in the session', function () {
|
||||||
|
$response = $this->get('/auth/mobile?redirect_uri=einundzwanzig%3A%2F%2Fauth&device_name=Pixel%2010');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
expect(session('mobile_auth.redirect_uri'))->toBe('einundzwanzig://auth')
|
||||||
|
->and(session('mobile_auth.device_name'))->toBe('Pixel 10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects redirect uris that are not whitelisted', function () {
|
||||||
|
$this->get('/auth/mobile?redirect_uri=https%3A%2F%2Fevil.example%2Fphish')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a login key and creates a user when the nostr signer callback verifies', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$k1 = bin2hex(random_bytes(32));
|
||||||
|
[$signedEvent, $npub] = makeSignedMobileNostrEvent($k1);
|
||||||
|
|
||||||
|
$response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent)));
|
||||||
|
|
||||||
|
$response->assertOk()->assertJson(['status' => 'OK']);
|
||||||
|
|
||||||
|
$user = User::query()->where('nostr', $npub)->first();
|
||||||
|
expect($user)->not->toBeNull();
|
||||||
|
|
||||||
|
$loginKey = LoginKey::query()->where('k1', $k1)->first();
|
||||||
|
expect($loginKey)->not->toBeNull()
|
||||||
|
->and($loginKey->user_id)->toBe($user->id);
|
||||||
|
|
||||||
|
Queue::assertPushed(FetchNostrProfileJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a nostr signer callback whose event is bound to a different challenge', function () {
|
||||||
|
$k1 = bin2hex(random_bytes(32));
|
||||||
|
[$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32)));
|
||||||
|
|
||||||
|
$response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent)));
|
||||||
|
|
||||||
|
$response->assertBadRequest();
|
||||||
|
expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues a token and redirects into the app when completing a verified login', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$k1 = bin2hex(random_bytes(32));
|
||||||
|
LoginKey::query()->create(['k1' => $k1, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']])
|
||||||
|
->get('/auth/mobile/complete/'.$k1);
|
||||||
|
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
expect($location)->toStartWith('einundzwanzig://auth?token=');
|
||||||
|
|
||||||
|
$token = $user->tokens()->first();
|
||||||
|
expect($token)->not->toBeNull()
|
||||||
|
->and($token->name)->toBe('Pixel 10');
|
||||||
|
|
||||||
|
// The plain-text token handed to the app must authenticate API requests.
|
||||||
|
$plainTextToken = urldecode(str($location)->after('?token=')->value());
|
||||||
|
$this->getJson('/api/user', ['Authorization' => 'Bearer '.$plainTextToken])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('id', $user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects back to the mobile login when the k1 is unknown or expired', function () {
|
||||||
|
$this->get('/auth/mobile/complete/'.bin2hex(random_bytes(32)))
|
||||||
|
->assertRedirect(route('auth.mobile'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an already authenticated user connect the app and replaces tokens of the same device', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->createToken('Pixel 10');
|
||||||
|
|
||||||
|
$response = $this
|
||||||
|
->actingAs($user)
|
||||||
|
->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']])
|
||||||
|
->post('/auth/mobile/confirm');
|
||||||
|
|
||||||
|
expect($response->headers->get('Location'))->toStartWith('einundzwanzig://auth?token=');
|
||||||
|
expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the confirmation screen instead of the login methods for authenticated users', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/auth/mobile')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(route('auth.mobile.confirm'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the token owner profile on /api/user', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->getJson('/api/user')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('id', $user->id)
|
||||||
|
->assertJsonPath('name', $user->name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies /api/user without a token', function () {
|
||||||
|
$this->getJson('/api/user')->assertUnauthorized();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user