diff --git a/backend/.sqlx/query-31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b.json b/backend/.sqlx/query-31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b.json new file mode 100644 index 0000000000..5df2805cb0 --- /dev/null +++ b/backend/.sqlx/query-31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b" +} diff --git a/backend/.sqlx/query-97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb.json b/backend/.sqlx/query-97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb.json new file mode 100644 index 0000000000..4e94d6f75c --- /dev/null +++ b/backend/.sqlx/query-97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM magic_link WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb" +} diff --git a/backend/.sqlx/query-a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40.json b/backend/.sqlx/query-a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40.json new file mode 100644 index 0000000000..83d3d489b3 --- /dev/null +++ b/backend/.sqlx/query-a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40" +} diff --git a/backend/.sqlx/query-c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006.json b/backend/.sqlx/query-c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006.json new file mode 100644 index 0000000000..0d9cfd0273 --- /dev/null +++ b/backend/.sqlx/query-c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006" +} diff --git a/backend/.sqlx/query-fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9.json b/backend/.sqlx/query-fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9.json new file mode 100644 index 0000000000..a428daa9bc --- /dev/null +++ b/backend/.sqlx/query-fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9" +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c1af44fc48..754c336c8d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -230,6 +230,83 @@ paths: schema: type: string + /auth/is_smtp_configured: + get: + security: [] + summary: check if SMTP is configured for password reset + operationId: isSmtpConfigured + tags: + - user + responses: + "200": + description: returns true if SMTP is configured + content: + application/json: + schema: + type: boolean + + /auth/request_password_reset: + post: + security: [] + summary: request password reset email + operationId: requestPasswordReset + tags: + - user + requestBody: + description: email to send password reset link to + required: true + content: + application/json: + schema: + type: object + required: + - email + properties: + email: + type: string + format: email + responses: + "200": + description: password reset email sent (if user exists) + content: + application/json: + schema: + $ref: "#/components/schemas/PasswordResetResponse" + "400": + description: SMTP not configured + + /auth/reset_password: + post: + security: [] + summary: reset password using token + operationId: resetPassword + tags: + - user + requestBody: + description: token and new password + required: true + content: + application/json: + schema: + type: object + required: + - token + - new_password + properties: + token: + type: string + new_password: + type: string + responses: + "200": + description: password reset successfully + content: + application/json: + schema: + $ref: "#/components/schemas/PasswordResetResponse" + "400": + description: invalid or expired token + /w/{workspace}/users/get/{username}: get: summary: get user (require admin privilege) @@ -17457,6 +17534,14 @@ components: - email - password + PasswordResetResponse: + type: object + properties: + message: + type: string + required: + - message + EditWorkspaceUser: type: object properties: diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 3e03fbd88f..8dbf7efa9e 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -53,6 +53,7 @@ use windmill_common::users::COOKIE_NAME; use windmill_common::users::{truncate_token, username_to_permissioned_as}; use windmill_common::utils::paginate; use windmill_common::worker::CLOUD_HOSTED; +use windmill_common::BASE_URL; use windmill_common::{ auth::{get_folders_for_user, get_groups_for_user}, db::UserDB, @@ -125,6 +126,9 @@ pub fn make_unauthed_service() -> Router { .route("/login", post(login)) .route("/logout", post(logout).get(logout)) .route("/is_first_time_setup", get(is_first_time_setup)) + .route("/request_password_reset", post(request_password_reset)) + .route("/reset_password", post(reset_password)) + .route("/is_smtp_configured", get(is_smtp_configured)) } pub async fn maybe_refresh_folders( @@ -3081,3 +3085,197 @@ async fn update_username_in_workpsace<'c>( Ok(()) } + +// Password Reset Types +#[derive(Deserialize)] +pub struct RequestPasswordReset { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPassword { + pub token: String, + pub new_password: String, +} + +#[derive(Serialize)] +pub struct PasswordResetResponse { + pub message: String, +} + +// Password Reset Functions + +/// Check if SMTP is configured +async fn is_smtp_configured(Extension(db): Extension) -> JsonResult { + let smtp = windmill_common::server::load_smtp_config(&db).await?; + Ok(Json(smtp.is_some())) +} + +/// Request a password reset email +async fn request_password_reset( + Extension(db): Extension, + Json(req): Json, +) -> Result> { + let email = req.email.to_lowercase(); + + // Check if SMTP is configured + let smtp = windmill_common::server::load_smtp_config(&db).await?; + let smtp = smtp.ok_or_else(|| { + Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string()) + })?; + + // Check if user exists with password login type + let user_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')", + &email + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + // Always return success to prevent email enumeration + // But only send email if user exists + if user_exists { + // Generate a secure token + let token = rd_string(32); + + // Delete any existing tokens for this email + sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email) + .execute(&db) + .await?; + + // Insert new token with 1 hour expiration + sqlx::query!( + "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')", + &email, + &token + ) + .execute(&db) + .await?; + + // Get the base URL for the reset link + let base_url = BASE_URL.read().await.clone(); + let base_url = if base_url.is_empty() { + std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string()) + } else { + base_url + }; + + let reset_link = format!("{}/user/reset-password?token={}", base_url, token); + + // Send the email + let subject = "Windmill Password Reset"; + let content = format!( + "You have requested a password reset for your Windmill account.\n\n\ + Click the link below to reset your password:\n\ + {}\n\n\ + This link will expire in 1 hour.\n\n\ + If you did not request this password reset, you can safely ignore this email.", + reset_link + ); + + // Send the email - don't fail the request if email fails + if let Err(e) = windmill_common::email_oss::send_email_plain_text( + subject, + &content, + vec![email.clone()], + smtp, + Some(Duration::from_secs(10)), + ) + .await + { + tracing::error!("Failed to send password reset email to {}: {:?}", email, e); + } + } + + // Always return success to prevent email enumeration + Ok(Json(PasswordResetResponse { + message: "If an account with that email exists, a password reset link has been sent." + .to_string(), + })) +} + +/// Reset password using a token +async fn reset_password( + Extension(db): Extension, + Extension(argon2): Extension>>, + Json(req): Json, +) -> Result> { + let mut tx = db.begin().await?; + + // Find the token and verify it's not expired + let magic_link = sqlx::query!( + "SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()", + &req.token + ) + .fetch_optional(&mut *tx) + .await?; + + let email = match magic_link { + Some(link) => link.email, + None => { + return Err(Error::BadRequest( + "Invalid or expired password reset token".to_string(), + )) + } + }; + + // Hash the new password + let password_hash = crate::users_oss::hash_password(argon2, req.new_password)?; + + // Update the password + let rows_updated = sqlx::query!( + "UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'", + &password_hash, + &email + ) + .execute(&mut *tx) + .await? + .rows_affected(); + + if rows_updated == 0 { + return Err(Error::BadRequest( + "Unable to update password. User may not exist or may use a different login method." + .to_string(), + )); + } + + // Delete the used token and any other tokens for this email + sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email) + .execute(&mut *tx) + .await?; + + // Invalidate all existing sessions for this user + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email + ) + .execute(&mut *tx) + .await?; + + // Audit log + let audit_author = AuditAuthor { + email: email.clone(), + username: email.clone(), + username_override: None, + token_prefix: None, + }; + + audit_log( + &mut *tx, + &audit_author, + "users.password_reset", + ActionKind::Update, + "global", + Some(&email), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(PasswordResetResponse { + message: "Password has been reset successfully. You can now log in with your new password." + .to_string(), + })) +} diff --git a/backend/windmill-api/src/users_oss.rs b/backend/windmill-api/src/users_oss.rs index 4d1b3976d2..7cb643b84a 100644 --- a/backend/windmill-api/src/users_oss.rs +++ b/backend/windmill-api/src/users_oss.rs @@ -55,6 +55,13 @@ pub async fn set_password( )) } +#[cfg(not(feature = "private"))] +pub fn hash_password(_argon2: Arc>, _password: String) -> Result { + Err(Error::internal_err( + "Not implemented in Windmill's Open Source repository".to_string(), + )) +} + #[cfg(not(feature = "private"))] pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) { tracing::warn!( @@ -70,7 +77,6 @@ pub struct OnboardingData { pub use_case: String, } - #[cfg(not(feature = "private"))] pub async fn submit_onboarding_data( _authed: ApiAuthed, @@ -80,4 +86,4 @@ pub async fn submit_onboarding_data( Err(Error::internal_err( "Not implemented in Windmill's Open Source repository".to_string(), )) -} \ No newline at end of file +} diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 617d494189..5d3fc0b898 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -84,6 +84,7 @@ let showPassword = $state(false) let logins: OAuthLogin[] | undefined = $state(undefined) let saml: string | undefined = $state(undefined) + let smtpConfigured: boolean | undefined = $state(undefined) type OAuthLogin = { type: string @@ -194,6 +195,17 @@ loadLogins() + async function checkSmtpConfigured() { + try { + smtpConfigured = await UserService.isSmtpConfigured() + } catch (err) { + console.error('Could not check if SMTP is configured', err) + smtpConfigured = false + } + } + + checkSmtpConfigured() + function handleKeyUp(event: KeyboardEvent) { const key = event.key @@ -372,6 +384,16 @@ autocomplete="current-password" /> + {#if smtpConfigured} + + {/if}
diff --git a/frontend/src/routes/user/forgot-password/+page.svelte b/frontend/src/routes/user/forgot-password/+page.svelte new file mode 100644 index 0000000000..adac107766 --- /dev/null +++ b/frontend/src/routes/user/forgot-password/+page.svelte @@ -0,0 +1,105 @@ + + +
+ +
+
+ {#if !$enterpriseLicense || !$whitelabelNameStore} + + {/if} +
+

+ Reset password +

+

+ Enter your email address and we'll send you a link to reset your password +

+
+ +
+
+ +
+
+ {#if submitted} +
+

+ If an account with that email exists, we've sent a password reset link. +

+

+ Please check your email and follow the instructions to reset your password. +

+
+ +
+
+ {:else} +
+
+ +
+ +
+
+ +
+ + +
+
+ {/if} +
+
+
diff --git a/frontend/src/routes/user/reset-password/+page.svelte b/frontend/src/routes/user/reset-password/+page.svelte new file mode 100644 index 0000000000..7299a079b9 --- /dev/null +++ b/frontend/src/routes/user/reset-password/+page.svelte @@ -0,0 +1,147 @@ + + +
+ +
+
+ {#if !$enterpriseLicense || !$whitelabelNameStore} + + {/if} +
+

+ {success ? 'Password Reset' : 'Set New Password'} +

+ {#if !success} +

Enter your new password below

+ {/if} +
+ +
+
+ +
+
+ {#if !token} +
+

Invalid or missing reset token.

+
+ +
+
+ {:else if success} +
+

Your password has been reset successfully.

+

You can now log in with your new password.

+
+ +
+
+ {:else} +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + +
+
+ {/if} +
+
+