login with impersonation

This commit is contained in:
Brandon Thomas committed 2026-04-13 22:14:26 -04:00
1 parent 0b81ee634c
commit 75ba968ace
6 files changed
+309 -33

No files matched your search

@@ -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<Utc>,
}
/// 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<Option<UserImpersonationRequestRecord>, 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"),
}))
}
@@ -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(())
}
@@ -1 +1,3 @@
pub mod insert_user_impersonation_request;
pub mod lookup_user_impersonation_request;
pub mod mark_impersonation_token_as_redeemed;
@@ -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<Utc>,
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<UserSessionToken, sqlx::Error>
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)
}
@@ -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;
@@ -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<UserSessionToken, LoginErrorResponse> {
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)
}