feat: auto-persist JWT secret, remove setup token requirement

- JWT secret auto-generates and persists to <STORAGE_PATH>/.jwt_secret
- Remove setup token: first admin setup is open until system initialized
- Fix schema.sql: move CREATE EXTENSION pg_trgm/ltree to top
- Update login UI and auth.js to remove setup token fields
This commit is contained in:
Dionisio
2026-03-05 22:12:21 +01:00
parent c77ce202c6
commit f2d35ca792
336 changed files with 104 additions and 114 deletions
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
+1 -2
View File
@@ -49,13 +49,12 @@ pub struct RegisterDto {
}
/// DTO for the one-time initial admin setup endpoint (`/api/setup`).
/// Requires the setup token printed to the server log on first boot.
/// Available only when the system is not yet initialized (no admin exists).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SetupAdminDto {
pub username: String,
pub email: String,
pub password: String,
pub setup_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
+83 -33
View File
@@ -605,44 +605,94 @@ impl AppConfig {
// Auth configuration
if let Ok(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") {
// SECURITY: Validate JWT secret minimum entropy (RFC 7518 §3.2
// recommends ≥256 bits for HS256). Panic on dangerously short
// secrets, warn on sub-optimal ones.
let len = jwt_secret.len();
if config.features.enable_auth && len < 16 {
panic!(
"FATAL: OXICLOUD_JWT_SECRET is dangerously short ({} bytes). \
Minimum: 32 bytes (256 bits) for HS256. \
Generate a secure secret with: openssl rand -hex 32",
len
);
} else if config.features.enable_auth && len < 32 {
tracing::warn!("==========================================================");
tracing::warn!(
"OXICLOUD_JWT_SECRET is only {} bytes — recommended minimum is 32 (256 bits).",
len
);
tracing::warn!("Generate a stronger secret with: openssl rand -hex 32");
tracing::warn!("==========================================================");
if !jwt_secret.is_empty() {
// SECURITY: Validate JWT secret minimum entropy (RFC 7518 §3.2
// recommends ≥256 bits for HS256). Panic on dangerously short
// secrets, warn on sub-optimal ones.
let len = jwt_secret.len();
if config.features.enable_auth && len < 16 {
panic!(
"FATAL: OXICLOUD_JWT_SECRET is dangerously short ({} bytes). \
Minimum: 32 bytes (256 bits) for HS256. \
Generate a secure secret with: openssl rand -hex 32",
len
);
} else if config.features.enable_auth && len < 32 {
tracing::warn!("==========================================================");
tracing::warn!(
"OXICLOUD_JWT_SECRET is only {} bytes — recommended minimum is 32 (256 bits).",
len
);
tracing::warn!("Generate a stronger secret with: openssl rand -hex 32");
tracing::warn!("==========================================================");
}
config.auth.jwt_secret = jwt_secret;
}
config.auth.jwt_secret = jwt_secret;
}
// SECURITY: Generate ephemeral secret when none is provided
// SECURITY: Auto-persist JWT secret to storage so it survives restarts.
// Priority: env var > persisted file > generate new.
if config.features.enable_auth && config.auth.jwt_secret.is_empty() {
// Generate a random secret for this session and warn loudly
use rand_core::{OsRng, RngCore};
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect();
config.auth.jwt_secret = generated_secret;
let secret_file = config.storage_path.join(".jwt_secret");
tracing::warn!("==========================================================");
tracing::warn!("OXICLOUD_JWT_SECRET is not set.");
tracing::warn!("A random secret has been generated for this session.");
tracing::warn!("All tokens will be INVALIDATED on restart.");
tracing::warn!("Set OXICLOUD_JWT_SECRET env var for production use.");
tracing::warn!("==========================================================");
if secret_file.exists() {
// Read persisted secret from previous run
match std::fs::read_to_string(&secret_file) {
Ok(persisted) => {
let persisted = persisted.trim().to_string();
if persisted.len() >= 32 {
config.auth.jwt_secret = persisted;
tracing::info!(
"JWT secret loaded from {}",
secret_file.display()
);
} else {
tracing::warn!(
"Persisted JWT secret too short ({}B), regenerating",
persisted.len()
);
}
}
Err(e) => {
tracing::warn!("Failed to read {}: {}", secret_file.display(), e);
}
}
}
// Still empty → generate and persist
if config.auth.jwt_secret.is_empty() {
use rand_core::{OsRng, RngCore};
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
let generated_secret: String =
key.iter().map(|b| format!("{:02x}", b)).collect();
// Persist to storage volume so it survives container restarts
if let Err(e) = std::fs::write(&secret_file, &generated_secret) {
tracing::error!(
"Failed to persist JWT secret to {}: {}. \
Tokens will be invalidated on restart!",
secret_file.display(),
e
);
} else {
// Restrict file permissions (owner-only read/write)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&secret_file,
std::fs::Permissions::from_mode(0o600),
);
}
tracing::info!(
"JWT secret auto-generated and persisted to {}",
secret_file.display()
);
}
config.auth.jwt_secret = generated_secret;
}
}
if let Ok(access_token_expiry) =
Regular → Executable
+5 -18
View File
@@ -612,7 +612,7 @@ impl AppServiceFactory {
path_resolver: None,
webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
setup_token: None,
};
// 9b. Wire admin settings service when auth is available
@@ -663,24 +663,14 @@ impl AppServiceFactory {
app_state.admin_settings_service = Some(admin_svc.clone());
// 9b-2. Generate one-time setup token if system is NOT yet initialized
// 9b-2. Log whether system needs first-time admin setup
if !admin_svc.is_system_initialized().await {
use rand_core::{OsRng, RngCore};
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let token = hex::encode(token_bytes);
tracing::warn!("╔══════════════════════════════════════════════════════════╗");
tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║");
tracing::warn!("║ ║");
tracing::warn!("║ POST /api/setup with this one-time token: ║");
tracing::warn!("║ {} ║", token);
tracing::warn!("║ ║");
tracing::warn!("║ This token is valid until the server restarts or the ║");
tracing::warn!("║ first admin is created. Keep it secret! ║");
tracing::warn!("║ Open the web UI to create the first admin account. ║");
tracing::warn!("║ The setup page is available until an admin is created. ║");
tracing::warn!("╚══════════════════════════════════════════════════════════╝");
app_state.setup_token = Some(token);
} else {
tracing::info!("System already initialized — setup endpoint disabled");
}
@@ -903,10 +893,7 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
/// One-time setup token generated on startup when the system is not yet
/// initialized. Printed to the server log so the operator can create the
/// first admin user via `POST /api/setup`.
pub setup_token: Option<String>,
}
// All AppState construction is done via struct literal in build_app_state().
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More