From 0f6aca4897a630f935aa5c5ba1aa12a14b4afd90 Mon Sep 17 00:00:00 2001 From: Prajjwal kumar Date: Tue, 28 Jul 2026 03:37:19 +0530 Subject: [PATCH] feat(auth): replace invite temp passwords with emailed set-password links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invites previously generated a temporary password and both returned it in the HTTP response (InviteMemberResponse.password) and emailed it in plaintext — flagged in the forgot-password security review as the invite flow's biggest weakness once a reset flow existed. - New users are now created with an unusable random bcrypt hash and email_verified=false; the invite email carries a single-use set-password link (reset-code machinery: SHA-256-keyed Redis entry, 7-day TTL) redeemed on the existing /reset-password page, which also marks the mailbox verified - Invite email is sent before any DB write: delivery failure fails the invite cleanly (500, nothing persisted); membership-insert failure compensates by deleting the user row and the code - Release deployments without an active email client now fail the invite instead of silently creating an unreachable account (debug builds keep the no_email dev flow, which logs the link) - user_pwreset_at TTL raised to cover the 7-day invite codes so a password change still kills every previously emailed link - no_email release logging keys on starts_with, closing a merchant-name-controlled subject collision that could route a live set-password link into release logs - login now checks the password before disclosing EmailNotVerified, removing a verification-status/existence oracle - InviteMemberResponse.password removed; MembersPage shows an "invitation sent" banner instead of credentials; InviteUserTemplate replaced by InviteSetPasswordTemplate; docs updated Co-Authored-By: Claude Fable 5 --- docs/api-refs/auth-and-onboarding.mdx | 3 +- src/email/no_email.rs | 17 ++- src/email/templates.rs | 61 ++++------ src/routes/user_auth.rs | 159 ++++++++++++++------------ website/src/pages/MembersPage.tsx | 62 ++-------- 5 files changed, 128 insertions(+), 174 deletions(-) diff --git a/docs/api-refs/auth-and-onboarding.mdx b/docs/api-refs/auth-and-onboarding.mdx index 544bec42..77846733 100644 --- a/docs/api-refs/auth-and-onboarding.mdx +++ b/docs/api-refs/auth-and-onboarding.mdx @@ -181,9 +181,8 @@ curl --location "$BASE_URL/merchant/members/invite" \ { "email": "teammate@example.com", "is_new_user": true, - "password": "TempPass#8821", "role": "member" } ``` -`password` is only present when the invite creates a brand-new user account — share it out of band so they can log in and change it. Inviting an email that's already a user omits `password` and just adds the membership. +No credentials are ever returned or emailed. When the invite creates a brand-new user (`is_new_user: true`), the invitee receives an email with a single-use **set-password link** (valid for 7 days) that opens the dashboard's reset-password page; the account is unusable until they set a password. If the invite email cannot be delivered, the request fails with `500` and nothing is created — just retry. Because the emailed link is the new account's only credential, inviting new users in a release deployment requires an active email client (`[email] active_email_client = "smtp"` or `"aws_ses"`); with `no_email_client` the request fails rather than creating an unreachable account. Inviting an email that's already a user simply adds the membership and sends a notification email. diff --git a/src/email/no_email.rs b/src/email/no_email.rs index 74d3adea..5f2afbd3 100644 --- a/src/email/no_email.rs +++ b/src/email/no_email.rs @@ -6,13 +6,18 @@ pub struct NoEmailClient; impl EmailClient for NoEmailClient { async fn send_email(&self, message: EmailMessage) -> error_stack::Result<(), EmailError> { // Extract the action URL from the body so developers can complete email - // verification or password reset manually. Avoid logging the full HTML body - // for other email types (e.g. invite emails) because it would expose - // temporary passwords. Reset URLs carry a live single-use credential, so they - // are only logged in debug builds — never from a release binary. + // verification, password reset, or an invite manually. Avoid logging the full + // HTML body for other email types. Reset and invite URLs carry a live single-use + // credential, so they are only logged in debug builds — never from a release + // binary. + // `starts_with` (not `contains`) for the release-logged verification branch: invite + // subjects embed an admin-chosen merchant name, so a substring match could let a + // crafted merchant name route a live set-password link into release logs. let subject_lower = message.subject.to_lowercase(); - let action_url = if subject_lower.contains("confirm your email") - || (cfg!(debug_assertions) && subject_lower.contains("reset your password")) + let action_url = if subject_lower.starts_with("confirm your email") + || (cfg!(debug_assertions) + && (subject_lower.contains("reset your password") + || subject_lower.contains("invited"))) { extract_href_from_cta(&message.html_body) } else { diff --git a/src/email/templates.rs b/src/email/templates.rs index 4f3b3b93..0afb98dd 100644 --- a/src/email/templates.rs +++ b/src/email/templates.rs @@ -299,19 +299,16 @@ impl PasswordResetTemplate { } } -pub struct InviteUserTemplate { +pub struct InviteSetPasswordTemplate { pub user_email: String, pub merchant_name: String, - pub temporary_password: String, - pub base_url: String, + pub set_password_url: String, } -impl InviteUserTemplate { +impl InviteSetPasswordTemplate { pub fn into_message(self) -> EmailMessage { let merchant = escape_html(&self.merchant_name); - let email = escape_html(&self.user_email); - let password = escape_html(&self.temporary_password); - let base_url = escape_html(&self.base_url); + let url = escape_html(&self.set_password_url); let html_body = format!( r#" @@ -322,7 +319,7 @@ impl InviteUserTemplate { - You’ve been added to {merchant} on Juspay Decision Engine. Your login details are inside. ͏ + You’ve been invited to {merchant} on Juspay Decision Engine. Set your password to get started. ͏ @@ -346,39 +343,33 @@ impl InviteUserTemplate { You’ve been invited

