Browse Source

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 <509385+stefan0xC@users.noreply.github.com>
pull/5591/merge
Daniel García 3 days ago
committed by GitHub
parent
commit
2a18665288
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .env.template
  2. 108
      src/api/core/accounts.rs
  3. 3
      src/api/core/mod.rs
  4. 65
      src/api/identity.rs
  5. 32
      src/auth.rs
  6. 6
      src/config.rs
  7. 21
      src/mail.rs
  8. 8
      src/static/templates/email/register_verify_email.hbs
  9. 24
      src/static/templates/email/register_verify_email.html.hbs
  10. 7
      src/static/templates/scss/vaultwarden.scss.hbs

5
.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
@ -489,7 +490,7 @@
## Maximum attempts before an email token is reset and a new email will need to be sent.
# EMAIL_ATTEMPTS_LIMIT=3
##
## Setup email 2FA regardless of any organization policy
## Setup email 2FA on registration regardless of any organization policy
# EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE=false
## Automatically setup email 2FA as fallback provider when needed
# EMAIL_2FA_AUTO_FALLBACK=false

108
src/api/core/accounts.rs

@ -70,18 +70,31 @@ 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<MembershipId>,
// Used only from the register/finish endpoint
email_verification_token: Option<String>,
accept_emergency_access_id: Option<EmergencyAccessId>,
accept_emergency_access_invite_token: Option<String>,
#[serde(alias = "token")]
org_invite_token: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -124,13 +137,78 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &mut DbCon
#[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();
let mut email_verified = false;
let mut pending_emergency_access = 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")
}
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 {
@ -144,20 +222,17 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
let password_hint = clean_password_hint(&data.master_password_hint);
enforce_password_hint_setting(&password_hint)?;
let mut verified_by_invite = false;
let mut user = match User::find_by_mail(&email, &mut conn).await {
Some(mut user) => {
Some(user) => {
if !user.password_hash.is_empty() {
err!("Registration not allowed or user already exists")
}
if let Some(token) = data.token {
if let Some(token) = data.org_invite_token {
let claims = decode_invite(&token)?;
if claims.email == email {
// Verify the email address when signing up via a valid invite token
verified_by_invite = true;
user.verified_at = Some(Utc::now().naive_utc());
email_verified = true;
user
} else {
err!("Registration email does not match invite email")
@ -181,7 +256,10 @@ pub async fn _register(data: Json<RegisterData>, 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()
{
User::new(email.clone())
} else {
err!("Registration not allowed or user already exists")
@ -216,8 +294,12 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
user.public_key = Some(keys.public_key);
}
if email_verified {
user.verified_at = Some(Utc::now().naive_utc());
}
if CONFIG.mail_enabled() {
if CONFIG.signups_verify() && !verified_by_invite {
if CONFIG.signups_verify() && !email_verified {
if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await {
error!("Error sending welcome email: {:#?}", e);
}
@ -226,7 +308,7 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
error!("Error sending welcome email: {:#?}", e);
}
if verified_by_invite && is_email_2fa_required(data.organization_user_id, &mut conn).await {
if email_verified && is_email_2fa_required(data.organization_user_id, &mut conn).await {
email::activate_email_2fa(&user, &mut conn).await.ok();
}
}

3
src/api/core/mod.rs

@ -205,6 +205,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

65
src/api/identity.rs

@ -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>")]
@ -714,7 +714,68 @@ 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: Option<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")
}
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 = "<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

32
src/auth.rs

@ -35,6 +35,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();
@ -145,6 +146,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
@ -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<String>,
pub verified: bool,
}
pub fn generate_register_verify_claims(email: String, name: Option<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

6
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;
@ -734,7 +735,7 @@ make_config! {
email_expiration_time: u64, true, def, 600;
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
email_attempts_limit: u64, true, def, 3;
/// Automatically enforce at login |> Setup email 2FA provider regardless of any organization policy
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
email_2fa_enforce_on_verified_invite: bool, true, def, false;
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
email_2fa_auto_fallback: bool, true, def, false;
@ -1386,6 +1387,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");

21
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",

8
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 }}

24
src/static/templates/email/register_verify_email.html.hbs

@ -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 }}

7
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 */

Loading…
Cancel
Save