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
+12
View File
@@ -115,6 +115,17 @@ pub struct AdminUserSummaryDto {
pub active: bool,
pub auth_provider: String,
pub is_external: bool,
/// TRUE when the user has a server-verifiable password on file
/// (`password_hash IS NOT NULL`). The admin table uses this
/// alongside `oidc_provider` and `opaque_registered` to render
/// the user's full capability set: a `password` chip lights up
/// here, an OIDC provider name renders the SSO badge, an
/// envelope-on-file flips the OPAQUE chip. A user with none of
/// the three is passwordless (magic-link only — the SPA renders
/// a distinct `passwordless` chip in that case). Admin-only
/// exposure — see the DTO doc for why this isn't on `UserDto`.
#[serde(default)]
pub has_password: bool,
/// Mirrors `UserListEntry::opaque_registered` — TRUE when the user
/// has an OPAQUE envelope on file. Surfaced on the admin table so
/// operators can see per-user rollout progress during the
@@ -148,6 +159,7 @@ impl From<UserListEntry> for AdminUserSummaryDto {
active: entry.active,
auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()),
is_external: entry.is_external,
has_password: entry.has_password,
opaque_registered: entry.opaque_registered,
opaque_migrated: entry.opaque_migrated,
}
+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)
}
}
@@ -47,6 +47,15 @@ pub struct UserListEntry {
pub active: bool,
pub oidc_provider: Option<String>,
pub is_external: bool,
/// TRUE when `auth.users.password_hash IS NOT NULL` — user has a
/// server-verifiable password on file (legacy or admin-set).
/// Distinct from `opaque_registered` (which is the zero-knowledge
/// envelope): a fully-migrated user carries BOTH — password for
/// the fallback / operator flows, envelope for the actual login.
/// A user with `has_password = false AND !opaque_registered AND
/// oidc_provider IS NULL` is passwordless — the only path in is
/// via magic-link (or, for externals, whatever grant they hold).
pub has_password: bool,
/// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user
/// has completed OPAQUE registration (typically via the Phase 2
/// silent-migration hook after a successful legacy login). Surfaced
@@ -769,18 +769,25 @@ impl UserRepository for UserPgRepository {
bool,
bool,
bool,
bool,
),
>(
// OPAQUE columns are projected as booleans via `IS NOT NULL`
// rather than as timestamps so the row-mapping tuple stays
// small and the wire shape is exactly what the admin table
// needs. Both are per-row scalar tests — no cost beyond the
// full-table sequential scan the LIMIT/OFFSET already pays.
// Auth-credential columns projected as booleans via `IS NOT
// NULL` rather than as timestamps / hashes so the row-mapping
// tuple stays small and the wire shape is exactly what the
// admin table needs. Per-row scalar tests — no cost beyond
// the full-table sequential scan the LIMIT/OFFSET already
// pays. `has_password` on the password_hash column tells
// the admin table whether a server-verifiable password is
// on file; combined with the two OPAQUE flags and
// oidc_provider, the SPA derives the full "capability
// set" per user (password / OPAQUE / SSO / passwordless).
r#"
SELECT
id, username, email, role::text,
storage_quota_bytes, storage_used_bytes,
last_login_at, active, oidc_provider, is_external,
(password_hash IS NOT NULL) AS has_password,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
FROM auth.users
@@ -810,6 +817,7 @@ impl UserRepository for UserPgRepository {
active,
oidc_provider,
is_external,
has_password,
opaque_registered,
opaque_migrated,
)| UserListEntry {
@@ -827,6 +835,7 @@ impl UserRepository for UserPgRepository {
active,
oidc_provider,
is_external,
has_password,
opaque_registered,
opaque_migrated,
},
@@ -92,7 +92,7 @@ impl OpaqueService {
ErrorKind::InternalError,
"opaque",
"OXICLOUD_AUTH_OPAQUE_SERVER_SETUP is required when OPAQUE is enabled — \
generate one with `oxicloud opaque-setup` and persist it in the env",
generate one with `oxicloud-cli opaque setup` and persist it in the env",
)
})?;
let setup = decode_server_setup(setup_b64)?;
@@ -256,7 +256,10 @@ mod tests {
};
let err = OpaqueService::from_config(cfg).expect_err("must reject missing setup");
assert_eq!(err.kind, ErrorKind::InternalError);
assert!(err.to_string().contains("OXICLOUD_AUTH_OPAQUE_SERVER_SETUP"));
assert!(
err.to_string()
.contains("OXICLOUD_AUTH_OPAQUE_SERVER_SETUP")
);
}
#[test]
@@ -105,9 +105,9 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::AuthResponseDto;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth;
use crate::infrastructure::services::opaque_login_exchange::{ExchangeId, OpaqueLoginExchange};
use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite};
use crate::interfaces::api::cookie_auth;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUserId;
+1 -4
View File
@@ -255,10 +255,7 @@ pub async fn require_internal_user_layer(
/// on a rate-limited public path that doesn't carry a `CurrentUser` at
/// middleware time; the gate never fires on it. If refresh ever moves
/// under the gate, add `(&Method::POST, "/api/auth/refresh")` here.
fn is_password_change_pending_allowlisted(
method: &axum::http::Method,
path: &str,
) -> bool {
fn is_password_change_pending_allowlisted(method: &axum::http::Method, path: &str) -> bool {
use axum::http::Method;
matches!(
(method, path),