diff --git a/Cargo.lock b/Cargo.lock index fa27b04..93025dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,15 @@ dependencies = [ "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]] name = "async-stream" version = "0.3.3" @@ -202,6 +211,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cipher" version = "0.4.3" @@ -212,6 +236,16 @@ dependencies = [ "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]] name = "color_quant" version = "1.1.0" @@ -238,7 +272,7 @@ dependencies = [ "rand", "sha2", "subtle", - "time", + "time 0.3.17", "version_check", ] @@ -330,6 +364,50 @@ dependencies = [ "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]] name = "devise" version = "0.3.1" @@ -591,7 +669,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -756,6 +834,30 @@ dependencies = [ "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]] name = "idna" version = "0.3.0" @@ -851,6 +953,15 @@ version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "lock_api" version = "0.4.9" @@ -938,7 +1049,7 @@ checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.42.0", ] @@ -1413,7 +1524,7 @@ dependencies = [ "serde_json", "state", "tempfile", - "time", + "time 0.3.17", "tokio", "tokio-stream", "tokio-util", @@ -1460,7 +1571,7 @@ dependencies = [ "smallvec", "stable-pattern", "state", - "time", + "time 0.3.17", "tokio", "uncased", ] @@ -1498,6 +1609,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" + [[package]] name = "security-framework" version = "2.8.2" @@ -1793,6 +1910,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + [[package]] name = "tfclient" version = "0.1.0" @@ -1826,6 +1952,17 @@ dependencies = [ "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]] name = "time" version = "0.3.17" @@ -2069,6 +2206,7 @@ name = "trifid-api" version = "0.1.0" dependencies = [ "base64 0.21.0", + "chrono", "dotenvy", "log", "paste", @@ -2141,6 +2279,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -2231,6 +2375,12 @@ dependencies = [ "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]] name = "wasi" 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" 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]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/tfweb/.idea/jsLibraryMappings.xml b/tfweb/.idea/jsLibraryMappings.xml index f837370..703ac1f 100644 --- a/tfweb/.idea/jsLibraryMappings.xml +++ b/tfweb/.idea/jsLibraryMappings.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/tfweb/.idea/tfweb.iml b/tfweb/.idea/tfweb.iml index ed165c4..04ff971 100644 --- a/tfweb/.idea/tfweb.iml +++ b/tfweb/.idea/tfweb.iml @@ -12,5 +12,6 @@ + \ No newline at end of file diff --git a/tfweb/src/app.css b/tfweb/src/app.css index b5c61c9..eae09d8 100644 --- a/tfweb/src/app.css +++ b/tfweb/src/app.css @@ -1,3 +1,14 @@ @tailwind base; @tailwind components; @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; + } +} \ No newline at end of file diff --git a/tfweb/src/components/QR.svelte b/tfweb/src/components/QR.svelte new file mode 100644 index 0000000..e53c6b5 --- /dev/null +++ b/tfweb/src/components/QR.svelte @@ -0,0 +1,29 @@ + + +
+ diff --git a/tfweb/src/lib/auth.ts b/tfweb/src/lib/auth.ts index a61e169..ab405e2 100644 --- a/tfweb/src/lib/auth.ts +++ b/tfweb/src/lib/auth.ts @@ -1,7 +1,7 @@ import {fetch_timeout} from "./util"; import {API_ROOT} from "./config"; import {Logger, logSetup} from "./logger"; -import {getCookie} from "./cookie"; +import {getCookie, setCookie} from "./cookie"; logSetup(); const logger = new Logger("auth.ts"); @@ -11,11 +11,51 @@ export function redact_token(token: string) { 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 { + 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]> { logger.info("Checking session authentication"); const session_token = getCookie("sessionToken"); if (session_token === "") { logger.error("No session token is present"); + setCookie("sessionToken", "", -1); return [false, ""]; } logger.info(`Session token is ${redact_token(session_token)}`); @@ -31,6 +71,7 @@ export async function enforce_session(): Promise<[boolean, string]> { if (!resp.ok) { const rawerror = JSON.parse(await resp.text()).errors[0].message; logger.error(`session token is invalid: ${rawerror}`); + setCookie("sessionToken", "", -1); return [false, rawerror]; } else { logger.info("session token OK"); @@ -40,6 +81,7 @@ export async function enforce_session(): Promise<[boolean, string]> { } catch (e) { // error in http request logger.error(`session token is invalid: ${e}`); + setCookie("sessionToken", "", -1); return [false, `${e}`] } } @@ -60,6 +102,7 @@ export async function enforce_auth(): Promise<[boolean, string]> { const auth_token = getCookie("authToken"); if (auth_token === "") { logger.error("No auth token is present"); + setCookie("authToken", "", -1); return [false, ""]; } logger.info(`MFA token is ${redact_token(auth_token)}`); @@ -74,6 +117,7 @@ export async function enforce_auth(): Promise<[boolean, string]> { }); if (!resp.ok) { const rawerror = JSON.parse(await resp.text()).errors[0].message; + setCookie("authToken", "", -1); return [false, rawerror]; } else { // session ok @@ -81,6 +125,7 @@ export async function enforce_auth(): Promise<[boolean, string]> { } } catch (e) { // error in http request + setCookie("authToken", "", -1); return [false, `${e}`] } } \ No newline at end of file diff --git a/tfweb/src/lib/i18n/en.json b/tfweb/src/lib/i18n/en.json index fbea936..ab88f1f 100644 --- a/tfweb/src/lib/i18n/en.json +++ b/tfweb/src/lib/i18n/en.json @@ -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.", "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" + } } } \ No newline at end of file diff --git a/tfweb/src/lib/totp.ts b/tfweb/src/lib/totp.ts new file mode 100644 index 0000000..aeb3d57 --- /dev/null +++ b/tfweb/src/lib/totp.ts @@ -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 { + 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 { + 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 { + 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}` + } +} \ No newline at end of file diff --git a/tfweb/src/routes/admin/+page.svelte b/tfweb/src/routes/admin/+page.svelte index a4eee09..b11f758 100644 --- a/tfweb/src/routes/admin/+page.svelte +++ b/tfweb/src/routes/admin/+page.svelte @@ -10,7 +10,6 @@ onMount(async () => { let st_result = await enforce_session(); if (!st_result[0]) { - logger.info(st_result); // Session token is invalid. redirect to login window.location = "/auth/login"; return; diff --git a/tfweb/src/routes/auth/mfa/+page.svelte b/tfweb/src/routes/auth/mfa/+page.svelte index 849ce91..0bfa2d3 100644 --- a/tfweb/src/routes/auth/mfa/+page.svelte +++ b/tfweb/src/routes/auth/mfa/+page.svelte @@ -1,8 +1,109 @@ \ No newline at end of file + + 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"; + } + + + +
+
+ {#if !isFinished} + +
+

{t('mfa.title')}

+

{t('mfa.subtitle')}

+
+ + +
+
+ + + {#if hasError} + {error} + {/if} +
+ + +
+ {:else} + +
+

{t('mfa.done')}

+

{t('mfa.doneSubtitle')}

+
+ {/if} +
+
\ No newline at end of file diff --git a/tfweb/src/routes/auth/mfasetup/+page.svelte b/tfweb/src/routes/auth/mfasetup/+page.svelte new file mode 100644 index 0000000..674f472 --- /dev/null +++ b/tfweb/src/routes/auth/mfasetup/+page.svelte @@ -0,0 +1,148 @@ + + + + + + +
+
+

{t('mfasetup.title')}

+

{t('mfasetup.subtitle')}

+ + {#if isLoadingMFA} +
+ + + + +

{t('mfasetup.loadingmfa')}

+
+ {:else} +

{t('mfasetup.qrtitle')}

+
+ +
+

{t('mfasetup.secrettitle')}

+ + {totp_secret.match(/.{1,4}/g).join(" ")} + + +
+
+ + {#if hasError} + {error} + {/if} +
+ + +
+ {/if} +
+
\ No newline at end of file diff --git a/trifid-api/Cargo.toml b/trifid-api/Cargo.toml index 9cc94d0..9f9ac6e 100644 --- a/trifid-api/Cargo.toml +++ b/trifid-api/Cargo.toml @@ -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"]} url = { version = "2.3.1", features = ["serde"] } urlencoding = "2.1.2" +chrono = "0.4.23" \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 6494d47..2d7fa80 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -116,7 +116,9 @@ async fn main() -> Result<(), Box> { crate::routes::v1::auth::check_session::check_session, crate::routes::v1::auth::check_session::check_session_auth, 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![ crate::routes::handler_400, diff --git a/trifid-api/src/routes/mod.rs b/trifid-api/src/routes/mod.rs index 5c2f1b3..839d56f 100644 --- a/trifid-api/src/routes/mod.rs +++ b/trifid-api/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod v1; +pub mod v2; use rocket::catch; use serde::{Serialize}; @@ -25,7 +26,7 @@ TODO: /v1/verify-totp-authenticator [done] /v1/dnclient /v2/enroll - /v2/whoami + /v2/whoami [in-progress] */ #[derive(Serialize)] diff --git a/trifid-api/src/routes/v2/mod.rs b/trifid-api/src/routes/v2/mod.rs new file mode 100644 index 0000000..f511e9f --- /dev/null +++ b/trifid-api/src/routes/v2/mod.rs @@ -0,0 +1 @@ +pub mod whoami; \ No newline at end of file diff --git a/trifid-api/src/routes/v2/whoami.rs b/trifid-api/src/routes/v2/whoami.rs new file mode 100644 index 0000000..27bea05 --- /dev/null +++ b/trifid-api/src/routes/v2/whoami.rs @@ -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) -> Result<(ContentType, Json), (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 {}, + }))) +} \ No newline at end of file