Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement new registration flow with email verification #5215

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
41 changes: 37 additions & 4 deletions src/api/core/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,29 @@ 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)]
Expand Down Expand Up @@ -122,13 +133,31 @@ 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) 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 {
Expand Down Expand Up @@ -198,6 +227,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.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 = "<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 }}
Loading