commit 1dd74e2e3ed5296822bb12cfb508f05d288a1b78
parent a09dde7fc87e000a341d3255401e12e9d5e0038a
Author: Florian Dold <dold@taler.net>
Date: Mon, 24 Aug 2026 02:29:11 +0200
bank web UI: handle pending and failed MFA challenges
Diffstat:
2 files changed, 128 insertions(+), 62 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/context/challenge.ts b/packages/libeufin-bank-webui/src/context/challenge.ts
@@ -14,18 +14,10 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import {
- Challenge,
- ChallengeRequestResponse,
- ChallengeResponse,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import {
- AsyncAction,
- useBankCoreApiContext,
-} from "@gnu-taler/web-util/browser";
+import { ChallengeResponse, TranslatedString } from "@gnu-taler/taler-util";
+import { AsyncAction } from "@gnu-taler/web-util/browser";
import { ComponentChildren, createContext, h, VNode } from "preact";
-import { useContext, useState } from "preact/hooks";
+import { useContext, useRef, useState } from "preact/hooks";
/**
*
@@ -35,6 +27,7 @@ import { useContext, useState } from "preact/hooks";
export type ContextType = {
pending?: MfaState;
cancel(): void;
+ regenerate(manual?: boolean): Promise<boolean>;
onNewChallenge(
operation: TranslatedString,
username: string,
@@ -50,6 +43,7 @@ const initial: ContextType = {
cancel: () => {
throw Error("BankChallengeHandlerProvider not initialized");
},
+ regenerate: async () => false,
};
const Context = createContext<ContextType>(initial);
@@ -58,11 +52,10 @@ export const useBankChallengeHandlerContext = (): ContextType =>
type MfaState = {
requirement: ChallengeResponse;
- loadingFirstChallenge: boolean;
username: string;
title: TranslatedString;
retry: AsyncAction<[string[]]>;
- initial?: { request: Challenge; response?: ChallengeRequestResponse };
+ regenerationCount: number;
};
export const BankChallengeHandlerProvider = ({
@@ -71,7 +64,8 @@ export const BankChallengeHandlerProvider = ({
children: ComponentChildren;
}): VNode => {
const [state, setState] = useState<MfaState>();
- const { lib } = useBankCoreApiContext();
+ const regenerationInFlight = useRef<number>();
+ const challengeGeneration = useRef(0);
/**
* Check the response of the handler and
@@ -90,47 +84,45 @@ export const BankChallengeHandlerProvider = ({
requirement: ChallengeResponse,
handler: AsyncAction<[string[]]>,
) {
- const loadingFirstChallenge =
- requirement.combi_and === true || requirement.challenges.length === 1;
-
- // Set the sate now, if "LFC" is true "initial" is undefined it means "loading"
+ challengeGeneration.current++;
setState({
username,
title: operation,
requirement,
retry: handler,
- loadingFirstChallenge,
+ regenerationCount: regenerationInFlight.current ?? 0,
});
-
- if (loadingFirstChallenge) {
- const challenge = requirement.challenges[0];
- const result = await lib.bank.sendChallenge(
- username,
- challenge.challenge_id,
- );
-
- setState({
- username,
- title: operation,
- retry: handler,
- requirement,
- loadingFirstChallenge,
- initial: {
- request: challenge,
- response: result.type === "ok" ? result.body : undefined,
- },
- });
- }
}
function cancel() {
+ state?.retry.cancel();
setState(undefined);
}
+ async function regenerate(manual = false): Promise<boolean> {
+ if (!state || (!manual && state.regenerationCount >= 1)) return false;
+ const previous = state;
+ const previousGeneration = challengeGeneration.current;
+ const regenerationCount = state.regenerationCount + 1;
+ regenerationInFlight.current = regenerationCount;
+ setState(undefined);
+ try {
+ await previous.retry.run([]);
+ const restarted = challengeGeneration.current > previousGeneration;
+ if (!restarted) {
+ setState({ ...previous, regenerationCount });
+ }
+ return restarted;
+ } finally {
+ regenerationInFlight.current = undefined;
+ }
+ }
+
return h(Context.Provider, {
value: {
pending: state,
cancel,
+ regenerate,
onNewChallenge,
},
children,
diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx
@@ -22,7 +22,7 @@ import {
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, h, VNode } from "preact";
-import { useEffect, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import { useBankChallengeHandlerContext } from "../context/challenge.js";
import { doAutoFocus } from "./PaytoWireTransferForm.js";
@@ -42,12 +42,14 @@ function SolveChallenge({
onSolved,
username,
expiration,
+ onTerminal,
}: {
onCancel: () => void;
challenge: Challenge;
expiration: AbsoluteTime;
onSolved: () => void;
username: string;
+ onTerminal: (manual?: boolean) => Promise<boolean>;
}): VNode {
const { i18n } = useTranslationContext();
const [tanCode, setTanCode] = useState<string>();
@@ -59,22 +61,35 @@ function SolveChallenge({
const [showExpired, setExpired] = useState(
expiration !== undefined && AbsoluteTime.isExpired(expiration),
);
+ const [manualRestart, setManualRestart] = useState(false);
+ const terminalHandled = useRef(false);
+
+ async function handleTerminal(manual = false): Promise<void> {
+ if (!manual && terminalHandled.current) return;
+ if (!manual) terminalHandled.current = true;
+ const restarted = await onTerminal(manual);
+ if (!restarted) setManualRestart(true);
+ }
const errors = undefinedIfEmpty({
code: !tanCode ? i18n.str`Required` : undefined,
});
useEffect(() => {
- if (showExpired) return;
+ if (showExpired) {
+ void handleTerminal();
+ return;
+ }
const remain = AbsoluteTime.remaining(expiration).d_ms;
if (remain === "forever") return;
const handler = setTimeout(() => {
setExpired(true);
+ void handleTerminal();
}, remain);
return () => {
clearTimeout(handler);
};
- }, []);
+ }, [expiration.t_ms]);
// i18n.str`confirm MFA challenge`,
const doVerification = useNotifiedOperation<
@@ -85,7 +100,7 @@ function SolveChallenge({
api.confirmChallenge(username, challenge.challenge_id, { tan }),
{
onSuccess: onSolved,
- onFail: showError(i18n.str`Faild to verify the code.`, (fail) => {
+ onFail: showError(i18n.str`Failed to verify the code.`, (fail) => {
switch (fail.case) {
case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND:
return i18n.str`Unknown challenge.`;
@@ -96,6 +111,8 @@ function SolveChallenge({
case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
return i18n.str`Wrong authentication number.`;
case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
+ setExpired(true);
+ void handleTerminal();
return i18n.str`Expired challenge.`;
default:
assertUnreachable(fail);
@@ -111,7 +128,7 @@ function SolveChallenge({
<h2 class="text-base font-semibold leading-7 text-gray-900">
<span
class="text-sm text-black font-semibold leading-6 "
- id="availability-label"
+ id="dialog-title"
>
<i18n.Translate>
Submit the transmitted code number.
@@ -153,7 +170,7 @@ function SolveChallenge({
>
<div>
<label
- for="username"
+ for={`tan-${challenge.challenge_id}`}
class="block text-sm font-medium leading-6 text-gray-900"
>
<i18n.Translate>Code</i18n.Translate>
@@ -162,14 +179,14 @@ function SolveChallenge({
<input
ref={doAutoFocus}
type="text"
- name="username"
- id="username"
+ name="authentication-code"
+ id={`tan-${challenge.challenge_id}`}
class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={tanCode ?? ""}
enterkeyhint="next"
placeholder="T-12345678"
- autocomplete="username"
- title={i18n.str`Username of the account`}
+ autocomplete="one-time-code"
+ title={i18n.str`Authentication code`}
required
onInput={(e): void => {
setTanCode(e.currentTarget.value);
@@ -193,12 +210,21 @@ function SolveChallenge({
{showExpired ? (
<p class="text-sm">
<i18n.Translate>
- The challenge is expired and can't be solved but you can go
- back and create a new challenge.
+ This challenge is terminal and cannot be retransmitted.
</i18n.Translate>
</p>
) : undefined}
+ {manualRestart && (
+ <button
+ type="button"
+ class="mt-3 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white"
+ onClick={() => void handleTerminal(true)}
+ >
+ <i18n.Translate>Restart authentication</i18n.Translate>
+ </button>
+ )}
+
<div class="mt-6 mb-4 flex justify-between">
<button
type="button"
@@ -213,7 +239,7 @@ function SolveChallenge({
submit
name="send again"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- disabled={!!errors}
+ disabled={!!errors || showExpired}
onClick={() => doVerification.run(tanCode!)}
>
<i18n.Translate>Verify</i18n.Translate>
@@ -250,6 +276,7 @@ export function SolveChallengeDialog({
<div class="z-40 max-w-7xl text-left">
{!mfa.pending ? undefined : (
<SolveMFAChallenges
+ key={`${mfa.pending.regenerationCount}:${mfa.pending.requirement.challenges.map((challenge) => challenge.challenge_id).join(":")}`}
currentChallenge={mfa.pending.requirement}
description={mfa.pending.title}
onCancel={mfa.cancel}
@@ -275,6 +302,7 @@ function SolveMFAChallenges({
onCancel,
}: Props): VNode {
const { i18n } = useTranslationContext();
+ const mfa = useBankChallengeHandlerContext();
const [solved, setSolved] = useState<string[]>([]);
const [selected, setSelected] = useState<{
@@ -291,6 +319,8 @@ function SolveMFAChallenges({
const [retransmission, setRetransmission] = useState<
Record<string, AbsoluteTime | undefined>
>({});
+ const autoSent = useRef(new Set<string>());
+ const [clock, setClock] = useState(0);
// i18n.str`send MFA challenge`,
const sendMessage = useNotifiedOperation<
@@ -298,13 +328,14 @@ function SolveMFAChallenges({
[Challenge]
>((ct, ch: Challenge) => api.sendChallenge(username, ch.challenge_id), {
onSuccess: (success, ch) => {
- if (success.earliest_retransmission) {
- setRetransmission({
- ...retransmission,
+ const earliestRetransmission = success.earliest_retransmission;
+ if (earliestRetransmission) {
+ setRetransmission((previous) => ({
+ ...previous,
[ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp(
- success.earliest_retransmission,
+ earliestRetransmission,
),
- });
+ }));
}
setSelected({
ch,
@@ -340,6 +371,42 @@ function SolveMFAChallenges({
return opEmptySuccess(dummyHttpResponse);
});
+ const nextAndChallenge = currentChallenge.combi_and
+ ? currentChallenge.challenges.find(
+ ({ challenge_id }) => !solved.includes(challenge_id),
+ )
+ : undefined;
+
+ useEffect(() => {
+ if (
+ !nextAndChallenge ||
+ selected ||
+ sendMessage.running ||
+ autoSent.current.has(nextAndChallenge.challenge_id)
+ ) {
+ return;
+ }
+ autoSent.current.add(nextAndChallenge.challenge_id);
+ void sendMessage.run(nextAndChallenge);
+ }, [
+ currentChallenge.combi_and,
+ nextAndChallenge?.challenge_id,
+ selected?.ch.challenge_id,
+ sendMessage.running,
+ ]);
+
+ useEffect(() => {
+ const waits = Object.values(retransmission)
+ .map((time) => (time ? AbsoluteTime.remaining(time).d_ms : "forever"))
+ .filter((time): time is number => time !== "forever" && time > 0);
+ if (waits.length === 0) return;
+ const timer = setTimeout(
+ () => setClock((value) => value + 1),
+ Math.min(...waits),
+ );
+ return () => clearTimeout(timer);
+ }, [retransmission, clock]);
+
if (selected) {
return (
<SolveChallenge
@@ -347,6 +414,7 @@ function SolveMFAChallenges({
challenge={selected.ch}
expiration={selected.expiration}
username={username}
+ onTerminal={(manual) => mfa.regenerate(manual)}
onSolved={async () => {
setSelected(undefined);
const total = [...solved, selected.ch.challenge_id];
@@ -379,7 +447,7 @@ function SolveMFAChallenges({
<h2 class="text-base font-semibold leading-7 text-gray-900">
<span
class="text-sm text-black font-semibold leading-6 "
- id="availability-label"
+ id="dialog-title"
>
<i18n.Translate>
Multi-factor authentication required
@@ -408,7 +476,7 @@ function SolveMFAChallenges({
</div>
<h2 class="text-base leading-7 text-gray-900 ">
- <span class="text-sm leading-6 " id="availability-label">
+ <span class="text-sm leading-6">
{currentChallenge.challenges.length === 1 ? (
<i18n.Translate>
The next challenge needs to be completed to confirm the
@@ -427,16 +495,21 @@ function SolveMFAChallenges({
)}
</span>
</h2>
- {currentChallenge.challenges.map((challenge, idx) => {
+ {currentChallenge.challenges.map((challenge) => {
const time =
retransmission[challenge.challenge_id] ?? AbsoluteTime.now();
const alreadySent = !AbsoluteTime.isExpired(time);
const noNeedToComplete =
hasSolvedEnough ||
- solved.indexOf(challenge.challenge_id) !== -1;
+ solved.indexOf(challenge.challenge_id) !== -1 ||
+ (currentChallenge.combi_and &&
+ nextAndChallenge?.challenge_id !== challenge.challenge_id);
return (
- <div key={idx} class="rounded-xl border px-2 my-2">
+ <div
+ key={challenge.challenge_id}
+ class="rounded-xl border px-2 my-2"
+ >
<dl class="divide-y divide-gray-100">
<div class="px-4 py-2 sm:grid sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">
@@ -509,6 +582,7 @@ function SolveMFAChallenges({
submit
name="send again"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!hasSolvedEnough}
onClick={() => onCompleted.run(solved)}
>
<i18n.Translate>Complete</i18n.Translate>