Bug: feat: blacklist user



Setup user blacklisting.
diff --git a/crates/router/src/analytics.rs b/crates/router/src/analytics.rs
index 3f0febcc592..325ca980243 100644
--- a/crates/router/src/analytics.rs
+++ b/crates/router/src/analytics.rs
@@ -21,7 +21,7 @@ pub mod routes {
         routes::AppState,
         services::{
             api,
-            authentication::{self as auth, AuthToken, AuthenticationData},
+            authentication::{self as auth, AuthenticationData},
             authorization::permissions::Permission,
             ApplicationResponse,
         },
@@ -378,23 +378,13 @@ pub mod routes {
         req: actix_web::HttpRequest,
         json_payload: web::Json<ReportRequest>,
     ) -> impl Responder {
-        let state_ref = &state;
-        let req_headers = &req.headers();
-
         let flow = AnalyticsFlow::GenerateRefundReport;
         Box::pin(api::server_wrap(
             flow,
             state.clone(),
             &req,
             json_payload.into_inner(),
-            |state, auth: AuthenticationData, payload| async move {
-                let jwt_payload =
-                    auth::parse_jwt_payload::<AppState, AuthToken>(req_headers, state_ref).await;
-
-                let user_id = jwt_payload
-                    .change_context(AnalyticsError::UnknownError)?
-                    .user_id;
-
+            |state, (auth, user_id): auth::AuthenticationDataWithUserId, payload| async move {
                 let user = UserInterface::find_user_by_id(&*state.store, &user_id)
                     .await
                     .change_context(AnalyticsError::UnknownError)?;
@@ -430,23 +420,13 @@ pub mod routes {
         req: actix_web::HttpRequest,
         json_payload: web::Json<ReportRequest>,
     ) -> impl Responder {
-        let state_ref = &state;
-        let req_headers = &req.headers();
-
         let flow = AnalyticsFlow::GenerateDisputeReport;
         Box::pin(api::server_wrap(
             flow,
             state.clone(),
             &req,
             json_payload.into_inner(),
-            |state, auth: AuthenticationData, payload| async move {
-                let jwt_payload =
-                    auth::parse_jwt_payload::<AppState, AuthToken>(req_headers, state_ref).await;
-
-                let user_id = jwt_payload
-                    .change_context(AnalyticsError::UnknownError)?
-                    .user_id;
-
+            |state, (auth, user_id): auth::AuthenticationDataWithUserId, payload| async move {
                 let user = UserInterface::find_user_by_id(&*state.store, &user_id)
                     .await
                     .change_context(AnalyticsError::UnknownError)?;
@@ -482,23 +462,13 @@ pub mod routes {
         req: actix_web::HttpRequest,
         json_payload: web::Json<ReportRequest>,
     ) -> impl Responder {
-        let state_ref = &state;
-        let req_headers = &req.headers();
-
         let flow = AnalyticsFlow::GeneratePaymentReport;
         Box::pin(api::server_wrap(
             flow,
             state.clone(),
             &req,
             json_payload.into_inner(),
-            |state, auth: AuthenticationData, payload| async move {
-                let jwt_payload =
-                    auth::parse_jwt_payload::<AppState, AuthToken>(req_headers, state_ref).await;
-
-                let user_id = jwt_payload
-                    .change_context(AnalyticsError::UnknownError)?
-                    .user_id;
-
+            |state, (auth, user_id): auth::AuthenticationDataWithUserId, payload| async move {
                 let user = UserInterface::find_user_by_id(&*state.store, &user_id)
                     .await
                     .change_context(AnalyticsError::UnknownError)?;
diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs
index 387da3c0641..12b688e3d3a 100644
--- a/crates/router/src/consts.rs
+++ b/crates/router/src/consts.rs
@@ -66,12 +66,16 @@ pub const ROUTING_CONFIG_ID_LENGTH: usize = 10;
 pub const LOCKER_REDIS_PREFIX: &str = "LOCKER_PM_TOKEN";
 pub const LOCKER_REDIS_EXPIRY_SECONDS: u32 = 60 * 15; // 15 minutes
 
-#[cfg(any(feature = "olap", feature = "oltp"))]
 pub const JWT_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24 * 2; // 2 days
 
+pub const USER_BLACKLIST_PREFIX: &str = "BU_";
+
 #[cfg(feature = "email")]
 pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day
 
+#[cfg(feature = "email")]
+pub const EMAIL_TOKEN_BLACKLIST_PREFIX: &str = "BET_";
+
 #[cfg(feature = "olap")]
 pub const VERIFY_CONNECTOR_ID_PREFIX: &str = "conn_verify";
 #[cfg(feature = "olap")]
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index ae66728e140..24b6eb9d127 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -264,6 +264,11 @@ pub async fn connect_account(
     }
 }
 
+pub async fn signout(state: AppState, user_from_token: auth::UserFromToken) -> UserResponse<()> {
+    auth::blacklist::insert_user_in_blacklist(&state, &user_from_token.user_id).await?;
+    Ok(ApplicationResponse::StatusOk)
+}
+
 pub async fn change_password(
     state: AppState,
     request: user_api::ChangePasswordRequest,
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 4a726084c2c..a69220231f5 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -965,6 +965,7 @@ impl User {
                 web::resource("/signin").route(web::post().to(user_signin_without_invite_checks)),
             )
             .service(web::resource("/v2/signin").route(web::post().to(user_signin)))
+            .service(web::resource("/signout").route(web::post().to(signout)))
             .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 4cd85efe8d5..a5362162dbc 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -164,6 +164,7 @@ impl From<Flow> for ApiIdentifier {
             | Flow::UserSignUp
             | Flow::UserSignInWithoutInviteChecks
             | Flow::UserSignIn
+            | Flow::Signout
             | Flow::ChangePassword
             | Flow::SetDashboardMetadata
             | Flow::GetMutltipleDashboardMetadata
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index 88e19ddf755..48721afff59 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -116,6 +116,20 @@ pub async fn user_connect_account(
     .await
 }
 
+pub async fn signout(state: web::Data<AppState>, http_req: HttpRequest) -> HttpResponse {
+    let flow = Flow::Signout;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &http_req,
+        (),
+        |state, user, _| user_core::signout(state, user),
+        &auth::DashboardNoPermissionAuth,
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 pub async fn change_password(
     state: web::Data<AppState>,
     http_req: HttpRequest,
diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs
index 7f1e078ad53..221106612f3 100644
--- a/crates/router/src/services/authentication.rs
+++ b/crates/router/src/services/authentication.rs
@@ -36,6 +36,7 @@ use crate::{
     types::domain,
     utils::OptionExt,
 };
+pub mod blacklist;
 
 #[derive(Clone, Debug)]
 pub struct AuthenticationData {
@@ -333,6 +334,9 @@ where
         state: &A,
     ) -> RouterResult<(UserWithoutMerchantFromToken, AuthenticationType)> {
         let payload = parse_jwt_payload::<A, UserAuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         Ok((
             UserWithoutMerchantFromToken {
@@ -495,6 +499,9 @@ where
         state: &A,
     ) -> RouterResult<((), AuthenticationType)> {
         let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         let permissions = authorization::get_permissions(&payload.role_id)?;
         authorization::check_authorization(&self.0, permissions)?;
@@ -521,6 +528,9 @@ where
         state: &A,
     ) -> RouterResult<(UserFromToken, AuthenticationType)> {
         let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         let permissions = authorization::get_permissions(&payload.role_id)?;
         authorization::check_authorization(&self.0, permissions)?;
@@ -556,6 +566,9 @@ where
         state: &A,
     ) -> RouterResult<((), AuthenticationType)> {
         let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         let permissions = authorization::get_permissions(&payload.role_id)?;
         authorization::check_authorization(&self.required_permission, permissions)?;
@@ -585,12 +598,6 @@ where
     Ok(payload)
 }
 
-#[derive(serde::Deserialize)]
-struct JwtAuthPayloadFetchMerchantAccount {
-    merchant_id: String,
-    role_id: String,
-}
-
 #[async_trait]
 impl<A> AuthenticateAndFetch<AuthenticationData, A> for JWTAuth
 where
@@ -601,9 +608,10 @@ where
         request_headers: &HeaderMap,
         state: &A,
     ) -> RouterResult<(AuthenticationData, AuthenticationType)> {
-        let payload =
-            parse_jwt_payload::<A, JwtAuthPayloadFetchMerchantAccount>(request_headers, state)
-                .await?;
+        let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         let permissions = authorization::get_permissions(&payload.role_id)?;
         authorization::check_authorization(&self.0, permissions)?;
@@ -638,6 +646,56 @@ where
     }
 }
 
+pub type AuthenticationDataWithUserId = (AuthenticationData, String);
+
+#[async_trait]
+impl<A> AuthenticateAndFetch<AuthenticationDataWithUserId, A> for JWTAuth
+where
+    A: AppStateInfo + Sync,
+{
+    async fn authenticate_and_fetch(
+        &self,
+        request_headers: &HeaderMap,
+        state: &A,
+    ) -> RouterResult<(AuthenticationDataWithUserId, AuthenticationType)> {
+        let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
+
+        let permissions = authorization::get_permissions(&payload.role_id)?;
+        authorization::check_authorization(&self.0, permissions)?;
+
+        let key_store = state
+            .store()
+            .get_merchant_key_store_by_merchant_id(
+                &payload.merchant_id,
+                &state.store().get_master_key().to_vec().into(),
+            )
+            .await
+            .change_context(errors::ApiErrorResponse::InvalidJwtToken)
+            .attach_printable("Failed to fetch merchant key store for the merchant id")?;
+
+        let merchant = state
+            .store()
+            .find_merchant_account_by_merchant_id(&payload.merchant_id, &key_store)
+            .await
+            .change_context(errors::ApiErrorResponse::InvalidJwtToken)?;
+
+        let auth = AuthenticationData {
+            merchant_account: merchant,
+            key_store,
+        };
+        Ok((
+            (auth.clone(), payload.user_id.clone()),
+            AuthenticationType::MerchantJwt {
+                merchant_id: auth.merchant_account.merchant_id.clone(),
+                user_id: None,
+            },
+        ))
+    }
+}
+
 pub struct DashboardNoPermissionAuth;
 
 #[cfg(feature = "olap")]
@@ -652,6 +710,9 @@ where
         state: &A,
     ) -> RouterResult<(UserFromToken, AuthenticationType)> {
         let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         Ok((
             UserFromToken {
@@ -679,7 +740,10 @@ where
         request_headers: &HeaderMap,
         state: &A,
     ) -> RouterResult<((), AuthenticationType)> {
-        parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if blacklist::check_user_in_blacklist(state, &payload.user_id, payload.exp).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
 
         Ok(((), AuthenticationType::NoAuth))
     }
diff --git a/crates/router/src/services/authentication/blacklist.rs b/crates/router/src/services/authentication/blacklist.rs
new file mode 100644
index 00000000000..6fab28433b4
--- /dev/null
+++ b/crates/router/src/services/authentication/blacklist.rs
@@ -0,0 +1,63 @@
+use std::sync::Arc;
+
+#[cfg(feature = "olap")]
+use common_utils::date_time;
+use error_stack::{IntoReport, ResultExt};
+use redis_interface::RedisConnectionPool;
+
+use crate::{
+    consts::{JWT_TOKEN_TIME_IN_SECS, USER_BLACKLIST_PREFIX},
+    core::errors::{ApiErrorResponse, RouterResult},
+    routes::app::AppStateInfo,
+};
+#[cfg(feature = "olap")]
+use crate::{
+    core::errors::{UserErrors, UserResult},
+    routes::AppState,
+};
+
+#[cfg(feature = "olap")]
+pub async fn insert_user_in_blacklist(state: &AppState, user_id: &str) -> UserResult<()> {
+    let user_blacklist_key = format!("{}{}", USER_BLACKLIST_PREFIX, user_id);
+    let expiry =
+        expiry_to_i64(JWT_TOKEN_TIME_IN_SECS).change_context(UserErrors::InternalServerError)?;
+    let redis_conn = get_redis_connection(state).change_context(UserErrors::InternalServerError)?;
+    redis_conn
+        .set_key_with_expiry(
+            user_blacklist_key.as_str(),
+            date_time::now_unix_timestamp(),
+            expiry,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)
+}
+
+pub async fn check_user_in_blacklist<A: AppStateInfo>(
+    state: &A,
+    user_id: &str,
+    token_expiry: u64,
+) -> RouterResult<bool> {
+    let token = format!("{}{}", USER_BLACKLIST_PREFIX, user_id);
+    let token_issued_at = expiry_to_i64(token_expiry - JWT_TOKEN_TIME_IN_SECS)?;
+    let redis_conn = get_redis_connection(state)?;
+    redis_conn
+        .get_key::<Option<i64>>(token.as_str())
+        .await
+        .change_context(ApiErrorResponse::InternalServerError)
+        .map(|timestamp| timestamp.map_or(false, |timestamp| timestamp > token_issued_at))
+}
+
+fn get_redis_connection<A: AppStateInfo>(state: &A) -> RouterResult<Arc<RedisConnectionPool>> {
+    state
+        .store()
+        .get_redis_conn()
+        .change_context(ApiErrorResponse::InternalServerError)
+        .attach_printable("Failed to get redis connection")
+}
+
+fn expiry_to_i64(expiry: u64) -> RouterResult<i64> {
+    expiry
+        .try_into()
+        .into_report()
+        .change_context(ApiErrorResponse::InternalServerError)
+}
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index ac2dfb47c63..c78455ffbe6 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -285,6 +285,8 @@ pub enum Flow {
     FrmFulfillment,
     /// Change password flow
     ChangePassword,
+    /// Signout flow
+    Signout,
     /// Set Dashboard Metadata flow
     SetDashboardMetadata,
     /// Get Multiple Dashboard Metadata flow

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [X] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
Added blacklist for users.
<!-- Describe your changes in detail -->


### Additional Changes

- [X] This PR modifies the API contract
- [ ] 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
Invalidate the token after logout.
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
Use the below api to logout.

```
curl --location --request POST '<URL>/user/signout' \
--header 'Authorization: Bearer JWT'
```

When you use the same token as above to test any other api then response will be `401`.

<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->


## 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
- [ ] I added a [CHANGELOG](/CHANGELOG.md) entry if applicable