- You’ve been added to {merchant} on Juspay Decision Engine. - Use the credentials below to sign in, then change your password from your account settings. + You’ve been invited to {merchant} on Juspay Decision Engine. + Click the button below to choose your password and activate your account.

- -
- - - - - - -
-

Email

-

{email}

-
-

Temporary password

-

{password}

-
- - +
- - Sign in to Decision Engine → + Set your password →
+ + + +
+ +

+ Button not working? Copy and paste this link into your browser: +

+

+ {url} +

@@ -386,8 +377,8 @@ impl InviteUserTemplate {

- For your security, please change your password after signing in. - If you weren’t expecting this invitation, contact your account administrator. + This link expires in 7 days and can be used only once. + If you weren’t expecting this invitation, you can safely ignore this email — the account cannot be used until a password is set.

Juspay Decision Engine  ·  Automated security email — please do not reply. @@ -402,15 +393,13 @@ impl InviteUserTemplate { "#, merchant = merchant, - email = email, - password = password, - base_url = base_url + url = url ); EmailMessage { to: self.user_email, subject: format!( - "You've been invited to join {} on Decision Engine", + "You're invited to {} on Decision Engine", self.merchant_name ), html_body, diff --git a/src/routes/user_auth.rs b/src/routes/user_auth.rs index f9fff77b..01c5a3c6 100644 --- a/src/routes/user_auth.rs +++ b/src/routes/user_auth.rs @@ -104,6 +104,11 @@ const FORGOT_PASSWORD_RATE_WINDOW_SECONDS: i64 = 3600; const FORGOT_PASSWORD_RATE_MAX_PER_EMAIL: i64 = 3; const FORGOT_PASSWORD_RATE_MAX_PER_IP: i64 = 30; +/// Set-password links for invited users reuse the reset-code machinery (same Redis prefix, +/// same redemption endpoint) but live longer — an invitee may not open the email the same +/// day the admin sends it. +const INVITE_SET_PASSWORD_TTL_SECONDS: i64 = 7 * 86400; // 7 days + #[axum::debug_handler] pub async fn signup( Json(payload): Json, @@ -368,6 +373,16 @@ pub async fn login( return Err(error::ContainerError::from(UserAuthError::AccountInactive)); } + if !auth::verify_password(&payload.password, &user.password_hash) + .change_context(UserAuthError::StorageError)? + { + return Err(error::ContainerError::from(UserAuthError::InvalidPassword)); + } + + // Only after the password checks out: revealing EmailNotVerified to a caller who + // doesn't hold the credential would turn login into an account-existence / + // verification-status oracle (invited accounts sit unverified until they set a + // password via their emailed link). let email_verified = { #[cfg(feature = "mysql")] { @@ -382,12 +397,6 @@ pub async fn login( return Err(error::ContainerError::from(UserAuthError::EmailNotVerified)); } - if !auth::verify_password(&payload.password, &user.password_hash) - .change_context(UserAuthError::StorageError)? - { - return Err(error::ContainerError::from(UserAuthError::InvalidPassword)); - } - let merchants = fetch_user_merchants(&app_state, &user.user_id).await?; let active_merchant_id = user.merchant_id.clone().unwrap_or_else(|| { merchants @@ -693,7 +702,7 @@ async fn record_password_reset_timestamp( let reset_at_key = format!("{}{}", PASSWORD_RESET_AT_PREFIX, user_id); let ttl = std::cmp::max( global_config.user_auth.jwt_expiry_seconds as i64, - PASSWORD_RESET_CODE_TTL_SECONDS, + std::cmp::max(PASSWORD_RESET_CODE_TTL_SECONDS, INVITE_SET_PASSWORD_TTL_SECONDS), ); if let Err(err) = app_state .redis_conn @@ -1015,8 +1024,6 @@ pub struct InviteMemberRequest { pub struct InviteMemberResponse { pub email: String, pub is_new_user: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub password: Option, pub role: String, } @@ -1155,17 +1162,67 @@ pub async fn invite_member( Ok(Json(InviteMemberResponse { email: existing_user.email, is_new_user: false, - password: None, role, })) } else { - // Create new user with generated password - let generated_password = generate_random_password(); + // New users never receive a password — not in the email, not in the API response. + // The account is created with an unusable random hash and the invitee sets their + // own password through a single-use emailed link (the reset-password machinery). + // + // That emailed link is the account's only credential, so an email backend that + // delivers nothing (NoEmailClient logs the URL in debug builds only) must fail + // the invite instead of reporting false success and leaving a dead account. + if !global_config.email.is_active() && !cfg!(debug_assertions) { + return Err(error::ContainerError::from(UserAuthError::EmailSendFailed)); + } - let password_hash = auth::hash_password(&generated_password) + let user_id = uuid::Uuid::new_v4().to_string(); + + // Unusable placeholder: a random secret hashed and immediately discarded. Login is + // impossible until the invitee sets a real password via the link (or, if the email + // is lost, via the forgot-password flow). Computed before the Redis write so no + // failure after the code is stored lacks a compensating delete. + let password_hash = auth::hash_password(&auth::generate_api_key()) .change_context(UserAuthError::PasswordHashingFailed)?; - let user_id = uuid::Uuid::new_v4().to_string(); + let code = auth::generate_api_key(); + let code_key = format!("{}{}", PASSWORD_RESET_CODE_PREFIX, auth::hash_api_key(&code)); + let issued_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let code_value = format!("{}:{}", user_id, issued_at); + app_state + .redis_conn + .set_key_with_ttl(&code_key, &code_value, INVITE_SET_PASSWORD_TTL_SECONDS) + .await + .change_context(UserAuthError::StorageError)?; + + // Send before any DB writes (same ordering as signup): the emailed link is the + // invitee's only way into the account, so a delivery failure must fail the invite + // cleanly — nothing persisted, the admin simply retries. + let email_client = APP_STATE + .get() + .map(|s| s.email_client.clone()) + .ok_or(UserAuthError::StorageError)?; + let set_password_url = format!( + "{}/reset-password?token={}", + global_config.email.base_url, code + ); + let send_result = email_client + .send_email( + crate::email::templates::InviteSetPasswordTemplate { + user_email: payload.email.clone(), + merchant_name: merchant_name.clone(), + set_password_url, + } + .into_message(), + ) + .await; + if send_result.is_err() { + let _ = app_state.redis_conn.delete_key(&code_key).await; + } + send_result.change_context(UserAuthError::EmailSendFailed)?; let new_user = NewUser { user_id: user_id.clone(), @@ -1177,16 +1234,22 @@ pub async fn invite_member( is_active: 1, #[cfg(feature = "postgres")] is_active: true, + // Mailbox control is proven when the set-password link is redeemed — the + // reset flow flips this to true. #[cfg(feature = "mysql")] - email_verified: 1, + email_verified: 0, #[cfg(feature = "postgres")] - email_verified: true, + email_verified: false, created_at: now, }; - crate::generics::generic_insert(&app_state.db, new_user) + if crate::generics::generic_insert(&app_state.db, new_user) .await - .change_context(UserAuthError::StorageError)?; + .is_err() + { + let _ = app_state.redis_conn.delete_key(&code_key).await; + return Err(error::ContainerError::from(UserAuthError::StorageError)); + } let new_user_merchant = NewUserMerchant { user_id: user_id.clone(), @@ -1199,7 +1262,8 @@ pub async fn invite_member( .await .is_err() { - // Compensating delete: remove orphaned user if membership insert fails + // Compensating deletes: remove the orphaned user and the now-dangling link + // if the membership insert fails. let conn = app_state.db.get_conn().await.ok(); if let Some(conn) = conn { let _ = crate::generics::generic_delete::< @@ -1208,37 +1272,13 @@ pub async fn invite_member( >(&conn, dsl::user_id.eq(user_id.clone())) .await; } + let _ = app_state.redis_conn.delete_key(&code_key).await; return Err(error::ContainerError::from(UserAuthError::StorageError)); } - let email_config = &global_config.email; - if email_config.is_active() { - let email_client = APP_STATE - .get() - .map(|s| s.email_client.clone()) - .ok_or(UserAuthError::StorageError)?; - - let email_msg = crate::email::templates::InviteUserTemplate { - user_email: payload.email.clone(), - merchant_name: merchant_name.clone(), - temporary_password: generated_password.clone(), - base_url: email_config.base_url.clone(), - } - .into_message(); - - if let Err(err) = email_client.send_email(email_msg).await { - crate::logger::warn!( - to = %payload.email, - error = ?err, - "Failed to send invite email; invite still succeeded" - ); - } - } - Ok(Json(InviteMemberResponse { email: payload.email, is_new_user: true, - password: Some(generated_password), role, })) } @@ -1295,37 +1335,6 @@ pub async fn list_members( Ok(Json(members)) } -fn generate_random_password() -> String { - use rand::Rng; - let mut rng = rand::thread_rng(); - let uppercase = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let lowercase = b"abcdefghijklmnopqrstuvwxyz"; - let digits = b"0123456789"; - let special = b"!@#$%^&*"; - - let mut password = vec![ - uppercase[rng.gen_range(0..uppercase.len())] as char, - lowercase[rng.gen_range(0..lowercase.len())] as char, - digits[rng.gen_range(0..digits.len())] as char, - special[rng.gen_range(0..special.len())] as char, - ]; - - let all: Vec = [ - uppercase.as_ref(), - lowercase.as_ref(), - digits.as_ref(), - special.as_ref(), - ] - .concat(); - for _ in 0..12 { - password.push(all[rng.gen_range(0..all.len())] as char); - } - - use rand::seq::SliceRandom; - password.shuffle(&mut rng); - password.into_iter().collect() -} - #[axum::debug_handler] pub async fn logout( headers: HeaderMap, diff --git a/website/src/pages/MembersPage.tsx b/website/src/pages/MembersPage.tsx index de8f874e..9d59c6fa 100644 --- a/website/src/pages/MembersPage.tsx +++ b/website/src/pages/MembersPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { UserPlus, Users, Copy, Check, Loader2, Eye, EyeOff } from 'lucide-react' +import { UserPlus, Users, Loader2 } from 'lucide-react' import { apiFetch } from '../lib/api' import { ErrorMessage } from '../components/ui/ErrorMessage' @@ -12,7 +12,6 @@ interface MemberInfo { interface InviteResponse { email: string is_new_user: boolean - password?: string role: string } @@ -28,30 +27,6 @@ function RoleBadge({ role }: { role: string }) { ) } -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false) - async function copy() { - if (!navigator.clipboard) return - try { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 1800) - } catch { - // clipboard unavailable or denied — fail silently - } - } - return ( - - ) -} - export function MembersPage() { const [members, setMembers] = useState([]) const [loadingMembers, setLoadingMembers] = useState(true) @@ -62,7 +37,6 @@ export function MembersPage() { const [inviting, setInviting] = useState(false) const [inviteError, setInviteError] = useState(null) const [inviteResult, setInviteResult] = useState(null) - const [showPassword, setShowPassword] = useState(false) async function loadMembers() { setLoadingMembers(true) @@ -159,35 +133,13 @@ export function MembersPage() { {inviteResult && ( -

+
{inviteResult.is_new_user ? ( - <> -

- New account created — share these credentials -

-
-
- Email - {inviteResult.email} - -
-
- Password - - {showPassword ? inviteResult.password : '••••••••••••••••'} - - - {inviteResult.password && } -
-
- +

+ Invitation sent to {inviteResult.email} — + they’ll receive an email with a link to set their password (valid for 7 + days). +

) : (

{inviteResult.email} has been added to this merchant.