use axum::body::Body;
use axum::extract::{Form, Path, Query, State};
use axum::http::{header, Response, StatusCode};
use axum::response::{IntoResponse, Redirect};
use axum::Extension;
use serde::Deserialize;
use tera::Context;
use super::super::auth;
use super::super::password;
use super::{inject_op_brand, render, ConsoleState};
use crate::core::Column as _;
use crate::sql::{Auto, FetcherPool as _};
use crate::tenancy::operators as ops;
const USERNAME_MAX: usize = 64;
const PASSWORD_MIN: usize = 8;
#[derive(Deserialize)]
pub(super) struct OperatorsQuery {
#[serde(default)]
error: Option<String>,
#[serde(default)]
notice: Option<String>,
#[serde(default)]
page: Option<i64>,
}
#[derive(Deserialize)]
pub(super) struct CreateForm {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
#[serde(default)]
confirm_password: String,
#[serde(default)]
generate: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct ResetForm {
#[serde(default)]
password: String,
#[serde(default)]
confirm_password: String,
#[serde(default)]
generate: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct ActiveForm {
#[serde(default)]
active: Option<String>,
}
pub(super) async fn operators_list(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Query(q): Query<OperatorsQuery>,
) -> Response<Body> {
page(
&state,
&op,
q.error.as_deref(),
q.notice.as_deref(),
None,
q.page,
)
.await
}
async fn page(
state: &ConsoleState,
op: &auth::Operator,
error: Option<&str>,
notice: Option<&str>,
secret: Option<(&str, &str)>,
requested_page: Option<i64>,
) -> Response<Body> {
use crate::core::Model as _;
let paged =
match super::Paged::of_model(&state.registry, auth::Operator::SCHEMA, requested_page).await
{
Ok(p) => p,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let rows: Vec<auth::Operator> = match auth::Operator::objects()
.order_by(&[("username", false)])
.limit(paged.limit)
.offset(paged.offset)
.fetch(&state.registry)
.await
{
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("could not read the operator list: {e}"),
)
.into_response();
}
};
let me = id_of(op);
let active_count = match super::count_where(
&state.registry,
auth::Operator::SCHEMA,
auth::Operator::active.eq(true).into(),
)
.await
{
Ok(n) => usize::try_from(n).unwrap_or(usize::MAX),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let view: Vec<_> = rows
.iter()
.map(|o| {
let id = o.id.get().copied().unwrap_or_default();
serde_json::json!({
"id": id,
"username": o.username,
"active": o.active,
"created_at": o.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
"password_changed_at": o.password_changed_at
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()),
"is_self": id == me,
"last_active": o.active && active_count <= 1,
})
})
.collect();
let mut ctx = Context::new();
inject_op_brand(&mut ctx, &state.op_brand);
ctx.insert("section", "operators");
ctx.insert("operator_username", &op.username);
ctx.insert("operators", &view);
ctx.insert("manage_enabled", &state.pools.is_some());
paged.inject(&mut ctx, "/operators", "");
ctx.insert("error", &error);
ctx.insert("notice", ¬ice);
if let Some((username, plain)) = secret {
ctx.insert("new_username", username);
ctx.insert("new_password", plain);
}
let mut resp = render(state, "op_operators.html", &ctx);
if secret.is_some() {
resp.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-store, max-age=0"),
);
}
resp
}
pub(super) async fn operator_create(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Form(form): Form<CreateForm>,
) -> Response<Body> {
let username = form.username.trim().to_owned();
if username.is_empty() {
return back_err(&state, &op, "A username is required.").await;
}
if username.chars().count() > USERNAME_MAX {
return back_err(
&state,
&op,
&format!("A username may be at most {USERNAME_MAX} characters."),
)
.await;
}
match auth::Operator::objects()
.where_(auth::Operator::username.eq(username.clone()))
.fetch(&state.registry)
.await
{
Ok(existing) => {
if !existing.is_empty() {
return back_err(
&state,
&op,
&format!("An operator named `{username}` already exists."),
)
.await;
}
}
Err(e) => return back_err(&state, &op, &format!("Registry lookup failed: {e}")).await,
}
let generated = form.generate.is_some();
let plain = match chosen_password(generated, &form.password, &form.confirm_password) {
Ok(p) => p,
Err(msg) => return back_err(&state, &op, &msg).await,
};
let hash = match password::hash(&plain) {
Ok(h) => h,
Err(e) => return back_err(&state, &op, &format!("Could not hash password: {e}")).await,
};
let mut row = auth::Operator {
id: Auto::default(),
username: username.clone(),
password_hash: hash,
active: true,
created_at: chrono::Utc::now(),
password_changed_at: None,
};
if let Err(e) = row.insert_pool(&state.registry).await {
return back_err(&state, &op, &format!("Could not create the operator: {e}")).await;
}
audit(&state, &op, id_of(&row), "operator_create", &username).await;
if generated {
page(&state, &op, None, None, Some((&username, &plain)), None).await
} else {
back_ok(&format!("created operator `{username}`"))
}
}
pub(super) async fn operator_set_active(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Path(id): Path<i64>,
Form(form): Form<ActiveForm>,
) -> Response<Body> {
let activate = form.active.is_some();
let mut target = match one_operator(&state, id).await {
Ok(Some(t)) => t,
Ok(None) => return back_err(&state, &op, "No such operator.").await,
Err(e) => return back_err(&state, &op, &e).await,
};
match ops::set_active(&state.registry, &mut target, activate, Some(id_of(&op))).await {
Ok(ops::Outcome::AlreadySo) => {
return back_ok(&format!(
"`{}` was already {}",
target.username,
if activate { "active" } else { "inactive" }
));
}
Ok(ops::Outcome::Changed) => {}
Err(e) => return back_err(&state, &op, &sentence(&e.to_string())).await,
}
let verb = if activate {
"operator_activate"
} else {
"operator_deactivate"
};
audit(&state, &op, id, verb, &target.username).await;
back_ok(&format!(
"{} `{}`",
if activate { "activated" } else { "deactivated" },
target.username
))
}
pub(super) async fn operator_reset_password(
State(state): State<ConsoleState>,
Extension(op): Extension<auth::Operator>,
Path(id): Path<i64>,
Form(form): Form<ResetForm>,
) -> Response<Body> {
let mut target = match one_operator(&state, id).await {
Ok(Some(t)) => t,
Ok(None) => return back_err(&state, &op, "No such operator.").await,
Err(e) => return back_err(&state, &op, &e).await,
};
let generated = form.generate.is_some();
let plain = match chosen_password(generated, &form.password, &form.confirm_password) {
Ok(p) => p,
Err(msg) => return back_err(&state, &op, &msg).await,
};
let hash = match password::hash(&plain) {
Ok(h) => h,
Err(e) => return back_err(&state, &op, &format!("Could not hash password: {e}")).await,
};
target.password_hash = hash;
target.password_changed_at = Some(chrono::Utc::now());
if let Err(e) = target.save_pool(&state.registry).await {
return back_err(&state, &op, &format!("Could not save: {e}")).await;
}
audit(&state, &op, id, "operator_reset_password", &target.username).await;
if generated {
let username = target.username.clone();
page(&state, &op, None, None, Some((&username, &plain)), None).await
} else {
back_ok(&format!(
"reset the password for `{}` — they are now signed out everywhere",
target.username
))
}
}
fn chosen_password(generate: bool, typed: &str, confirm: &str) -> Result<String, String> {
if generate {
if !typed.is_empty() {
return Err("Choose one: generate a password, or type one. Both were supplied.".into());
}
return Ok(password::generate(20));
}
if typed.is_empty() {
return Err("A password is required.".into());
}
if typed != confirm {
return Err("The password and its confirmation did not match.".into());
}
if typed.chars().count() < PASSWORD_MIN {
return Err(format!(
"A password must be at least {PASSWORD_MIN} characters."
));
}
Ok(typed.to_owned())
}
async fn one_operator(state: &ConsoleState, id: i64) -> Result<Option<auth::Operator>, String> {
auth::Operator::objects()
.where_(auth::Operator::id.eq(id))
.fetch(&state.registry)
.await
.map(|rows: Vec<auth::Operator>| rows.into_iter().next())
.map_err(|e| format!("could not read the operator: {e}"))
}
fn id_of(op: &auth::Operator) -> i64 {
op.id.get().copied().unwrap_or_default()
}
fn sentence(msg: &str) -> String {
let mut chars = msg.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + chars.as_str()
})
}
fn back_ok(notice: &str) -> Response<Body> {
Redirect::to(&format!(
"/operators?notice={}",
crate::url_codec::url_encode(notice)
))
.into_response()
}
async fn back_err(state: &ConsoleState, op: &auth::Operator, msg: &str) -> Response<Body> {
page(state, op, Some(msg), None, None, None).await
}
async fn audit(
state: &ConsoleState,
actor: &auth::Operator,
subject_id: i64,
verb: &str,
subject_username: &str,
) {
let mut extra = serde_json::Map::new();
extra.insert(
"target_username".into(),
serde_json::Value::String(subject_username.to_owned()),
);
super::emit_registry_audit(
&state.registry,
"rustango_operators",
&subject_id.to_string(),
id_of(actor),
verb,
extra,
)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_generated_password_needs_no_typing_and_refuses_both() {
let p = chosen_password(true, "", "").expect("generate");
assert!(
p.chars().count() >= 20,
"generated password should be at least 20 chars, got {}",
p.chars().count()
);
assert!(
chosen_password(true, "typed", "typed").is_err(),
"supplying both is ambiguous and should be refused"
);
}
#[test]
fn a_typed_password_must_be_confirmed_and_long_enough() {
assert!(chosen_password(false, "", "").is_err(), "empty");
assert!(
chosen_password(false, "longenough", "different").is_err(),
"mismatched confirmation"
);
assert!(
chosen_password(false, "short", "short").is_err(),
"too short"
);
assert_eq!(
chosen_password(false, "longenough", "longenough").as_deref(),
Ok("longenough")
);
}
#[test]
fn the_minimum_matches_the_change_password_form() {
assert_eq!(PASSWORD_MIN, 8);
}
}