committed by
GitHub
19 changed files with 627 additions and 18 deletions
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN verify_devices; |
||||
@ -0,0 +1,2 @@ |
|||||
|
ALTER TABLE users |
||||
|
ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT TRUE; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN verify_devices; |
||||
@ -0,0 +1,2 @@ |
|||||
|
ALTER TABLE users |
||||
|
ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT TRUE; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN verify_devices; |
||||
@ -0,0 +1,2 @@ |
|||||
|
ALTER TABLE users |
||||
|
ADD COLUMN verify_devices BOOLEAN NOT NULL DEFAULT 1; -- TRUE |
||||
@ -0,0 +1,491 @@ |
|||||
|
//! New device verification, the Bitwarden "New device login protection" feature.
|
||||
|
//!
|
||||
|
//! A password login from a device that is not known yet first has to be confirmed with a code that
|
||||
|
//! is mailed to the account address. The device is only stored once that code was accepted, so a
|
||||
|
//! correct master password on its own never turns an unknown device into a known one.
|
||||
|
//!
|
||||
|
//! Reference: <https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs>
|
||||
|
|
||||
|
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds}; |
||||
|
use rocket::{Route, serde::json::Json}; |
||||
|
use serde_json::Value; |
||||
|
|
||||
|
use crate::{ |
||||
|
CONFIG, |
||||
|
api::{EmptyResult, PasswordOrOtpData}, |
||||
|
auth::{ClientIp, Headers}, |
||||
|
crypto, |
||||
|
db::{ |
||||
|
DbConn, |
||||
|
models::{Device, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, |
||||
|
}, |
||||
|
error::{Error, ErrorEvent}, |
||||
|
mail, |
||||
|
}; |
||||
|
|
||||
|
pub fn routes() -> Vec<Route> { |
||||
|
routes![resend_new_device_otp, put_verify_devices, post_verify_devices] |
||||
|
} |
||||
|
|
||||
|
/// Accounts younger than this are exempt upstream.
|
||||
|
const NEW_ACCOUNT_EXEMPTION_HOURS: i64 = 24; |
||||
|
|
||||
|
/// Minimum time between two verification mails, so repeated logins cannot flood a mailbox.
|
||||
|
/// Matches the protected actions resend delay.
|
||||
|
const RESEND_DELAY_SECONDS: i64 = 30; |
||||
|
|
||||
|
/// Data stored in the `twofactor` table under [`TwoFactorType::NewDeviceVerification`]. Only read
|
||||
|
/// and written here, so a code issued for a new device can never authorize anything else.
|
||||
|
#[derive(Debug, Serialize, Deserialize)] |
||||
|
pub struct NewDeviceVerificationData { |
||||
|
/// Code the user has to send back as `NewDeviceOtp`.
|
||||
|
pub token: String, |
||||
|
#[serde(with = "ts_seconds")] |
||||
|
pub token_sent: NaiveDateTime, |
||||
|
/// Failed validation attempts for the current token.
|
||||
|
pub attempts: u64, |
||||
|
} |
||||
|
|
||||
|
impl NewDeviceVerificationData { |
||||
|
fn new(token: String) -> Self { |
||||
|
Self { |
||||
|
token, |
||||
|
token_sent: Utc::now().naive_utc(), |
||||
|
attempts: 0, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fn to_json(&self) -> String { |
||||
|
serde_json::to_string(&self).unwrap() |
||||
|
} |
||||
|
|
||||
|
fn from_json(string: &str) -> Result<Self, Error> { |
||||
|
if let Ok(data) = serde_json::from_str(string) { |
||||
|
Ok(data) |
||||
|
} else { |
||||
|
err!("Could not decode NewDeviceVerificationData from string") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fn add_attempt(&mut self) { |
||||
|
self.attempts = self.attempts.saturating_add(1); |
||||
|
} |
||||
|
|
||||
|
fn time_since_sent(&self) -> TimeDelta { |
||||
|
Utc::now().naive_utc() - self.token_sent |
||||
|
} |
||||
|
|
||||
|
fn is_expired(&self, max_age_seconds: i64) -> bool { |
||||
|
self.time_since_sent().num_seconds() > max_age_seconds |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Everything the decision in [`new_device_action`] depends on.
|
||||
|
#[expect(clippy::struct_excessive_bools, reason = "Every condition upstream checks, kept separate to stay testable")] |
||||
|
#[derive(Clone, Copy)] |
||||
|
pub struct NewDeviceState { |
||||
|
pub enforced: bool, |
||||
|
pub verify_devices: bool, |
||||
|
/// The account is younger than the Bitwarden exemption period.
|
||||
|
pub recently_created: bool, |
||||
|
pub has_two_factor: bool, |
||||
|
pub known_device: bool, |
||||
|
pub has_devices: bool, |
||||
|
/// A `NewDeviceOtp` field was sent, an empty one included.
|
||||
|
pub otp_supplied: bool, |
||||
|
pub otp_not_empty: bool, |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
||||
|
pub enum NewDeviceAction { |
||||
|
/// Continue the login unchanged.
|
||||
|
Skip, |
||||
|
/// Validate the supplied `NewDeviceOtp` before continuing.
|
||||
|
Verify, |
||||
|
/// Mail a code and reject this login attempt.
|
||||
|
Challenge, |
||||
|
} |
||||
|
|
||||
|
/// Mirrors `DeviceValidator.HandleNewDeviceVerificationAsync` of the Bitwarden server.
|
||||
|
pub fn new_device_action(state: NewDeviceState) -> NewDeviceAction { |
||||
|
// A code implies an unknown device, upstream skips the lookup for it.
|
||||
|
if !state.otp_not_empty && state.known_device { |
||||
|
return NewDeviceAction::Skip; |
||||
|
} |
||||
|
|
||||
|
// Upstream skips device verification for 2FA users entirely, they keep their existing flow.
|
||||
|
if !state.enforced || !state.verify_devices || state.recently_created || state.has_two_factor { |
||||
|
return NewDeviceAction::Skip; |
||||
|
} |
||||
|
|
||||
|
// An empty code counts as a wrong code upstream.
|
||||
|
if state.otp_supplied { |
||||
|
return NewDeviceAction::Verify; |
||||
|
} |
||||
|
|
||||
|
// A user without any device is a freshly registered user.
|
||||
|
if !state.has_devices { |
||||
|
return NewDeviceAction::Skip; |
||||
|
} |
||||
|
|
||||
|
NewDeviceAction::Challenge |
||||
|
} |
||||
|
|
||||
|
/// The clients match `ErrorModel.Message` literally to switch to their new device verification
|
||||
|
/// screen and show `error_description`. See `api.service.ts` and
|
||||
|
/// `new-device-verification.component.ts` in `bitwarden/clients`.
|
||||
|
fn verification_required_error() -> Error { |
||||
|
let body = json!({ |
||||
|
"error": "device_error", |
||||
|
"error_description": "New device verification required", |
||||
|
"ErrorModel": { |
||||
|
"Message": "new device verification required", |
||||
|
"Object": "error" |
||||
|
} |
||||
|
}); |
||||
|
Error::from(("New device verification required", body)).with_event(ErrorEvent { |
||||
|
event: EventType::UserFailedLogIn, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fn invalid_otp_error() -> Error { |
||||
|
let body = json!({ |
||||
|
"error": "device_error", |
||||
|
"error_description": "Invalid New Device OTP", |
||||
|
"ErrorModel": { |
||||
|
"Message": "invalid new device otp", |
||||
|
"Object": "error" |
||||
|
} |
||||
|
}); |
||||
|
Error::from(("Invalid new device OTP", body)).with_event(ErrorEvent { |
||||
|
event: EventType::UserFailedLogIn, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/// Runs new device verification for a password login, before the device is stored. `Ok(())` means
|
||||
|
/// the login may continue, an error carries the response the Bitwarden clients expect.
|
||||
|
pub async fn validate_new_device_login( |
||||
|
user: &mut User, |
||||
|
device_id: &DeviceId, |
||||
|
device_type: i32, |
||||
|
new_device_otp: Option<&str>, |
||||
|
is_auth_request: bool, |
||||
|
ip: &ClientIp, |
||||
|
conn: &DbConn, |
||||
|
) -> EmptyResult { |
||||
|
// Login with device re-uses the password grant but is only ever approved from a known device.
|
||||
|
let enforced = CONFIG.new_device_verification() && CONFIG.mail_enabled() && !is_auth_request; |
||||
|
|
||||
|
let recently_created = Utc::now().naive_utc() - user.created_at < TimeDelta::hours(NEW_ACCOUNT_EXEMPTION_HOURS); |
||||
|
|
||||
|
// Skip the extra queries when the feature cannot apply anyway. Every condition here also
|
||||
|
// makes `new_device_action` return `Skip`.
|
||||
|
if !enforced || !user.verify_devices || recently_created { |
||||
|
return Ok(()); |
||||
|
} |
||||
|
|
||||
|
let devices = Device::find_by_user(&user.uuid, conn).await; |
||||
|
let state = NewDeviceState { |
||||
|
enforced, |
||||
|
verify_devices: user.verify_devices, |
||||
|
recently_created, |
||||
|
has_two_factor: !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(), |
||||
|
known_device: devices.iter().any(|d| &d.uuid == device_id), |
||||
|
has_devices: !devices.is_empty(), |
||||
|
otp_supplied: new_device_otp.is_some(), |
||||
|
otp_not_empty: new_device_otp.is_some_and(|otp| !otp.is_empty()), |
||||
|
}; |
||||
|
|
||||
|
match new_device_action(state) { |
||||
|
NewDeviceAction::Skip => Ok(()), |
||||
|
NewDeviceAction::Verify => { |
||||
|
validate_otp(new_device_otp.unwrap_or_default(), &user.uuid, conn).await?; |
||||
|
|
||||
|
// The user proved access to their mailbox, so upstream marks the address as verified.
|
||||
|
if user.verified_at.is_none() { |
||||
|
user.verified_at = Some(Utc::now().naive_utc()); |
||||
|
user.save(conn).await?; |
||||
|
} |
||||
|
Ok(()) |
||||
|
} |
||||
|
NewDeviceAction::Challenge => { |
||||
|
send_otp(user, device_type, ip, conn).await?; |
||||
|
Err(verification_required_error()) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Generates and mails a new code, unless a still valid one was sent very recently.
|
||||
|
async fn send_otp(user: &User, device_type: i32, ip: &ClientIp, conn: &DbConn) -> EmptyResult { |
||||
|
let type_ = TwoFactorType::NewDeviceVerification as i32; |
||||
|
|
||||
|
if let Some(ref tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, conn).await { |
||||
|
let data = NewDeviceVerificationData::from_json(&tf.data)?; |
||||
|
if !data.is_expired(CONFIG.email_expiration_time().cast_signed()) |
||||
|
&& data.time_since_sent().num_seconds() < RESEND_DELAY_SECONDS |
||||
|
{ |
||||
|
// Keep the code the user just received valid instead of mailing another one.
|
||||
|
return Ok(()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Saving replaces any previous code, only the most recent one stays valid.
|
||||
|
let data = NewDeviceVerificationData::new(crypto::generate_email_token(CONFIG.email_token_size())); |
||||
|
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::NewDeviceVerification, data.to_json()); |
||||
|
twofactor.save(conn).await?; |
||||
|
|
||||
|
if let Err(e) = |
||||
|
mail::send_new_device_verification(&user.email, &data.token, &ip.ip.to_string(), &data.token_sent, device_type) |
||||
|
.await |
||||
|
{ |
||||
|
error!("Error sending new device verification email: {e:#?}"); |
||||
|
// Drop the code that never went out, the resend delay would otherwise suppress the next
|
||||
|
// attempt and ask the user for a code they cannot have.
|
||||
|
if let Err(e) = twofactor.delete(conn).await { |
||||
|
error!("Error removing the unsent new device verification code: {e:#?}"); |
||||
|
} |
||||
|
err!( |
||||
|
"Could not send the new device verification email. Please contact your administrator.", |
||||
|
ErrorEvent { |
||||
|
event: EventType::UserFailedLogIn |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
Ok(()) |
||||
|
} |
||||
|
|
||||
|
/// Validates a `NewDeviceOtp` and consumes it when it is correct.
|
||||
|
async fn validate_otp(otp: &str, user_id: &UserId, conn: &DbConn) -> EmptyResult { |
||||
|
let type_ = TwoFactorType::NewDeviceVerification as i32; |
||||
|
let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { |
||||
|
return Err(invalid_otp_error()); |
||||
|
}; |
||||
|
|
||||
|
let mut data = NewDeviceVerificationData::from_json(&tf.data)?; |
||||
|
|
||||
|
if data.is_expired(CONFIG.email_expiration_time().cast_signed()) { |
||||
|
tf.delete(conn).await?; |
||||
|
return Err(invalid_otp_error()); |
||||
|
} |
||||
|
|
||||
|
if !crypto::ct_eq(&data.token, otp) { |
||||
|
data.add_attempt(); |
||||
|
if data.attempts >= CONFIG.email_attempts_limit() { |
||||
|
// Force a new code to be requested instead of allowing endless guesses.
|
||||
|
tf.delete(conn).await?; |
||||
|
} else { |
||||
|
tf.data = data.to_json(); |
||||
|
tf.save(conn).await?; |
||||
|
} |
||||
|
return Err(invalid_otp_error()); |
||||
|
} |
||||
|
|
||||
|
// Consume the code so it cannot be replayed.
|
||||
|
tf.delete(conn).await?; |
||||
|
Ok(()) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct ResendNewDeviceOtpData { |
||||
|
email: String, |
||||
|
master_password_hash: String, |
||||
|
} |
||||
|
|
||||
|
/// Mirrors `POST /accounts/resend-new-device-otp` upstream, which answers successfully whatever
|
||||
|
/// happens so it cannot be used to probe for accounts.
|
||||
|
#[post("/accounts/resend-new-device-otp", data = "<data>")] |
||||
|
async fn resend_new_device_otp(data: Json<ResendNewDeviceOtpData>, ip: ClientIp, conn: DbConn) -> EmptyResult { |
||||
|
crate::ratelimit::check_limit_login(&ip.ip)?; |
||||
|
|
||||
|
let data: ResendNewDeviceOtpData = data.into_inner(); |
||||
|
|
||||
|
if !CONFIG.new_device_verification() || !CONFIG.mail_enabled() { |
||||
|
return Ok(()); |
||||
|
} |
||||
|
|
||||
|
let Some(user) = User::find_by_mail(data.email.trim(), &conn).await else { |
||||
|
return Ok(()); |
||||
|
}; |
||||
|
|
||||
|
if !user.enabled || !user.verify_devices || !user.check_valid_password(&data.master_password_hash) { |
||||
|
return Ok(()); |
||||
|
} |
||||
|
|
||||
|
// The device type is not part of this request, `Unknown Browser` matches upstream.
|
||||
|
if let Err(e) = send_otp(&user, 14, &ip, &conn).await { |
||||
|
error!("Error resending new device verification code: {e:#?}"); |
||||
|
} |
||||
|
|
||||
|
Ok(()) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct SetVerifyDevicesData { |
||||
|
#[serde(alias = "MasterPasswordHash")] |
||||
|
master_password_hash: Option<String>, |
||||
|
otp: Option<String>, |
||||
|
#[serde(alias = "VerifyDevices")] |
||||
|
verify_devices: bool, |
||||
|
} |
||||
|
|
||||
|
/// Changes the account setting that controls whether new devices need to be verified.
|
||||
|
/// Current clients use `POST`, older ones and the API docs use `PUT`.
|
||||
|
#[put("/accounts/verify-devices", data = "<data>")] |
||||
|
async fn put_verify_devices(data: Json<SetVerifyDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult { |
||||
|
set_verify_devices(data, headers, conn).await |
||||
|
} |
||||
|
|
||||
|
#[post("/accounts/verify-devices", data = "<data>")] |
||||
|
async fn post_verify_devices(data: Json<SetVerifyDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult { |
||||
|
set_verify_devices(data, headers, conn).await |
||||
|
} |
||||
|
|
||||
|
async fn set_verify_devices(data: Json<SetVerifyDevicesData>, headers: Headers, conn: DbConn) -> EmptyResult { |
||||
|
let data: SetVerifyDevicesData = data.into_inner(); |
||||
|
let mut user = headers.user; |
||||
|
|
||||
|
// Same user verification upstream requires for this setting.
|
||||
|
PasswordOrOtpData { |
||||
|
master_password_hash: data.master_password_hash, |
||||
|
otp: data.otp, |
||||
|
} |
||||
|
.validate(&user, true, &conn) |
||||
|
.await?; |
||||
|
|
||||
|
user.verify_devices = data.verify_devices; |
||||
|
user.save(&conn).await |
||||
|
} |
||||
|
|
||||
|
/// Reports the state of this feature to the pre-2023 web vault, the only client that used it.
|
||||
|
/// The section stays disabled because its setter was never part of Vaultwarden, so showing it
|
||||
|
/// would only produce a broken toggle.
|
||||
|
pub fn device_verification_settings(user: &User) -> Value { |
||||
|
let enabled = CONFIG.new_device_verification() && CONFIG.mail_enabled() && user.verify_devices; |
||||
|
|
||||
|
json!({ |
||||
|
"isDeviceVerificationSectionEnabled": false, |
||||
|
"unknownDeviceVerificationEnabled": enabled, |
||||
|
"object": "deviceVerificationSettings" |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
#[cfg(test)] |
||||
|
mod tests { |
||||
|
use super::*; |
||||
|
|
||||
|
/// A state in which a login gets challenged, so single fields can be flipped per case.
|
||||
|
fn challenged() -> NewDeviceState { |
||||
|
NewDeviceState { |
||||
|
enforced: true, |
||||
|
verify_devices: true, |
||||
|
recently_created: false, |
||||
|
has_two_factor: false, |
||||
|
known_device: false, |
||||
|
has_devices: true, |
||||
|
otp_supplied: false, |
||||
|
otp_not_empty: false, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Case name, whether a code was sent along, what else differs from a challenged login, outcome.
|
||||
|
type Case = (&'static str, bool, fn(&mut NewDeviceState), NewDeviceAction); |
||||
|
|
||||
|
#[test] |
||||
|
fn decision_matches_upstream() { |
||||
|
use NewDeviceAction::{Challenge, Skip, Verify}; |
||||
|
|
||||
|
let cases: [Case; 11] = [ |
||||
|
("unknown device without 2fa", false, |_| (), Challenge), |
||||
|
("feature disabled", false, |s| s.enforced = false, Skip), |
||||
|
("user opted out", false, |s| s.verify_devices = false, Skip), |
||||
|
("account within the exemption period", false, |s| s.recently_created = true, Skip), |
||||
|
("2fa configured", false, |s| s.has_two_factor = true, Skip), |
||||
|
("2fa configured and a code sent", true, |s| s.has_two_factor = true, Skip), |
||||
|
("known device", false, |s| s.known_device = true, Skip), |
||||
|
("account without any device", false, |s| s.has_devices = false, Skip), |
||||
|
("code sent", true, |_| (), Verify), |
||||
|
("code sent from a known device", true, |s| s.known_device = true, Verify), |
||||
|
("code sent without any device", true, |s| s.has_devices = false, Verify), |
||||
|
]; |
||||
|
|
||||
|
for (case, sends_code, setup, expected) in cases { |
||||
|
let mut state = challenged(); |
||||
|
state.otp_supplied = sends_code; |
||||
|
state.otp_not_empty = sends_code; |
||||
|
setup(&mut state); |
||||
|
assert_eq!(new_device_action(state), expected, "{case}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Upstream only skips the known device lookup for a non-empty code, but still treats an empty
|
||||
|
/// one as a wrong code.
|
||||
|
#[test] |
||||
|
fn empty_code_is_treated_as_a_wrong_code() { |
||||
|
let sent_empty = NewDeviceState { |
||||
|
otp_supplied: true, |
||||
|
..challenged() |
||||
|
}; |
||||
|
assert_eq!(new_device_action(sent_empty), NewDeviceAction::Verify); |
||||
|
|
||||
|
let known_device = NewDeviceState { |
||||
|
known_device: true, |
||||
|
..sent_empty |
||||
|
}; |
||||
|
assert_eq!(new_device_action(known_device), NewDeviceAction::Skip); |
||||
|
} |
||||
|
|
||||
|
/// The shortcut in `validate_new_device_login` must never skip a login the decision would challenge.
|
||||
|
#[test] |
||||
|
fn shortcut_only_skips_what_the_decision_skips() { |
||||
|
for enforced in [false, true] { |
||||
|
for verify_devices in [false, true] { |
||||
|
for recently_created in [false, true] { |
||||
|
if enforced && verify_devices && !recently_created { |
||||
|
continue; |
||||
|
} |
||||
|
let state = NewDeviceState { |
||||
|
enforced, |
||||
|
verify_devices, |
||||
|
recently_created, |
||||
|
..challenged() |
||||
|
}; |
||||
|
assert_eq!(new_device_action(state), NewDeviceAction::Skip); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// The clients compare these strings literally, changing them breaks the flow silently.
|
||||
|
#[test] |
||||
|
fn client_matched_response_fields_are_stable() { |
||||
|
let required: Value = serde_json::from_str(&verification_required_error().to_string()).unwrap(); |
||||
|
assert_eq!(required["error"], "device_error"); |
||||
|
assert_eq!(required["error_description"], "New device verification required"); |
||||
|
assert_eq!(required["ErrorModel"]["Message"], "new device verification required"); |
||||
|
// Must not look like a 2FA response, the clients check that first.
|
||||
|
assert!(required.get("TwoFactorProviders2").is_none()); |
||||
|
|
||||
|
let invalid: Value = serde_json::from_str(&invalid_otp_error().to_string()).unwrap(); |
||||
|
assert_eq!(invalid["error_description"], "Invalid New Device OTP"); |
||||
|
assert_eq!(invalid["ErrorModel"]["Message"], "invalid new device otp"); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn stored_code_survives_json_and_expires() { |
||||
|
let mut data = NewDeviceVerificationData::from_json(&NewDeviceVerificationData::new("123456".into()).to_json()) |
||||
|
.expect("stored data must round trip"); |
||||
|
assert_eq!(data.token, "123456"); |
||||
|
assert_eq!(data.attempts, 0); |
||||
|
assert!(!data.is_expired(600)); |
||||
|
|
||||
|
data.add_attempt(); |
||||
|
assert_eq!(data.attempts, 1); |
||||
|
|
||||
|
data.token_sent -= TimeDelta::seconds(601); |
||||
|
assert!(data.is_expired(600)); |
||||
|
assert!(!data.is_expired(3600)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
Your Vaultwarden New Device Verification Code |
||||
|
<!----------------> |
||||
|
A login attempt was made from a device that has not been used with your account before. To finish logging in, enter the code below in the client that is asking for it. |
||||
|
|
||||
|
Your new device verification code is: {{token}} |
||||
|
|
||||
|
* Date: {{datetime}} |
||||
|
* IP Address: {{ip}} |
||||
|
* Device Type: {{device_type}} |
||||
|
|
||||
|
If this was not you, do not enter the code. Someone knows your master password and you should change it as soon as possible. |
||||
|
{{> email/email_footer_text }} |
||||
@ -0,0 +1,36 @@ |
|||||
|
Your Vaultwarden New Device Verification Code |
||||
|
<!----------------> |
||||
|
{{> email/email_header }} |
||||
|
<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"> |
||||
|
A login attempt was made from a device that has not been used with your account before. To finish logging in, enter the code below in the client that is asking for it. |
||||
|
</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"> |
||||
|
Your new device 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"> |
||||
|
<b>Date:</b> {{datetime}} |
||||
|
</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"> |
||||
|
<b>IP Address:</b> {{ip}} |
||||
|
</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"> |
||||
|
<b>Device Type:</b> {{device_type}} |
||||
|
</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 last" 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; -webkit-text-size-adjust: none;" valign="top"> |
||||
|
If this was not you, do not enter the code. Someone knows your master password and you should change it as soon as possible. |
||||
|
</td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
{{> email/email_footer }} |
||||
Loading…
Reference in new issue