Ask for the password over the page instead of throwing the form away

password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.

An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
This commit is contained in:
ignacionelson
2026-09-21 18:05:54 -03:00
parent 60171799e7
commit a45eae315c
8 changed files with 352 additions and 4 deletions
@@ -7,6 +7,7 @@ use App\Modules\Identity\AuthSource;
use App\Modules\Identity\PasswordVerification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -41,8 +42,12 @@ class ConfirmablePasswordController extends Controller
* their local hash is a Str::password(64) nobody has ever seen -- and
* this screen stands in front of enrolling in two-factor, so those
* accounts could not enrol at all.
*
* Asked for JSON, it answers with a bare 204: that is the password
* dialog (RequirePasswordConfirmation), which stays on the page and
* sends the refused request again itself, so there is nowhere to go.
*/
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
{
$user = $request->user();
assert($user !== null);
@@ -55,6 +60,10 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time());
if ($request->expectsJson()) {
return response()->noContent();
}
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Middleware;
use App\Modules\Identity\AuthSource;
use Closure;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* `password.confirm`, answered in place for the app's own screens.
*
* The framework's version redirects to the confirm-password screen and
* relies on the "intended" URL to come back. For a GET that works. For
* the writes this guards it cannot: Redirector::guest() only remembers
* the exact URL of a GET, so a POST comes back to the page it was sent
* from, freshly rendered, and whatever was typed into the form -- a
* token's name and scopes, a release reason -- is gone, along with the
* action itself.
*
* So an Inertia request gets a 423 instead, which the browser turns into
* a password dialog over the page it is on (password-confirmation-dialog.tsx).
* Nothing navigates, the form keeps its state, and once the password is
* proved the same request is sent again. The check itself is the
* framework's, unchanged: this only decides what the refusal looks like.
* Anything else -- a plain form post, a JSON client -- is answered
* exactly as before.
*/
class RequirePasswordConfirmation extends RequirePassword
{
/**
* Marks the 423 as this refusal and not any other, so the browser does
* not open a password dialog in answer to something else.
*/
public const HEADER = 'X-Password-Confirmation';
public function handle($request, Closure $next, $redirectToRoute = null, $passwordTimeoutSeconds = null)
{
if ($request->header('X-Inertia') && $this->shouldConfirmPassword($request, $passwordTimeoutSeconds)) {
return $this->inertiaRefusal($request);
}
return parent::handle($request, $next, $redirectToRoute, $passwordTimeoutSeconds);
}
private function inertiaRefusal(Request $request): Response
{
$user = $request->user();
return $this->responseFactory->json([
'message' => 'Password confirmation required.',
// The same question the confirm-password screen asks: an account
// provisioned by a provider has no password to type, and the
// dialog has to offer it a way to set one instead.
'has_local_password' => $user?->auth_source === AuthSource::Local,
], 423, [self::HEADER => 'required']);
}
}
+5
View File
@@ -12,6 +12,7 @@ use App\Modules\Identity\Http\Middleware\EnforceTwoFactor;
use App\Modules\Identity\Http\Middleware\EnsureAccountIsActive;
use App\Modules\Identity\Http\Middleware\EnsureSetupIsComplete;
use App\Modules\Identity\Http\Middleware\EnsureStaff;
use App\Modules\Identity\Http\Middleware\RequirePasswordConfirmation;
use App\Modules\Platform\Http\Middleware\EnsureCapability;
use App\Modules\Platform\Http\Middleware\SetLocale;
use App\Support\WriteSafeRedirect;
@@ -101,6 +102,10 @@ return Application::configure(basePath: dirname(__DIR__))
'staff-token' => EnsureStaffToken::class,
'token-can' => EnsureTokenCan::class,
'api-active' => EnsureApiAccountIsActive::class,
// Replaces the framework's own: a write that needs the password
// re-proved gets a dialog over the page instead of a redirect
// that throws the submitted form away. See the class.
'password.confirm' => RequirePasswordConfirmation::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
+2 -1
View File
@@ -2256,5 +2256,6 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Inicias sesión con una cuenta conectada. Tener una contraseña propia te permite entrar sin ella, y hace falta para activar la verificación en dos pasos.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Tu contraseña la gestiona el directorio de tu organización, así que no se puede cambiar aquí.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Un enlace que cualquiera puede abrir, sin iniciar sesión. Puedes revocarlo cuando quieras.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider solo confirma una dirección cuando el claim opcional \"xms_edov\" está en el registro de tu aplicación (Token configuration → optional claims → ID token). Hasta que esté, las cuentas se crean igual, pero todas quedan esperando en Solicitudes de cuenta, marques lo que marques aquí."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider solo confirma una dirección cuando el claim opcional \"xms_edov\" está en el registro de tu aplicación (Token configuration → optional claims → ID token). Hasta que esté, las cuentas se crean igual, pero todas quedan esperando en Solicitudes de cuenta, marques lo que marques aquí.",
"Too many attempts. Wait a minute and try again.": "Demasiados intentos. Espera un minuto e inténtalo de nuevo."
}
+17 -1
View File
@@ -3,8 +3,10 @@ import '../css/app.css';
import { createInertiaApp, router } from '@inertiajs/react';
import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { route as routeFn } from 'ziggy-js';
import { PasswordConfirmationDialog } from './components/password-confirmation-dialog';
import { initializeTheme } from './hooks/use-appearance';
import { xsrfCookieName } from './lib/xsrf';
@@ -75,7 +77,21 @@ createInertiaApp({
const root = createRoot(el);
root.render(<App {...props} />);
// The password dialog sits beside every page rather than in any
// one layout: the writes it answers for are spread across the
// staff shell, the settings screens and every portal theme, and
// it needs the page context (translations) the children of <App>
// are given. The page itself renders as Inertia would on its own.
root.render(
<App {...props}>
{({ Component, props: pageProps, key }) => (
<>
{createElement(Component, { key, ...pageProps })}
<PasswordConfirmationDialog />
</>
)}
</App>,
);
},
progress: {
color: '#4B5563',
@@ -0,0 +1,180 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/react';
import axios from 'axios';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect, useRef, useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/hooks/use-translation';
interface Refused {
// Typed as pending, but what the start event hands over is the request
// as sent: the visit plus its callbacks (useForm's among them), which
// is what lets a replay finish the form's own submission.
visit: PendingVisit;
hasLocalPassword: boolean;
}
const visitKey = (method: string, url: string) => `${method.toUpperCase()} ${url}`;
/**
* The browser half of RequirePasswordConfirmation.
*
* A write that needs the password re-proved comes back as a 423 instead of
* a redirect. Inertia calls that an "invalid" response; this catches it,
* asks for the password over the page the user is on, and then sends the
* refused request again, exactly as it was first sent -- same data, same
* callbacks -- so the form that sent it carries on as if nothing had
* happened. Nothing navigates, so nothing typed into the form is lost.
*
* Mounted once, around every page, in app.tsx.
*/
export function PasswordConfirmationDialog() {
const { t } = useTranslation();
// Writes in flight, by method and URL. The invalid event carries only
// the response, so this is how a refusal finds the visit that caused it.
const inFlight = useRef(new Map<string, PendingVisit>());
const [refused, setRefused] = useState<Refused | null>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState<string | undefined>();
const [processing, setProcessing] = useState(false);
useEffect(() => {
const removeStart = router.on('start', (event) => {
const visit = event.detail.visit;
if (visit.method !== 'get') {
inFlight.current.set(visitKey(visit.method, visit.url.href), visit);
}
});
// After `invalid` for the same request, so the visit is still here
// when a refusal needs it.
const removeFinish = router.on('finish', (event) => {
const visit = event.detail.visit;
inFlight.current.delete(visitKey(visit.method, visit.url.href));
});
const removeInvalid = router.on('invalid', (event) => {
const response = event.detail.response;
if (response.status !== 423 || response.headers['x-password-confirmation'] !== 'required') {
return;
}
const visit = inFlight.current.get(visitKey(response.config.method ?? '', response.config.url ?? ''));
if (!visit) {
return;
}
// Stops Inertia's own error modal, which would show the raw JSON.
event.preventDefault();
setPassword('');
setError(undefined);
setRefused({ visit, hasLocalPassword: response.data?.has_local_password !== false });
});
return () => {
removeStart();
removeFinish();
removeInvalid();
};
}, []);
const submit: FormEventHandler = async (e) => {
e.preventDefault();
if (!refused) {
return;
}
setProcessing(true);
setError(undefined);
try {
await axios.post(route('password.confirm.store'), { password });
} catch (failure) {
setPassword('');
if (axios.isAxiosError(failure) && failure.response?.status === 422) {
setError(failure.response.data?.errors?.password?.[0] ?? t('Something went wrong. Please try again.'));
} else if (axios.isAxiosError(failure) && failure.response?.status === 429) {
setError(t('Too many attempts. Wait a minute and try again.'));
} else {
setError(t('Something went wrong. Please try again.'));
}
setProcessing(false);
return;
}
setProcessing(false);
setRefused(null);
// Sent again as it was. The three state flags describe the first
// attempt, which finished; carried over, they would mark the new
// one finished before it started.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, completed, cancelled, interrupted, ...options } = refused.visit;
router.visit(url, options);
};
return (
<Dialog open={refused !== null} onOpenChange={(open) => !open && setRefused(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Confirm your password')}</DialogTitle>
<DialogDescription>
{t('This is a secure area of the application. Please confirm your password before continuing.')}
</DialogDescription>
</DialogHeader>
{refused && !refused.hasLocalPassword ? (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t('You sign in through a connected account, so there is no password here to confirm. Set one to continue.')}
</p>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setRefused(null)}>
{t('Cancel')}
</Button>
<Button asChild>
<a href={route('password.edit')}>{t('Set a password')}</a>
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password-confirmation-dialog-password">{t('Password')}</Label>
<Input
id="password-confirmation-dialog-password"
type="password"
name="password"
placeholder={t('Password')}
autoComplete="current-password"
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
/>
<InputError message={error} />
</div>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setRefused(null)}>
{t('Cancel')}
</Button>
<Button type="submit" disabled={processing || password === ''}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
{t('Confirm password')}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -91,7 +91,13 @@ export default function ConnectedAccounts({ providers, has_local_password }: Con
router.post(
route('connected-accounts.connect', { provider: provider.provider }),
{},
{ onStart: () => setProcessing(true) },
// onFinish too: a password confirmation the
// user cancels ends the request here, and
// the button must not stay dead.
{
onStart: () => setProcessing(true),
onFinish: () => setProcessing(false),
},
)
}
>
@@ -0,0 +1,70 @@
<?php
use App\Models\User;
use App\Modules\Identity\AuthSource;
/*
* password.confirm used to answer every request with a redirect to the
* confirm-password screen. For a write that throws the submission away:
* the redirect cannot carry a POST body, so the user came back to an
* empty form and the action never ran. An Inertia request now gets a 423
* the browser turns into a dialog over the page, and the same request is
* sent again once the password is proved.
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
test('an inertia write without a fresh confirmation is refused in place, not redirected', function () {
$this->actingAs($this->user)
->withHeaders(['X-Inertia' => 'true'])
->post('/settings/two-factor')
->assertStatus(423)
->assertHeader('X-Password-Confirmation', 'required')
->assertJson(['has_local_password' => true]);
// Refused, not half-done: the action did not run.
expect($this->user->refresh()->two_factor_secret)->toBeNull();
});
test('the refusal tells the dialog when there is no password to type', function () {
$user = User::factory()->create(['auth_source' => AuthSource::Social]);
$this->actingAs($user)
->withHeaders(['X-Inertia' => 'true'])
->post('/settings/two-factor')
->assertStatus(423)
->assertJson(['has_local_password' => false]);
});
test('a plain form post is still redirected to the confirm-password screen', function () {
$this->actingAs($this->user)
->post('/settings/two-factor')
->assertRedirect(route('password.confirm'));
});
test('the dialog confirms over json, and the replayed request goes through', function () {
$this->actingAs($this->user)
->postJson('/confirm-password', ['password' => 'password'])
->assertNoContent();
$this->actingAs($this->user)
->withHeaders(['X-Inertia' => 'true'])
->post('/settings/two-factor')
->assertRedirect();
expect($this->user->refresh()->two_factor_secret)->not->toBeNull();
});
test('a wrong password in the dialog is a validation error, and confirms nothing', function () {
$this->actingAs($this->user)
->postJson('/confirm-password', ['password' => 'wrong-password'])
->assertStatus(422)
->assertJsonValidationErrors('password');
$this->actingAs($this->user)
->withHeaders(['X-Inertia' => 'true'])
->post('/settings/two-factor')
->assertStatus(423);
});