diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs new file mode 100644 index 0000000000..c99f46f59d --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs @@ -0,0 +1,51 @@ +use chrono::{DateTime, Utc}; +use sqlx::{FromRow, MySqlPool, Row}; + +use tokens::tokens::users::UserToken; + +pub struct UserImpersonationRequestRecord { + pub token: String, + pub impersonated_user_token: UserToken, + pub impersonator_user_token: UserToken, + pub public_impersonation_token: String, + pub is_redeemed: bool, + pub expires_at: DateTime, +} + +/// Look up a user impersonation request by the public impersonation token (the "password"). +pub async fn lookup_user_impersonation_request( + public_impersonation_token: &str, + mysql_pool: &MySqlPool, +) -> Result, sqlx::Error> { + let maybe_row = sqlx::query( + r#" +SELECT + token, + impersonated_user_token, + impersonator_user_token, + public_impersonation_token, + is_redeemed, + expires_at +FROM user_impersonation_requests +WHERE public_impersonation_token = ? +LIMIT 1 + "#, + ) + .bind(public_impersonation_token) + .fetch_optional(mysql_pool) + .await?; + + let row = match maybe_row { + Some(row) => row, + None => return Ok(None), + }; + + Ok(Some(UserImpersonationRequestRecord { + token: row.get("token"), + impersonated_user_token: UserToken::new_from_str(row.get("impersonated_user_token")), + impersonator_user_token: UserToken::new_from_str(row.get("impersonator_user_token")), + public_impersonation_token: row.get("public_impersonation_token"), + is_redeemed: row.get("is_redeemed"), + expires_at: row.get("expires_at"), + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs new file mode 100644 index 0000000000..9950a14ad6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs @@ -0,0 +1,35 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +pub struct MarkImpersonationTokenAsRedeemedArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + pub public_impersonation_token: &'a str, + pub ip_address_redemption: &'a str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn mark_impersonation_token_as_redeemed<'a, 'c, E>( + args: MarkImpersonationTokenAsRedeemedArgs<'a, 'c, E>, +) -> Result<(), sqlx::Error> + where E: 'a + Executor<'c, Database = MySql> +{ + sqlx::query( + r#" +UPDATE user_impersonation_requests +SET + is_redeemed = TRUE, + ip_address_redemption = ? +WHERE public_impersonation_token = ? + "#, + ) + .bind(args.ip_address_redemption) + .bind(args.public_impersonation_token) + .execute(args.mysql_executor) + .await?; + + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs index 6b582f6f70..72dbbdfac9 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs @@ -1 +1,3 @@ pub mod insert_user_impersonation_request; +pub mod lookup_user_impersonation_request; +pub mod mark_impersonation_token_as_redeemed; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs new file mode 100644 index 0000000000..b194d36e8c --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs @@ -0,0 +1,53 @@ +use std::marker::PhantomData; + +use chrono::{DateTime, Utc}; +use sqlx::{Executor, MySql}; + +use tokens::tokens::user_sessions::UserSessionToken; +use tokens::tokens::users::UserToken; + +pub struct CreateImpersonatedUserSessionArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + /// The user being impersonated (the session will act as this user). + pub user_token: &'a UserToken, + + /// The staff/moderator user who initiated the impersonation. + pub impersonator_user_token: &'a UserToken, + + pub ip_address: &'a str, + pub expires_at: DateTime, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn create_impersonated_user_session<'a, 'c, E>( + args: CreateImpersonatedUserSessionArgs<'a, 'c, E>, +) -> Result + where E: 'a + Executor<'c, Database = MySql> +{ + let session_token = UserSessionToken::generate(); + + sqlx::query( + r#" +INSERT INTO user_sessions ( + token, + user_token, + maybe_impersonation_user_token, + ip_address_creation, + expires_at +) +VALUES (?, ?, ?, ?, ?) + "#, + ) + .bind(session_token.as_str()) + .bind(args.user_token.as_str()) + .bind(args.impersonator_user_token.as_str()) + .bind(args.ip_address) + .bind(args.expires_at) + .execute(args.mysql_executor) + .await?; + + Ok(session_token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs index a591ae7554..bf2073a9d1 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs @@ -1,3 +1,4 @@ +pub mod create_impersonated_user_session; pub mod create_user_session; pub mod create_user_session_with_transactor; pub mod delete_user_session; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs index 5a8db5c8ab..f1a9a77419 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs @@ -1,15 +1,29 @@ use std::fmt; use std::fmt::Formatter; +use std::marker::PhantomData; use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{Duration, Utc}; +use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; +use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; use http_server_common::request::get_request_ip::get_request_ip; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use log::{info, warn}; +use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{ + insert_staff_audit_log, InsertStaffAuditLogArgs, +}; +use mysql_queries::queries::user_impersonation_requests::lookup_user_impersonation_request::lookup_user_impersonation_request; +use mysql_queries::queries::user_impersonation_requests::mark_impersonation_token_as_redeemed::{ + mark_impersonation_token_as_redeemed, MarkImpersonationTokenAsRedeemedArgs, +}; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_email::lookup_user_for_login_by_email; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_username::lookup_user_for_login_by_username; +use mysql_queries::queries::users::user_sessions::create_impersonated_user_session::{ + create_impersonated_user_session, CreateImpersonatedUserSessionArgs, +}; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; use password::bcrypt_confirm_password::bcrypt_confirm_password; use sqlx::MySqlPool; @@ -130,51 +144,56 @@ pub async fn login_handler( } }; - match bcrypt_confirm_password(request.password.clone(), &actual_hash) { + let ip_address = get_request_ip(&http_request); + + // Determine the password error to return if impersonation also fails. + let password_error = match bcrypt_confirm_password(request.password.clone(), &actual_hash) { + Ok(true) => None, // Password is valid — normal login. + Ok(false) => Some(LoginErrorResponse::invalid_credentials()), Err(PasswordConfirmError::HashIsSentinelValue) => { - warn!("Attempting to log in with a passwordless account"); - return Err(LoginErrorResponse::account_without_password()); + Some(LoginErrorResponse::account_without_password()) } Err(e) => { warn!("Login hash comparison error: {:?}", e); - return Err(LoginErrorResponse::server_error()); - } - Ok(is_valid) => { - if !is_valid { - info!("invalid credentials"); - return Err(LoginErrorResponse::invalid_credentials()); - } - // Good to go...! - }, - } - - let ip_address = get_request_ip(&http_request); - - let create_session_result = - create_user_session(&user.token.0, &ip_address, &mysql_pool).await; - - let session_token = match create_session_result { - Ok(token) => token, - Err(e) => { - warn!("login create session error : {:?}", e); - return Err(LoginErrorResponse::server_error()); + Some(LoginErrorResponse::server_error()) } }; - info!("login session created for user: {} / {:?}", &user.token, &user.token); + let session_token = if password_error.is_none() { + // Normal login path — password matched. + let create_session_result = + create_user_session(&user.token.0, &ip_address, &mysql_pool).await; + let token = match create_session_result { + Ok(token) => token, + Err(e) => { + warn!("login create session error: {:?}", e); + return Err(LoginErrorResponse::server_error()); + } + }; - let user_feature_flags = - UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref()); + info!("login session created for user: {} / {:?}", &user.token, &user.token); - // NB: Enroll new users in studio for a while. - enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await - .map_err(|e| { - warn!("error enrolling in studio: {:?}", e); - }).ok(); + let user_feature_flags = + UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref()); + // NB: Enroll new users in studio for a while. + enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await + .map_err(|e| { + warn!("error enrolling in studio: {:?}", e); + }).ok(); - let session_token = UserSessionToken::new_from_str(&session_token); + UserSessionToken::new_from_str(&token) + } else { + // Password didn't match — try impersonation token as a fallback. + match try_impersonation_login(&user.token, &request.password, &ip_address, &mysql_pool).await { + Ok(session_token) => session_token, + Err(_) => { + // Impersonation also failed; return the original password error. + return Err(password_error.unwrap()); + } + } + }; let session_cookie = match session_cookie_manager.create_cookie(&session_token, &user.token) { Ok(cookie) => cookie, @@ -199,3 +218,118 @@ pub async fn login_handler( .content_type("application/json") .body(body)) } + +/// Attempt to log in via a moderator impersonation token (supplied in the password field). +/// +/// Returns the impersonated user's session token on success, or an error if the +/// impersonation token is invalid, expired, already redeemed, or doesn't match. +async fn try_impersonation_login( + target_user_token: &tokens::tokens::users::UserToken, + password: &str, + ip_address: &str, + mysql_pool: &MySqlPool, +) -> Result { + + let record = lookup_user_impersonation_request(password, mysql_pool) + .await + .map_err(|err| { + warn!("Impersonation lookup error: {:?}", err); + LoginErrorResponse::server_error() + })? + .ok_or_else(|| { + info!("No impersonation request found for provided token"); + LoginErrorResponse::invalid_credentials() + })?; + + // Validate: not already redeemed. + if record.is_redeemed { + warn!("Impersonation token already redeemed: {}", record.token); + return Err(LoginErrorResponse::invalid_credentials()); + } + + // Validate: not expired. + if Utc::now() > record.expires_at { + warn!("Impersonation token expired: {} (expired at {:?})", record.token, record.expires_at); + return Err(LoginErrorResponse::invalid_credentials()); + } + + // Validate: the impersonated user matches the user we're logging into. + if record.impersonated_user_token != *target_user_token { + warn!( + "Impersonation token user mismatch: token is for {} but login is for {}", + record.impersonated_user_token.as_str(), + target_user_token.as_str(), + ); + return Err(LoginErrorResponse::invalid_credentials()); + } + + info!( + "Valid impersonation login: moderator={} impersonating={}", + record.impersonator_user_token.as_str(), + target_user_token.as_str(), + ); + + // Begin transaction: audit log + mark redeemed + create session. + let mut transaction = mysql_pool.begin().await + .map_err(|err| { + warn!("Failed to begin impersonation transaction: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Insert staff audit log for the redemption. + let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs { + audit_action: StaffAuditAction::ImpersonateUserRedeem, + maybe_entity_type: Some(StaffAuditEntityType::User), + maybe_entity_token: Some(target_user_token.as_str()), + staff_user_token: &record.impersonator_user_token, + actor_ip_address: ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to insert impersonation audit log: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Mark the impersonation token as redeemed. + mark_impersonation_token_as_redeemed(MarkImpersonationTokenAsRedeemedArgs { + public_impersonation_token: password, + ip_address_redemption: ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to mark impersonation token as redeemed: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Create the impersonated session (60 min expiry). + let expires_at = Utc::now() + Duration::minutes(60); + + let session_token = create_impersonated_user_session( + CreateImpersonatedUserSessionArgs { + user_token: target_user_token, + impersonator_user_token: &record.impersonator_user_token, + ip_address, + expires_at, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }, + ).await.map_err(|err| { + warn!("Failed to create impersonated session: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Commit the transaction. + transaction.commit().await.map_err(|err| { + warn!("Failed to commit impersonation transaction: {:?}", err); + LoginErrorResponse::server_error() + })?; + + info!( + "Impersonation session created: moderator={} as user={} session={}", + record.impersonator_user_token.as_str(), + target_user_token.as_str(), + session_token.as_str(), + ); + + Ok(session_token) +}