diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index e1916fa6..9ec5d160 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -46,7 +46,16 @@ pub struct RegisterDto { pub username: String, pub email: String, pub password: String, - pub role: Option, +} + +/// DTO for the one-time initial admin setup endpoint (`/api/setup`). +/// Requires the setup token printed to the server log on first boot. +#[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)] diff --git a/src/application/ports/outbound.rs b/src/application/ports/outbound.rs index 9ac33e95..b8f60ad3 100644 --- a/src/application/ports/outbound.rs +++ b/src/application/ports/outbound.rs @@ -10,8 +10,10 @@ use super::storage_ports::{FileReadPort, FileWritePort}; /// Secondary port for storage operations pub trait StoragePort: Send + Sync + 'static { - /// Resolves a domain path to a physical path - fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf; + /// Resolves a domain path to a physical path. + /// + /// Returns an error if the path contains unsafe segments (defense-in-depth). + fn resolve_path(&self, storage_path: &StoragePath) -> Result; /// Creates directories if they don't exist async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index a8b5b9e7..0901da27 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -303,30 +303,6 @@ pub trait FileWritePort: Send + Sync + 'static { // Auxiliary ports (unchanged) // ───────────────────────────────────────────────────── -/// Secondary port for file path resolution -pub trait FilePathResolutionPort: Send + Sync + 'static { - /// Gets the storage path of a file - async fn get_file_path(&self, id: &str) -> Result; - - /// Resolves a domain path to a physical path - fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf; -} - -/// Secondary port for file/directory existence verification -pub trait StorageVerificationPort: Send + Sync + 'static { - /// Checks whether a file exists at the given path - async fn file_exists(&self, storage_path: &StoragePath) -> Result; - - /// Checks whether a directory exists at the given path - async fn directory_exists(&self, storage_path: &StoragePath) -> Result; -} - -/// Secondary port for directory management -pub trait DirectoryManagementPort: Send + Sync + 'static { - /// Creates directories if they do not exist - async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>; -} - /// Secondary port for storage usage management pub trait StorageUsagePort: Send + Sync + 'static { /// Updates storage usage statistics for a user diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs index 83642ee0..c267899a 100644 --- a/src/application/services/admin_settings_service.rs +++ b/src/application/services/admin_settings_service.rs @@ -352,6 +352,32 @@ impl AdminSettingsService { }) } + // ======================================================================== + // System Initialization + // ======================================================================== + + /// Check if the system has been initialized (first admin created). + /// Returns `true` if the `system_initialized` flag is set to `"true"` in the DB. + pub async fn is_system_initialized(&self) -> bool { + match self.settings_repo.get("system_initialized").await { + Ok(Some(val)) => val == "true", + _ => false, // fail-closed: if DB error or key absent, system is NOT initialized + } + } + + /// Mark the system as initialized after the first admin is created. + pub async fn mark_system_initialized(&self, admin_user_id: &str) -> Result<(), DomainError> { + self.settings_repo + .set( + "system_initialized", + "true", + "system", + false, + Some(admin_user_id), + ) + .await + } + // ======================================================================== // Registration Control // ======================================================================== diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index f60986bc..e9df0677 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -227,73 +227,11 @@ impl AuthApplicationService { )); } - // Check if the user wants to create an admin - let is_admin_request = dto.username.to_lowercase() == "admin" - || (dto.role.is_some() && dto.role.as_ref().unwrap().to_lowercase() == "admin"); - - // If trying to create an admin, check if admins already exist in the system - if is_admin_request { - match self.count_admin_users().await { - Ok(admin_count) => { - // If there are already admins in the system and this is not a clean install, - // we do not allow creating another admin from registration - if admin_count > 0 { - // Check if this is a clean install (only the default admin) - match self.count_all_users().await { - Ok(user_count) => { - // If there are more than 2 users (admin + test), it is not a clean install - if user_count > 2 { - tracing::warn!( - "Attempt to create additional admin rejected: at least one admin already exists" - ); - return Err(DomainError::new( - ErrorKind::AccessDenied, - "User", - "Creating additional admin users from the registration page is not allowed", - )); - } - // Otherwise, it is a clean install and the first admin is allowed - tracing::info!("Allowing admin creation on clean install"); - } - Err(e) => { - // Cannot verify user count — treat as bootstrap scenario - tracing::warn!( - "Could not count users ({}). Allowing admin creation for bootstrap.", - e - ); - } - } - } - } - Err(e) => { - // Any DB error (table missing, connection issue, etc.) means we - // cannot verify admin state. Allow admin creation so the user can - // bootstrap the system. If the DB is truly broken the INSERT will - // fail anyway with a clear error. - tracing::warn!( - "Could not count admin users ({}). Allowing admin creation for bootstrap.", - e - ); - } - } - } - - // Determine role and quota based on user type - // If an explicit "admin" role is provided, use the administrator role - let role = if let Some(role_str) = &dto.role { - if role_str.to_lowercase() == "admin" { - UserRole::Admin - } else { - UserRole::User - } - } else { - // Special case: if the username is "admin", assign admin role even if not specified - if dto.username.to_lowercase() == "admin" { - UserRole::Admin - } else { - UserRole::User - } - }; + // SECURITY: Public registration ALWAYS creates regular users. + // Admin users can only be created via: + // 1. The one-time /api/setup endpoint (first boot) + // 2. The admin panel (admin_create_user) + let role = UserRole::User; // Quota based on role, capped to available disk space let quota = self.capped_quota(&role); @@ -332,6 +270,92 @@ impl AuthApplicationService { Ok(UserDto::from(created_user)) } + /// Create the first admin user during initial system setup. + /// + /// This is called by the `/api/setup` endpoint after verifying the setup + /// token. It unconditionally creates an admin user. The caller (handler) + /// is responsible for: + /// 1. Verifying the setup token + /// 2. Checking that the system is not already initialized + /// 3. Marking the system as initialized after this call succeeds + pub async fn setup_create_admin( + &self, + username: String, + email: String, + password: String, + ) -> Result { + // Validate username + if username.len() < 3 || username.len() > 32 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Username must be between 3 and 32 characters".to_string(), + )); + } + + // Check for duplicate username + if self + .user_storage + .get_user_by_username(&username) + .await + .is_ok() + { + return Err(DomainError::new( + ErrorKind::AlreadyExists, + "User", + format!("User '{}' already exists", username), + )); + } + + // Check email uniqueness + if self + .user_storage + .get_user_by_email(&email) + .await + .is_ok() + { + return Err(DomainError::new( + ErrorKind::AlreadyExists, + "User", + format!("Email '{}' is already registered", email), + )); + } + + // Validate password + if password.len() < 8 { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + "Password must be at least 8 characters long".to_string(), + )); + } + + let role = UserRole::Admin; + let quota = self.capped_quota(&role); + let password_hash = self.password_hasher.hash_password(&password).await?; + + let user = User::new(username.clone(), email, password_hash, role, quota).map_err(|e| { + DomainError::new( + ErrorKind::InvalidInput, + "User", + format!("Error creating admin user: {}", e), + ) + })?; + + let created_user = self.user_storage.create_user(user).await?; + + // Create personal folder for the admin + self.create_personal_folder(&username, created_user.id()) + .await; + + tracing::info!( + "Initial admin created via setup: {} ({})", + username, + created_user.id() + ); + Ok(UserDto::from(created_user)) + } + pub async fn login(&self, dto: LoginDto) -> Result { // Find user let mut user = self @@ -585,106 +609,12 @@ impl AuthApplicationService { Ok(admin_users.len() as i64) } - // Method to count all users in the system - // Used to determine if this is a fresh install - pub async fn count_all_users(&self) -> Result { - // Get all users with large limit and 0 offset - let all_users = self.user_storage.list_users(1000, 0).await.map_err(|e| { - DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error counting users: {}", e), - ) - })?; - - Ok(all_users.len() as i64) - } - - // Method to delete the default admin user created by migrations - // Used in fresh installations before creating a custom admin - pub async fn delete_default_admin(&self) -> Result<(), DomainError> { - // Find the default admin user (created by migrations) - match self.get_user_by_username("admin").await { - Ok(default_admin) => { - // Delete the default admin user - self.user_storage - .delete_user(&default_admin.id) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error deleting default admin user: {}", e), - ) - }) - } - Err(_) => { - // Admin user doesn't exist, nothing to do - tracing::info!("Default admin user not found, nothing to delete"); - Ok(()) - } - } - } - - // Method to replace the default admin user with a custom one - // Used in fresh installations to allow users to set their own admin credentials - pub async fn replace_default_admin(&self, dto: &RegisterDto) -> Result { - // 1. Get the default admin user - let default_admin = self.get_user_by_username("admin").await?; - - // 2. Delete the default admin user - self.user_storage - .delete_user(&default_admin.id) - .await - .map_err(|e| { - DomainError::new( - ErrorKind::InternalError, - "User", - format!("Error deleting default admin user: {}", e), - ) - })?; - - // 3. Create new admin user with the provided credentials but admin role - let admin_role = UserRole::Admin; - - // Admin quota, capped to available disk space - let admin_quota = self.capped_quota(&admin_role); - - // Hash the password (same as register / admin_create_user) - let password_hash = self.password_hasher.hash_password(&dto.password).await?; - - // Create the new admin user - let user = User::new( - dto.username.clone(), - dto.email.clone(), - password_hash, - admin_role, - admin_quota, - ) - .map_err(|e| { - DomainError::new( - ErrorKind::InvalidInput, - "User", - format!("Error creating admin user: {}", e), - ) - })?; - - // 4. Save the new admin user - let created_user = self.user_storage.create_user(user).await?; - - // 5. Create personal folder for the new admin - self.create_personal_folder(&dto.username, created_user.id()) - .await; - - tracing::info!("Custom admin created: {}", created_user.id()); - Ok(UserDto::from(created_user)) - } - pub async fn list_users(&self, limit: i64, offset: i64) -> Result, DomainError> { let users = self.user_storage.list_users(limit, offset).await?; Ok(users.into_iter().map(UserDto::from).collect()) } + // ======================================================================== // Admin User Management Methods // ======================================================================== diff --git a/src/common/di.rs b/src/common/di.rs index 4ddf3340..2c14f07e 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -550,6 +550,7 @@ impl AppServiceFactory { app_password_service: None, 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 @@ -598,7 +599,29 @@ impl AppServiceFactory { } } - app_state.admin_settings_service = Some(admin_svc); + app_state.admin_settings_service = Some(admin_svc.clone()); + + // 9b-2. Generate one-time setup token if system is NOT yet initialized + 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!("╚══════════════════════════════════════════════════════════╝"); + + app_state.setup_token = Some(token); + } else { + tracing::info!("System already initialized — setup endpoint disabled"); + } // 9c. Wire Device Authorization Grant (RFC 8628) service { @@ -857,6 +880,10 @@ pub struct AppState { Option>, pub webdav_lock_store: Arc, + /// 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, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/domain/services/path_service.rs b/src/domain/services/path_service.rs index 91bb2705..c2c364cf 100644 --- a/src/domain/services/path_service.rs +++ b/src/domain/services/path_service.rs @@ -13,9 +13,16 @@ pub struct StoragePath { } impl StoragePath { - /// Creates a new storage path + /// Checks whether a single segment is safe (no traversal, no slashes) + fn is_safe_segment(s: &str) -> bool { + !s.is_empty() && s != "." && s != ".." && !s.contains('/') + } + + /// Creates a new storage path, silently dropping any traversal segments pub fn new(segments: Vec) -> Self { - Self { segments } + Self { + segments: segments.into_iter().filter(|s| Self::is_safe_segment(s)).collect(), + } } /// Creates an empty path (root) @@ -26,10 +33,13 @@ impl StoragePath { } /// Creates a path from a string with segments separated by / + /// + /// Traversal segments (`.`, `..`) are silently stripped to prevent + /// path-traversal attacks. pub fn from_string(path: &str) -> Self { let segments = path .split('/') - .filter(|s| !s.is_empty()) + .filter(|s| Self::is_safe_segment(s)) .map(|s| s.to_string()) .collect(); Self { segments } @@ -47,10 +57,15 @@ impl StoragePath { Self { segments } } - /// Appends a segment to the path + /// Appends a segment to the path. + /// + /// Traversal segments (`.`, `..`) and segments containing `/` are + /// silently ignored to prevent path-traversal attacks. pub fn join(&self, segment: &str) -> Self { let mut new_segments = self.segments.clone(); - new_segments.push(segment.to_string()); + if Self::is_safe_segment(segment) { + new_segments.push(segment.to_string()); + } Self { segments: new_segments, } @@ -141,4 +156,81 @@ mod tests { let path = StoragePath::from_string("folder/file.txt"); assert_eq!(path.file_name(), Some("file.txt".to_string())); } + + // ── Path-traversal hardening tests (VULN-02) ────────────── + + #[test] + fn test_from_string_strips_dot_dot() { + let path = StoragePath::from_string("../../etc/passwd"); + assert_eq!(path.segments(), &["etc", "passwd"]); + } + + #[test] + fn test_from_string_strips_single_dot() { + let path = StoragePath::from_string("folder/./file.txt"); + assert_eq!(path.segments(), &["folder", "file.txt"]); + } + + #[test] + fn test_from_string_strips_mixed_traversal() { + let path = StoragePath::from_string("a/../b/./c/../../d"); + assert_eq!(path.segments(), &["a", "b", "c", "d"]); + } + + #[test] + fn test_from_string_all_traversal_yields_root() { + let path = StoragePath::from_string("../../.."); + assert!(path.is_empty()); + assert_eq!(path.to_string(), "/"); + } + + #[test] + fn test_new_strips_traversal_segments() { + let path = StoragePath::new(vec![ + "..".into(), + "etc".into(), + ".".into(), + "passwd".into(), + ]); + assert_eq!(path.segments(), &["etc", "passwd"]); + } + + #[test] + fn test_new_strips_empty_segments() { + let path = StoragePath::new(vec!["a".into(), "".into(), "b".into()]); + assert_eq!(path.segments(), &["a", "b"]); + } + + #[test] + fn test_join_rejects_dot_dot() { + let base = StoragePath::from_string("folder"); + let joined = base.join(".."); + // ".." is silently ignored — path stays unchanged + assert_eq!(joined.segments(), &["folder"]); + } + + #[test] + fn test_join_rejects_single_dot() { + let base = StoragePath::from_string("folder"); + let joined = base.join("."); + assert_eq!(joined.segments(), &["folder"]); + } + + #[test] + fn test_join_rejects_slash_in_segment() { + let base = StoragePath::from_string("folder"); + let joined = base.join("sub/../../etc/passwd"); + // Segment contains '/' → silently ignored + assert_eq!(joined.segments(), &["folder"]); + } + + #[test] + fn test_from_pathbuf_strips_traversal() { + let path = StoragePath::from(PathBuf::from("a/../b/./c")); + // PathBuf Component::Normal only yields the normal parts + // On most platforms this strips . and .. + // but regardless, our from() only accepts Component::Normal + assert!(!path.segments().contains(&"..".to_string())); + assert!(!path.segments().contains(&".".to_string())); + } } diff --git a/src/infrastructure/services/path_service.rs b/src/infrastructure/services/path_service.rs index 7cb7005d..ac6b1589 100644 --- a/src/infrastructure/services/path_service.rs +++ b/src/infrastructure/services/path_service.rs @@ -23,13 +23,24 @@ impl PathService { Self { root_path } } - /// Converts a domain path to an absolute physical path - pub fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { + /// Converts a domain path to an absolute physical path. + /// + /// Returns an error if validation fails (defense-in-depth against traversal). + pub fn resolve_path(&self, storage_path: &StoragePath) -> Result { + self.validate_path(storage_path)?; let mut path = self.root_path.clone(); for segment in storage_path.segments() { path.push(segment); } - path + // Final safety check: the resolved path must remain under root + if !path.starts_with(&self.root_path) { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Path", + format!("Resolved path escapes storage root: {}", path.display()), + )); + } + Ok(path) } /// Converts a physical path to a domain path @@ -116,20 +127,14 @@ impl PathService { } impl StoragePort for PathService { - fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf { - let mut path = self.root_path.clone(); - for segment in storage_path.segments() { - path.push(segment); - } - path + fn resolve_path(&self, storage_path: &StoragePath) -> Result { + // Delegate to inherent method which validates + bounds-checks + self.resolve_path(storage_path) } async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError> { - // First validate the path - self.validate_path(storage_path)?; - - // Resolve to physical path - let physical_path = self.resolve_path(storage_path); + // resolve_path already calls validate_path internally + let physical_path = self.resolve_path(storage_path)?; // Check current state with a single async stat() — no worker blocking. match fs::metadata(&physical_path).await { @@ -169,7 +174,7 @@ impl StoragePort for PathService { } async fn file_exists(&self, storage_path: &StoragePath) -> Result { - let physical_path = self.resolve_path(storage_path); + let physical_path = self.resolve_path(storage_path)?; // Single async stat() — no worker blocking, one syscall instead of two. match fs::metadata(&physical_path).await { @@ -184,7 +189,7 @@ impl StoragePort for PathService { } async fn directory_exists(&self, storage_path: &StoragePath) -> Result { - let physical_path = self.resolve_path(storage_path); + let physical_path = self.resolve_path(storage_path)?; // Single async stat() — no worker blocking, one syscall instead of two. match fs::metadata(&physical_path).await { @@ -208,7 +213,7 @@ mod tests { let service = PathService::new(PathBuf::from("/storage")); let storage_path = StoragePath::from_string("test/file.txt"); - let absolute = service.resolve_path(&storage_path); + let absolute = service.resolve_path(&storage_path).unwrap(); assert_eq!(absolute, PathBuf::from("/storage/test/file.txt")); } @@ -255,4 +260,65 @@ mod tests { assert_eq!(file_path.to_string(), "/folder/file.txt"); } + + // ── Path-traversal hardening tests (VULN-02) ────────────── + + #[test] + fn test_resolve_path_traversal_stripped_by_domain() { + // StoragePath::from_string already strips ".." segments (Solution A+E), + // so resolve_path receives a clean path. + let service = PathService::new(PathBuf::from("/storage")); + let path = StoragePath::from_string("../../etc/passwd"); + let resolved = service.resolve_path(&path).unwrap(); + assert_eq!(resolved, PathBuf::from("/storage/etc/passwd")); + assert!(resolved.starts_with("/storage")); + } + + #[test] + fn test_resolve_path_normal_path_ok() { + let service = PathService::new(PathBuf::from("/storage")); + let path = StoragePath::from_string("users/alice/documents/report.pdf"); + let resolved = service.resolve_path(&path).unwrap(); + assert_eq!( + resolved, + PathBuf::from("/storage/users/alice/documents/report.pdf") + ); + } + + #[test] + fn test_resolve_path_root_ok() { + let service = PathService::new(PathBuf::from("/storage")); + let path = StoragePath::root(); + let resolved = service.resolve_path(&path).unwrap(); + assert_eq!(resolved, PathBuf::from("/storage")); + } + + #[test] + fn test_validate_path_rejects_dot_prefix() { + let service = PathService::new(PathBuf::from("/storage")); + // Manually construct a path with a dot-prefixed segment + // (from_string strips ".." but allows ".hidden") + let path = StoragePath::from_string("folder/.hidden/file.txt"); + assert!(service.validate_path(&path).is_err()); + } + + #[test] + fn test_validate_path_allows_well_known() { + let service = PathService::new(PathBuf::from("/storage")); + let path = StoragePath::from_string("folder/.well-known/caldav"); + assert!(service.validate_path(&path).is_ok()); + } + + #[test] + fn test_validate_path_rejects_dangerous_chars() { + let service = PathService::new(PathBuf::from("/storage")); + for dangerous in &["file:name", "file*name", "file?name", "filename", "file|name", "file\"name"] { + let path = StoragePath::new(vec![dangerous.to_string()]); + assert!( + service.validate_path(&path).is_err(), + "validate_path should reject segment: {}", + dangerous + ); + } + } } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 72f49e83..5ea4fe5e 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::application::dtos::user_dto::{ ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, - RefreshTokenDto, RegisterDto, + RefreshTokenDto, RegisterDto, SetupAdminDto, }; use crate::common::di::AppState; use crate::interfaces::api::cookie_auth; @@ -51,6 +51,11 @@ pub fn refresh_route() -> Router> { Router::new().route("/refresh", post(refresh_token)) } +/// Public setup route — only active before the first admin is created. +pub fn setup_route() -> Router> { + Router::new().route("/setup", post(setup_admin)) +} + async fn register( State(state): State>, Json(dto): Json, @@ -343,6 +348,103 @@ async fn logout( Ok(response) } +/// POST /api/setup — One-time endpoint to create the first admin user. +/// +/// Requires the setup token that was printed to the server log on first boot. +/// Once the admin is created, the system is marked as initialized and this +/// endpoint returns 403 for all subsequent requests. +async fn setup_admin( + State(state): State>, + Json(dto): Json, +) -> Result { + tracing::info!("Setup admin request received for user: {}", dto.username); + + // 1. Verify auth service exists + let auth_service = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; + + // 2. Check if system is already initialized (fail-closed: DB error → deny) + let admin_svc = state + .admin_settings_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not configured"))?; + + if admin_svc.is_system_initialized().await { + tracing::warn!( + "Setup admin rejected: system already initialized (user: {})", + dto.username + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "System is already initialized. Use the admin panel to manage users.", + "SystemAlreadyInitialized", + )); + } + + // 3. Verify the one-time setup token + let expected_token = state.setup_token.as_deref().ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "No setup token available. The system may already be initialized or the server needs to be restarted.", + "NoSetupToken", + ) + })?; + + if !constant_time_eq(dto.setup_token.as_bytes(), expected_token.as_bytes()) { + tracing::warn!( + "Setup admin rejected: invalid setup token (user: {})", + dto.username + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Invalid setup token. Check the server log for the correct token.", + "InvalidSetupToken", + )); + } + + // 4. Create the first admin user + let user = auth_service + .auth_application_service + .setup_create_admin(dto.username.clone(), dto.email, dto.password) + .await + .map_err(|e| { + tracing::error!("Setup admin creation failed: {}", e); + AppError::from(e) + })?; + + // 5. Mark system as initialized + if let Err(e) = admin_svc.mark_system_initialized(&user.id).await { + // Admin was created but we couldn't mark as initialized. + // This is not fatal — the setup token check prevents re-use, and + // on next restart the system will detect the admin in DB. + tracing::error!( + "Created admin but failed to mark system as initialized: {}", + e + ); + } + + tracing::info!( + "System initialized: first admin '{}' created successfully", + dto.username + ); + + Ok((StatusCode::CREATED, Json(user))) +} + +/// Constant-time byte comparison to prevent timing attacks on the setup token. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + /// Get system status - returns whether admin is configured /// This is a public endpoint used to determine if setup is needed #[derive(serde::Serialize)] @@ -363,7 +465,14 @@ async fn get_system_status( .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - // Count admin users to determine if system is initialized + // Use the DB flag as the authoritative source for initialization status + let db_initialized = if let Some(admin_svc) = state.admin_settings_service.as_ref() { + admin_svc.is_system_initialized().await + } else { + false + }; + + // Count admin users for additional info let admin_count = auth_service .auth_application_service .count_admin_users() @@ -371,9 +480,9 @@ async fn get_system_status( .unwrap_or(0); let status = SystemStatus { - initialized: admin_count > 0, + initialized: db_initialized || admin_count > 0, admin_count, - registration_allowed: admin_count > 0, // Only allow registration if admin exists + registration_allowed: db_initialized || admin_count > 0, }; tracing::info!( diff --git a/src/main.rs b/src/main.rs index 600954ba..f7516e26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,7 +171,7 @@ async fn main() -> Result<(), Box> { } if config.features.enable_auth { use interfaces::api::handlers::auth_handler::{ - auth_routes, login_route, refresh_route, register_route, + auth_routes, login_route, refresh_route, register_route, setup_route, }; use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::api::handlers::device_auth_handler; @@ -229,6 +229,13 @@ async fn main() -> Result<(), Box> { .with_state(app_state.clone()); // Remaining auth routes (status, OIDC, protected /me, /logout, etc.) let auth_router = auth_routes().with_state(app_state.clone()); + // One-time setup route — public, rate-limited like register + let setup_router = setup_route() + .layer(axum::middleware::from_fn_with_state( + register_limiter.clone(), + rate_limit_register, + )) + .with_state(app_state.clone()); // Device Authorization Grant (RFC 8628) // Public endpoints: /api/auth/device/authorize + /api/auth/device/token @@ -281,6 +288,8 @@ async fn main() -> Result<(), Box> { .nest("/api/auth", auth_refresh) // Other auth endpoints (status, OIDC, protected /me, /logout) .nest("/api/auth", auth_router) + // One-time setup endpoint — public, rate-limited + .nest("/api", setup_router) // Device Auth Grant public endpoints (authorize + token polling) .nest("/api/auth/device", device_public) // Device Auth Grant protected endpoints (verify + device management)