webard / filament-otp-actions
Confirm Filament actions with a one-time code from the user's multi-factor authentication method.
Requires
- php: ^8.2
- filament/actions: *
- filament/filament: *
- filament/schemas: *
- filament/support: *
README
Proof of concept. This package is not ready for production. It depends on Filament features that are not in a release yet, the API is still open to change, and it has no test suite of its own. Published so the approach can be tried and discussed.
Confirm Filament actions with a one-time code from the multi-factor authentication method that the user already set up on their panel.
use Filament\Actions\Action; Action::make('transferFunds') ->schema([ TextInput::make('amount')->numeric()->required(), ]) ->requiresConfirmation() ->requiresOtpConfirmation() ->action(function (array $data): void { // Only runs once the user has entered a valid code. })
What it does
The action's own modal comes first, whatever it is - a form, a confirmation, or both. Once the form validates and the confirmation is accepted, the action pauses and a code modal is stacked on top of it. The action resumes only after a valid code, with everything the user entered still intact.
An action with no modal of its own goes straight to the code modal.
Macros
| Macro | What it does |
|---|---|
requiresOtpConfirmation(bool | Closure $condition = true, bool | string | Closure | null $cancelsOnClose = false) |
Pauses the action until a valid code is entered. The condition is evaluated after the schema is validated, so it can read $action->getData(). $cancelsOnClose decides what closing the code modal does. |
disableWhenNoOtp() |
Disables the action when the user has no multi-factor method enabled. |
hideWhenNoOtp() |
Hides it instead. |
disableWhenNoOtp() and hideWhenNoOtp() compose with any disabled() / hidden() condition already on the action, rather than replacing it.
Challenge only large transfers:
Action::make('transferFunds') ->schema([ TextInput::make('amount')->numeric()->required(), ]) ->requiresOtpConfirmation(fn (Action $action): bool => $action->getData()['amount'] > 1000) ->action(/* ... */)
Closing the code modal
By default, closing the code modal takes the user back to the action they came from, with everything they had entered still in place. They can correct the form and submit again.
Pass cancelsOnClose to abandon the action instead, which closes every modal and discards what was entered:
Action::make('transferFunds') ->schema([ TextInput::make('amount')->numeric()->required(), ]) ->requiresOtpConfirmation(cancelsOnClose: true) ->action(/* ... */)
For a flow several modals deep, pass the name of an ancestor action to close back to that one rather than all of them:
Action::make('approvePayment') ->requiresOtpConfirmation(cancelsOnClose: 'reviewInvoice') ->action(/* ... */)
It takes a Closure too, so the decision can depend on anything the action knows:
->requiresOtpConfirmation( cancelsOnClose: fn (Action $action): bool => $action->getData()['amount'] > 10_000, )
Whatever you pass, the code modal always abandons the action when the user has no authentication method set up - there is nothing to go back and correct.
Requirements
Multi-factor authentication has to be configured on the panel, and the user model has to implement the contracts of the providers you register:
use Filament\Auth\MultiFactor\App\AppAuthentication; use Filament\Auth\MultiFactor\Email\EmailAuthentication; $panel ->profile() ->multiFactorAuthentication([ AppAuthentication::make()->recoverable(), EmailAuthentication::make(), ]);
Whichever methods the user has enabled are offered in the code modal, exactly as they are during login - including the picker when more than one is enabled, and app recovery codes.
How it works
The package uses two pieces of Filament core:
MultiFactorChallengebuilds the challenge and rate limits it, sharing the login challenge's limiter so a second factor cannot be brute forced through an action instead.pauseWhen()pauses the action after its schema is validated and before itsbefore()hook, and resumes it without repeating the rate limiter or the hooks that already ran.
Both are pull requests against Filament. Until they are released, the package needs a build of Filament that contains them.
Where a confirmation lives
A confirmation is held in a request-scoped singleton, never in the action's arguments. The browser may call a paused action at any time - after closing the code modal, for example - so a confirmation must be something only the server can grant. Cancelling the code modal and calling the action again simply challenges again.
When the user has no method set up
An action that requires a code but is neither hidden nor disabled still cannot run. Instead of a challenge that nothing could answer, the modal explains why, and links to the profile page where the user sets a method up:
Action::make('transferFunds') ->requiresOtpConfirmation() ->action(/* ... */)
The link only appears when the panel has ->profile() enabled. Use disableWhenNoOtp() or hideWhenNoOtp() when you would rather the action not be offered at all.
Caveats
- The macros only work on actions that go through Filament's action pipeline. An action with
->url(), a string->action('someLivewireMethod'),->dispatch()orpostToUrl()never reaches it, so it cannot be confirmed. isEnabled()on a provider throws when the user model does not implement that provider's contract.disableWhenNoOtp()/hideWhenNoOtp()treat that as "no method available" and hide or disable the action.