feat(auth): add passkey sign-in (#12154)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-10-13 10:10:03 +02:00
commit 4f92d3c094
29 files changed
+923 -8

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Support\PasskeyRelyingParty;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ConfigurePasskeys
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->routeIs('passkey.*')) {
return $next($request);
}
$relyingParty = instanceSettings()->is_passkey_enabled ? PasskeyRelyingParty::resolve($request) : null;
abort_if($relyingParty === null, 404);
config([
'passkeys.relying_party_id' => $relyingParty['id'],
'passkeys.allowed_origins' => $relyingParty['origins'],
]);
return $next($request);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Profile;
use App\Support\PasskeyRelyingParty;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Laravel\Passkeys\Actions\DeletePasskey;
use Livewire\Component;
class Passkeys extends Component
{
public bool $show_form = false;
public function mount(): void
{
$this->show_form = session('status') === 'passkey-add';
}
public function registered(): void
{
$this->show_form = false;
$this->dispatch('success', 'Passkey added.');
}
public function rename(int $id, string $name): bool
{
abort_unless(instanceSettings()->is_passkey_enabled, 404);
$passkey = Auth::user()->passkeys()->findOrFail($id);
$validated = Validator::make(
['name' => trim($name)],
['name' => ['required', 'string', 'max:255']],
)->validate();
$passkey->update(['name' => $validated['name']]);
$this->dispatch('success', 'Passkey renamed.');
return true;
}
public function delete(int $id, mixed $password = null): mixed
{
abort_unless(instanceSettings()->is_passkey_enabled, 404);
$passkey = Auth::user()->passkeys()->findOrFail($id);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
app(DeletePasskey::class)(Auth::user(), $passkey);
$this->dispatch('success', 'Passkey deleted.');
return true;
}
public function render(): View
{
$enabled = (bool) instanceSettings()->is_passkey_enabled;
return view('livewire.profile.passkeys', [
'enabled' => $enabled,
'available' => $enabled && PasskeyRelyingParty::isAvailable(),
'passkeys' => $enabled ? Auth::user()->passkeys()->latest()->get() : collect(),
]);
}
}
+6
View File
@@ -41,6 +41,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
#[Validate('boolean')]
public bool $is_passkey_enabled;
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
@@ -72,6 +75,7 @@ class Advanced extends Component
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_passkey_enabled' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
@@ -95,6 +99,7 @@ class Advanced extends Component
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_passkey_enabled = $this->settings->is_passkey_enabled ?? true;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
@@ -216,6 +221,7 @@ class Advanced extends Component
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_passkey_enabled = $this->is_passkey_enabled;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
+2
View File
@@ -60,6 +60,7 @@ class InstanceSettings extends Model
'avatar_s3_storage_id',
'image_cdn_url',
'is_dashboard_force_https_enabled',
'is_passkey_enabled',
];
protected $hidden = [
@@ -102,6 +103,7 @@ class InstanceSettings extends Model
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
'is_passkey_enabled' => 'boolean',
];
protected static function booted(): void
+16 -2
View File
@@ -21,6 +21,8 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\NewAccessToken;
@@ -41,9 +43,9 @@ use OpenApi\Attributes as OA;
'marketing_emails' => ['type' => 'boolean', 'description' => 'The flag to receive marketing emails.'],
],
)]
class User extends Authenticatable implements SendsEmail
class User extends Authenticatable implements PasskeyUser, SendsEmail
{
use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
protected $fillable = [
'name',
@@ -599,6 +601,18 @@ class User extends Authenticatable implements SendsEmail
return $this->oauthIdentities()->exists();
}
/**
* Hex handle: iOS Safari corrupts raw binary handles (laravel/passkeys-server#41).
*/
public function getPasskeyUserHandle(): string
{
return hash_hmac(
'sha256',
$this->getTable().'|'.$this->getKey(),
Config::string('passkeys.user_handle_secret'),
);
}
/**
* Check if the user has a password set.
*/
+12
View File
@@ -14,6 +14,9 @@ use Laravel\Fortify\Events\RecoveryCodesGenerated;
use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed;
use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled;
use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled;
use Laravel\Passkeys\Events\PasskeyDeleted;
use Laravel\Passkeys\Events\PasskeyRegistered;
use Laravel\Passkeys\Events\PasskeyVerified;
use SocialiteProviders\Authentik\AuthentikExtendSocialite;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Clerk\ClerkExtendSocialite;
@@ -72,6 +75,15 @@ class EventServiceProvider extends ServiceProvider
Event::listen(RecoveryCodesGenerated::class, function (RecoveryCodesGenerated $event): void {
auditLog('auth.user.recovery_codes_regenerated', $this->authContext($event->user));
});
Event::listen(PasskeyRegistered::class, function (PasskeyRegistered $event): void {
auditLog('auth.user.passkey_registered', $this->authContext($event->user));
});
Event::listen(PasskeyVerified::class, function (PasskeyVerified $event): void {
auditLog('auth.user.passkey_verified', $this->authContext($event->user));
});
Event::listen(PasskeyDeleted::class, function (PasskeyDeleted $event): void {
auditLog('auth.user.passkey_deleted', $this->authContext($event->user));
});
}
/**
+6 -1
View File
@@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Models\OauthSetting;
use App\Models\User;
use App\Support\PasskeyRelyingParty;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\ServiceProvider;
@@ -68,6 +69,7 @@ class FortifyServiceProvider extends ServiceProvider
fn (OauthSetting $oauthSetting) => $oauthSetting->couldBeEnabled() && $oauthSetting->allowsUserCreation()
),
'enabled_oauth_providers' => $enabled_oauth_providers,
'passkeys_available' => PasskeyRelyingParty::isAvailable(),
]);
});
@@ -108,7 +110,10 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::confirmPasswordView(function () {
return view('auth.confirm-password');
return view('auth.confirm-password', [
'can_confirm_with_passkey' => PasskeyRelyingParty::isAvailable()
&& auth()->user()->hasPasskeysEnabled(),
]);
});
Fortify::twoFactorChallengeView(function () {
+4
View File
@@ -74,6 +74,10 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(30)->by('passkeys:'.auth_rate_limit_ip($request));
});
RateLimiter::for('forgot-password', function (Request $request) {
$limits = [
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))),
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Support;
use Illuminate\Http\Request;
class PasskeyRelyingParty
{
/**
* @return array{id: string, origins: list<string>}|null
*/
public static function resolve(Request $request): ?array
{
$requestHost = $request->getHost();
foreach ([instanceSettings()->fqdn, config('app.url')] as $url) {
$parts = parse_url((string) $url);
$host = strtolower($parts['host'] ?? '');
if (strtolower($parts['scheme'] ?? '') !== 'https' || $host !== $requestHost) {
continue;
}
if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) {
continue;
}
$origins = ['https://'.$host.(isset($parts['port']) ? ':'.$parts['port'] : '')];
// The fqdn setting stores no port, so a custom port can only come from the request.
if (! isset($parts['port']) && ! in_array($request->getPort(), [80, 443])) {
$origins[] = 'https://'.$host.':'.$request->getPort();
}
return ['id' => $host, 'origins' => $origins];
}
if ($requestHost === 'localhost') {
return ['id' => 'localhost', 'origins' => [$request->getSchemeAndHttpHost()]];
}
return null;
}
public static function isAvailable(?Request $request = null): bool
{
return (bool) instanceSettings()->is_passkey_enabled
&& self::resolve($request ?? request()) !== null;
}
}
+6
View File
@@ -85,6 +85,12 @@ function allowedPathsForUnsubscribedAccounts()
// Account basics stay available without a paid plan.
'profile',
'profile/appearance',
'profile/passkeys/confirm',
'user/confirm-password',
'user/passkeys',
'user/passkeys/options',
'passkeys/confirm',
'passkeys/confirm/options',
];
}
function allowedPathsForBoardingAccounts()
+22 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\ConfigurePasskeys;
use App\Providers\RouteServiceProvider;
use Laravel\Fortify\Features;
@@ -89,7 +90,7 @@ return [
|
*/
'middleware' => ['web'],
'middleware' => ['web', ConfigurePasskeys::class],
/*
|--------------------------------------------------------------------------
@@ -106,6 +107,7 @@ return [
'login' => 'login',
'two-factor' => 'two-factor',
'forgot-password' => 'forgot-password',
'passkeys' => 'passkeys',
],
/*
@@ -121,6 +123,22 @@ return [
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| Defaults only. ConfigurePasskeys sets the relying party for each request.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
@@ -142,6 +160,9 @@ return [
'confirmPassword' => true,
// 'window' => 0,
]),
Features::passkeys([
'confirmPassword' => true,
]),
],
];
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('credential_id')->unique();
$table->json('credential');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_passkey_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_passkey_enabled');
});
Schema::dropIfExists('passkeys');
}
};
+36 -1
View File
@@ -6,6 +6,7 @@
"": {
"name": "coolify",
"dependencies": {
"@laravel/passkeys": "^0.4.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.20",
"@tanstack/virtual-core": "3.17.11",
@@ -87,6 +88,34 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@laravel/passkeys": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz",
"integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/browser": "^13.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"svelte": "^5.0.0",
"vue": "^3.3.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
@@ -360,6 +389,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
@@ -1248,7 +1283,7 @@
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
+1
View File
@@ -16,6 +16,7 @@
"vite": "8.2.1"
},
"dependencies": {
"@laravel/passkeys": "^0.4.0",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.20",
"@tanstack/virtual-core": "3.17.11",
+22
View File
@@ -0,0 +1,22 @@
import { Passkeys } from '@laravel/passkeys';
window.Passkeys = Passkeys;
window.passkeyDeviceName = () => {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua) ? 'Edge'
: /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: 'Browser';
const os = /iPhone|iPad/.test(ua) ? 'iOS'
: /Android/.test(ua) ? 'Android'
: /Mac/.test(ua) ? 'Mac'
: /Windows/.test(ua) ? 'Windows'
: /Linux/.test(ua) ? 'Linux'
: 'device';
return `${browser} on ${os}`;
};
window.dispatchEvent(new CustomEvent('passkeys:ready'));
@@ -28,6 +28,11 @@
{{ __('auth.confirm_password') }}
</x-forms.button>
</form>
@if ($can_confirm_with_passkey)
<x-auth.passkey-verify options-route="passkey.confirm-options" submit-route="passkey.confirm"
label="Confirm with a passkey" />
@endif
</div>
</x-auth.shell>
</x-layout-simple>
+8 -2
View File
@@ -22,12 +22,14 @@
<form action="/login" method="POST" class="flex flex-col gap-4">
@csrf
@env('local')
<x-forms.input value="test@example.com" type="email" autocomplete="email" name="email" required
<x-forms.input value="test@example.com" type="email"
autocomplete="{{ $passkeys_available ? 'email webauthn' : 'email' }}" name="email" required
autofocus label="{{ __('input.email') }}" />
<x-forms.input value="password" type="password" autocomplete="current-password" name="password"
required label="{{ __('input.password') }}" />
@else
<x-forms.input type="email" name="email" autocomplete="email" required autofocus
<x-forms.input type="email" name="email"
autocomplete="{{ $passkeys_available ? 'email webauthn' : 'email' }}" required autofocus
label="{{ __('input.email') }}" />
<x-forms.input type="password" name="password" autocomplete="current-password" required
label="{{ __('input.password') }}" />
@@ -78,6 +80,10 @@
</x-forms.button>
</form>
@if ($passkeys_available)
<x-auth.passkey-verify autofill />
@endif
@if ($enabled_oauth_providers->isNotEmpty())
<div class="auth-divider"><span>Or continue with</span></div>
<div class="flex flex-col gap-2">
@@ -0,0 +1,55 @@
@props([
'optionsRoute' => 'passkey.login-options',
'submitRoute' => 'passkey.login',
'label' => 'Sign in with a passkey',
'autofill' => false,
])
@vite('resources/js/passkeys.js')
<div x-cloak x-show="supported" class="flex flex-col gap-2" x-data="{
supported: false,
loading: false,
error: null,
routes: { options: @js(route($optionsRoute)), submit: @js(route($submitRoute)) },
init() {
const ready = () => {
this.supported = Boolean(window.Passkeys?.isSupported());
if (this.supported && @js($autofill)) {
this.autofill();
}
};
window.Passkeys ? ready() : window.addEventListener('passkeys:ready', ready, { once: true });
},
done(response) {
if (response) {
window.location.href = response.redirect || '/';
}
},
async autofill() {
try {
this.done(await window.Passkeys.autofill({ routes: this.routes }));
} catch (e) {
this.error = e.message;
}
},
async verify() {
this.loading = true;
this.error = null;
try {
this.done(await window.Passkeys.verify({ routes: this.routes }));
} catch (e) {
if (e.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
}">
<x-forms.button class="w-full justify-center" type="button" x-on:click="verify()" x-bind:disabled="loading">
{{ $label }}
</x-forms.button>
<template x-if="error">
<x-auth.alert type="error"><span x-text="error"></span></x-auth.alert>
</template>
</div>
@@ -300,6 +300,8 @@
</div>
</section>
<livewire:profile.passkeys />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
@@ -0,0 +1,104 @@
<div>
@if ($enabled)
@assets
@vite('resources/js/passkeys.js')
@endassets
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Passkeys</h2>
<p>Sign in with your fingerprint, face, or a security key. A passkey sign-in skips two-factor authentication.</p>
</div>
@if ($available && ! $show_form)
<a class="button" href="{{ route('profile.passkeys.confirm') }}">Add passkey</a>
@endif
</div>
<div class="application-settings-section-body flex flex-col gap-4">
@unless ($available)
<x-callout type="info" title="Passkeys are not available on this address">
Passkeys need HTTPS on the instance domain, or <code>localhost</code>. An IP address does not work.
</x-callout>
@endunless
@if ($available && $show_form)
<form class="flex flex-col gap-2" x-data="{
name: '',
loading: false,
error: null,
init() {
const ready = () => { this.name = this.name || window.passkeyDeviceName(); };
window.Passkeys ? ready() : window.addEventListener('passkeys:ready', ready, { once: true });
},
async register() {
this.loading = true;
this.error = null;
try {
if (!window.Passkeys?.isSupported()) {
throw new Error('This browser does not support passkeys.');
}
await window.Passkeys.register({ name: this.name });
await $wire.registered();
} catch (e) {
if (e.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
}" x-on:submit.prevent="register()">
<div class="flex items-end gap-2">
<x-forms.input x-model="name" label="Passkey name" required maxlength="255" />
<x-forms.button type="submit" isHighlighted x-bind:disabled="loading">Create passkey</x-forms.button>
<x-forms.button type="button" wire:click="$set('show_form', false)">Cancel</x-forms.button>
</div>
<p x-cloak x-show="error" x-text="error" class="text-sm text-error"></p>
</form>
@endif
@forelse ($passkeys as $passkey)
<div wire:key="passkey-{{ $passkey->id }}"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-neutral-200 p-3 dark:border-white/[0.07]"
x-data="{ editing: false, name: @js($passkey->name), saved: @js($passkey->name) }">
<div class="min-w-0">
<div x-show="!editing" class="text-sm font-semibold text-black dark:text-fg">{{ $passkey->name }}</div>
<form x-cloak x-show="editing" class="flex items-end gap-2"
x-on:submit.prevent="$wire.rename({{ $passkey->id }}, name).then(ok => { if (ok) { saved = name; editing = false; } })">
<x-forms.input x-model="name" required maxlength="255" aria-label="Passkey name" />
<x-forms.button type="submit">Save</x-forms.button>
</form>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
{{ $passkey->authenticator ?? 'Passkey' }}
· Added {{ $passkey->created_at->diffForHumans() }}
· {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }}
</p>
</div>
<div class="flex items-center gap-2">
<x-forms.button type="button" x-on:click="editing = !editing; name = saved">
<span x-text="editing ? 'Cancel' : 'Rename'"></span>
</x-forms.button>
<div wire:key="passkey-delete-{{ $passkey->id }}-{{ md5($passkey->name) }}">
<x-modal-confirmation title="Delete passkey?" buttonTitle="Delete" isErrorButton
submitAction="delete({{ $passkey->id }})"
:actions="['You cannot sign in with this passkey after it is deleted.']"
confirmationText="{{ $passkey->name }}"
confirmationLabel="Enter the passkey name to confirm"
shortConfirmationLabel="Passkey name" step3ButtonText="Delete passkey" />
</div>
</div>
</div>
@empty
@if ($available && ! $show_form)
<x-empty size="sm" title="No passkeys"
description="Add a passkey to sign in without your password."
icon-name="keys" />
@endif
@endforelse
@error('name')
<p class="text-sm text-error">{{ $message }}</p>
@enderror
</div>
</section>
@endif
</div>
@@ -32,6 +32,12 @@
['value' => false, 'label' => 'Require two-step confirmation'],
['value' => true, 'label' => 'Skip two-step confirmation'],
]" />
<x-forms.listbox canGate="update" :canResource="$settings" id="is_passkey_enabled" label="Passkeys"
helper="Let users sign in with a passkey. Passkeys need an HTTPS instance URL. Stored passkeys are kept when this is disabled."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</x-application.settings-section>
@@ -15,7 +15,7 @@
'lg:col-span-2' => !str_starts_with(strtolower($fqdn ?? ''), 'https://'),
])>
<x-forms.input canGate="update" :canResource="$settings" id="fqdn" label="URL"
helper="Enter the full URL of the instance (for example, https://dashboard.example.com).<br><br><span class='text-coollabs dark:text-warning'>Important:</span> Include <b>https://</b> to secure the dashboard with HTTPS."
helper="Enter the full URL of the instance (for example, https://dashboard.example.com).<br><br><span class='text-coollabs dark:text-warning'>Important:</span> Include <b>https://</b> to secure the dashboard with HTTPS.<br><br>Passkeys are tied to this domain. If you change it, users must add their passkeys again."
placeholder="https://coolify.yourdomain.com" />
</div>
+3
View File
@@ -186,6 +186,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/profile', ProfileIndex::class)->name('profile');
Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar');
Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance');
Route::get('/profile/passkeys/confirm', fn () => redirect()->route('profile')->with('status', 'passkey-add'))
->middleware('password.confirm')
->name('profile.passkeys.confirm');
Route::prefix('tags')->group(function () {
Route::get('/{tagName?}', TagsShow::class)->name('tags.show');
+98
View File
@@ -0,0 +1,98 @@
<?php
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Laravel\Passkeys\Events\PasskeyDeleted;
use Laravel\Passkeys\Events\PasskeyRegistered;
use Laravel\Passkeys\Events\PasskeyVerified;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutDefer();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->withoutVite();
Log::spy();
$this->user = User::factory()->create(['name' => 'Passkey User']);
$this->team = $this->user->teams()->sole();
});
it('allows the authenticated passkey paths for unsubscribed and boarding accounts', function () {
expect(allowedPathsForBoardingAccounts())->toContain(
'user/passkeys',
'user/passkeys/options',
'passkeys/confirm',
'passkeys/confirm/options',
'profile/passkeys/confirm',
);
});
it('sets the session team after a login that did not set one', function () {
$this->actingAs($this->user)->get('/profile')->assertOk();
expect(session('currentTeam')->id)->toBe($this->team->id);
});
it('sends a multi team user without a stored team to the team selection', function () {
$other = Team::factory()->create();
$other->members()->attach($this->user->id, ['role' => 'member']);
$this->actingAs($this->user)->get('/profile')->assertRedirect(route('team.select'));
});
it('asks for the password before the add passkey form opens', function () {
$this->actingAs($this->user)
->withSession(['currentTeam' => $this->team])
->get(route('profile.passkeys.confirm'))
->assertRedirect(route('password.confirm'));
$this->actingAs($this->user)
->withSession(['currentTeam' => $this->team, 'auth.password_confirmed_at' => time()])
->get(route('profile.passkeys.confirm'))
->assertRedirect(route('profile'))
->assertSessionHas('status', 'passkey-add');
});
it('lets restricted accounts confirm their password before adding a passkey', function (bool $selfHosted) {
config()->set('constants.coolify.self_hosted', $selfHosted);
$this->team->update(['show_boarding' => true]);
$this->actingAs($this->user)->withSession(['currentTeam' => $this->team]);
$this->get(route('profile.passkeys.confirm'))->assertRedirect(route('password.confirm'));
$this->get(route('password.confirm'))->assertOk();
$this->post(route('password.confirm.store'), ['password' => 'wrong-password'])
->assertSessionHasErrors('password');
expect(session('auth.password_confirmed_at'))->toBeNull();
$this->post(route('password.confirm.store'), ['password' => 'password'])
->assertRedirect(route('profile.passkeys.confirm'));
expect(session('auth.password_confirmed_at'))->toBeInt();
$this->get(route('profile.passkeys.confirm'))
->assertRedirect(route('profile'))
->assertSessionHas('status', 'passkey-add');
$this->getJson(route('passkey.registration-options'))->assertOk();
})->with(['onboarding' => true, 'cloud without a subscription' => false]);
it('records audit events for passkey changes', function (string $eventClass, string $auditName) {
$passkey = $this->user->passkeys()->create([
'name' => 'Laptop',
'credential_id' => 'credential-1',
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
]);
event(new $eventClass($this->user, $passkey));
expect(AuditEvent::query()->where('event', $auditName)->count())->toBe(1);
})->with([
[PasskeyRegistered::class, 'auth.user.passkey_registered'],
[PasskeyVerified::class, 'auth.user.passkey_verified'],
[PasskeyDeleted::class, 'auth.user.passkey_deleted'],
]);
@@ -0,0 +1,39 @@
<?php
use App\Livewire\Settings\Advanced;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
});
it('lets an instance admin turn passkeys off and keeps stored passkeys', function () {
$rootTeam = Team::factory()->create(['id' => 0]);
$admin = User::factory()->create();
$rootTeam->members()->attach($admin->id, ['role' => 'owner']);
$admin->passkeys()->create([
'name' => 'Laptop',
'credential_id' => 'credential-1',
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
]);
$this->actingAs($admin);
session(['currentTeam' => $rootTeam]);
Livewire::test(Advanced::class)
->assertSet('is_passkey_enabled', true)
->set('is_passkey_enabled', false)
->call('instantSave');
expect(InstanceSettings::findOrFail(0)->is_passkey_enabled)->toBeFalse()
->and($admin->passkeys()->count())->toBe(1);
});
@@ -0,0 +1,100 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Support\PasskeyRelyingParty;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->withoutVite();
});
function setPasskeyInstance(?string $fqdn, string $appUrl, bool $enabled = true): void
{
InstanceSettings::query()->where('id', 0)->update(['fqdn' => $fqdn, 'is_passkey_enabled' => $enabled]);
config()->set('app.url', $appUrl);
Once::flush();
}
it('resolves the relying party', function (?string $fqdn, string $appUrl, string $requestUrl, ?array $expected) {
setPasskeyInstance($fqdn, $appUrl);
expect(PasskeyRelyingParty::resolve(Request::create($requestUrl)))->toBe($expected);
})->with([
'https fqdn' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com/login',
['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]],
'fqdn behind http proxy' => ['https://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login',
['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]],
'app url fallback keeps the port' => [null, 'https://dev.example.ts.net:8443', 'https://dev.example.ts.net:8443/login',
['id' => 'dev.example.ts.net', 'origins' => ['https://dev.example.ts.net:8443']]],
'localhost uses the request origin' => [null, 'http://localhost', 'http://localhost:8000/login',
['id' => 'localhost', 'origins' => ['http://localhost:8000']]],
'fqdn on a custom port' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com:8443/login',
['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com', 'https://coolify.example.com:8443']]],
'fqdn with uppercase letters' => ['https://Coolify.Example.com', 'http://localhost', 'https://coolify.example.com/login',
['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]],
'http fqdn' => ['http://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', null],
'raw ip' => [null, 'https://203.0.113.10', 'https://203.0.113.10/login', null],
]);
it('is not available on a host that is not the instance host', function () {
setPasskeyInstance('https://coolify.example.com', 'http://localhost');
expect(PasskeyRelyingParty::isAvailable(Request::create('http://203.0.113.10:8000/login')))->toBeFalse();
});
it('is not available when the instance toggle is off', function () {
setPasskeyInstance(null, 'http://localhost', enabled: false);
expect(PasskeyRelyingParty::isAvailable(Request::create('http://localhost/login')))->toBeFalse();
});
it('returns login options with the resolved relying party', function () {
setPasskeyInstance('https://coolify.example.com', 'http://localhost');
$this->getJson('https://coolify.example.com/passkeys/login/options')
->assertOk()
->assertJsonPath('options.rpId', 'coolify.example.com');
});
it('returns 404 on passkey routes when passkeys are not available', function (bool $enabled, string $url) {
setPasskeyInstance(null, 'http://localhost', $enabled);
$this->getJson($url)->assertNotFound();
})->with([
'toggle off' => [false, 'http://localhost/passkeys/login/options'],
'raw ip host' => [true, 'http://203.0.113.10:8000/passkeys/login/options'],
]);
it('keeps the password login page reachable when passkeys are not available', function () {
setPasskeyInstance(null, 'http://localhost', enabled: false);
User::factory()->create();
$this->get('http://203.0.113.10:8000/login')->assertOk();
});
it('rate limits the passkey routes by client ip', function () {
setPasskeyInstance(null, 'http://localhost');
foreach (range(1, 30) as $attempt) {
$this->getJson('/passkeys/login/options')->assertOk();
}
$this->getJson('/passkeys/login/options')->assertStatus(429);
});
it('does not need the instance settings on other fortify routes', function () {
InstanceSettings::query()->delete();
Once::flush();
$this->post('/login', ['email' => 'nobody@example.com', 'password' => 'wrong-password'])
->assertRedirect()
->assertSessionHasErrors('email');
});
+42
View File
@@ -0,0 +1,42 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
});
it('registers the fortify passkey routes', function () {
expect(Route::has('passkey.login-options'))->toBeTrue()
->and(Route::has('passkey.login'))->toBeTrue()
->and(Route::has('passkey.confirm'))->toBeTrue()
->and(Route::has('passkey.store'))->toBeTrue();
});
it('returns a stable 64 character hex user handle', function () {
$user = User::factory()->create();
$handle = $user->getPasskeyUserHandle();
expect($handle)->toMatch('/^[0-9a-f]{64}$/')
->and(User::find($user->id)->getPasskeyUserHandle())->toBe($handle);
});
it('enables passkeys by default and stores passkeys for a user', function () {
$user = User::factory()->create();
$user->passkeys()->create([
'name' => 'Laptop',
'credential_id' => 'credential-1',
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
]);
expect(instanceSettings()->is_passkey_enabled)->toBeTrue()
->and($user->hasPasskeysEnabled())->toBeTrue();
});
+137
View File
@@ -0,0 +1,137 @@
<?php
use App\Livewire\Profile\Passkeys;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Laravel\Passkeys\Passkey;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->withoutVite();
$this->user = User::factory()->create(['name' => 'Passkey User', 'password' => Hash::make('secret-password')]);
$this->actingAs($this->user);
session(['currentTeam' => $this->user->teams()->sole()]);
});
function profilePasskey(User $user, string $name = 'Laptop'): Passkey
{
return $user->passkeys()->create([
'name' => $name,
'credential_id' => 'credential-'.$user->id.'-'.$name,
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
]);
}
it('lists only the passkeys of the current user', function () {
profilePasskey($this->user, 'My laptop');
profilePasskey(User::factory()->create(), 'Other phone');
Livewire::test(Passkeys::class)
->assertSee('My laptop')
->assertDontSee('Other phone');
});
it('opens the add form only after the confirm redirect', function () {
Livewire::test(Passkeys::class)->assertSet('show_form', false);
session()->flash('status', 'passkey-add');
Livewire::test(Passkeys::class)->assertSet('show_form', true);
});
it('renames a passkey of the current user', function () {
$passkey = profilePasskey($this->user);
Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' Work laptop ');
expect($passkey->fresh()->name)->toBe('Work laptop');
});
it('rejects an empty or too long passkey name', function (string $name) {
$passkey = profilePasskey($this->user);
Livewire::test(Passkeys::class)->call('rename', $passkey->id, $name)->assertHasErrors('name');
expect($passkey->fresh()->name)->toBe('Laptop');
})->with(['empty' => [' '], 'too long' => [str_repeat('a', 256)]]);
it('does not rename the passkey of another user', function () {
$passkey = profilePasskey(User::factory()->create());
expect(fn () => Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Stolen'))
->toThrow(ModelNotFoundException::class);
expect($passkey->fresh()->name)->toBe('Laptop');
});
it('deletes a passkey with the correct password', function () {
$passkey = profilePasskey($this->user);
Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password');
expect(Passkey::find($passkey->id))->toBeNull();
});
it('does not delete a passkey with a wrong password', function () {
$passkey = profilePasskey($this->user);
Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'wrong-password');
expect(Passkey::find($passkey->id))->not->toBeNull();
});
it('does not delete the passkey of another user', function () {
$passkey = profilePasskey(User::factory()->create());
expect(fn () => Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password'))
->toThrow(ModelNotFoundException::class);
expect(Passkey::find($passkey->id))->not->toBeNull();
});
it('renders nothing when passkeys are turned off', function () {
InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]);
Once::flush();
profilePasskey($this->user, 'My laptop');
Livewire::test(Passkeys::class)->assertDontSee('My laptop')->assertDontSee('Passkeys');
});
it('refuses passkey changes when passkeys are turned off', function (string $action, array $arguments) {
$passkey = profilePasskey($this->user);
InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]);
Once::flush();
Livewire::test(Passkeys::class)->call($action, $passkey->id, ...$arguments)->assertNotFound();
expect($passkey->fresh()?->name)->toBe('Laptop');
})->with([
'rename' => ['rename', ['Renamed']],
'delete' => ['delete', ['secret-password']],
]);
it('explains why a passkey cannot be added on an address that does not support passkeys', function () {
profilePasskey($this->user, 'My laptop');
$this->get('http://203.0.113.10:8000/profile')
->assertOk()
->assertSee('My laptop')
->assertSee('Passkeys need HTTPS')
->assertDontSee('Add passkey');
});
it('reports if a rename was saved', function () {
$passkey = profilePasskey($this->user);
Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Work laptop')->assertReturned(true);
Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' ')->assertReturned(null);
});
+1
View File
@@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => {
input: [
"resources/css/app.css",
"resources/js/app.js",
"resources/js/passkeys.js",
],
refresh: true,
}),