diff --git a/.env.template b/.env.template index 80eb475620..23d4fc29a9 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 diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6b4c4ac5b2..58889f4613 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, + org_invite_token: Option, } #[derive(Debug, Deserialize)] @@ -124,13 +137,80 @@ 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; + let mut pending_org_invite = 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") + } + + pending_org_invite = Some((organization_user_id, claims)); + 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 { @@ -181,7 +261,11 @@ 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() + || pending_org_invite.is_some() + { User::new(email.clone()) } else { err!("Registration not allowed or user already exists") @@ -200,6 +284,10 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult user.client_kdf_iter = client_kdf_iter; } + if email_verified { + user.verified_at = Some(Utc::now().naive_utc()); + } + user.client_kdf_memory = data.kdf_memory; user.client_kdf_parallelism = data.kdf_parallelism; diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 172bca4266..3aa9ad793f 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 38cdfce583..86cdd4710c 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 cfb7c30be5..0fabd6a44c 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 09e6ac37b7..2c0740d41c 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; @@ -1383,6 +1384,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 d074995a97..015d8acbe3 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 0000000000..37eaab9e79 --- /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 0000000000..b3d382a04a --- /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 42c4d8dce7..cdc1e2664c 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 */