feat(oidc): add option for auto-register, local login, and user matching by email
This commit is contained in:
parent
5804a59be9
commit
0032a30bb1
|
|
@ -33,6 +33,10 @@ pub struct Config {
|
||||||
pub oidc_client_secret: Option<String>,
|
pub oidc_client_secret: Option<String>,
|
||||||
pub oidc_issuer_url: Option<String>,
|
pub oidc_issuer_url: Option<String>,
|
||||||
pub oidc_redirect_uri: Option<String>,
|
pub oidc_redirect_uri: Option<String>,
|
||||||
|
pub oidc_auto_register: bool,
|
||||||
|
|
||||||
|
// Authentication Configuration
|
||||||
|
pub allow_local_auth: bool,
|
||||||
|
|
||||||
// S3 Configuration
|
// S3 Configuration
|
||||||
pub s3_enabled: bool,
|
pub s3_enabled: bool,
|
||||||
|
|
@ -409,6 +413,44 @@ impl Config {
|
||||||
None
|
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 Configuration
|
||||||
s3_enabled: match env::var("S3_ENABLED") {
|
s3_enabled: match env::var("S3_ENABLED") {
|
||||||
|
|
@ -523,6 +565,7 @@ impl Config {
|
||||||
// OIDC validation
|
// OIDC validation
|
||||||
if config.oidc_enabled {
|
if config.oidc_enabled {
|
||||||
println!("🔐 OIDC is enabled");
|
println!("🔐 OIDC is enabled");
|
||||||
|
println!("🔓 OIDC auto-registration: {}", config.oidc_auto_register);
|
||||||
if config.oidc_client_id.is_none() {
|
if config.oidc_client_id.is_none() {
|
||||||
println!("❌ OIDC_CLIENT_ID is required when OIDC is enabled");
|
println!("❌ OIDC_CLIENT_ID is required when OIDC is enabled");
|
||||||
}
|
}
|
||||||
|
|
@ -539,6 +582,19 @@ impl Config {
|
||||||
println!("🔐 OIDC is disabled");
|
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");
|
println!("✅ Configuration validation completed successfully!\n");
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
|
|
|
||||||
|
|
@ -249,4 +249,74 @@ impl Database {
|
||||||
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc),
|
auth_provider: row.get::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_email(&self, email: &str) -> Result<Option<User>> {
|
||||||
|
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::<String, _>("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::<String, _>("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<User> {
|
||||||
|
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::<String, _>("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::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Oidc),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{create_jwt, AuthUser},
|
auth::{create_jwt, AuthUser},
|
||||||
models::{CreateUser, LoginRequest, LoginResponse, UserResponse, UserRole},
|
models::{CreateUser, LoginRequest, LoginResponse, User, UserResponse, UserRole},
|
||||||
|
oidc::OidcUserInfo,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -39,6 +40,18 @@ async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(user_data): Json<CreateUser>,
|
Json(user_data): Json<CreateUser>,
|
||||||
) -> Response {
|
) -> 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 {
|
match state.db.create_user(user_data).await {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
let user_response: UserResponse = user.into();
|
let user_response: UserResponse = user.into();
|
||||||
|
|
@ -84,6 +97,12 @@ async fn login(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(login_data): Json<LoginRequest>,
|
Json(login_data): Json<LoginRequest>,
|
||||||
) -> Result<Json<LoginResponse>, StatusCode> {
|
) -> Result<Json<LoginResponse>, 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
|
let user = state
|
||||||
.db
|
.db
|
||||||
.get_user_by_username(&login_data.username)
|
.get_user_by_username(&login_data.username)
|
||||||
|
|
@ -204,49 +223,93 @@ async fn oidc_callback(
|
||||||
StatusCode::UNAUTHORIZED
|
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();
|
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);
|
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 {
|
let user = match state.db.get_user_by_oidc_subject(&user_info.sub, issuer_url).await {
|
||||||
Ok(Some(existing_user)) => {
|
Ok(Some(existing_user)) => {
|
||||||
tracing::debug!("Found existing OIDC user: {}", existing_user.username);
|
tracing::debug!("Found existing OIDC user: {}", existing_user.username);
|
||||||
existing_user
|
existing_user
|
||||||
},
|
},
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::debug!("Creating new OIDC user");
|
// No OIDC user found, check if there's an existing local user with this email
|
||||||
// Create new user
|
let email = user_info.email.clone();
|
||||||
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));
|
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
|
||||||
|
);
|
||||||
|
|
||||||
tracing::debug!("New user details - username: {}, email: {}", username, email);
|
match state.db.link_user_to_oidc(
|
||||||
|
existing_local_user.id,
|
||||||
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,
|
&user_info.sub,
|
||||||
issuer_url,
|
issuer_url,
|
||||||
&email,
|
email_addr,
|
||||||
).await;
|
).await {
|
||||||
|
Ok(linked_user) => {
|
||||||
match result {
|
tracing::info!(
|
||||||
Ok(user) => {
|
"Successfully linked user '{}' to OIDC identity",
|
||||||
tracing::info!("Successfully created OIDC user: {}", user.username);
|
linked_user.username
|
||||||
user
|
);
|
||||||
|
linked_user
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to create OIDC user: {} (full error: {:#})", e, e);
|
tracing::error!("Failed to link existing user to OIDC: {}", e);
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Database error during OIDC lookup: {}", e);
|
tracing::error!("Database error during OIDC lookup: {}", e);
|
||||||
|
|
@ -266,3 +329,49 @@ async fn oidc_callback(
|
||||||
user: user.into(),
|
user: user.into(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create a new OIDC user
|
||||||
|
async fn create_new_oidc_user(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_info: &OidcUserInfo,
|
||||||
|
issuer_url: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<User, StatusCode> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue