diff --git a/Cargo.lock b/Cargo.lock index 1a677d4f..f616897f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "oath 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)", "percent-encoding 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "quoted_printable 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.19 (registry+https://github.com/rust-lang/crates.io-index)", "ring 0.14.6 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 231c1c2f..bac6eb4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,9 @@ regex = "1.2.0" # URL encoding library percent-encoding = "2.0.0" +# Random +rand = "0.7.0" + [patch.crates-io] # Add support for Timestamp type rmp = { git = 'https://github.com/dani-garcia/msgpack-rust' } diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 5ea2308e..eeb91c46 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -1,18 +1,16 @@ -use data_encoding::{BASE32}; +use data_encoding::BASE32; use rocket::Route; use rocket_contrib::json::Json; -use crate::api::{JsonResult, JsonUpcase, NumberOrString, PasswordData}; -use crate::api::core::two_factor::{_generate_recover_code, totp}; +use crate::api::core::two_factor::_generate_recover_code; +use crate::api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}; use crate::auth::Headers; use crate::crypto; use crate::db::{ - DbConn, models::{TwoFactor, TwoFactorType}, + DbConn, }; -const TOTP_TIME_STEP: u64 = 30; - pub fn routes() -> Vec { routes![ generate_authenticator, @@ -20,7 +18,6 @@ pub fn routes() -> Vec { activate_authenticator_put, ] } - #[post("/two-factor/get-authenticator", data = "")] fn generate_authenticator(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordData = data.into_inner().data; @@ -80,7 +77,7 @@ fn activate_authenticator(data: JsonUpcase, headers: He let twofactor = TwoFactor::new(user.uuid.clone(), type_, key.to_uppercase()); // Validate the token provided with the key - totp::validate_totp_code(token, &twofactor.data)?; + validate_totp_code(token, &twofactor.data)?; _generate_recover_code(&mut user, &conn); twofactor.save(&conn)?; @@ -96,3 +93,28 @@ fn activate_authenticator(data: JsonUpcase, headers: He fn activate_authenticator_put(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { activate_authenticator(data, headers, conn) } + +pub fn validate_totp_code_str(totp_code: &str, secret: &str) -> EmptyResult { + let totp_code: u64 = match totp_code.parse() { + Ok(code) => code, + _ => err!("TOTP code is not a number"), + }; + + validate_totp_code(totp_code, secret) +} + +pub fn validate_totp_code(totp_code: u64, secret: &str) -> EmptyResult { + use oath::{totp_raw_now, HashType}; + + let decoded_secret = match BASE32.decode(secret.as_bytes()) { + Ok(s) => s, + Err(_) => err!("Invalid TOTP secret"), + }; + + let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1); + if generated != totp_code { + err!("Invalid TOTP code"); + } + + Ok(()) +} diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 80d1411f..b291bd11 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -1,18 +1,18 @@ use chrono::Utc; -use data_encoding::{BASE64}; +use data_encoding::BASE64; use rocket::Route; use rocket_contrib::json::Json; use serde_json; use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, PasswordData}; use crate::auth::Headers; -use crate::CONFIG; use crate::crypto; use crate::db::{ - DbConn, models::{TwoFactor, TwoFactorType, User}, + DbConn, }; -use crate::error::{MapResult}; +use crate::error::MapResult; +use crate::CONFIG; pub fn routes() -> Vec { routes![ @@ -71,7 +71,7 @@ enum DuoStatus { // Using the global duo config User(DuoData), // Using the user's config - Disabled(bool), // True if there is a global setting + Disabled(bool), // True if there is a global setting } impl DuoStatus { @@ -343,4 +343,4 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) - } Ok(username.into()) -} \ No newline at end of file +} diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 16d61832..ae5900e9 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -1,23 +1,29 @@ -use data_encoding::{BASE32}; -use oath::{totp_raw_now, HashType}; use rocket::Route; use rocket_contrib::json::Json; use serde_json; -use crate::api::core::two_factor::totp; use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData}; use crate::auth::Headers; use crate::db::{ models::{TwoFactor, TwoFactorType}, DbConn, }; -use crate::error::{Error}; -use crate::{crypto, mail}; +use crate::error::Error; +use crate::mail; +use chrono::{Duration, NaiveDateTime, Utc}; +use rand::Rng; +use std::char; +use std::ops::Add; -const TOTP_TIME_STEP: u64 = 120; +const MAX_TIME_DIFFERENCE: i64 = 600; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email,] + routes![ + get_email, + send_email_login, + send_email, + email, + ] } #[derive(Deserialize)] @@ -27,7 +33,8 @@ struct SendEmailLoginData { MasterPasswordHash: String, } -// Does not require Bearer token +/// User is trying to login and wants to use email 2FA. +/// Does not require Bearer token #[post("/two-factor/send-email-login", data = "")] // JsonResult fn send_email_login(data: JsonUpcase, conn: DbConn) -> EmptyResult { let data: SendEmailLoginData = data.into_inner().data; @@ -46,16 +53,15 @@ fn send_email_login(data: JsonUpcase, conn: DbConn) -> Empty } let type_ = TwoFactorType::Email as i32; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?; - - let twofactor_data = EmailTokenData::from_json(&twofactor.data)?; - - let decoded_key = totp::validate_decode_key(&twofactor_data.totp_secret)?; + let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?; - let generated_token = totp_raw_now(&decoded_key, 6, 0, TOTP_TIME_STEP, &HashType::SHA1); - let token_string = generated_token.to_string(); + let generated_token = generate_token(); + let mut twofactor_data = EmailTokenData::from_json(&twofactor.data)?; + twofactor_data.set_token(generated_token); + twofactor.data = twofactor_data.to_json(); + twofactor.save(&conn)?; - mail::send_token(&twofactor_data.email, &token_string)?; + mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?; Ok(()) } @@ -75,7 +81,7 @@ fn get_email(data: JsonUpcase, headers: Headers, conn: DbConn) -> _ => false, }; - Ok(Json(json!({// TODO check! FIX! + Ok(Json(json!({ "Email": user.email, "Enabled": enabled, "Object": "twoFactorEmail" @@ -85,16 +91,26 @@ fn get_email(data: JsonUpcase, headers: Headers, conn: DbConn) -> #[derive(Deserialize)] #[allow(non_snake_case)] struct SendEmailData { + /// Email where 2FA codes will be sent to, can be different than user email account. Email: String, - // Email where 2FA codes will be sent to, can be different than user email account. MasterPasswordHash: String, } -// Send a verification email to the specified email address to check whether it exists/belongs to user. +fn generate_token() -> String { + const TOKEN_LEN: usize = 6; + let mut rng = rand::thread_rng(); + + (0..TOKEN_LEN) + .map(|_| { + let num = rng.gen_range(0, 9); + char::from_digit(num, 10).unwrap() + }) + .collect() +} + +/// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] fn send_email(data: JsonUpcase, headers: Headers, conn: DbConn) -> EmptyResult { - use oath::{totp_raw_now, HashType}; - let data: SendEmailData = data.into_inner().data; let user = headers.user; @@ -104,16 +120,12 @@ fn send_email(data: JsonUpcase, headers: Headers, conn: DbConn) - let type_ = TwoFactorType::Email as i32; - // TODO: Delete previous email thing. - match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { - Some(tf) => tf.delete(&conn), - _ => Ok(()), - }; - - let secret = crypto::get_random(vec![0u8; 20]); - let base32_secret = BASE32.encode(&secret); + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { + tf.delete(&conn)?; + } - let twofactor_data = EmailTokenData::new(data.Email, base32_secret); + let generated_token = generate_token(); + let twofactor_data = EmailTokenData::new(data.Email, generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new( @@ -123,10 +135,7 @@ fn send_email(data: JsonUpcase, headers: Headers, conn: DbConn) - ); twofactor.save(&conn)?; - let generated_token = totp_raw_now(&secret, 6, 0, TOTP_TIME_STEP, &HashType::SHA1); - let token_string = generated_token.to_string(); - - mail::send_token(&twofactor_data.email, &token_string)?; + mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?; Ok(()) } @@ -139,7 +148,7 @@ struct EmailData { Token: String, } -// Verify email used for 2FA email codes. +/// Verify email belongs to user and can be used for 2FA email codes. #[put("/two-factor/email", data = "")] fn email(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { let data: EmailData = data.into_inner().data; @@ -149,19 +158,23 @@ fn email(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonRes err!("Invalid password"); } - let token_u64 = match data.Token.parse::() { - Ok(token) => token, - _ => err!("Could not parse token"), - }; - let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?; - let email_data = EmailTokenData::from_json(&twofactor.data)?; + let mut email_data = EmailTokenData::from_json(&twofactor.data)?; + + let issued_token = match &email_data.last_token { + Some(t) => t, + _ => err!("No token available"), + }; - totp::validate_totp_code_with_time_step(token_u64, &email_data.totp_secret, TOTP_TIME_STEP)?; + if issued_token != &data.Token { + err!("Email token does not match") + } + email_data.reset_token(); twofactor.atype = TwoFactorType::Email as i32; + twofactor.data = email_data.to_json(); twofactor.save(&conn)?; Ok(Json(json!({ @@ -171,26 +184,26 @@ fn email(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonRes }))) } -pub fn validate_email_code_str(code: &str, data: &str) -> EmptyResult { - let totp_code: u64 = match code.parse() { - Ok(code) => code, - _ => err!("Email code is not a number"), +/// Validate the email code when used as TwoFactor token mechanism +pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &DbConn) -> EmptyResult { + let mut email_data = EmailTokenData::from_json(&data)?; + let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn)?; + let issued_token = match &email_data.last_token { + Some(t) => t, + _ => err!("No token available"), }; - validate_email_code(totp_code, data) -} - -pub fn validate_email_code(code: u64, data: &str) -> EmptyResult { - let email_data = EmailTokenData::from_json(&data)?; + if issued_token != &*token { + err!("Email token does not match") + } - let decoded_secret = match BASE32.decode(email_data.totp_secret.as_bytes()) { - Ok(s) => s, - Err(_) => err!("Invalid email secret"), - }; + email_data.reset_token(); + twofactor.data = email_data.to_json(); + twofactor.save(&conn)?; - let generated = totp_raw_now(&decoded_secret, 6, 0, TOTP_TIME_STEP, &HashType::SHA1); - if generated != code { - err!("Invalid email code"); + let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0); + if date.add(Duration::seconds(MAX_TIME_DIFFERENCE)) < Utc::now().naive_utc() { + err!("Email token too old") } Ok(()) @@ -199,17 +212,28 @@ pub fn validate_email_code(code: u64, data: &str) -> EmptyResult { #[derive(Serialize, Deserialize)] pub struct EmailTokenData { pub email: String, - pub totp_secret: String, + pub last_token: Option, + pub token_sent: i64, } impl EmailTokenData { - pub fn new(email: String, totp_secret: String) -> EmailTokenData { + pub fn new(email: String, token: String) -> EmailTokenData { EmailTokenData { email, - totp_secret, + last_token: Some(token), + token_sent: Utc::now().naive_utc().timestamp(), } } + pub fn set_token(&mut self, token: String) { + self.last_token = Some(token); + self.token_sent = Utc::now().naive_utc().timestamp(); + } + + pub fn reset_token(&mut self) { + self.last_token = None; + } + pub fn to_json(&self) -> String { serde_json::to_string(&self).unwrap() } @@ -235,7 +259,7 @@ pub fn obscure_email(email: &str) -> String { let new_name = match name_size { 1..=3 => "*".repeat(name_size), _ => { - let stars = "*".repeat(name_size-2); + let stars = "*".repeat(name_size - 2); name.truncate(2); format!("{}{}", name, stars) } diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 6190fc20..7a53d9c8 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -1,4 +1,4 @@ -use data_encoding::{BASE32}; +use data_encoding::BASE32; use rocket::Route; use rocket_contrib::json::Json; use serde_json; @@ -8,8 +8,8 @@ use crate::api::{JsonResult, JsonUpcase, NumberOrString, PasswordData}; use crate::auth::Headers; use crate::crypto; use crate::db::{ - DbConn, models::{TwoFactor, User}, + DbConn, }; pub(crate) mod authenticator; @@ -17,7 +17,6 @@ pub(crate) mod duo; pub(crate) mod email; pub(crate) mod u2f; pub(crate) mod yubikey; -pub(crate) mod totp; pub fn routes() -> Vec { let mut routes = routes![ diff --git a/src/api/core/two_factor/totp.rs b/src/api/core/two_factor/totp.rs deleted file mode 100644 index 1e2ce361..00000000 --- a/src/api/core/two_factor/totp.rs +++ /dev/null @@ -1,46 +0,0 @@ -use data_encoding::BASE32; - -use crate::api::EmptyResult; - -pub fn validate_totp_code_str(totp_code: &str, secret: &str) -> EmptyResult { - let totp_code: u64 = match totp_code.parse() { - Ok(code) => code, - _ => err!("TOTP code is not a number"), - }; - - validate_totp_code(totp_code, secret) -} - -pub fn validate_totp_code(totp_code: u64, secret: &str) -> EmptyResult { - validate_totp_code_with_time_step(totp_code, &secret, 30) -} - -pub fn validate_totp_code_with_time_step(totp_code: u64, secret: &str, time_step: u64) -> EmptyResult { - use oath::{totp_raw_now, HashType}; - - let decoded_secret = match BASE32.decode(secret.as_bytes()) { - Ok(s) => s, - Err(_) => err!("Invalid TOTP secret"), - }; - - let generated = totp_raw_now(&decoded_secret, 6, 0, time_step, &HashType::SHA1); - if generated != totp_code { - err!("Invalid TOTP code"); - } - - Ok(()) -} - -pub fn validate_decode_key(key: &str) -> Result, crate::error::Error> { - // Validate key as base32 and 20 bytes length - let decoded_key: Vec = match BASE32.decode(key.as_bytes()) { - Ok(decoded) => decoded, - _ => err!("Invalid totp secret"), - }; - - if decoded_key.len() != 20 { - err!("Invalid key length") - } - - Ok(decoded_key) -} \ No newline at end of file diff --git a/src/api/core/two_factor/u2f.rs b/src/api/core/two_factor/u2f.rs index 0bf9cfb7..5ccf2914 100644 --- a/src/api/core/two_factor/u2f.rs +++ b/src/api/core/two_factor/u2f.rs @@ -6,15 +6,15 @@ use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest}; use u2f::protocol::{Challenge, U2f}; use u2f::register::Registration; -use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}; use crate::api::core::two_factor::_generate_recover_code; +use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}; use crate::auth::Headers; -use crate::CONFIG; use crate::db::{ - DbConn, models::{TwoFactor, TwoFactorType}, + DbConn, }; -use crate::error::{Error}; +use crate::error::Error; +use crate::CONFIG; const U2F_VERSION: &str = "U2F_V2"; diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index a7b91de3..3fef249d 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -16,7 +16,11 @@ use crate::error::{Error, MapResult}; use crate::CONFIG; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] + routes![ + generate_yubikey, + activate_yubikey, + activate_yubikey_put, + ] } #[derive(Deserialize, Debug)] diff --git a/src/api/identity.rs b/src/api/identity.rs index b6163625..7e60eb3b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -4,15 +4,15 @@ use rocket::Route; use rocket_contrib::json::Json; use serde_json::Value; -use crate::api::{ApiResult, EmptyResult, JsonResult}; -use crate::api::core::two_factor::{duo, email, yubikey}; use crate::api::core::two_factor::email::EmailTokenData; +use crate::api::core::two_factor::{duo, email, yubikey}; +use crate::api::{ApiResult, EmptyResult, JsonResult}; use crate::auth::ClientIp; -use crate::CONFIG; -use crate::db::DbConn; use crate::db::models::*; +use crate::db::DbConn; use crate::mail; use crate::util; +use crate::CONFIG; pub fn routes() -> Vec { routes![login] @@ -179,7 +179,10 @@ fn twofactor_auth( None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?), }; - let selected_twofactor = twofactors.into_iter().filter(|tf| tf.atype == selected_id && tf.enabled).nth(0); + let selected_twofactor = twofactors + .into_iter() + .filter(|tf| tf.atype == selected_id && tf.enabled) + .nth(0); use crate::api::core::two_factor as _tf; use crate::crypto::ct_eq; @@ -188,11 +191,11 @@ fn twofactor_auth( let mut remember = data.two_factor_remember.unwrap_or(0); match TwoFactorType::from_i32(selected_id) { - Some(TwoFactorType::Authenticator) => _tf::totp::validate_totp_code_str(twofactor_code, &selected_data?)?, + Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(twofactor_code, &selected_data?)?, Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?, Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?, Some(TwoFactorType::Duo) => _tf::duo::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?, - Some(TwoFactorType::Email) => _tf::email::validate_email_code_str(twofactor_code, &selected_data?)?, + Some(TwoFactorType::Email) => _tf::email::validate_email_code_str(user_uuid, twofactor_code, &selected_data?, conn)?, Some(TwoFactorType::Remember) => { match device.twofactor_remember { diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index a390e219..233d5312 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -3,8 +3,8 @@ use diesel::prelude::*; use serde_json::Value; use crate::api::EmptyResult; -use crate::db::DbConn; use crate::db::schema::twofactor; +use crate::db::DbConn; use crate::error::MapResult; use super::User; @@ -36,7 +36,6 @@ pub enum TwoFactorType { U2fRegisterChallenge = 1000, U2fLoginChallenge = 1001, EmailVerificationChallenge = 1002, - } /// Local methods