mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 01:18:34 +00:00
login with impersonation
This commit is contained in:
1 parent
0b81ee634c
commit
75ba968ace
6 files changed
+309
-33
No files matched your search
+51
@@ -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"),
|
||||
}))
|
||||
}
|
||||
+35
@@ -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;
|
||||
+53
@@ -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;
|
||||
|
||||
+167
-33
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user