feat(admin/user): show users auth method + add cli to recover broken opaque login

This commit is contained in:
Edouard Vanbelle
2026-08-05 21:12:51 +02:00
parent c4bf2568ba
commit 84a1b0e005
14 changed files with 527 additions and 81 deletions
+1 -3
View File
@@ -45,9 +45,7 @@
//! because subsequent hurl files don't assume `hasOpaque=false`.
use base64::Engine as _;
use base64::engine::general_purpose::{
STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD,
};
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD};
/// Decode base64 emitted by the server. The server emits URL-safe-no-pad
/// (matching what the SPA's WASM client expects); this helper accepts
-34
View File
@@ -1,34 +0,0 @@
//! `opaque-setup` — one-shot operator helper that mints a fresh
//! [`opaque_ke::ServerSetup`] and prints its base64 encoding to stdout.
//!
//! The output goes into `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` (env var or secrets
//! manager) and MUST be persisted verbatim. Rotating it invalidates every
//! user's registration — treat it like the JWT secret, only more so.
//!
//! Usage:
//! ```text
//! cargo run --bin opaque-setup > opaque_setup.b64
//! # or paste directly into your env / .env file:
//! echo "OXICLOUD_AUTH_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)" >> .env
//! ```
//!
//! The generated value is a small (~64 byte) Ristretto255 keypair
//! serialised for storage. Nothing else — no config file, no key
//! rotation state. Idempotent per invocation (each run generates a
//! DIFFERENT value; only run it once per deployment).
use oxicloud::infrastructure::services::opaque_service::OpaqueService;
fn main() {
let b64 = OpaqueService::generate_server_setup_b64();
// Print JUST the value — no trailing newline commentary — so shell
// pipelines (`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)`)
// capture cleanly without needing `tr -d '\n'` afterwards.
println!("{b64}");
// Guidance goes to stderr so it doesn't contaminate the pipeline.
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
}
+282
View File
@@ -0,0 +1,282 @@
//! `oxicloud-cli` — operator toolbox for the OxiCloud deployment.
//!
//! Single binary with subcommand tree, shipped alongside the `oxicloud`
//! server binary. Replaces the per-task one-off bins (previously
//! `opaque-setup`, and any future `opaque-reset` etc.) with a
//! discoverable `--help`-driven surface so the container ships one
//! toolbox binary rather than N one-off ones.
//!
//! ## Layout
//!
//! ```text
//! oxicloud-cli <domain> <action> [flags]
//!
//! Domains:
//! opaque OPAQUE aPAKE substrate management
//! setup Print a fresh ServerSetup value for OXICLOUD_AUTH_OPAQUE_SERVER_SETUP
//! reset Clear envelope(s) so silent-migration re-mints under current KSF
//! ```
//!
//! Growth pattern: each new domain gets its own module below (e.g.
//! `mod opaque`) with a `#[derive(Subcommand)]` enum for its actions
//! and a `run(args) -> ExitCode` entrypoint. Keep each module
//! self-contained so a future extraction is a file move.
//!
//! ## Environment
//!
//! * `DATABASE_URL` — required by any subcommand that talks to the DB
//! (`opaque reset`); not needed for pure primitive helpers
//! (`opaque setup`). Each subcommand documents its own dependencies.
use std::process::ExitCode;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "oxicloud-cli",
version,
about = "OxiCloud operator toolbox",
long_about = "OxiCloud operator toolbox — subcommand entrypoint for operational \
tasks that don't belong in the main server binary."
)]
struct Cli {
#[command(subcommand)]
domain: Domain,
}
#[derive(Subcommand)]
enum Domain {
/// OPAQUE aPAKE substrate management (setup, reset).
Opaque {
#[command(subcommand)]
action: opaque::Action,
},
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
match cli.domain {
Domain::Opaque { action } => opaque::run(action).await,
}
}
// ── opaque domain ──────────────────────────────────────────────────────
mod opaque {
use std::env;
use std::process::ExitCode;
use clap::Subcommand;
use oxicloud::infrastructure::services::opaque_service::OpaqueService;
use sqlx::{PgPool, Row};
#[derive(Subcommand)]
pub enum Action {
/// Generate a fresh OPAQUE ServerSetup and print its base64
/// encoding to stdout. Guidance goes to stderr so shell
/// pipelines capture cleanly.
///
/// Run ONCE per deployment; persist the printed value as
/// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value
/// invalidates every user's OPAQUE registration — treat it
/// like your JWT secret.
Setup,
/// Clear the OPAQUE envelope for one user or all users
/// WITHOUT touching password or setting force_password_change.
///
/// Use case: KSF rotation. If you change
/// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes
/// become cryptographically incompatible with the newly
/// published KSF — logins fail with InvalidCredentials.
/// Nulling the envelope columns forces the SPA's `/lookup`
/// to report `hasOpaque: false`, which routes the next login
/// through legacy `/api/auth/login`; silent-migration then
/// mints a fresh envelope under the CURRENT KSF. Passwords
/// are unchanged.
///
/// NOT for forgotten-passphrase recovery — use the admin
/// password-reset endpoint (`PUT /api/admin/users/{id}/password`)
/// which sets a temp password + force_change flag in one shot.
Reset {
/// Email OR username to reset (dispatched on `@` presence,
/// same rule as `POST /api/auth/login`).
#[arg(long, conflicts_with = "all")]
user: Option<String>,
/// Reset every user with an OPAQUE envelope.
#[arg(long, conflicts_with = "user")]
all: bool,
/// Print what would change without touching the DB.
#[arg(long)]
dry_run: bool,
},
}
pub async fn run(action: Action) -> ExitCode {
match action {
Action::Setup => run_setup(),
Action::Reset {
user,
all,
dry_run,
} => run_reset(user, all, dry_run).await,
}
}
fn run_setup() -> ExitCode {
// Match the legacy `opaque-setup` bin's contract:
// - value on stdout, no trailing commentary (pipeline-safe)
// - guidance on stderr
let b64 = OpaqueService::generate_server_setup_b64();
println!("{b64}");
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
ExitCode::from(0)
}
async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> ExitCode {
// clap enforces `conflicts_with`, but not "at least one of".
// Belt-and-braces check here so the failure is explicit.
if user.is_none() && !all {
eprintln!("opaque reset: pass either --user <id> or --all");
return ExitCode::from(2);
}
let database_url = match env::var("DATABASE_URL") {
Ok(v) => v,
Err(_) => {
eprintln!("opaque reset: DATABASE_URL not set");
return ExitCode::from(2);
}
};
let pool = match PgPool::connect(&database_url).await {
Ok(p) => p,
Err(e) => {
eprintln!("opaque reset: failed to connect to database: {e}");
return ExitCode::from(1);
}
};
// Preview the affected row set before writing. Doubles as
// dry-run output and as diagnostics when --user matches nothing.
// Envelope-presence bool lets the operator see which rows had
// an envelope vs which only carry a stale migration mark.
let select_sql = if all {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
ORDER BY email
"#
} else {
r#"
SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope
FROM auth.users
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#
};
let rows_result = if all {
sqlx::query(select_sql).fetch_all(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(select_sql).bind(ident).fetch_all(&pool).await
};
let rows = match rows_result {
Ok(r) => r,
Err(e) => {
eprintln!("opaque reset: query failed: {e}");
return ExitCode::from(1);
}
};
if rows.is_empty() {
if all {
println!("opaque reset: no users have an OPAQUE envelope — nothing to do.");
return ExitCode::from(0);
} else {
eprintln!(
"opaque reset: no user matches --user {} — nothing changed.",
user.as_deref().unwrap_or("")
);
return ExitCode::from(1);
}
}
println!(
"opaque reset ({}): {} row(s) to affect",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
},
rows.len()
);
for row in &rows {
let id: uuid::Uuid = row.get("id");
let email: String = row.get("email");
let had_envelope: bool = row.get("had_envelope");
println!(
" {} {} {}",
id,
email,
if had_envelope {
"had-envelope"
} else {
"no-envelope-had-migrated-mark"
}
);
}
if dry_run {
return ExitCode::from(0);
}
// Actual UPDATE. Kept identical in shape to the SELECT above so
// the planner sees the same query pattern for both. We
// DELIBERATELY do NOT touch password_hash or
// force_password_change_at_next_login — this tool is scoped
// to "the passwords are fine, the envelopes are stale."
let update_sql_all = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE opaque_envelope IS NOT NULL
OR opaque_migrated_at IS NOT NULL
"#;
let update_sql_one = r#"
UPDATE auth.users
SET opaque_envelope = NULL,
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL
WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END
"#;
let write_result = if all {
sqlx::query(update_sql_all).execute(&pool).await
} else {
let ident = user.as_deref().unwrap();
sqlx::query(update_sql_one).bind(ident).execute(&pool).await
};
let affected = match write_result {
Ok(r) => r.rows_affected(),
Err(e) => {
eprintln!("opaque reset: update failed: {e}");
return ExitCode::from(1);
}
};
println!(
"opaque reset: cleared envelope columns on {affected} row(s). \
Users log in with their existing password; silent-migration \
re-mints envelopes under the current KSF on next login."
);
ExitCode::from(0)
}
}