feat: add password reset flow using configured SMTP settings (#7525)

* feat: add password reset flow using configured SMTP settings

Implements password reset functionality for users with email/password login:

Backend:
- Add `/auth/request_password_reset` endpoint to request a password reset email
- Add `/auth/reset_password` endpoint to reset password using token
- Add `/auth/is_smtp_configured` endpoint to check if SMTP is available
- Uses existing `magic_link` table for storing reset tokens
- Tokens expire after 1 hour
- Invalidates all existing sessions on password reset
- Includes audit logging

Frontend:
- Add "Forgot password?" link on login page (shown when SMTP is configured)
- Add `/user/forgot-password` page for requesting password reset
- Add `/user/reset-password` page for entering new password
- Both pages follow existing Windmill design patterns

Security:
- Always returns success response to prevent email enumeration
- Password must be at least 8 characters
- Uses argon2 for password hashing (same as existing login)

Closes #7524

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* nits

* nits

* fix oss

* nits

* fix oss

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: HugoCasa <hugo@casademont.ch>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
claude[bot]
2026-01-09 10:31:56 +00:00
committed by GitHub
co-authored by windmill-internal-app[bot] claude[bot] HugoCasa Ruben Fiszel
parent 7a86ea154e
commit 6f7cf2fb16
11 changed files with 653 additions and 2 deletions
@@ -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"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM magic_link WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+85
View File
@@ -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:
+198
View File
@@ -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<DB>) -> JsonResult<bool> {
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<DB>,
Json(req): Json<RequestPasswordReset>,
) -> Result<Json<PasswordResetResponse>> {
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<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(req): Json<ResetPassword>,
) -> Result<Json<PasswordResetResponse>> {
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(),
}))
}
+8 -2
View File
@@ -55,6 +55,13 @@ pub async fn set_password(
))
}
#[cfg(not(feature = "private"))]
pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<String> {
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(),
))
}
}
+22
View File
@@ -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"
/>
</div>
{#if smtpConfigured}
<div class="text-right pt-1">
<a
href="{base}/user/forgot-password"
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot password?
</a>
</div>
{/if}
</div>
<div class="pt-2">
@@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
let email = $state('')
let loading = $state(false)
let submitted = $state(false)
async function requestPasswordReset() {
if (!email) {
sendUserToast('Please enter your email address', true)
return
}
loading = true
try {
await UserService.requestPasswordReset({ requestBody: { email } })
submitted = true
sendUserToast('If an account with that email exists, a password reset link has been sent.')
} catch (err: any) {
if (err?.body?.includes('SMTP is not configured')) {
sendUserToast('Password reset is not available. SMTP is not configured.', true)
} else {
sendUserToast('An error occurred. Please try again later.', true)
}
} finally {
loading = false
}
}
function handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
requestPasswordReset()
}
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
Reset password
</h2>
<p class="mt-2 text-center text-xs text-secondary">
Enter your email address and we'll send you a link to reset your password
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if submitted}
<div class="text-center space-y-4">
<p class="text-secondary">
If an account with that email exists, we've sent a password reset link.
</p>
<p class="text-secondary text-sm">
Please check your email and follow the instructions to reset your password.
</p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{:else}
<div class="space-y-6">
<div class="space-y-1">
<label for="email" class="block text-xs font-semibold text-emphasis">Email</label>
<div>
<input
type="email"
bind:value={email}
id="email"
autocomplete="email"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="pt-2 flex flex-col gap-2">
<Button on:click={requestPasswordReset} variant="accent" disabled={!email || loading}>
{loading ? 'Sending...' : 'Send reset link'}
</Button>
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,147 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
const token = $page.url.searchParams.get('token') ?? ''
let newPassword = $state('')
let confirmPassword = $state('')
let loading = $state(false)
let success = $state(false)
async function resetPassword() {
if (!token) {
sendUserToast('Invalid or missing reset token', true)
return
}
if (!newPassword || !confirmPassword) {
sendUserToast('Please fill in both password fields', true)
return
}
if (newPassword !== confirmPassword) {
sendUserToast('Passwords do not match', true)
return
}
loading = true
try {
await UserService.resetPassword({
requestBody: {
token,
new_password: newPassword
}
})
success = true
sendUserToast('Password has been reset successfully!')
} catch (err: any) {
console.error('Could not reset password', err)
sendUserToast('Could not reset password: ' + err, true)
} finally {
loading = false
}
}
function handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
resetPassword()
}
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
{success ? 'Password Reset' : 'Set New Password'}
</h2>
{#if !success}
<p class="mt-2 text-center text-xs text-secondary"> Enter your new password below </p>
{/if}
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if !token}
<div class="text-center space-y-4">
<p class="text-red-500">Invalid or missing reset token.</p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/forgot-password')}>
Request New Reset Link
</Button>
</div>
</div>
{:else if success}
<div class="text-center space-y-4">
<p class="text-secondary"> Your password has been reset successfully. </p>
<p class="text-secondary text-sm"> You can now log in with your new password. </p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/login')}>Go to login</Button>
</div>
</div>
{:else}
<div class="space-y-6">
<div class="space-y-1">
<label for="new-password" class="block text-xs font-semibold text-emphasis">
New Password
</label>
<div>
<input
type="password"
bind:value={newPassword}
id="new-password"
autocomplete="new-password"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="space-y-1">
<label for="confirm-password" class="block text-xs font-semibold text-emphasis">
Confirm Password
</label>
<div>
<input
type="password"
bind:value={confirmPassword}
id="confirm-password"
autocomplete="new-password"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="pt-2 flex flex-col gap-2">
<Button
on:click={resetPassword}
variant="accent"
disabled={!newPassword || !confirmPassword || loading}
>
{loading ? 'Resetting...' : 'Reset password'}
</Button>
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{/if}
</div>
</div>
</div>