Conversation
Replace password-based authentication with WebAuthn passkeys for improved security: - Add web-auth/webauthn-lib dependency for WebAuthn support - Create passkeys table and Passkey model - Add passkey registration/authentication actions - Update setup flow to register passkey instead of password - Update login to authenticate via passkey - Add passkey management to settings page (add/delete passkeys) - Keep optional TOTP 2FA as additional security layer - Update PHPStan config to ignore WebAuthn named param false positives Users now sign in using device biometrics (fingerprint, face) or security keys instead of passwords.
There was a problem hiding this comment.
Pull request overview
This PR implements passkey-only authentication to replace traditional password-based login, enhancing security through WebAuthn biometric authentication (fingerprint, face recognition) or security keys.
Key Changes
- Replaced password authentication with WebAuthn passkeys using the web-auth/webauthn-lib library
- Added passkey registration during setup flow and passkey management in settings
- Maintained optional TOTP 2FA as an additional security layer
Reviewed changes
Copilot reviewed 18 out of 20 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| database/migrations/2026_01_06_000001_create_passkeys_table.php | Creates passkeys table with credential storage |
| app/Models/Passkey.php | New model for managing WebAuthn passkey credentials |
| routes/admin.php | Added passkey authentication and management routes |
| resources/js/Pages/Setup.tsx | Replaced password fields with passkey registration flow |
| resources/js/Pages/Auth/Login.tsx | Implemented passkey-based authentication UI |
| resources/js/Pages/Settings/Index.tsx | Added passkey management interface (add/delete) |
| app/Http/Controllers/Admin/SetupController.php | Multi-step setup with passkey registration |
| app/Http/Controllers/Admin/AuthController.php | Passkey verification endpoints for login |
| app/Http/Controllers/Admin/SettingsController.php | Passkey CRUD operations |
| app/Actions/Admin/Passkey/*.php | WebAuthn credential generation and verification logic |
| composer.json | Added web-auth/webauthn-lib dependency |
| phpstan.neon | Added ignores for WebAuthn library named parameters |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Find the passkey by credential ID | ||
| $credentialId = $publicKeyCredential->rawId; | ||
| $passkey = Passkey::whereRaw('credential_id = ?', [$credentialId])->first(); |
There was a problem hiding this comment.
The whereRaw query with a placeholder parameter is used incorrectly. The credential_id is a binary field, and comparing it directly may not work as expected across different database engines. Consider using a proper Eloquent where clause or ensure the binary comparison is database-agnostic.
| $passkey = Passkey::whereRaw('credential_id = ?', [$credentialId])->first(); | |
| $passkey = Passkey::where('credential_id', $credentialId)->first(); |
| ]); | ||
|
|
||
| return ['success' => true, 'passkey' => $passkey]; | ||
| } catch (\Throwable $e) { |
There was a problem hiding this comment.
The catch block catches all Throwable exceptions and returns a generic error message. This broad exception handling may hide important debugging information. Consider logging the exception or providing more specific error messages based on the exception type to aid troubleshooting.
| } catch (\Throwable $e) { | |
| } catch (\Throwable $e) { | |
| logger()->error('Failed to verify WebAuthn registration.', [ | |
| 'exception' => $e, | |
| ]); |
| function base64ToArrayBuffer(base64: string): ArrayBuffer { | ||
| const binaryString = atob(base64); | ||
| const bytes = new Uint8Array(binaryString.length); | ||
| for (let i = 0; i < binaryString.length; i++) { | ||
| bytes[i] = binaryString.charCodeAt(i); | ||
| } | ||
| return bytes.buffer; | ||
| } | ||
|
|
||
| // Helper to convert ArrayBuffer to base64 | ||
| function arrayBufferToBase64(buffer: ArrayBuffer): string { | ||
| const bytes = new Uint8Array(buffer); | ||
| let binary = ''; | ||
| for (let i = 0; i < bytes.byteLength; i++) { | ||
| binary += String.fromCharCode(bytes[i]); | ||
| } | ||
| return btoa(binary); | ||
| } | ||
|
|
||
| // Get CSRF token from cookie | ||
| function getCsrfToken(): string { | ||
| const xsrfCookie = document.cookie | ||
| .split('; ') | ||
| .find((row) => row.startsWith('XSRF-TOKEN=')); | ||
| if (xsrfCookie) { | ||
| return decodeURIComponent(xsrfCookie.split('=')[1]); | ||
| } | ||
| return ( | ||
| document | ||
| .querySelector('meta[name="csrf-token"]') | ||
| ?.getAttribute('content') || '' | ||
| ); | ||
| } |
There was a problem hiding this comment.
The helper functions base64ToArrayBuffer, arrayBufferToBase64, and getCsrfToken are duplicated across three different files (Setup.tsx, Login.tsx, and Settings/Index.tsx). This code duplication violates the DRY principle and makes maintenance harder. Consider extracting these utility functions into a shared module that can be imported across all files.
| } catch { | ||
| setErrors({ general: 'An error occurred' }); |
There was a problem hiding this comment.
The error handling in the catch block at line 123 swallows the actual error and returns a generic message. This makes debugging difficult as the actual error information is lost. The error should be logged or at least include more context about what went wrong.
| } catch { | |
| setErrors({ general: 'An error occurred' }); | |
| } catch (error) { | |
| console.error('Error while saving setup info:', error); | |
| setErrors({ | |
| general: 'An error occurred while saving setup info. Please try again.', | |
| }); |
| } | ||
|
|
||
| // Expire after 2 minutes | ||
| if (! $challenge || now()->timestamp - $challengeAt > 120) { |
There was a problem hiding this comment.
The challenge expiration time is hardcoded as 120 seconds in multiple locations (lines 85, 401). This magic number should be extracted into a named constant to improve maintainability and ensure consistency across the codebase.
| public function storeInfo(Request $request): JsonResponse | ||
| { | ||
| $validated = $request->validate([ | ||
| 'site_name' => ['required', 'string', 'max:255'], | ||
| 'site_url' => ['required', 'url', 'max:1024'], | ||
| 'username' => ['required', 'string', 'min:3', 'max:50'], | ||
| 'email' => ['required', 'email', 'max:255'], | ||
| 'password' => ['required', 'string', 'min:8', 'confirmed'], | ||
| ]); | ||
|
|
||
| // Store in session for later | ||
| $request->session()->put('setup_info', $validated); | ||
|
|
||
| return response()->json(['success' => true]); | ||
| } | ||
|
|
||
| /** | ||
| * Generate passkey registration options (step 2). | ||
| */ | ||
| public function passkeyOptions(Request $request): JsonResponse | ||
| { | ||
| $setupInfo = $request->session()->get('setup_info'); | ||
|
|
||
| if (! $setupInfo) { | ||
| return response()->json(['error' => 'Setup info not found. Please start over.'], 400); | ||
| } | ||
|
|
||
| // Temporarily set the required settings for passkey generation | ||
| Setting::setValue('site_name', $setupInfo['site_name']); | ||
| Setting::setValue('site_url', $setupInfo['site_url']); | ||
| Setting::setValue('admin_username', $setupInfo['username']); | ||
| Setting::setValue('admin_email', $setupInfo['email']); | ||
|
|
||
| $result = GenerateRegistrationOptions::run(); | ||
|
|
||
| // Store challenge in session | ||
| $request->session()->put('setup_passkey_challenge', $result['challenge']); | ||
| $request->session()->put('setup_passkey_challenge_at', now()->timestamp); | ||
|
|
||
| return response()->json($result['options']); | ||
| } | ||
|
|
||
| /** | ||
| * Complete setup with passkey registration (step 3). | ||
| */ | ||
| public function store(Request $request): JsonResponse | ||
| { | ||
| $setupInfo = $request->session()->get('setup_info'); | ||
| $challenge = $request->session()->get('setup_passkey_challenge'); | ||
| $challengeAt = $request->session()->get('setup_passkey_challenge_at', 0); | ||
|
|
||
| if (! $setupInfo) { | ||
| return response()->json(['error' => 'Setup info not found. Please start over.'], 400); | ||
| } | ||
|
|
||
| // Expire after 2 minutes | ||
| if (! $challenge || now()->timestamp - $challengeAt > 120) { | ||
| $request->session()->forget(['setup_passkey_challenge', 'setup_passkey_challenge_at']); | ||
|
|
||
| return response()->json(['error' => 'Challenge expired. Please try again.'], 400); | ||
| } | ||
|
|
||
| $validated = $request->validate([ | ||
| 'credential' => ['required', 'array'], | ||
| 'passkey_name' => ['required', 'string', 'max:255'], | ||
| ]); | ||
|
|
||
| // Verify and register the passkey | ||
| $result = VerifyRegistration::run( | ||
| $validated['credential'], | ||
| $challenge, | ||
| $validated['passkey_name'] | ||
| ); | ||
|
|
||
| if (! $result['success']) { | ||
| return response()->json(['error' => $result['message'] ?? 'Passkey registration failed'], 400); | ||
| } | ||
|
|
||
| // Complete setup (settings were already saved in passkeyOptions) | ||
| SetupAdmin::run( | ||
| $validated['username'], | ||
| $validated['email'], | ||
| $validated['password'], | ||
| $validated['site_name'], | ||
| $validated['site_url'] | ||
| $setupInfo['username'], | ||
| $setupInfo['email'], | ||
| $setupInfo['site_name'], | ||
| $setupInfo['site_url'] | ||
| ); | ||
|
|
||
| // Clear session data | ||
| $request->session()->forget([ | ||
| 'setup_info', | ||
| 'setup_passkey_challenge', | ||
| 'setup_passkey_challenge_at', | ||
| ]); | ||
|
|
||
| // Auto-login after setup | ||
| $request->session()->regenerate(); | ||
| $request->session()->put('admin_authenticated', true); | ||
|
|
||
| return redirect()->route('admin.dashboard'); | ||
| return response()->json([ | ||
| 'success' => true, | ||
| 'redirect' => route('admin.dashboard'), | ||
| ]); |
There was a problem hiding this comment.
The setup endpoints in SetupController (storeInfo, passkeyOptions, and store) can be invoked even after initial setup is complete, allowing an unauthenticated attacker to re-run the setup wizard and register a new admin passkey. Because these actions rely only on session state and do not re-check SetupAdmin::isComplete() or require existing admin authentication, an attacker can call /admin/setup/info → /admin/setup/passkey/options → /admin/setup from the public login page, complete a WebAuthn registration, and end up with admin_authenticated set to true and a valid admin passkey, bypassing any configured TOTP 2FA. You should hard-block all setup routes once setup_complete is true (e.g., by tightening RedirectIfSetupRequired to cover all admin.setup* routes and/or adding explicit guards in these methods) so that the setup flow cannot be used post-install to create additional admin credentials.
Replace password-based authentication with WebAuthn passkeys for
improved security:
Users now sign in using device biometrics (fingerprint, face) or
security keys instead of passwords.