commit 71b91a51ab0a75b87c6986c06dd6afcc1388c1f1 parent 202048215d45dd36ee3d637d003b81f77c7d4586 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 21 Mar 2025 07:30:00 -0300 fix #9342 Diffstat:
22 files changed, 504 insertions(+), 68 deletions(-)
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -56,6 +56,9 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; import { CreateCashout } from "./pages/regional/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; +import { useBankState } from "./hooks/bank-state.js"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { TokenRequest } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 100; @@ -168,6 +171,8 @@ function PublicRounting({ const { i18n } = useTranslationContext(); const location = useCurrentLocation(publicPages); const { navigateTo } = useNavigationContext(); + const [, updateBankState] = useBankState(); + const { config, lib } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); @@ -179,11 +184,16 @@ function PublicRounting({ async function doAutomaticLogin(username: string, password: string) { await handleError(async () => { - const resp = await lib.bank.createAccessTokenBasic(username, password, { + const tokenRequest = { scope: "readwrite", duration: SESSION_DURATION, refreshable: true, - }); + } as TokenRequest; + const resp = await lib.bank.createAccessTokenBasic( + username, + password, + tokenRequest, + ); if (resp.type === "ok") { onLoggedUser( username, @@ -192,7 +202,21 @@ function PublicRounting({ ); } else { switch (resp.case) { - case HttpStatusCode.Unauthorized: + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "login", + id: String(resp.body.challenge_id), + location: privatePages.home.url({}), + sent: AbsoluteTime.never(), + request: { + tokenRequest, + username, + password, + }, + }); + return; + } + case HttpStatusCode.Unauthorized: { return notify({ type: "error", title: i18n.str`Wrong credentials for "${username}"`, @@ -200,7 +224,26 @@ function PublicRounting({ debug: resp.detail, when: AbsoluteTime.now(), }); - case HttpStatusCode.NotFound: + } + case TalerErrorCode.GENERIC_FORBIDDEN: { + return notify({ + type: "error", + title: i18n.str`You have no permission to this account.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case TalerErrorCode.BANK_ACCOUNT_LOCKED: { + return notify({ + type: "error", + title: i18n.str`This account is locked. If you have a active session you can change the password or contact the administrator.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case HttpStatusCode.NotFound: { return notify({ type: "error", title: i18n.str`Account not found`, @@ -208,6 +251,7 @@ function PublicRounting({ debug: resp.detail, when: AbsoluteTime.now(), }); + } default: assertUnreachable(resp); } @@ -223,7 +267,12 @@ function PublicRounting({ <div class="sm:mx-auto sm:w-full sm:max-w-sm"> <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to ${config.bank_name}!`}</h2> </div> - <LoginForm routeRegister={publicPages.register} /> + <LoginForm + routeRegister={publicPages.register} + onAuthorizationRequired={() => + navigateTo(publicPages.solveSecondFactor.url({})) + } + /> </Fragment> ); } diff --git a/packages/bank-ui/src/hooks/bank-state.ts b/packages/bank-ui/src/hooks/bank-state.ts @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { TokenRequest } from "@gnu-taler/taler-util"; import { AbsoluteTime, Codec, @@ -35,6 +36,7 @@ import { const { codecForTanTransmission } = TalerCorebankApi; export type ChallengeInProgess = + | LoginChallenge | DeleteAccountChallenge | UpdateAccountChallenge | UpdatePasswordChallenge @@ -46,12 +48,20 @@ type BaseChallenge<OpType extends string, ReqType> = { id: string; operation: OpType; sent: AbsoluteTime; - location: AppLocation; + location: AppLocation | undefined; info?: TalerCorebankApi.TanTransmission; request: ReqType; }; type DeleteAccountChallenge = BaseChallenge<"delete-account", string>; +type LoginChallenge = BaseChallenge< + "login", + { + tokenRequest: TokenRequest; + username: string; + password: string; + } +>; type UpdateAccountChallenge = BaseChallenge< "update-account", TalerCorebankApi.AccountReconfiguration @@ -102,7 +112,7 @@ const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> => .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) .property("info", codecOptional(codecForTanTransmission())) - .property("request", codecForAny()) + .property("request", codecForAny()) //FIXME: complete definition .build("UpdateAccountChallenge"); const codecForChallengeCreateTransaction = @@ -113,7 +123,7 @@ const codecForChallengeCreateTransaction = .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) .property("info", codecOptional(codecForTanTransmission())) - .property("request", codecForAny()) + .property("request", codecForAny()) //FIXME: complete definition .build("CreateTransactionChallenge"); const codecForChallengeConfirmWithdrawal = @@ -124,7 +134,7 @@ const codecForChallengeConfirmWithdrawal = .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) .property("info", codecOptional(codecForTanTransmission())) - .property("request", codecForAny()) + .property("request", codecForAny()) //FIXME: complete definition .build("ConfirmWithdrawalChallenge"); const codecForAppLocation = codecForString as () => Codec<AppLocation>; @@ -136,9 +146,19 @@ const codecForChallengeCashout = (): Codec<CashoutChallenge> => .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) .property("info", codecOptional(codecForTanTransmission())) - .property("request", codecForAny()) + .property("request", codecForAny()) //FIXME: complete definition .build("CashoutChallenge"); +const codecForLoginChallenge = (): Codec<LoginChallenge> => + buildCodecForObject<LoginChallenge>() + .property("operation", codecForConstString("login")) + .property("id", codecForString()) + .property("location", codecOptional(codecForAppLocation())) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForAny()) //FIXME: complete definition + .build("LoginChallenge"); + const codecForChallenge = (): Codec<ChallengeInProgess> => buildCodecForUnion<ChallengeInProgess>() .discriminateOn("operation") @@ -148,6 +168,7 @@ const codecForChallenge = (): Codec<ChallengeInProgess> => .alternative("delete-account", codecForChallengeDeleteAccount()) .alternative("update-account", codecForChallengeUpdateAccount()) .alternative("update-password", codecForChallengeUpdatePassword()) + .alternative("login", codecForLoginChallenge()) .build("ChallengeInProgess"); interface BankState { diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -110,6 +110,7 @@ export namespace State { status: "login"; reason: "not-found" | "forbidden"; routeRegister?: RouteDefinition; + onAuthorizationRequired: () => void; } } @@ -127,7 +128,7 @@ const viewMapping: utils.StateViewMap<State> = { "invalid-iban": InvalidIbanView, "loading-error": (d) => { return Fragment({ - children: [ErrorLoadingWithDebug({ error: d.error }), LoginForm({})], + children: [ErrorLoadingWithDebug({ error: d.error })], })!; }, ready: ReadyView, diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts @@ -60,11 +60,13 @@ export function useComponentState({ case HttpStatusCode.Unauthorized: return { status: "login", + onAuthorizationRequired, reason: "forbidden", }; case HttpStatusCode.NotFound: return { status: "login", + onAuthorizationRequired, reason: "not-found", }; default: { diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx @@ -84,6 +84,8 @@ function ShowPedingOperation({ switch (op) { case "delete-account": return i18n.str`Pending account delete operation`; + case "login": + return i18n.str`Pending login`; case "update-account": return i18n.str`Pending account update operation`; case "update-password": diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -35,6 +35,9 @@ import { useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; import { USERNAME_REGEX } from "./RegistrationPage.js"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { useBankState } from "../hooks/bank-state.js"; +import { TokenRequest } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 104; @@ -52,10 +55,12 @@ export function LoginForm({ currentUser, fixedUser, routeRegister, + onAuthorizationRequired, }: { fixedUser?: boolean; currentUser?: string; routeRegister?: RouteDefinition; + onAuthorizationRequired: () => void; }): VNode { const session = useSessionState(); @@ -73,6 +78,7 @@ export function LoginForm({ } = useBankCoreApiContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const { config } = useBankCoreApiContext(); + const [, updateBankState] = useBankState(); const errors = undefinedIfEmpty({ username: !username @@ -93,16 +99,22 @@ export function LoginForm({ session.logOut(); } + const tokenRequest = { + scope: "readwrite", + duration: SESSION_DURATION, + refreshable: true, + } as TokenRequest; + const loginHandler = !username || !password ? undefined : withErrorHandler( async () => - authenticator.createAccessTokenBasic(username, password, { - scope: "readwrite", - duration: SESSION_DURATION, - refreshable: true, - }), + authenticator.createAccessTokenBasic( + username, + password, + tokenRequest, + ), (result) => { session.logIn({ username, @@ -114,6 +126,25 @@ export function LoginForm({ }, (fail) => { switch (fail.case) { + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "login", + id: String(fail.body.challenge_id), + location: undefined, + sent: AbsoluteTime.never(), + request: { + tokenRequest, + username, + password, + }, + }); + onAuthorizationRequired() + return i18n.str`Second factor authentication required.`; + } + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`You have no permission to this account.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`You have no permission to this account.`; case HttpStatusCode.Unauthorized: return i18n.str`Wrong credentials for "${username}"`; case HttpStatusCode.NotFound: diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -212,7 +212,7 @@ export function NeedConfirmationView({ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> </h3> <div class="mt-3 text-sm leading-6"> - <ShouldBeSameUser username={account}> + <ShouldBeSameUser username={account} onAuthorizationRequired={onAuthorizationRequired}> <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" autoCapitalize="none" diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -102,7 +102,6 @@ export function PaymentOptions({ onAuthorizationRequired, }: PaymentOptionProps): VNode { const { i18n } = useTranslationContext(); - const [bankState, updateBankState] = useBankState(); return ( <div class="mt-4"> diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -48,9 +48,17 @@ import { useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty } from "../utils.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { OperationNotFound } from "./WithdrawalQRCode.js"; +import { UserAndToken } from "@gnu-taler/taler-util"; +import { UserAndPassword } from "@gnu-taler/taler-util"; +import { TokenSuccessResponse } from "@gnu-taler/taler-util"; +import { createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 111; +type CredsType = + | { type: "basic-operation"; password: UserAndPassword } + | { type: "token-operation"; token: UserAndToken }; + const TAN_PREFIX = "T-"; const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/; export function SolveChallengePage({ @@ -67,9 +75,10 @@ export function SolveChallengePage({ const [bankState, updateBankState] = useBankState(); const [code, setCode] = useState<string | undefined>(undefined); const [notification, notify, handleError] = useLocalNotification(); - const { state } = useSessionState(); - const creds = state.status !== "loggedIn" ? undefined : state; const { navigateTo } = useNavigationContext(); + const session = useSessionState(); + const userSession = + session.state.status !== "loggedIn" ? undefined : session.state; if (!bankState.currentChallenge) { return ( @@ -95,10 +104,26 @@ export function SolveChallengePage({ : undefined, }); + const creds = + ch.operation === "login" + ? ({ + type: "basic-operation", + password: ch.request, + } as CredsType) + : userSession + ? ({ + type: "token-operation", + token: userSession, + } as CredsType) + : undefined; + async function startChallenge() { if (!creds) return; await handleError(async () => { - const resp = await api.sendChallenge(creds, ch.id); + const resp = await (creds.type === "basic-operation" + ? api.sendLoginChallenge(creds.password, ch.id) + : api.sendChallenge(creds.token, ch.id)); + if (resp.type === "ok") { const newCh = structuredClone(ch); newCh.sent = AbsoluteTime.now(); @@ -126,6 +151,22 @@ export function SolveChallengePage({ debug: resp.detail, when: AbsoluteTime.now(), }); + case HttpStatusCode.Forbidden: + return notify({ + type: "error", + title: i18n.str`You have no rights to complete the challenge.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.TooManyRequests: + return notify({ + type: "error", + title: i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ type: "error", @@ -148,7 +189,9 @@ export function SolveChallengePage({ : code; await handleError(async () => { { - const resp = await api.confirmChallenge(creds, ch.id, { tan }); + const resp = await (creds.type === "basic-operation" + ? api.confirmLoginChallenge(creds.password, ch.id, { tan }) + : api.confirmChallenge(creds.token, ch.id, { tan })); if (resp.type === "fail") { setCode(""); switch (resp.case) { @@ -200,18 +243,89 @@ export function SolveChallengePage({ { const resp = await (async (ch: ChallengeInProgess) => { switch (ch.operation) { - case "delete-account": - return await api.deleteAccount(creds, ch.id); - case "update-account": - return await api.updateAccount(creds, ch.request, ch.id); - case "update-password": - return await api.updatePassword(creds, ch.request, ch.id); - case "create-transaction": - return await api.createTransaction(creds, ch.request, ch.id); - case "confirm-withdrawal": - return await api.confirmWithdrawalById(creds, ch.request, ch.id); - case "create-cashout": - return await api.createCashout(creds, ch.request, ch.id); + case "delete-account": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.deleteAccount(userSession, ch.id); + } + case "update-account": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.updateAccount(userSession, ch.request, ch.id); + } + case "update-password": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.updatePassword(userSession, ch.request, ch.id); + } + case "create-transaction": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.createTransaction( + userSession, + ch.request, + ch.id, + ); + } + case "confirm-withdrawal": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.confirmWithdrawalById( + userSession, + ch.request, + ch.id, + ); + } + case "create-cashout": { + if (!userSession) { + // this should not happen since creds is present + throw Error( + `Session lost after challenge completed and operation retry. Try again or report.`, + ); + } + return await api.createCashout(userSession, ch.request, ch.id); + } + + case "login": { + const result = await api.createAccessTokenBasic( + ch.request.username, + ch.request.password, + ch.request.tokenRequest, + ch.id, + ); + if (result.type === "ok") { + const d = result.body as TokenSuccessResponse; + session.logIn({ + username: ch.request.username, + token: createRFC8959AccessTokenEncoded(d.access_token), + expiration: AbsoluteTime.fromProtocolTimestamp( + result.body.expiration, + ), + }); + } + return result; + } + default: assertUnreachable(ch); } @@ -276,7 +390,7 @@ export function SolveChallengePage({ onStart={startChallenge} onCancel={() => { updateBankState("currentChallenge", undefined); - navigateTo(ch.location); + navigateTo(ch.location ?? routeClose.url({})); }} /> {ch.info && ( @@ -412,6 +526,8 @@ function ChallengeDetails({ return i18n.str`Confirming withdrawal`; case "create-cashout": return i18n.str`Making a cashout`; + case "login": + return i18n.str`Authentication`; } })(challenge.operation); @@ -581,6 +697,18 @@ function ChallengeDetails({ </Fragment> ); } + case "login": { + return ( + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Username</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.username} + </dd> + </div> + ); + } case "update-password": { return ( <Fragment> diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -114,7 +114,7 @@ function OldWithdrawalForm({ // const walletInegrationApi = useTalerWalletIntegrationAPI() // const { navigateTo } = useNavigationContext(); - const [bankState, updateBankState] = useBankState(); + const [, updateBankState] = useBankState(); const { lib: { bank: api }, config, diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -67,16 +67,16 @@ export function WireTransfer({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm currentUser={account} /> + <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} /> </Fragment> ); } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={account} />; + return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} />; case HttpStatusCode.NotFound: - return <LoginForm currentUser={account} />; + return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; default: assertUnreachable(result); } diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -237,7 +237,7 @@ export function WithdrawalConfirmationQuestion({ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> </h3> <div class="mt-3 text-sm leading-6"> - <ShouldBeSameUser username={details.username}> + <ShouldBeSameUser username={details.username} onAuthorizationRequired={onAuthorizationRequired}> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" @@ -457,9 +457,11 @@ export function WithdrawalConfirmationQuestion({ export function ShouldBeSameUser({ username, children, + onAuthorizationRequired, }: { username: string; children: ComponentChildren; + onAuthorizationRequired: () => void; }): VNode { const { state: credentials } = useSessionState(); const { i18n } = useTranslationContext(); @@ -467,7 +469,7 @@ export function ShouldBeSameUser({ return ( <Fragment> <Attention type="info" title={i18n.str`Authentication required`} /> - <LoginForm currentUser={username} fixedUser /> + <LoginForm currentUser={username} fixedUser onAuthorizationRequired={onAuthorizationRequired}/> </Fragment> ); } @@ -478,7 +480,7 @@ export function ShouldBeSameUser({ type="warning" title={i18n.str`This operation was created with another username`} /> - <LoginForm currentUser={username} fixedUser /> + <LoginForm currentUser={username} fixedUser onAuthorizationRequired={onAuthorizationRequired}/> </Fragment> ); } diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -97,7 +97,10 @@ export function ShowAccountDetails({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm currentUser={account} /> + <LoginForm + currentUser={account} + onAuthorizationRequired={onAuthorizationRequired} + /> </Fragment> ); } @@ -105,7 +108,12 @@ export function ShowAccountDetails({ switch (result.case) { case HttpStatusCode.Unauthorized: case HttpStatusCode.NotFound: - return <LoginForm currentUser={account} />; + return ( + <LoginForm + currentUser={account} + onAuthorizationRequired={onAuthorizationRequired} + /> + ); default: assertUnreachable(result); } diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -235,6 +235,7 @@ export function UpdateAccountPassword({ <div class="mt-2"> <input type="password" + ref={focus ? doAutoFocus : undefined} class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="current" id="current-password" @@ -268,7 +269,6 @@ export function UpdateAccountPassword({ </label> <div class="mt-2"> <input - ref={focus ? doAutoFocus : undefined} type="password" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="password" diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -152,6 +152,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; + const hasPhone = !!defaultValue.phone || !!form.phone; + const hasEmail = !!defaultValue.email || !!form.email; + function updateForm(newForm: typeof defaultValue): void { const trimmedMinCashoutStr = newForm.min_cashout?.trim(); const parsedMinCashout = Amounts.parse( @@ -508,6 +511,137 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </p> </div> + {!config.supported_tan_channels || + config.supported_tan_channels.length === 0 ? undefined : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Enable second factor authentication`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === + -1 ? undefined : ( + <label + onClick={(e) => { + if (!hasEmail) return; + if (form.tan_channel === TanChannel.EMAIL) { + form.tan_channel = "remove"; + } else { + form.tan_channel = TanChannel.EMAIL; + } + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show" || !hasEmail} + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.EMAIL + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Using email</i18n.Translate> + </span> + {purpose !== "show" && + !hasEmail && + i18n.str`Add an email in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.EMAIL + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -1 ? undefined : ( + <label + onClick={(e) => { + if (!hasPhone) return; + if (form.tan_channel === TanChannel.SMS) { + form.tan_channel = "remove"; + } else { + form.tan_channel = TanChannel.SMS; + } + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show" || !hasPhone} + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.SMS + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-1-label" + class="block text-sm font-medium text-gray-900" + > + <i18n.Translate>Using SMS</i18n.Translate> + </span> + {purpose !== "show" && + !hasPhone && + i18n.str`Add a phone number in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.SMS + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + </div> + </div> + </div> + )} + {isCashoutEnabled && ( <TextField id="cashout-account" diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -79,16 +79,16 @@ export function RemoveAccount({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm currentUser={account} /> + <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} /> </Fragment> ); } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={account} />; + return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; case HttpStatusCode.NotFound: - return <LoginForm currentUser={account} />; + return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; default: assertUnreachable(result); } diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -132,9 +132,9 @@ export function CreateCashout({ if (resultAccount.type === "fail") { switch (resultAccount.case) { case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={accountName} />; + return <LoginForm currentUser={accountName} onAuthorizationRequired={onAuthorizationRequired} />; case HttpStatusCode.NotFound: - return <LoginForm currentUser={accountName} />; + return <LoginForm currentUser={accountName} onAuthorizationRequired={onAuthorizationRequired}/>; default: assertUnreachable(resultAccount); } diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -21,6 +21,7 @@ import { HttpStatusCode, + TokenRequest, createRFC8959AccessTokenEncoded, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -38,7 +39,7 @@ const tokenRequest = { d_us: "forever" as const, }, refreshable: true, -}; +} as TokenRequest; export function LoginPage(_p: Props): VNode { const [token, setToken] = useState(""); diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -1057,6 +1057,13 @@ deploymentCli refreshable: false, }); if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + console.error( + `unable to login into bank accountfor user ${id}, 2fa required`, + ); + console.error(j2s(resp.body)); + process.exit(2); + } console.error( `unable to login into bank accountfor user ${id}, status ${resp.case}`, ); diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts @@ -27,8 +27,10 @@ import { TalerError, TalerErrorCode, TokenRequest, + UserAndPassword, UserAndToken, codecForTalerCommonConfigResponse, + codecForTokenInfoList, codecForTokenSuccessResponse, opKnownAlternativeFailure, opKnownHttpFailure, @@ -202,10 +204,7 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-tokens * */ - async getAccessTokenList( - user: string, - pagination?: PaginationParams, - ) { + async getAccessTokenList(user: string, pagination?: PaginationParams) { const url = new URL(`accounts/${user}/token`, this.baseUrl); addPaginationParams(url, pagination); const resp = await this.httpLib.fetch(url.href, { @@ -213,7 +212,7 @@ export class TalerCoreBankHttpClient { }); switch (resp.status) { case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForPublicAccountsResponse()); + return opSuccessFromHttp(resp, codecForTokenInfoList()); case HttpStatusCode.NoContent: return opFixedSuccess({ public_accounts: [] }); case HttpStatusCode.NotFound: @@ -1066,15 +1065,36 @@ export class TalerCoreBankHttpClient { * */ async sendChallenge(auth: UserAndToken, cid: string) { - const url = new URL( - `accounts/${auth.username}/challenge/${cid}`, - this.baseUrl, + // Should have the same argument and response type than sendLoginChallenge + return this.__interal_sendChallenge( + auth.username, + makeBearerTokenAuthHeader(auth.token), + cid, ); + } + + /** + * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID + * + */ + async sendLoginChallenge(auth: UserAndPassword, cid: string) { + // Should have the same argument and response type than sendChallenge + return this.__interal_sendChallenge( + auth.username, + makeBasicAuthHeader(auth.username, auth.password), + cid, + ); + } + + private async __interal_sendChallenge( + username: string, + Authorization: string | undefined, + cid: string, + ) { + const url = new URL(`accounts/${username}/challenge/${cid}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - }, + headers: { Authorization }, }); switch (resp.status) { case HttpStatusCode.Ok: @@ -1110,15 +1130,44 @@ export class TalerCoreBankHttpClient { cid: string, body: TalerCorebankApi.ChallengeSolve, ) { + return this.__interal_confirmChallenge( + auth.username, + makeBearerTokenAuthHeader(auth.token), + cid, + body, + ); + } + + /** + * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID-confirm + * + */ + async confirmLoginChallenge( + auth: UserAndPassword, + cid: string, + body: TalerCorebankApi.ChallengeSolve, + ) { + return this.__interal_confirmChallenge( + auth.username, + makeBasicAuthHeader(auth.username, auth.password), + cid, + body, + ); + } + + private async __interal_confirmChallenge( + username: string, + Authorization: string | undefined, + cid: string, + body: TalerCorebankApi.ChallengeSolve, + ) { const url = new URL( - `accounts/${auth.username}/challenge/${cid}/confirm`, + `accounts/${username}/challenge/${cid}/confirm`, this.baseUrl, ); const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - }, + headers: { Authorization }, body, }); switch (resp.status) { diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -353,8 +353,9 @@ export const codecForCoinHistoryResponse = () => .property("history", codecForAny()) .build("CoinHistoryResponse"); -export type TokenScope = "readonly" | "readwrite" | "revenue" | "wiregateway"; - +export type TokenScope = BankTokenScope | MerchantTokenScope; +export type BankTokenScope = "readonly" | "readwrite" | "revenue" | "wiregateway"; +export type MerchantTokenScope = "write"; export interface TokenRequest { // Service-defined scope for the token. // Typical scopes would be "readonly" or "readwrite". diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts @@ -133,6 +133,7 @@ function convert<Type>(updated: string | undefined, key: StorageKey<Type>, defau try { return key.codec.decode(JSON.parse(updated)); } catch (e) { + console.error("Decoding error", e) //decode error return defaultValue; }