diff --git a/src/config.rs b/src/config.rs index 6ac59e0..3689f6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,10 @@ pub struct Config { pub oidc_client_secret: Option, pub oidc_issuer_url: Option, pub oidc_redirect_uri: Option, + pub oidc_auto_register: bool, + + // Authentication Configuration + pub allow_local_auth: bool, // S3 Configuration pub s3_enabled: bool, @@ -409,7 +413,45 @@ impl Config { None } }, - + oidc_auto_register: match env::var("OIDC_AUTO_REGISTER") { + Ok(val) => match val.to_lowercase().as_str() { + "true" | "1" | "yes" | "on" => { + println!("✅ OIDC_AUTO_REGISTER: true (loaded from env)"); + true + } + _ => { + println!("✅ OIDC_AUTO_REGISTER: false (loaded from env)"); + false + } + }, + Err(_) => { + println!("⚠️ OIDC_AUTO_REGISTER: true (using default - env var not set)"); + true // Default to true for convenience + } + }, + + // Authentication Configuration + allow_local_auth: match env::var("ALLOW_LOCAL_AUTH") { + Ok(val) => match val.to_lowercase().as_str() { + "true" | "1" | "yes" | "on" => { + println!("✅ ALLOW_LOCAL_AUTH: true (loaded from env)"); + true + } + "false" | "0" | "no" | "off" => { + println!("✅ ALLOW_LOCAL_AUTH: false (loaded from env)"); + false + } + _ => { + println!("⚠️ ALLOW_LOCAL_AUTH: Invalid value '{}', defaulting to true", val); + true + } + }, + Err(_) => { + println!("⚠️ ALLOW_LOCAL_AUTH: true (using default - env var not set)"); + true // Default to true for backward compatibility + } + }, + // S3 Configuration s3_enabled: match env::var("S3_ENABLED") { Ok(val) => { @@ -523,6 +565,7 @@ impl Config { // OIDC validation if config.oidc_enabled { println!("🔐 OIDC is enabled"); + println!("🔓 OIDC auto-registration: {}", config.oidc_auto_register); if config.oidc_client_id.is_none() { println!("❌ OIDC_CLIENT_ID is required when OIDC is enabled"); } @@ -538,6 +581,19 @@ impl Config { } else { println!("🔐 OIDC is disabled"); } + + // Authentication method validation + println!("🔑 Local authentication (username/password): {}", + if config.allow_local_auth { "enabled" } else { "disabled" }); + + if !config.oidc_enabled && !config.allow_local_auth { + println!("❌ WARNING: Both OIDC and local authentication are disabled!"); + println!(" You will not be able to log in. Enable at least one authentication method."); + return Err(anyhow::anyhow!( + "Invalid authentication configuration: Both OIDC and local auth are disabled. \ + Enable at least one authentication method (OIDC_ENABLED=true or ALLOW_LOCAL_AUTH=true)" + )); + } println!("✅ Configuration validation completed successfully!\n"); diff --git a/src/db/users.rs b/src/db/users.rs index 979a57b..aea5520 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -216,7 +216,7 @@ impl Database { let row = sqlx::query( r#" - INSERT INTO users (username, email, role, created_at, updated_at, + INSERT INTO users (username, email, role, created_at, updated_at, oidc_subject, oidc_issuer, oidc_email, auth_provider) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, username, email, password_hash, role, created_at, updated_at, @@ -249,4 +249,74 @@ impl Database { auth_provider: row.get::("auth_provider").try_into().unwrap_or(AuthProvider::Oidc), }) } + + pub async fn get_user_by_email(&self, email: &str) -> Result> { + let row = sqlx::query( + "SELECT id, username, email, password_hash, role, created_at, updated_at, + oidc_subject, oidc_issuer, oidc_email, auth_provider FROM users WHERE email = $1" + ) + .bind(email) + .fetch_optional(&self.pool) + .await?; + + match row { + Some(row) => Ok(Some(User { + id: row.get("id"), + username: row.get("username"), + email: row.get("email"), + password_hash: row.get("password_hash"), + role: row.get::("role").try_into().unwrap_or(crate::models::UserRole::User), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + oidc_subject: row.get("oidc_subject"), + oidc_issuer: row.get("oidc_issuer"), + oidc_email: row.get("oidc_email"), + auth_provider: row.get::("auth_provider").try_into().unwrap_or(AuthProvider::Local), + })), + None => Ok(None), + } + } + + pub async fn link_user_to_oidc( + &self, + user_id: Uuid, + oidc_subject: &str, + oidc_issuer: &str, + oidc_email: &str, + ) -> Result { + let row = sqlx::query( + r#" + UPDATE users + SET oidc_subject = $2, + oidc_issuer = $3, + oidc_email = $4, + auth_provider = $5, + updated_at = NOW() + WHERE id = $1 + RETURNING id, username, email, password_hash, role, created_at, updated_at, + oidc_subject, oidc_issuer, oidc_email, auth_provider + "# + ) + .bind(user_id) + .bind(oidc_subject) + .bind(oidc_issuer) + .bind(oidc_email) + .bind(AuthProvider::Oidc.to_string()) + .fetch_one(&self.pool) + .await?; + + Ok(User { + id: row.get("id"), + username: row.get("username"), + email: row.get("email"), + password_hash: row.get("password_hash"), + role: row.get::("role").try_into().unwrap_or(crate::models::UserRole::User), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + oidc_subject: row.get("oidc_subject"), + oidc_issuer: row.get("oidc_issuer"), + oidc_email: row.get("oidc_email"), + auth_provider: row.get::("auth_provider").try_into().unwrap_or(AuthProvider::Oidc), + }) + } } \ No newline at end of file diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 5091c3f..f050c63 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -10,7 +10,8 @@ use std::sync::Arc; use crate::{ auth::{create_jwt, AuthUser}, - models::{CreateUser, LoginRequest, LoginResponse, UserResponse, UserRole}, + models::{CreateUser, LoginRequest, LoginResponse, User, UserResponse, UserRole}, + oidc::OidcUserInfo, AppState, }; @@ -39,6 +40,18 @@ async fn register( State(state): State>, Json(user_data): Json, ) -> Response { + // Check if local authentication is enabled + if !state.config.allow_local_auth { + tracing::warn!("Local registration attempt rejected - local auth is disabled"); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "Local registration is disabled", + "details": "This instance only allows OIDC authentication. Please contact your administrator." + })) + ).into_response(); + } + match state.db.create_user(user_data).await { Ok(user) => { let user_response: UserResponse = user.into(); @@ -84,6 +97,12 @@ async fn login( State(state): State>, Json(login_data): Json, ) -> Result, StatusCode> { + // Check if local authentication is enabled + if !state.config.allow_local_auth { + tracing::warn!("Local authentication attempt rejected - local auth is disabled"); + return Err(StatusCode::FORBIDDEN); + } + let user = state .db .get_user_by_username(&login_data.username) @@ -204,47 +223,91 @@ async fn oidc_callback( StatusCode::UNAUTHORIZED })?; - // Find or create user in database + // Find or create user in database with email-based syncing let issuer_url = state.config.oidc_issuer_url.as_ref().unwrap(); tracing::debug!("Looking up user by OIDC subject: {} and issuer: {}", user_info.sub, issuer_url); + let user = match state.db.get_user_by_oidc_subject(&user_info.sub, issuer_url).await { Ok(Some(existing_user)) => { tracing::debug!("Found existing OIDC user: {}", existing_user.username); existing_user }, Ok(None) => { - tracing::debug!("Creating new OIDC user"); - // Create new user - let username = user_info.preferred_username - .or_else(|| user_info.email.clone()) - .unwrap_or_else(|| format!("oidc_user_{}", &user_info.sub[..8])); - - let email = user_info.email.unwrap_or_else(|| format!("{}@oidc.local", username)); - - tracing::debug!("New user details - username: {}, email: {}", username, email); - - let create_user = CreateUser { - username, - email: email.clone(), - password: "".to_string(), // Not used for OIDC users - role: Some(UserRole::User), - }; - - let result = state.db.create_oidc_user( - create_user, - &user_info.sub, - issuer_url, - &email, - ).await; - - match result { - Ok(user) => { - tracing::info!("Successfully created OIDC user: {}", user.username); - user - }, - Err(e) => { - tracing::error!("Failed to create OIDC user: {} (full error: {:#})", e, e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); + // No OIDC user found, check if there's an existing local user with this email + let email = user_info.email.clone(); + + if let Some(email_addr) = &email { + tracing::debug!("Checking for existing local user with email: {}", email_addr); + match state.db.get_user_by_email(email_addr).await { + Ok(Some(existing_local_user)) => { + // Found existing local user with matching email - link to OIDC + tracing::info!( + "Found existing local user '{}' with email '{}', linking to OIDC identity", + existing_local_user.username, + email_addr + ); + + match state.db.link_user_to_oidc( + existing_local_user.id, + &user_info.sub, + issuer_url, + email_addr, + ).await { + Ok(linked_user) => { + tracing::info!( + "Successfully linked user '{}' to OIDC identity", + linked_user.username + ); + linked_user + }, + Err(e) => { + tracing::error!("Failed to link existing user to OIDC: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + }, + Ok(None) => { + // No existing user with this email + if state.config.oidc_auto_register { + // Auto-registration is enabled, create new OIDC user + tracing::debug!("No existing user with this email, creating new OIDC user (auto-registration enabled)"); + create_new_oidc_user( + &state, + &user_info, + issuer_url, + email.as_deref(), + ).await? + } else { + // Auto-registration is disabled, reject login + tracing::warn!( + "OIDC login attempted for unregistered email '{}', but auto-registration is disabled", + email_addr + ); + return Err(StatusCode::FORBIDDEN); + } + }, + Err(e) => { + tracing::error!("Database error during email lookup: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } else { + // No email provided by OIDC provider + if state.config.oidc_auto_register { + // Auto-registration is enabled, create new user without email sync + tracing::debug!("No email provided by OIDC, creating new user (auto-registration enabled)"); + create_new_oidc_user( + &state, + &user_info, + issuer_url, + None, + ).await? + } else { + // Auto-registration is disabled and no email to sync + tracing::warn!( + "OIDC login attempted without email claim, but auto-registration is disabled" + ); + return Err(StatusCode::FORBIDDEN); } } } @@ -265,4 +328,50 @@ async fn oidc_callback( token, user: user.into(), })) +} + +// Helper function to create a new OIDC user +async fn create_new_oidc_user( + state: &Arc, + user_info: &OidcUserInfo, + issuer_url: &str, + email: Option<&str>, +) -> Result { + tracing::debug!("Creating new OIDC user"); + + let username = user_info.preferred_username + .clone() + .or_else(|| email.map(|e| e.to_string())) + .unwrap_or_else(|| format!("oidc_user_{}", &user_info.sub[..8])); + + let user_email = email + .map(|e| e.to_string()) + .unwrap_or_else(|| format!("{}@oidc.local", username)); + + tracing::debug!("New user details - username: {}, email: {}", username, user_email); + + let create_user = CreateUser { + username, + email: user_email.clone(), + password: "".to_string(), // Not used for OIDC users + role: Some(UserRole::User), + }; + + let result = state.db.create_oidc_user( + create_user, + &user_info.sub, + issuer_url, + &user_email, + ).await; + + match result { + Ok(user) => { + tracing::info!("Successfully created OIDC user: {}", user.username); + Ok(user) + }, + Err(e) => { + tracing::error!("Failed to create OIDC user: {} (full error: {:#})", e, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } } \ No newline at end of file