Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-26 05:50:29 +01:00
Commit
c07c9995ea
13 geänderte Dateien mit 1674 neuen und 1064 gelöschten Zeilen
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
120
src/api/core/two_factor/authenticator.rs
Normale Datei
120
src/api/core/two_factor/authenticator.rs
Normale Datei
|
@ -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(())
|
||||||
|
}
|
346
src/api/core/two_factor/duo.rs
Normale Datei
346
src/api/core/two_factor/duo.rs
Normale Datei
|
@ -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())
|
||||||
|
}
|
341
src/api/core/two_factor/email.rs
Normale Datei
341
src/api/core/two_factor/email.rs
Normale Datei
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
146
src/api/core/two_factor/mod.rs
Normale Datei
146
src/api/core/two_factor/mod.rs
Normale Datei
|
@ -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)
|
||||||
|
}
|
315
src/api/core/two_factor/u2f.rs
Normale Datei
315
src/api/core/two_factor/u2f.rs
Normale Datei
|
@ -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")
|
||||||
|
}
|
194
src/api/core/two_factor/yubikey.rs
Normale Datei
194
src/api/core/two_factor/yubikey.rs
Normale Datei
|
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,17 @@
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
use rocket::request::{Form, FormItems, FromForm};
|
use rocket::request::{Form, FormItems, FromForm};
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
|
|
||||||
use rocket_contrib::json::Json;
|
use rocket_contrib::json::Json;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use num_traits::FromPrimitive;
|
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::models::*;
|
||||||
use crate::db::DbConn;
|
use crate::db::DbConn;
|
||||||
|
|
||||||
use crate::util;
|
|
||||||
|
|
||||||
use crate::api::{ApiResult, EmptyResult, JsonResult};
|
|
||||||
|
|
||||||
use crate::auth::ClientIp;
|
|
||||||
|
|
||||||
use crate::mail;
|
use crate::mail;
|
||||||
|
use crate::util;
|
||||||
use crate::CONFIG;
|
use crate::CONFIG;
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
|
@ -129,6 +124,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
|
||||||
"refresh_token": device.refresh_token,
|
"refresh_token": device.refresh_token,
|
||||||
"Key": user.akey,
|
"Key": user.akey,
|
||||||
"PrivateKey": user.private_key,
|
"PrivateKey": user.private_key,
|
||||||
|
//"TwoFactorToken": "11122233333444555666777888999"
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(token) = twofactor_token {
|
if let Some(token) = twofactor_token {
|
||||||
|
@ -189,7 +185,10 @@ fn twofactor_auth(
|
||||||
None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?),
|
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::api::core::two_factor as _tf;
|
||||||
use crate::crypto::ct_eq;
|
use crate::crypto::ct_eq;
|
||||||
|
@ -198,10 +197,11 @@ fn twofactor_auth(
|
||||||
let mut remember = data.two_factor_remember.unwrap_or(0);
|
let mut remember = data.two_factor_remember.unwrap_or(0);
|
||||||
|
|
||||||
match TwoFactorType::from_i32(selected_id) {
|
match TwoFactorType::from_i32(selected_id) {
|
||||||
Some(TwoFactorType::Authenticator) => _tf::validate_totp_code_str(twofactor_code, &selected_data?)?,
|
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(twofactor_code, &selected_data?)?,
|
||||||
Some(TwoFactorType::U2f) => _tf::validate_u2f_login(user_uuid, twofactor_code, conn)?,
|
Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?,
|
||||||
Some(TwoFactorType::YubiKey) => _tf::validate_yubikey_login(twofactor_code, &selected_data?)?,
|
Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?,
|
||||||
Some(TwoFactorType::Duo) => _tf::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?,
|
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) => {
|
Some(TwoFactorType::Remember) => {
|
||||||
match device.twofactor_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::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||||
|
|
||||||
Some(TwoFactorType::U2f) if CONFIG.domain_set() => {
|
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();
|
let mut challenge_list = Vec::new();
|
||||||
|
|
||||||
for key in request.registered_keys {
|
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"),
|
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!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Host": host,
|
"Host": host,
|
||||||
|
@ -285,13 +285,26 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
|
||||||
None => err!("No YubiKey devices registered"),
|
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!({
|
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||||
"Nfc": yubikey_metadata.Nfc,
|
"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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -325,6 +325,18 @@ make_config! {
|
||||||
_duo_akey: Pass, false, option;
|
_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 Email Settings
|
||||||
smtp: _enable_smtp {
|
smtp: _enable_smtp {
|
||||||
/// Enabled
|
/// 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")
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -539,6 +559,7 @@ fn load_templates(path: &str) -> Handlebars {
|
||||||
reg!("email/pw_hint_none", ".html");
|
reg!("email/pw_hint_none", ".html");
|
||||||
reg!("email/pw_hint_some", ".html");
|
reg!("email/pw_hint_some", ".html");
|
||||||
reg!("email/send_org_invite", ".html");
|
reg!("email/send_org_invite", ".html");
|
||||||
|
reg!("email/twofactor_email", ".html");
|
||||||
|
|
||||||
reg!("admin/base");
|
reg!("admin/base");
|
||||||
reg!("admin/login");
|
reg!("admin/login");
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
|
use diesel;
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::api::EmptyResult;
|
||||||
|
use crate::db::schema::twofactor;
|
||||||
|
use crate::db::DbConn;
|
||||||
|
use crate::error::MapResult;
|
||||||
|
|
||||||
use super::User;
|
use super::User;
|
||||||
|
|
||||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
||||||
|
@ -28,6 +35,7 @@ pub enum TwoFactorType {
|
||||||
// These are implementation details
|
// These are implementation details
|
||||||
U2fRegisterChallenge = 1000,
|
U2fRegisterChallenge = 1000,
|
||||||
U2fLoginChallenge = 1001,
|
U2fLoginChallenge = 1001,
|
||||||
|
EmailVerificationChallenge = 1002,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// 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
|
/// Database methods
|
||||||
impl TwoFactor {
|
impl TwoFactor {
|
||||||
pub fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
|
|
13
src/mail.rs
13
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)
|
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 {
|
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
|
||||||
let html = PartBuilder::new()
|
let html = PartBuilder::new()
|
||||||
.body(encode_to_str(body_html))
|
.body(encode_to_str(body_html))
|
||||||
|
|
9
src/static/templates/email/twofactor_email.hbs
Normale Datei
9
src/static/templates/email/twofactor_email.hbs
Normale Datei
|
@ -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>
|
129
src/static/templates/email/twofactor_email.html.hbs
Normale Datei
129
src/static/templates/email/twofactor_email.html.hbs
Normale Datei
|
@ -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>
|
Laden …
In neuem Issue referenzieren