commit c07c9995eacda130382a65910bd661551849c2fa
parent 2c2276c5bb2fa3a1c8078b1d7ea6185bb769e4e9
Author: Daniel García <dani-garcia@users.noreply.github.com>
Date: Tue, 27 Aug 2019 21:07:41 +0200
Merge pull request #555 from vverst/email-codes
Add Email 2FA login
Diffstat:
13 files changed, 1674 insertions(+), 1064 deletions(-)
diff --git a/src/api/core/two_factor.rs b/src/api/core/two_factor.rs
@@ -1,1037 +0,0 @@
-use data_encoding::{BASE32, BASE64};
-use rocket_contrib::json::Json;
-use serde_json;
-use serde_json::Value;
-
-use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
-use crate::auth::Headers;
-use crate::crypto;
-use crate::db::{
- models::{TwoFactor, TwoFactorType, User},
- DbConn,
-};
-use crate::error::{Error, MapResult};
-
-use rocket::Route;
-
-pub fn routes() -> Vec<Route> {
- routes![
- get_twofactor,
- get_recover,
- recover,
- disable_twofactor,
- disable_twofactor_put,
- generate_authenticator,
- activate_authenticator,
- activate_authenticator_put,
- generate_u2f,
- generate_u2f_challenge,
- activate_u2f,
- activate_u2f_put,
- generate_yubikey,
- activate_yubikey,
- activate_yubikey_put,
- get_duo,
- activate_duo,
- activate_duo_put,
- ]
-}
-
-#[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)
-}
-
-#[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(())
-}
-
-use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
-use u2f::protocol::{Challenge, U2f};
-use u2f::register::Registration;
-
-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());
-}
-
-#[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")
-}
-
-#[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,
-}
-
-use yubico::config::Config;
-use yubico::verify;
-
-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"),
- }
-}
-
-#[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";
-
-use chrono::Utc;
-
-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())
-}
diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs
@@ -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(())
+}
diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs
@@ -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())
+}
diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs
@@ -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");
+ }
+}
diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs
@@ -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)
+}
diff --git a/src/api/core/two_factor/u2f.rs b/src/api/core/two_factor/u2f.rs
@@ -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")
+}
diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs
@@ -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"),
+ }
+}
diff --git a/src/api/identity.rs b/src/api/identity.rs
@@ -1,22 +1,17 @@
+use num_traits::FromPrimitive;
use rocket::request::{Form, FormItems, FromForm};
use rocket::Route;
-
use rocket_contrib::json::Json;
use serde_json::Value;
-use num_traits::FromPrimitive;
-
-use crate::db::models::*;
-use crate::db::DbConn;
-
-use crate::util;
-
+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::db::models::*;
+use crate::db::DbConn;
use crate::mail;
-
+use crate::util;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
@@ -129,6 +124,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
"refresh_token": device.refresh_token,
"Key": user.akey,
"PrivateKey": user.private_key,
+ //"TwoFactorToken": "11122233333444555666777888999"
});
if let Some(token) = twofactor_token {
@@ -189,7 +185,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).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;
@@ -198,10 +197,11 @@ fn twofactor_auth(
let mut remember = data.two_factor_remember.unwrap_or(0);
match TwoFactorType::from_i32(selected_id) {
- Some(TwoFactorType::Authenticator) => _tf::validate_totp_code_str(twofactor_code, &selected_data?)?,
- Some(TwoFactorType::U2f) => _tf::validate_u2f_login(user_uuid, twofactor_code, conn)?,
- Some(TwoFactorType::YubiKey) => _tf::validate_yubikey_login(twofactor_code, &selected_data?)?,
- Some(TwoFactorType::Duo) => _tf::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?,
+ 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(user_uuid, twofactor_code, &selected_data?, conn)?,
Some(TwoFactorType::Remember) => {
match device.twofactor_remember {
@@ -246,7 +246,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::U2f) if CONFIG.domain_set() => {
- let request = two_factor::generate_u2f_login(user_uuid, conn)?;
+ let request = two_factor::u2f::generate_u2f_login(user_uuid, conn)?;
let mut challenge_list = Vec::new();
for key in request.registered_keys {
@@ -271,7 +271,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
None => err!("User does not exist"),
};
- let (signature, host) = two_factor::generate_duo_signature(&email, conn)?;
+ let (signature, host) = duo::generate_duo_signature(&email, conn)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Host": host,
@@ -285,13 +285,26 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
None => err!("No YubiKey devices registered"),
};
- let yubikey_metadata: two_factor::YubikeyMetadata = serde_json::from_str(&twofactor.data)?;
+ let yubikey_metadata: yubikey::YubikeyMetadata = serde_json::from_str(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Nfc": yubikey_metadata.Nfc,
})
}
+ Some(tf_type @ TwoFactorType::Email) => {
+ let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, &conn) {
+ Some(tf) => tf,
+ None => err!("No twofactor email registered"),
+ };
+
+ let email_data = EmailTokenData::from_json(&twofactor.data)?;
+
+ result["TwoFactorProviders2"][provider.to_string()] = json!({
+ "Email": email::obscure_email(&email_data.email),
+ })
+ }
+
_ => {}
}
}
diff --git a/src/config.rs b/src/config.rs
@@ -325,6 +325,18 @@ make_config! {
_duo_akey: Pass, false, option;
},
+ /// Email 2FA Settings
+ email_2fa: _enable_email_2fa {
+ /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured
+ _enable_email_2fa: bool, true, def, true;
+ /// Token number length |> Length of the numbers in an email token. Minimum of 6. Maximum is 19.
+ email_token_size: u32, true, def, 6;
+ /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
+ email_expiration_time: u64, true, def, 600;
+ /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
+ email_attempts_limit: u64, true, def, 3;
+ },
+
/// SMTP Email Settings
smtp: _enable_smtp {
/// Enabled
@@ -375,6 +387,14 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication")
}
+ if cfg.email_token_size < 6 {
+ err!("`EMAIL_TOKEN_SIZE` has a minimum size of 6")
+ }
+
+ if cfg.email_token_size > 19 {
+ err!("`EMAIL_TOKEN_SIZE` has a maximum size of 19")
+ }
+
Ok(())
}
@@ -539,6 +559,7 @@ fn load_templates(path: &str) -> Handlebars {
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/send_org_invite", ".html");
+ reg!("email/twofactor_email", ".html");
reg!("admin/base");
reg!("admin/login");
diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs
@@ -1,5 +1,12 @@
+use diesel;
+use diesel::prelude::*;
use serde_json::Value;
+use crate::api::EmptyResult;
+use crate::db::schema::twofactor;
+use crate::db::DbConn;
+use crate::error::MapResult;
+
use super::User;
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
@@ -28,6 +35,7 @@ pub enum TwoFactorType {
// These are implementation details
U2fRegisterChallenge = 1000,
U2fLoginChallenge = 1001,
+ EmailVerificationChallenge = 1002,
}
/// Local methods
@@ -59,14 +67,6 @@ impl TwoFactor {
}
}
-use crate::db::schema::twofactor;
-use crate::db::DbConn;
-use diesel;
-use diesel::prelude::*;
-
-use crate::api::EmptyResult;
-use crate::error::MapResult;
-
/// Database methods
impl TwoFactor {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
diff --git a/src/mail.rs b/src/mail.rs
@@ -168,6 +168,19 @@ pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, de
send_email(&address, &subject, &body_html, &body_text)
}
+pub fn send_token(address: &str, token: &str) -> EmptyResult {
+
+ let (subject, body_html, body_text) = get_text(
+ "email/twofactor_email",
+ json!({
+ "url": CONFIG.domain(),
+ "token": token,
+ }),
+ )?;
+
+ send_email(&address, &subject, &body_html, &body_text)
+}
+
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
let html = PartBuilder::new()
.body(encode_to_str(body_html))
diff --git a/src/static/templates/email/twofactor_email.hbs b/src/static/templates/email/twofactor_email.hbs
@@ -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>
diff --git a/src/static/templates/email/twofactor_email.html.hbs b/src/static/templates/email/twofactor_email.html.hbs
@@ -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>