full 2fa flow

This commit is contained in:
c0repwn3r 2023-02-23 14:42:00 -05:00
parent 0c5656efb6
commit 5bdcac5e89
Signed by: core
GPG Key ID: FDBF740DADDCEECF
16 changed files with 685 additions and 12 deletions

169
Cargo.lock generated
View File

@ -54,6 +54,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "async-stream" name = "async-stream"
version = "0.3.3" version = "0.3.3"
@ -202,6 +211,21 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"
dependencies = [
"iana-time-zone",
"js-sys",
"num-integer",
"num-traits",
"time 0.1.45",
"wasm-bindgen",
"winapi",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.3" version = "0.4.3"
@ -212,6 +236,16 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
]
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@ -238,7 +272,7 @@ dependencies = [
"rand", "rand",
"sha2", "sha2",
"subtle", "subtle",
"time", "time 0.3.17",
"version_check", "version_check",
] ]
@ -330,6 +364,50 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "cxx"
version = "1.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86d3488e7665a7a483b57e25bdd90d0aeb2bc7608c8d0346acf2ad3f1caf1d62"
dependencies = [
"cc",
"cxxbridge-flags",
"cxxbridge-macro",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48fcaf066a053a41a81dfb14d57d99738b767febb8b735c3016e469fac5da690"
dependencies = [
"cc",
"codespan-reporting",
"once_cell",
"proc-macro2",
"quote",
"scratch",
"syn",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2ef98b8b717a829ca5603af80e1f9e2e48013ab227b68ef37872ef84ee479bf"
[[package]]
name = "cxxbridge-macro"
version = "1.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "086c685979a698443656e5cf7856c95c642295a38599f12fb1ff76fb28d19892"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "devise" name = "devise"
version = "0.3.1" version = "0.3.1"
@ -591,7 +669,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
] ]
[[package]] [[package]]
@ -756,6 +834,30 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"winapi",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca"
dependencies = [
"cxx",
"cxx-build",
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.3.0" version = "0.3.0"
@ -851,6 +953,15 @@ version = "0.2.139"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
[[package]]
name = "link-cplusplus"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.9" version = "0.4.9"
@ -938,7 +1049,7 @@ checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.42.0", "windows-sys 0.42.0",
] ]
@ -1413,7 +1524,7 @@ dependencies = [
"serde_json", "serde_json",
"state", "state",
"tempfile", "tempfile",
"time", "time 0.3.17",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
@ -1460,7 +1571,7 @@ dependencies = [
"smallvec", "smallvec",
"stable-pattern", "stable-pattern",
"state", "state",
"time", "time 0.3.17",
"tokio", "tokio",
"uncased", "uncased",
] ]
@ -1498,6 +1609,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "scratch"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2"
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.8.2" version = "2.8.2"
@ -1793,6 +1910,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "termcolor"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "tfclient" name = "tfclient"
version = "0.1.0" version = "0.1.0"
@ -1826,6 +1952,17 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "time"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
dependencies = [
"libc",
"wasi 0.10.0+wasi-snapshot-preview1",
"winapi",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.17" version = "0.3.17"
@ -2069,6 +2206,7 @@ name = "trifid-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.21.0", "base64 0.21.0",
"chrono",
"dotenvy", "dotenvy",
"log", "log",
"paste", "paste",
@ -2141,6 +2279,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.4" version = "0.2.4"
@ -2231,6 +2375,12 @@ dependencies = [
"try-lock", "try-lock",
] ]
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"
@ -2327,6 +2477,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="JavaScriptLibraryMappings"> <component name="JavaScriptLibraryMappings">
<file url="PROJECT" libraries="{all, flag-icons}" /> <file url="PROJECT" libraries="{all, flag-icons, qrcode}" />
</component> </component>
</project> </project>

View File

