diff --git a/tfweb/src/lib/auth.ts b/tfweb/src/lib/auth.ts index 7074283..ab9a690 100644 --- a/tfweb/src/lib/auth.ts +++ b/tfweb/src/lib/auth.ts @@ -110,6 +110,32 @@ export async function authSession(email: string): Promise<[AuthResult, null | AP } } +export async function signup(email: string): Promise<[AuthResult, null | APIError]> { + logger.info('sending signup'); + + try { + logger.debug(`api call: baseurl ${PUBLIC_BASE_URL}`); + const resp = await fetch(`${PUBLIC_BASE_URL}/v1/signup`, { + 'method': 'POST', + 'body': JSON.stringify({ + email: email + }), + 'headers': { + 'Content-Type': 'application/json' + } + }); + if (!resp.ok) { + const rawerror = JSON.parse(await resp.text()).errors[0]; + logger.error(`error sending authentication: ${rawerror.message}`); + return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}]; + } + return [AuthResult.Successful, null] + } catch (e) { + logger.error(`error making API request: ${e}`); + return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}] + } +} + export async function verifyLink(ml: string): Promise<[AuthResult, null | APIError]> { logger.info('checking magic link authentication'); @@ -164,10 +190,41 @@ export async function createTotp(token: string): Promise<[AuthResult, TOTPCreate logger.error(`error sending totp create: ${rawerror.message}`); return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}]; } - logger.info(`call success, ${await resp.text()}`) return [AuthResult.Successful, JSON.parse(await resp.text()).data] } catch (e) { logger.error(`error making API request: ${e}`); return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}] } } + +export async function verifyTotp(token: string, totpToken: string, totpCode: string): Promise<[AuthResult, undefined | APIError]> { + logger.info('verifying totp authenticator'); + + try { + logger.debug(`api call: baseurl ${PUBLIC_BASE_URL}`); + const resp = await fetch(`${PUBLIC_BASE_URL}/v1/totp-authenticators`, { + 'method': 'POST', + 'body': JSON.stringify({ + totpToken: totpToken, + code: totpCode + }), + 'headers': { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + if (!resp.ok) { + logger.error('call returned error code'); + const rawerror = JSON.parse(await resp.text()).errors[0]; + logger.error(`error sending totp create: ${rawerror.message}`); + return [AuthResult.Failed, {code: rawerror.code, message: rawerror.message}]; + } + + window.localStorage.setItem("mfa", JSON.parse(await resp.text()).data.authToken); + + return [AuthResult.Successful, undefined] + } catch (e) { + logger.error(`error making API request: ${e}`); + return [AuthResult.Failed, {code: "api_call_failed", message: `${e}`}] + } +} diff --git a/tfweb/src/lib/i18n/locales/en.json b/tfweb/src/lib/i18n/locales/en.json index 676fd42..6a19098 100644 --- a/tfweb/src/lib/i18n/locales/en.json +++ b/tfweb/src/lib/i18n/locales/en.json @@ -2,8 +2,9 @@ "itworks": { "header": "It works!", "body": "If you're seeing this page, tfweb is installed and (probably) correctly configured.", - "linkbody": "Perhaps you meant to visit the {link0}?", - "linkbody.link0": "admin panel" + "linkbody": "Perhaps you meant to visit the {link0} or {link1}?", + "linkbody.link0": "admin panel", + "linkbody.link1": "create an account" }, "login": { "title": "Log in to your account", @@ -20,6 +21,22 @@ "usermissing": "That user does not exist." } }, + "signup": { + "title": "Create an account", + "subtitle": "We'll send you an email with a \"magic link\"", + "label": "What is your email?", + "button": "Create account", + "email": "Check your email", + "emailbody": "We sent you an email with a link to complete signing up.", + "emailbody2": "Didn't work? Check your junk inbox or click {link0} to try again.", + "emailbody2.link0": "here", + "error": { + "invalidEmail": "That email address isn't valid. Try again.", + "generic": "There was an error logging you in. Try again or contact support with the error code {err}", + "userexists": "That user already exists. Try {link0}?", + "userexists.link0": "logging in" + } + }, "ml": { "header": "Authenticated!", "body": "Redirecting to admin page...", diff --git a/tfweb/src/routes/+page.svelte b/tfweb/src/routes/+page.svelte index 2bf313c..27f53fb 100644 --- a/tfweb/src/routes/+page.svelte +++ b/tfweb/src/routes/+page.svelte @@ -21,5 +21,5 @@ <h1>{$t('itworks.header')}</h1> <p>{$t('itworks.body')}</p> <!-- eslint-disable-next-line svelte/no-at-html-tags --> - <p>{@html $t('itworks.linkbody', {values:{link0:'<a href="/admin">'+$t('itworks.linkbody.link0')+'</a>'}})}</p> + <p>{@html $t('itworks.linkbody', {values:{link0:'<a href="/admin">'+$t('itworks.linkbody.link0')+'</a>',link1:'<a href="/signup">'+$t('itworks.linkbody.link1')+'</a>'}})}</p> </LoadingWrapper> diff --git a/tfweb/src/routes/2fasetup/+page.svelte b/tfweb/src/routes/2fasetup/+page.svelte index 32d72cf..15dfa59 100644 --- a/tfweb/src/routes/2fasetup/+page.svelte +++ b/tfweb/src/routes/2fasetup/+page.svelte @@ -2,7 +2,8 @@ import {isLoading, t} from "svelte-i18n"; import LoadingWrapper from "$components/LoadingWrapper.svelte"; import {onMount} from "svelte"; - import {AuthResult, authSession, createTotp, isAuthedSession} from "$lib/auth.ts"; + import {AuthResult, authSession, createTotp, isAuthedSession, verifyTotp} from "$lib/auth.ts"; + import type {TOTPCreateInfo} from "$lib/auth.ts"; import type {SessionInfo} from "$lib/auth.ts"; import type {APIError} from "$lib/auth.ts"; import {Logger, logSetup} from "$lib/logger"; @@ -18,6 +19,10 @@ let logger = new Logger("2fasetup/+page.svelte"); let totp_secret = ''; + let otp_url = ''; + let totp_create_token = ''; + + let code = ''; onMount(async () => { let session_load_info = await isAuthedSession(); @@ -54,10 +59,41 @@ return; } + logger.info('setting secret and otpurl'); + + totp_secret = (create_res[1] as TOTPCreateInfo).secret; + otp_url = (create_res[1] as TOTPCreateInfo).url; + totp_create_token = (create_res[1] as TOTPCreateInfo).totpToken; + + logger.info(totp_secret); + logger.info(otp_url); + loading = false; }) async function onSubmit() { + let create_res = await verifyTotp(window.localStorage.getItem("session"), totp_create_token, code); + + if (create_res[0] == AuthResult.Failed) { + logger.error(`totp auth fail`); + isError = true; + let err = create_res[1] as APIError; + + let etext = err.code; + + if (etext === "api_call_failed") { + etext = $t('2fasetup.error.api'); + } else if (etext === "ERR_ALREADY_HAS_TOTP") { + window.location.href = '/2fa'; + return; + } + + error = $t('2fasetup.error.generic', {values:{err:etext}}); + loading = false; + return; + } + + window.location.href = '/admin'; } </script> @@ -71,13 +107,13 @@ <p>{$t('2fasetup.body')}</p> <h4>{$t('2fasetup.scan')}</h4> <div> - <QrCode value="abcd1234"/> + <QrCode bind:value={otp_url}/> </div> <h4>{$t('2fasetup.code')}</h4> - <div><span>totp secret hete</span></div> + <div><span>{totp_secret}</span></div> <form on:submit|preventDefault={onSubmit}> <label for="code">{$t('2fasetup.verify')}</label> - <input type="number" min="0" max="999999" id="code" /> + <input bind:value={code} type="number" min="0" max="999999" id="code" /> <button>{$t('2fasetup.button')}</button> </form> </LoadingWrapper> diff --git a/tfweb/src/routes/admin/+page.svelte b/tfweb/src/routes/admin/+page.svelte index 4d914cb..ac02147 100644 --- a/tfweb/src/routes/admin/+page.svelte +++ b/tfweb/src/routes/admin/+page.svelte @@ -2,7 +2,7 @@ import {isLoading, t} from "svelte-i18n"; import LoadingWrapper from "$components/LoadingWrapper.svelte"; import {onMount} from "svelte"; - import {AuthResult, isAuthedSession} from "$lib/auth.ts"; + import {AuthResult, isAuthedMFA, isAuthedSession} from "$lib/auth.ts"; import {Logger, logSetup} from "$lib/logger"; import type {APIError} from "$lib/auth.ts"; @@ -23,6 +23,14 @@ return; } + let mfa_load_info = await isAuthedMFA(); + if (mfa_load_info[0] == AuthResult.Failed) { + let err = mfa_load_info[1] as APIError; + logger.error(`mfa load failed: ${err.code} ${err.message}`); + window.location.href = '/2fa'; + return; + } + loading = false; }) </script> diff --git a/tfweb/src/routes/signup/+page.svelte b/tfweb/src/routes/signup/+page.svelte new file mode 100644 index 0000000..32f52fb --- /dev/null +++ b/tfweb/src/routes/signup/+page.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import {isLoading, t} from "svelte-i18n"; + import LoadingWrapper from "$components/LoadingWrapper.svelte"; + import {onMount} from "svelte"; + import {AuthResult, authSession, isAuthedSession, signup} from "$lib/auth.ts"; + import type {APIError} from "$lib/auth.ts"; + import {Logger, logSetup} from "$lib/logger"; + + let loading = true; + let isError = false; + let error = ''; + $: currentlyLoading = $isLoading || loading; + + logSetup(); + let logger = new Logger("signup/+page.svelte"); + + onMount(async () => { + let session_load_info = await isAuthedSession(); + if (session_load_info[0] != AuthResult.Failed) { + logger.error(`session load success, the user is already logged in`); + window.location.href = '/2fa'; + return; + } + + loading = false; + }) + + let email = ''; + let email_regexp = new RegExp(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/); + + let hasErrForm = false; + let errForm = ''; + + let isDone = false; + + async function onSubmit() { + loading = true; + + if (!email_regexp.test(email)) { + errForm = $t('signup.error.invalidEmail'); + hasErrForm = true; + loading = false; + return; + } + + let auth_result = await signup(email); + + if (auth_result[0] === AuthResult.Failed) { + + hasErrForm = true; + + // parse the error and filter out common ones for more specific error messages + let err = auth_result[1] as APIError; + + if (err.code == "ERR_USER_EXISTS") { + errForm = $t('signup.error.userexists', {values:{link0:'<a href="/login">'+$t('signup.error.userexists.link0')+'</a>'}}); + } else { + errForm = $t('signup.error.generic', {values: {err: (auth_result[1] as APIError).code}}); + } + + loading = false; + return; + } + + isDone = true; + loading = false; + } +</script> + +<svelte:head> + <title>{$t("common.title", {values: {title: $t("common.page.signup")}})}</title> +</svelte:head> + +<LoadingWrapper isLoading={currentlyLoading} isError={isError} error={error}> + {#if isDone} + <h1>{$t('signup.email')}</h1> + <p>{$t('signup.emailbody')}</p> + <!-- eslint-disable-next-line svelte/no-at-html-tags --> + <p>{@html $t('signup.emailbody2', {values:{link0:'<a href="/signup">'+$t('signup.emailbody2.link0')+'</a>'}})}</p> + {:else} + <h1>{$t('signup.title')}</h1> + <h3>{$t('signup.subtitle')}</h3> + <form on:submit|preventDefault={onSubmit}> + <label for="email">{$t('signup.label')}</label> + <input type="email" bind:value={email} placeholder="john.doe@google.com" id="email"/> + <button>{$t('signup.button')}</button> + {#if hasErrForm} + <!-- eslint-disable-next-line svelte/no-at-html-tags --> + <p>{@html errForm}</p> + {/if} + </form> + {/if} +</LoadingWrapper>