From 3c7408e21e41407bdf5c991a68a76805eec24393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 20 Nov 2024 22:11:52 +0100 Subject: [PATCH] Implement registration with required verified email --- .env.template | 3 +- src/api/core/accounts.rs | 41 +++++++++++-- src/api/core/mod.rs | 3 + src/api/identity.rs | 59 ++++++++++++++++++- src/auth.rs | 32 ++++++++++ src/config.rs | 4 +- src/mail.rs | 22 +++++++ .../templates/email/register_verify_email.hbs | 8 +++ .../email/register_verify_email.html.hbs | 24 ++++++++ 9 files changed, 188 insertions(+), 8 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 62ce5258..43748f48 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 87e44529..e9a91efa 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -68,18 +68,29 @@ 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, + #[serde(alias = "orgInviteToken")] + token: Option, + + // Used only from the register/finish endpoint + email_verification_token: Option, } #[derive(Debug, Deserialize)] @@ -122,13 +133,31 @@ async fn is_email_2fa_required(org_user_uuid: Option, conn: &mut DbConn) #[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(); + if email_verification && data.email_verification_token.is_none() { + err!("Email verification token is required"); + } + + let email_verified = match &data.email_verification_token { + Some(token) if email_verification => { + let claims = crate::auth::decode_register_verify(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 + data.name = Some(claims.name); + claims.verified + } + _ => false, + }; + // 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 { @@ -198,6 +227,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 75c63c16..122bf44f 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -193,6 +193,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 445d61fd..02c8529c 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 = "")] @@ -719,7 +719,62 @@ 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: String, + // 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") + } + + // TODO: We might want to do some rate limiting here + // Also, test this with invites/emergency access etc + + if User::find_by_mail(&data.email, &mut conn).await.is_some() { + // TODO: Add some random delay here to prevent timing attacks? + return Ok(RegisterVerificationResponse::NoContent(())); + } + + let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); + + 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, &data.name, &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 809ef9fd..44b2adbe 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -31,6 +31,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(); @@ -141,6 +142,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 @@ -308,6 +313,33 @@ pub fn generate_file_download_claims(uuid: String, file_id: String) -> FileDownl } } +#[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: String, + pub verified: bool, +} + +pub fn generate_register_verify_claims(email: String, name: String, 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 e4e80927..c4a8a005 100644 --- a/src/config.rs +++ b/src/config.rs @@ -472,7 +472,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; @@ -1353,6 +1354,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 5ce4a079..1754400b 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -202,6 +202,28 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_register_verify_email(email: &str, name: &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(), + "name": name, + "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