feat(admin/user): show users auth method + add cli to recover broken opaque login
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user