@ -12,5 +12,6 @@
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="flag-icons" level="application" /> <orderEntry type="library" name="flag-icons" level="application" />
<orderEntry type="library" name="all" level="application" /> <orderEntry type="library" name="all" level="application" />
<orderEntry type="library" name="qrcode" level="application" />
</component> </component>
</module> </module>

View File

@ -1,3 +1,14 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
input[type=number].appearance-none::-webkit-inner-spin-button,
input[type=number].appearance-none::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number].appearance-none {
-moz-appearance:textfield;
}
}

View File

@ -0,0 +1,29 @@
<script>
import { onMount } from 'svelte';
export let codeValue;
let qrcode;
onMount(() => {
let script = document.createElement('script');
script.src = "https://cdn.rawgit.com/davidshimjs/qrcodejs/gh-pages/qrcode.min.js"
document.head.append(script);
script.onload = function() {
qrcode = new QRCode("qrcode", {
text: codeValue,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
};
});
</script>
<div id="qrcode" class="w-max"></div>

View File

@ -1,7 +1,7 @@
import {fetch_timeout} from "./util"; import {fetch_timeout} from "./util";
import {API_ROOT} from "./config"; import {API_ROOT} from "./config";
import {Logger, logSetup} from "./logger"; import {Logger, logSetup} from "./logger";
import {getCookie} from "./cookie"; import {getCookie, setCookie} from "./cookie";
logSetup(); logSetup();
const logger = new Logger("auth.ts"); const logger = new Logger("auth.ts");
@ -11,11 +11,51 @@ export function redact_token(token: string) {
return token.substring(0, 5) + stars; return token.substring(0, 5) + stars;
} }
export interface UserInfo {
data: UserData,
metadata: object
}
export interface UserData {
actorType: string,
actor: Actor
}
export interface Actor {
id: string,
organizationID: string,
email: string,
createdAt: string,
hasTOTPAuthenticator: string
}
export async function get_user_info(api_key: string): Promise<UserInfo | string> {
logger.info("Asking server for user information");
try {
const resp = await fetch_timeout(`${API_ROOT}/v2/whoami`, {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${api_key}`
}
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`error fetching user information: ${rawerror}`);
return rawerror;
}
return JSON.parse(await resp.text()) as UserInfo;
} catch (e) {
logger.error(`Error fetching userinfo: ${e}`);
return `${e}`
}
}
export async function enforce_session(): Promise<[boolean, string]> { export async function enforce_session(): Promise<[boolean, string]> {
logger.info("Checking session authentication"); logger.info("Checking session authentication");
const session_token = getCookie("sessionToken"); const session_token = getCookie("sessionToken");
if (session_token === "") { if (session_token === "") {
logger.error("No session token is present"); logger.error("No session token is present");
setCookie("sessionToken", "", -1);
return [false, ""]; return [false, ""];
} }
logger.info(`Session token is ${redact_token(session_token)}`); logger.info(`Session token is ${redact_token(session_token)}`);
@ -31,6 +71,7 @@ export async function enforce_session(): Promise<[boolean, string]> {
if (!resp.ok) { if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message; const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`session token is invalid: ${rawerror}`); logger.error(`session token is invalid: ${rawerror}`);
setCookie("sessionToken", "", -1);
return [false, rawerror]; return [false, rawerror];
} else { } else {
logger.info("session token OK"); logger.info("session token OK");
@ -40,6 +81,7 @@ export async function enforce_session(): Promise<[boolean, string]> {
} catch (e) { } catch (e) {
// error in http request // error in http request
logger.error(`session token is invalid: ${e}`); logger.error(`session token is invalid: ${e}`);
setCookie("sessionToken", "", -1);
return [false, `${e}`] return [false, `${e}`]
} }
} }
@ -60,6 +102,7 @@ export async function enforce_auth(): Promise<[boolean, string]> {
const auth_token = getCookie("authToken"); const auth_token = getCookie("authToken");
if (auth_token === "") { if (auth_token === "") {
logger.error("No auth token is present"); logger.error("No auth token is present");
setCookie("authToken", "", -1);
return [false, ""]; return [false, ""];
} }
logger.info(`MFA token is ${redact_token(auth_token)}`); logger.info(`MFA token is ${redact_token(auth_token)}`);
@ -74,6 +117,7 @@ export async function enforce_auth(): Promise<[boolean, string]> {
}); });
if (!resp.ok) { if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message; const rawerror = JSON.parse(await resp.text()).errors[0].message;
setCookie("authToken", "", -1);
return [false, rawerror]; return [false, rawerror];
} else { } else {
// session ok // session ok
@ -81,6 +125,7 @@ export async function enforce_auth(): Promise<[boolean, string]> {
} }
} catch (e) { } catch (e) {
// error in http request // error in http request
setCookie("authToken", "", -1);
return [false, `${e}`] return [false, `${e}`]
} }
} }

View File

@ -47,5 +47,27 @@
"unable to parse the request body, is it properly formatted?": "There was an error processing your request, please try again later.", "unable to parse the request body, is it properly formatted?": "There was an error processing your request, please try again later.",
"this token is invalid - no rows returned by a query that expected to return at least one row": "This token is invalid or has expired." "this token is invalid - no rows returned by a query that expected to return at least one row": "This token is invalid or has expired."
} }
},
"mfa": {
"title": "Two-factor authentication",
"subtitle": "Enter the code displayed on your authenticator app",
"actionButtonText": "Check code",
"apierror": {
"invalid TOTP code (maybe it expired?)": "Incorrect 2FA code"
}
},
"mfasetup": {
"title": "Protect your account",
"subtitle": "2FA is required for all trifid accounts. Protect your account with any TOTP-compatible authenticator app.",
"qrtitle": "Scan the QR code with your authenticator app.",
"secrettitle": "Or, copy this code into your authenticator app.",
"verifytitle": "Enter the code shown on your authenticator app",
"loadingmfa": "Hang on while we load your account...",
"actionButtonText": "Add authenticator",
"apierror": {
"Invalid TOTP code": "Incorrect 2FA code"
}
} }
} }

92
tfweb/src/lib/totp.ts Normal file
View File

@ -0,0 +1,92 @@
import {fetch_timeout} from "./util";
import {Logger, logSetup} from "./logger";
import {API_ROOT} from "./config";
const logger = new Logger("totp.ts");
logSetup();
export interface TOTPSetupDetails {
totpToken: string,
secret: string,
url: string
}
export async function startTotpSetup(api_key: string): Promise<TOTPSetupDetails | string> {
logger.info("Starting TOTP setup");
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/totp-authenticators`, {
'method': 'POST',
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${api_key}`
},
'body': "{}"
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`API returned error setting up TOTP: ${rawerror}`);
return rawerror;
}
logger.info('Initiated TOTP setup successfully');
return (await resp.json()).data as TOTPSetupDetails;
} catch (e) {
logger.error(`Error while trying to setup TOTP: ${e}`);
return `${e}`
}
}
export interface TOTPToken {
token: string
}
export async function finishTOTPSetup(api_key: string, token: string, code: string): Promise<TOTPToken | string> {
logger.info("Finishing up TOTP setup");
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/verify-totp-authenticator`, {
'method': 'POST',
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${api_key}`
},
'body': `{"totpToken":"${token}","code":"${code}"}`
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`API returned error finishing up TOTP: ${rawerror}`);
return rawerror;
}
logger.info('Finished TOTP setup! Auth token issued');
return {
token: (await resp.json()).data.authToken
};
} catch (e) {
logger.error(`Error while trying to finish TOTP: ${e}`);
return `${e}`
}
}
export async function validateTOTP(api_key: string, code: string): Promise<TOTPToken | string> {
logger.info("Validating 2fa code");
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/auth/totp`, {
'method': 'POST',
'headers': {
'Content-Type': 'application/json',
'Authorization': `Bearer ${api_key}`
},
'body': `{"code":"${code}"}`
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`API returned error verifying TOTP: ${rawerror}`);
return rawerror;
}
logger.info('auth token issued');
return {
token: (await resp.json()).data.authToken
};
} catch (e) {
logger.error(`Error while trying to validate TOTP: ${e}`);
return `${e}`
}
}

View File

@ -10,7 +10,6 @@
onMount(async () => { onMount(async () => {
let st_result = await enforce_session(); let st_result = await enforce_session();
if (!st_result[0]) { if (!st_result[0]) {
logger.info(st_result);
// Session token is invalid. redirect to login // Session token is invalid. redirect to login
window.location = "/auth/login"; window.location = "/auth/login";
return; return;

View File

@ -1,8 +1,109 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"; import {onMount} from "svelte";
import {enforce_session} from "../../../lib/auth"; import {enforce_session} from "$lib/auth";
import {Logger, logSetup} from "$lib/logger";
import {get_user_info} from "$lib/auth";
import type {UserInfo} from "$lib/auth";
import {t} from "$lib/i18n";
import {validateTOTP} from "$lib/totp";
import {setCookie} from "$lib/cookie";
import {enforce_auth} from "$lib/auth";
let logger = new Logger("auth/mfa/+page.svelte");
logSetup();
let isFinished = false;
let isloading = false;
let api_token = "";
let mfa_token = "";
let hasError = false;
let error = "";
onMount(async () => { onMount(async () => {
let st_res = await enforce_session(); let st_res = await enforce_session();
if (!st_res[0]) {
// session token is invalid
// Session token is invalid. redirect to login
logger.info("Invalid session token, redirecting to login");
window.location = "/auth/login";
return;
}
api_token = st_res[1];
let at_res = await enforce_auth();
if (at_res[0]) {
logger.info("User already TOTP authenticated");
window.location = "/admin";
return;
}
let user: UserInfo | string = await get_user_info(api_token);
if (typeof user === "string") {
logger.error(`Unable to get user info: ${user}`);
hasError = true;
error = user;
return;
}
}); });
async function tryMFACode() {
isloading = true;
logger.info(`Submitting 2FA verify with code ${mfa_token}`);
let resp = await validateTOTP(api_token, mfa_token);
if (typeof resp === "string") {
logger.error(`Unable to validate TOTP token: ${resp}`);
hasError = true;
isloading = false;
error = t(`mfa.apierror.${resp}`);
return;
}
// set cookie
setCookie("authToken", resp.token, 86400 * 365);
window.location = "/admin";
}
</script> </script>
<div class="flex in-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
{#if !isFinished}
<!-- Title -->
<div>
<h1 class="font-semibold text-2xl">{t('mfa.title')}</h1>
<h2 class="ftext-sm">{t('mfa.subtitle')}</h2>
</div>
<!-- The actual form -->
<form class="mt-5" action="#" method="POST" on:submit|preventDefault={tryMFACode}>
<div class="-space-y-px rounded-md shadow-sm">
<label for="mfa_token" class="sr-only">{t('mfa.prompt')}</label>
<input bind:value={mfa_token} type="number" maxlength="6" id="mfa_token"
class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
{#if hasError}
<span class="text-red-600 text-sm">{error}</span>
{/if}
</div>
<button class="bg-purple-400 dark:bg-purple-600 mt-4 w-full py-2 -space-y-px rounded-md shadow-sm place-content-center">
{#if !isloading}
{t('mfa.actionButtonText')}
{:else}
<svg class="animate-spin w-5 h-5 inline-block m-auto" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
</button>
</form>
{:else}
<!-- Title -->
<div>
<h1 class="font-semibold text-2xl">{t('mfa.done')}</h1>
<h2 class="ftext-sm">{t('mfa.doneSubtitle')}</h2>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,148 @@
<script lang="ts">
import {onMount} from "svelte";
import {enforce_session} from "$lib/auth";
import {Logger, logSetup} from "$lib/logger";
import {get_user_info} from "$lib/auth";
import type {UserInfo} from "$lib/auth";
import {t} from "$lib/i18n";
import {startTotpSetup} from "$lib/totp";
import {browser} from "$app/environment";
import QR from "../../../components/QR.svelte";
import {finishTOTPSetup} from "$lib/totp";
import {setCookie} from "$lib/cookie";
let logger = new Logger("auth/mfa/+page.svelte");
logSetup();
let api_token = "";
let isLoadingMFA = true;
let hasError = false;
let error = "";
let totp_setup_token = "";
let totp_otpurl = "";
let totp_secret = "";
let mfa_token = "";
let isloading = false;
onMount(async () => {
let st_res = await enforce_session();
if (!st_res[0]) {
// session token is invalid
// Session token is invalid. redirect to mfa
logger.info("Invalid session token, redirecting to mfa");
window.location = "/auth/mfa";
return;
}
api_token = st_res[1];
let user: UserInfo | string = await get_user_info(api_token);
if (typeof user === "string") {
logger.error(`Unable to get user info: ${user}`);
hasError = true;
error = user;
return;
}
if (user.data.actor.hasTOTPAuthenticator) {
logger.info("User already has mfa set up, redirecting");
window.location = "/auth/mfa";
return;
}
let resp = await startTotpSetup(api_token);
if (typeof resp === "string") {
logger.error(`Unable to create TOTP token: ${resp}`);
hasError = true;
error = t(`mfasetup.apierror.${resp}`);
return;
}
totp_setup_token = resp.totpToken;
totp_secret = resp.secret;
totp_otpurl = resp.url;
isLoadingMFA = false;
});
function validateInput(evt) {
if (evt.which < 48 || evt.which > 57) {
evt.preventDefault();
}
}
async function tryMFACode() {
isloading = true;
logger.info(`Submitting 2FA verify with code ${mfa_token}`);
let resp = await finishTOTPSetup(api_token, totp_setup_token, mfa_token);
if (typeof resp === "string") {
logger.error(`Unable to validate TOTP token: ${resp}`);
hasError = true;
isloading = false;
error = t(`mfasetup.apierror.${resp}`);
return;
}
// set cookie
setCookie("authToken", resp.token);
window.location = "/admin";
window.location.reload();
}
</script>
<sveltekit:head>
<script src="https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"></script>
</sveltekit:head>
<div class="flex in-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<h1 class="font-semibold text-2xl">{t('mfasetup.title')}</h1>
<h2 class="text-sm">{t('mfasetup.subtitle')}</h2>
{#if isLoadingMFA}
<div class="mt-5">
<svg class="animate-spin w-5 h-5 inline-block m-auto" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-xs inline-block">{t('mfasetup.loadingmfa')}</h3>
</div>
{:else}
<h4 class="text-sm mt-5 font-semibold ">{t('mfasetup.qrtitle')}</h4>
<div class="mt-2 dark:bg-slate-600 bg-slate-200 rounded aspect-square p-4 w-min">
<QR codeValue="{totp_otpurl}"/>
</div>
<h4 class="text-sm mt-5">{t('mfasetup.secrettitle')}</h4>
<span class="font-mono text-md mt-4 block">
{totp_secret.match(/.{1,4}/g).join(" ")}
</span>
<form class="mt-5" action="#" method="POST" on:submit|preventDefault={tryMFACode}>
<div class="-space-y-px rounded-md shadow-sm">
<input on:keypress={validateInput} bind:value={mfa_token} type="number" id="mfa_token" placeholder="{t('mfasetup.verifytitle')}"
class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
{#if hasError}
<span class="text-red-600 text-sm">{error}</span>
{/if}
</div>
<button class="-space-y-px bg-purple-400 dark:bg-purple-600 mt-4 w-full py-2 rounded-md shadow-sm place-content-center">
{#if !isloading}
{t('mfasetup.actionButtonText')}
{:else}
<svg class="animate-spin w-5 h-5 inline-block m-auto" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
</button>
</form>
{/if}
</div>
</div>

View File

@ -19,3 +19,4 @@ totp-rs = { version = "4.2.0", features = ["qr", "otpauth", "gen_secret"]}
uuid = { version = "1.3.0", features = ["v4", "fast-rng", "macro-diagnostics"]} uuid = { version = "1.3.0", features = ["v4", "fast-rng", "macro-diagnostics"]}
url = { version = "2.3.1", features = ["serde"] } url = { version = "2.3.1", features = ["serde"] }
urlencoding = "2.1.2" urlencoding = "2.1.2"
chrono = "0.4.23"

View File

@ -116,7 +116,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
crate::routes::v1::auth::check_session::check_session, crate::routes::v1::auth::check_session::check_session,
crate::routes::v1::auth::check_session::check_session_auth, crate::routes::v1::auth::check_session::check_session_auth,
crate::routes::v1::auth::check_session::options, crate::routes::v1::auth::check_session::options,
crate::routes::v1::auth::check_session::options_auth crate::routes::v1::auth::check_session::options_auth,
crate::routes::v2::whoami::whoami_request,
crate::routes::v2::whoami::options
]) ])
.register("/", catchers![ .register("/", catchers![
crate::routes::handler_400, crate::routes::handler_400,

View File

@ -1,4 +1,5 @@
pub mod v1; pub mod v1;
pub mod v2;
use rocket::catch; use rocket::catch;
use serde::{Serialize}; use serde::{Serialize};
@ -25,7 +26,7 @@ TODO:
/v1/verify-totp-authenticator [done] /v1/verify-totp-authenticator [done]
/v1/dnclient /v1/dnclient
/v2/enroll /v2/enroll
/v2/whoami /v2/whoami [in-progress]
*/ */
#[derive(Serialize)] #[derive(Serialize)]

View File

@ -0,0 +1 @@
pub mod whoami;

View File

@ -0,0 +1,61 @@
use chrono::{NaiveDateTime, Utc};
use serde::{Serialize, Deserialize};
use rocket::{options, get, State};
use rocket::http::{ContentType, Status};
use rocket::serde::json::Json;
use sqlx::PgPool;
use crate::auth::PartialUserInfo;
use crate::tokens::user_has_totp;
#[derive(Serialize, Deserialize)]
pub struct WhoamiMetadata {}
#[derive(Serialize, Deserialize)]
pub struct WhoamiActor {
pub id: String,
#[serde(rename = "organizationID")]
pub organization_id: String,
pub email: String,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "hasTOTPAuthenticator")]
pub has_totpauthenticator: bool,
}
#[derive(Serialize, Deserialize)]
pub struct WhoamiData {
#[serde(rename = "actorType")]
pub actor_type: String,
pub actor: WhoamiActor,
}
#[derive(Serialize, Deserialize)]
pub struct WhoamiResponse {
pub data: WhoamiData,
pub metadata: WhoamiMetadata,
}
#[options("/v2/whoami")]
pub fn options() -> &'static str {
""
}
#[get("/v2/whoami")]
pub async fn whoami_request(user: PartialUserInfo, db: &State<PgPool>) -> Result<(ContentType, Json<WhoamiResponse>), (Status, String)> {
Ok((ContentType::JSON, Json(WhoamiResponse {
data: WhoamiData {
actor_type: "user".to_string(),
actor: WhoamiActor {
id: user.user_id.to_string(),
organization_id: "TEMP_ORG_BECAUSE_THAT_ISNT_IMPLEMENTED_YET".to_string(),
email: user.email,
created_at: NaiveDateTime::from_timestamp_opt(user.created_at, 0).unwrap().and_local_timezone(Utc).unwrap().to_rfc3339(),
has_totpauthenticator: match user_has_totp(user.user_id, db.inner()).await {
Ok(b) => b,
Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_DBERROR", "an error occured trying to verify your user", e)))
},
}
},
metadata: WhoamiMetadata {},
})))
}