use std::collections::HashMap;
use serde::Serialize;
use sqlx::Row as SqlxRow;
use crate::auth::guards::enforce_cross_rank_safe;
use crate::auth::recovery_admin::{
admin_revoke_sessions, admin_set_temp_password, check_session_elevated,
issue_admin_reset_token, lock_user_account, unlock_user_account, AdminActor, AdminIssueOutcome,
AdminRevokeOutcome, AdminTempPwOutcome, LockDuration, LockOutcome, UnlockOutcome,
};
use crate::auth::{self, Identity, Role};
use crate::error::{Error, Result};
use crate::http::{Request, Response};
use super::audit;
use super::builtin::{client_ip, correlation_id_from};
use super::handlers::{csrf_token, AdminCtx};
use super::render::{self, BaseContext, FormSection};
fn validate_return_to(raw: &str) -> Option<String> {
if raw.is_empty() {
return None;
}
if raw.bytes().any(|b| b < 0x20 || b == 0x7f) {
return None;
}
if raw.starts_with("//") {
return None;
}
if raw.contains('\\') {
return None;
}
if raw.contains("..") {
return None;
}
if raw == "/admin" || raw.starts_with("/admin/") || raw.starts_with("/admin?") {
Some(raw.to_string())
} else {
None
}
}
fn redirect_after_reauth(return_to: Option<&str>) -> String {
return_to
.and_then(validate_return_to)
.unwrap_or_else(|| "/admin".to_string())
}
#[derive(Serialize)]
struct ReauthCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
email: String,
return_to: String,
error: Option<String>,
mfa_enabled: bool,
}
pub(crate) async fn show_reauth(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
let raw = req.query().get("return_to").map(|s| s.to_string());
let return_to = redirect_after_reauth(raw.as_deref());
let view = ReauthCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Confirm your identity",
email: identity.email.clone(),
return_to,
error: None,
mfa_enabled: identity.mfa_enabled,
};
let body = ctx.templates.render("admin/reauth.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_reauth(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let password = form.get("password").unwrap_or("").to_string();
let code = form.get("code").unwrap_or("").trim().to_string();
let raw_return_to = form.get("return_to").map(|s| s.to_string());
let return_to = redirect_after_reauth(raw_return_to.as_deref());
let uniform_failure = |ctx: &AdminCtx, return_to: &str| -> Result<Response> {
let view = ReauthCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Confirm your identity",
email: identity.email.clone(),
return_to: return_to.to_string(),
error: Some("Could not verify your password.".to_string()),
mfa_enabled: identity.mfa_enabled,
};
let body = ctx.templates.render("admin/reauth.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::UNAUTHORIZED))
};
let user = match auth::find_user_by_email(&ctx.db, &identity.email).await? {
Some(u) => u,
None => return uniform_failure(ctx, &return_to),
};
if !auth::verify_password(&password, &user.password_hash) {
return uniform_failure(ctx, &return_to);
}
if identity.mfa_enabled {
if code.is_empty() {
return uniform_failure(ctx, &return_to);
}
use crate::auth::mfa::{
consume_backup_code, verify_totp_for_user, BackupConsumeOutcome, MfaKey, VerifyOutcome,
};
let policy = ctx.admin.active_recovery_policy();
let step_seconds = policy.mfa_step_seconds();
let skew_steps = policy.mfa_skew_steps();
let key = MfaKey::from_env()?;
let correlation_id = req.header("x-correlation-id");
let totp_outcome =
verify_totp_for_user(&ctx.db, user.id, &code, step_seconds, skew_steps, &key).await?;
let totp_verified = matches!(totp_outcome, VerifyOutcome::Verified { .. });
let second_factor_ok = if totp_verified {
true
} else {
match totp_outcome {
VerifyOutcome::Invalid => {
let backup_outcome = consume_backup_code(
&ctx.db,
&req,
user.id,
&code,
"reauth",
correlation_id,
)
.await?;
matches!(backup_outcome, BackupConsumeOutcome::Consumed { .. })
}
VerifyOutcome::Replay { .. }
| VerifyOutcome::NotEnrolled
| VerifyOutcome::Verified { .. } => false,
}
};
if !second_factor_ok {
return uniform_failure(ctx, &return_to);
}
}
let cookie = match req.header("cookie") {
Some(c) => c,
None => return uniform_failure(ctx, &return_to),
};
let token = match auth::session_token_from_cookie(cookie) {
Some(t) => t,
None => return uniform_failure(ctx, &return_to),
};
let session_id = match auth::current_session_id(&ctx.db, &token).await? {
Some(id) => id,
None => return uniform_failure(ctx, &return_to),
};
let ttl = ctx.admin.active_recovery_policy().reauth_window();
if identity.mfa_enabled {
crate::auth::mfa::promote_session_mfa_elevated(&ctx.db, session_id, ttl).await?;
} else {
crate::auth::recovery_admin::promote_session_elevated(&ctx.db, session_id, ttl).await?;
}
Ok(Response::redirect(return_to))
}
#[derive(Serialize)]
struct MustChangePasswordCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
sections: Vec<FormSection>,
errors: Vec<String>,
}
pub(crate) async fn show_must_change_password(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
if !identity.must_change_password {
return Ok(Response::redirect("/admin"));
}
let min_length = ctx.admin.active_password_policy().min_length();
let view = MustChangePasswordCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Set a new password",
sections: render::must_change_password_form_sections(min_length),
errors: Vec::new(),
};
let body = ctx
.templates
.render("admin/must_change_password.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_must_change_password(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
if !identity.must_change_password {
return Ok(Response::redirect("/admin"));
}
let form = req.form()?;
let new1 = form.get("new_password1").unwrap_or("").to_string();
let new2 = form.get("new_password2").unwrap_or("").to_string();
let mut errors: Vec<String> = Vec::new();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
if new1.is_empty() {
let msg = "Enter a new password.".to_string();
errors.push(msg.clone());
field_errors
.entry("new_password1".into())
.or_default()
.push(msg);
} else if new1 != new2 {
let msg = "Passwords do not match.".to_string();
errors.push(msg.clone());
field_errors
.entry("new_password2".into())
.or_default()
.push(msg);
} else if let Err(e) = ctx.admin.active_password_policy().validate(&new1) {
let msg = e.to_string();
errors.push(msg.clone());
field_errors
.entry("new_password1".into())
.or_default()
.push(msg);
}
let user = if errors.is_empty() {
match auth::find_user_by_email(&ctx.db, &identity.email).await? {
Some(u) => Some(u),
None => {
let msg = "Could not load your account. Please sign in again.".to_string();
errors.push(msg);
None
}
}
} else {
None
};
if errors.is_empty() {
if let Some(u) = user.as_ref() {
if auth::verify_password(&new1, &u.password_hash) {
let msg = "New password must be different from your current password.".to_string();
errors.push(msg.clone());
field_errors
.entry("new_password1".into())
.or_default()
.push(msg);
}
}
}
if !errors.is_empty() {
let min_length = ctx.admin.active_password_policy().min_length();
let mut sections = render::must_change_password_form_sections(min_length);
render::apply_field_errors(&mut sections, &field_errors);
let view = MustChangePasswordCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Set a new password",
sections,
errors,
};
let body = ctx
.templates
.render("admin/must_change_password.html", &view)?;
return Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST));
}
auth::set_password(&ctx.db, identity.user_id, &new1).await?;
sqlx::query("UPDATE rustio_users SET must_change_password = FALSE WHERE id = $1")
.bind(identity.user_id)
.execute(ctx.db.pool())
.await?;
let cookie_token = req
.header("cookie")
.and_then(crate::auth::session_token_from_cookie);
let current_session_id = match &cookie_token {
Some(t) => crate::auth::current_session_id(&ctx.db, t).await?,
None => None,
};
let target = match current_session_id {
Some(sid) => crate::auth::SessionTarget::UserExceptCurrent {
user_id: identity.user_id,
current_session_id: sid,
},
None => crate::auth::SessionTarget::User {
user_id: identity.user_id,
},
};
let outcome = crate::auth::invalidate_sessions(
&ctx.db,
target,
crate::auth::SessionInvalidationReason::UserRequested,
)
.await?;
let revoked_count = outcome.revoked_session_ids.len();
super::handlers::record_session_revocations(
ctx,
&identity,
&outcome.revoked_session_ids,
&req,
"must_change_password",
)
.await;
let triggered_by_audit_id: Option<i64> = sqlx::query_scalar(
"SELECT id FROM rustio_admin_actions \
WHERE action_type = 'password_reset_by_other' \
AND object_id = $1 \
ORDER BY id DESC LIMIT 1",
)
.bind(identity.user_id)
.fetch_optional(ctx.db.pool())
.await
.unwrap_or(None);
let cid = correlation_id_from(&req);
let ip = client_ip(&req);
let metadata = match triggered_by_audit_id {
Some(id) => serde_json::json!({
"triggered_by_audit_id": id,
"invalidated_session_count": revoked_count,
}),
None => serde_json::json!({
"invalidated_session_count": revoked_count,
}),
};
let _ = audit::record(
&ctx.db,
audit::LogEntry {
user_id: identity.user_id,
action_type: audit::ActionType::Update,
model_name: "user",
object_id: identity.user_id,
ip_address: ip.as_deref(),
summary: format!(
"forced password rotation completed; {revoked_count} other session(s) revoked"
),
correlation_id: cid.as_deref(),
session_id: None,
metadata: Some(metadata),
actor_user_id: None,
event: Some(audit::AuditEvent::ForcedPasswordChangeCompleted),
},
)
.await;
Ok(Response::redirect("/admin"))
}
#[derive(Serialize)]
struct AdminResetPasswordCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
target_user_id: i64,
target_email: String,
success: bool,
errors: Vec<String>,
field_errors: HashMap<String, Vec<String>>,
reason: String,
mode: String,
success_mode: String,
temp_password: String,
email_status: String,
revoked_session_count: usize,
}
impl AdminResetPasswordCtx {
fn form(
base: BaseContext,
target_user_id: i64,
target_email: String,
reason: String,
mode: String,
errors: Vec<String>,
field_errors: HashMap<String, Vec<String>>,
) -> Self {
Self {
base,
page_title: format!("Reset password — {target_email}"),
target_user_id,
target_email,
success: false,
errors,
field_errors,
reason,
mode,
success_mode: String::new(),
temp_password: String::new(),
email_status: String::new(),
revoked_session_count: 0,
}
}
}
async fn load_target(
db: &crate::orm::Db,
target_user_id: i64,
) -> Result<Option<(String, bool, Role)>> {
let row = sqlx::query("SELECT email, is_active, role FROM rustio_users WHERE id = $1")
.bind(target_user_id)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let email: String = r.try_get("email")?;
let is_active: bool = r.try_get("is_active")?;
let role_str: String = r.try_get("role")?;
let role = Role::parse(&role_str)?;
Ok(Some((email, is_active, role)))
}
None => Ok(None),
}
}
fn reauth_redirect_for(target_user_id: i64) -> Response {
let path = format!("/admin/users/{target_user_id}/reset-password");
Response::redirect(format!(
"/admin/reauth?return_to={}",
urlencoding::encode(&path)
))
}
async fn current_session_id_for(ctx: &AdminCtx, req: &Request) -> Result<Option<i64>> {
let cookie = match req.header("cookie") {
Some(c) => c,
None => return Ok(None),
};
let token = match auth::session_token_from_cookie(cookie) {
Some(t) => t,
None => return Ok(None),
};
auth::current_session_id(&ctx.db, &token).await
}
pub(crate) async fn show_admin_reset_password(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: &Request,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, req).await?;
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(reauth_redirect_for(target_user_id));
}
let view = AdminResetPasswordCtx::form(
BaseContext::new(Some(&actor_identity), csrf_token(req), &ctx.admin),
target_user_id,
target_email,
String::new(),
"email".to_string(),
Vec::new(),
HashMap::new(),
);
let body = ctx
.templates
.render("admin/admin_reset_password.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_admin_reset_password(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: Request,
) -> Result<Response> {
let (target_email, target_is_active, target_role) =
match load_target(&ctx.db, target_user_id).await? {
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, &req).await?;
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(reauth_redirect_for(target_user_id));
}
let form = req.form()?;
let mode = form.get("mode").unwrap_or("email").trim().to_string();
let reason = form.get("reason").unwrap_or("").trim().to_string();
let mut errors: Vec<String> = Vec::new();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
if reason.chars().count() < 8 {
let msg = "Reason must be at least 8 characters.".to_string();
errors.push(msg.clone());
field_errors.entry("reason".into()).or_default().push(msg);
}
if mode != "email" && mode != "temp_pw" {
errors.push(format!("Unknown mode: {mode:?}."));
}
if !target_is_active {
errors.push(format!(
"{target_email} is currently disabled. Re-enable the account before resetting."
));
}
let render_form = |ctx: &AdminCtx,
errors: Vec<String>,
field_errors: HashMap<String, Vec<String>>|
-> Result<Response> {
let view = AdminResetPasswordCtx::form(
BaseContext::new(Some(&actor_identity), csrf_token(&req), &ctx.admin),
target_user_id,
target_email.clone(),
reason.clone(),
mode.clone(),
errors,
field_errors,
);
let body = ctx
.templates
.render("admin/admin_reset_password.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST))
};
if !errors.is_empty() {
return render_form(ctx, errors, field_errors);
}
let actor = AdminActor {
user_id: actor_identity.user_id,
email: &actor_identity.email,
};
let cid = correlation_id_from(&req);
match mode.as_str() {
"email" => {
let outcome = issue_admin_reset_token(
&ctx.db,
&ctx.admin,
&req,
target_user_id,
actor,
&reason,
cid.as_deref(),
)
.await?;
match outcome {
AdminIssueOutcome::Issued { .. } => {
Ok(Response::redirect("/admin/users"))
}
AdminIssueOutcome::UnknownTarget => {
Err(Error::NotFound(format!("user #{target_user_id}")))
}
AdminIssueOutcome::InactiveTarget => render_form(
ctx,
vec![format!(
"{target_email} is currently disabled. Re-enable before resetting."
)],
HashMap::new(),
),
}
}
"temp_pw" => {
let outcome = admin_set_temp_password(
&ctx.db,
&req,
target_user_id,
actor,
&reason,
cid.as_deref(),
)
.await?;
match outcome {
AdminTempPwOutcome::Set {
temp_password,
revoked_session_count,
..
} => {
let view = AdminResetPasswordCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(&req), &ctx.admin),
page_title: format!("Password reset for {target_email}"),
target_user_id,
target_email: target_email.clone(),
success: true,
errors: Vec::new(),
field_errors: HashMap::new(),
reason: String::new(),
mode: String::new(),
success_mode: "temp_pw".to_string(),
temp_password,
email_status: String::new(),
revoked_session_count,
};
let body = ctx
.templates
.render("admin/admin_reset_password.html", &view)?;
Ok(Response::html(body))
}
AdminTempPwOutcome::UnknownTarget => {
Err(Error::NotFound(format!("user #{target_user_id}")))
}
AdminTempPwOutcome::InactiveTarget => render_form(
ctx,
vec![format!(
"{target_email} is currently disabled. Re-enable before resetting."
)],
HashMap::new(),
),
}
}
_ => unreachable!("mode validated above"),
}
}
#[derive(Serialize)]
struct LockUserCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
target_user_id: i64,
target_email: String,
errors: Vec<String>,
field_errors: HashMap<String, Vec<String>>,
reason: String,
duration: String,
freeform_minutes: String,
}
#[derive(Serialize)]
struct ConfirmActionCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
target_user_id: i64,
target_email: String,
errors: Vec<String>,
field_errors: HashMap<String, Vec<String>>,
reason: String,
action_title: &'static str,
action_description: &'static str,
submit_label: &'static str,
form_action: String,
}
fn parse_lock_duration(
duration: &str,
freeform_minutes: &str,
) -> std::result::Result<LockDuration, String> {
match duration {
"15min" => Ok(LockDuration::FifteenMinutes),
"1h" => Ok(LockDuration::OneHour),
"24h" => Ok(LockDuration::TwentyFourHours),
"7d" => Ok(LockDuration::SevenDays),
"indefinite" => Ok(LockDuration::Indefinite),
"freeform" => match freeform_minutes.trim().parse::<u32>() {
Ok(m) if m >= 1 => Ok(LockDuration::Minutes(m)),
Ok(_) => Err("Freeform duration must be at least 1 minute.".to_string()),
Err(_) => Err("Enter a whole number of minutes.".to_string()),
},
_ => Err(format!("Unknown duration: {duration:?}.")),
}
}
pub(crate) async fn show_lock_user(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: &Request,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, req).await?;
if !matches!(session_id, Some(id) if check_session_elevated(&ctx.db, id).await?) {
return Ok(reauth_redirect_for_path(&format!(
"/admin/users/{target_user_id}/lock"
)));
}
let view = LockUserCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(req), &ctx.admin),
page_title: format!("Lock account — {target_email}"),
target_user_id,
target_email,
errors: Vec::new(),
field_errors: HashMap::new(),
reason: String::new(),
duration: "1h".to_string(),
freeform_minutes: String::new(),
};
let body = ctx.templates.render("admin/lock_user.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_lock_user(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: Request,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, &req).await?;
if !matches!(session_id, Some(id) if check_session_elevated(&ctx.db, id).await?) {
return Ok(reauth_redirect_for_path(&format!(
"/admin/users/{target_user_id}/lock"
)));
}
let form = req.form()?;
let duration_raw = form.get("duration").unwrap_or("1h").trim().to_string();
let freeform_minutes = form
.get("freeform_minutes")
.unwrap_or("")
.trim()
.to_string();
let reason = form.get("reason").unwrap_or("").trim().to_string();
let mut errors: Vec<String> = Vec::new();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
if reason.chars().count() < 8 {
let msg = "Reason must be at least 8 characters.".to_string();
errors.push(msg.clone());
field_errors.entry("reason".into()).or_default().push(msg);
}
let parsed_duration = match parse_lock_duration(&duration_raw, &freeform_minutes) {
Ok(d) => Some(d),
Err(msg) => {
errors.push(msg.clone());
field_errors.entry("duration".into()).or_default().push(msg);
None
}
};
if !errors.is_empty() {
let view = LockUserCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(&req), &ctx.admin),
page_title: format!("Lock account — {target_email}"),
target_user_id,
target_email,
errors,
field_errors,
reason,
duration: duration_raw,
freeform_minutes,
};
let body = ctx.templates.render("admin/lock_user.html", &view)?;
return Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST));
}
let actor = AdminActor {
user_id: actor_identity.user_id,
email: &actor_identity.email,
};
let cid = correlation_id_from(&req);
let outcome = lock_user_account(
&ctx.db,
&req,
target_user_id,
actor,
parsed_duration.expect("validated above"),
&reason,
cid.as_deref(),
)
.await?;
match outcome {
LockOutcome::Locked { .. } => Ok(Response::redirect("/admin/users")),
LockOutcome::UnknownTarget => Err(Error::NotFound(format!("user #{target_user_id}"))),
}
}
pub(crate) async fn show_unlock_user(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: &Request,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, req).await?;
if !matches!(session_id, Some(id) if check_session_elevated(&ctx.db, id).await?) {
return Ok(reauth_redirect_for_path(&format!(
"/admin/users/{target_user_id}/unlock"
)));
}
let view = ConfirmActionCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(req), &ctx.admin),
page_title: format!("Unlock account — {target_email}"),
target_user_id,
target_email,
errors: Vec::new(),
field_errors: HashMap::new(),
reason: String::new(),
action_title: "Unlock account",
action_description: "Clear the account lock and reset the failed-login counter. The user can sign in immediately.",
submit_label: "Unlock account",
form_action: format!("/admin/users/{target_user_id}/unlock"),
};
let body = ctx
.templates
.render("admin/confirm_admin_action.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_unlock_user(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: Request,
) -> Result<Response> {
do_confirm_action(ctx, actor_identity, target_user_id, req, ActionKind::Unlock).await
}
pub(crate) async fn show_admin_revoke_sessions(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: &Request,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let session_id = current_session_id_for(ctx, req).await?;
if !matches!(session_id, Some(id) if check_session_elevated(&ctx.db, id).await?) {
return Ok(reauth_redirect_for_path(&format!(
"/admin/users/{target_user_id}/revoke-sessions"
)));
}
let view = ConfirmActionCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(req), &ctx.admin),
page_title: format!("Revoke sessions — {target_email}"),
target_user_id,
target_email,
errors: Vec::new(),
field_errors: HashMap::new(),
reason: String::new(),
action_title: "Revoke all sessions",
action_description: "Sign the user out of every device immediately. No lock is applied; they can sign in again on a fresh session.",
submit_label: "Revoke all sessions",
form_action: format!("/admin/users/{target_user_id}/revoke-sessions"),
};
let body = ctx
.templates
.render("admin/confirm_admin_action.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_admin_revoke_sessions(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: Request,
) -> Result<Response> {
do_confirm_action(ctx, actor_identity, target_user_id, req, ActionKind::Revoke).await
}
#[derive(Debug, Clone, Copy)]
enum ActionKind {
Unlock,
Revoke,
}
async fn do_confirm_action(
ctx: &AdminCtx,
actor_identity: Identity,
target_user_id: i64,
req: Request,
kind: ActionKind,
) -> Result<Response> {
let (target_email, _is_active, target_role) = match load_target(&ctx.db, target_user_id).await?
{
Some(t) => t,
None => return Err(Error::NotFound(format!("user #{target_user_id}"))),
};
enforce_cross_rank_safe(&actor_identity, target_user_id, target_role)?;
let path_suffix = match kind {
ActionKind::Unlock => "unlock",
ActionKind::Revoke => "revoke-sessions",
};
let session_id = current_session_id_for(ctx, &req).await?;
if !matches!(session_id, Some(id) if check_session_elevated(&ctx.db, id).await?) {
return Ok(reauth_redirect_for_path(&format!(
"/admin/users/{target_user_id}/{path_suffix}"
)));
}
let form = req.form()?;
let reason = form.get("reason").unwrap_or("").trim().to_string();
if reason.chars().count() < 8 {
let (action_title, action_description, submit_label) = match kind {
ActionKind::Unlock => (
"Unlock account",
"Clear the account lock and reset the failed-login counter. The user can sign in immediately.",
"Unlock account",
),
ActionKind::Revoke => (
"Revoke all sessions",
"Sign the user out of every device immediately. No lock is applied; they can sign in again on a fresh session.",
"Revoke all sessions",
),
};
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
let msg = "Reason must be at least 8 characters.".to_string();
field_errors
.entry("reason".into())
.or_default()
.push(msg.clone());
let view = ConfirmActionCtx {
base: BaseContext::new(Some(&actor_identity), csrf_token(&req), &ctx.admin),
page_title: format!("{action_title} — {target_email}"),
target_user_id,
target_email,
errors: vec![msg],
field_errors,
reason,
action_title,
action_description,
submit_label,
form_action: format!("/admin/users/{target_user_id}/{path_suffix}"),
};
let body = ctx
.templates
.render("admin/confirm_admin_action.html", &view)?;
return Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST));
}
let actor = AdminActor {
user_id: actor_identity.user_id,
email: &actor_identity.email,
};
let cid = correlation_id_from(&req);
match kind {
ActionKind::Unlock => {
let outcome = unlock_user_account(
&ctx.db,
&req,
target_user_id,
actor,
&reason,
cid.as_deref(),
)
.await?;
match outcome {
UnlockOutcome::Unlocked { .. } => Ok(Response::redirect("/admin/users")),
UnlockOutcome::UnknownTarget => {
Err(Error::NotFound(format!("user #{target_user_id}")))
}
}
}
ActionKind::Revoke => {
let outcome = admin_revoke_sessions(
&ctx.db,
&req,
target_user_id,
actor,
&reason,
cid.as_deref(),
)
.await?;
match outcome {
AdminRevokeOutcome::Revoked { .. } => Ok(Response::redirect("/admin/users")),
AdminRevokeOutcome::UnknownTarget => {
Err(Error::NotFound(format!("user #{target_user_id}")))
}
}
}
}
}
fn reauth_redirect_for_path(path: &str) -> Response {
Response::redirect(format!(
"/admin/reauth?return_to={}",
urlencoding::encode(path)
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_internal_admin_paths_are_accepted() {
for path in [
"/admin",
"/admin/",
"/admin/users",
"/admin/users/42/reset-password",
"/admin/users/42/lock",
"/admin?ok=1",
"/admin/users?role=admin&page=2",
] {
assert_eq!(
validate_return_to(path),
Some(path.to_string()),
"expected accept for {path:?}"
);
}
}
#[test]
fn external_urls_are_rejected() {
for raw in [
"https://evil.com/admin",
"http://evil.com/admin",
"//evil.com/admin", "//evil.com", r"\\evil.com\admin", "javascript:alert(1)", "data:text/html,<script>alert(1)</script>",
"ftp://evil.com/admin",
] {
assert!(
validate_return_to(raw).is_none(),
"expected reject for {raw:?}"
);
}
}
#[test]
fn paths_outside_admin_surface_are_rejected() {
for raw in [
"/",
"/login",
"/admin-evil", "/admin\u{0000}/x", "/static/admin.css", "/api/users",
" /admin/users", "/Admin/users", ] {
assert!(
validate_return_to(raw).is_none(),
"expected reject for {raw:?}"
);
}
}
#[test]
fn path_traversal_is_rejected() {
for raw in [
"/admin/../etc/passwd",
"/admin/users/..",
"/admin/..//evil.com",
"/admin/../../",
] {
assert!(
validate_return_to(raw).is_none(),
"expected reject for {raw:?}"
);
}
}
#[test]
fn malformed_inputs_are_rejected() {
assert!(validate_return_to("").is_none());
assert!(validate_return_to("/admin\r\nLocation: /evil").is_none());
assert!(validate_return_to("/admin\n").is_none());
assert!(validate_return_to("/admin\t").is_none());
assert!(validate_return_to("/admin\x7f/").is_none());
}
#[test]
fn redirect_collapses_invalid_to_admin_dashboard() {
assert_eq!(redirect_after_reauth(None), "/admin");
assert_eq!(redirect_after_reauth(Some("")), "/admin");
assert_eq!(redirect_after_reauth(Some("https://evil.com")), "/admin");
assert_eq!(redirect_after_reauth(Some("//evil.com")), "/admin");
assert_eq!(redirect_after_reauth(Some("/login")), "/admin");
}
#[test]
fn redirect_passes_valid_internal_paths() {
assert_eq!(
redirect_after_reauth(Some("/admin/users/42/reset-password")),
"/admin/users/42/reset-password"
);
assert_eq!(redirect_after_reauth(Some("/admin")), "/admin");
assert_eq!(
redirect_after_reauth(Some("/admin?next=1")),
"/admin?next=1"
);
}
#[test]
fn parse_lock_duration_accepts_locked_presets() {
assert!(matches!(
parse_lock_duration("15min", ""),
Ok(LockDuration::FifteenMinutes)
));
assert!(matches!(
parse_lock_duration("1h", ""),
Ok(LockDuration::OneHour)
));
assert!(matches!(
parse_lock_duration("24h", ""),
Ok(LockDuration::TwentyFourHours)
));
assert!(matches!(
parse_lock_duration("7d", ""),
Ok(LockDuration::SevenDays)
));
assert!(matches!(
parse_lock_duration("indefinite", ""),
Ok(LockDuration::Indefinite)
));
}
#[test]
fn parse_lock_duration_freeform_accepts_positive_minutes() {
assert!(matches!(
parse_lock_duration("freeform", "30"),
Ok(LockDuration::Minutes(30))
));
assert!(matches!(
parse_lock_duration("freeform", " 120 "),
Ok(LockDuration::Minutes(120))
));
}
#[test]
fn parse_lock_duration_freeform_rejects_zero_negative_garbage() {
assert!(parse_lock_duration("freeform", "0").is_err());
assert!(parse_lock_duration("freeform", "").is_err());
assert!(parse_lock_duration("freeform", "abc").is_err());
assert!(parse_lock_duration("freeform", "-1").is_err());
assert!(parse_lock_duration("freeform", "1.5").is_err());
}
#[test]
fn parse_lock_duration_rejects_unknown_preset() {
assert!(parse_lock_duration("forever", "").is_err());
assert!(parse_lock_duration("", "").is_err());
assert!(parse_lock_duration("1H", "").is_err()); }
#[test]
fn parse_lock_duration_freeform_overflow_rejected() {
let very_big = "4294967296"; assert!(parse_lock_duration("freeform", very_big).is_err());
}
}