diff --git a/.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json b/.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json new file mode 100644 index 0000000..c8a2294 --- /dev/null +++ b/.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select otp_method from otp where user_id = $1 and otp_method = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "otp_method", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b" +} diff --git a/.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json b/.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json new file mode 100644 index 0000000..c04d17b --- /dev/null +++ b/.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "select otp_method from otp where user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "otp_method", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f" +} diff --git a/.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json b/.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json new file mode 100644 index 0000000..899aa41 --- /dev/null +++ b/.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from otp where user_id = $1 and otp_method = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194" +} diff --git a/.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json b/.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json new file mode 100644 index 0000000..14325c7 --- /dev/null +++ b/.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "update otp set recovery_key = $3 where user_id = $1 and otp_method = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8" +} diff --git a/.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json b/.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json new file mode 100644 index 0000000..9cf09cc --- /dev/null +++ b/.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "select * from otp where user_id = $1 and otp_method = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "otp_method", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "secret_seed", + "type_info": "Bpchar" + }, + { + "ordinal": 4, + "name": "recovery_key", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8" +} diff --git a/.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json b/.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json new file mode 100644 index 0000000..3a8b595 --- /dev/null +++ b/.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Bpchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c" +} diff --git a/Cargo.lock b/Cargo.lock index 8f9bfb4..463013d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.21.7" @@ -431,6 +437,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" + [[package]] name = "cookie" version = "0.18.1" @@ -654,6 +666,7 @@ dependencies = [ "rocket_dyn_templates", "serde_json", "sqlx", + "totp-rs", "uuid", ] @@ -2068,6 +2081,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +[[package]] +name = "qrcodegen-image" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708" +dependencies = [ + "base64 0.22.1", + "image", + "qrcodegen", +] + [[package]] name = "quote" version = "1.0.38" @@ -3110,6 +3134,23 @@ dependencies = [ "winnow", ] +[[package]] +name = "totp-rs" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "qrcodegen-image", + "rand", + "sha1", + "sha2", + "url", + "urlencoding", +] + [[package]] name = "tower-service" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index 687f91b..de6da25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ orion = "0.17" uuid = { version = "1.11", features = ["serde"] } clap = { version = "4.5", features = ["derive"] } qrcode-generator = "5" +totp-rs = { version = "5.6", features = ["gen_secret", "qr"] } diff --git a/assets/site.css b/assets/site.css index 4305a40..04bc48d 100644 --- a/assets/site.css +++ b/assets/site.css @@ -103,6 +103,7 @@ textarea, border-radius: 4pt; padding: 4pt 8pt; background-color: var(--clr-surface-tonal-a10); + font-size: 1em; } input, @@ -123,11 +124,15 @@ input:hover, select:hover, button:hover, textarea:hover, -button:hover, .btn:hover { border-color: var(--clr-primary-a0); } +button:hover, +.btn:hover { + cursor: pointer; +} + textarea { resize: vertical; font-size: 1em; @@ -157,7 +162,8 @@ section { p.form-error, div.form-error, -a.error { +a.error, +button.error { background-color: var(--clr-error-surface); border: 2pt solid var(--clr-error-primary-0); color: var(--clr-error-primary-40); @@ -165,6 +171,15 @@ a.error { padding: .5em 1em; } +a.error:hover, +button.error:hover, +a.error:focus, +button.error:focus, +a.error:active, +button.error:active { + border-color: var(--clr-error-primary-40); +} + p.note { font-size: .8em; } @@ -285,13 +300,15 @@ button.submit { } input#ident, -input#microchip_id { +input#microchip_id, +input.id { font-family: monospace; font-size: 1.6em; box-sizing: content-box; } -input#ident { +input#ident, +input.id { width: 6ch; } @@ -371,6 +388,28 @@ input#ident { font-size: 2em; } +.totp-qrcode>img { + display: block; + margin: 0 auto; + height: 160pt; +} + +.totp-form input { + width: 100%; + text-align: center; + margin-bottom: 1em; +} + +pre.recovery-key { + text-align: center; + background-color: var(--clr-surface-tonal-a10); + border: 2pt solid var(--clr-surface-tonal-a50); + border-radius: 4pt; + font-size: 1.2em; + font-weight: bold; + padding: .5em 2em; +} + @media screen and (max-width: 400px) { header.padded>nav { display: flex; @@ -381,6 +420,10 @@ input#ident { p.subnav { flex-direction: column; } + + .split { + flex-direction: column; + } } @media screen and (max-width: 700px) { @@ -405,4 +448,4 @@ input#ident { div.fields.submit { align-items: center; } -} +} \ No newline at end of file diff --git a/migrations/8_totp.sql b/migrations/8_totp.sql new file mode 100644 index 0000000..6d15a1b --- /dev/null +++ b/migrations/8_totp.sql @@ -0,0 +1,11 @@ +create table otp ( + user_id uuid not null, + created_at timestamptz not null default current_timestamp, + -- enum str on client side + otp_method varchar(32) not null, + -- 160bit base32-encoded key + secret_seed char(32) not null, + recovery_key varchar(512) not null, + primary key (user_id, otp_method), + foreign key (user_id) references users (id) on delete cascade +); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index dc3aabc..98700ca 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,2 +1,3 @@ +pub mod otp; pub mod pw; pub mod session; diff --git a/src/auth/otp.rs b/src/auth/otp.rs new file mode 100644 index 0000000..72936fc --- /dev/null +++ b/src/auth/otp.rs @@ -0,0 +1,53 @@ +use rocket::{ + http::{Cookie, CookieJar}, + time::Duration, +}; +use totp_rs::{Secret, TOTP}; + +const ISSUER: &'static str = "dolltags"; + +fn make_cookie<'a>(value: String) -> Cookie<'a> { + Cookie::build(("otp_init", value)) + .max_age(Duration::minutes(5)) + .http_only(true) + .same_site(rocket::http::SameSite::Strict) + .path("/account/settings") + .build() +} + +/// Creates a TOTP instance using in-code defaults and user OTP settings. +/// i use the internal user account UUID instead of their username to cover the case +/// where a user needs to change their account username. +pub fn make_totp(account_id: &str, secret: Vec) -> Result { + TOTP::new( + totp_rs::Algorithm::SHA1, + 6, + 1, + 30, + secret, + Some(String::from(ISSUER)), + String::from(account_id), + ) +} + +/// Adds the provided OTP secret as encrypted cookie for 5 minutes +pub fn cache_secret<'a>(cookies: &CookieJar<'a>, secret: &'a Secret) { + if let Secret::Encoded(b32) = secret.to_encoded() { + cookies.add_private(make_cookie(b32)); + } +} + +/// Tries to get a potentially existing secret from the cookies. +/// If no cookie is set or if the cookie's value doesn't parse to +/// a valid base32 secret this returns None +pub fn get_secret(cookies: &CookieJar<'_>) -> Option { + cookies.get_private("otp_init").and_then(|cookie| { + let val = cookie.value_trimmed(); + Secret::Encoded(String::from(val)).to_raw().ok() + }) +} + +/// Manually revokes the encrypted cookie +pub fn remove_secret(cookies: &CookieJar<'_>) { + cookies.remove_private(make_cookie(String::from("woof"))) +} diff --git a/src/auth/session.rs b/src/auth/session.rs index feb2de0..54d71af 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -1,10 +1,11 @@ use std::str::FromStr; use rocket::{ - http::{CookieJar, Status}, + http::{Cookie, CookieJar, Status}, outcome::try_outcome, request::{FromRequest, Outcome}, response::Redirect, + time::Duration, Request, }; use rocket_dyn_templates::{context, Template}; @@ -110,6 +111,52 @@ impl<'a> FromRequest<'a> for Admin { } } +/// Creates a 2FA auth session cookie with short lifespan and a bit of enforcing +fn make_2fa_auth_cookie<'a>(value: String) -> Cookie<'a> { + Cookie::build(("2fa_auth", value)) + .max_age(Duration::minutes(5)) + .http_only(true) + .same_site(rocket::http::SameSite::Strict) + .path("/login") + .build() +} + +/// Starts a 2FA auth session for a login, caching the user ID in a temp. short-lived session +pub fn init_2fa_auth(cookies: &CookieJar<'_>, user_id: &Uuid) { + cookies.add_private(make_2fa_auth_cookie(user_id.to_string())); +} + +/// Gets the user id from the currently active auth session, or nothing otherwise +pub fn check_2fa_auth(cookies: &CookieJar<'_>) -> Option { + cookies + .get_private("2fa_auth") + .and_then(|v| Uuid::from_str(&v.value()).ok()) +} + +/// Clears the auth session +pub fn clear_2fa_auth(cookies: &CookieJar<'_>) { + cookies.remove_private(make_2fa_auth_cookie(String::new())); +} + +/// [`AuthSession`] is used as a temporary user ID storage during the login procedures. +/// It retrieves its data from a private cookie from the cookie jar +#[derive(Debug)] +pub struct AuthSession(pub Uuid); + +#[rocket::async_trait] +impl<'a> FromRequest<'a> for AuthSession { + type Error = (); + + async fn from_request(req: &'a Request<'_>) -> Outcome { + let cookies = req.cookies(); + + match check_2fa_auth(cookies) { + Some(v) => Outcome::Success(AuthSession(v)), + None => Outcome::Forward(Status::Unauthorized), + } + } +} + #[catch(401)] pub fn unauthorized(req: &Request) -> Redirect { let next = req.uri().to_string(); diff --git a/src/db/mod.rs b/src/db/mod.rs index d8556c7..0864279 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod doll; pub mod migrate; +pub mod otp; pub mod reseed; pub mod schema; pub mod user; diff --git a/src/db/otp.rs b/src/db/otp.rs new file mode 100644 index 0000000..ecea1af --- /dev/null +++ b/src/db/otp.rs @@ -0,0 +1,88 @@ +use uuid::Uuid; + +use super::schema::{DbHook, OTP}; + +pub const METHOD_TOTP: &'static str = "totp"; + +/// Checks that the provided user has the specified OTP method +pub async fn has_otp(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result { + Ok(sqlx::query!( + "select otp_method from otp where user_id = $1 and otp_method = $2", + id, + method + ) + .fetch_optional(&mut **db) + .await? + .is_some()) +} + +/// Lists the OTP methods the user has enabled +pub async fn list_enabled_methods(db: &mut DbHook, id: &Uuid) -> sqlx::Result> { + sqlx::query_scalar!("select otp_method from otp where user_id = $1", id) + .fetch_all(&mut **db) + .await +} + +/// Gets the requested OTP method config, if set +pub async fn get_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result> { + sqlx::query_as!( + OTP, + "select * from otp where user_id = $1 and otp_method = $2", + id, + method + ) + .fetch_optional(&mut **db) + .await +} + +/// Adds a new otp method with the provided config; will fail without check if one is already set +pub async fn add_otp_method( + db: &mut DbHook, + id: &Uuid, + secret: &str, + hashed_recovery_key: &str, +) -> sqlx::Result<()> { + sqlx::query!( + "insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)", + id, + METHOD_TOTP, + secret, + hashed_recovery_key, + ) + .execute(&mut **db) + .await?; + + Ok(()) +} + +/// Deletes the given OTP method's configuration for the user +pub async fn delete_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<()> { + sqlx::query!( + "delete from otp where user_id = $1 and otp_method = $2", + id, + method + ) + .execute(&mut **db) + .await?; + + Ok(()) +} + +/// Changes the recovery key for the given OTP method; if there was none defined, will do nothing +pub async fn change_recovery_key( + db: &mut DbHook, + id: &Uuid, + otp_method: &str, + hashed_recovery_key: &str, +) -> sqlx::Result<()> { + sqlx::query!( + "update otp set recovery_key = $3 where user_id = $1 and otp_method = $2", + id, + otp_method, + hashed_recovery_key + ) + .execute(&mut **db) + .await?; + + Ok(()) +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 43c5c68..d7d1dc0 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -98,6 +98,18 @@ pub struct User { pub is_admin: bool, } +/// A user's OTP config for a given OTP scheme. +/// WAT? why does it work without a serialize impl.? +#[derive(Debug)] +pub struct OTP { + pub user_id: Uuid, + pub created_at: chrono::DateTime, + + pub otp_method: String, + pub secret_seed: String, + pub recovery_key: String, +} + /// The service status aggregate #[derive(Debug, Serialize)] #[serde(crate = "rocket::serde")] diff --git a/src/ids.rs b/src/ids.rs index 5e9fb1a..ab94b69 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -4,12 +4,32 @@ use rocket::{form, request::FromParam}; use crate::db::{doll, schema::DollTagsDb}; +const SYMBOLS: [char; 36] = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', + 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', +]; + +/// Generate a random recovery key, of length 14 (16 with the two `-`) +/// and format `xxxx-xxxxxx-xxxx` using the charset `a-z0-9` +pub fn generate_recovery_key() -> String { + let uniform = Uniform::new_inclusive::(0, 35); + let mut rng = thread_rng(); + + let first: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect(); + let second: String = (1..=6).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect(); + let third: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect(); + + format!("{}-{}-{}", first, second, third) +} + +/// Generate 10 random doll tags IDs, hoping to have at least 5 of them available pub fn generate_ids() -> Vec { let uniform = Uniform::new_inclusive::(100_000, 999_999); let mut rng = thread_rng(); (1..=10).map(|_| uniform.sample(&mut rng)).collect() } +/// Generates 10 random doll tags IDs and check against the DB to find up to 5 free ones pub async fn pick_ids(mut db: DollTagsDb) -> Result, sqlx::Error> { let mut ids_bundle = generate_ids(); let occupied_ids = doll::check_ids(&mut *db, &ids_bundle).await?; diff --git a/src/main.rs b/src/main.rs index ea1cb18..446d230 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,20 +68,26 @@ fn rocket() -> _ { .mount( "/account", routes![ - account::index, - account::qr_profile, - account::show_settings, - account::change_settings, - account::change_password, + account::common::index, + account::common::qr_profile, + account::settings::show_settings, + account::settings::change_settings, + account::settings::change_password, form::register_tag::show_register, form::register_tag::handle_register, form::register_tag::show_edit_tag, form::register_tag::handle_edit_tag, - account::ask_delete, - account::confirm_delete, - account::ask_terminate_account, - account::confirm_terminate_account, - account::export_data, + account::common::ask_delete, + account::common::confirm_delete, + account::common::ask_terminate_account, + account::common::confirm_terminate_account, + account::common::export_data, + account::otp::show_totp_enable_start, + account::otp::handle_totp_enable_start, + account::otp::show_confirm_totp_regenerate_key, + account::otp::regenerate_key, + account::otp::show_confirm_totp_disable, + account::otp::handle_confirm_totp_disable, ], ) .mount( @@ -105,6 +111,10 @@ fn rocket() -> _ { accounts::show_login, accounts::handle_login, accounts::logout, + accounts::show_2fa_form, + accounts::handle_2fa_form, + accounts::show_2fa_recover_form, + accounts::handle_2fa_recover_form, ], ) } diff --git a/src/routes/account/common.rs b/src/routes/account/common.rs new file mode 100644 index 0000000..44b350b --- /dev/null +++ b/src/routes/account/common.rs @@ -0,0 +1,154 @@ +use std::net::IpAddr; + +use qrcode_generator::QrCodeEcc; +use rocket::{ + http::{uri::Absolute, CookieJar}, + response::Redirect, + serde::{json::Json, Serialize}, + State, +}; +use rocket_dyn_templates::{context, Template}; +use sqlx::Acquire; + +use crate::{ + auth::session, + config::Config, + db::{ + doll, + schema::{DollProfile, DollTagsDb, User}, + user, + }, + pages::CommonTemplateState, + routes::error_handlers::{PageResult, RawResult}, +}; + +#[get("/")] +pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult { + let tags = doll::list(&mut *db, &user.id).await?; + let archived_tags = doll::list_archived(&mut *db, &user.id).await?; + + Ok(Template::render( + "account/index", + context! { + meta, + user, + tags, + archived_tags, + }, + ) + .into()) +} + +#[get("/qr-png/")] +pub fn qr_profile(id: &str, _user: User, config: &State) -> PageResult { + let public_uri = Absolute::parse(&config.public_url)?; + let built_uri = uri!(public_uri, crate::routes::public::short_url(id)); + let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?; + + Ok(image.into()) +} + +#[derive(Debug, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct DataDump { + account: User, + tags: Vec, + reserved_tags: Vec, +} + +#[get("/data_dump")] +pub async fn export_data( + mut db: DollTagsDb, + user: User, + client_ip: IpAddr, +) -> RawResult> { + let tags = doll::list_all(&mut *db, &user.id).await?; + let reserved_tags = doll::list_archived(&mut *db, &user.id).await?; + + warn!( + "[audit|{}] [{}] exported data", + client_ip, + user.id.to_string() + ); + + Ok(Json(DataDump { + account: user, + tags, + reserved_tags, + })) +} + +#[get("/delete/")] +pub async fn ask_delete( + mut db: DollTagsDb, + id: i32, + user: User, + meta: CommonTemplateState, +) -> PageResult { + let db_tag = doll::get(&mut *db, id, "", false).await?; + + if let Some(tag) = db_tag { + if tag.bound_to_id != user.id { + Ok(Redirect::to(uri!("/account", index)).into()) + } else { + Ok(Template::render( + "tag/delete", + context! { + meta, + tag, + }, + ) + .into()) + } + } else { + Ok(Redirect::to(uri!("/account", index)).into()) + } +} + +#[get("/yes_delete_this/")] +pub async fn confirm_delete( + mut db: DollTagsDb, + id: i32, + user: User, + client_ip: IpAddr, +) -> PageResult { + let mut trx = db.begin().await?; + doll::delete(&mut trx, id, &user.id).await?; + trx.commit().await?; + + warn!( + "[audit|{}] [{}] deleted tag {:0>6}", + client_ip, + user.id.to_string(), + id + ); + + Ok(Redirect::to(uri!("/account", index)).into()) +} + +#[get("/terminate")] +pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template { + Template::render("account/terminate", context! {meta}) +} + +#[get("/termin@or")] +pub async fn confirm_terminate_account( + mut db: DollTagsDb, + user: User, + cookies: &CookieJar<'_>, + client_ip: IpAddr, +) -> PageResult { + let mut trx = db.begin().await?; + doll::delete_all_from_account(&mut trx, &user.id).await?; + user::delete(&mut trx, &user.id).await?; + session::logout(cookies); + trx.commit().await?; + + warn!( + "[audit|{}] [{}] deleted account", + client_ip, + user.id.to_string() + ); + + Ok(Redirect::to("/").into()) +} diff --git a/src/routes/account/mod.rs b/src/routes/account/mod.rs new file mode 100644 index 0000000..732325d --- /dev/null +++ b/src/routes/account/mod.rs @@ -0,0 +1,3 @@ +pub mod common; +pub mod otp; +pub mod settings; diff --git a/src/routes/account/otp.rs b/src/routes/account/otp.rs new file mode 100644 index 0000000..71546fb --- /dev/null +++ b/src/routes/account/otp.rs @@ -0,0 +1,210 @@ +use std::net::IpAddr; + +use rocket::{form::Form, http::CookieJar, response::Redirect, tokio::task}; +use rocket_dyn_templates::{context, Template}; +use totp_rs::Secret; + +use crate::{ + auth::{self, pw}, + db::{ + self, + otp::METHOD_TOTP, + schema::{DollTagsDb, User}, + }, + ids::generate_recovery_key, + pages::CommonTemplateState, + routes::{self, error_handlers::PageResult}, +}; + +#[get("/settings/totp?")] +pub async fn show_totp_enable_start( + mut db: DollTagsDb, + invalid_code: bool, + user: User, + cookies: &CookieJar<'_>, + meta: CommonTemplateState, +) -> PageResult { + if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + + // cookie recovery in case it was a form error and not a new session + let mut totp_secret = auth::otp::get_secret(cookies); + if totp_secret.is_none() { + let new_secret = Secret::generate_secret(); + auth::otp::cache_secret(cookies, &new_secret); + totp_secret = Some(new_secret); + } + let totp_secret = totp_secret.unwrap(); + + let totp = auth::otp::make_totp(&user.id.to_string(), totp_secret.to_bytes()?)?; + let totp_qrcode = totp.get_qr_base64()?; + + Ok(Template::render( + "account/otp/start", + context! { + meta, + totp_qrcode, + invalid_code, + secret: totp.get_secret_base32(), + }, + ) + .into()) +} + +#[derive(FromForm)] +pub struct OtpEnableForm { + #[field(validate = len(6..=6))] + pub otp_code: String, +} + +#[post("/settings/totp", data = "
")] +pub async fn handle_totp_enable_start( + mut db: DollTagsDb, + form: Form, + user: User, + cookies: &CookieJar<'_>, + meta: CommonTemplateState, + client_ip: IpAddr, +) -> PageResult { + if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + let secret = match auth::otp::get_secret(cookies) { + Some(v) => v, + None => return Ok(Redirect::to(uri!("/account", show_totp_enable_start(false))).into()), + }; + + let totp = auth::otp::make_totp(&user.id.to_string(), secret.to_bytes()?)?; + + if !totp.check_current(&form.otp_code)? { + return Ok(Redirect::to(uri!("/account", show_totp_enable_start(true))).into()); + } + + warn!( + "[audit|{}] [{}] enabled TOTP 2FA", + client_ip, + user.id.to_string(), + ); + + let recovery_key = generate_recovery_key(); + + { + let recovery_key = recovery_key.clone(); + let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??; + + db::otp::add_otp_method( + &mut *db, + &user.id, + &totp.get_secret_base32(), + &hashed_recovery_key, + ) + .await?; + } + + auth::otp::remove_secret(cookies); + + Ok(Template::render( + "account/otp/confirm", + context! { + meta, + recovery_key, + }, + ) + .into()) +} + +#[get("/settings/totp/generate-key")] +pub async fn show_confirm_totp_regenerate_key( + mut db: DollTagsDb, + user: User, + meta: CommonTemplateState, +) -> PageResult { + if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + + Ok(Template::render( + "account/otp/confirm_regenerate_key", + context! { + meta, + }, + ) + .into()) +} + +#[post("/settings/totp/generate-key")] +pub async fn regenerate_key( + mut db: DollTagsDb, + user: User, + meta: CommonTemplateState, + client_ip: IpAddr, +) -> PageResult { + if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + + warn!( + "[audit|{}] [{}] regenerated TOTP 2FA recovery key", + client_ip, + user.id.to_string(), + ); + + let recovery_key = generate_recovery_key(); + + { + let recovery_key = recovery_key.clone(); + let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??; + + db::otp::change_recovery_key(&mut *db, &user.id, METHOD_TOTP, &hashed_recovery_key).await?; + } + + Ok(Template::render( + "account/otp/regenerate_key", + context! { + recovery_key, + meta, + }, + ) + .into()) +} + +#[get("/settings/totp/disable")] +pub async fn show_confirm_totp_disable( + mut db: DollTagsDb, + user: User, + meta: CommonTemplateState, +) -> PageResult { + if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + + Ok(Template::render( + "account/otp/disable", + context! { + meta, + }, + ) + .into()) +} + +#[post("/settings/totp/disable")] +pub async fn handle_confirm_totp_disable( + mut db: DollTagsDb, + user: User, + client_ip: IpAddr, +) -> PageResult { + if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? { + return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()); + } + + warn!( + "[audit|{}] [{}] deactivated TOTP 2FA", + client_ip, + user.id.to_string(), + ); + + db::otp::delete_otp_method(&mut *db, &user.id, METHOD_TOTP).await?; + + Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into()) +} diff --git a/src/routes/account.rs b/src/routes/account/settings.rs similarity index 56% rename from src/routes/account.rs rename to src/routes/account/settings.rs index 0244ac0..22e5a48 100644 --- a/src/routes/account.rs +++ b/src/routes/account/settings.rs @@ -1,67 +1,42 @@ use std::net::IpAddr; -use qrcode_generator::QrCodeEcc; use rocket::{ form::{self, Contextual, Error, Form}, - http::{uri::Absolute, CookieJar}, response::Redirect, - serde::{json::Json, Serialize}, tokio::task, - State, }; use rocket_dyn_templates::{context, Template}; -use sqlx::Acquire; use crate::{ - auth::{pw, session}, - config::Config, + auth::pw, db::{ - doll, - schema::{DollProfile, DollTagsDb, User}, + otp, + schema::{DollTagsDb, User}, user, }, pages::CommonTemplateState, + routes::error_handlers::PageResult, }; -use super::error_handlers::{PageResult, RawResult}; - -#[get("/")] -pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult { - let tags = doll::list(&mut *db, &user.id).await?; - let archived_tags = doll::list_archived(&mut *db, &user.id).await?; +#[get("/settings")] +pub async fn show_settings( + mut db: DollTagsDb, + user: User, + meta: CommonTemplateState, +) -> PageResult { + let enabled_otp_methods = otp::list_enabled_methods(&mut *db, &user.id).await?; Ok(Template::render( - "account/index", - context! { - meta, - user, - tags, - archived_tags, - }, - ) - .into()) -} - -#[get("/qr-png/")] -pub fn qr_profile(id: &str, _user: User, config: &State) -> PageResult { - let public_uri = Absolute::parse(&config.public_url)?; - let built_uri = uri!(public_uri, crate::routes::public::short_url(id)); - let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?; - - Ok(image.into()) -} - -#[get("/settings")] -pub fn show_settings(user: User, meta: CommonTemplateState) -> Template { - Template::render( "account/settings", context! { user, meta, + enabled_otp_methods, prev_common: form::Context::default(), prev_password: form::Context::default(), }, ) + .into()) } #[derive(FromForm)] @@ -209,108 +184,3 @@ pub async fn change_password( Ok(Redirect::to(uri!("/account", show_settings)).into()) } - -#[derive(Debug, Serialize)] -#[serde(crate = "rocket::serde")] -pub struct DataDump { - account: User, - tags: Vec, - reserved_tags: Vec, -} - -#[get("/data_dump")] -pub async fn export_data( - mut db: DollTagsDb, - user: User, - client_ip: IpAddr, -) -> RawResult> { - let tags = doll::list_all(&mut *db, &user.id).await?; - let reserved_tags = doll::list_archived(&mut *db, &user.id).await?; - - warn!( - "[audit|{}] [{}] exported data", - client_ip, - user.id.to_string() - ); - - Ok(Json(DataDump { - account: user, - tags, - reserved_tags, - })) -} - -#[get("/delete/")] -pub async fn ask_delete( - mut db: DollTagsDb, - id: i32, - user: User, - meta: CommonTemplateState, -) -> PageResult { - let db_tag = doll::get(&mut *db, id, "", false).await?; - - if let Some(tag) = db_tag { - if tag.bound_to_id != user.id { - Ok(Redirect::to(uri!("/account", index)).into()) - } else { - Ok(Template::render( - "tag/delete", - context! { - meta, - tag, - }, - ) - .into()) - } - } else { - Ok(Redirect::to(uri!("/account", index)).into()) - } -} - -#[get("/yes_delete_this/")] -pub async fn confirm_delete( - mut db: DollTagsDb, - id: i32, - user: User, - client_ip: IpAddr, -) -> PageResult { - let mut trx = db.begin().await?; - doll::delete(&mut trx, id, &user.id).await?; - trx.commit().await?; - - warn!( - "[audit|{}] [{}] deleted tag {:0>6}", - client_ip, - user.id.to_string(), - id - ); - - Ok(Redirect::to(uri!("/account", index)).into()) -} - -#[get("/terminate")] -pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template { - Template::render("account/terminate", context! {meta}) -} - -#[get("/termin@or")] -pub async fn confirm_terminate_account( - mut db: DollTagsDb, - user: User, - cookies: &CookieJar<'_>, - client_ip: IpAddr, -) -> PageResult { - let mut trx = db.begin().await?; - doll::delete_all_from_account(&mut trx, &user.id).await?; - user::delete(&mut trx, &user.id).await?; - session::logout(cookies); - trx.commit().await?; - - warn!( - "[audit|{}] [{}] deleted account", - client_ip, - user.id.to_string() - ); - - Ok(Redirect::to("/").into()) -} diff --git a/src/routes/form/accounts.rs b/src/routes/form/accounts.rs index 064194d..2452a20 100644 --- a/src/routes/form/accounts.rs +++ b/src/routes/form/accounts.rs @@ -8,15 +8,24 @@ use rocket::{ tokio::task, }; use rocket_dyn_templates::{context, Template}; +use totp_rs::Secret; use crate::{ - auth::{pw, session}, + auth::{ + otp::make_totp, + pw, + session::{self, AuthSession}, + }, db::{ + otp::{self, METHOD_TOTP}, schema::{DollTagsDb, User}, user, }, pages::CommonTemplateState, - routes::error_handlers::{PageResponse, PageResult}, + routes::{ + self, + error_handlers::{PageResponse, PageResult}, + }, }; #[derive(Debug, FromForm)] @@ -91,17 +100,211 @@ pub async fn handle_login( task::spawn_blocking(move || pw::verify(&password, &user.password)).await??; if right_password && user.enabled { - session::login(cookies, &user.id); + let enabled_methods = otp::list_enabled_methods(&mut *db, &user.id).await?; + + if enabled_methods.len() == 0 { + session::login(cookies, &user.id); + warn!( + "[audit|{}] [{}] login successful ({})", + client_ip, + user.id.to_string(), + &values.username + ); + let next_url = String::from(next.unwrap_or("/")); + Ok(Redirect::to(next_url).into()) + } else { + warn!( + "[audit|{}] [{}] login user credentials validated; 2FA needed ({})", + client_ip, + user.id.to_string(), + &values.username + ); + + // only one impl. for now; flow and UI may change if/when more are added + if enabled_methods[0].contains(METHOD_TOTP) { + session::init_2fa_auth(cookies, &user.id); + + Ok(Redirect::to(uri!(show_2fa_form(next))).into()) + } else { + panic!( + "login - 2FA redirect - {:?} not implemented", + enabled_methods + ); + } + } + } else { + Ok(miss()) + } +} + +#[get("/login/2fa?")] +pub fn show_2fa_form( + maybe_loggedin: Option, + _auth_session: AuthSession, + cookies: &CookieJar<'_>, + next: Option<&str>, + meta: CommonTemplateState, +) -> PageResult { + if maybe_loggedin.is_some() { + let next = String::from(next.unwrap_or("/")); + session::clear_2fa_auth(cookies); + Ok(Redirect::to(next).into()) + } else { + Ok(Template::render( + "account/otp/login", + context! { + recovery_url: uri!(show_2fa_recover_form(next)), + meta, + }, + ) + .into()) + } +} + +#[derive(Debug, FromForm)] +pub struct TOTP2FAForm<'a> { + #[field(validate=len(6..=6))] + pub otp_code: &'a str, +} + +#[post("/login/2fa?", data = "")] +pub async fn handle_2fa_form( + mut db: DollTagsDb, + maybe_loggedin: Option, + auth_session: AuthSession, + next: Option<&str>, + cookies: &CookieJar<'_>, + form: Form>>, + meta: CommonTemplateState, + client_ip: IpAddr, +) -> PageResult { + if maybe_loggedin.is_some() { + let next = String::from(next.unwrap_or("/")); + return Ok(Redirect::to(next).into()); + } + + let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?; + + let (form_data, otp_config) = match (&form.value, otp_config) { + (Some(form), Some(otp)) => (form, otp), + _ => { + return Ok(Template::render( + "account/otp/login", + context! { + recovery_url: uri!(show_2fa_recover_form(next)), + failure: true, + meta, + }, + ) + .into()) + } + }; + + let totp = make_totp( + &auth_session.0.to_string(), + Secret::Encoded(otp_config.secret_seed) + .to_raw()? + .to_bytes()?, + )?; + + if totp.check_current(form_data.otp_code)? { + session::clear_2fa_auth(cookies); + session::login(cookies, &auth_session.0); + warn!( - "[audit|{}] [{}] login successful ({})", + "[audit|{}] [{}] TOTP 2FA login successful", client_ip, - user.id.to_string(), - &values.username + &auth_session.0.to_string() ); let next_url = String::from(next.unwrap_or("/")); Ok(Redirect::to(next_url).into()) } else { - Ok(miss()) + Ok(Template::render( + "account/otp/login", + context! { + recovery_url: uri!(show_2fa_recover_form(next)), + failure: true, + meta, + }, + ) + .into()) + } +} + +#[get("/login/2fa/recover?")] +pub fn show_2fa_recover_form( + maybe_loggedin: Option, + _auth_session: AuthSession, + next: Option<&str>, + meta: CommonTemplateState, +) -> PageResult { + if maybe_loggedin.is_some() { + let next = String::from(next.unwrap_or("/")); + return Ok(Redirect::to(next).into()); + } + + Ok(Template::render("account/otp/recovery_login", context! {meta}).into()) +} + +#[derive(Debug, FromForm)] +pub struct TOTP2FARecoverForm<'a> { + #[field(validate=len(16..=16))] + pub recovery_key: &'a str, +} + +#[post("/login/2fa/recover?", data = "")] +pub async fn handle_2fa_recover_form( + mut db: DollTagsDb, + maybe_loggedin: Option, + auth_session: AuthSession, + cookies: &CookieJar<'_>, + form: Form>>, + next: Option<&str>, + meta: CommonTemplateState, + client_ip: IpAddr, +) -> PageResult { + if maybe_loggedin.is_some() { + let next = String::from(next.unwrap_or("/")); + return Ok(Redirect::to(next).into()); + } + + let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?; + + let (form_data, otp_config) = match (&form.value, otp_config) { + (Some(form), Some(otp)) => (form, otp), + _ => { + return Ok(Template::render( + "account/otp/recovery_login", + context! {failure: true, meta}, + ) + .into()) + } + }; + + let submitted_key = String::from(form_data.recovery_key); + let hashed_key = otp_config.recovery_key; + let right_recovery_key = + task::spawn_blocking(move || pw::verify(&submitted_key, &hashed_key)).await??; + + if right_recovery_key { + warn!( + "[audit|{}] [{}] TOTP 2FA recovery key used - disabling TOTP 2FA", + client_ip, + &auth_session.0.to_string() + ); + + otp::delete_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?; + session::clear_2fa_auth(cookies); + session::login(cookies, &auth_session.0); + + Ok(Redirect::to(uri!( + "/account", + routes::account::settings::show_settings, + "#otp" + )) + .into()) + } else { + Ok(Template::render("account/otp/recovery_login", context! {failure: true, meta}).into()) } } diff --git a/src/routes/form/register_tag.rs b/src/routes/form/register_tag.rs index 8720764..4c73c09 100644 --- a/src/routes/form/register_tag.rs +++ b/src/routes/form/register_tag.rs @@ -85,17 +85,17 @@ pub async fn show_edit_tag( ) -> PageResult { let normalized_id = match id_public_to_db(id) { Some(v) => v, - None => return Ok(Redirect::to(uri!("/account", account::index)).into()), + None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()), }; let tag = match doll::get(&mut *db, normalized_id, "", true).await? { Some(v) => { if v.bound_to_id != user.id { - return Ok(Redirect::to(uri!("/account", account::index)).into()); + return Ok(Redirect::to(uri!("/account", account::common::index)).into()); } v } - None => return Ok(Redirect::to(uri!("/account", account::index)).into()), + None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()), }; Ok(Template::render( @@ -262,7 +262,9 @@ pub async fn handle_edit_tag( meta: CommonTemplateState, ) -> PageResult { let id = match id_public_to_db(id) { - None => return Ok(Redirect::to(uri!("/account", crate::routes::account::index)).into()), + None => { + return Ok(Redirect::to(uri!("/account", crate::routes::account::common::index)).into()) + } Some(v) => v, }; let tag = match tag.value { diff --git a/templates/account/otp/confirm.html.tera b/templates/account/otp/confirm.html.tera new file mode 100644 index 0000000..d0ca81c --- /dev/null +++ b/templates/account/otp/confirm.html.tera @@ -0,0 +1,15 @@ +{% extends "base" %} +{% import "macros/form" as form %} +{% block title %}TOTP enabled - {% endblock title %} +{% block main %} +

TOTP 2FA was successfully enabled.

+ +

+ Before moving on, it's strongly recommended that you save this key somewhere safe as it will be + needed to disable 2FA should you lose access to your 2FA codes. +

+ +
{{recovery_key}}
+ +Finish and go to settings +{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/confirm_regenerate_key.html.tera b/templates/account/otp/confirm_regenerate_key.html.tera new file mode 100644 index 0000000..345f395 --- /dev/null +++ b/templates/account/otp/confirm_regenerate_key.html.tera @@ -0,0 +1,19 @@ +{% extends "base" %} +{% import "macros/form" as form %} +{% block title %}Regenerate your recovery key - {% endblock title %} +{% block main %} +

You're about to regenerate a recovery key.

+ +

+ This will invalidate the current 2FA recovery key and generate a new one + which you'll have to store safely. +

+ +
+ + + + +

Or go back to the settings

+
+{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/disable.html.tera b/templates/account/otp/disable.html.tera new file mode 100644 index 0000000..9f77a54 --- /dev/null +++ b/templates/account/otp/disable.html.tera @@ -0,0 +1,24 @@ +{% extends "base" %} +{% import "macros/form" as form %} +{% block title %}Disable TOTP 2FA - {% endblock title %} +{% block main %} +

You're about to disable TOTP 2FA.

+ +

+ TOTP 2FA is an important additional security measure that comes into play when + your password gets compromised by someone. +

+

+ If you're about to disable it due to having lost your recovery key, know that + you can instead choose to generate a new key. +

+

If you still want to disable TOTP 2FA on your account, click on the red button below.

+ +
+
+ +
+ +

Or go back to the settings

+
+{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/login.html.tera b/templates/account/otp/login.html.tera new file mode 100644 index 0000000..3eec43b --- /dev/null +++ b/templates/account/otp/login.html.tera @@ -0,0 +1,17 @@ +{% extends "base" %} +{% block title %}Second authentication factor - {% endblock title %} +{% block main %} +
+

+ + {% if failure %} +

Couldn't validate this code.

+ {% endif %} + + + + + +

Use your recovery key instead

+
+{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/recovery_login.html.tera b/templates/account/otp/recovery_login.html.tera new file mode 100644 index 0000000..dc77ec0 --- /dev/null +++ b/templates/account/otp/recovery_login.html.tera @@ -0,0 +1,19 @@ +{% extends "base" %} +{% block title %}Second authentication factor - {% endblock title %} +{% block main %} +
+

+ +

Entering your recovery key will log you in and will disable 2FA.

+

You will be brought to the settings page in case you'd want to reconfigure it.

+ + {% if failure %} +

Couldn't validate this key.

+ {% endif %} + + + + +
+{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/regenerate_key.html.tera b/templates/account/otp/regenerate_key.html.tera new file mode 100644 index 0000000..b5ce74c --- /dev/null +++ b/templates/account/otp/regenerate_key.html.tera @@ -0,0 +1,11 @@ +{% extends "base" %} +{% import "macros/form" as form %} +{% block title %}Regenerate your recovery key - {% endblock title %} +{% block main %} +

Here's your new recovery key.

+ +
{{recovery_key}}
+ +

Make sure to save it somewhere safe, it may come in handy.

+

Finish and go back to settings

+{% endblock main %} \ No newline at end of file diff --git a/templates/account/otp/start.html.tera b/templates/account/otp/start.html.tera new file mode 100644 index 0000000..98735de --- /dev/null +++ b/templates/account/otp/start.html.tera @@ -0,0 +1,29 @@ +{% extends "base" %} +{% import "macros/form" as form %} +{% block title %}Adding a one-time password - {% endblock title %} +{% block main %} +

To add a one-time password provider, scan this QrCode with your authenticator app.

+ +
+
+ {{secret}} +

Alternatively, you can copy/paste this code: {{secret}}

+
+ +
+

+ +
+ + {% if invalid_code %} +

The OTP code you sent was invalid, please retry.

+ {% endif %} + + +
+
+
+{% endblock main %} \ No newline at end of file diff --git a/templates/account/settings.html.tera b/templates/account/settings.html.tera index ac20efd..f3916a7 100644 --- a/templates/account/settings.html.tera +++ b/templates/account/settings.html.tera @@ -66,6 +66,29 @@ +
+

Two-factor authentication

+ + {% if "totp" in enabled_otp_methods %} +

You have enabled time-based 2FA, which will prompt you for a code on each login.

+ +

+ You may regenerate a recovery key + in case you lost your current one, + or disable TOTP 2FA altogether. +

+ {% endif %} + + {% if enabled_otp_methods|length == 0 %} +

+ You don't have two-factor auth enabled.
+ You can add one using your authenticator app of choice by clicking below. +

+ + Enable 2FA with an authenticator + {% endif %} +
+

Exporting your data