passwd-user: allow clearing, allow setting expiry

This commit is contained in:
2025-12-23 15:14:13 +09:00
parent 6cbf719cfb
commit 77ad080f83
8 changed files with 150 additions and 38 deletions

3
Cargo.lock generated
View File

@@ -285,8 +285,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -1334,6 +1336,7 @@ dependencies = [
"async-bincode",
"bincode 2.0.1",
"build-info-build",
"chrono",
"clap",
"clap-verbosity-flag",
"clap_complete",

View File

@@ -22,6 +22,7 @@ autolib = false
anyhow = "1.0.100"
async-bincode = "0.8.0"
bincode = "2.0.1"
chrono = { version = "0.4.42", features = ["serde"] }
clap = { version = "4.5.53", features = ["cargo", "derive"] }
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
clap_complete = { version = "4.5.62", features = ["unstable-dynamic"] }

View File

@@ -6,15 +6,16 @@ use tokio_stream::StreamExt;
use crate::{
client::commands::{
erroneous_server_response, print_authorization_owner_hint,
read_password_from_stdin_with_double_check,
erroneous_server_response, interactive_password_dialogue_with_double_check,
interactive_password_expiry_dialogue, print_authorization_owner_hint,
},
core::{
completion::prefix_completer,
protocol::{
ClientToServerMessageStream, CreateUserError, Request, Response,
print_create_users_output_status, print_create_users_output_status_json,
print_set_password_output_status, request_validation::ValidationError,
SetUserPasswordRequest, print_create_users_output_status,
print_create_users_output_status_json, print_set_password_output_status,
request_validation::ValidationError,
},
types::MySQLUser,
},
@@ -87,8 +88,14 @@ pub async fn create_users(
.default(false)
.interact()?
{
let password = read_password_from_stdin_with_double_check(username)?;
let message = Request::PasswdUser((username.to_owned(), password));
let password = interactive_password_dialogue_with_double_check(username)?;
let expiry = interactive_password_expiry_dialogue(username)?;
let message = Request::PasswdUser(SetUserPasswordRequest {
user: username.clone(),
new_password: Some(password),
expiry: expiry,
});
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();

View File

@@ -13,7 +13,8 @@ use crate::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response, SetPasswordError,
print_set_password_output_status, request_validation::ValidationError,
SetUserPasswordRequest, print_set_password_output_status,
request_validation::ValidationError,
},
types::MySQLUser,
},
@@ -37,9 +38,21 @@ pub struct PasswdUserArgs {
/// Print the information as JSON
#[arg(short, long)]
json: bool,
/// Set the password to expire on the given date (YYYY-MM-DD)
#[arg(short, long, value_name = "DATE", conflicts_with = "no-expire")]
expire_on: Option<chrono::NaiveDate>,
/// Set the password to never expire
#[arg(short, long, conflicts_with = "expire_on")]
no_expire: bool,
/// Clear the password for the user instead of setting a new one
#[arg(short, long, conflicts_with_all = &["password_file", "stdin", "expire_on", "no-expire"])]
clear: bool,
}
pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
pub fn interactive_password_dialogue_with_double_check(username: &MySQLUser) -> anyhow::Result<String> {
Password::new()
.with_prompt(format!("New MySQL password for user '{username}'"))
.with_confirmation(
@@ -50,6 +63,29 @@ pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyho
.map_err(Into::into)
}
pub fn interactive_password_expiry_dialogue(username: &MySQLUser) -> anyhow::Result<Option<chrono::NaiveDate>> {
let input = dialoguer::Input::<String>::new()
.with_prompt(format!(
"Enter the password expiry date for user '{username}' (YYYY-MM-DD)"
))
.allow_empty(true)
.validate_with(|input: &String| {
chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map(|_| ())
.map_err(|_| "Invalid date format. Please use YYYY-MM-DD".to_string())
})
.interact_text()?;
if input.trim().is_empty() {
return Ok(None);
}
let date = chrono::NaiveDate::parse_from_str(&input, "%Y-%m-%d")
.map_err(|e| anyhow::anyhow!("Failed to parse date: {}", e))?;
Ok(Some(date))
}
pub async fn passwd_user(
args: PasswdUserArgs,
mut server_connection: ClientToServerMessageStream,
@@ -76,22 +112,38 @@ pub async fn passwd_user(
}
}
let password = if let Some(password_file) = args.password_file {
std::fs::read_to_string(password_file)
.context("Failed to read password file")?
.trim()
.to_string()
let password: Option<String> = if let Some(password_file) = args.password_file {
Some(
std::fs::read_to_string(password_file)
.context("Failed to read password file")?
.trim()
.to_string(),
)
} else if args.stdin {
let mut buffer = String::new();
std::io::stdin()
.read_line(&mut buffer)
.context("Failed to read password from stdin")?;
buffer.trim().to_string()
Some(buffer.trim().to_string())
} else if args.clear {
None
} else {
read_password_from_stdin_with_double_check(&args.username)?
Some(interactive_password_dialogue_with_double_check(&args.username)?)
};
let message = Request::PasswdUser((args.username.clone(), password));
let expiry_date = if args.no_expire {
None
} else if let Some(date) = args.expire_on {
Some(date)
} else {
interactive_password_expiry_dialogue(&args.username)?
};
let message = Request::PasswdUser(SetUserPasswordRequest {
user: args.username.clone(),
new_password: password,
expiry: expiry_date,
});
if let Err(err) = server_connection.send(message).await {
server_connection.close().await.ok();

View File

@@ -8,7 +8,7 @@ use tokio::net::UnixStream as TokioUnixStream;
use crate::{
client::{
commands::{erroneous_server_response, read_password_from_stdin_with_double_check},
commands::{erroneous_server_response, interactive_password_dialogue_with_double_check},
mysql_admutils_compatibility::{
common::trim_user_name_to_32_chars,
error_messages::{
@@ -20,7 +20,7 @@ use crate::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
completion::{mysql_user_completer, prefix_completer},
protocol::{
ClientToServerMessageStream, Request, Response, create_client_to_server_message_stream,
ClientToServerMessageStream, Request, Response, SetUserPasswordRequest, create_client_to_server_message_stream
},
types::MySQLUser,
},
@@ -252,8 +252,12 @@ async fn passwd_users(
.collect::<Vec<_>>();
for user in users {
let password = read_password_from_stdin_with_double_check(&user.user)?;
let message = Request::PasswdUser((user.user.clone(), password));
let password = interactive_password_dialogue_with_double_check(&user.user)?;
let message = Request::PasswdUser(SetUserPasswordRequest {
user: user.user.clone(),
new_password: Some(password),
expiry: None,
});
server_connection.send(message).await?;
match server_connection.next().await {
Some(Ok(Response::SetUserPassword(result))) => match result {

View File

@@ -6,7 +6,12 @@ use crate::core::{
types::{DbOrUser, MySQLUser},
};
pub type SetUserPasswordRequest = (MySQLUser, String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetUserPasswordRequest {
pub user: MySQLUser,
pub new_password: Option<String>,
pub expiry: Option<chrono::NaiveDate>,
}
pub type SetUserPasswordResponse = Result<(), SetPasswordError>;
@@ -18,6 +23,9 @@ pub enum SetPasswordError {
#[error("User does not exist")]
UserDoesNotExist,
#[error("Cannot clear password with an expiry date set")]
ClearPasswordWithExpiry,
#[error("MySQL error: {0}")]
MySqlError(String),
}
@@ -44,6 +52,9 @@ impl SetPasswordError {
SetPasswordError::UserDoesNotExist => {
format!("User '{username}' does not exist.")
}
SetPasswordError::ClearPasswordWithExpiry => {
format!("Cannot clear password for user '{username}' when an expiry date is set.")
}
SetPasswordError::MySqlError(err) => {
format!("MySQL error: {err}")
}
@@ -56,6 +67,7 @@ impl SetPasswordError {
match self {
SetPasswordError::ValidationError(err) => err.error_type(),
SetPasswordError::UserDoesNotExist => "user-does-not-exist".to_string(),
SetPasswordError::ClearPasswordWithExpiry => "clear-password-with-expiry".to_string(),
SetPasswordError::MySqlError(_) => "mysql-error".to_string(),
}
}

View File

@@ -11,7 +11,8 @@ use crate::{
common::UnixUser,
protocol::{
Request, Response, ServerToClientMessageStream, SetPasswordError,
create_server_to_client_message_stream, request_validation::GroupDenylist,
SetUserPasswordRequest, create_server_to_client_message_stream,
request_validation::GroupDenylist,
},
},
server::{
@@ -175,9 +176,15 @@ async fn session_handler_with_db_connection(
// TODO: don't clone the request
let request_to_display = match &request {
Request::PasswdUser((db_user, _)) => {
Request::PasswdUser((db_user.to_owned(), "<REDACTED>".to_string()))
}
Request::PasswdUser(SetUserPasswordRequest {
user,
new_password,
expiry,
}) => Request::PasswdUser(SetUserPasswordRequest {
user: user.clone(),
new_password: new_password.as_ref().map(|_| "<REDACTED>".to_string()),
expiry: *expiry,
}),
request => request.to_owned(),
};
@@ -339,10 +346,15 @@ async fn session_handler_with_db_connection(
.await;
Response::DropUsers(result)
}
Request::PasswdUser((db_user, password)) => {
Request::PasswdUser(SetUserPasswordRequest {
user,
new_password,
expiry,
}) => {
let result = set_password_for_database_user(
&db_user,
&password,
&user,
new_password.as_deref(),
expiry,
unix_user,
db_connection,
db_is_mariadb,

View File

@@ -188,7 +188,8 @@ pub async fn drop_database_users(
pub async fn set_password_for_database_user(
db_user: &MySQLUser,
password: &str,
password: Option<&str>,
expiry: Option<chrono::NaiveDate>,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
_db_is_mariadb: bool,
@@ -197,24 +198,44 @@ pub async fn set_password_for_database_user(
validate_db_or_user_request(&DbOrUser::User(db_user.clone()), unix_user, group_denylist)
.map_err(SetPasswordError::ValidationError)?;
if password.is_none() && expiry.is_some() {
return Err(SetPasswordError::ClearPasswordWithExpiry);
}
match unsafe_user_exists(db_user, &mut *connection).await {
Ok(false) => return Err(SetPasswordError::UserDoesNotExist),
Err(err) => return Err(SetPasswordError::MySqlError(err.to_string())),
_ => {}
}
let result = sqlx::query(
format!(
let result = if let Some(password) = password {
let mut query = format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
quote_literal(db_user),
quote_literal(password).as_str(),
)
.as_str(),
)
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()));
);
if let Some(expiry_date) = expiry {
query.push_str(&format!(" PASSWORD EXPIRE DATE '{}'", expiry_date));
}
sqlx::query(query.as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
} else {
let query = format!(
"ALTER USER {}@'%' IDENTIFIED WITH mysql_native_password AS ''",
quote_literal(db_user),
);
sqlx::query(query.as_str())
.execute(&mut *connection)
.await
.map(|_| ())
.map_err(|err| SetPasswordError::MySqlError(err.to_string()))
};
if result.is_err() {
tracing::error!(