Skip to content

Commit

Permalink
Wip
Browse files Browse the repository at this point in the history
  • Loading branch information
frederikrothenberger committed Oct 4, 2024
1 parent ee07a98 commit 1a15466
Show file tree
Hide file tree
Showing 7 changed files with 524 additions and 224 deletions.
33 changes: 27 additions & 6 deletions src/frontend/src/components/authenticateBox/errorToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type KindToError<K extends FlowError["kind"]> = Omit<
"kind"
>;

// Makes the error human readable
// Makes the error human-readable
const clarifyError: {
[K in FlowError["kind"]]: (err: KindToError<K>) => {
title: string;
Expand Down Expand Up @@ -41,11 +41,6 @@ const clarifyError: {
detail: err.error.message,
}),
badPin: () => ({ title: "Could not authenticate", message: "Invalid PIN" }),
badChallenge: () => ({
title: "Failed to register",
message:
"Failed to register with Internet Identity, because the CAPTCHA challenge wasn't successful",
}),
registerNoSpace: () => ({
title: "Failed to register",
message:
Expand All @@ -56,6 +51,32 @@ const clarifyError: {
message:
"The Dapp you are authenticating to does not allow PIN identities and you only have a PIN identity. Please retry using a Passkey: open a new Internet Identity page, add a passkey and retry.",
}),
alreadyInProgress: () => ({
title: "Registration is already in progress",
message: "Registration has already been started on this session.",
}),
rateLimitExceeded: () => ({
title: "Registration rate limit exceeded",
message:
"Internet Identity is under heavy load. Too many registrations. Please try again later.",
}),
invalidCaller: () => ({
title: "Registration is not allowed using the anonymous identity",
message:
"Registration was attempted using the anonymous identity which is not allowed.",
}),
invalidAuthnMethod: (err) => ({
title: "Invalid authentication method",
message: `Invalid authentication method: ${err.message}.`,
}),
noRegistrationFlow: () => ({
title: "Registration flow timed out",
message: "Registration flow timed out. Please restart.",
}),
unexpectedCall: (err) => ({
title: "Unexpected call",
message: `Unexpected call: expected next step "${err.nextStep.step}"`,
}),
};

export const flowErrorToastTemplate = <K extends FlowError["kind"]>(
Expand Down
23 changes: 15 additions & 8 deletions src/frontend/src/components/authenticateBox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ import {
import { I18n } from "$src/i18n";
import { getAnchors, setAnchorUsed } from "$src/storage";
import {
AlreadyInProgress,
ApiError,
AuthFail,
AuthenticatedConnection,
BadChallenge,
BadPin,
Connection,
InvalidAuthnMethod,
InvalidCaller,
LoginSuccess,
NoRegistrationFlow,
RateLimitExceeded,
RegisterNoSpace,
UnexpectedCall,
UnknownUser,
WebAuthnFailed,
bufferEqual,
Expand Down Expand Up @@ -80,7 +85,7 @@ export const authenticateBox = async ({
newAnchor: boolean;
authnMethod: "pin" | "passkey" | "recovery";
}> => {
const promptAuth = (autoSelectIdentity?: bigint) =>
const promptAuth = async (autoSelectIdentity?: bigint) =>
authenticateBoxFlow<PinIdentityMaterial>({
i18n,
templates,
Expand All @@ -89,7 +94,7 @@ export const authenticateBox = async ({
loginPinIdentityMaterial: (opts) =>
loginPinIdentityMaterial({ ...opts, connection }),
recover: () => useRecovery(connection),
registerFlowOpts: getRegisterFlowOpts({
registerFlowOpts: await getRegisterFlowOpts({
connection,
allowPinAuthentication,
}),
Expand Down Expand Up @@ -223,10 +228,7 @@ export const authenticateBoxFlow = async <I>({
newAnchor: true;
authnMethod: "pin" | "passkey" | "recovery";
})
| BadChallenge
| ApiError
| AuthFail
| RegisterNoSpace
| FlowError
| { tag: "canceled" }
> => {
const result2 = await registerFlow(registerFlowOpts);
Expand Down Expand Up @@ -329,10 +331,15 @@ export type FlowError =
| AuthFail
| BadPin
| { kind: "pinNotAllowed" }
| BadChallenge
| WebAuthnFailed
| UnknownUser
| ApiError
| InvalidCaller
| AlreadyInProgress
| RateLimitExceeded
| NoRegistrationFlow
| UnexpectedCall
| InvalidAuthnMethod
| RegisterNoSpace;

export const handleLoginFlowResult = async <E>(
Expand Down
131 changes: 62 additions & 69 deletions src/frontend/src/flows/register/captcha.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,30 @@
import { Challenge } from "$generated/internet_identity_types";
import { mainWindow } from "$src/components/mainWindow";
import { DynamicKey, I18n } from "$src/i18n";
import { WrongCaptchaSolution } from "$src/utils/iiConnection";
import { mount, renderPage, withRef } from "$src/utils/lit-html";
import { Chan } from "$src/utils/utils";
import { isNullish, nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
import { asyncReplace } from "lit-html/directives/async-replace.js";
import { Ref, createRef, ref } from "lit-html/directives/ref.js";

import { isNullish, nonNullish } from "@dfinity/utils";
import copyJson from "./captcha.json";

// A symbol that we can differentiate from generic `T` types
// when verifying the challenge
export const badChallenge: unique symbol = Symbol("ii.bad_challenge");

export const promptCaptchaTemplate = <T>({
cancel,
requestChallenge,
verifyChallengeChars,
captcha_png_base64,
checkCaptcha,
onContinue,
i18n,
stepper,
focus: focus_,
scrollToTop = false,
}: {
cancel: () => void;
requestChallenge: () => Promise<Challenge>;
verifyChallengeChars: (cr: {
chars: string;
challenge: Challenge;
}) => Promise<T | typeof badChallenge>;
onContinue: (result: T) => void;
captcha_png_base64: string;
checkCaptcha: (
solution: string
) => Promise<Exclude<T, WrongCaptchaSolution> | WrongCaptchaSolution>;
onContinue: (result: Exclude<T, WrongCaptchaSolution>) => void;
i18n: I18n;
stepper: TemplateResult;
focus?: boolean;
Expand Down Expand Up @@ -62,7 +56,7 @@ export const promptCaptchaTemplate = <T>({
// The various states the component can inhabit
type State =
| { status: "requesting" }
| { status: "prompting"; challenge: Challenge }
| { status: "prompting"; captcha_png_base64: string }
| { status: "verifying" }
| { status: "bad" };

Expand All @@ -76,7 +70,7 @@ export const promptCaptchaTemplate = <T>({
state.status === "requesting"
? spinnerImg
: state.status === "prompting"
? captchaImg(state.challenge.png_base64)
? captchaImg(state.captcha_png_base64)
: Chan.unchanged,
def: spinnerImg,
});
Expand Down Expand Up @@ -110,57 +104,55 @@ export const promptCaptchaTemplate = <T>({
? (e) => {
e.preventDefault();
e.stopPropagation();
doVerify(state.challenge);
doVerify();
}
: undefined
);

const nextDisabled: Chan<boolean> = next.map(isNullish);
const nextCaption: Chan<DynamicKey> = state.map(({ status }) =>
status === "requesting"
? copy.generating
: status === "verifying"
? copy.verifying
: copy.next
);

// The "retry" button behavior
const retry: Chan<(() => Promise<void>) | undefined> = state.map((state) =>
state.status === "prompting" || state.status === "bad" ? doRetry : undefined
);
const retryDisabled: Chan<boolean> = retry.map(isNullish);

// On retry, request a new challenge
const doRetry = async () => {
state.send({ status: "requesting" });
const challenge = await requestChallenge();
state.send({ status: "prompting", challenge });
};
const nextCaption: Chan<DynamicKey> = state.map(({ status }) => {
console.log("next caption", status);
if (status === "requesting") {
return copy.generating;
}
if (status === "verifying") {
return copy.verifying;
}
console.log("copy next", copy.next);
return copy.next;
});

// On retry, prompt with a new challenge
// On verification, check the chars and either continue (on good challenge)
// or go to "bad" state
const doVerify = (challenge: Challenge) => {
const doVerify = () => {
state.send({ status: "verifying" });
void withRef(input, async (input) => {
const res = await verifyChallengeChars({
chars: input.value,
challenge,
});
if (res === badChallenge) {
const res = await checkCaptcha(input.value);
if (isBadCaptchaResult(res)) {
console.log("bad captcha");
// on a bad challenge, show some error, clear the input & focus
// and retry
state.send({ status: "bad" });
input.value = "";
input.focus();
void doRetry();
} else {
onContinue(res);
console.log("sending prompting");
state.send({ status: "requesting" });
state.send({
status: "prompting",
captcha_png_base64: res.new_captcha_png_base64,
});
return;
}
onContinue(res);
});
};

// Kickstart everything
void doRetry();
void state.send({
status: "prompting",
captcha_png_base64: captcha_png_base64,
});

// A "resize" handler than ensures that the captcha is centered when after
// the page is resized. This is particularly useful on mobile devices, where
Expand Down Expand Up @@ -192,15 +184,6 @@ export const promptCaptchaTemplate = <T>({
class="c-input c-input--icon"
>
${asyncReplace(img)}
<i
tabindex="0"
id="seedCopy"
class="c-button__icon"
@click=${asyncReplace(retry)}
?disabled=${asyncReplace(retryDisabled)}
>
<span>${copy.retry}</span>
</i>
</div>
<label>
<strong class="t-strong">${copy.instructions}</strong>
Expand Down Expand Up @@ -257,23 +240,22 @@ export function promptCaptchaPage<T>(
}

export const promptCaptcha = <T>({
createChallenge,
captcha_png_base64,
stepper,
register,
checkCaptcha,
}: {
createChallenge: () => Promise<Challenge>;
captcha_png_base64: string;
stepper: TemplateResult;
register: (cr: {
chars: string;
challenge: Challenge;
}) => Promise<T | typeof badChallenge>;
}): Promise<T | { tag: "canceled" }> => {
checkCaptcha: (
solution: string
) => Promise<Exclude<T, WrongCaptchaSolution> | WrongCaptchaSolution>;
}): Promise<Exclude<T, WrongCaptchaSolution> | "canceled"> => {
return new Promise((resolve) => {
const i18n = new I18n();
promptCaptchaPage({
verifyChallengeChars: register,
requestChallenge: () => createChallenge(),
cancel: () => resolve({ tag: "canceled" }),
promptCaptchaPage<T>({
cancel: () => resolve("canceled"),
captcha_png_base64,
checkCaptcha,
onContinue: resolve,
i18n,
stepper,
Expand All @@ -283,6 +265,17 @@ export const promptCaptcha = <T>({
});
};

const isBadCaptchaResult = <T>(
res: Exclude<T, WrongCaptchaSolution> | WrongCaptchaSolution
): res is WrongCaptchaSolution => {
return (
nonNullish(res) &&
typeof res === "object" &&
"kind" in res &&
res.kind === "wrongCaptchaSolution"
);
};

// Returns a function that returns `first` on the first call,
// and values returned by `f()` from the second call on.
export function precomputeFirst<T>(f: () => T): () => T {
Expand Down
Loading

0 comments on commit 1a15466

Please sign in to comment.