Skip to content

Commit

Permalink
Implement registration with required verified email
Browse files Browse the repository at this point in the history
  • Loading branch information
dani-garcia committed Nov 21, 2024
1 parent 96813b1 commit 8f3aaa1
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 9 deletions.
3 changes: 2 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 41 additions & 5 deletions src/api/core/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,34 @@ pub fn routes() -> Vec<rocket::Route> {
#[serde(rename_all = "camelCase")]
pub struct RegisterData {
email: String,

kdf: Option<i32>,
kdf_iterations: Option<i32>,
kdf_memory: Option<i32>,
kdf_parallelism: Option<i32>,

#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>,

master_password_hash: String,
master_password_hint: Option<String>,

name: Option<String>,
token: Option<String>,

#[allow(dead_code)]
organization_user_id: Option<String>,
#[serde(alias = "orgInviteToken")]
token: Option<String>,

// Used only from the register/finish endpoint
email_verification_token: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
pub struct KeysData {
encrypted_private_key: String,
public_key: String,
}
Expand Down Expand Up @@ -122,13 +133,34 @@ async fn is_email_2fa_required(org_user_uuid: Option<String>, conn: &mut DbConn)

#[post("/accounts/register", data = "<data>")]
async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
_register(data, conn).await
_register(data, false, conn).await
}

pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult {
let data: RegisterData = data.into_inner();
pub async fn _register(data: Json<RegisterData>, 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) => {
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,
};

// TODO: Validate email_verification_token, if present
// data.email_verification_token

// 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 {
Expand Down Expand Up @@ -198,6 +230,10 @@ pub async fn _register(data: Json<RegisterData>, 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;

Expand Down
3 changes: 3 additions & 0 deletions src/api/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ fn config() -> Json<Value> {
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
Expand Down
59 changes: 57 additions & 2 deletions src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::{
};

pub fn routes() -> Vec<Route> {
routes![login, prelogin, identity_register]
routes![login, prelogin, identity_register, register_verification_email, register_finish]
}

#[post("/connect/token", data = "<data>")]
Expand Down Expand Up @@ -719,7 +719,62 @@ async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {

#[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, 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<String>),
}

#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
mut conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
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.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 = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
_register(data, true, conn).await
}

// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
Expand Down
32 changes: 32 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin()));
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin()));

static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
Expand Down Expand Up @@ -141,6 +142,10 @@ pub fn decode_file_download(token: &str) -> Result<FileDownloadClaims, Error> {
decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string())
}

pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error> {
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
}

#[derive(Debug, Serialize, Deserialize)]
pub struct LoginJwtClaims {
// Not before
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions src/mail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/static/templates/email/register_verify_email.hbs
Original file line number Diff line number Diff line change
@@ -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 }}
24 changes: 24 additions & 0 deletions src/static/templates/email/register_verify_email.html.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Verify Your Email
<!---------------->
{{> 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; text-align: center;" valign="top" align="center">
Verify this email address to finish creating your account by clicking the link below.
</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; text-align: center;" valign="top" align="center">
<a href="{{{url}}}"
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Verify Email Address Now
</a>
</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; text-align: center;" valign="top" align="center">
If you did not request to verify your account, you can safely ignore this email.
</td>
</tr>
</table>
{{> email/email_footer }}

0 comments on commit 8f3aaa1

Please sign in to comment.