mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat(auth): add passkey sign-in (#12154)
This commit is contained in:
29 files changed
+923
-8
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
};
|
||||
Generated
+36
-1
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => {
|
||||
input: [
|
||||
"resources/css/app.css",
|
||||
"resources/js/app.js",
|
||||
"resources/js/passkeys.js",
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user