Skip to content

Commit 2a18665

Browse files
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 <[email protected]>
1 parent 71952a4 commit 2a18665

File tree

10 files changed

+260
-19
lines changed

10 files changed

+260
-19
lines changed

.env.template

+3-2
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@
229229
# SIGNUPS_ALLOWED=true
230230

231231
## Controls if new users need to verify their email address upon registration
232-
## Note that setting this option to true prevents logins until the email address has been verified!
232+
## On new client versions, this will require the user to verify their email at signup time.
233+
## On older clients, it will require the user to verify their email before they can log in.
233234
## The welcome email will include a verification link, and login attempts will periodically
234235
## trigger another verification email to be sent.
235236
# SIGNUPS_VERIFY=false
@@ -489,7 +490,7 @@
489490
## Maximum attempts before an email token is reset and a new email will need to be sent.
490491
# EMAIL_ATTEMPTS_LIMIT=3
491492
##
492-
## Setup email 2FA regardless of any organization policy
493+
## Setup email 2FA on registration regardless of any organization policy
493494
# EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE=false
494495
## Automatically setup email 2FA as fallback provider when needed
495496
# EMAIL_2FA_AUTO_FALLBACK=false

src/api/core/accounts.rs

+95-13
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,31 @@ pub fn routes() -> Vec<rocket::Route> {
7070
#[serde(rename_all = "camelCase")]
7171
pub struct RegisterData {
7272
email: String,
73+
7374
kdf: Option<i32>,
7475
kdf_iterations: Option<i32>,
7576
kdf_memory: Option<i32>,
7677
kdf_parallelism: Option<i32>,
78+
79+
#[serde(alias = "userSymmetricKey")]
7780
key: String,
81+
#[serde(alias = "userAsymmetricKeys")]
7882
keys: Option<KeysData>,
83+
7984
master_password_hash: String,
8085
master_password_hint: Option<String>,
86+
8187
name: Option<String>,
82-
token: Option<String>,
88+
8389
#[allow(dead_code)]
8490
organization_user_id: Option<MembershipId>,
91+
92+
// Used only from the register/finish endpoint
93+
email_verification_token: Option<String>,
94+
accept_emergency_access_id: Option<EmergencyAccessId>,
95+
accept_emergency_access_invite_token: Option<String>,
96+
#[serde(alias = "token")]
97+
org_invite_token: Option<String>,
8598
}
8699

87100
#[derive(Debug, Deserialize)]
@@ -124,13 +137,78 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &mut DbCon
124137

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

130-
pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult {
131-
let data: RegisterData = data.into_inner();
143+
pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut conn: DbConn) -> JsonResult {
144+
let mut data: RegisterData = data.into_inner();
132145
let email = data.email.to_lowercase();
133146

147+
let mut email_verified = false;
148+
149+
let mut pending_emergency_access = None;
150+
151+
// First, validate the provided verification tokens
152+
if email_verification {
153+
match (
154+
&data.email_verification_token,
155+
&data.accept_emergency_access_id,
156+
&data.accept_emergency_access_invite_token,
157+
&data.organization_user_id,
158+
&data.org_invite_token,
159+
) {
160+
// Normal user registration, when email verification is required
161+
(Some(email_verification_token), None, None, None, None) => {
162+
let claims = crate::auth::decode_register_verify(email_verification_token)?;
163+
if claims.sub != data.email {
164+
err!("Email verification token does not match email");
165+
}
166+
167+
// During this call we don't get the name, so extract it from the claims
168+
if claims.name.is_some() {
169+
data.name = claims.name;
170+
}
171+
email_verified = claims.verified;
172+
}
173+
// Emergency access registration
174+
(None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => {
175+
if !CONFIG.emergency_access_allowed() {
176+
err!("Emergency access is not enabled.")
177+
}
178+
179+
let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?;
180+
181+
if claims.email != data.email {
182+
err!("Claim email does not match email")
183+
}
184+
if &claims.emer_id != accept_emergency_access_id {
185+
err!("Claim emer_id does not match accept_emergency_access_id")
186+
}
187+
188+
pending_emergency_access = Some((accept_emergency_access_id, claims));
189+
email_verified = true;
190+
}
191+
// Org invite
192+
(None, None, None, Some(organization_user_id), Some(org_invite_token)) => {
193+
let claims = decode_invite(org_invite_token)?;
194+
195+
if claims.email != data.email {
196+
err!("Claim email does not match email")
197+
}
198+
199+
if &claims.member_id != organization_user_id {
200+
err!("Claim org_user_id does not match organization_user_id")
201+
}
202+
203+
email_verified = true;
204+
}
205+
206+
_ => {
207+
err!("Registration is missing required parameters")
208+
}
209+
}
210+
}
211+
134212
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
135213
// This also prevents issues with very long usernames causing to large JWT's. See #2419
136214
if let Some(ref name) = data.name {
@@ -144,20 +222,17 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
144222
let password_hint = clean_password_hint(&data.master_password_hint);
145223
enforce_password_hint_setting(&password_hint)?;
146224

147-
let mut verified_by_invite = false;
148-
149225
let mut user = match User::find_by_mail(&email, &mut conn).await {
150-
Some(mut user) => {
226+
Some(user) => {
151227
if !user.password_hash.is_empty() {
152228
err!("Registration not allowed or user already exists")
153229
}
154230

155-
if let Some(token) = data.token {
231+
if let Some(token) = data.org_invite_token {
156232
let claims = decode_invite(&token)?;
157233
if claims.email == email {
158234
// Verify the email address when signing up via a valid invite token
159-
verified_by_invite = true;
160-
user.verified_at = Some(Utc::now().naive_utc());
235+
email_verified = true;
161236
user
162237
} else {
163238
err!("Registration email does not match invite email")
@@ -181,7 +256,10 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
181256
// Order is important here; the invitation check must come first
182257
// because the vaultwarden admin can invite anyone, regardless
183258
// of other signup restrictions.
184-
if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) {
259+
if Invitation::take(&email, &mut conn).await
260+
|| CONFIG.is_signup_allowed(&email)
261+
|| pending_emergency_access.is_some()
262+
{
185263
User::new(email.clone())
186264
} else {
187265
err!("Registration not allowed or user already exists")
@@ -216,8 +294,12 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
216294
user.public_key = Some(keys.public_key);
217295
}
218296

297+
if email_verified {
298+
user.verified_at = Some(Utc::now().naive_utc());
299+
}
300+
219301
if CONFIG.mail_enabled() {
220-
if CONFIG.signups_verify() && !verified_by_invite {
302+
if CONFIG.signups_verify() && !email_verified {
221303
if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await {
222304
error!("Error sending welcome email: {:#?}", e);
223305
}
@@ -226,7 +308,7 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
226308
error!("Error sending welcome email: {:#?}", e);
227309
}
228310

229-
if verified_by_invite && is_email_2fa_required(data.organization_user_id, &mut conn).await {
311+
if email_verified && is_email_2fa_required(data.organization_user_id, &mut conn).await {
230312
email::activate_email_2fa(&user, &mut conn).await.ok();
231313
}
232314
}

src/api/core/mod.rs

+3
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@ fn config() -> Json<Value> {
205205
feature_states.insert("key-rotation-improvements".to_string(), true);
206206
feature_states.insert("flexible-collections-v-1".to_string(), false);
207207

208+
feature_states.insert("email-verification".to_string(), true);
209+
feature_states.insert("unauth-ui-refresh".to_string(), true);
210+
208211
Json(json!({
209212
// Note: The clients use this version to handle backwards compatibility concerns
210213
// This means they expect a version that closely matches the Bitwarden server version

src/api/identity.rs

+63-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use crate::{
2424
};
2525

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

3030
#[post("/connect/token", data = "<data>")]
@@ -714,7 +714,68 @@ async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
714714

715715
#[post("/accounts/register", data = "<data>")]
716716
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
717-
_register(data, conn).await
717+
_register(data, false, conn).await
718+
}
719+
720+
#[derive(Debug, Deserialize)]
721+
#[serde(rename_all = "camelCase")]
722+
struct RegisterVerificationData {
723+
email: String,
724+
name: Option<String>,
725+
// receiveMarketingEmails: bool,
726+
}
727+
728+
#[derive(rocket::Responder)]
729+
enum RegisterVerificationResponse {
730+
NoContent(()),
731+
Token(Json<String>),
732+
}
733+
734+
#[post("/accounts/register/send-verification-email", data = "<data>")]
735+
async fn register_verification_email(
736+
data: Json<RegisterVerificationData>,
737+
mut conn: DbConn,
738+
) -> ApiResult<RegisterVerificationResponse> {
739+
let data = data.into_inner();
740+
741+
if !CONFIG.is_signup_allowed(&data.email) {
742+
err!("Registration not allowed or user already exists")
743+
}
744+
745+
let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify();
746+
747+
if User::find_by_mail(&data.email, &mut conn).await.is_some() {
748+
if should_send_mail {
749+
// There is still a timing side channel here in that the code
750+
// paths that send mail take noticeably longer than ones that
751+
// don't. Add a randomized sleep to mitigate this somewhat.
752+
use rand::{rngs::SmallRng, Rng, SeedableRng};
753+
let mut rng = SmallRng::from_os_rng();
754+
let delta: i32 = 100;
755+
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
756+
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
757+
}
758+
return Ok(RegisterVerificationResponse::NoContent(()));
759+
}
760+
761+
let token_claims =
762+
crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
763+
let token = crate::auth::encode_jwt(&token_claims);
764+
765+
if should_send_mail {
766+
mail::send_register_verify_email(&data.email, &token).await?;
767+
768+
Ok(RegisterVerificationResponse::NoContent(()))
769+
} else {
770+
// If email verification is not required, return the token directly
771+
// the clients will use this token to finish the registration
772+
Ok(RegisterVerificationResponse::Token(Json(token)))
773+
}
774+
}
775+
776+
#[post("/accounts/register/finish", data = "<data>")]
777+
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
778+
_register(data, true, conn).await
718779
}
719780

720781
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts

src/auth.rs

+32
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.
3535
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
3636
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
3737
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin()));
38+
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
3839

3940
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
4041
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
@@ -145,6 +146,10 @@ pub fn decode_file_download(token: &str) -> Result<FileDownloadClaims, Error> {
145146
decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string())
146147
}
147148

149+
pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error> {
150+
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
151+
}
152+
148153
#[derive(Debug, Serialize, Deserialize)]
149154
pub struct LoginJwtClaims {
150155
// Not before
@@ -315,6 +320,33 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId)
315320
}
316321
}
317322

323+
#[derive(Debug, Serialize, Deserialize)]
324+
pub struct RegisterVerifyClaims {
325+
// Not before
326+
pub nbf: i64,
327+
// Expiration time
328+
pub exp: i64,
329+
// Issuer
330+
pub iss: String,
331+
// Subject
332+
pub sub: String,
333+
334+
pub name: Option<String>,
335+
pub verified: bool,
336+
}
337+
338+
pub fn generate_register_verify_claims(email: String, name: Option<String>, verified: bool) -> RegisterVerifyClaims {
339+
let time_now = Utc::now();
340+
RegisterVerifyClaims {
341+
nbf: time_now.timestamp(),
342+
exp: (time_now + TimeDelta::try_minutes(30).unwrap()).timestamp(),
343+
iss: JWT_REGISTER_VERIFY_ISSUER.to_string(),
344+
sub: email,
345+
name,
346+
verified,
347+
}
348+
}
349+
318350
#[derive(Debug, Serialize, Deserialize)]
319351
pub struct BasicJwtClaims {
320352
// Not before

src/config.rs

+4-2
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,8 @@ make_config! {
484484
disable_icon_download: bool, true, def, false;
485485
/// Allow new signups |> Controls whether new users can register. Users can be invited by the vaultwarden admin even if this is disabled
486486
signups_allowed: bool, true, def, true;
487-
/// Require email verification on signups. This will prevent logins from succeeding until the address has been verified
487+
/// Require email verification on signups. On new client versions, this will require verification at signup time. On older clients,
488+
/// this will prevent logins from succeeding until the address has been verified
488489
signups_verify: bool, true, def, false;
489490
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
490491
signups_verify_resend_time: u64, true, def, 3_600;
@@ -734,7 +735,7 @@ make_config! {
734735
email_expiration_time: u64, true, def, 600;
735736
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
736737
email_attempts_limit: u64, true, def, 3;
737-
/// Automatically enforce at login |> Setup email 2FA provider regardless of any organization policy
738+
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
738739
email_2fa_enforce_on_verified_invite: bool, true, def, false;
739740
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
740741
email_2fa_auto_fallback: bool, true, def, false;
@@ -1386,6 +1387,7 @@ where
13861387
reg!("email/protected_action", ".html");
13871388
reg!("email/pw_hint_none", ".html");
13881389
reg!("email/pw_hint_some", ".html");
1390+
reg!("email/register_verify_email", ".html");
13891391
reg!("email/send_2fa_removed_from_org", ".html");
13901392
reg!("email/send_emergency_access_invite", ".html");
13911393
reg!("email/send_org_invite", ".html");

src/mail.rs

+21
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,27 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
201201
send_email(address, &subject, body_html, body_text).await
202202
}
203203

204+
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
205+
let mut query = url::Url::parse("https://query.builder").unwrap();
206+
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
207+
let query_string = match query.query() {
208+
None => err!("Failed to build verify URL query parameters"),
209+
Some(query) => query,
210+
};
211+
212+
let (subject, body_html, body_text) = get_text(
213+
"email/register_verify_email",
214+
json!({
215+
// `url.Url` would place the anchor `#` after the query parameters
216+
"url": format!("{}/#/finish-signup/?{}", CONFIG.domain(), query_string),
217+
"img_src": CONFIG._smtp_img_src(),
218+
"email": email,
219+
}),
220+
)?;
221+
222+
send_email(email, &subject, body_html, body_text).await
223+
}
224+
204225
pub async fn send_welcome(address: &str) -> EmptyResult {
205226
let (subject, body_html, body_text) = get_text(
206227
"email/welcome",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Verify Your Email
2+
<!---------------->
3+
Verify this email address to finish creating your account by clicking the link below.
4+
5+
Verify Email Address Now: {{{url}}}
6+
7+
If you did not request to verify your account, you can safely ignore this email.
8+
{{> email/email_footer_text }}

0 commit comments

Comments
 (0)