adding some initial SSO providers, updating publish workflow
Some checks failed
CI / Rustfmt (push) Failing after 21s
CI / Cargo Audit & Deny (push) Failing after 33s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 7s
CI / Web Advisory Checks (push) Successful in 33s
CI / Security Advisory Checks (push) Successful in 34s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 11s
Publish Images And Chart / Publish init-user (push) Failing after 10s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish api (push) Failing after 7s
Publish Images And Chart / Publish executor (push) Failing after 9s
Publish Images And Chart / Publish notifier (push) Failing after 10s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Clippy (push) Successful in 18m52s
CI / Tests (push) Has been cancelled
Some checks failed
CI / Rustfmt (push) Failing after 21s
CI / Cargo Audit & Deny (push) Failing after 33s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 7s
CI / Web Advisory Checks (push) Successful in 33s
CI / Security Advisory Checks (push) Successful in 34s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 11s
Publish Images And Chart / Publish init-user (push) Failing after 10s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish api (push) Failing after 7s
Publish Images And Chart / Publish executor (push) Failing after 9s
Publish Images And Chart / Publish notifier (push) Failing after 10s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Clippy (push) Successful in 18m52s
CI / Tests (push) Has been cancelled
This commit is contained in:
@@ -70,6 +70,7 @@ jsonschema = { workspace = true }
|
||||
# HTTP client
|
||||
reqwest = { workspace = true }
|
||||
openidconnect = "4.0"
|
||||
ldap3 = "0.12"
|
||||
url = { workspace = true }
|
||||
|
||||
# Archive/compression
|
||||
|
||||
479
crates/api/src/auth/ldap.rs
Normal file
479
crates/api/src/auth/ldap.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! LDAP authentication helpers for username/password login.
|
||||
|
||||
use attune_common::{
|
||||
config::LdapConfig,
|
||||
repositories::{
|
||||
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput},
|
||||
Create, Update,
|
||||
},
|
||||
};
|
||||
use ldap3::{dn_escape, ldap_escape, Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
auth::jwt::{generate_access_token, generate_refresh_token},
|
||||
dto::TokenResponse,
|
||||
middleware::error::ApiError,
|
||||
state::SharedState,
|
||||
};
|
||||
|
||||
/// Claims extracted from the LDAP directory for an authenticated user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LdapUserClaims {
|
||||
/// The LDAP server URL the user was authenticated against.
|
||||
pub server_url: String,
|
||||
/// The user's full distinguished name.
|
||||
pub dn: String,
|
||||
/// Login attribute value (uid, sAMAccountName, etc.).
|
||||
pub login: Option<String>,
|
||||
/// Email address.
|
||||
pub email: Option<String>,
|
||||
/// Display name (cn).
|
||||
pub display_name: Option<String>,
|
||||
/// Group memberships (memberOf values).
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// The result of a successful LDAP authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LdapAuthenticatedIdentity {
|
||||
pub token_response: TokenResponse,
|
||||
}
|
||||
|
||||
/// Authenticate a user against the configured LDAP directory.
|
||||
///
|
||||
/// This performs a bind (either direct or search+bind) to verify
|
||||
/// the user's credentials, then fetches their attributes and upserts
|
||||
/// the identity in the database.
|
||||
pub async fn authenticate(
|
||||
state: &SharedState,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<LdapAuthenticatedIdentity, ApiError> {
|
||||
let ldap_config = ldap_config(state)?;
|
||||
|
||||
// Connect and authenticate
|
||||
let claims = if ldap_config.bind_dn_template.is_some() {
|
||||
direct_bind(&ldap_config, login, password).await?
|
||||
} else {
|
||||
search_and_bind(&ldap_config, login, password).await?
|
||||
};
|
||||
|
||||
// Upsert identity in DB and issue JWT tokens
|
||||
let identity = upsert_identity(state, &claims).await?;
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
let token_response = TokenResponse::new(
|
||||
access_token,
|
||||
refresh_token,
|
||||
state.jwt_config.access_token_expiration,
|
||||
)
|
||||
.with_user(
|
||||
identity.id,
|
||||
identity.login.clone(),
|
||||
identity.display_name.clone(),
|
||||
);
|
||||
|
||||
Ok(LdapAuthenticatedIdentity { token_response })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ldap_config(state: &SharedState) -> Result<LdapConfig, ApiError> {
|
||||
let config = state
|
||||
.config
|
||||
.security
|
||||
.ldap
|
||||
.clone()
|
||||
.filter(|ldap| ldap.enabled)
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotImplemented("LDAP authentication is not configured".to_string())
|
||||
})?;
|
||||
|
||||
// Reject partial service-account configuration: having exactly one of
|
||||
// search_bind_dn / search_bind_password is almost certainly a config
|
||||
// error and would silently fall back to anonymous search, which is a
|
||||
// very different security posture than the admin intended.
|
||||
let has_dn = config.search_bind_dn.is_some();
|
||||
let has_pw = config.search_bind_password.is_some();
|
||||
if has_dn != has_pw {
|
||||
let missing = if has_dn {
|
||||
"search_bind_password"
|
||||
} else {
|
||||
"search_bind_dn"
|
||||
};
|
||||
return Err(ApiError::InternalServerError(format!(
|
||||
"LDAP misconfiguration: search_bind_dn and search_bind_password must both be set \
|
||||
or both be omitted (missing {missing})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Build an `LdapConnSettings` from the config.
|
||||
fn conn_settings(config: &LdapConfig) -> LdapConnSettings {
|
||||
let mut settings = LdapConnSettings::new();
|
||||
if config.starttls {
|
||||
settings = settings.set_starttls(true);
|
||||
}
|
||||
if config.danger_skip_tls_verify {
|
||||
settings = settings.set_no_tls_verify(true);
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// Open a new LDAP connection.
|
||||
async fn connect(config: &LdapConfig) -> Result<Ldap, ApiError> {
|
||||
let settings = conn_settings(config);
|
||||
let (conn, ldap) = LdapConnAsync::with_settings(settings, &config.url)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ApiError::InternalServerError(format!("Failed to connect to LDAP server: {err}"))
|
||||
})?;
|
||||
// Drive the connection in the background
|
||||
ldap3::drive!(conn);
|
||||
Ok(ldap)
|
||||
}
|
||||
|
||||
/// Direct-bind authentication: construct the DN from the template and bind.
|
||||
async fn direct_bind(
|
||||
config: &LdapConfig,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<LdapUserClaims, ApiError> {
|
||||
let template = config.bind_dn_template.as_deref().unwrap_or_default();
|
||||
// Escape the login value for safe interpolation into a Distinguished Name
|
||||
// (RFC 4514). Without this, characters like `,`, `+`, `"`, `\`, `<`, `>`,
|
||||
// `;`, `=`, NUL, `#` (leading), or space (leading/trailing) in the username
|
||||
// would alter the DN structure.
|
||||
let escaped_login = dn_escape(login);
|
||||
let bind_dn = template.replace("{login}", &escaped_login);
|
||||
|
||||
let mut ldap = connect(config).await?;
|
||||
|
||||
// Bind as the user
|
||||
let result = ldap
|
||||
.simple_bind(&bind_dn, password)
|
||||
.await
|
||||
.map_err(|err| ApiError::InternalServerError(format!("LDAP bind failed: {err}")))?;
|
||||
|
||||
if result.rc != 0 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid LDAP credentials".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user attributes
|
||||
let claims = fetch_user_attributes(config, &mut ldap, &bind_dn).await?;
|
||||
|
||||
let _ = ldap.unbind().await;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Search-and-bind authentication:
|
||||
/// 1. Bind as the service account (or anonymous)
|
||||
/// 2. Search for the user entry (must match exactly one)
|
||||
/// 3. Re-bind as the user with their DN + password
|
||||
async fn search_and_bind(
|
||||
config: &LdapConfig,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> Result<LdapUserClaims, ApiError> {
|
||||
let search_base = config.user_search_base.as_deref().ok_or_else(|| {
|
||||
ApiError::InternalServerError(
|
||||
"LDAP user_search_base is required when bind_dn_template is not set".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut ldap = connect(config).await?;
|
||||
|
||||
// Step 1: Bind as service account or anonymous.
|
||||
// Partial config (only one of dn/password) is already rejected by
|
||||
// ldap_config(), so this match is exhaustive over valid states.
|
||||
if let (Some(bind_dn), Some(bind_pw)) = (
|
||||
config.search_bind_dn.as_deref(),
|
||||
config.search_bind_password.as_deref(),
|
||||
) {
|
||||
let result = ldap.simple_bind(bind_dn, bind_pw).await.map_err(|err| {
|
||||
ApiError::InternalServerError(format!("LDAP service bind failed: {err}"))
|
||||
})?;
|
||||
if result.rc != 0 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(ApiError::InternalServerError(
|
||||
"LDAP service account bind failed — check search_bind_dn and search_bind_password"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// If no service account, we proceed with an anonymous connection (already connected)
|
||||
|
||||
// Step 2: Search for the user.
|
||||
// Escape the login value for safe interpolation into an LDAP search filter
|
||||
// (RFC 4515). Without this, characters like `(`, `)`, `*`, `\`, and NUL in
|
||||
// the username could broaden the filter, match unintended entries, or break
|
||||
// the search entirely.
|
||||
let escaped_login = ldap_escape(login);
|
||||
let filter = config.user_filter.replace("{login}", &escaped_login);
|
||||
let attrs = vec![
|
||||
config.login_attr.as_str(),
|
||||
config.email_attr.as_str(),
|
||||
config.display_name_attr.as_str(),
|
||||
config.group_attr.as_str(),
|
||||
"dn",
|
||||
];
|
||||
|
||||
let (results, _result) = ldap
|
||||
.search(search_base, Scope::Subtree, &filter, attrs)
|
||||
.await
|
||||
.map_err(|err| ApiError::InternalServerError(format!("LDAP user search failed: {err}")))?
|
||||
.success()
|
||||
.map_err(|err| ApiError::InternalServerError(format!("LDAP search error: {err}")))?;
|
||||
|
||||
// The search must return exactly one entry. Zero means the user was not
|
||||
// found; more than one means the filter or directory layout is ambiguous
|
||||
// and we must not guess which identity to authenticate.
|
||||
let result_count = results.len();
|
||||
if result_count == 0 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid LDAP credentials".to_string(),
|
||||
));
|
||||
}
|
||||
if result_count > 1 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(ApiError::InternalServerError(format!(
|
||||
"LDAP user search returned {result_count} entries (expected exactly 1) — \
|
||||
tighten the user_filter or user_search_base to ensure uniqueness"
|
||||
)));
|
||||
}
|
||||
|
||||
// SAFETY: result_count == 1 guaranteed by the checks above.
|
||||
let entry = results
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("checked result_count == 1");
|
||||
let search_entry = SearchEntry::construct(entry);
|
||||
let user_dn = search_entry.dn.clone();
|
||||
|
||||
// Step 3: Re-bind as the user
|
||||
let result = ldap
|
||||
.simple_bind(&user_dn, password)
|
||||
.await
|
||||
.map_err(|err| ApiError::InternalServerError(format!("LDAP user bind failed: {err}")))?;
|
||||
if result.rc != 0 {
|
||||
let _ = ldap.unbind().await;
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid LDAP credentials".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let claims = extract_claims(config, &search_entry);
|
||||
let _ = ldap.unbind().await;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Fetch the user's LDAP attributes after a successful bind.
|
||||
async fn fetch_user_attributes(
|
||||
config: &LdapConfig,
|
||||
ldap: &mut Ldap,
|
||||
user_dn: &str,
|
||||
) -> Result<LdapUserClaims, ApiError> {
|
||||
let attrs = vec![
|
||||
config.login_attr.as_str(),
|
||||
config.email_attr.as_str(),
|
||||
config.display_name_attr.as_str(),
|
||||
config.group_attr.as_str(),
|
||||
];
|
||||
|
||||
let (results, _result) = ldap
|
||||
.search(user_dn, Scope::Base, "(objectClass=*)", attrs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ApiError::InternalServerError(format!(
|
||||
"LDAP attribute fetch failed for DN {user_dn}: {err}"
|
||||
))
|
||||
})?
|
||||
.success()
|
||||
.map_err(|err| {
|
||||
ApiError::InternalServerError(format!("LDAP attribute search error: {err}"))
|
||||
})?;
|
||||
|
||||
let entry = results.into_iter().next().ok_or_else(|| {
|
||||
ApiError::InternalServerError(format!("LDAP entry not found for DN: {user_dn}"))
|
||||
})?;
|
||||
let search_entry = SearchEntry::construct(entry);
|
||||
|
||||
Ok(extract_claims(config, &search_entry))
|
||||
}
|
||||
|
||||
/// Extract user claims from an LDAP search entry.
|
||||
fn extract_claims(config: &LdapConfig, entry: &SearchEntry) -> LdapUserClaims {
|
||||
let first_attr =
|
||||
|name: &str| -> Option<String> { entry.attrs.get(name).and_then(|v| v.first()).cloned() };
|
||||
|
||||
let groups = entry
|
||||
.attrs
|
||||
.get(&config.group_attr)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
LdapUserClaims {
|
||||
server_url: config.url.clone(),
|
||||
dn: entry.dn.clone(),
|
||||
login: first_attr(&config.login_attr),
|
||||
email: first_attr(&config.email_attr),
|
||||
display_name: first_attr(&config.display_name_attr),
|
||||
groups,
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert an identity row for the LDAP-authenticated user.
|
||||
async fn upsert_identity(
|
||||
state: &SharedState,
|
||||
claims: &LdapUserClaims,
|
||||
) -> Result<attune_common::models::identity::Identity, ApiError> {
|
||||
let existing =
|
||||
IdentityRepository::find_by_ldap_dn(&state.db, &claims.server_url, &claims.dn).await?;
|
||||
let desired_login = derive_login(claims);
|
||||
let display_name = claims.display_name.clone();
|
||||
let attributes = json!({ "ldap": claims });
|
||||
|
||||
match existing {
|
||||
Some(identity) => {
|
||||
let updated = UpdateIdentityInput {
|
||||
display_name,
|
||||
password_hash: None,
|
||||
attributes: Some(attributes),
|
||||
};
|
||||
IdentityRepository::update(&state.db, identity.id, updated)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
None => {
|
||||
// Avoid login collisions
|
||||
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
|
||||
Some(_) => fallback_dn_login(claims),
|
||||
None => desired_login,
|
||||
};
|
||||
|
||||
IdentityRepository::create(
|
||||
&state.db,
|
||||
CreateIdentityInput {
|
||||
login,
|
||||
display_name,
|
||||
password_hash: None,
|
||||
attributes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the login name from LDAP claims.
|
||||
fn derive_login(claims: &LdapUserClaims) -> String {
|
||||
claims
|
||||
.login
|
||||
.clone()
|
||||
.or_else(|| claims.email.clone())
|
||||
.unwrap_or_else(|| fallback_dn_login(claims))
|
||||
}
|
||||
|
||||
/// Generate a deterministic fallback login from the LDAP server URL + DN.
|
||||
fn fallback_dn_login(claims: &LdapUserClaims) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(claims.server_url.as_bytes());
|
||||
hasher.update(b":");
|
||||
hasher.update(claims.dn.as_bytes());
|
||||
let digest = hex::encode(hasher.finalize());
|
||||
format!("ldap:{}", &digest[..24])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_bind_dn_escapes_special_characters() {
|
||||
// Simulate what direct_bind does with the template
|
||||
let template = "uid={login},ou=users,dc=example,dc=com";
|
||||
let malicious_login = "admin,ou=admins,dc=evil,dc=com";
|
||||
let escaped = dn_escape(malicious_login);
|
||||
let bind_dn = template.replace("{login}", &escaped);
|
||||
// The commas in the login value must be escaped so they don't
|
||||
// introduce additional RDN components.
|
||||
assert!(
|
||||
bind_dn.contains("\\2c"),
|
||||
"commas in login must be escaped in DN: {bind_dn}"
|
||||
);
|
||||
assert!(
|
||||
bind_dn.starts_with("uid=admin\\2cou\\3dadmins\\2cdc\\3devil\\2cdc\\3dcom,ou=users"),
|
||||
"DN structure must be preserved: {bind_dn}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_filter_escapes_special_characters() {
|
||||
let filter_template = "(uid={login})";
|
||||
let malicious_login = "admin)(|(uid=*))";
|
||||
let escaped = ldap_escape(malicious_login);
|
||||
let filter = filter_template.replace("{login}", &escaped);
|
||||
// The parentheses and asterisk must be escaped so they don't
|
||||
// alter the filter structure.
|
||||
assert!(
|
||||
!filter.contains(")("),
|
||||
"parentheses in login must be escaped in filter: {filter}"
|
||||
);
|
||||
assert!(
|
||||
filter.contains("\\28"),
|
||||
"open-paren must be hex-escaped: {filter}"
|
||||
);
|
||||
assert!(
|
||||
filter.contains("\\29"),
|
||||
"close-paren must be hex-escaped: {filter}"
|
||||
);
|
||||
assert!(
|
||||
filter.contains("\\2a"),
|
||||
"asterisk must be hex-escaped: {filter}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dn_escape_preserves_safe_usernames() {
|
||||
let safe = "jdoe";
|
||||
let escaped = dn_escape(safe);
|
||||
assert_eq!(escaped.as_ref(), "jdoe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_escape_preserves_safe_usernames() {
|
||||
let safe = "jdoe";
|
||||
let escaped = ldap_escape(safe);
|
||||
assert_eq!(escaped.as_ref(), "jdoe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_dn_login_is_deterministic() {
|
||||
let claims = LdapUserClaims {
|
||||
server_url: "ldap://ldap.example.com".to_string(),
|
||||
dn: "uid=test,ou=users,dc=example,dc=com".to_string(),
|
||||
login: None,
|
||||
email: None,
|
||||
display_name: None,
|
||||
groups: vec![],
|
||||
};
|
||||
let a = fallback_dn_login(&claims);
|
||||
let b = fallback_dn_login(&claims);
|
||||
assert_eq!(a, b);
|
||||
assert!(a.starts_with("ldap:"));
|
||||
assert_eq!(a.len(), "ldap:".len() + 24);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Authentication and authorization module
|
||||
|
||||
pub mod jwt;
|
||||
pub mod ldap;
|
||||
pub mod middleware;
|
||||
pub mod oidc;
|
||||
pub mod password;
|
||||
|
||||
@@ -172,6 +172,26 @@ pub struct AuthSettingsResponse {
|
||||
#[schema(example = "https://auth.example.com/assets/logo.svg")]
|
||||
pub oidc_provider_icon_url: Option<String>,
|
||||
|
||||
/// Whether LDAP login is configured and enabled.
|
||||
#[schema(example = false)]
|
||||
pub ldap_enabled: bool,
|
||||
|
||||
/// Whether LDAP login should be shown by default.
|
||||
#[schema(example = false)]
|
||||
pub ldap_visible_by_default: bool,
|
||||
|
||||
/// Provider name for `?auth=<provider>`.
|
||||
#[schema(example = "ldap")]
|
||||
pub ldap_provider_name: Option<String>,
|
||||
|
||||
/// User-facing provider label for the login button.
|
||||
#[schema(example = "Company LDAP")]
|
||||
pub ldap_provider_label: Option<String>,
|
||||
|
||||
/// Optional icon URL shown beside the provider label.
|
||||
#[schema(example = "https://ldap.example.com/assets/logo.svg")]
|
||||
pub ldap_provider_icon_url: Option<String>,
|
||||
|
||||
/// Whether unauthenticated self-service registration is allowed.
|
||||
#[schema(example = false)]
|
||||
pub self_registration_enabled: bool,
|
||||
|
||||
@@ -70,6 +70,7 @@ use crate::dto::{
|
||||
// Authentication
|
||||
crate::routes::auth::auth_settings,
|
||||
crate::routes::auth::login,
|
||||
crate::routes::auth::ldap_login,
|
||||
crate::routes::auth::register,
|
||||
crate::routes::auth::refresh_token,
|
||||
crate::routes::auth::get_current_user,
|
||||
@@ -239,6 +240,7 @@ use crate::dto::{
|
||||
|
||||
// Auth DTOs
|
||||
LoginRequest,
|
||||
crate::routes::auth::LdapLoginRequest,
|
||||
RegisterRequest,
|
||||
RefreshTokenRequest,
|
||||
ChangePasswordRequest,
|
||||
@@ -453,4 +455,43 @@ mod tests {
|
||||
println!("Total API paths: {}", path_count);
|
||||
println!("Total API operations: {}", operation_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_endpoints_registered() {
|
||||
let doc = ApiDoc::openapi();
|
||||
|
||||
let expected_auth_paths = vec![
|
||||
"/auth/settings",
|
||||
"/auth/login",
|
||||
"/auth/ldap/login",
|
||||
"/auth/register",
|
||||
"/auth/refresh",
|
||||
"/auth/me",
|
||||
"/auth/change-password",
|
||||
];
|
||||
|
||||
for path in &expected_auth_paths {
|
||||
assert!(
|
||||
doc.paths.paths.contains_key(*path),
|
||||
"Expected auth endpoint {} to be registered in OpenAPI spec, but it was missing. \
|
||||
Registered paths: {:?}",
|
||||
path,
|
||||
doc.paths.paths.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ldap_login_request_schema_registered() {
|
||||
let doc = ApiDoc::openapi();
|
||||
|
||||
let components = doc.components.as_ref().expect("components should exist");
|
||||
|
||||
assert!(
|
||||
components.schemas.contains_key("LdapLoginRequest"),
|
||||
"Expected LdapLoginRequest schema to be registered in OpenAPI components. \
|
||||
Registered schemas: {:?}",
|
||||
components.schemas.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn routes() -> Router<SharedState> {
|
||||
.route("/login", post(login))
|
||||
.route("/oidc/login", get(oidc_login))
|
||||
.route("/callback", get(oidc_callback))
|
||||
.route("/ldap/login", post(ldap_login))
|
||||
.route("/logout", get(logout))
|
||||
.route("/register", post(register))
|
||||
.route("/refresh", post(refresh_token))
|
||||
@@ -104,6 +105,13 @@ pub async fn auth_settings(
|
||||
.as_ref()
|
||||
.filter(|oidc| oidc.enabled);
|
||||
|
||||
let ldap = state
|
||||
.config
|
||||
.security
|
||||
.ldap
|
||||
.as_ref()
|
||||
.filter(|ldap| ldap.enabled);
|
||||
|
||||
let response = AuthSettingsResponse {
|
||||
authentication_enabled: state.config.security.enable_auth,
|
||||
local_password_enabled: state.config.security.enable_auth,
|
||||
@@ -112,9 +120,21 @@ pub async fn auth_settings(
|
||||
oidc_enabled: oidc.is_some(),
|
||||
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
|
||||
oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()),
|
||||
oidc_provider_label: oidc
|
||||
.map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())),
|
||||
oidc_provider_label: oidc.map(|oidc| {
|
||||
oidc.provider_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| oidc.provider_name.clone())
|
||||
}),
|
||||
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()),
|
||||
ldap_enabled: ldap.is_some(),
|
||||
ldap_visible_by_default: ldap.is_some() && state.config.security.login_page.show_ldap_login,
|
||||
ldap_provider_name: ldap.map(|ldap| ldap.provider_name.clone()),
|
||||
ldap_provider_label: ldap.map(|ldap| {
|
||||
ldap.provider_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| ldap.provider_name.clone())
|
||||
}),
|
||||
ldap_provider_icon_url: ldap.and_then(|ldap| ldap.provider_icon_url.clone()),
|
||||
self_registration_enabled: state.config.security.allow_self_registration,
|
||||
};
|
||||
|
||||
@@ -369,6 +389,17 @@ pub async fn get_current_user(
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Request body for LDAP login.
|
||||
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
||||
pub struct LdapLoginRequest {
|
||||
/// User login name (uid, sAMAccountName, etc.)
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub login: String,
|
||||
/// User password
|
||||
#[validate(length(min = 1, max = 512))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OidcLoginParams {
|
||||
pub redirect_to: Option<String>,
|
||||
@@ -401,6 +432,34 @@ pub async fn oidc_callback(
|
||||
)
|
||||
}
|
||||
|
||||
/// Authenticate via LDAP directory.
|
||||
///
|
||||
/// POST /auth/ldap/login
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/ldap/login",
|
||||
tag = "auth",
|
||||
request_body = LdapLoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Successfully authenticated via LDAP", body = inline(ApiResponse<TokenResponse>)),
|
||||
(status = 401, description = "Invalid LDAP credentials"),
|
||||
(status = 501, description = "LDAP not configured")
|
||||
)
|
||||
)]
|
||||
pub async fn ldap_login(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<LdapLoginRequest>,
|
||||
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid LDAP login request: {e}")))?;
|
||||
|
||||
let authenticated =
|
||||
crate::auth::ldap::authenticate(&state, &payload.login, &payload.password).await?;
|
||||
|
||||
Ok(Json(ApiResponse::new(authenticated.token_response)))
|
||||
}
|
||||
|
||||
/// Logout the current browser session and optionally redirect through the provider logout flow.
|
||||
pub async fn logout(
|
||||
State(state): State<SharedState>,
|
||||
|
||||
@@ -305,6 +305,126 @@ async fn test_login_nonexistent_user() {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ── LDAP auth tests ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_ldap_login_returns_501_when_not_configured() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/auth/ldap/login",
|
||||
json!({
|
||||
"login": "jdoe",
|
||||
"password": "secret"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// LDAP is not configured in config.test.yaml, so the endpoint
|
||||
// should return 501 Not Implemented.
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_ldap_login_validates_empty_login() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/auth/ldap/login",
|
||||
json!({
|
||||
"login": "",
|
||||
"password": "secret"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Validation should fail before we even check LDAP config
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_ldap_login_validates_empty_password() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/auth/ldap/login",
|
||||
json!({
|
||||
"login": "jdoe",
|
||||
"password": ""
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_ldap_login_validates_missing_fields() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.post("/auth/ldap/login", json!({}), None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Missing required fields should return 422
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// ── auth/settings LDAP field tests ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_auth_settings_includes_ldap_fields_disabled() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/auth/settings", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||
|
||||
// LDAP is not configured in config.test.yaml, so these should all
|
||||
// reflect the disabled state.
|
||||
assert_eq!(body["data"]["ldap_enabled"], false);
|
||||
assert_eq!(body["data"]["ldap_visible_by_default"], false);
|
||||
assert!(body["data"]["ldap_provider_name"].is_null());
|
||||
assert!(body["data"]["ldap_provider_label"].is_null());
|
||||
assert!(body["data"]["ldap_provider_icon_url"].is_null());
|
||||
|
||||
// Existing fields should still be present
|
||||
assert!(body["data"]["authentication_enabled"].is_boolean());
|
||||
assert!(body["data"]["local_password_enabled"].is_boolean());
|
||||
assert!(body["data"]["oidc_enabled"].is_boolean());
|
||||
assert!(body["data"]["self_registration_enabled"].is_boolean());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_get_current_user() {
|
||||
|
||||
Reference in New Issue
Block a user