diff --git a/resources/js/frontend/components/Passkeys.js b/resources/js/frontend/components/Passkeys.js new file mode 100644 index 00000000000..4e5d6d03f1b --- /dev/null +++ b/resources/js/frontend/components/Passkeys.js @@ -0,0 +1,251 @@ +import { startAuthentication, startRegistration, browserSupportsWebAuthn, WebAuthnAbortService } from '@simplewebauthn/browser'; + +export default class Passkeys { + constructor() { + this.supported = browserSupportsWebAuthn(); + this._waiting = false; + this._error = null; + this._defaults = {}; + } + + configure(defaults) { + this._defaults = defaults; + return this; + } + + get waiting() { + return this._waiting; + } + + get error() { + return this._error; + } + + /** + * Authenticate with a passkey. + * + * @param {Object} options + * @param {string} options.optionsUrl - URL to fetch assertion options + * @param {string} options.verifyUrl - URL to verify the assertion + * @param {Function} [options.onSuccess] - Callback on success with response data + * @param {Function} [options.onError] - Callback on error with error object + * @param {boolean} [options.useBrowserAutofill=false] - Use browser autofill UI + * @param {string} [options.csrfToken] - Override CSRF token + */ + async authenticate(options = {}) { + const { + optionsUrl, + verifyUrl, + onSuccess, + onError, + useBrowserAutofill = false, + csrfToken, + } = { ...this._defaults, ...options }; + + if (!useBrowserAutofill) { + this._waiting = true; + } + this._error = null; + + try { + const authOptionsResponse = await fetch(optionsUrl, { + credentials: 'same-origin', + }); + + if (!authOptionsResponse.ok) { + throw new Error('Failed to fetch authentication options'); + } + + const optionsJSON = await authOptionsResponse.json(); + + let authResponse; + try { + authResponse = await startAuthentication({ optionsJSON, useBrowserAutofill }); + } catch (e) { + if (e.name === 'AbortError' || e.name === 'NotAllowedError') { + return; + } + console.error(e); + this._error = 'Authentication failed.'; + if (onError) { + onError({ message: this._error, originalError: e }); + } + return; + } + + const verifyResponse = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken || this._getCsrfToken(), + }, + credentials: 'same-origin', + body: JSON.stringify(authResponse), + }); + + const data = await verifyResponse.json(); + + if (!verifyResponse.ok) { + this._error = data.message || 'Verification failed.'; + if (onError) { + onError({ message: this._error, status: verifyResponse.status }); + } + return; + } + + if (onSuccess) { + onSuccess(data); + } + } catch (e) { + this._handleError(e, onError); + } finally { + if (!useBrowserAutofill) { + this._waiting = false; + } + } + } + + /** + * Register a new passkey. + * + * @param {Object} options + * @param {string} options.optionsUrl - URL to fetch attestation options + * @param {string} options.verifyUrl - URL to verify and store the passkey + * @param {string} [options.name='Passkey'] - Name for the passkey + * @param {Function} [options.onSuccess] - Callback on success with response data + * @param {Function} [options.onError] - Callback on error with error object + * @param {string} [options.csrfToken] - Override CSRF token + */ + async register(options = {}) { + const { + optionsUrl, + verifyUrl, + name = 'Passkey', + onSuccess, + onError, + csrfToken, + } = { ...this._defaults, ...options }; + + this._waiting = true; + this._error = null; + + try { + const createOptionsResponse = await fetch(optionsUrl, { + credentials: 'same-origin', + }); + + if (!createOptionsResponse.ok) { + throw new Error('Failed to fetch registration options'); + } + + const optionsJSON = await createOptionsResponse.json(); + + let registrationResponse; + try { + registrationResponse = await startRegistration({ optionsJSON }); + } catch (e) { + if (e.name === 'AbortError' || e.name === 'NotAllowedError') { + return; + } + console.error(e); + this._error = 'Registration failed.'; + if (onError) { + onError({ message: this._error, originalError: e }); + } + return; + } + + const verifyResponse = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken || this._getCsrfToken(), + }, + credentials: 'same-origin', + body: JSON.stringify({ + ...registrationResponse, + name, + }), + }); + + const data = await verifyResponse.json(); + + if (!verifyResponse.ok) { + this._error = data.message || 'Verification failed.'; + if (onError) { + onError({ message: this._error, status: verifyResponse.status }); + } + return; + } + + if (onSuccess) { + onSuccess(data); + } + } catch (e) { + this._handleError(e, onError); + } finally { + this._waiting = false; + } + } + + /** + * Cancel any ongoing WebAuthn ceremony. + */ + cancel() { + WebAuthnAbortService.cancelCeremony(); + } + + /** + * Initialize browser autofill for passkey authentication. + * Call this on page load to enable passkey suggestions in form fields. + * + * @param {Object} options + * @param {string} options.optionsUrl - URL to fetch assertion options + * @param {string} options.verifyUrl - URL to verify the assertion + * @param {Function} [options.onSuccess] - Callback on success with response data + * @param {Function} [options.onError] - Callback on error with error object + * @param {string} [options.csrfToken] - Override CSRF token + */ + initAutofill(options = {}) { + if (!this.supported) { + return; + } + + this.authenticate({ + ...options, + useBrowserAutofill: true, + }); + } + + /** + * Get the CSRF token from the page. + * @private + */ + _getCsrfToken() { + const metaTag = document.querySelector('meta[name="csrf-token"]'); + if (metaTag) { + return metaTag.getAttribute('content'); + } + + const input = document.querySelector('input[name="_token"]'); + if (input) { + return input.value; + } + + return ''; + } + + /** + * Handle errors consistently. + * @private + */ + _handleError(e, onError) { + this._error = e.message || 'Something went wrong'; + + if (onError) { + onError({ message: this._error, originalError: e }); + } + } +} diff --git a/resources/js/frontend/helpers.js b/resources/js/frontend/helpers.js index 8377adc06ff..5bb668b89af 100644 --- a/resources/js/frontend/helpers.js +++ b/resources/js/frontend/helpers.js @@ -1,8 +1,10 @@ import FieldConditions from './components/FieldConditions.js'; +import Passkeys from './components/Passkeys.js'; class Statamic { constructor() { this.$conditions = new FieldConditions(); + this.$passkeys = new Passkeys(); } } diff --git a/resources/js/tests/Frontend/Passkeys.test.js b/resources/js/tests/Frontend/Passkeys.test.js new file mode 100644 index 00000000000..e643167496f --- /dev/null +++ b/resources/js/tests/Frontend/Passkeys.test.js @@ -0,0 +1,418 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import Passkeys from '../../frontend/components/Passkeys.js'; + +vi.mock('@simplewebauthn/browser', () => ({ + browserSupportsWebAuthn: vi.fn(() => true), + startAuthentication: vi.fn(), + startRegistration: vi.fn(), + WebAuthnAbortService: { + cancelCeremony: vi.fn(), + }, +})); + +import { browserSupportsWebAuthn, startAuthentication, startRegistration, WebAuthnAbortService } from '@simplewebauthn/browser'; + +describe('Passkeys', () => { + let passkeys; + + beforeEach(() => { + passkeys = new Passkeys(); + vi.clearAllMocks(); + + global.fetch = vi.fn(); + + Object.defineProperty(document, 'querySelector', { + value: vi.fn(() => null), + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('it checks browser support', () => { + expect(passkeys.supported).toBe(true); + }); + + test('it starts in non-waiting state', () => { + expect(passkeys.waiting).toBe(false); + }); + + test('it starts with no error', () => { + expect(passkeys.error).toBe(null); + }); + + describe('authenticate', () => { + test('it fetches options and calls startAuthentication', async () => { + const mockOptions = { challenge: 'test-challenge' }; + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockOptions), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/dashboard' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + const onSuccess = vi.fn(); + + await passkeys.authenticate({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onSuccess, + }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(startAuthentication).toHaveBeenCalledWith({ + optionsJSON: mockOptions, + useBrowserAutofill: false, + }); + expect(onSuccess).toHaveBeenCalledWith({ redirect: '/dashboard' }); + }); + + test('it handles authentication errors gracefully', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }); + + const abortError = new Error('User cancelled'); + abortError.name = 'NotAllowedError'; + startAuthentication.mockRejectedValueOnce(abortError); + + const onError = vi.fn(); + + await passkeys.authenticate({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onError, + }); + + // NotAllowedError should be silently ignored + expect(onError).not.toHaveBeenCalled(); + }); + + test('it handles verification failure', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Invalid passkey' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + const onError = vi.fn(); + + await passkeys.authenticate({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onError, + }); + + expect(onError).toHaveBeenCalledWith({ + message: 'Invalid passkey', + status: 401, + }); + expect(passkeys.error).toBe('Invalid passkey'); + }); + }); + + describe('register', () => { + test('it fetches options and calls startRegistration', async () => { + const mockOptions = { challenge: 'test-challenge' }; + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockOptions), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ verified: true }), + }); + + startRegistration.mockResolvedValueOnce({ id: 'new-credential' }); + + const onSuccess = vi.fn(); + + await passkeys.register({ + optionsUrl: '/passkeys/create', + verifyUrl: '/passkeys/store', + name: 'My Passkey', + onSuccess, + }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(startRegistration).toHaveBeenCalledWith({ + optionsJSON: mockOptions, + }); + expect(onSuccess).toHaveBeenCalledWith({ verified: true }); + }); + + test('it uses default passkey name', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ verified: true }), + }); + + startRegistration.mockResolvedValueOnce({ id: 'new-credential' }); + + await passkeys.register({ + optionsUrl: '/passkeys/create', + verifyUrl: '/passkeys/store', + }); + + const lastCall = global.fetch.mock.calls[1]; + const body = JSON.parse(lastCall[1].body); + expect(body.name).toBe('Passkey'); + }); + }); + + describe('cancel', () => { + test('it calls WebAuthnAbortService.cancelCeremony', () => { + passkeys.cancel(); + expect(WebAuthnAbortService.cancelCeremony).toHaveBeenCalled(); + }); + }); + + describe('initAutofill', () => { + test('it calls authenticate with useBrowserAutofill', async () => { + // Create a fresh instance to ensure supported is true + browserSupportsWebAuthn.mockReturnValue(true); + const freshPasskeys = new Passkeys(); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + // Note: initAutofill returns immediately if not supported, so we need to await the authenticate call + await freshPasskeys.initAutofill({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + }); + + // Small delay to allow async operation to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(startAuthentication).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'test' }, + useBrowserAutofill: true, + }); + }); + + test('it does nothing if browser does not support webauthn', async () => { + browserSupportsWebAuthn.mockReturnValueOnce(false); + const noSupportPasskeys = new Passkeys(); + + await noSupportPasskeys.initAutofill({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + test('it returns the instance', () => { + const result = passkeys.configure({ optionsUrl: '/options' }); + expect(result).toBe(passkeys); + }); + + test('it uses configured defaults for authenticate', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/dashboard' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + const onSuccess = vi.fn(); + + passkeys.configure({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onSuccess, + }); + + await passkeys.authenticate(); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(onSuccess).toHaveBeenCalledWith({ redirect: '/dashboard' }); + }); + + test('it uses configured defaults for register', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ verified: true }), + }); + + startRegistration.mockResolvedValueOnce({ id: 'new-credential' }); + + const onSuccess = vi.fn(); + + passkeys.configure({ + optionsUrl: '/passkeys/create', + verifyUrl: '/passkeys/store', + onSuccess, + }); + + await passkeys.register({ name: 'My Key' }); + + expect(onSuccess).toHaveBeenCalledWith({ verified: true }); + + const lastCall = global.fetch.mock.calls[1]; + const body = JSON.parse(lastCall[1].body); + expect(body.name).toBe('My Key'); + }); + + test('call-time options override configured defaults', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + const defaultSuccess = vi.fn(); + const overrideSuccess = vi.fn(); + + passkeys.configure({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onSuccess: defaultSuccess, + }); + + await passkeys.authenticate({ onSuccess: overrideSuccess }); + + expect(defaultSuccess).not.toHaveBeenCalled(); + expect(overrideSuccess).toHaveBeenCalledWith({ redirect: '/' }); + }); + + test('it uses configured defaults for initAutofill', async () => { + browserSupportsWebAuthn.mockReturnValue(true); + const freshPasskeys = new Passkeys(); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + const onSuccess = vi.fn(); + + freshPasskeys.configure({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + onSuccess, + }); + + await freshPasskeys.initAutofill(); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(startAuthentication).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'test' }, + useBrowserAutofill: true, + }); + }); + }); + + describe('CSRF token', () => { + test('it reads CSRF token from meta tag', async () => { + document.querySelector = vi.fn((selector) => { + if (selector === 'meta[name="csrf-token"]') { + return { getAttribute: () => 'meta-csrf-token' }; + } + return null; + }); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + await passkeys.authenticate({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + }); + + const lastCall = global.fetch.mock.calls[1]; + expect(lastCall[1].headers['X-CSRF-TOKEN']).toBe('meta-csrf-token'); + }); + + test('it allows overriding CSRF token', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ challenge: 'test' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ redirect: '/' }), + }); + + startAuthentication.mockResolvedValueOnce({ id: 'credential-id' }); + + await passkeys.authenticate({ + optionsUrl: '/passkeys/options', + verifyUrl: '/passkeys/login', + csrfToken: 'custom-token', + }); + + const lastCall = global.fetch.mock.calls[1]; + expect(lastCall[1].headers['X-CSRF-TOKEN']).toBe('custom-token'); + }); + }); +}); diff --git a/routes/web.php b/routes/web.php index 220088c94e8..a1f706681a1 100755 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,8 @@ use Statamic\Http\Controllers\TwoFactorChallengeController; use Statamic\Http\Controllers\TwoFactorSetupController; use Statamic\Http\Controllers\User\LoginController; +use Statamic\Http\Controllers\User\PasskeyController; +use Statamic\Http\Controllers\User\PasskeyLoginController; use Statamic\Http\Controllers\User\PasswordController; use Statamic\Http\Controllers\User\ProfileController; use Statamic\Http\Controllers\User\RegisterController; @@ -51,6 +53,19 @@ Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action'); + Route::group(['prefix' => 'passkeys'], function () { + Route::middleware(ThrottleRequests::class.':30,1')->group(function () { + Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.options'); + Route::post('auth', [PasskeyLoginController::class, 'login'])->name('passkeys.login'); + }); + + Route::middleware('auth')->group(function () { + Route::get('create', [PasskeyController::class, 'create'])->name('passkeys.create'); + Route::post('/', [PasskeyController::class, 'store'])->name('passkeys.store'); + Route::delete('{id}', [PasskeyController::class, 'destroy'])->name('passkeys.destroy'); + }); + }); + if (TwoFactor::enabled()) { Route::get('two-factor-setup', TwoFactorSetupController::class)->name('two-factor-setup'); Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index 530180f9fba..f35f67a9bde 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -100,7 +100,10 @@ public function profile() */ public function loginForm() { - $data = $this->getFormSession(); + $data = array_merge($this->getFormSession(), [ + 'passkey_options_url' => route('statamic.passkeys.options'), + 'passkey_verify_url' => route('statamic.passkeys.login'), + ]); $knownParams = ['redirect', 'error_redirect', 'allow_request_redirect']; @@ -293,6 +296,112 @@ public function registrationForm() return $this->registerForm(); } + /** + * Output a passkey registration form. + * + * Maps to {{ user:passkey_form }} + * + * @return string + */ + public function passkeyForm() + { + $data = [ + 'passkey_options_url' => route('statamic.passkeys.create'), + 'passkey_verify_url' => route('statamic.passkeys.store'), + ]; + + if (! $this->canParseContents()) { + return $data; + } + + return $this->parse($data); + } + + /** + * Output the current user's passkeys. + * + * Maps to {{ user:passkeys }} + * + * @return string + */ + public function passkeys() + { + if (! $user = User::current()) { + return $this->canParseContents() ? $this->parseNoResults() : []; + } + + $passkeys = $user->passkeys()->map(function ($passkey) { + return [ + 'id' => $passkey->id(), + 'name' => $passkey->name(), + 'last_login' => $passkey->lastLogin(), + ]; + })->values()->all(); + + if (! $this->canParseContents()) { + return $passkeys; + } + + if (empty($passkeys)) { + return $this->parseNoResults(); + } + + return $this->parseLoop($passkeys); + } + + /** + * Output a delete passkey form. + * + * Maps to {{ user:delete_passkey_form }} + * + * @return string + */ + public function deletePasskeyForm() + { + if (! $user = User::current()) { + return ''; + } + + $id = $this->params->get('id'); + + if (! $id || ! $user->passkeys()->get($id)) { + return ''; + } + + $action = route('statamic.passkeys.destroy', ['id' => $id]); + $method = 'POST'; + + $knownParams = ['id', 'redirect']; + + $params = []; + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if (! $this->canParseContents()) { + return [ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => array_merge( + $this->formMetaPrefix($this->formParams($method, $params)), + ['_method' => 'DELETE'] + ), + ]; + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= ''; + + $html .= $this->formMetaFields($params); + + $html .= $this->parse([]); + + $html .= $this->formClose(); + + return $html; + } + /** * Outputs a logout URL. * diff --git a/src/Http/Controllers/CP/Auth/LoginController.php b/src/Http/Controllers/CP/Auth/LoginController.php index 4b2a04deef6..7972ec90484 100644 --- a/src/Http/Controllers/CP/Auth/LoginController.php +++ b/src/Http/Controllers/CP/Auth/LoginController.php @@ -70,10 +70,10 @@ public function login(Request $request) 'password' => 'required|string', ]); - $this->checkPasskeyEnforcement($request); - $this->handleTooManyLoginAttempts($request); + $this->checkPasskeyEnforcement($request); + $user = User::fromUser($this->validateCredentials($request)); if (TwoFactor::enabled() && $user->hasEnabledTwoFactorAuthentication()) { @@ -158,14 +158,20 @@ public function username() private function checkPasskeyEnforcement(Request $request) { - if (! config('statamic.webauthn.allow_password_login_with_passkey', true)) { - if ($user = User::findByEmail($request->get($this->username()))) { - if ($user->passkeys()->isNotEmpty()) { - throw ValidationException::withMessages([ - $this->username() => [trans('statamic::messages.password_passkeys_only')], - ]); - } - } + if (config('statamic.webauthn.allow_password_login_with_passkey', true)) { + return; + } + + if (! $user = User::findByEmail($request->get($this->username()))) { + return; + } + + if ($user->passkeys()->isEmpty()) { + return; } + + throw ValidationException::withMessages([ + $this->username() => [trans('statamic::messages.password_passkeys_only')], + ]); } } diff --git a/src/Http/Controllers/CP/Auth/PasskeyController.php b/src/Http/Controllers/CP/Auth/PasskeyController.php index 85c4ab9b355..7f6181d3f31 100644 --- a/src/Http/Controllers/CP/Auth/PasskeyController.php +++ b/src/Http/Controllers/CP/Auth/PasskeyController.php @@ -2,15 +2,12 @@ namespace Statamic\Http\Controllers\CP\Auth; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Inertia\Inertia; -use Statamic\Auth\WebAuthn\Serializer; use Statamic\Contracts\Auth\Passkey; use Statamic\Facades\User; -use Statamic\Facades\WebAuthn; +use Statamic\Http\Controllers\User\PasskeyController as Controller; -class PasskeyController +class PasskeyController extends Controller { public function index() { @@ -26,31 +23,4 @@ public function index() 'storeUrl' => cp_route('passkeys.store'), ]); } - - public function create() - { - $options = WebAuthn::prepareAttestation(User::current()); - - return app(Serializer::class)->normalize($options); - } - - public function store(Request $request) - { - $credentials = $request->only(['id', 'rawId', 'response', 'type']); - - WebAuthn::validateAttestation(User::current(), $credentials, $request->name); - - return ['verified' => true]; - } - - public function destroy($id) - { - if (! $passkey = User::current()->passkeys()->get($id)) { - abort(403); - } - - $passkey->delete(); - - return new JsonResponse([], 201); - } } diff --git a/src/Http/Controllers/CP/Auth/PasskeyLoginController.php b/src/Http/Controllers/CP/Auth/PasskeyLoginController.php index bf4ca0350b2..e9315222b1e 100644 --- a/src/Http/Controllers/CP/Auth/PasskeyLoginController.php +++ b/src/Http/Controllers/CP/Auth/PasskeyLoginController.php @@ -3,46 +3,15 @@ namespace Statamic\Http\Controllers\CP\Auth; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Statamic\Auth\WebAuthn\Serializer; -use Statamic\Contracts\Auth\User as UserContract; use Statamic\Facades\URL; -use Statamic\Facades\WebAuthn; +use Statamic\Http\Controllers\User\PasskeyLoginController as Controller; use Statamic\Support\Str; -class PasskeyLoginController +class PasskeyLoginController extends Controller { - public function options() + protected function successRedirectUrl(Request $request): string { - $options = WebAuthn::prepareAssertion(); - - return app(Serializer::class)->normalize($options); - } - - public function login(Request $request) - { - $credentials = $request->only(['id', 'rawId', 'response', 'type']); - - $user = WebAuthn::getUserFromCredentials($credentials); - - WebAuthn::validateAssertion($user, $credentials); - - $this->authenticate($user); - - return ['redirect' => $this->successRedirectUrl()]; - } - - private function authenticate(UserContract $user): void - { - Auth::login($user, config('statamic.webauthn.remember_me', true)); - - session()->elevate(); - session()->regenerate(); - } - - private function successRedirectUrl() - { - $referer = request('referer'); + $referer = $request->input('referer'); return Str::contains($referer, '/'.config('statamic.cp.route')) && ! URL::isExternalToApplication($referer) ? $referer diff --git a/src/Http/Controllers/User/LoginController.php b/src/Http/Controllers/User/LoginController.php index 2d7b0af73ce..a0e1394dfe4 100644 --- a/src/Http/Controllers/User/LoginController.php +++ b/src/Http/Controllers/User/LoginController.php @@ -21,6 +21,8 @@ public function login(UserLoginRequest $request) { $this->handleTooManyLoginAttempts($request); + $this->checkPasskeyEnforcement($request); + $user = User::fromUser($this->validateCredentials($request)); if (TwoFactor::enabled() && $user->hasEnabledTwoFactorAuthentication()) { @@ -34,6 +36,31 @@ public function login(UserLoginRequest $request) return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect)->withSuccess(__('Login successful.')); } + private function checkPasskeyEnforcement(Request $request) + { + if (config('statamic.webauthn.allow_password_login_with_passkey', true)) { + return; + } + + if (! $user = User::findByEmail($request->get($this->username()))) { + return; + } + + if ($user->passkeys()->isEmpty()) { + return; + } + + $errorRedirect = $request->input('_error_redirect'); + + $errorResponse = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) + : back(); + + throw new HttpResponseException( + $errorResponse->withInput()->withErrors(__('statamic::messages.password_passkeys_only')) + ); + } + protected function twoFactorChallengeRedirect(): string { return route('statamic.two-factor-challenge'); diff --git a/src/Http/Controllers/User/PasskeyController.php b/src/Http/Controllers/User/PasskeyController.php new file mode 100644 index 00000000000..e5267839ce6 --- /dev/null +++ b/src/Http/Controllers/User/PasskeyController.php @@ -0,0 +1,44 @@ +normalize($options); + } + + public function store(Request $request) + { + $credentials = $request->only(['id', 'rawId', 'response', 'type']); + + WebAuthn::validateAttestation(User::current(), $credentials, $request->name ?? 'Passkey'); + + return ['verified' => true]; + } + + public function destroy($id, Request $request) + { + if (! $passkey = User::current()->passkeys()->get($id)) { + abort(403); + } + + $passkey->delete(); + + if ($request->wantsJson()) { + return new JsonResponse([], 204); + } + + return back()->with('success', __('Passkey deleted.')); + } +} diff --git a/src/Http/Controllers/User/PasskeyLoginController.php b/src/Http/Controllers/User/PasskeyLoginController.php new file mode 100644 index 00000000000..72b5d223fd7 --- /dev/null +++ b/src/Http/Controllers/User/PasskeyLoginController.php @@ -0,0 +1,49 @@ +normalize($options); + } + + public function login(Request $request) + { + $credentials = $request->only(['id', 'rawId', 'response', 'type']); + + $user = WebAuthn::getUserFromCredentials($credentials); + + WebAuthn::validateAssertion($user, $credentials); + + $this->authenticate($user); + + return ['redirect' => $this->successRedirectUrl($request)]; + } + + protected function authenticate(UserContract $user): void + { + Auth::login($user, config('statamic.webauthn.remember_me', true)); + + session()->elevate(); + session()->regenerate(); + } + + protected function successRedirectUrl(Request $request): string + { + $redirect = $request->input('redirect', '/'); + + return URL::isExternalToApplication($redirect) ? '/' : $redirect; + } +} diff --git a/tests/Feature/Auth/DeletePasskeyTest.php b/tests/Feature/Auth/DeletePasskeyTest.php index 54ed4d47819..0a73be97dc7 100644 --- a/tests/Feature/Auth/DeletePasskeyTest.php +++ b/tests/Feature/Auth/DeletePasskeyTest.php @@ -38,7 +38,7 @@ public function it_deletes_a_passkey() $this ->actingAs($user) ->deleteRequest('passkey-123') - ->assertStatus(201); + ->assertStatus(204); } #[Test] diff --git a/tests/Tags/User/DeletePasskeyFormTest.php b/tests/Tags/User/DeletePasskeyFormTest.php new file mode 100644 index 00000000000..74fbf0b0cf8 --- /dev/null +++ b/tests/Tags/User/DeletePasskeyFormTest.php @@ -0,0 +1,154 @@ +tag('{{ user:delete_passkey_form id="passkey-123" }}Delete{{ /user:delete_passkey_form }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function it_returns_empty_when_passkey_not_found() + { + $user = Mockery::mock(User::make()->email('test@example.com'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn(collect([])); + $user->save(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:delete_passkey_form id="nonexistent" }}Delete{{ /user:delete_passkey_form }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function it_renders_form() + { + $passkey = Mockery::mock(Passkey::class); + $passkey->shouldReceive('id')->andReturn('passkey-123'); + + $user = Mockery::mock(User::make()->email('test@example.com'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn(collect(['passkey-123' => $passkey])); + $user->save(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:delete_passkey_form id="passkey-123" }}{{ /user:delete_passkey_form }}'); + + $this->assertStringContainsString('