From 1dae6093c9ff0526dcd26ebd3dc9d57a118c58f4 Mon Sep 17 00:00:00 2001 From: Timshel Date: Sat, 15 Mar 2025 18:33:17 +0000 Subject: [PATCH 1/4] Use subtle to replace deprecated ring::constant_time::verify_slices_are_equal (#5680) --- Cargo.lock | 5 +++-- Cargo.toml | 3 ++- src/crypto.rs | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9db5a23c..72728273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2972,9 +2972,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.11" +version = "0.17.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73" +checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" dependencies = [ "cc", "cfg-if", @@ -4142,6 +4142,7 @@ dependencies = [ "semver", "serde", "serde_json", + "subtle", "syslog", "time", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 68ef1866..8fdd6866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,8 @@ libsqlite3-sys = { version = "0.31.0", features = ["bundled"], optional = true } # Crypto-related libraries rand = "0.9.0" -ring = "0.17.11" +ring = "0.17.13" +subtle = "2.6.1" # UUID generation uuid = { version = "1.14.0", features = ["v4"] } diff --git a/src/crypto.rs b/src/crypto.rs index 5ab8f1fb..ada0a26a 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -110,7 +110,6 @@ pub fn generate_api_key() -> String { // Constant time compare // pub fn ct_eq, U: AsRef<[u8]>>(a: T, b: U) -> bool { - use ring::constant_time::verify_slices_are_equal; - - verify_slices_are_equal(a.as_ref(), b.as_ref()).is_ok() + use subtle::ConstantTimeEq; + a.as_ref().ct_eq(b.as_ref()).into() } From 994d157064d1fd70bbaecae956c476afc9d5bb87 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Sat, 15 Mar 2025 11:46:42 -0700 Subject: [PATCH 2/4] Add support for mutual-tls feature flag (#5698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for mutual-tls feature flag * Fix formatting --------- Co-authored-by: Daniel García --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index 80eb4756..0ea15371 100644 --- a/.env.template +++ b/.env.template @@ -353,6 +353,7 @@ ## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension. ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0) +## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials ## Require new device emails. When a user logs in an email is required to be sent. diff --git a/src/config.rs b/src/config.rs index 09e6ac37..f82ff63d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -842,6 +842,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { "inline-menu-positioning-improvements", "ssh-key-vault-item", "ssh-agent", + "mutual-tls", ]; let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect(); From 71952a4ab59aec5fae324a1acb80774f84800be8 Mon Sep 17 00:00:00 2001 From: Josh <2635f0d1-2818-4dac-aae0-7043ff60c14e@otake.pw> Date: Sat, 15 Mar 2025 11:57:04 -0700 Subject: [PATCH 3/4] Add AnonAddy/SimpleLogin self host feature flag (#5694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- .env.template | 2 ++ src/config.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.env.template b/.env.template index 0ea15371..9e030710 100644 --- a/.env.template +++ b/.env.template @@ -353,6 +353,8 @@ ## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension. ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0) +## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.2.0) +## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.2.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials diff --git a/src/config.rs b/src/config.rs index f82ff63d..ae6a0a7e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -842,6 +842,8 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { "inline-menu-positioning-improvements", "ssh-key-vault-item", "ssh-agent", + "anon-addy-self-host-alias", + "simple-login-self-host-alias", "mutual-tls", ]; let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); From 2a186652885d210b3c3df5b213051316a37ed434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 17 Mar 2025 16:28:01 +0100 Subject: [PATCH 4/4] Implement new registration flow with email verification (#5215) * Implement registration with required verified email * Optional name, emergency access, and signups_allowed * Implement org invite, remove unneeded invite accept * fix invitation logic for new registration flow (#5691) * fix invitation logic for new registration flow * clarify email_2fa_enforce_on_verified_invite --------- Co-authored-by: Stefan Melmuk <509385+stefan0xC@users.noreply.github.com> --- .env.template | 5 +- src/api/core/accounts.rs | 108 +++++++++++++++--- src/api/core/mod.rs | 3 + src/api/identity.rs | 65 ++++++++++- src/auth.rs | 32 ++++++ src/config.rs | 6 +- src/mail.rs | 21 ++++ .../templates/email/register_verify_email.hbs | 8 ++ .../email/register_verify_email.html.hbs | 24 ++++ .../templates/scss/vaultwarden.scss.hbs | 7 ++ 10 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 src/static/templates/email/register_verify_email.hbs create mode 100644 src/static/templates/email/register_verify_email.html.hbs diff --git a/.env.template b/.env.template index 9e030710..a2c3ba19 100644 --- a/.env.template +++ b/.env.template @@ -229,7 +229,8 @@ # SIGNUPS_ALLOWED=true ## Controls if new users need to verify their email address upon registration -## Note that setting this option to true prevents logins until the email address has been verified! +## On new client versions, this will require the user to verify their email at signup time. +## On older clients, it will require the user to verify their email before they can log in. ## The welcome email will include a verification link, and login attempts will periodically ## trigger another verification email to be sent. # SIGNUPS_VERIFY=false @@ -489,7 +490,7 @@ ## Maximum attempts before an email token is reset and a new email will need to be sent. # EMAIL_ATTEMPTS_LIMIT=3 ## -## Setup email 2FA regardless of any organization policy +## Setup email 2FA on registration regardless of any organization policy # EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE=false ## Automatically setup email 2FA as fallback provider when needed # EMAIL_2FA_AUTO_FALLBACK=false diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6b4c4ac5..e751954b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -70,18 +70,31 @@ pub fn routes() -> Vec { #[serde(rename_all = "camelCase")] pub struct RegisterData { email: String, + kdf: Option, kdf_iterations: Option, kdf_memory: Option, kdf_parallelism: Option, + + #[serde(alias = "userSymmetricKey")] key: String, + #[serde(alias = "userAsymmetricKeys")] keys: Option, + master_password_hash: String, master_password_hint: Option, + name: Option, - token: Option, + #[allow(dead_code)] organization_user_id: Option, + + // Used only from the register/finish endpoint + email_verification_token: Option, + accept_emergency_access_id: Option, + accept_emergency_access_invite_token: Option, + #[serde(alias = "token")] + org_invite_token: Option, } #[derive(Debug, Deserialize)] @@ -124,13 +137,78 @@ async fn is_email_2fa_required(member_id: Option, conn: &mut DbCon #[post("/accounts/register", data = "")] async fn register(data: Json, conn: DbConn) -> JsonResult { - _register(data, conn).await + _register(data, false, conn).await } -pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult { - let data: RegisterData = data.into_inner(); +pub async fn _register(data: Json, email_verification: bool, mut conn: DbConn) -> JsonResult { + let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); + let mut email_verified = false; + + let mut pending_emergency_access = None; + + // First, validate the provided verification tokens + if email_verification { + match ( + &data.email_verification_token, + &data.accept_emergency_access_id, + &data.accept_emergency_access_invite_token, + &data.organization_user_id, + &data.org_invite_token, + ) { + // Normal user registration, when email verification is required + (Some(email_verification_token), None, None, None, None) => { + let claims = crate::auth::decode_register_verify(email_verification_token)?; + if claims.sub != data.email { + err!("Email verification token does not match email"); + } + + // During this call we don't get the name, so extract it from the claims + if claims.name.is_some() { + data.name = claims.name; + } + email_verified = claims.verified; + } + // Emergency access registration + (None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => { + if !CONFIG.emergency_access_allowed() { + err!("Emergency access is not enabled.") + } + + let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?; + + if claims.email != data.email { + err!("Claim email does not match email") + } + if &claims.emer_id != accept_emergency_access_id { + err!("Claim emer_id does not match accept_emergency_access_id") + } + + pending_emergency_access = Some((accept_emergency_access_id, claims)); + email_verified = true; + } + // Org invite + (None, None, None, Some(organization_user_id), Some(org_invite_token)) => { + let claims = decode_invite(org_invite_token)?; + + if claims.email != data.email { + err!("Claim email does not match email") + } + + if &claims.member_id != organization_user_id { + err!("Claim org_user_id does not match organization_user_id") + } + + email_verified = true; + } + + _ => { + err!("Registration is missing required parameters") + } + } + } + // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // This also prevents issues with very long usernames causing to large JWT's. See #2419 if let Some(ref name) = data.name { @@ -144,20 +222,17 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult let password_hint = clean_password_hint(&data.master_password_hint); enforce_password_hint_setting(&password_hint)?; - let mut verified_by_invite = false; - let mut user = match User::find_by_mail(&email, &mut conn).await { - Some(mut user) => { + Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") } - if let Some(token) = data.token { + if let Some(token) = data.org_invite_token { let claims = decode_invite(&token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token - verified_by_invite = true; - user.verified_at = Some(Utc::now().naive_utc()); + email_verified = true; user } else { err!("Registration email does not match invite email") @@ -181,7 +256,10 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult // Order is important here; the invitation check must come first // because the vaultwarden admin can invite anyone, regardless // of other signup restrictions. - if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) { + if Invitation::take(&email, &mut conn).await + || CONFIG.is_signup_allowed(&email) + || pending_emergency_access.is_some() + { User::new(email.clone()) } else { err!("Registration not allowed or user already exists") @@ -216,8 +294,12 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult user.public_key = Some(keys.public_key); } + if email_verified { + user.verified_at = Some(Utc::now().naive_utc()); + } + if CONFIG.mail_enabled() { - if CONFIG.signups_verify() && !verified_by_invite { + if CONFIG.signups_verify() && !email_verified { if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await { error!("Error sending welcome email: {:#?}", e); } @@ -226,7 +308,7 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult error!("Error sending welcome email: {:#?}", e); } - if verified_by_invite && is_email_2fa_required(data.organization_user_id, &mut conn).await { + if email_verified && is_email_2fa_required(data.organization_user_id, &mut conn).await { email::activate_email_2fa(&user, &mut conn).await.ok(); } } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 172bca42..3aa9ad79 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -205,6 +205,9 @@ fn config() -> Json { feature_states.insert("key-rotation-improvements".to_string(), true); feature_states.insert("flexible-collections-v-1".to_string(), false); + feature_states.insert("email-verification".to_string(), true); + feature_states.insert("unauth-ui-refresh".to_string(), true); + Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns // This means they expect a version that closely matches the Bitwarden server version diff --git a/src/api/identity.rs b/src/api/identity.rs index 38cdfce5..86cdd471 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -24,7 +24,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register] + routes![login, prelogin, identity_register, register_verification_email, register_finish] } #[post("/connect/token", data = "")] @@ -714,7 +714,68 @@ async fn prelogin(data: Json, conn: DbConn) -> Json { #[post("/accounts/register", data = "")] async fn identity_register(data: Json, conn: DbConn) -> JsonResult { - _register(data, conn).await + _register(data, false, conn).await +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterVerificationData { + email: String, + name: Option, + // receiveMarketingEmails: bool, +} + +#[derive(rocket::Responder)] +enum RegisterVerificationResponse { + NoContent(()), + Token(Json), +} + +#[post("/accounts/register/send-verification-email", data = "")] +async fn register_verification_email( + data: Json, + mut conn: DbConn, +) -> ApiResult { + let data = data.into_inner(); + + if !CONFIG.is_signup_allowed(&data.email) { + err!("Registration not allowed or user already exists") + } + + let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); + + if User::find_by_mail(&data.email, &mut conn).await.is_some() { + if should_send_mail { + // There is still a timing side channel here in that the code + // paths that send mail take noticeably longer than ones that + // don't. Add a randomized sleep to mitigate this somewhat. + use rand::{rngs::SmallRng, Rng, SeedableRng}; + let mut rng = SmallRng::from_os_rng(); + let delta: i32 = 100; + let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64; + tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; + } + return Ok(RegisterVerificationResponse::NoContent(())); + } + + let token_claims = + crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); + let token = crate::auth::encode_jwt(&token_claims); + + if should_send_mail { + mail::send_register_verify_email(&data.email, &token).await?; + + Ok(RegisterVerificationResponse::NoContent(())) + } else { + // If email verification is not required, return the token directly + // the clients will use this token to finish the registration + Ok(RegisterVerificationResponse::Token(Json(token))) + } +} + +#[post("/accounts/register/finish", data = "")] +async fn register_finish(data: Json, conn: DbConn) -> JsonResult { + _register(data, true, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts diff --git a/src/auth.rs b/src/auth.rs index cfb7c30b..0fabd6a4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -35,6 +35,7 @@ static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG. static JWT_SEND_ISSUER: Lazy = Lazy::new(|| format!("{}|send", CONFIG.domain_origin())); static JWT_ORG_API_KEY_ISSUER: Lazy = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin())); static JWT_FILE_DOWNLOAD_ISSUER: Lazy = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin())); +static JWT_REGISTER_VERIFY_ISSUER: Lazy = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin())); static PRIVATE_RSA_KEY: OnceCell = OnceCell::new(); static PUBLIC_RSA_KEY: OnceCell = OnceCell::new(); @@ -145,6 +146,10 @@ pub fn decode_file_download(token: &str) -> Result { decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string()) } +pub fn decode_register_verify(token: &str) -> Result { + decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string()) +} + #[derive(Debug, Serialize, Deserialize)] pub struct LoginJwtClaims { // Not before @@ -315,6 +320,33 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) } } +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterVerifyClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, + + pub name: Option, + pub verified: bool, +} + +pub fn generate_register_verify_claims(email: String, name: Option, verified: bool) -> RegisterVerifyClaims { + let time_now = Utc::now(); + RegisterVerifyClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(30).unwrap()).timestamp(), + iss: JWT_REGISTER_VERIFY_ISSUER.to_string(), + sub: email, + name, + verified, + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before diff --git a/src/config.rs b/src/config.rs index ae6a0a7e..6a06cac6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -484,7 +484,8 @@ make_config! { disable_icon_download: bool, true, def, false; /// Allow new signups |> Controls whether new users can register. Users can be invited by the vaultwarden admin even if this is disabled signups_allowed: bool, true, def, true; - /// Require email verification on signups. This will prevent logins from succeeding until the address has been verified + /// Require email verification on signups. On new client versions, this will require verification at signup time. On older clients, + /// this will prevent logins from succeeding until the address has been verified signups_verify: bool, true, def, false; /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) signups_verify_resend_time: u64, true, def, 3_600; @@ -734,7 +735,7 @@ make_config! { 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; - /// Automatically enforce at login |> Setup email 2FA provider regardless of any organization policy + /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy email_2fa_enforce_on_verified_invite: bool, true, def, false; /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed email_2fa_auto_fallback: bool, true, def, false; @@ -1386,6 +1387,7 @@ where reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); + reg!("email/register_verify_email", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); diff --git a/src/mail.rs b/src/mail.rs index d074995a..015d8acb 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -201,6 +201,27 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult { + let mut query = url::Url::parse("https://query.builder").unwrap(); + query.query_pairs_mut().append_pair("email", email).append_pair("token", token); + let query_string = match query.query() { + None => err!("Failed to build verify URL query parameters"), + Some(query) => query, + }; + + let (subject, body_html, body_text) = get_text( + "email/register_verify_email", + json!({ + // `url.Url` would place the anchor `#` after the query parameters + "url": format!("{}/#/finish-signup/?{}", CONFIG.domain(), query_string), + "img_src": CONFIG._smtp_img_src(), + "email": email, + }), + )?; + + send_email(email, &subject, body_html, body_text).await +} + pub async fn send_welcome(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/welcome", diff --git a/src/static/templates/email/register_verify_email.hbs b/src/static/templates/email/register_verify_email.hbs new file mode 100644 index 00000000..37eaab9e --- /dev/null +++ b/src/static/templates/email/register_verify_email.hbs @@ -0,0 +1,8 @@ +Verify Your Email + +Verify this email address to finish creating your account by clicking the link below. + +Verify Email Address Now: {{{url}}} + +If you did not request to verify your account, you can safely ignore this email. +{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/register_verify_email.html.hbs b/src/static/templates/email/register_verify_email.html.hbs new file mode 100644 index 00000000..b3d382a0 --- /dev/null +++ b/src/static/templates/email/register_verify_email.html.hbs @@ -0,0 +1,24 @@ +Verify Your Email + +{{> email/email_header }} + + + + + + + + + + +
+ Verify this email address to finish creating your account by clicking the link below. +
+ + Verify Email Address Now + +
+ If you did not request to verify your account, you can safely ignore this email. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 42c4d8dc..cdc1e266 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -93,12 +93,19 @@ bit-nav-logo bit-nav-item .bwi-shield { /**** END Static Vaultwarden Changes ****/ /**** START Dynamic Vaultwarden Changes ****/ {{#if signup_disabled}} +/* From web vault 2025.1.2 and onwards, the signup button is hidden + when signups are disabled as the web vault checks the /api/config endpoint. + Note that the clients tend to aggressively cache this endpoint, so it might + take a while for the change to take effect. To avoid the button appearing + when it shouldn't, we'll keep this style in place for a couple of versions */ +{{#if webver "<2025.3.0"}} /* Hide the register link on the login screen */ app-login form div + div + div + div + hr, app-login form div + div + div + div + hr + p { @extend %vw-hide; } {{/if}} +{{/if}} {{#unless mail_enabled}} /* Hide `Email` 2FA if mail is not enabled */