diff --git a/crates/ezidam/src/icons.rs b/crates/ezidam/src/icons.rs index 2a27661..7834370 100644 --- a/crates/ezidam/src/icons.rs +++ b/crates/ezidam/src/icons.rs @@ -44,7 +44,8 @@ impl Icon { "paperclip-large", PaperclipLarge, r#""#, "users", Users, r#""#, "mail", Mail, r#""#, - "password", Password, r#""# + "password", Password, r#""#, + "2fa-large", TwoFaLarge, r#""# } } @@ -70,6 +71,7 @@ pub fn icons_to_templates(tera: &mut Tera) { Icon::Users, Icon::Mail, Icon::Password, + Icon::TwoFaLarge, ]; // For each icon, it will output: ("icons/name", "...") diff --git a/crates/ezidam/src/routes/oauth/totp.rs b/crates/ezidam/src/routes/oauth/totp.rs index 26655b9..d4afd7e 100644 --- a/crates/ezidam/src/routes/oauth/totp.rs +++ b/crates/ezidam/src/routes/oauth/totp.rs @@ -2,7 +2,7 @@ use crate::routes::oauth::{redirect_uri, AUTHORIZATION_CODE_LEN}; use crate::routes::prelude::*; use apps::App; use authorization_codes::AuthorizationCode; -use hash::SecretString; +use hash::{Secret, SecretString}; use rocket::http::{Cookie, CookieJar}; use rocket::{get, post}; use users::totp_login_request::TOTP_REQUEST_COOKIE_NAME; @@ -108,16 +108,36 @@ pub async fn totp_verify( .totp_secret() .ok_or_else(|| Error::bad_request("TOTP is not enabled for user"))?; - // Create totp - let totp = totp::new(totp_secret, None, user.username().to_string())?; + let (check_totp, delete_totp_backup) = match user.totp_backup_hashed().map(Secret::from_hash) { + Some(totp_backup) => { + let input_code = form.code.to_string(); - // Verify totp code - if !totp.check_current(form.code)? { - return Ok(Either::Right(Flash::new( - Redirect::to(uri!(totp_page(auth_request))), - FlashKind::Danger, - "Wrong code. Please try again.", - ))); + let totp_backup_matches = + task::spawn_blocking(move || totp_backup.compare(&input_code)).await??; + + if totp_backup_matches { + // Don't check totp, delete backup + (false, true) + } else { + // Check totp (since the check failed), do not delete backup + (true, false) + } + } + None => (true, false), + }; + + if check_totp { + // Create totp + let totp = totp::new(totp_secret, None, user.username().to_string())?; + + // Verify totp code + if !totp.check_current(form.code)? { + return Ok(Either::Right(Flash::new( + Redirect::to(uri!(totp_page(auth_request))), + FlashKind::Danger, + "Wrong code. Please try again.", + ))); + } } // Generate authorization code @@ -131,6 +151,11 @@ pub async fn totp_verify( // Mark totp token as used totp_request.use_code(&mut transaction).await?; + // Delete totp backup if it got used + if delete_totp_backup { + user.set_totp_backup(&mut transaction, None).await?; + } + transaction.commit().await?; // Delete cookie diff --git a/crates/ezidam/src/routes/settings.rs b/crates/ezidam/src/routes/settings.rs index a716148..c4e3065 100644 --- a/crates/ezidam/src/routes/settings.rs +++ b/crates/ezidam/src/routes/settings.rs @@ -19,6 +19,7 @@ pub fn routes() -> Vec { user_settings_security_password, user_settings_security_totp, user_settings_security_totp_form, + user_settings_security_totp_backup, user_settings_visual, ] } diff --git a/crates/ezidam/src/routes/settings/security.rs b/crates/ezidam/src/routes/settings/security.rs index 5564121..6b52990 100644 --- a/crates/ezidam/src/routes/settings/security.rs +++ b/crates/ezidam/src/routes/settings/security.rs @@ -4,7 +4,7 @@ use crate::tokens::{ JWT_COOKIE_NAME, JWT_DURATION_MINUTES, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_DURATION_DAYS, }; use apps::App; -use hash::PaperKey; +use hash::{PaperKey, Secret, SecretString}; use jwt::database::Key; use jwt::PrivateKey; use refresh_tokens::RefreshToken; @@ -402,3 +402,63 @@ pub async fn user_settings_security_totp_form( "One-time password has been generated.", )) } + +#[derive(Debug, FromForm)] +pub struct TotpBackupForm { + pub generate_totp_backup: bool, +} + +#[post("/settings/security/totp_backup", data = "
")] +pub async fn user_settings_security_totp_backup( + mut db: Connection, + jwt_user: JwtUser, + form: Form, +) -> Result> { + let (flash_kind, flash_message) = if form.generate_totp_backup { + let mut transaction = db.begin().await?; + + // Get user info + let user = User::get_by_id(&mut transaction, &UserID(jwt_user.0.subject)) + .await? + .ok_or_else(|| Error::not_found("Could not find user"))?; + + transaction.commit().await?; + + if user.totp_secret().is_some() { + // Create TOTP backup code + let backup_code = task::spawn_blocking(|| SecretString::new_digits(8)).await?; + let plain_backup_code = backup_code.to_string(); + + let secret = task::spawn_blocking(|| Secret::new(backup_code)).await??; + + let mut transaction = db.begin().await?; + + // Save backup code + user.set_totp_backup(&mut transaction, Some(&secret)) + .await?; + + transaction.commit().await?; + + ( + FlashKind::Success, + format!( + "Your backup code has been generated. It will only be shown once!\ +
{plain_backup_code}
" + ), + ) + } else { + ( + FlashKind::Warning, + "Enable TOTP first to get backup code.".into(), + ) + } + } else { + (FlashKind::Warning, "Nothing to do.".into()) + }; + + Ok(Flash::new( + Redirect::to(uri!(user_settings_security)), + flash_kind, + flash_message, + )) +} diff --git a/crates/ezidam/templates/pages/settings/security.html.tera b/crates/ezidam/templates/pages/settings/security.html.tera index f477bda..b72fa4e 100644 --- a/crates/ezidam/templates/pages/settings/security.html.tera +++ b/crates/ezidam/templates/pages/settings/security.html.tera @@ -58,9 +58,17 @@ Protect your account by requiring an additional code when you log in.

{% if totp_enabled %} - - Disable OTP - + + {% else %}
+ + + {% endblock content %} diff --git a/crates/hash/src/secret.rs b/crates/hash/src/secret.rs index d05337b..d0408ac 100644 --- a/crates/hash/src/secret.rs +++ b/crates/hash/src/secret.rs @@ -1,7 +1,7 @@ use crate::error::Error; use crate::hash::{hash, Hash}; use nanoid::nanoid; -use nanoid_dictionary::ALPHANUMERIC; +use nanoid_dictionary::{ALPHANUMERIC, NUMBERS}; use std::fmt::{Display, Formatter}; // Struct to generate the secret @@ -10,6 +10,9 @@ impl SecretString { pub fn new(length: usize) -> Self { Self(nanoid!(length, ALPHANUMERIC)) } + pub fn new_digits(length: usize) -> Self { + Self(nanoid!(length, NUMBERS)) + } } impl Default for SecretString { fn default() -> Self { diff --git a/crates/users/src/database.rs b/crates/users/src/database.rs index 58ab124..2befd51 100644 --- a/crates/users/src/database.rs +++ b/crates/users/src/database.rs @@ -5,7 +5,7 @@ use database::sqlx::SqliteExecutor; use database::Error as DatabaseError; use database::Users as DatabaseUsers; use email_address::EmailAddress; -use hash::{PaperKey, Password}; +use hash::{PaperKey, Password, Secret}; use id::UserID; use std::str::FromStr; @@ -244,8 +244,10 @@ impl User { pub async fn set_totp_backup( &self, conn: impl SqliteExecutor<'_>, - backup: Option<&str>, + backup: Option<&Secret>, ) -> Result<(), Error> { + let backup = backup.map(|backup| backup.hash()); + DatabaseUsers::set_totp_backup(conn, self.id.as_ref(), backup).await?; Ok(()) diff --git a/crates/users/src/lib.rs b/crates/users/src/lib.rs index 1247cd2..e839559 100644 --- a/crates/users/src/lib.rs +++ b/crates/users/src/lib.rs @@ -61,4 +61,7 @@ impl User { pub fn totp_secret(&self) -> Option> { self.totp_secret.clone() } + pub fn totp_backup_hashed(&self) -> Option<&str> { + self.totp_backup.as_deref() + } }