add concept for register flow

This commit is contained in:
mrjvs 2023-11-05 01:16:45 +01:00
parent 4f4ee13556
commit df85861cf2
11 changed files with 400 additions and 1 deletions

View file

@ -8,7 +8,9 @@
"@headlessui/react": "^1.5.0",
"@movie-web/providers": "^1.0.4",
"@react-spring/web": "^9.7.1",
"@scure/bip39": "^1.2.1",
"@sozialhelden/ietf-language-tags": "^5.4.2",
"@types/node-forge": "^1.3.8",
"classnames": "^2.3.2",
"core-js": "^3.29.1",
"dompurify": "^3.0.1",
@ -19,6 +21,7 @@
"hls.js": "^1.0.7",
"i18next": "^22.4.5",
"immer": "^10.0.2",
"node-forge": "^1.3.1",
"ofetch": "^1.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -30,6 +33,7 @@
"react-use": "^17.4.0",
"slugify": "^1.6.6",
"subsrt-ts": "^2.1.1",
"universal-base64url": "^1.1.0",
"unzipit": "^1.4.3",
"zustand": "^4.3.9"
},

View file

@ -23,9 +23,15 @@ dependencies:
'@react-spring/web':
specifier: ^9.7.1
version: 9.7.3(react-dom@17.0.2)(react@17.0.2)
'@scure/bip39':
specifier: ^1.2.1
version: 1.2.1
'@sozialhelden/ietf-language-tags':
specifier: ^5.4.2
version: 5.4.2
'@types/node-forge':
specifier: ^1.3.8
version: 1.3.8
classnames:
specifier: ^2.3.2
version: 2.3.2
@ -56,6 +62,9 @@ dependencies:
immer:
specifier: ^10.0.2
version: 10.0.2
node-forge:
specifier: ^1.3.1
version: 1.3.1
ofetch:
specifier: ^1.0.0
version: 1.3.3
@ -89,6 +98,9 @@ dependencies:
subsrt-ts:
specifier: ^2.1.1
version: 2.1.1
universal-base64url:
specifier: ^1.1.0
version: 1.1.0
unzipit:
specifier: ^1.4.3
version: 1.4.3
@ -1889,6 +1901,11 @@ packages:
- encoding
dev: false
/@noble/hashes@1.3.2:
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -2019,6 +2036,17 @@ packages:
rollup: 2.79.1
dev: true
/@scure/base@1.1.3:
resolution: {integrity: sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==}
dev: false
/@scure/bip39@1.2.1:
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
dependencies:
'@noble/hashes': 1.3.2
'@scure/base': 1.1.3
dev: false
/@sozialhelden/ietf-language-tags@5.4.2:
resolution: {integrity: sha512-aCN7bVOfX9sBN0EHyWJT14H8bx+VYBo8tdcynai35wgoxKMfVtgEECkQ1gs8nEL6GHGes8lPIfo6AjIch44N3w==}
dependencies:
@ -2121,9 +2149,14 @@ packages:
resolution: {integrity: sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==}
dev: true
/@types/node-forge@1.3.8:
resolution: {integrity: sha512-vGXshY9vim9CJjrpcS5raqSjEfKlJcWy2HNdgUasR66fAnVEYarrf1ULV4nfvpC1nZq/moA9qyqBcu83x+Jlrg==}
dependencies:
'@types/node': 17.0.45
dev: false
/@types/node@17.0.45:
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
dev: true
/@types/pako@2.0.0:
resolution: {integrity: sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==}
@ -4733,6 +4766,11 @@ packages:
whatwg-url: 5.0.0
dev: false
/node-forge@1.3.1:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'}
dev: false
/node-releases@2.0.13:
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
dev: true
@ -6112,6 +6150,16 @@ packages:
crypto-random-string: 2.0.0
dev: true
/universal-base64@2.1.0:
resolution: {integrity: sha512-WeOkACVnIXJZr/qlv7++Rl1zuZOHN96v2yS5oleUuv8eJOs5j9M5U3xQEIoWqn1OzIuIcgw0fswxWnUVGDfW6g==}
dev: false
/universal-base64url@1.1.0:
resolution: {integrity: sha512-qWv2+8KCaAWdpqqXwU8W0Yj9pflYDXP37/a3kec6Y4Je7bYzgIfxEVRjZWeLR67be7iot1lGCy5Nuo+xB0fojA==}
dependencies:
universal-base64: 2.1.0
dev: false
/universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}

