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('assertStringContainsString('method="POST"', $output); + $this->assertStringContainsString('action="'.route('statamic.passkeys.destroy', ['id' => 'passkey-123']).'"', $output); + $this->assertStringContainsString('name="_method"', $output); + $this->assertStringContainsString('value="DELETE"', $output); + $this->assertStringContainsString('name="_token"', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_fetches_form_data() + { + $passkey = Mockery::mock(Passkey::class); + $passkey->shouldReceive('id')->andReturn('passkey-789'); + + $user = Mockery::mock(User::make()->email('test@example.com'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn(collect(['passkey-789' => $passkey])); + $user->save(); + + $this->actingAs($user); + + $form = Statamic::tag('user:delete_passkey_form')->params(['id' => 'passkey-789'])->fetch(); + + $this->assertIsArray($form); + $this->assertArrayHasKey('attrs', $form); + $this->assertArrayHasKey('params', $form); + $this->assertEquals('DELETE', $form['params']['_method']); + } + + #[Test] + public function it_requires_authentication_for_delete() + { + $this->deleteJson(route('statamic.passkeys.destroy', ['id' => 'passkey-123']))->assertUnauthorized(); + } + + #[Test] + public function it_deletes_a_passkey_via_json() + { + $mockPasskey = Mockery::mock(Passkey::class); + $mockPasskey->shouldReceive('delete')->once(); + + $mockCollection = collect(['passkey-123' => $mockPasskey]); + $mockCollection = Mockery::mock($mockCollection)->makePartial(); + $mockCollection->shouldReceive('get')->with('passkey-123')->andReturn($mockPasskey); + + $user = Mockery::mock(User::make()->id('test-user')->email('test@example.com')->password('secret'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn($mockCollection); + $user->save(); + + $this + ->actingAs($user) + ->deleteJson(route('statamic.passkeys.destroy', ['id' => 'passkey-123'])) + ->assertStatus(204); + } + + #[Test] + public function it_deletes_a_passkey_via_form_and_redirects() + { + $mockPasskey = Mockery::mock(Passkey::class); + $mockPasskey->shouldReceive('delete')->once(); + + $mockCollection = collect(['passkey-123' => $mockPasskey]); + $mockCollection = Mockery::mock($mockCollection)->makePartial(); + $mockCollection->shouldReceive('get')->with('passkey-123')->andReturn($mockPasskey); + + $user = Mockery::mock(User::make()->id('test-user')->email('test@example.com')->password('secret'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn($mockCollection); + $user->save(); + + $this + ->actingAs($user) + ->from('/account/passkeys') + ->delete(route('statamic.passkeys.destroy', ['id' => 'passkey-123'])) + ->assertRedirect('/account/passkeys') + ->assertSessionHas('success'); + } + + #[Test] + public function it_returns_403_when_deleting_nonexistent_passkey() + { + $mockCollection = collect([]); + $mockCollection = Mockery::mock($mockCollection)->makePartial(); + $mockCollection->shouldReceive('get')->with('nonexistent')->andReturnNull(); + + $user = Mockery::mock(User::make()->id('test-user')->email('test@example.com')->password('secret'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn($mockCollection); + $user->save(); + + $this + ->actingAs($user) + ->deleteJson(route('statamic.passkeys.destroy', ['id' => 'nonexistent'])) + ->assertStatus(403); + } +} diff --git a/tests/Tags/User/LoginFormTest.php b/tests/Tags/User/LoginFormTest.php index 5ae2eebcb0e..cd1f328ccf7 100644 --- a/tests/Tags/User/LoginFormTest.php +++ b/tests/Tags/User/LoginFormTest.php @@ -4,19 +4,23 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; +use Mockery; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\TwoFactor\RecoveryCode; +use Statamic\Contracts\Auth\Passkey; use Statamic\Contracts\Auth\TwoFactor\TwoFactorAuthenticationProvider; use Statamic\Events\TwoFactorAuthenticationChallenged; use Statamic\Facades\Parse; use Statamic\Facades\User; +use Statamic\Facades\WebAuthn; use Statamic\Statamic; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; #[Group('2fa')] +#[Group('passkeys')] class LoginFormTest extends TestCase { use PreventSavingStacheItemsToDisk; @@ -363,4 +367,157 @@ protected function disableTwoFactor($app) { $app['config']->set('statamic.users.two_factor_enabled', false); } + + #[Test] + public function it_includes_passkey_data() + { + $output = $this->tag('{{ user:login_form }}{{ passkey_options_url }}|{{ passkey_verify_url }}{{ /user:login_form }}'); + + $this->assertStringContainsString(route('statamic.passkeys.options'), $output); + $this->assertStringContainsString(route('statamic.passkeys.login'), $output); + } + + #[Test] + public function it_allows_password_login_when_user_has_no_passkeys() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + config(['statamic.webauthn.allow_password_login_with_passkey' => false]); + + $this + ->post('/!/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'secret', + ]) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_blocks_password_login_when_user_has_passkeys_and_enforcement_enabled() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + $passkey = Mockery::mock(Passkey::class); + $passkey->shouldReceive('id')->andReturn('passkey-1'); + $user->setPasskeys(collect([$passkey])); + + config(['statamic.webauthn.allow_password_login_with_passkey' => false]); + + $this + ->from('/login') + ->post('/!/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'secret', + ]) + ->assertRedirect('/login'); + + $this->assertGuest(); + } + + #[Test] + public function it_allows_password_login_when_user_has_passkeys_and_enforcement_disabled() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + $passkey = Mockery::mock(Passkey::class); + $passkey->shouldReceive('id')->andReturn('passkey-1'); + $user->setPasskeys(collect([$passkey])); + + config(['statamic.webauthn.allow_password_login_with_passkey' => true]); + + $this + ->post('/!/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'secret', + ]) + ->assertRedirect('/'); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_gets_passkey_login_options() + { + $response = $this->get(route('statamic.passkeys.options')); + + $response->assertOk(); + + $data = $response->json(); + + $this->assertArrayHasKey('challenge', $data); + $this->assertArrayHasKey('userVerification', $data); + $this->assertEquals('required', $data['userVerification']); + } + + #[Test] + public function it_logs_in_with_passkey() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + WebAuthn::shouldReceive('getUserFromCredentials')->once()->andReturn($user); + WebAuthn::shouldReceive('validateAssertion')->once()->andReturnTrue(); + + $this + ->postJson(route('statamic.passkeys.login')) + ->assertOk() + ->assertJson(['redirect' => '/']); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_redirects_to_provided_url_after_passkey_login() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + WebAuthn::shouldReceive('getUserFromCredentials')->once()->andReturn($user); + WebAuthn::shouldReceive('validateAssertion')->once()->andReturnTrue(); + + $this + ->postJson(route('statamic.passkeys.login'), ['redirect' => '/dashboard']) + ->assertOk() + ->assertJson(['redirect' => '/dashboard']); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_does_not_redirect_to_external_url_after_passkey_login() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + WebAuthn::shouldReceive('getUserFromCredentials')->once()->andReturn($user); + WebAuthn::shouldReceive('validateAssertion')->once()->andReturnTrue(); + + $this + ->postJson(route('statamic.passkeys.login'), ['redirect' => 'https://evil.com']) + ->assertOk() + ->assertJson(['redirect' => '/']); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_fails_passkey_login_when_validation_fails() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + WebAuthn::shouldReceive('getUserFromCredentials')->once()->andReturn($user); + WebAuthn::shouldReceive('validateAssertion')->once()->andThrow(new \Exception('Invalid')); + + $this + ->postJson(route('statamic.passkeys.login')) + ->assertStatus(500); + + $this->assertGuest(); + } } diff --git a/tests/Tags/User/PasskeyFormTest.php b/tests/Tags/User/PasskeyFormTest.php new file mode 100644 index 00000000000..448f03795bf --- /dev/null +++ b/tests/Tags/User/PasskeyFormTest.php @@ -0,0 +1,152 @@ +tag('{{ user:passkey_form }}{{ passkey_options_url }}|{{ passkey_verify_url }}{{ /user:passkey_form }}'); + + $this->assertStringContainsString(route('statamic.passkeys.create'), $output); + $this->assertStringContainsString(route('statamic.passkeys.store'), $output); + } + + #[Test] + public function it_fetches_form_data() + { + $form = Statamic::tag('user:passkey_form')->fetch(); + + $this->assertIsArray($form); + $this->assertEquals(route('statamic.passkeys.create'), $form['passkey_options_url']); + $this->assertEquals(route('statamic.passkeys.store'), $form['passkey_verify_url']); + } + + #[Test] + public function it_requires_authentication_for_create_options() + { + $this->getJson(route('statamic.passkeys.create'))->assertUnauthorized(); + } + + #[Test] + public function it_requires_authentication_for_store() + { + $this->postJson(route('statamic.passkeys.store'))->assertUnauthorized(); + } + + #[Test] + public function it_gets_creation_options() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + $response = $this + ->actingAs($user) + ->get(route('statamic.passkeys.create')) + ->assertOk(); + + $data = $response->json(); + + $this->assertArrayHasKey('challenge', $data); + $this->assertArrayHasKey('user', $data); + $this->assertArrayHasKey('rp', $data); + } + + #[Test] + public function it_stores_a_passkey() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + $mockPasskey = Mockery::mock(Passkey::class); + + $payload = [ + 'id' => 'credential-id', + 'rawId' => 'raw-id', + 'response' => [], + 'type' => 'public-key', + ]; + + WebAuthn::shouldReceive('validateAttestation') + ->once() + ->with($user, $payload, 'Test Passkey') + ->andReturn($mockPasskey); + + $this + ->actingAs($user) + ->postJson(route('statamic.passkeys.store'), [ + ...$payload, + 'name' => 'Test Passkey', + ]) + ->assertOk() + ->assertJson(['verified' => true]); + } + + #[Test] + public function it_stores_a_passkey_with_default_name() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + $mockPasskey = Mockery::mock(Passkey::class); + + $payload = [ + 'id' => 'credential-id', + 'rawId' => 'raw-id', + 'response' => [], + 'type' => 'public-key', + ]; + + WebAuthn::shouldReceive('validateAttestation') + ->once() + ->with($user, $payload, 'Passkey') + ->andReturn($mockPasskey); + + $this + ->actingAs($user) + ->postJson(route('statamic.passkeys.store'), $payload) + ->assertOk() + ->assertJson(['verified' => true]); + } + + #[Test] + public function it_fails_storing_when_validation_throws_exception() + { + $user = User::make()->id('test-user')->email('test@example.com')->password('secret'); + $user->save(); + + WebAuthn::shouldReceive('validateAttestation') + ->once() + ->andThrow(new \Exception('Invalid credentials')); + + $this + ->actingAs($user) + ->postJson(route('statamic.passkeys.store'), [ + 'id' => 'credential-id', + 'rawId' => 'raw-id', + 'response' => [], + 'type' => 'public-key', + ]) + ->assertStatus(500); + } +} diff --git a/tests/Tags/User/PasskeysTest.php b/tests/Tags/User/PasskeysTest.php new file mode 100644 index 00000000000..88dce1a8e86 --- /dev/null +++ b/tests/Tags/User/PasskeysTest.php @@ -0,0 +1,104 @@ +tag('{{ user:passkeys }}{{ name }}{{ /user:passkeys }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function it_returns_no_results_when_user_has_no_passkeys() + { + $user = User::make()->email('test@example.com'); + $user->save(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:passkeys }}{{ name }}{{ /user:passkeys }}{{ unless user:passkeys }}no-passkeys{{ /unless }}'); + + $this->assertStringContainsString('no-passkeys', (string) $output); + } + + #[Test] + public function it_lists_user_passkeys() + { + $passkey1 = Mockery::mock(Passkey::class); + $passkey1->shouldReceive('id')->andReturn('passkey-1'); + $passkey1->shouldReceive('name')->andReturn('My Laptop'); + $passkey1->shouldReceive('lastLogin')->andReturn(now()->subDay()); + + $passkey2 = Mockery::mock(Passkey::class); + $passkey2->shouldReceive('id')->andReturn('passkey-2'); + $passkey2->shouldReceive('name')->andReturn('My Phone'); + $passkey2->shouldReceive('lastLogin')->andReturn(null); + + $user = Mockery::mock(User::make()->email('test@example.com'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn(collect([ + 'passkey-1' => $passkey1, + 'passkey-2' => $passkey2, + ])); + $user->save(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:passkeys }}{{ name }},{{ /user:passkeys }}'); + + $this->assertStringContainsString('My Laptop', $output); + $this->assertStringContainsString('My Phone', $output); + } + + #[Test] + public function it_fetches_data_without_content() + { + $passkey = Mockery::mock(Passkey::class); + $passkey->shouldReceive('id')->andReturn('passkey-123'); + $passkey->shouldReceive('name')->andReturn('Test'); + $passkey->shouldReceive('lastLogin')->andReturn(null); + + $user = Mockery::mock(User::make()->email('test@example.com'))->makePartial(); + $user->shouldReceive('passkeys')->andReturn(collect(['passkey-123' => $passkey])); + $user->save(); + + $this->actingAs($user); + + $passkeys = Statamic::tag('user:passkeys')->fetch(); + + $this->assertIsArray($passkeys); + $this->assertCount(1, $passkeys); + $this->assertEquals('passkey-123', $passkeys[0]['id']); + $this->assertEquals('Test', $passkeys[0]['name']); + } + + #[Test] + public function it_returns_empty_array_when_not_logged_in_without_content() + { + $passkeys = Statamic::tag('user:passkeys')->fetch(); + + $this->assertIsArray($passkeys); + $this->assertEmpty($passkeys); + } +}