diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php index 356965c5..eb77ef7f 100644 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -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)); } } diff --git a/app/Modules/Identity/Http/Middleware/RequirePasswordConfirmation.php b/app/Modules/Identity/Http/Middleware/RequirePasswordConfirmation.php new file mode 100644 index 00000000..3b839b50 --- /dev/null +++ b/app/Modules/Identity/Http/Middleware/RequirePasswordConfirmation.php @@ -0,0 +1,61 @@ +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']); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6c2d2140..5c672179 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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) { diff --git a/lang/es.json b/lang/es.json index 94c6d011..0b9c80b9 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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." } diff --git a/resources/js/app.tsx b/resources/js/app.tsx index a1343438..1a51f6e3 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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(); + // 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 + // are given. The page itself renders as Inertia would on its own. + root.render( + + {({ Component, props: pageProps, key }) => ( + <> + {createElement(Component, { key, ...pageProps })} + + + )} + , + ); }, progress: { color: '#4B5563', diff --git a/resources/js/components/password-confirmation-dialog.tsx b/resources/js/components/password-confirmation-dialog.tsx new file mode 100644 index 00000000..dd98ff56 --- /dev/null +++ b/resources/js/components/password-confirmation-dialog.tsx @@ -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()); + + const [refused, setRefused] = useState(null); + const [password, setPassword] = useState(''); + const [error, setError] = useState(); + 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 ( + !open && setRefused(null)}> + + + {t('Confirm your password')} + + {t('This is a secure area of the application. Please confirm your password before continuing.')} + + + + {refused && !refused.hasLocalPassword ? ( +
+

+ {t('You sign in through a connected account, so there is no password here to confirm. Set one to continue.')} +

+ + + + +
+ ) : ( +
+
+ + setPassword(e.target.value)} + /> + +
+ + + + + +
+ )} +
+
+ ); +} diff --git a/resources/js/pages/settings/connected-accounts.tsx b/resources/js/pages/settings/connected-accounts.tsx index f7627579..ee1f5988 100644 --- a/resources/js/pages/settings/connected-accounts.tsx +++ b/resources/js/pages/settings/connected-accounts.tsx @@ -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), + }, ) } > diff --git a/tests/Feature/Auth/PasswordConfirmationDialogTest.php b/tests/Feature/Auth/PasswordConfirmationDialogTest.php new file mode 100644 index 00000000..c2d22749 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationDialogTest.php @@ -0,0 +1,70 @@ +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); +});