View file

@ -0,0 +1,40 @@
import { generateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
import forge from "node-forge";
import { encode } from "universal-base64url";
async function seedFromMnemonic(mnemonic: string) {
const md = forge.md.sha256.create();
md.update(mnemonic);
// TODO this is probably not correct
return md.digest().toHex();
}
export async function keysFromMenmonic(mnemonic: string) {
const seed = await seedFromMnemonic(mnemonic);
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
seed,
});
return {
privateKey,
publicKey,
};
}
export function genMnemonic(): string {
return generateMnemonic(wordlist);
}
export async function signCode(
_code: string,
_privateKey: forge.pki.ed25519.NativeBuffer
): Promise<Uint8Array> {
// TODO add real signature
return new Uint8Array();
}
export function bytesToBase64Url(bytes: Uint8Array): string {
return encode(String.fromCodePoint(...bytes));
}

View file

@ -0,0 +1,13 @@
import { ofetch } from "ofetch";
export interface MetaResponse {
name: string;
description?: string;
hasCaptcha: boolean;
}
export async function getBackendMeta(url: string): Promise<MetaResponse> {
return ofetch<MetaResponse>("/meta", {
baseURL: url,
});
}

View file

@ -0,0 +1,64 @@
import { ofetch } from "ofetch";
import { SessionResponse, UserResponse } from "@/backend/accounts/auth";
import { keysFromMenmonic, signCode } from "@/backend/accounts/crypto";
export interface ChallengeTokenResponse {
challenge: string;
}
export async function getRegisterChallengeToken(
url: string,
captchaToken?: string
): Promise<ChallengeTokenResponse> {
return ofetch<ChallengeTokenResponse>("/auth/register/start", {
method: "POST",
body: {
captchaToken,
},
baseURL: url,
});
}
export interface RegisterResponse {
user: UserResponse;
session: SessionResponse;
token: string;
}
export interface RegisterInput {
publicKey: string;
challenge: {
code: string;
signature: string;
};
device: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
}
export async function registerAccount(
url: string,
data: RegisterInput
): Promise<RegisterResponse> {
return ofetch<RegisterResponse>("/auth/register/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseURL: url,
});
}
export async function signChallenge(mnemonic: string, challengeCode: string) {
const keys = await keysFromMenmonic(mnemonic);
const signature = await signCode(challengeCode, keys.privateKey);
return {
publicKey: keys.publicKey,
signature,
};
}

54
src/pages/Register.tsx Normal file
View file

@ -0,0 +1,54 @@
import { useState } from "react";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import {
AccountCreatePart,
AccountProfile,
} from "@/pages/parts/auth/AccountCreatePart";
import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePart";
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
export function RegisterPage() {
const [step, setStep] = useState(0);
const [mnemonic, setMnemonic] = useState<null | string>(null);
const [account, setAccount] = useState<null | AccountProfile>(null);
return (
<SubPageLayout>
{step === 0 ? (
<TrustBackendPart
onNext={() => {
setStep(1);
}}
/>
) : null}
{step === 1 ? (
<PassphraseGeneratePart
onNext={(n) => {
setMnemonic(n);
setStep(2);
}}
/>
) : null}
{step === 2 ? (
<AccountCreatePart
onNext={(v) => {
setAccount(v);
setStep(3);
}}
/>
) : null}
{step === 3 ? (
<VerifyPassphrase
mnemonic={mnemonic}
profile={account}
onNext={() => {
setStep(4);
}}
/>
) : null}
{step === 4 ? <p>Success, account now exists</p> : null}
</SubPageLayout>
);
}

View file

