mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-29 16:06:07 +00:00
add concept for register flow
This commit is contained in:
parent
4f4ee13556
commit
df85861cf2
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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'}
|
||||
|
|
40
src/backend/accounts/crypto.ts
Normal file
40
src/backend/accounts/crypto.ts
Normal 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));
|
||||
}
|
13
src/backend/accounts/meta.ts
Normal file
13
src/backend/accounts/meta.ts
Normal 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,
|
||||
});
|
||||
}
|
64
src/backend/accounts/register.ts
Normal file
64
src/backend/accounts/register.ts
Normal 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
54
src/pages/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
46
src/pages/parts/auth/AccountCreatePart.tsx
Normal file
46
src/pages/parts/auth/AccountCreatePart.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
src/pages/parts/auth/PassphraseGeneratePart.tsx
Normal file
20
src/pages/parts/auth/PassphraseGeneratePart.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/pages/parts/auth/TrustBackendPart.tsx
Normal file
40
src/pages/parts/auth/TrustBackendPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
src/pages/parts/auth/VerifyPassphrasePart.tsx
Normal file
68
src/pages/parts/auth/VerifyPassphrasePart.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
|
||||
|
|
Loading…
Reference in a new issue