committed by
GitHub
13 changed files with 1674 additions and 1064 deletions
File diff suppressed because it is too large
@ -0,0 +1,120 @@ |
|||
use data_encoding::BASE32; |
|||
use rocket::Route; |
|||
use rocket_contrib::json::Json; |
|||
|
|||
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::{ |
|||
models::{TwoFactor, TwoFactorType}, |
|||
DbConn, |
|||
}; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![ |
|||
generate_authenticator, |
|||
activate_authenticator, |
|||
activate_authenticator_put, |
|||
] |
|||
} |
|||
#[post("/two-factor/get-authenticator", data = "<data>")] |
|||
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: PasswordData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let type_ = TwoFactorType::Authenticator as i32; |
|||
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn); |
|||
|
|||
let (enabled, key) = match twofactor { |
|||
Some(tf) => (true, tf.data), |
|||
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20]))), |
|||
}; |
|||
|
|||
Ok(Json(json!({ |
|||
"Enabled": enabled, |
|||
"Key": key, |
|||
"Object": "twoFactorAuthenticator" |
|||
}))) |
|||
} |
|||
|
|||
#[derive(Deserialize, Debug)] |
|||
#[allow(non_snake_case)] |
|||
struct EnableAuthenticatorData { |
|||
MasterPasswordHash: String, |
|||
Key: String, |
|||
Token: NumberOrString, |
|||
} |
|||
|
|||
#[post("/two-factor/authenticator", data = "<data>")] |
|||
fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: EnableAuthenticatorData = data.into_inner().data; |
|||
let password_hash = data.MasterPasswordHash; |
|||
let key = data.Key; |
|||
let token = data.Token.into_i32()? as u64; |
|||
|
|||
let mut user = headers.user; |
|||
|
|||
if !user.check_valid_password(&password_hash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
// Validate key as base32 and 20 bytes length
|
|||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) { |
|||
Ok(decoded) => decoded, |
|||
_ => err!("Invalid totp secret"), |
|||
}; |
|||
|
|||
if decoded_key.len() != 20 { |
|||
err!("Invalid key length") |
|||
} |
|||
|
|||
let type_ = TwoFactorType::Authenticator; |
|||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, key.to_uppercase()); |
|||
|
|||
// Validate the token provided with the key
|
|||
validate_totp_code(token, &twofactor.data)?; |
|||
|
|||
_generate_recover_code(&mut user, &conn); |
|||
twofactor.save(&conn)?; |
|||
|
|||
Ok(Json(json!({ |
|||
"Enabled": true, |
|||
"Key": key, |
|||
"Object": "twoFactorAuthenticator" |
|||
}))) |
|||
} |
|||
|
|||
#[put("/two-factor/authenticator", data = "<data>")] |
|||
fn activate_authenticator_put(data: JsonUpcase<EnableAuthenticatorData>, 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(()) |
|||
} |
@ -0,0 +1,346 @@ |
|||
use chrono::Utc; |
|||
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::crypto; |
|||
use crate::db::{ |
|||
models::{TwoFactor, TwoFactorType, User}, |
|||
DbConn, |
|||
}; |
|||
use crate::error::MapResult; |
|||
use crate::CONFIG; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![ |
|||
get_duo, |
|||
activate_duo, |
|||
activate_duo_put, |
|||
] |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
struct DuoData { |
|||
host: String, |
|||
ik: String, |
|||
sk: String, |
|||
} |
|||
|
|||
impl DuoData { |
|||
fn global() -> Option<Self> { |
|||
match CONFIG.duo_host() { |
|||
Some(host) => Some(Self { |
|||
host, |
|||
ik: CONFIG.duo_ikey().unwrap(), |
|||
sk: CONFIG.duo_skey().unwrap(), |
|||
}), |
|||
None => None, |
|||
} |
|||
} |
|||
fn msg(s: &str) -> Self { |
|||
Self { |
|||
host: s.into(), |
|||
ik: s.into(), |
|||
sk: s.into(), |
|||
} |
|||
} |
|||
fn secret() -> Self { |
|||
Self::msg("<global_secret>") |
|||
} |
|||
fn obscure(self) -> Self { |
|||
let mut host = self.host; |
|||
let mut ik = self.ik; |
|||
let mut sk = self.sk; |
|||
|
|||
let digits = 4; |
|||
let replaced = "************"; |
|||
|
|||
host.replace_range(digits.., replaced); |
|||
ik.replace_range(digits.., replaced); |
|||
sk.replace_range(digits.., replaced); |
|||
|
|||
Self { host, ik, sk } |
|||
} |
|||
} |
|||
|
|||
enum DuoStatus { |
|||
Global(DuoData), |
|||
// Using the global duo config
|
|||
User(DuoData), |
|||
// Using the user's config
|
|||
Disabled(bool), // True if there is a global setting
|
|||
} |
|||
|
|||
impl DuoStatus { |
|||
fn data(self) -> Option<DuoData> { |
|||
match self { |
|||
DuoStatus::Global(data) => Some(data), |
|||
DuoStatus::User(data) => Some(data), |
|||
DuoStatus::Disabled(_) => None, |
|||
} |
|||
} |
|||
} |
|||
|
|||
const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>"; |
|||
|
|||
#[post("/two-factor/get-duo", data = "<data>")] |
|||
fn get_duo(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: PasswordData = data.into_inner().data; |
|||
|
|||
if !headers.user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let data = get_user_duo_data(&headers.user.uuid, &conn); |
|||
|
|||
let (enabled, data) = match data { |
|||
DuoStatus::Global(_) => (true, Some(DuoData::secret())), |
|||
DuoStatus::User(data) => (true, Some(data.obscure())), |
|||
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), |
|||
DuoStatus::Disabled(false) => (false, None), |
|||
}; |
|||
|
|||
let json = if let Some(data) = data { |
|||
json!({ |
|||
"Enabled": enabled, |
|||
"Host": data.host, |
|||
"SecretKey": data.sk, |
|||
"IntegrationKey": data.ik, |
|||
"Object": "twoFactorDuo" |
|||
}) |
|||
} else { |
|||
json!({ |
|||
"Enabled": enabled, |
|||
"Object": "twoFactorDuo" |
|||
}) |
|||
}; |
|||
|
|||
Ok(Json(json)) |
|||
} |
|||
|
|||
#[derive(Deserialize)] |
|||
#[allow(non_snake_case, dead_code)] |
|||
struct EnableDuoData { |
|||
MasterPasswordHash: String, |
|||
Host: String, |
|||
SecretKey: String, |
|||
IntegrationKey: String, |
|||
} |
|||
|
|||
impl From<EnableDuoData> for DuoData { |
|||
fn from(d: EnableDuoData) -> Self { |
|||
Self { |
|||
host: d.Host, |
|||
ik: d.IntegrationKey, |
|||
sk: d.SecretKey, |
|||
} |
|||
} |
|||
} |
|||
|
|||
fn check_duo_fields_custom(data: &EnableDuoData) -> bool { |
|||
fn empty_or_default(s: &str) -> bool { |
|||
let st = s.trim(); |
|||
st.is_empty() || s == DISABLED_MESSAGE_DEFAULT |
|||
} |
|||
|
|||
!empty_or_default(&data.Host) && !empty_or_default(&data.SecretKey) && !empty_or_default(&data.IntegrationKey) |
|||
} |
|||
|
|||
#[post("/two-factor/duo", data = "<data>")] |
|||
fn activate_duo(data: JsonUpcase<EnableDuoData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: EnableDuoData = data.into_inner().data; |
|||
|
|||
if !headers.user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let (data, data_str) = if check_duo_fields_custom(&data) { |
|||
let data_req: DuoData = data.into(); |
|||
let data_str = serde_json::to_string(&data_req)?; |
|||
duo_api_request("GET", "/auth/v2/check", "", &data_req).map_res("Failed to validate Duo credentials")?; |
|||
(data_req.obscure(), data_str) |
|||
} else { |
|||
(DuoData::secret(), String::new()) |
|||
}; |
|||
|
|||
let type_ = TwoFactorType::Duo; |
|||
let twofactor = TwoFactor::new(headers.user.uuid.clone(), type_, data_str); |
|||
twofactor.save(&conn)?; |
|||
|
|||
Ok(Json(json!({ |
|||
"Enabled": true, |
|||
"Host": data.host, |
|||
"SecretKey": data.sk, |
|||
"IntegrationKey": data.ik, |
|||
"Object": "twoFactorDuo" |
|||
}))) |
|||
} |
|||
|
|||
#[put("/two-factor/duo", data = "<data>")] |
|||
fn activate_duo_put(data: JsonUpcase<EnableDuoData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
activate_duo(data, headers, conn) |
|||
} |
|||
|
|||
fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { |
|||
const AGENT: &str = "bitwarden_rs:Duo/1.0 (Rust)"; |
|||
|
|||
use reqwest::{header::*, Client, Method}; |
|||
use std::str::FromStr; |
|||
|
|||
let url = format!("https://{}{}", &data.host, path); |
|||
let date = Utc::now().to_rfc2822(); |
|||
let username = &data.ik; |
|||
let fields = [&date, method, &data.host, path, params]; |
|||
let password = crypto::hmac_sign(&data.sk, &fields.join("\n")); |
|||
|
|||
let m = Method::from_str(method).unwrap_or_default(); |
|||
|
|||
Client::new() |
|||
.request(m, &url) |
|||
.basic_auth(username, Some(password)) |
|||
.header(USER_AGENT, AGENT) |
|||
.header(DATE, date) |
|||
.send()? |
|||
.error_for_status()?; |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
const DUO_EXPIRE: i64 = 300; |
|||
const APP_EXPIRE: i64 = 3600; |
|||
|
|||
const AUTH_PREFIX: &str = "AUTH"; |
|||
const DUO_PREFIX: &str = "TX"; |
|||
const APP_PREFIX: &str = "APP"; |
|||
|
|||
fn get_user_duo_data(uuid: &str, conn: &DbConn) -> DuoStatus { |
|||
let type_ = TwoFactorType::Duo as i32; |
|||
|
|||
// If the user doesn't have an entry, disabled
|
|||
let twofactor = match TwoFactor::find_by_user_and_type(uuid, type_, &conn) { |
|||
Some(t) => t, |
|||
None => return DuoStatus::Disabled(DuoData::global().is_some()), |
|||
}; |
|||
|
|||
// If the user has the required values, we use those
|
|||
if let Ok(data) = serde_json::from_str(&twofactor.data) { |
|||
return DuoStatus::User(data); |
|||
} |
|||
|
|||
// Otherwise, we try to use the globals
|
|||
if let Some(global) = DuoData::global() { |
|||
return DuoStatus::Global(global); |
|||
} |
|||
|
|||
// If there are no globals configured, just disable it
|
|||
DuoStatus::Disabled(false) |
|||
} |
|||
|
|||
// let (ik, sk, ak, host) = get_duo_keys();
|
|||
fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { |
|||
let data = User::find_by_mail(email, &conn) |
|||
.and_then(|u| get_user_duo_data(&u.uuid, &conn).data()) |
|||
.or_else(DuoData::global) |
|||
.map_res("Can't fetch Duo keys")?; |
|||
|
|||
Ok((data.ik, data.sk, CONFIG.get_duo_akey(), data.host)) |
|||
} |
|||
|
|||
pub fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> { |
|||
let now = Utc::now().timestamp(); |
|||
|
|||
let (ik, sk, ak, host) = get_duo_keys_email(email, conn)?; |
|||
|
|||
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE); |
|||
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE); |
|||
|
|||
Ok((format!("{}:{}", duo_sign, app_sign), host)) |
|||
} |
|||
|
|||
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String { |
|||
let val = format!("{}|{}|{}", email, ikey, expire); |
|||
let cookie = format!("{}|{}", prefix, BASE64.encode(val.as_bytes())); |
|||
|
|||
format!("{}|{}", cookie, crypto::hmac_sign(key, &cookie)) |
|||
} |
|||
|
|||
pub fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult { |
|||
let split: Vec<&str> = response.split(':').collect(); |
|||
if split.len() != 2 { |
|||
err!("Invalid response length"); |
|||
} |
|||
|
|||
let auth_sig = split[0]; |
|||
let app_sig = split[1]; |
|||
|
|||
let now = Utc::now().timestamp(); |
|||
|
|||
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn)?; |
|||
|
|||
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?; |
|||
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?; |
|||
|
|||
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) { |
|||
err!("Error validating duo authentication") |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> { |
|||
let split: Vec<&str> = val.split('|').collect(); |
|||
if split.len() != 3 { |
|||
err!("Invalid value length") |
|||
} |
|||
|
|||
let u_prefix = split[0]; |
|||
let u_b64 = split[1]; |
|||
let u_sig = split[2]; |
|||
|
|||
let sig = crypto::hmac_sign(key, &format!("{}|{}", u_prefix, u_b64)); |
|||
|
|||
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) { |
|||
err!("Duo signatures don't match") |
|||
} |
|||
|
|||
if u_prefix != prefix { |
|||
err!("Prefixes don't match") |
|||
} |
|||
|
|||
let cookie_vec = match BASE64.decode(u_b64.as_bytes()) { |
|||
Ok(c) => c, |
|||
Err(_) => err!("Invalid Duo cookie encoding"), |
|||
}; |
|||
|
|||
let cookie = match String::from_utf8(cookie_vec) { |
|||
Ok(c) => c, |
|||
Err(_) => err!("Invalid Duo cookie encoding"), |
|||
}; |
|||
|
|||
let cookie_split: Vec<&str> = cookie.split('|').collect(); |
|||
if cookie_split.len() != 3 { |
|||
err!("Invalid cookie length") |
|||
} |
|||
|
|||
let username = cookie_split[0]; |
|||
let u_ikey = cookie_split[1]; |
|||
let expire = cookie_split[2]; |
|||
|
|||
if !crypto::ct_eq(ikey, u_ikey) { |
|||
err!("Invalid ikey") |
|||
} |
|||
|
|||
let expire = match expire.parse() { |
|||
Ok(e) => e, |
|||
Err(_) => err!("Invalid expire time"), |
|||
}; |
|||
|
|||
if time >= expire { |
|||
err!("Expired authorization") |
|||
} |
|||
|
|||
Ok(username.into()) |
|||
} |
@ -0,0 +1,341 @@ |
|||
use rocket::Route; |
|||
use rocket_contrib::json::Json; |
|||
use serde_json; |
|||
|
|||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData}; |
|||
use crate::auth::Headers; |
|||
use crate::crypto; |
|||
use crate::db::{ |
|||
models::{TwoFactor, TwoFactorType}, |
|||
DbConn, |
|||
}; |
|||
use crate::error::Error; |
|||
use crate::mail; |
|||
use crate::CONFIG; |
|||
|
|||
use chrono::{Duration, NaiveDateTime, Utc}; |
|||
use std::char; |
|||
use std::ops::Add; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![ |
|||
get_email, |
|||
send_email_login, |
|||
send_email, |
|||
email, |
|||
] |
|||
} |
|||
|
|||
#[derive(Deserialize)] |
|||
#[allow(non_snake_case)] |
|||
struct SendEmailLoginData { |
|||
Email: String, |
|||
MasterPasswordHash: String, |
|||
} |
|||
|
|||
/// User is trying to login and wants to use email 2FA.
|
|||
/// Does not require Bearer token
|
|||
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
|||
fn send_email_login(data: JsonUpcase<SendEmailLoginData>, conn: DbConn) -> EmptyResult { |
|||
let data: SendEmailLoginData = data.into_inner().data; |
|||
|
|||
use crate::db::models::User; |
|||
|
|||
// Get the user
|
|||
let user = match User::find_by_mail(&data.Email, &conn) { |
|||
Some(user) => user, |
|||
None => err!("Username or password is incorrect. Try again."), |
|||
}; |
|||
|
|||
// Check password
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Username or password is incorrect. Try again.") |
|||
} |
|||
|
|||
if !CONFIG._enable_email_2fa() { |
|||
err!("Email 2FA is disabled") |
|||
} |
|||
|
|||
let type_ = TwoFactorType::Email as i32; |
|||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?; |
|||
|
|||
let generated_token = generate_token(CONFIG.email_token_size())?; |
|||
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, &twofactor_data.last_token?)?; |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
/// When user clicks on Manage email 2FA show the user the related information
|
|||
#[post("/two-factor/get-email", data = "<data>")] |
|||
fn get_email(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: PasswordData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let type_ = TwoFactorType::Email as i32; |
|||
let enabled = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { |
|||
Some(x) => x.enabled, |
|||
_ => false, |
|||
}; |
|||
|
|||
Ok(Json(json!({ |
|||
"Email": user.email, |
|||
"Enabled": enabled, |
|||
"Object": "twoFactorEmail" |
|||
}))) |
|||
} |
|||
|
|||
#[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, |
|||
MasterPasswordHash: String, |
|||
} |
|||
|
|||
|
|||
fn generate_token(token_size: u32) -> Result<String, Error> { |
|||
if token_size > 19 { |
|||
err!("Generating token failed") |
|||
} |
|||
|
|||
// 8 bytes to create an u64 for up to 19 token digits
|
|||
let bytes = crypto::get_random(vec![0; 8]); |
|||
let mut bytes_array = [0u8; 8]; |
|||
bytes_array.copy_from_slice(&bytes); |
|||
|
|||
let number = u64::from_be_bytes(bytes_array) % 10u64.pow(token_size); |
|||
let token = format!("{:0size$}", number, size = token_size as usize); |
|||
Ok(token) |
|||
} |
|||
|
|||
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
|||
#[post("/two-factor/send-email", data = "<data>")] |
|||
fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult { |
|||
let data: SendEmailData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
if !CONFIG._enable_email_2fa() { |
|||
err!("Email 2FA is disabled") |
|||
} |
|||
|
|||
let type_ = TwoFactorType::Email as i32; |
|||
|
|||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { |
|||
tf.delete(&conn)?; |
|||
} |
|||
|
|||
let generated_token = generate_token(CONFIG.email_token_size())?; |
|||
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( |
|||
user.uuid, |
|||
TwoFactorType::EmailVerificationChallenge, |
|||
twofactor_data.to_json(), |
|||
); |
|||
twofactor.save(&conn)?; |
|||
|
|||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?; |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
#[derive(Deserialize, Serialize)] |
|||
#[allow(non_snake_case)] |
|||
struct EmailData { |
|||
Email: String, |
|||
MasterPasswordHash: String, |
|||
Token: String, |
|||
} |
|||
|
|||
/// Verify email belongs to user and can be used for 2FA email codes.
|
|||
#[put("/two-factor/email", data = "<data>")] |
|||
fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: EmailData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let type_ = TwoFactorType::EmailVerificationChallenge as i32; |
|||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?; |
|||
|
|||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?; |
|||
|
|||
let issued_token = match &email_data.last_token { |
|||
Some(t) => t, |
|||
_ => err!("No token available"), |
|||
}; |
|||
|
|||
if !crypto::ct_eq(issued_token, data.Token) { |
|||
err!("Token is invalid") |
|||
} |
|||
|
|||
email_data.reset_token(); |
|||
twofactor.atype = TwoFactorType::Email as i32; |
|||
twofactor.data = email_data.to_json(); |
|||
twofactor.save(&conn)?; |
|||
|
|||
Ok(Json(json!({ |
|||
"Email": email_data.email, |
|||
"Enabled": "true", |
|||
"Object": "twoFactorEmail" |
|||
}))) |
|||
} |
|||
|
|||
/// 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"), |
|||
}; |
|||
|
|||
if !crypto::ct_eq(issued_token, token) { |
|||
email_data.add_attempt(); |
|||
if email_data.attempts >= CONFIG.email_attempts_limit() { |
|||
email_data.reset_token(); |
|||
} |
|||
twofactor.data = email_data.to_json(); |
|||
twofactor.save(&conn)?; |
|||
|
|||
err!("Token is invalid") |
|||
} |
|||
|
|||
email_data.reset_token(); |
|||
twofactor.data = email_data.to_json(); |
|||
twofactor.save(&conn)?; |
|||
|
|||
let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0); |
|||
let max_time = CONFIG.email_expiration_time() as i64; |
|||
if date.add(Duration::seconds(max_time)) < Utc::now().naive_utc() { |
|||
err!("Token has expired") |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
/// Data stored in the TwoFactor table in the db
|
|||
#[derive(Serialize, Deserialize)] |
|||
pub struct EmailTokenData { |
|||
/// Email address where the token will be sent to. Can be different from account email.
|
|||
pub email: String, |
|||
/// Some(token): last valid token issued that has not been entered.
|
|||
/// None: valid token was used and removed.
|
|||
pub last_token: Option<String>, |
|||
/// UNIX timestamp of token issue.
|
|||
pub token_sent: i64, |
|||
/// Amount of token entry attempts for last_token.
|
|||
pub attempts: u64, |
|||
} |
|||
|
|||
impl EmailTokenData { |
|||
pub fn new(email: String, token: String) -> EmailTokenData { |
|||
EmailTokenData { |
|||
email, |
|||
last_token: Some(token), |
|||
token_sent: Utc::now().naive_utc().timestamp(), |
|||
attempts: 0, |
|||
} |
|||
} |
|||
|
|||
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; |
|||
self.attempts = 0; |
|||
} |
|||
|
|||
pub fn add_attempt(&mut self) { |
|||
self.attempts = self.attempts + 1; |
|||
} |
|||
|
|||
pub fn to_json(&self) -> String { |
|||
serde_json::to_string(&self).unwrap() |
|||
} |
|||
|
|||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> { |
|||
let res: Result<EmailTokenData, crate::serde_json::Error> = serde_json::from_str(&string); |
|||
match res { |
|||
Ok(x) => Ok(x), |
|||
Err(_) => err!("Could not decode EmailTokenData from string"), |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// Takes an email address and obscures it by replacing it with asterisks except two characters.
|
|||
pub fn obscure_email(email: &str) -> String { |
|||
let split: Vec<&str> = email.split("@").collect(); |
|||
|
|||
let mut name = split[0].to_string(); |
|||
let domain = &split[1]; |
|||
|
|||
let name_size = name.chars().count(); |
|||
|
|||
let new_name = match name_size { |
|||
1..=3 => "*".repeat(name_size), |
|||
_ => { |
|||
let stars = "*".repeat(name_size - 2); |
|||
name.truncate(2); |
|||
format!("{}{}", name, stars) |
|||
} |
|||
}; |
|||
|
|||
format!("{}@{}", new_name, &domain) |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
|
|||
#[test] |
|||
fn test_obscure_email_long() { |
|||
let email = "bytes@example.ext"; |
|||
|
|||
let result = obscure_email(&email); |
|||
|
|||
// Only first two characters should be visible.
|
|||
assert_eq!(result, "by***@example.ext"); |
|||
} |
|||
|
|||
#[test] |
|||
fn test_obscure_email_short() { |
|||
let email = "byt@example.ext"; |
|||
|
|||
let result = obscure_email(&email); |
|||
|
|||
// If it's smaller than 3 characters it should only show asterisks.
|
|||
assert_eq!(result, "***@example.ext"); |
|||
} |
|||
|
|||
#[test] |
|||
fn test_token() { |
|||
let result = generate_token(19).unwrap(); |
|||
|
|||
assert_eq!(result.chars().count(), 19); |
|||
} |
|||
|
|||
#[test] |
|||
fn test_token_too_large() { |
|||
let result = generate_token(20); |
|||
|
|||
assert!(result.is_err(), "too large token should give an error"); |
|||
} |
|||
} |
@ -0,0 +1,146 @@ |
|||
use data_encoding::BASE32; |
|||
use rocket::Route; |
|||
use rocket_contrib::json::Json; |
|||
use serde_json; |
|||
use serde_json::Value; |
|||
|
|||
use crate::api::{JsonResult, JsonUpcase, NumberOrString, PasswordData}; |
|||
use crate::auth::Headers; |
|||
use crate::crypto; |
|||
use crate::db::{ |
|||
models::{TwoFactor, User}, |
|||
DbConn, |
|||
}; |
|||
|
|||
pub(crate) mod authenticator; |
|||
pub(crate) mod duo; |
|||
pub(crate) mod email; |
|||
pub(crate) mod u2f; |
|||
pub(crate) mod yubikey; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
let mut routes = routes![ |
|||
get_twofactor, |
|||
get_recover, |
|||
recover, |
|||
disable_twofactor, |
|||
disable_twofactor_put, |
|||
]; |
|||
|
|||
routes.append(&mut authenticator::routes()); |
|||
routes.append(&mut duo::routes()); |
|||
routes.append(&mut email::routes()); |
|||
routes.append(&mut u2f::routes()); |
|||
routes.append(&mut yubikey::routes()); |
|||
|
|||
routes |
|||
} |
|||
|
|||
#[get("/two-factor")] |
|||
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult { |
|||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn); |
|||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_list).collect(); |
|||
|
|||
Ok(Json(json!({ |
|||
"Data": twofactors_json, |
|||
"Object": "list", |
|||
"ContinuationToken": null, |
|||
}))) |
|||
} |
|||
|
|||
#[post("/two-factor/get-recover", data = "<data>")] |
|||
fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult { |
|||
let data: PasswordData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
Ok(Json(json!({ |
|||
"Code": user.totp_recover, |
|||
"Object": "twoFactorRecover" |
|||
}))) |
|||
} |
|||
|
|||
#[derive(Deserialize)] |
|||
#[allow(non_snake_case)] |
|||
struct RecoverTwoFactor { |
|||
MasterPasswordHash: String, |
|||
Email: String, |
|||
RecoveryCode: String, |
|||
} |
|||
|
|||
#[post("/two-factor/recover", data = "<data>")] |
|||
fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult { |
|||
let data: RecoverTwoFactor = data.into_inner().data; |
|||
|
|||
use crate::db::models::User; |
|||
|
|||
// Get the user
|
|||
let mut user = match User::find_by_mail(&data.Email, &conn) { |
|||
Some(user) => user, |
|||
None => err!("Username or password is incorrect. Try again."), |
|||
}; |
|||
|
|||
// Check password
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Username or password is incorrect. Try again.") |
|||
} |
|||
|
|||
// Check if recovery code is correct
|
|||
if !user.check_valid_recovery_code(&data.RecoveryCode) { |
|||
err!("Recovery code is incorrect. Try again.") |
|||
} |
|||
|
|||
// Remove all twofactors from the user
|
|||
TwoFactor::delete_all_by_user(&user.uuid, &conn)?; |
|||
|
|||
// Remove the recovery code, not needed without twofactors
|
|||
user.totp_recover = None; |
|||
user.save(&conn)?; |
|||
Ok(Json(json!({}))) |
|||
} |
|||
|
|||
fn _generate_recover_code(user: &mut User, conn: &DbConn) { |
|||
if user.totp_recover.is_none() { |
|||
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20])); |
|||
user.totp_recover = Some(totp_recover); |
|||
user.save(conn).ok(); |
|||
} |
|||
} |
|||
|
|||
#[derive(Deserialize)] |
|||
#[allow(non_snake_case)] |
|||
struct DisableTwoFactorData { |
|||
MasterPasswordHash: String, |
|||
Type: NumberOrString, |
|||
} |
|||
|
|||
#[post("/two-factor/disable", data = "<data>")] |
|||
fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: DisableTwoFactorData = data.into_inner().data; |
|||
let password_hash = data.MasterPasswordHash; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&password_hash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let type_ = data.Type.into_i32()?; |
|||
|
|||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { |
|||
twofactor.delete(&conn)?; |
|||
} |
|||
|
|||
Ok(Json(json!({ |
|||
"Enabled": false, |
|||
"Type": type_, |
|||
"Object": "twoFactorProvider" |
|||
}))) |
|||
} |
|||
|
|||
#[put("/two-factor/disable", data = "<data>")] |
|||
fn disable_twofactor_put(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
disable_twofactor(data, headers, conn) |
|||
} |
@ -0,0 +1,315 @@ |
|||
use rocket::Route; |
|||
use rocket_contrib::json::Json; |
|||
use serde_json; |
|||
use serde_json::Value; |
|||
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest}; |
|||
use u2f::protocol::{Challenge, U2f}; |
|||
use u2f::register::Registration; |
|||
|
|||
use crate::api::core::two_factor::_generate_recover_code; |
|||
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}; |
|||
use crate::auth::Headers; |
|||
use crate::db::{ |
|||
models::{TwoFactor, TwoFactorType}, |
|||
DbConn, |
|||
}; |
|||
use crate::error::Error; |
|||
use crate::CONFIG; |
|||
|
|||
const U2F_VERSION: &str = "U2F_V2"; |
|||
|
|||
lazy_static! { |
|||
static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain()); |
|||
static ref U2F: U2f = U2f::new(APP_ID.clone()); |
|||
} |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![ |
|||
generate_u2f, |
|||
generate_u2f_challenge, |
|||
activate_u2f, |
|||
activate_u2f_put, |
|||
] |
|||
} |
|||
|
|||
#[post("/two-factor/get-u2f", data = "<data>")] |
|||
fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
if !CONFIG.domain_set() { |
|||
err!("`DOMAIN` environment variable is not set. U2F disabled") |
|||
} |
|||
let data: PasswordData = data.into_inner().data; |
|||
|
|||
if !headers.user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let (enabled, keys) = get_u2f_registrations(&headers.user.uuid, &conn)?; |
|||
let keys_json: Vec<Value> = keys.iter().map(U2FRegistration::to_json).collect(); |
|||
|
|||
Ok(Json(json!({ |
|||
"Enabled": enabled, |
|||
"Keys": keys_json, |
|||
"Object": "twoFactorU2f" |
|||
}))) |
|||
} |
|||
|
|||
#[post("/two-factor/get-u2f-challenge", data = "<data>")] |
|||
fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: PasswordData = data.into_inner().data; |
|||
|
|||
if !headers.user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let _type = TwoFactorType::U2fRegisterChallenge; |
|||
let challenge = _create_u2f_challenge(&headers.user.uuid, _type, &conn).challenge; |
|||
|
|||
Ok(Json(json!({ |
|||
"UserId": headers.user.uuid, |
|||
"AppId": APP_ID.to_string(), |
|||
"Challenge": challenge, |
|||
"Version": U2F_VERSION, |
|||
}))) |
|||
} |
|||
|
|||
#[derive(Deserialize, Debug)] |
|||
#[allow(non_snake_case)] |
|||
struct EnableU2FData { |
|||
Id: NumberOrString, |
|||
// 1..5
|
|||
Name: String, |
|||
MasterPasswordHash: String, |
|||
DeviceResponse: String, |
|||
} |
|||
|
|||
// This struct is referenced from the U2F lib
|
|||
// because it doesn't implement Deserialize
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
#[serde(remote = "Registration")] |
|||
struct RegistrationDef { |
|||
key_handle: Vec<u8>, |
|||
pub_key: Vec<u8>, |
|||
attestation_cert: Option<Vec<u8>>, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
struct U2FRegistration { |
|||
id: i32, |
|||
name: String, |
|||
#[serde(with = "RegistrationDef")] |
|||
reg: Registration, |
|||
counter: u32, |
|||
compromised: bool, |
|||
} |
|||
|
|||
impl U2FRegistration { |
|||
fn to_json(&self) -> Value { |
|||
json!({ |
|||
"Id": self.id, |
|||
"Name": self.name, |
|||
"Compromised": self.compromised, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// This struct is copied from the U2F lib
|
|||
// to add an optional error code
|
|||
#[derive(Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
struct RegisterResponseCopy { |
|||
pub registration_data: String, |
|||
pub version: String, |
|||
pub client_data: String, |
|||
|
|||
pub error_code: Option<NumberOrString>, |
|||
} |
|||
|
|||
impl Into<RegisterResponse> for RegisterResponseCopy { |
|||
fn into(self) -> RegisterResponse { |
|||
RegisterResponse { |
|||
registration_data: self.registration_data, |
|||
version: self.version, |
|||
client_data: self.client_data, |
|||
} |
|||
} |
|||
} |
|||
|
|||
#[post("/two-factor/u2f", data = "<data>")] |
|||
fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: EnableU2FData = data.into_inner().data; |
|||
let mut user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let tf_type = TwoFactorType::U2fRegisterChallenge as i32; |
|||
let tf_challenge = match TwoFactor::find_by_user_and_type(&user.uuid, tf_type, &conn) { |
|||
Some(c) => c, |
|||
None => err!("Can't recover challenge"), |
|||
}; |
|||
|
|||
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?; |
|||
tf_challenge.delete(&conn)?; |
|||
|
|||
let response: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?; |
|||
|
|||
let error_code = response |
|||
.error_code |
|||
.clone() |
|||
.map_or("0".into(), NumberOrString::into_string); |
|||
|
|||
if error_code != "0" { |
|||
err!("Error registering U2F token") |
|||
} |
|||
|
|||
let registration = U2F.register_response(challenge.clone(), response.into())?; |
|||
let full_registration = U2FRegistration { |
|||
id: data.Id.into_i32()?, |
|||
name: data.Name, |
|||
reg: registration, |
|||
compromised: false, |
|||
counter: 0, |
|||
}; |
|||
|
|||
let mut regs = get_u2f_registrations(&user.uuid, &conn)?.1; |
|||
|
|||
// TODO: Check that there is no repeat Id
|
|||
regs.push(full_registration); |
|||
save_u2f_registrations(&user.uuid, ®s, &conn)?; |
|||
|
|||
_generate_recover_code(&mut user, &conn); |
|||
|
|||
let keys_json: Vec<Value> = regs.iter().map(U2FRegistration::to_json).collect(); |
|||
Ok(Json(json!({ |
|||
"Enabled": true, |
|||
"Keys": keys_json, |
|||
"Object": "twoFactorU2f" |
|||
}))) |
|||
} |
|||
|
|||
#[put("/two-factor/u2f", data = "<data>")] |
|||
fn activate_u2f_put(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
activate_u2f(data, headers, conn) |
|||
} |
|||
|
|||
fn _create_u2f_challenge(user_uuid: &str, type_: TwoFactorType, conn: &DbConn) -> Challenge { |
|||
let challenge = U2F.generate_challenge().unwrap(); |
|||
|
|||
TwoFactor::new(user_uuid.into(), type_, serde_json::to_string(&challenge).unwrap()) |
|||
.save(conn) |
|||
.expect("Error saving challenge"); |
|||
|
|||
challenge |
|||
} |
|||
|
|||
fn save_u2f_registrations(user_uuid: &str, regs: &[U2FRegistration], conn: &DbConn) -> EmptyResult { |
|||
TwoFactor::new(user_uuid.into(), TwoFactorType::U2f, serde_json::to_string(regs)?).save(&conn) |
|||
} |
|||
|
|||
fn get_u2f_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<U2FRegistration>), Error> { |
|||
let type_ = TwoFactorType::U2f as i32; |
|||
let (enabled, regs) = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) { |
|||
Some(tf) => (tf.enabled, tf.data), |
|||
None => return Ok((false, Vec::new())), // If no data, return empty list
|
|||
}; |
|||
|
|||
let data = match serde_json::from_str(®s) { |
|||
Ok(d) => d, |
|||
Err(_) => { |
|||
// If error, try old format
|
|||
let mut old_regs = _old_parse_registrations(®s); |
|||
|
|||
if old_regs.len() != 1 { |
|||
err!("The old U2F format only allows one device") |
|||
} |
|||
|
|||
// Convert to new format
|
|||
let new_regs = vec![U2FRegistration { |
|||
id: 1, |
|||
name: "Unnamed U2F key".into(), |
|||
reg: old_regs.remove(0), |
|||
compromised: false, |
|||
counter: 0, |
|||
}]; |
|||
|
|||
// Save new format
|
|||
save_u2f_registrations(user_uuid, &new_regs, &conn)?; |
|||
|
|||
new_regs |
|||
} |
|||
}; |
|||
|
|||
Ok((enabled, data)) |
|||
} |
|||
|
|||
fn _old_parse_registrations(registations: &str) -> Vec<Registration> { |
|||
#[derive(Deserialize)] |
|||
struct Helper(#[serde(with = "RegistrationDef")] Registration); |
|||
|
|||
let regs: Vec<Value> = serde_json::from_str(registations).expect("Can't parse Registration data"); |
|||
|
|||
regs.into_iter() |
|||
.map(|r| serde_json::from_value(r).unwrap()) |
|||
.map(|Helper(r)| r) |
|||
.collect() |
|||
} |
|||
|
|||
pub fn generate_u2f_login(user_uuid: &str, conn: &DbConn) -> ApiResult<U2fSignRequest> { |
|||
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fLoginChallenge, conn); |
|||
|
|||
let registrations: Vec<_> = get_u2f_registrations(user_uuid, conn)? |
|||
.1 |
|||
.into_iter() |
|||
.map(|r| r.reg) |
|||
.collect(); |
|||
|
|||
if registrations.is_empty() { |
|||
err!("No U2F devices registered") |
|||
} |
|||
|
|||
Ok(U2F.sign_request(challenge, registrations)) |
|||
} |
|||
|
|||
pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult { |
|||
let challenge_type = TwoFactorType::U2fLoginChallenge as i32; |
|||
let tf_challenge = TwoFactor::find_by_user_and_type(user_uuid, challenge_type, &conn); |
|||
|
|||
let challenge = match tf_challenge { |
|||
Some(tf_challenge) => { |
|||
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?; |
|||
tf_challenge.delete(&conn)?; |
|||
challenge |
|||
} |
|||
None => err!("Can't recover login challenge"), |
|||
}; |
|||
let response: SignResponse = serde_json::from_str(response)?; |
|||
let mut registrations = get_u2f_registrations(user_uuid, conn)?.1; |
|||
if registrations.is_empty() { |
|||
err!("No U2F devices registered") |
|||
} |
|||
|
|||
for reg in &mut registrations { |
|||
let response = U2F.sign_response(challenge.clone(), reg.reg.clone(), response.clone(), reg.counter); |
|||
match response { |
|||
Ok(new_counter) => { |
|||
reg.counter = new_counter; |
|||
save_u2f_registrations(user_uuid, ®istrations, &conn)?; |
|||
|
|||
return Ok(()); |
|||
} |
|||
Err(u2f::u2ferror::U2fError::CounterTooLow) => { |
|||
reg.compromised = true; |
|||
save_u2f_registrations(user_uuid, ®istrations, &conn)?; |
|||
|
|||
err!("This device might be compromised!"); |
|||
} |
|||
Err(e) => { |
|||
warn!("E {:#}", e); |
|||
// break;
|
|||
} |
|||
} |
|||
} |
|||
err!("error verifying response") |
|||
} |
@ -0,0 +1,194 @@ |
|||
use rocket::Route; |
|||
use rocket_contrib::json::Json; |
|||
use serde_json; |
|||
use serde_json::Value; |
|||
use yubico::config::Config; |
|||
use yubico::verify; |
|||
|
|||
use crate::api::core::two_factor::_generate_recover_code; |
|||
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData}; |
|||
use crate::auth::Headers; |
|||
use crate::db::{ |
|||
models::{TwoFactor, TwoFactorType}, |
|||
DbConn, |
|||
}; |
|||
use crate::error::{Error, MapResult}; |
|||
use crate::CONFIG; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![ |
|||
generate_yubikey, |
|||
activate_yubikey, |
|||
activate_yubikey_put, |
|||
] |
|||
} |
|||
|
|||
#[derive(Deserialize, Debug)] |
|||
#[allow(non_snake_case)] |
|||
struct EnableYubikeyData { |
|||
MasterPasswordHash: String, |
|||
Key1: Option<String>, |
|||
Key2: Option<String>, |
|||
Key3: Option<String>, |
|||
Key4: Option<String>, |
|||
Key5: Option<String>, |
|||
Nfc: bool, |
|||
} |
|||
|
|||
#[derive(Deserialize, Serialize, Debug)] |
|||
#[allow(non_snake_case)] |
|||
pub struct YubikeyMetadata { |
|||
Keys: Vec<String>, |
|||
pub Nfc: bool, |
|||
} |
|||
|
|||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> { |
|||
let data_keys = [&data.Key1, &data.Key2, &data.Key3, &data.Key4, &data.Key5]; |
|||
|
|||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect() |
|||
} |
|||
|
|||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value { |
|||
let mut result = json!({}); |
|||
|
|||
for (i, key) in yubikeys.into_iter().enumerate() { |
|||
result[format!("Key{}", i + 1)] = Value::String(key); |
|||
} |
|||
|
|||
result |
|||
} |
|||
|
|||
fn get_yubico_credentials() -> Result<(String, String), Error> { |
|||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { |
|||
(Some(id), Some(secret)) => Ok((id, secret)), |
|||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"), |
|||
} |
|||
} |
|||
|
|||
fn verify_yubikey_otp(otp: String) -> EmptyResult { |
|||
let (yubico_id, yubico_secret) = get_yubico_credentials()?; |
|||
|
|||
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret); |
|||
|
|||
match CONFIG.yubico_server() { |
|||
Some(server) => verify(otp, config.set_api_hosts(vec![server])), |
|||
None => verify(otp, config), |
|||
} |
|||
.map_res("Failed to verify OTP") |
|||
.and(Ok(())) |
|||
} |
|||
|
|||
#[post("/two-factor/get-yubikey", data = "<data>")] |
|||
fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
// Make sure the credentials are set
|
|||
get_yubico_credentials()?; |
|||
|
|||
let data: PasswordData = data.into_inner().data; |
|||
let user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
let user_uuid = &user.uuid; |
|||
let yubikey_type = TwoFactorType::YubiKey as i32; |
|||
|
|||
let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &conn); |
|||
|
|||
if let Some(r) = r { |
|||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; |
|||
|
|||
let mut result = jsonify_yubikeys(yubikey_metadata.Keys); |
|||
|
|||
result["Enabled"] = Value::Bool(true); |
|||
result["Nfc"] = Value::Bool(yubikey_metadata.Nfc); |
|||
result["Object"] = Value::String("twoFactorU2f".to_owned()); |
|||
|
|||
Ok(Json(result)) |
|||
} else { |
|||
Ok(Json(json!({ |
|||
"Enabled": false, |
|||
"Object": "twoFactorU2f", |
|||
}))) |
|||
} |
|||
} |
|||
|
|||
#[post("/two-factor/yubikey", data = "<data>")] |
|||
fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
let data: EnableYubikeyData = data.into_inner().data; |
|||
let mut user = headers.user; |
|||
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) { |
|||
err!("Invalid password"); |
|||
} |
|||
|
|||
// Check if we already have some data
|
|||
let mut yubikey_data = match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn) { |
|||
Some(data) => data, |
|||
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), |
|||
}; |
|||
|
|||
let yubikeys = parse_yubikeys(&data); |
|||
|
|||
if yubikeys.is_empty() { |
|||
return Ok(Json(json!({ |
|||
"Enabled": false, |
|||
"Object": "twoFactorU2f", |
|||
}))); |
|||
} |
|||
|
|||
// Ensure they are valid OTPs
|
|||
for yubikey in &yubikeys { |
|||
if yubikey.len() == 12 { |
|||
// YubiKey ID
|
|||
continue; |
|||
} |
|||
|
|||
verify_yubikey_otp(yubikey.to_owned()).map_res("Invalid Yubikey OTP provided")?; |
|||
} |
|||
|
|||
let yubikey_ids: Vec<String> = yubikeys.into_iter().map(|x| (&x[..12]).to_owned()).collect(); |
|||
|
|||
let yubikey_metadata = YubikeyMetadata { |
|||
Keys: yubikey_ids, |
|||
Nfc: data.Nfc, |
|||
}; |
|||
|
|||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap(); |
|||
yubikey_data.save(&conn)?; |
|||
|
|||
_generate_recover_code(&mut user, &conn); |
|||
|
|||
let mut result = jsonify_yubikeys(yubikey_metadata.Keys); |
|||
|
|||
result["Enabled"] = Value::Bool(true); |
|||
result["Nfc"] = Value::Bool(yubikey_metadata.Nfc); |
|||
result["Object"] = Value::String("twoFactorU2f".to_owned()); |
|||
|
|||
Ok(Json(result)) |
|||
} |
|||
|
|||
#[put("/two-factor/yubikey", data = "<data>")] |
|||
fn activate_yubikey_put(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult { |
|||
activate_yubikey(data, headers, conn) |
|||
} |
|||
|
|||
pub fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { |
|||
if response.len() != 44 { |
|||
err!("Invalid Yubikey OTP length"); |
|||
} |
|||
|
|||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); |
|||
let response_id = &response[..12]; |
|||
|
|||
if !yubikey_metadata.Keys.contains(&response_id.to_owned()) { |
|||
err!("Given Yubikey is not registered"); |
|||
} |
|||
|
|||
let result = verify_yubikey_otp(response.to_owned()); |
|||
|
|||
match result { |
|||
Ok(_answer) => Ok(()), |
|||
Err(_e) => err!("Failed to verify Yubikey against OTP server"), |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
Your Two-step Login Verification Code |
|||
<!----------------> |
|||
<html> |
|||
<p> |
|||
Your two-step verification code is: <b>{{token}}</b> |
|||
|
|||
Use this code to complete logging in with Bitwarden. |
|||
</p> |
|||
</html> |
@ -0,0 +1,129 @@ |
|||
Your Two-step Login Verification Code |
|||
<!----------------> |
|||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<head> |
|||
<meta name="viewport" content="width=device-width" /> |
|||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> |
|||
<title>Bitwarden_rs</title> |
|||
</head> |
|||
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6"> |
|||
<style type="text/css"> |
|||
body { |
|||
margin: 0; |
|||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; |
|||
box-sizing: border-box; |
|||
font-size: 16px; |
|||
color: #333; |
|||
line-height: 25px; |
|||
-webkit-font-smoothing: antialiased; |
|||
-webkit-text-size-adjust: none; |
|||
} |
|||
body * { |
|||
margin: 0; |
|||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; |
|||
box-sizing: border-box; |
|||
font-size: 16px; |
|||
color: #333; |
|||
line-height: 25px; |
|||
-webkit-font-smoothing: antialiased; |
|||
-webkit-text-size-adjust: none; |
|||
} |
|||
img { |
|||
max-width: 100%; |
|||
border: none; |
|||
} |
|||
body { |
|||
-webkit-font-smoothing: antialiased; |
|||
-webkit-text-size-adjust: none; |
|||
width: 100% !important; |
|||
height: 100%; |
|||
line-height: 25px; |
|||
} |
|||
body { |
|||
background-color: #f6f6f6; |
|||
} |
|||
@media only screen and (max-width: 600px) { |
|||
body { |
|||
padding: 0 !important; |
|||
} |
|||
.container { |
|||
padding: 0 !important; |
|||
width: 100% !important; |
|||
} |
|||
.container-table { |
|||
padding: 0 !important; |
|||
width: 100% !important; |
|||
} |
|||
.content { |
|||
padding: 0 0 10px 0 !important; |
|||
} |
|||
.content-wrap { |
|||
padding: 10px !important; |
|||
} |
|||
.invoice { |
|||
width: 100% !important; |
|||
} |
|||
.main { |
|||
border-right: none !important; |
|||
border-left: none !important; |
|||
border-radius: 0 !important; |
|||
} |
|||
.logo { |
|||
padding-top: 10px !important; |
|||
} |
|||
.footer { |
|||
margin-top: 10px !important; |
|||
} |
|||
.indented { |
|||
padding-left: 10px; |
|||
} |
|||
} |
|||
</style> |
|||
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6"> |
|||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center"> |
|||
<img src="{{url}}/bwrs_images/logo-gray.png" alt="" width="250" height="39" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;" /> |
|||
</td> |
|||
</tr> |
|||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top"> |
|||
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;"> |
|||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top"> |
|||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white"> |
|||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
|||
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top"> |
|||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
|||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
|||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top"> |
|||
Your two-step verification code is: <b>{{token}}</b> |
|||
</td> |
|||
</tr> |
|||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
|||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top"> |
|||
Use this code to complete logging in with Bitwarden. |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;"> |
|||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top"> |
|||
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;"> |
|||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;"> |
|||
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;"><img src="{{url}}/bwrs_images/mail-github.png" alt="GitHub" width="30" height="30" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;" /></a></td> |
|||
</tr> |
|||
</table> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
</body> |
|||
</html> |
Loading…
Reference in new issue