Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-27 06:00:28 +01:00
Implement registration with required verified email
Dieser Commit ist enthalten in:
Ursprung
96813b1317
Commit
3c7408e21e
9 geänderte Dateien mit 188 neuen und 8 gelöschten Zeilen
|
@ -229,7 +229,8 @@
|
||||||
# SIGNUPS_ALLOWED=true
|
# SIGNUPS_ALLOWED=true
|
||||||
|
|
||||||
## Controls if new users need to verify their email address upon registration
|
## 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
|
## The welcome email will include a verification link, and login attempts will periodically
|
||||||
## trigger another verification email to be sent.
|
## trigger another verification email to be sent.
|
||||||
# SIGNUPS_VERIFY=false
|
# SIGNUPS_VERIFY=false
|
||||||
|
|
|
@ -68,18 +68,29 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RegisterData {
|
pub struct RegisterData {
|
||||||
email: String,
|
email: String,
|
||||||
|
|
||||||
kdf: Option<i32>,
|
kdf: Option<i32>,
|
||||||
kdf_iterations: Option<i32>,
|
kdf_iterations: Option<i32>,
|
||||||
kdf_memory: Option<i32>,
|
kdf_memory: Option<i32>,
|
||||||
kdf_parallelism: Option<i32>,
|
kdf_parallelism: Option<i32>,
|
||||||
|
|
||||||
|
#[serde(alias = "userSymmetricKey")]
|
||||||
key: String,
|
key: String,
|
||||||
|
#[serde(alias = "userAsymmetricKeys")]
|
||||||
keys: Option<KeysData>,
|
keys: Option<KeysData>,
|
||||||
|
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
|
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
token: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
organization_user_id: Option<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
@ -122,13 +133,31 @@ async fn is_email_2fa_required(org_user_uuid: Option<String>, conn: &mut DbConn)
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
#[post("/accounts/register", data = "<data>")]
|
||||||
async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
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 {
|
pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut conn: DbConn) -> JsonResult {
|
||||||
let data: RegisterData = data.into_inner();
|
let mut data: RegisterData = data.into_inner();
|
||||||
let email = data.email.to_lowercase();
|
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)
|
// 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
|
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
||||||
if let Some(ref name) = data.name {
|
if let Some(ref name) = data.name {
|
||||||
|
@ -198,6 +227,10 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
|
||||||
user.client_kdf_iter = client_kdf_iter;
|
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_memory = data.kdf_memory;
|
||||||
user.client_kdf_parallelism = data.kdf_parallelism;
|
user.client_kdf_parallelism = data.kdf_parallelism;
|
||||||
|
|
||||||
|
|
|
@ -193,6 +193,9 @@ fn config() -> Json<Value> {
|
||||||
feature_states.insert("key-rotation-improvements".to_string(), true);
|
feature_states.insert("key-rotation-improvements".to_string(), true);
|
||||||
feature_states.insert("flexible-collections-v-1".to_string(), false);
|
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!({
|
Json(json!({
|
||||||
// Note: The clients use this version to handle backwards compatibility concerns
|
// Note: The clients use this version to handle backwards compatibility concerns
|
||||||
// This means they expect a version that closely matches the Bitwarden server version
|
// This means they expect a version that closely matches the Bitwarden server version
|
||||||
|
|
|
@ -24,7 +24,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
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>")]
|
#[post("/connect/token", data = "<data>")]
|
||||||
|
@ -719,7 +719,62 @@ async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||||
|
|
||||||
#[post("/accounts/register", data = "<data>")]
|
#[post("/accounts/register", data = "<data>")]
|
||||||
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
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
|
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||||
|
|
32
src/auth.rs
32
src/auth.rs
|
@ -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_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_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_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 PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
|
||||||
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
|
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
|
||||||
|
@ -141,6 +142,10 @@ pub fn decode_file_download(token: &str) -> Result<FileDownloadClaims, Error> {
|
||||||
decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string())
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LoginJwtClaims {
|
pub struct LoginJwtClaims {
|
||||||
// Not before
|
// 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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
|
|
|
@ -472,7 +472,8 @@ make_config! {
|
||||||
disable_icon_download: bool, true, def, false;
|
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
|
/// 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;
|
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;
|
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)
|
/// 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;
|
signups_verify_resend_time: u64, true, def, 3_600;
|
||||||
|
@ -1353,6 +1354,7 @@ where
|
||||||
reg!("email/protected_action", ".html");
|
reg!("email/protected_action", ".html");
|
||||||
reg!("email/pw_hint_none", ".html");
|
reg!("email/pw_hint_none", ".html");
|
||||||
reg!("email/pw_hint_some", ".html");
|
reg!("email/pw_hint_some", ".html");
|
||||||
|
reg!("email/register_verify_email", ".html");
|
||||||
reg!("email/send_2fa_removed_from_org", ".html");
|
reg!("email/send_2fa_removed_from_org", ".html");
|
||||||
reg!("email/send_emergency_access_invite", ".html");
|
reg!("email/send_emergency_access_invite", ".html");
|
||||||
reg!("email/send_org_invite", ".html");
|
reg!("email/send_org_invite", ".html");
|
||||||
|
|
22
src/mail.rs
22
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
|
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 {
|
pub async fn send_welcome(address: &str) -> EmptyResult {
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
"email/welcome",
|
"email/welcome",
|
||||||
|
|
8
src/static/templates/email/register_verify_email.hbs
Normale Datei
8
src/static/templates/email/register_verify_email.hbs
Normale Datei
|
@ -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
Normale Datei
24
src/static/templates/email/register_verify_email.html.hbs
Normale Datei
|
@ -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 }}
|
Laden …
In neuem Issue referenzieren