@ -0,0 +1,46 @@
import { useCallback, useState } from "react";
import { Button } from "@/components/Button";
import { Input } from "@/components/player/internals/ContextMenu/Input";
export interface AccountProfile {
device: string;
account: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
}
interface AccountCreatePartProps {
onNext?: (data: AccountProfile) => void;
}
export function AccountCreatePart(props: AccountCreatePartProps) {
const [account, setAccount] = useState("");
const [device, setDevice] = useState("");
// TODO validate device and account before next step
const nextStep = useCallback(() => {
props.onNext?.({
account,
device,
profile: {
colorA: "#fff",
colorB: "#000",
icon: "brush",
},
});
}, [account, device, props]);
return (
<div>
<p>Account name</p>
<Input value={account} onInput={setAccount} />
<p>Device name</p>
<Input value={device} onInput={setDevice} />
<Button onClick={() => nextStep()}>Next</Button>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { useMemo } from "react";
import { genMnemonic } from "@/backend/accounts/crypto";
import { Button } from "@/components/Button";
interface PassphraseGeneratePartProps {
onNext?: (mnemonic: string) => void;
}
export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
const mnemonic = useMemo(() => genMnemonic(), []);
return (
<div>
<p>Remeber the following passphrase:</p>
<p className="border rounded-xl p-2">{mnemonic}</p>
<Button onClick={() => props.onNext?.(mnemonic)}>Next</Button>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { useAsync } from "react-use";
import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta";
import { Button } from "@/components/Button";
import { conf } from "@/setup/config";
interface TrustBackendPartProps {
onNext?: (meta: MetaResponse) => void;
}
export function TrustBackendPart(props: TrustBackendPartProps) {
const result = useAsync(async () => {
const url = conf().BACKEND_URL;
return {
domain: new URL(url).hostname,
data: await getBackendMeta(conf().BACKEND_URL),
};
}, []);
if (result.loading) return <p>loading...</p>;
if (result.error || !result.value)
return <p>Failed to talk to backend, did you configure it correctly?</p>;
return (
<div>
<p>
do you trust{" "}
<span className="text-white font-bold">{result.value.domain}</span>
</p>
<div className="border rounded-xl p-4">
<p className="text-white font-bold">{result.value.data.name}</p>
{result.value.data.description ? (
<p>{result.value.data.description}</p>
) : null}
</div>
<Button onClick={() => props.onNext?.(result.value.data)}>Next</Button>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { useState } from "react";
import { useAsyncFn } from "react-use";
import { bytesToBase64Url } from "@/backend/accounts/crypto";
import {
getRegisterChallengeToken,
registerAccount,
signChallenge,
} from "@/backend/accounts/register";
import { Button } from "@/components/Button";
import { Input } from "@/components/player/internals/ContextMenu/Input";
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
interface VerifyPassphraseProps {
mnemonic: string | null;
profile: AccountProfile | null;
onNext?: () => void;
}
export function VerifyPassphrase(props: VerifyPassphraseProps) {
const [mnemonic, setMnemonic] = useState("");
const setAccount = useAuthStore((s) => s.setAccount);
const [result, execute] = useAsyncFn(
async (inputMnemonic: string) => {
if (!props.mnemonic || !props.profile)
throw new Error("invalid input data");
if (inputMnemonic !== props.mnemonic)
throw new Error("Passphrase doesn't match");
const url = conf().BACKEND_URL;
// TODO captcha?
const { challenge } = await getRegisterChallengeToken(url);
const keys = await signChallenge(inputMnemonic, challenge);
const registerResult = await registerAccount(url, {
challenge: {
code: challenge,
signature: bytesToBase64Url(keys.signature),
},
publicKey: bytesToBase64Url(keys.publicKey),
device: props.profile.device,
profile: props.profile.profile,
});
setAccount({
profile: registerResult.user.profile,
sessionId: registerResult.session.id,
token: registerResult.token,
userId: registerResult.user.id,
});
props.onNext?.();
},
[props, setAccount]
);
return (
<div>
<p>verify passphrase</p>
<Input value={mnemonic} onInput={setMnemonic} />
{result.loading ? <p>Loading...</p> : null}
{result.error ? <p>error: {result.error.toString()}</p> : null}
<Button onClick={() => execute(mnemonic)}>Register</Button>
</div>
);
}

View file

@ -17,6 +17,7 @@ import { DmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { PlayerView } from "@/pages/PlayerView";
import { RegisterPage } from "@/pages/Register";
import { SettingsPage } from "@/pages/Settings";
import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history";
@ -87,6 +88,7 @@ function App() {
</LegacyUrlView>
</Route>
<Route exact path={["/browse/:query?", "/"]} component={HomePage} />
<Route exact path="/register" component={RegisterPage} />
<Route exact path="/faq" component={AboutPage} />
<Route exact path="/dmca" component={DmcaPage} />