Issue:
Bug: Force password reset



- Force user to change the password after certain amount of time.
- Force user to set password after user lands to dashboard without setting his/her password (invite flow).

Pull request description:
## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [x] Enhancement
- [x] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
- Allow users to set password when newly signing in
- Allow users to force change password after specified duration of time

### Additional Changes

- [x] This PR modifies the API contract
- [x] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
Closes [#4411](https://github.com/juspay/hyperswitch/issues/4411)


## How did you test it?
Use the curl:
```
curl --location 'http://localhost:8080/user/rotate_password' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer JWT' \
--data '{
    "password": "new_password"
}'
```

Response will 200 Ok for all successful password change.

If current password and new password is same then response will be Invalid password error.

## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible


Testing:
Use the curl:
```
curl --location 'http://localhost:8080/user/rotate_password' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer JWT' \
--data '{
    "password": "new_password"
}'
```

Response will 200 Ok for all successful password change.

If current password and new password is same then response will be Invalid password error.

Patch:
diff --git a/config/config.example.toml b/config/config.example.toml
index f0dad483c65..c9fb0f34740 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -345,6 +345,9 @@ active_email_client = "SES"          # The currently active email client
 email_role_arn = ""        # The amazon resource name ( arn ) of the role which has permission to send emails
 sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session.
 
+[user]
+password_validity_in_days = 90        # Number of days after which password should be updated
+
 #tokenization configuration which describe token lifetime and payment method for specific connector
 [tokenization]
 stripe = { long_lived_token = false, payment_method = "wallet", payment_method_type = { type = "disable_only", list = "google_pay" } }
diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml
index faf3bb7f466..7596fd5cd6b 100644
--- a/config/deployments/integration_test.toml
+++ b/config/deployments/integration_test.toml
@@ -108,6 +108,9 @@ refund_tolerance = 100                                                  # Fake d
 refund_ttl = 172800                                                     # Time to live for dummy connector refund in redis
 slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2awm23agh-p_G5xNpziv6yAiedTkkqLg"    # Slack invite url for hyperswitch
 
+[user]
+password_validity_in_days = 90
+
 [frm]
 enabled = true
 
diff --git a/config/deployments/production.toml b/config/deployments/production.toml
index f564b976bc0..747bb269a18 100644
--- a/config/deployments/production.toml
+++ b/config/deployments/production.toml
@@ -115,6 +115,9 @@ refund_tolerance = 100                                                  # Fake d
 refund_ttl = 172800                                                     # Time to live for dummy connector refund in redis
 slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2awm23agh-p_G5xNpziv6yAiedTkkqLg"    # Slack invite url for hyperswitch
 
+[user]
+password_validity_in_days = 90
+
 [frm]
 enabled = false
 
diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml
index 35c8a2ba3d6..fb669dfbdb8 100644
--- a/config/deployments/sandbox.toml
+++ b/config/deployments/sandbox.toml
@@ -115,6 +115,9 @@ refund_tolerance = 100                                                  # Fake d
 refund_ttl = 172800                                                     # Time to live for dummy connector refund in redis
 slack_invite_url = "https://join.slack.com/t/hyperswitch-io/shared_invite/zt-2awm23agh-p_G5xNpziv6yAiedTkkqLg"    # Slack invite url for hyperswitch
 
+[user]
+password_validity_in_days = 90
+
 [frm]
 enabled = true
 
diff --git a/config/development.toml b/config/development.toml
index 3f05872bc6a..a7b7f4000ea 100644
--- a/config/development.toml
+++ b/config/development.toml
@@ -261,6 +261,9 @@ active_email_client = "SES"
 email_role_arn = ""
 sts_role_session_name = ""
 
+[user]
+password_validity_in_days = 90
+
 [bank_config.eps]
 stripe = { banks = "arzte_und_apotheker_bank,austrian_anadi_bank_ag,bank_austria,bankhaus_carl_spangler,bankhaus_schelhammer_und_schattera_ag,bawag_psk_ag,bks_bank_ag,brull_kallmus_bank_ag,btv_vier_lander_bank,capital_bank_grawe_gruppe_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_alpeadriabank_international_ag,hypo_noe_lb_fur_niederosterreich_u_wien,hypo_oberosterreich_salzburg_steiermark,hypo_tirol_bank_ag,hypo_vorarlberg_bank_ag,hypo_bank_burgenland_aktiengesellschaft,marchfelder_bank,oberbank_ag,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag,vr_bank_braunau" }
 adyen = { banks = "bank_austria,bawag_psk_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_tirol_bank_ag,posojilnica_bank_e_gen,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag" }
diff --git a/config/docker_compose.toml b/config/docker_compose.toml
index 9fd22f46707..f11c2e19045 100644
--- a/config/docker_compose.toml
+++ b/config/docker_compose.toml
@@ -51,6 +51,9 @@ jwt_secret = "secret"
 master_enc_key = "73ad7bbbbc640c845a150f67d058b279849370cd2c1f3c67c4dd6c869213e13a"
 recon_admin_api_key = "recon_test_admin"
 
+[user]
+password_validity_in_days = 90
+
 [locker]
 host = ""
 host_rs = ""
diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index b29b28f0136..dab9ace3ac2 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -14,8 +14,8 @@ use crate::user::{
     CreateInternalUserRequest, DashboardEntryResponse, ForgotPasswordRequest,
     GetUserDetailsResponse, GetUserRoleDetailsRequest, GetUserRoleDetailsResponse,
     InviteUserRequest, ListUsersResponse, ReInviteUserRequest, ResetPasswordRequest,
-    SendVerifyEmailRequest, SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest,
-    SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse,
+    RotatePasswordRequest, SendVerifyEmailRequest, SignInResponse, SignUpRequest,
+    SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, TokenOrPayloadResponse, TokenResponse,
     UpdateUserAccountDetailsRequest, UserFromEmailRequest, UserMerchantCreate, VerifyEmailRequest,
 };
 
@@ -60,6 +60,7 @@ common_utils::impl_misc_api_event_type!(
     ConnectAccountRequest,
     ForgotPasswordRequest,
     ResetPasswordRequest,
+    RotatePasswordRequest,
     InviteUserRequest,
     ReInviteUserRequest,
     VerifyEmailRequest,
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index fe4e37f8a8c..b2128cc949c 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -91,6 +91,11 @@ pub struct ResetPasswordRequest {
     pub password: Secret<String>,
 }
 
+#[derive(serde::Deserialize, Debug, serde::Serialize)]
+pub struct RotatePasswordRequest {
+    pub password: Secret<String>,
+}
+
 #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
 pub struct InviteUserRequest {
     pub email: pii::Email,
diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs
index 3c86064731b..96e91f0c738 100644
--- a/crates/common_enums/src/enums.rs
+++ b/crates/common_enums/src/enums.rs
@@ -2716,6 +2716,7 @@ pub enum TokenPurpose {
     TOTP,
     VerifyEmail,
     AcceptInvitationFromEmail,
+    ForceSetPassword,
     ResetPassword,
     AcceptInvite,
     UserInfo,
diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs
index 2e28bc5d30f..810d82d321f 100644
--- a/crates/diesel_models/src/schema.rs
+++ b/crates/diesel_models/src/schema.rs
@@ -1191,6 +1191,7 @@ diesel::table! {
         last_modified_at -> Timestamp,
         #[max_length = 64]
         preferred_merchant_id -> Nullable<Varchar>,
+        last_password_modified_at -> Nullable<Timestamp>,
     }
 }
 
diff --git a/crates/diesel_models/src/user.rs b/crates/diesel_models/src/user.rs
index 84fe8710060..850619f8af6 100644
--- a/crates/diesel_models/src/user.rs
+++ b/crates/diesel_models/src/user.rs
@@ -20,6 +20,7 @@ pub struct User {
     pub created_at: PrimitiveDateTime,
     pub last_modified_at: PrimitiveDateTime,
     pub preferred_merchant_id: Option<String>,
+    pub last_password_modified_at: Option<PrimitiveDateTime>,
 }
 
 #[derive(
@@ -35,6 +36,7 @@ pub struct UserNew {
     pub created_at: Option<PrimitiveDateTime>,
     pub last_modified_at: Option<PrimitiveDateTime>,
     pub preferred_merchant_id: Option<String>,
+    pub last_password_modified_at: Option<PrimitiveDateTime>,
 }
 
 #[derive(Clone, Debug, AsChangeset, router_derive::DebugAsDisplay)]
@@ -45,6 +47,7 @@ pub struct UserUpdateInternal {
     is_verified: Option<bool>,
     last_modified_at: PrimitiveDateTime,
     preferred_merchant_id: Option<String>,
+    last_password_modified_at: Option<PrimitiveDateTime>,
 }
 
 #[derive(Debug)]
@@ -52,10 +55,12 @@ pub enum UserUpdate {
     VerifyUser,
     AccountUpdate {
         name: Option<String>,
-        password: Option<Secret<String>>,
         is_verified: Option<bool>,
         preferred_merchant_id: Option<String>,
     },
+    PasswordUpdate {
+        password: Option<Secret<String>>,
+    },
 }
 
 impl From<UserUpdate> for UserUpdateInternal {
@@ -68,18 +73,27 @@ impl From<UserUpdate> for UserUpdateInternal {
                 is_verified: Some(true),
                 last_modified_at,
                 preferred_merchant_id: None,
+                last_password_modified_at: None,
             },
             UserUpdate::AccountUpdate {
                 name,
-                password,
                 is_verified,
                 preferred_merchant_id,
             } => Self {
                 name,
-                password,
+                password: None,
                 is_verified,
                 last_modified_at,
                 preferred_merchant_id,
+                last_password_modified_at: None,
+            },
+            UserUpdate::PasswordUpdate { password } => Self {
+                name: None,
+                password,
+                is_verified: None,
+                last_modified_at,
+                preferred_merchant_id: None,
+                last_password_modified_at: Some(last_modified_at),
             },
         }
     }
diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs
index 423f8e03d23..dc8b5dd6bd5 100644
--- a/crates/router/src/configs/secrets_transformers.rs
+++ b/crates/router/src/configs/secrets_transformers.rs
@@ -334,6 +334,7 @@ pub(crate) async fn fetch_raw_secrets(
         dummy_connector: conf.dummy_connector,
         #[cfg(feature = "email")]
         email: conf.email,
+        user: conf.user,
         mandates: conf.mandates,
         network_transaction_id_supported_connectors: conf
             .network_transaction_id_supported_connectors,
diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs
index adc09fb55af..5d2077b8829 100644
--- a/crates/router/src/configs/settings.rs
+++ b/crates/router/src/configs/settings.rs
@@ -89,6 +89,7 @@ pub struct Settings<S: SecretState> {
     pub dummy_connector: DummyConnector,
     #[cfg(feature = "email")]
     pub email: EmailSettings,
+    pub user: UserSettings,
     pub cors: CorsSettings,
     pub mandates: Mandates,
     pub network_transaction_id_supported_connectors: NetworkTransactionIdSupportedConnectors,
@@ -392,6 +393,11 @@ pub struct Secrets {
     pub master_enc_key: Secret<String>,
 }
 
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct UserSettings {
+    pub password_validity_in_days: u16,
+}
+
 #[derive(Debug, Deserialize, Clone)]
 #[serde(default)]
 pub struct Locker {
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index 8a7d77cdbc9..ddcd10c32e4 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -198,7 +198,7 @@ impl UserErrors {
             Self::IpAddressParsingFailed => "Something went wrong",
             Self::InvalidMetadataRequest => "Invalid Metadata Request",
             Self::MerchantIdParsingError => "Invalid Merchant Id",
-            Self::ChangePasswordError => "Old and new password cannot be the same",
+            Self::ChangePasswordError => "Old and new password cannot be same",
             Self::InvalidDeleteOperation => "Delete Operation Not Supported",
             Self::MaxInvitationsError => "Maximum invite count per request exceeded",
             Self::RoleNotFound => "Role Not Found",
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 6ad0afa9104..e51ad6120c9 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -349,11 +349,8 @@ pub async fn change_password(
         .store
         .update_user_by_user_id(
             user.get_user_id(),
-            diesel_models::user::UserUpdate::AccountUpdate {
-                name: None,
+            diesel_models::user::UserUpdate::PasswordUpdate {
                 password: Some(new_password_hash),
-                is_verified: None,
-                preferred_merchant_id: None,
             },
         )
         .await
@@ -419,9 +416,48 @@ pub async fn forgot_password(
     Ok(ApplicationResponse::StatusOk)
 }
 
+pub async fn rotate_password(
+    state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
+    request: user_api::RotatePasswordRequest,
+    _req_state: ReqState,
+) -> UserResponse<()> {
+    let user: domain::UserFromStorage = state
+        .store
+        .find_user_by_id(&user_token.user_id)
+        .await
+        .change_context(UserErrors::InternalServerError)?
+        .into();
+
+    let password = domain::UserPassword::new(request.password.to_owned())?;
+    let hash_password = utils::user::password::generate_password_hash(password.get_secret())?;
+
+    if user.compare_password(request.password).is_ok() {
+        return Err(UserErrors::ChangePasswordError.into());
+    }
+
+    let user = state
+        .store
+        .update_user_by_user_id(
+            &user_token.user_id,
+            storage_user::UserUpdate::PasswordUpdate {
+                password: Some(hash_password),
+            },
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    let _ = auth::blacklist::insert_user_in_blacklist(&state, &user.user_id)
+        .await
+        .map_err(|e| logger::error!(?e));
+
+    Ok(ApplicationResponse::StatusOk)
+}
+
 #[cfg(feature = "email")]
-pub async fn reset_password(
+pub async fn reset_password_token_only_flow(
     state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
     request: user_api::ResetPasswordRequest,
 ) -> UserResponse<()> {
     let token = request.token.expose();
@@ -431,8 +467,60 @@ pub async fn reset_password(
 
     auth::blacklist::check_email_token_in_blacklist(&state, &token).await?;
 
+    let user_from_db: domain::UserFromStorage = state
+        .store
+        .find_user_by_email(
+            &email_token
+                .get_email()
+                .change_context(UserErrors::InternalServerError)?,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?
+        .into();
+
+    if user_from_db.get_user_id() != user_token.user_id {
+        return Err(UserErrors::LinkInvalid.into());
+    }
+
     let password = domain::UserPassword::new(request.password)?;
+    let hash_password = utils::user::password::generate_password_hash(password.get_secret())?;
+
+    let user = state
+        .store
+        .update_user_by_email(
+            &email_token
+                .get_email()
+                .change_context(UserErrors::InternalServerError)?,
+            storage_user::UserUpdate::PasswordUpdate {
+                password: Some(hash_password),
+            },
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
 
+    let _ = auth::blacklist::insert_email_token_in_blacklist(&state, &token)
+        .await
+        .map_err(|e| logger::error!(?e));
+    let _ = auth::blacklist::insert_user_in_blacklist(&state, &user.user_id)
+        .await
+        .map_err(|e| logger::error!(?e));
+
+    Ok(ApplicationResponse::StatusOk)
+}
+
+#[cfg(feature = "email")]
+pub async fn reset_password(
+    state: AppState,
+    request: user_api::ResetPasswordRequest,
+) -> UserResponse<()> {
+    let token = request.token.expose();
+    let email_token = auth::decode_jwt::<email_types::EmailToken>(&token, &state)
+        .await
+        .change_context(UserErrors::LinkInvalid)?;
+
+    auth::blacklist::check_email_token_in_blacklist(&state, &token).await?;
+
+    let password = domain::UserPassword::new(request.password)?;
     let hash_password = utils::user::password::generate_password_hash(password.get_secret())?;
 
     let user = state
@@ -441,11 +529,8 @@ pub async fn reset_password(
             &email_token
                 .get_email()
                 .change_context(UserErrors::InternalServerError)?,
-            storage_user::UserUpdate::AccountUpdate {
-                name: None,
+            storage_user::UserUpdate::PasswordUpdate {
                 password: Some(hash_password),
-                is_verified: Some(true),
-                preferred_merchant_id: None,
             },
         )
         .await
@@ -1449,7 +1534,6 @@ pub async fn update_user_details(
 
     let user_update = storage_user::UserUpdate::AccountUpdate {
         name: name.map(|x| x.get_secret().expose()),
-        password: None,
         is_verified: None,
         preferred_merchant_id: req.preferred_merchant_id,
     };
diff --git a/crates/router/src/db/user.rs b/crates/router/src/db/user.rs
index 47b3d376a16..9ec7cf6fab4 100644
--- a/crates/router/src/db/user.rs
+++ b/crates/router/src/db/user.rs
@@ -162,6 +162,7 @@ impl UserInterface for MockDb {
             created_at: user_data.created_at.unwrap_or(time_now),
             last_modified_at: user_data.created_at.unwrap_or(time_now),
             preferred_merchant_id: user_data.preferred_merchant_id,
+            last_password_modified_at: user_data.last_password_modified_at,
         };
         users.push(user.clone());
         Ok(user)
@@ -218,18 +219,21 @@ impl UserInterface for MockDb {
                     },
                     storage::UserUpdate::AccountUpdate {
                         name,
-                        password,
                         is_verified,
                         preferred_merchant_id,
                     } => storage::User {
                         name: name.clone().map(Secret::new).unwrap_or(user.name.clone()),
-                        password: password.clone().unwrap_or(user.password.clone()),
                         is_verified: is_verified.unwrap_or(user.is_verified),
                         preferred_merchant_id: preferred_merchant_id
                             .clone()
                             .or(user.preferred_merchant_id.clone()),
                         ..user.to_owned()
                     },
+                    storage::UserUpdate::PasswordUpdate { password } => storage::User {
+                        password: password.clone().unwrap_or(user.password.clone()),
+                        last_password_modified_at: Some(common_utils::date_time::now()),
+                        ..user.to_owned()
+                    },
                 };
                 user.to_owned()
             })
@@ -258,18 +262,21 @@ impl UserInterface for MockDb {
                     },
                     storage::UserUpdate::AccountUpdate {
                         name,
-                        password,
                         is_verified,
                         preferred_merchant_id,
                     } => storage::User {
                         name: name.clone().map(Secret::new).unwrap_or(user.name.clone()),
-                        password: password.clone().unwrap_or(user.password.clone()),
                         is_verified: is_verified.unwrap_or(user.is_verified),
                         preferred_merchant_id: preferred_merchant_id
                             .clone()
                             .or(user.preferred_merchant_id.clone()),
                         ..user.to_owned()
                     },
+                    storage::UserUpdate::PasswordUpdate { password } => storage::User {
+                        password: password.clone().unwrap_or(user.password.clone()),
+                        last_password_modified_at: Some(common_utils::date_time::now()),
+                        ..user.to_owned()
+                    },
                 };
                 user.to_owned()
             })
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 6a8a4ee5e03..b7b2e8a2d70 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1184,6 +1184,7 @@ impl User {
             .service(web::resource("").route(web::get().to(get_user_details)))
             .service(web::resource("/v2/signin").route(web::post().to(user_signin)))
             .service(web::resource("/signout").route(web::post().to(signout)))
+            .service(web::resource("/rotate_password").route(web::post().to(rotate_password)))
             .service(web::resource("/change_password").route(web::post().to(change_password)))
             .service(web::resource("/internal_signup").route(web::post().to(internal_user_signup)))
             .service(web::resource("/switch_merchant").route(web::post().to(switch_merchant_id)))
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index bf91f8055d1..a308c3e8908 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -203,6 +203,7 @@ impl From<Flow> for ApiIdentifier {
             | Flow::ListUsersForMerchantAccount
             | Flow::ForgotPassword
             | Flow::ResetPassword
+            | Flow::RotatePassword
             | Flow::InviteMultipleUser
             | Flow::ReInviteUser
             | Flow::UserSignUpWithMerchantId
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index a3f18168501..da444559084 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -5,6 +5,7 @@ use api_models::{
     errors::types::ApiErrorResponse,
     user::{self as user_api},
 };
+use common_enums::TokenPurpose;
 use common_utils::errors::ReportSwitchExt;
 use router_env::Flow;
 
@@ -357,43 +358,78 @@ pub async fn list_users_for_merchant_account(
     .await
 }
 
-#[cfg(feature = "email")]
-pub async fn forgot_password(
+pub async fn rotate_password(
     state: web::Data<AppState>,
     req: HttpRequest,
-    payload: web::Json<user_api::ForgotPasswordRequest>,
+    payload: web::Json<user_api::RotatePasswordRequest>,
 ) -> HttpResponse {
-    let flow = Flow::ForgotPassword;
+    let flow = Flow::RotatePassword;
     Box::pin(api::server_wrap(
         flow,
         state.clone(),
         &req,
         payload.into_inner(),
-        |state, _, payload, _| user_core::forgot_password(state, payload),
-        &auth::NoAuth,
+        user_core::rotate_password,
+        &auth::SinglePurposeJWTAuth(TokenPurpose::ForceSetPassword),
         api_locking::LockAction::NotApplicable,
     ))
     .await
 }
 
 #[cfg(feature = "email")]
-pub async fn reset_password(
+pub async fn forgot_password(
     state: web::Data<AppState>,
     req: HttpRequest,
-    payload: web::Json<user_api::ResetPasswordRequest>,
+    payload: web::Json<user_api::ForgotPasswordRequest>,
 ) -> HttpResponse {
-    let flow = Flow::ResetPassword;
+    let flow = Flow::ForgotPassword;
     Box::pin(api::server_wrap(
         flow,
         state.clone(),
         &req,
         payload.into_inner(),
-        |state, _, payload, _| user_core::reset_password(state, payload),
+        |state, _, payload, _| user_core::forgot_password(state, payload),
         &auth::NoAuth,
         api_locking::LockAction::NotApplicable,
     ))
     .await
 }
+
+#[cfg(feature = "email")]
+pub async fn reset_password(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    payload: web::Json<user_api::ResetPasswordRequest>,
+    query: web::Query<user_api::TokenOnlyQueryParam>,
+) -> HttpResponse {
+    let flow = Flow::ResetPassword;
+    let is_token_only = query.into_inner().token_only;
+    if let Some(true) = is_token_only {
+        Box::pin(api::server_wrap(
+            flow,
+            state.clone(),
+            &req,
+            payload.into_inner(),
+            |state, user, payload, _| {
+                user_core::reset_password_token_only_flow(state, user, payload)
+            },
+            &auth::SinglePurposeJWTAuth(TokenPurpose::ResetPassword),
+            api_locking::LockAction::NotApplicable,
+        ))
+        .await
+    } else {
+        Box::pin(api::server_wrap(
+            flow,
+            state.clone(),
+            &req,
+            payload.into_inner(),
+            |state, _, payload, _| user_core::reset_password(state, payload),
+            &auth::NoAuth,
+            api_locking::LockAction::NotApplicable,
+        ))
+        .await
+    }
+}
 pub async fn invite_multiple_user(
     state: web::Data<AppState>,
     req: HttpRequest,
diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs
index b4b5df2f46c..882a3ab0d14 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -774,6 +774,26 @@ impl UserFromStorage {
         Ok(Some(days_left_for_verification.whole_days()))
     }
 
+    pub fn is_password_rotate_required(&self, state: &AppState) -> UserResult<bool> {
+        let last_password_modified_at =
+            if let Some(last_password_modified_at) = self.0.last_password_modified_at {
+                last_password_modified_at.date()
+            } else {
+                return Ok(true);
+            };
+
+        let password_change_duration =
+            time::Duration::days(state.conf.user.password_validity_in_days.into());
+        let last_date_for_password_rotate = last_password_modified_at
+            .checked_add(password_change_duration)
+            .ok_or(UserErrors::InternalServerError)?;
+
+        let today = common_utils::date_time::now().date();
+        let days_left_for_password_rotate = last_date_for_password_rotate - today;
+
+        Ok(days_left_for_password_rotate.whole_days() < 0)
+    }
+
     pub fn get_preferred_merchant_id(&self) -> Option<String> {
         self.0.preferred_merchant_id.clone()
     }
diff --git a/crates/router/src/types/domain/user/decision_manager.rs b/crates/router/src/types/domain/user/decision_manager.rs
index 616c595b244..d635ac064fe 100644
--- a/crates/router/src/types/domain/user/decision_manager.rs
+++ b/crates/router/src/types/domain/user/decision_manager.rs
@@ -44,8 +44,7 @@ impl SPTFlow {
             Self::AcceptInvitationFromEmail | Self::ResetPassword => Ok(true),
             Self::VerifyEmail => Ok(user.0.is_verified),
             // Final Checks
-            // TODO: this should be based on last_password_modified_at as a placeholder using false
-            Self::ForceSetPassword => Ok(false),
+            Self::ForceSetPassword => user.is_password_rotate_required(state),
             Self::MerchantSelect => user
                 .get_roles_from_db(state)
                 .await
@@ -159,8 +158,9 @@ const ACCEPT_INVITATION_FROM_EMAIL_FLOW: [UserFlow; 5] = [
     UserFlow::JWTFlow(JWTFlow::UserInfo),
 ];
 
-const RESET_PASSWORD_FLOW: [UserFlow; 2] = [
+const RESET_PASSWORD_FLOW: [UserFlow; 3] = [
     UserFlow::SPTFlow(SPTFlow::TOTP),
+    UserFlow::SPTFlow(SPTFlow::VerifyEmail),
     UserFlow::SPTFlow(SPTFlow::ResetPassword),
 ];
 
@@ -286,7 +286,8 @@ impl From<SPTFlow> for TokenPurpose {
             SPTFlow::VerifyEmail => Self::VerifyEmail,
             SPTFlow::AcceptInvitationFromEmail => Self::AcceptInvitationFromEmail,
             SPTFlow::MerchantSelect => Self::AcceptInvite,
-            SPTFlow::ResetPassword | SPTFlow::ForceSetPassword => Self::ResetPassword,
+            SPTFlow::ResetPassword => Self::ResetPassword,
+            SPTFlow::ForceSetPassword => Self::ForceSetPassword,
         }
     }
 }
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index e8ffe0685b2..e2b621e2132 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -362,6 +362,8 @@ pub enum Flow {
     ForgotPassword,
     /// Reset password using link
     ResetPassword,
+    /// Force set or force change password
+    RotatePassword,
     /// Invite multiple users
     InviteMultipleUser,
     /// Reinvite user
diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml
index 799582c733f..73136a191cd 100644
--- a/loadtest/config/development.toml
+++ b/loadtest/config/development.toml
@@ -28,6 +28,9 @@ host = "redis-queue"
 admin_api_key = "test_admin"
 jwt_secret = "secret"
 
+[user]
+password_validity_in_days = 90
+
 [locker]
 host = ""
 host_rs = ""
diff --git a/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/down.sql b/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/down.sql
new file mode 100644
index 00000000000..89253d525ce
--- /dev/null
+++ b/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/down.sql
@@ -0,0 +1,2 @@
+-- This file should undo anything in `up.sql`
+ALTER TABLE users DROP COLUMN IF EXISTS last_password_modified_at;
\ No newline at end of file
diff --git a/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/up.sql b/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/up.sql
new file mode 100644
index 00000000000..d5846b0fad4
--- /dev/null
+++ b/migrations/2024-05-07-092445_add_last_password_modified_at_column_to_users/up.sql
@@ -0,0 +1,2 @@
+-- Your SQL goes here
+ALTER TABLE users ADD COLUMN IF NOT EXISTS last_password_modified_at TIMESTAMP;
\ No newline at end of file