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:
@@ -10,8 +10,9 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY_HOST: ${{ vars.CONTAINER_REGISTRY_HOST }}
|
REGISTRY_HOST: ${{ vars.CLUSTER_GITEA_HOST }}
|
||||||
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
||||||
|
REGISTRY_PLAIN_HTTP: ${{ vars.CONTAINER_REGISTRY_INSECURE }}
|
||||||
CHART_NAME: attune
|
CHART_NAME: attune
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -21,6 +22,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
registry: ${{ steps.meta.outputs.registry }}
|
registry: ${{ steps.meta.outputs.registry }}
|
||||||
namespace: ${{ steps.meta.outputs.namespace }}
|
namespace: ${{ steps.meta.outputs.namespace }}
|
||||||
|
registry_plain_http: ${{ steps.meta.outputs.registry_plain_http }}
|
||||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||||
image_tags: ${{ steps.meta.outputs.image_tags }}
|
image_tags: ${{ steps.meta.outputs.image_tags }}
|
||||||
chart_version: ${{ steps.meta.outputs.chart_version }}
|
chart_version: ${{ steps.meta.outputs.chart_version }}
|
||||||
@@ -35,9 +37,10 @@ jobs:
|
|||||||
|
|
||||||
registry="${REGISTRY_HOST}"
|
registry="${REGISTRY_HOST}"
|
||||||
namespace="${REGISTRY_NAMESPACE}"
|
namespace="${REGISTRY_NAMESPACE}"
|
||||||
|
registry_plain_http_raw="${REGISTRY_PLAIN_HTTP:-}"
|
||||||
|
|
||||||
if [ -z "$registry" ]; then
|
if [ -z "$registry" ]; then
|
||||||
echo "CONTAINER_REGISTRY_HOST repository variable is required"
|
echo "CLUSTER_GITEA_HOST app variable is required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -45,6 +48,15 @@ jobs:
|
|||||||
namespace="${{ github.repository_owner }}"
|
namespace="${{ github.repository_owner }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$(printf '%s' "$registry_plain_http_raw" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
1|true|yes|on)
|
||||||
|
registry_plain_http="true"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
registry_plain_http="false"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
short_sha="$(printf '%s' "${{ github.sha }}" | cut -c1-12)"
|
short_sha="$(printf '%s' "${{ github.sha }}" | cut -c1-12)"
|
||||||
ref_type="${{ github.ref_type }}"
|
ref_type="${{ github.ref_type }}"
|
||||||
ref_name="${{ github.ref_name }}"
|
ref_name="${{ github.ref_name }}"
|
||||||
@@ -64,6 +76,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "registry=$registry"
|
echo "registry=$registry"
|
||||||
echo "namespace=$namespace"
|
echo "namespace=$namespace"
|
||||||
|
echo "registry_plain_http=$registry_plain_http"
|
||||||
echo "image_tag=$version"
|
echo "image_tag=$version"
|
||||||
echo "image_tags=$image_tags"
|
echo "image_tags=$image_tags"
|
||||||
echo "chart_version=$chart_version"
|
echo "chart_version=$chart_version"
|
||||||
@@ -141,8 +154,18 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
|
if: needs.metadata.outputs.registry_plain_http != 'true'
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Setup Docker Buildx For Plain HTTP Registry
|
||||||
|
if: needs.metadata.outputs.registry_plain_http == 'true'
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."${{ needs.metadata.outputs.registry }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
- name: Log in to Gitea OCI registry
|
- name: Log in to Gitea OCI registry
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -153,13 +176,18 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
username="${REGISTRY_USERNAME:-${{ github.actor }}}"
|
username="${REGISTRY_USERNAME:-${{ github.actor }}}"
|
||||||
password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
|
password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
|
||||||
|
registry="${{ needs.metadata.outputs.registry }}"
|
||||||
|
|
||||||
if [ -z "$password" ]; then
|
if [ -z "$password" ]; then
|
||||||
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
|
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '%s' "$password" | docker login "${{ needs.metadata.outputs.registry }}" \
|
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
|
||||||
|
registry="http://${registry}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$password" | docker login "$registry" \
|
||||||
--username "$username" \
|
--username "$username" \
|
||||||
--password-stdin
|
--password-stdin
|
||||||
|
|
||||||
@@ -224,14 +252,20 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}"
|
registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}"
|
||||||
registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
|
registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
|
||||||
|
login_args=()
|
||||||
|
|
||||||
if [ -z "$registry_password" ]; then
|
if [ -z "$registry_password" ]; then
|
||||||
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
|
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
|
||||||
|
login_args+=(--plain-http)
|
||||||
|
fi
|
||||||
|
|
||||||
printf '%s' "$registry_password" | helm registry login "${{ needs.metadata.outputs.registry }}" \
|
printf '%s' "$registry_password" | helm registry login "${{ needs.metadata.outputs.registry }}" \
|
||||||
--username "$registry_username" \
|
--username "$registry_username" \
|
||||||
|
"${login_args[@]}" \
|
||||||
--password-stdin
|
--password-stdin
|
||||||
|
|
||||||
- name: Lint chart
|
- name: Lint chart
|
||||||
@@ -248,5 +282,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Push chart to OCI registry
|
- name: Push chart to OCI registry
|
||||||
run: |
|
run: |
|
||||||
|
push_args=()
|
||||||
|
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
|
||||||
|
push_args+=(--plain-http)
|
||||||
|
fi
|
||||||
|
|
||||||
helm push "dist/${CHART_NAME}-${{ needs.metadata.outputs.chart_version }}.tgz" \
|
helm push "dist/${CHART_NAME}-${{ needs.metadata.outputs.chart_version }}.tgz" \
|
||||||
"oci://${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/helm"
|
"oci://${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/helm" \
|
||||||
|
"${push_args[@]}"
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ Completion listener advances workflow → Schedules successor tasks → Complete
|
|||||||
- **Auth Type**: JWT (access tokens: 1h, refresh tokens: 7d)
|
- **Auth Type**: JWT (access tokens: 1h, refresh tokens: 7d)
|
||||||
- **Password Hashing**: Argon2id
|
- **Password Hashing**: Argon2id
|
||||||
- **Protected Routes**: Use `RequireAuth(user)` extractor in Axum
|
- **Protected Routes**: Use `RequireAuth(user)` extractor in Axum
|
||||||
|
- **External Identity Providers**: OIDC and LDAP are supported as optional login methods alongside local username/password. Both upsert an `identity` row on first login and store provider-specific claims under `attributes.oidc` or `attributes.ldap` respectively. The web UI login page adapts dynamically based on the `GET /auth/settings` response, showing/hiding each method. The `?auth=<provider_name>` query parameter overrides which method is displayed (e.g., `?auth=direct`, `?auth=sso`, `?auth=ldap`).
|
||||||
|
- **OIDC** (`crates/api/src/auth/oidc.rs`): Browser-redirect flow using the `openidconnect` crate. Config: `security.oidc` in YAML. Routes: `GET /auth/oidc/login` (redirect to provider), `GET /auth/callback` (authorization code exchange). Identity matched by `attributes->'oidc'->>'issuer'` + `attributes->'oidc'->>'sub'`. Supports PKCE, ID token verification via JWKS, userinfo endpoint enrichment, and provider-initiated logout via `end_session_endpoint`.
|
||||||
|
- **LDAP** (`crates/api/src/auth/ldap.rs`): Server-side bind flow using the `ldap3` crate. Config: `security.ldap` in YAML. Route: `POST /auth/ldap/login` (accepts `{login, password}`, returns `TokenResponse`). Two authentication modes: **direct bind** (construct DN from `bind_dn_template` with `{login}` placeholder) or **search-and-bind** (bind as service account → search `user_search_base` with `user_filter` → re-bind as discovered DN). Identity matched by `attributes->'ldap'->>'server_url'` + `attributes->'ldap'->>'dn'`. Supports STARTTLS, TLS cert skip (`danger_skip_tls_verify`), and configurable attribute mapping (`login_attr`, `email_attr`, `display_name_attr`, `group_attr`).
|
||||||
|
- **Login Page Config** (`security.login_page`): `show_local_login`, `show_oidc_login`, `show_ldap_login` — all default to `true`. Controls which methods are visible by default on the web UI login page.
|
||||||
- **Secrets Storage**: AES-GCM encrypted in `key` table (JSONB `value` column) with scoped ownership. Supports structured values (objects, arrays) in addition to plain strings. All encryption/decryption goes through `attune_common::crypto` (`encrypt_json`/`decrypt_json`) — the worker's `SecretManager` no longer has its own crypto implementation, eliminating a prior ciphertext format incompatibility between the API (`BASE64(nonce++ciphertext)`) and the old worker code (`BASE64(nonce):BASE64(ciphertext)`). The worker stores the raw encryption key string and passes it to the shared crypto module, which derives the AES-256 key internally via SHA-256.
|
- **Secrets Storage**: AES-GCM encrypted in `key` table (JSONB `value` column) with scoped ownership. Supports structured values (objects, arrays) in addition to plain strings. All encryption/decryption goes through `attune_common::crypto` (`encrypt_json`/`decrypt_json`) — the worker's `SecretManager` no longer has its own crypto implementation, eliminating a prior ciphertext format incompatibility between the API (`BASE64(nonce++ciphertext)`) and the old worker code (`BASE64(nonce):BASE64(ciphertext)`). The worker stores the raw encryption key string and passes it to the shared crypto module, which derives the AES-256 key internally via SHA-256.
|
||||||
- **User Info**: Stored in `identity` table
|
- **User Info**: Stored in `identity` table
|
||||||
|
|
||||||
|
|||||||
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -477,6 +477,7 @@ dependencies = [
|
|||||||
"hmac",
|
"hmac",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"ldap3",
|
||||||
"mockall",
|
"mockall",
|
||||||
"openidconnect",
|
"openidconnect",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
@@ -3043,6 +3044,39 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lber"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ldap3"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures",
|
||||||
|
"futures-util",
|
||||||
|
"lber",
|
||||||
|
"log",
|
||||||
|
"native-tls",
|
||||||
|
"nom 7.1.3",
|
||||||
|
"percent-encoding",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ security:
|
|||||||
post_logout_redirect_uri: http://localhost:3000/login
|
post_logout_redirect_uri: http://localhost:3000/login
|
||||||
scopes:
|
scopes:
|
||||||
- groups
|
- groups
|
||||||
|
ldap:
|
||||||
|
enabled: false
|
||||||
|
url: ldap://localhost:389
|
||||||
|
bind_dn_template: "uid={login},ou=users,dc=example,dc=com"
|
||||||
|
provider_name: ldap
|
||||||
|
provider_label: Development LDAP
|
||||||
|
|
||||||
# Packs directory (where pack action files are located)
|
# Packs directory (where pack action files are located)
|
||||||
packs_base_dir: ./packs
|
packs_base_dir: ./packs
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ security:
|
|||||||
login_page:
|
login_page:
|
||||||
show_local_login: true
|
show_local_login: true
|
||||||
show_oidc_login: true
|
show_oidc_login: true
|
||||||
|
show_ldap_login: true
|
||||||
|
|
||||||
# Optional OIDC browser login configuration
|
# Optional OIDC browser login configuration
|
||||||
oidc:
|
oidc:
|
||||||
@@ -107,6 +108,26 @@ security:
|
|||||||
scopes:
|
scopes:
|
||||||
- groups
|
- groups
|
||||||
|
|
||||||
|
# Optional LDAP authentication configuration
|
||||||
|
ldap:
|
||||||
|
enabled: false
|
||||||
|
url: ldap://ldap.example.com:389
|
||||||
|
# Direct-bind mode: construct DN from template
|
||||||
|
# bind_dn_template: "uid={login},ou=users,dc=example,dc=com"
|
||||||
|
# Search-and-bind mode: search for user with a service account
|
||||||
|
user_search_base: "ou=users,dc=example,dc=com"
|
||||||
|
user_filter: "(uid={login})"
|
||||||
|
search_bind_dn: "cn=readonly,dc=example,dc=com"
|
||||||
|
search_bind_password: "readonly-password"
|
||||||
|
login_attr: uid
|
||||||
|
email_attr: mail
|
||||||
|
display_name_attr: cn
|
||||||
|
group_attr: memberOf
|
||||||
|
starttls: false
|
||||||
|
danger_skip_tls_verify: false
|
||||||
|
provider_name: ldap
|
||||||
|
provider_label: Company LDAP
|
||||||
|
|
||||||
# Worker configuration (optional, for worker services)
|
# Worker configuration (optional, for worker services)
|
||||||
# Uncomment and configure if running worker processes
|
# Uncomment and configure if running worker processes
|
||||||
# worker:
|
# worker:
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ jsonschema = { workspace = true }
|
|||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
|
ldap3 = "0.12"
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
|
||||||
# Archive/compression
|
# 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
|
//! Authentication and authorization module
|
||||||
|
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
|
pub mod ldap;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod oidc;
|
pub mod oidc;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|||||||
@@ -172,6 +172,26 @@ pub struct AuthSettingsResponse {
|
|||||||
#[schema(example = "https://auth.example.com/assets/logo.svg")]
|
#[schema(example = "https://auth.example.com/assets/logo.svg")]
|
||||||
pub oidc_provider_icon_url: Option<String>,
|
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.
|
/// Whether unauthenticated self-service registration is allowed.
|
||||||
#[schema(example = false)]
|
#[schema(example = false)]
|
||||||
pub self_registration_enabled: bool,
|
pub self_registration_enabled: bool,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ use crate::dto::{
|
|||||||
// Authentication
|
// Authentication
|
||||||
crate::routes::auth::auth_settings,
|
crate::routes::auth::auth_settings,
|
||||||
crate::routes::auth::login,
|
crate::routes::auth::login,
|
||||||
|
crate::routes::auth::ldap_login,
|
||||||
crate::routes::auth::register,
|
crate::routes::auth::register,
|
||||||
crate::routes::auth::refresh_token,
|
crate::routes::auth::refresh_token,
|
||||||
crate::routes::auth::get_current_user,
|
crate::routes::auth::get_current_user,
|
||||||
@@ -239,6 +240,7 @@ use crate::dto::{
|
|||||||
|
|
||||||
// Auth DTOs
|
// Auth DTOs
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
crate::routes::auth::LdapLoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
@@ -453,4 +455,43 @@ mod tests {
|
|||||||
println!("Total API paths: {}", path_count);
|
println!("Total API paths: {}", path_count);
|
||||||
println!("Total API operations: {}", operation_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("/login", post(login))
|
||||||
.route("/oidc/login", get(oidc_login))
|
.route("/oidc/login", get(oidc_login))
|
||||||
.route("/callback", get(oidc_callback))
|
.route("/callback", get(oidc_callback))
|
||||||
|
.route("/ldap/login", post(ldap_login))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.route("/register", post(register))
|
.route("/register", post(register))
|
||||||
.route("/refresh", post(refresh_token))
|
.route("/refresh", post(refresh_token))
|
||||||
@@ -104,6 +105,13 @@ pub async fn auth_settings(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|oidc| oidc.enabled);
|
.filter(|oidc| oidc.enabled);
|
||||||
|
|
||||||
|
let ldap = state
|
||||||
|
.config
|
||||||
|
.security
|
||||||
|
.ldap
|
||||||
|
.as_ref()
|
||||||
|
.filter(|ldap| ldap.enabled);
|
||||||
|
|
||||||
let response = AuthSettingsResponse {
|
let response = AuthSettingsResponse {
|
||||||
authentication_enabled: state.config.security.enable_auth,
|
authentication_enabled: state.config.security.enable_auth,
|
||||||
local_password_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_enabled: oidc.is_some(),
|
||||||
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
|
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_name: oidc.map(|oidc| oidc.provider_name.clone()),
|
||||||
oidc_provider_label: oidc
|
oidc_provider_label: oidc.map(|oidc| {
|
||||||
.map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())),
|
oidc.provider_label
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| oidc.provider_name.clone())
|
||||||
|
}),
|
||||||
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.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,
|
self_registration_enabled: state.config.security.allow_self_registration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,6 +389,17 @@ pub async fn get_current_user(
|
|||||||
Ok(Json(ApiResponse::new(response)))
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct OidcLoginParams {
|
pub struct OidcLoginParams {
|
||||||
pub redirect_to: Option<String>,
|
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.
|
/// Logout the current browser session and optionally redirect through the provider logout flow.
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
|||||||
@@ -305,6 +305,126 @@ async fn test_login_nonexistent_user() {
|
|||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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]
|
#[tokio::test]
|
||||||
#[ignore = "integration test — requires database"]
|
#[ignore = "integration test — requires database"]
|
||||||
async fn test_get_current_user() {
|
async fn test_get_current_user() {
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ pub struct SecurityConfig {
|
|||||||
/// Optional OpenID Connect configuration for browser login.
|
/// Optional OpenID Connect configuration for browser login.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub oidc: Option<OidcConfig>,
|
pub oidc: Option<OidcConfig>,
|
||||||
|
|
||||||
|
/// Optional LDAP configuration for username/password login against a directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ldap: Option<LdapConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_jwt_access_expiration() -> u64 {
|
fn default_jwt_access_expiration() -> u64 {
|
||||||
@@ -327,6 +331,10 @@ pub struct LoginPageConfig {
|
|||||||
/// Show the OIDC/SSO option by default when configured.
|
/// Show the OIDC/SSO option by default when configured.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_oidc_login: bool,
|
pub show_oidc_login: bool,
|
||||||
|
|
||||||
|
/// Show the LDAP option by default when configured.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_ldap_login: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LoginPageConfig {
|
impl Default for LoginPageConfig {
|
||||||
@@ -334,6 +342,7 @@ impl Default for LoginPageConfig {
|
|||||||
Self {
|
Self {
|
||||||
show_local_login: true,
|
show_local_login: true,
|
||||||
show_oidc_login: true,
|
show_oidc_login: true,
|
||||||
|
show_ldap_login: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,6 +388,95 @@ fn default_oidc_provider_name() -> String {
|
|||||||
"oidc".to_string()
|
"oidc".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LDAP authentication configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LdapConfig {
|
||||||
|
/// Enable LDAP login flow.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// LDAP server URL (e.g., "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636").
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
/// Bind DN template. Use `{login}` as placeholder for the user-supplied login.
|
||||||
|
/// Example: "uid={login},ou=users,dc=example,dc=com"
|
||||||
|
/// If not set, an anonymous bind is attempted first to search for the user.
|
||||||
|
pub bind_dn_template: Option<String>,
|
||||||
|
|
||||||
|
/// Base DN for user searches when bind_dn_template is not set.
|
||||||
|
/// Example: "ou=users,dc=example,dc=com"
|
||||||
|
pub user_search_base: Option<String>,
|
||||||
|
|
||||||
|
/// LDAP search filter template. Use `{login}` as placeholder.
|
||||||
|
/// Default: "(uid={login})"
|
||||||
|
#[serde(default = "default_ldap_user_filter")]
|
||||||
|
pub user_filter: String,
|
||||||
|
|
||||||
|
/// DN of a service account used to search for users (required when using search-based auth).
|
||||||
|
pub search_bind_dn: Option<String>,
|
||||||
|
|
||||||
|
/// Password for the search service account.
|
||||||
|
pub search_bind_password: Option<String>,
|
||||||
|
|
||||||
|
/// LDAP attribute to use as the login name. Default: "uid"
|
||||||
|
#[serde(default = "default_ldap_login_attr")]
|
||||||
|
pub login_attr: String,
|
||||||
|
|
||||||
|
/// LDAP attribute to use as the email. Default: "mail"
|
||||||
|
#[serde(default = "default_ldap_email_attr")]
|
||||||
|
pub email_attr: String,
|
||||||
|
|
||||||
|
/// LDAP attribute to use as the display name. Default: "cn"
|
||||||
|
#[serde(default = "default_ldap_display_name_attr")]
|
||||||
|
pub display_name_attr: String,
|
||||||
|
|
||||||
|
/// LDAP attribute that contains group membership. Default: "memberOf"
|
||||||
|
#[serde(default = "default_ldap_group_attr")]
|
||||||
|
pub group_attr: String,
|
||||||
|
|
||||||
|
/// Whether to use STARTTLS. Default: false
|
||||||
|
#[serde(default)]
|
||||||
|
pub starttls: bool,
|
||||||
|
|
||||||
|
/// Whether to skip TLS certificate verification (insecure!). Default: false
|
||||||
|
#[serde(default)]
|
||||||
|
pub danger_skip_tls_verify: bool,
|
||||||
|
|
||||||
|
/// Provider name used in login-page overrides such as `?auth=<provider_name>`.
|
||||||
|
#[serde(default = "default_ldap_provider_name")]
|
||||||
|
pub provider_name: String,
|
||||||
|
|
||||||
|
/// User-facing provider label shown on the login page.
|
||||||
|
pub provider_label: Option<String>,
|
||||||
|
|
||||||
|
/// Optional icon URL shown beside the provider label on the login page.
|
||||||
|
pub provider_icon_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_provider_name() -> String {
|
||||||
|
"ldap".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_user_filter() -> String {
|
||||||
|
"(uid={login})".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_login_attr() -> String {
|
||||||
|
"uid".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_email_attr() -> String {
|
||||||
|
"mail".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_display_name_attr() -> String {
|
||||||
|
"cn".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ldap_group_attr() -> String {
|
||||||
|
"memberOf".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker configuration
|
/// Worker configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerConfig {
|
pub struct WorkerConfig {
|
||||||
@@ -753,6 +851,7 @@ impl Default for SecurityConfig {
|
|||||||
allow_self_registration: false,
|
allow_self_registration: false,
|
||||||
login_page: LoginPageConfig::default(),
|
login_page: LoginPageConfig::default(),
|
||||||
oidc: None,
|
oidc: None,
|
||||||
|
ldap: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1134,7 @@ mod tests {
|
|||||||
allow_self_registration: false,
|
allow_self_registration: false,
|
||||||
login_page: LoginPageConfig::default(),
|
login_page: LoginPageConfig::default(),
|
||||||
oidc: None,
|
oidc: None,
|
||||||
|
ldap: None,
|
||||||
},
|
},
|
||||||
worker: None,
|
worker: None,
|
||||||
sensor: None,
|
sensor: None,
|
||||||
@@ -1057,4 +1157,102 @@ mod tests {
|
|||||||
config.security.jwt_secret = None;
|
config.security.jwt_secret = None;
|
||||||
assert!(config.validate().is_err());
|
assert!(config.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ldap_config_defaults() {
|
||||||
|
let yaml = r#"
|
||||||
|
enabled: true
|
||||||
|
url: "ldap://localhost:389"
|
||||||
|
client_id: "test"
|
||||||
|
"#;
|
||||||
|
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert!(cfg.enabled);
|
||||||
|
assert_eq!(cfg.url, "ldap://localhost:389");
|
||||||
|
assert_eq!(cfg.user_filter, "(uid={login})");
|
||||||
|
assert_eq!(cfg.login_attr, "uid");
|
||||||
|
assert_eq!(cfg.email_attr, "mail");
|
||||||
|
assert_eq!(cfg.display_name_attr, "cn");
|
||||||
|
assert_eq!(cfg.group_attr, "memberOf");
|
||||||
|
assert_eq!(cfg.provider_name, "ldap");
|
||||||
|
assert!(!cfg.starttls);
|
||||||
|
assert!(!cfg.danger_skip_tls_verify);
|
||||||
|
assert!(cfg.bind_dn_template.is_none());
|
||||||
|
assert!(cfg.user_search_base.is_none());
|
||||||
|
assert!(cfg.search_bind_dn.is_none());
|
||||||
|
assert!(cfg.search_bind_password.is_none());
|
||||||
|
assert!(cfg.provider_label.is_none());
|
||||||
|
assert!(cfg.provider_icon_url.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ldap_config_full_deserialization() {
|
||||||
|
let yaml = r#"
|
||||||
|
enabled: true
|
||||||
|
url: "ldaps://ldap.corp.com:636"
|
||||||
|
bind_dn_template: "uid={login},ou=people,dc=corp,dc=com"
|
||||||
|
user_search_base: "ou=people,dc=corp,dc=com"
|
||||||
|
user_filter: "(sAMAccountName={login})"
|
||||||
|
search_bind_dn: "cn=svc,dc=corp,dc=com"
|
||||||
|
search_bind_password: "secret"
|
||||||
|
login_attr: "sAMAccountName"
|
||||||
|
email_attr: "userPrincipalName"
|
||||||
|
display_name_attr: "displayName"
|
||||||
|
group_attr: "memberOf"
|
||||||
|
starttls: true
|
||||||
|
danger_skip_tls_verify: true
|
||||||
|
provider_name: "corpldap"
|
||||||
|
provider_label: "Corporate Directory"
|
||||||
|
provider_icon_url: "https://corp.com/icon.svg"
|
||||||
|
"#;
|
||||||
|
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert!(cfg.enabled);
|
||||||
|
assert_eq!(cfg.url, "ldaps://ldap.corp.com:636");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.bind_dn_template.as_deref(),
|
||||||
|
Some("uid={login},ou=people,dc=corp,dc=com")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.user_search_base.as_deref(),
|
||||||
|
Some("ou=people,dc=corp,dc=com")
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.user_filter, "(sAMAccountName={login})");
|
||||||
|
assert_eq!(cfg.search_bind_dn.as_deref(), Some("cn=svc,dc=corp,dc=com"));
|
||||||
|
assert_eq!(cfg.search_bind_password.as_deref(), Some("secret"));
|
||||||
|
assert_eq!(cfg.login_attr, "sAMAccountName");
|
||||||
|
assert_eq!(cfg.email_attr, "userPrincipalName");
|
||||||
|
assert_eq!(cfg.display_name_attr, "displayName");
|
||||||
|
assert_eq!(cfg.group_attr, "memberOf");
|
||||||
|
assert!(cfg.starttls);
|
||||||
|
assert!(cfg.danger_skip_tls_verify);
|
||||||
|
assert_eq!(cfg.provider_name, "corpldap");
|
||||||
|
assert_eq!(cfg.provider_label.as_deref(), Some("Corporate Directory"));
|
||||||
|
assert_eq!(
|
||||||
|
cfg.provider_icon_url.as_deref(),
|
||||||
|
Some("https://corp.com/icon.svg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_security_config_ldap_none_by_default() {
|
||||||
|
let yaml = r#"jwt_secret: "s""#;
|
||||||
|
let cfg: SecurityConfig = serde_yaml_ng::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert!(cfg.ldap.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_login_page_show_ldap_default_true() {
|
||||||
|
let cfg: LoginPageConfig = serde_yaml_ng::from_str("{}").unwrap();
|
||||||
|
|
||||||
|
assert!(cfg.show_ldap_login);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_login_page_show_ldap_explicit_false() {
|
||||||
|
let cfg: LoginPageConfig = serde_yaml_ng::from_str("show_ldap_login: false").unwrap();
|
||||||
|
|
||||||
|
assert!(!cfg.show_ldap_login);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,27 @@ impl IdentityRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_ldap_dn<'e, E>(
|
||||||
|
executor: E,
|
||||||
|
server_url: &str,
|
||||||
|
dn: &str,
|
||||||
|
) -> Result<Option<Identity>>
|
||||||
|
where
|
||||||
|
E: Executor<'e, Database = Postgres> + 'e,
|
||||||
|
{
|
||||||
|
sqlx::query_as::<_, Identity>(
|
||||||
|
"SELECT id, login, display_name, password_hash, attributes, created, updated
|
||||||
|
FROM identity
|
||||||
|
WHERE attributes->'ldap'->>'server_url' = $1
|
||||||
|
AND attributes->'ldap'->>'dn' = $2",
|
||||||
|
)
|
||||||
|
.bind(server_url)
|
||||||
|
.bind(dn)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission Set Repository
|
// Permission Set Repository
|
||||||
|
|||||||
@@ -479,3 +479,173 @@ async fn test_identity_login_case_sensitive() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(found_upper.id, identity2.id);
|
assert_eq!(found_upper.id, identity2.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LDAP-specific tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "integration test — requires database"]
|
||||||
|
async fn test_find_by_ldap_dn_found() {
|
||||||
|
let pool = create_test_pool().await.unwrap();
|
||||||
|
|
||||||
|
let login = unique_pack_ref("ldap_found");
|
||||||
|
let server_url = "ldap://ldap.example.com";
|
||||||
|
let dn = "uid=jdoe,ou=users,dc=example,dc=com";
|
||||||
|
|
||||||
|
let input = CreateIdentityInput {
|
||||||
|
login: login.clone(),
|
||||||
|
display_name: Some("LDAP User".to_string()),
|
||||||
|
attributes: json!({
|
||||||
|
"ldap": {
|
||||||
|
"server_url": server_url,
|
||||||
|
"dn": dn,
|
||||||
|
"login": "jdoe",
|
||||||
|
"email": "jdoe@example.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = IdentityRepository::create(&pool, input).await.unwrap();
|
||||||
|
|
||||||
|
let found = IdentityRepository::find_by_ldap_dn(&pool, server_url, dn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("LDAP identity not found");
|
||||||
|
|
||||||
|
assert_eq!(found.id, created.id);
|
||||||
|
assert_eq!(found.login, login);
|
||||||
|
assert_eq!(found.attributes["ldap"]["server_url"], server_url);
|
||||||
|
assert_eq!(found.attributes["ldap"]["dn"], dn);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "integration test — requires database"]
|
||||||
|
async fn test_find_by_ldap_dn_not_found() {
|
||||||
|
let pool = create_test_pool().await.unwrap();
|
||||||
|
|
||||||
|
let found = IdentityRepository::find_by_ldap_dn(
|
||||||
|
&pool,
|
||||||
|
"ldap://nonexistent.example.com",
|
||||||
|
"uid=nobody,ou=users,dc=example,dc=com",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "integration test — requires database"]
|
||||||
|
async fn test_find_by_ldap_dn_wrong_server() {
|
||||||
|
let pool = create_test_pool().await.unwrap();
|
||||||
|
|
||||||
|
let dn = "uid=jdoe,ou=users,dc=example,dc=com";
|
||||||
|
|
||||||
|
let input = CreateIdentityInput {
|
||||||
|
login: unique_pack_ref("ldap_wrong_srv"),
|
||||||
|
display_name: Some("Server A User".to_string()),
|
||||||
|
attributes: json!({
|
||||||
|
"ldap": {
|
||||||
|
"server_url": "ldap://server-a.example.com",
|
||||||
|
"dn": dn,
|
||||||
|
"login": "jdoe"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
IdentityRepository::create(&pool, input).await.unwrap();
|
||||||
|
|
||||||
|
// Search with same DN but different server — composite key must match both
|
||||||
|
let found = IdentityRepository::find_by_ldap_dn(&pool, "ldap://server-b.example.com", dn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "integration test — requires database"]
|
||||||
|
async fn test_find_by_ldap_dn_multiple_identities_different_servers() {
|
||||||
|
let pool = create_test_pool().await.unwrap();
|
||||||
|
|
||||||
|
let dn = "uid=shared,ou=users,dc=example,dc=com";
|
||||||
|
let server_a = "ldap://multi-a.example.com";
|
||||||
|
let server_b = "ldap://multi-b.example.com";
|
||||||
|
|
||||||
|
let input_a = CreateIdentityInput {
|
||||||
|
login: unique_pack_ref("ldap_multi_a"),
|
||||||
|
display_name: Some("User on Server A".to_string()),
|
||||||
|
attributes: json!({
|
||||||
|
"ldap": {
|
||||||
|
"server_url": server_a,
|
||||||
|
"dn": dn,
|
||||||
|
"login": "shared_a"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let identity_a = IdentityRepository::create(&pool, input_a).await.unwrap();
|
||||||
|
|
||||||
|
let input_b = CreateIdentityInput {
|
||||||
|
login: unique_pack_ref("ldap_multi_b"),
|
||||||
|
display_name: Some("User on Server B".to_string()),
|
||||||
|
attributes: json!({
|
||||||
|
"ldap": {
|
||||||
|
"server_url": server_b,
|
||||||
|
"dn": dn,
|
||||||
|
"login": "shared_b"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
let identity_b = IdentityRepository::create(&pool, input_b).await.unwrap();
|
||||||
|
|
||||||
|
// Query server A — should return identity_a
|
||||||
|
let found_a = IdentityRepository::find_by_ldap_dn(&pool, server_a, dn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("Identity for server A not found");
|
||||||
|
assert_eq!(found_a.id, identity_a.id);
|
||||||
|
assert_eq!(found_a.attributes["ldap"]["server_url"], server_a);
|
||||||
|
|
||||||
|
// Query server B — should return identity_b
|
||||||
|
let found_b = IdentityRepository::find_by_ldap_dn(&pool, server_b, dn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("Identity for server B not found");
|
||||||
|
assert_eq!(found_b.id, identity_b.id);
|
||||||
|
assert_eq!(found_b.attributes["ldap"]["server_url"], server_b);
|
||||||
|
|
||||||
|
// Confirm they are distinct identities
|
||||||
|
assert_ne!(found_a.id, found_b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "integration test — requires database"]
|
||||||
|
async fn test_find_by_ldap_dn_ignores_oidc_attributes() {
|
||||||
|
let pool = create_test_pool().await.unwrap();
|
||||||
|
|
||||||
|
// Create an identity with OIDC attributes (no "ldap" key)
|
||||||
|
let input = CreateIdentityInput {
|
||||||
|
login: unique_pack_ref("ldap_oidc"),
|
||||||
|
display_name: Some("OIDC User".to_string()),
|
||||||
|
attributes: json!({
|
||||||
|
"oidc": {
|
||||||
|
"issuer": "https://auth.example.com",
|
||||||
|
"subject": "abc123",
|
||||||
|
"email": "oidc@example.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
IdentityRepository::create(&pool, input).await.unwrap();
|
||||||
|
|
||||||
|
// Searching by LDAP DN should not match OIDC-only identities
|
||||||
|
let found = IdentityRepository::find_by_ldap_dn(&pool, "https://auth.example.com", "abc123")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ The Helm chart is pushed as an OCI chart to:
|
|||||||
|
|
||||||
## Required Gitea Repository Configuration
|
## Required Gitea Repository Configuration
|
||||||
|
|
||||||
Set these repository variables:
|
Set these variables:
|
||||||
|
|
||||||
- `CONTAINER_REGISTRY_HOST`: Registry hostname only, for example `gitea.example.com`
|
- `CLUSTER_GITEA_HOST`: Registry hostname only, for example `gitea.example.com`
|
||||||
- `CONTAINER_REGISTRY_NAMESPACE`: Optional override for the registry namespace. If omitted, the workflow uses the repository owner.
|
- `CONTAINER_REGISTRY_NAMESPACE`: Optional override for the registry namespace. If omitted, the workflow uses the repository owner.
|
||||||
|
- `CONTAINER_REGISTRY_INSECURE`: Optional boolean toggle for plain HTTP registries. Set to `true` for cluster-internal registries such as `gitea-http.gitea.svc.cluster.local`.
|
||||||
|
|
||||||
Set one of these authentication options:
|
Set one of these authentication options:
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ Log in to the registry:
|
|||||||
helm registry login gitea.example.com --username <user>
|
helm registry login gitea.example.com --username <user>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a plain HTTP internal registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm registry login gitea-http.gitea.svc.cluster.local --username <user> --plain-http
|
||||||
|
```
|
||||||
|
|
||||||
Install the chart:
|
Install the chart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ interface AuthSettingsResponse {
|
|||||||
oidc_provider_name: string | null;
|
oidc_provider_name: string | null;
|
||||||
oidc_provider_label: string | null;
|
oidc_provider_label: string | null;
|
||||||
oidc_provider_icon_url: string | null;
|
oidc_provider_icon_url: string | null;
|
||||||
|
ldap_enabled: boolean;
|
||||||
|
ldap_visible_by_default: boolean;
|
||||||
|
ldap_provider_name: string | null;
|
||||||
|
ldap_provider_label: string | null;
|
||||||
|
ldap_provider_icon_url: string | null;
|
||||||
self_registration_enabled: boolean;
|
self_registration_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +38,12 @@ export default function LoginPage() {
|
|||||||
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
|
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [credentials, setCredentials] = useState({ login: "", password: "" });
|
const [credentials, setCredentials] = useState({ login: "", password: "" });
|
||||||
|
const [ldapCredentials, setLdapCredentials] = useState({
|
||||||
|
login: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [ldapError, setLdapError] = useState<string | null>(null);
|
||||||
|
const [isLdapSubmitting, setIsLdapSubmitting] = useState(false);
|
||||||
|
|
||||||
const redirectPath = sessionStorage.getItem("redirect_after_login");
|
const redirectPath = sessionStorage.getItem("redirect_after_login");
|
||||||
const from =
|
const from =
|
||||||
@@ -67,19 +78,36 @@ export default function LoginPage() {
|
|||||||
const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null;
|
const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null;
|
||||||
const providerLabel =
|
const providerLabel =
|
||||||
settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO";
|
settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO";
|
||||||
|
const ldapEnabled = settings?.ldap_enabled ?? false;
|
||||||
|
const ldapProviderName = settings?.ldap_provider_name?.toLowerCase() ?? null;
|
||||||
|
const ldapProviderLabel =
|
||||||
|
settings?.ldap_provider_label ?? settings?.ldap_provider_name ?? "LDAP";
|
||||||
|
|
||||||
let showLocal = settings?.local_password_visible_by_default ?? false;
|
let showLocal = settings?.local_password_visible_by_default ?? false;
|
||||||
let showOidc = settings?.oidc_visible_by_default ?? false;
|
let showOidc = settings?.oidc_visible_by_default ?? false;
|
||||||
|
let showLdap = settings?.ldap_visible_by_default ?? false;
|
||||||
|
|
||||||
if (authOverride === "direct") {
|
if (authOverride === "direct") {
|
||||||
if (localEnabled) {
|
if (localEnabled) {
|
||||||
showLocal = true;
|
showLocal = true;
|
||||||
showOidc = false;
|
showOidc = false;
|
||||||
|
showLdap = false;
|
||||||
}
|
}
|
||||||
} else if (authOverride && providerName && authOverride === providerName) {
|
} else if (authOverride && providerName && authOverride === providerName) {
|
||||||
if (oidcEnabled) {
|
if (oidcEnabled) {
|
||||||
showLocal = false;
|
showLocal = false;
|
||||||
showOidc = true;
|
showOidc = true;
|
||||||
|
showLdap = false;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
authOverride &&
|
||||||
|
ldapProviderName &&
|
||||||
|
authOverride === ldapProviderName
|
||||||
|
) {
|
||||||
|
if (ldapEnabled) {
|
||||||
|
showLocal = false;
|
||||||
|
showOidc = false;
|
||||||
|
showLdap = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +135,29 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ldapProviderName && authOverride === ldapProviderName) {
|
||||||
|
setOverrideError(
|
||||||
|
ldapEnabled
|
||||||
|
? null
|
||||||
|
: `${ldapProviderLabel} was requested, but it is not available on this server.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setOverrideError(
|
setOverrideError(
|
||||||
`Unknown authentication override '${authOverride}'. Falling back to the server defaults.`,
|
`Unknown authentication override '${authOverride}'. Falling back to the server defaults.`,
|
||||||
);
|
);
|
||||||
}, [authOverride, localEnabled, oidcEnabled, providerLabel, providerName, settings]);
|
}, [
|
||||||
|
authOverride,
|
||||||
|
localEnabled,
|
||||||
|
oidcEnabled,
|
||||||
|
providerLabel,
|
||||||
|
providerName,
|
||||||
|
ldapEnabled,
|
||||||
|
ldapProviderLabel,
|
||||||
|
ldapProviderName,
|
||||||
|
settings,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleOidcLogin = () => {
|
const handleOidcLogin = () => {
|
||||||
sessionStorage.setItem("redirect_after_login", from);
|
sessionStorage.setItem("redirect_after_login", from);
|
||||||
@@ -143,6 +190,37 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLdapLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLdapError(null);
|
||||||
|
setIsLdapSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
data: { access_token: string; refresh_token: string };
|
||||||
|
}>("/auth/ldap/login", ldapCredentials);
|
||||||
|
await completeLogin({
|
||||||
|
accessToken: response.data.data.access_token,
|
||||||
|
refreshToken: response.data.data.refresh_token,
|
||||||
|
});
|
||||||
|
sessionStorage.removeItem("redirect_after_login");
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "response" in error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { message?: string } };
|
||||||
|
};
|
||||||
|
setLdapError(
|
||||||
|
axiosError.response?.data?.message ?? "LDAP authentication failed.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setLdapError("LDAP authentication failed.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLdapSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
@@ -272,12 +350,93 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!settingsError && authEnabled && !showLocal && !showOidc ? (
|
{authEnabled && (showLocal || showOidc) && showLdap ? (
|
||||||
|
<div className="my-6 flex items-center gap-3 text-xs uppercase tracking-[0.24em] text-gray-400">
|
||||||
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
|
or
|
||||||
|
<div className="h-px flex-1 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{authEnabled && showLdap ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-gray-600">
|
||||||
|
Sign in with {ldapProviderLabel}.
|
||||||
|
</p>
|
||||||
|
<form className="space-y-4" onSubmit={handleLdapLogin}>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="ldap-login"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{ldapProviderLabel} Login
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ldap-login"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
value={ldapCredentials.login}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLdapCredentials((current) => ({
|
||||||
|
...current,
|
||||||
|
login: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="ldap-password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ldap-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={ldapCredentials.password}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLdapCredentials((current) => ({
|
||||||
|
...current,
|
||||||
|
password: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ldapError ? (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{ldapError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLdapSubmitting}
|
||||||
|
className="w-full rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-600 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isLdapSubmitting
|
||||||
|
? "Signing in..."
|
||||||
|
: `Sign in with ${ldapProviderLabel}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!settingsError &&
|
||||||
|
authEnabled &&
|
||||||
|
!showLocal &&
|
||||||
|
!showOidc &&
|
||||||
|
!showLdap ? (
|
||||||
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
No login method is shown by default for this server. Use
|
No login method is shown by default for this server. Use
|
||||||
`?auth=direct`
|
`?auth=direct`
|
||||||
{providerName ? ` or ?auth=${providerName}` : ""} to choose
|
{providerName ? ` or ?auth=${providerName}` : ""}
|
||||||
a specific method.
|
{ldapProviderName ? ` or ?auth=${ldapProviderName}` : ""} to
|
||||||
|
choose a specific method.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
63
work-summary/2026-03-19-ldap-authentication.md
Normal file
63
work-summary/2026-03-19-ldap-authentication.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# LDAP Authentication Support
|
||||||
|
|
||||||
|
**Date**: 2026-03-19
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Added LDAP as an authentication provider alongside the existing OIDC and local username/password login methods. LDAP authentication follows the same architectural patterns as OIDC — server-side credential verification, identity upsert with provider-specific claims stored in the `attributes` JSONB column, and JWT token issuance.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
#### New Files
|
||||||
|
- **`crates/api/src/auth/ldap.rs`** — LDAP authentication module using the `ldap3` crate (v0.12). Supports two authentication modes:
|
||||||
|
- **Direct bind**: Constructs a DN from a configurable `bind_dn_template` (e.g., `uid={login},ou=users,dc=example,dc=com`) and binds directly as the user.
|
||||||
|
- **Search-and-bind**: Binds as a service account (or anonymous), searches for the user entry using `user_search_base` + `user_filter`, then re-binds as the discovered DN with the user's password.
|
||||||
|
- After successful authentication, fetches user attributes (login, email, display name, groups) and upserts an identity row with claims stored under `attributes.ldap`.
|
||||||
|
|
||||||
|
#### Modified Files
|
||||||
|
- **`crates/common/src/config.rs`**:
|
||||||
|
- Added `LdapConfig` struct with fields for server URL, bind DN template, search base/filter, service account credentials, attribute mapping, TLS settings, and UI metadata (provider name/label/icon).
|
||||||
|
- Added `ldap: Option<LdapConfig>` to `SecurityConfig`.
|
||||||
|
- Added `show_ldap_login: bool` to `LoginPageConfig`.
|
||||||
|
|
||||||
|
- **`crates/common/src/repositories/identity.rs`**:
|
||||||
|
- Added `find_by_ldap_dn()` method to `IdentityRepository`, querying `attributes->'ldap'->>'server_url'` and `attributes->'ldap'->>'dn'` (mirrors the existing `find_by_oidc_subject` pattern).
|
||||||
|
|
||||||
|
- **`crates/api/Cargo.toml`**:
|
||||||
|
- Added `ldap3 = "0.12"` dependency.
|
||||||
|
|
||||||
|
- **`crates/api/src/auth/mod.rs`**:
|
||||||
|
- Added `pub mod ldap;`.
|
||||||
|
|
||||||
|
- **`crates/api/src/routes/auth.rs`**:
|
||||||
|
- Added `POST /auth/ldap/login` route and `ldap_login` handler (validates `LdapLoginRequest`, delegates to `ldap::authenticate`, returns `TokenResponse`).
|
||||||
|
- Updated `auth_settings` handler to populate LDAP fields in the response.
|
||||||
|
|
||||||
|
- **`crates/api/src/dto/auth.rs`**:
|
||||||
|
- Added `ldap_enabled`, `ldap_visible_by_default`, `ldap_provider_name`, `ldap_provider_label`, `ldap_provider_icon_url` fields to `AuthSettingsResponse`.
|
||||||
|
|
||||||
|
### Frontend (React/TypeScript)
|
||||||
|
|
||||||
|
- **`web/src/pages/auth/LoginPage.tsx`**:
|
||||||
|
- Extended `AuthSettingsResponse` interface with LDAP fields.
|
||||||
|
- Added LDAP login form (username/password) with emerald-colored submit button, error handling, and `?auth=ldap` override support.
|
||||||
|
- Added divider between sections when multiple login methods are visible.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **`config.example.yaml`**: Added full LDAP configuration example with comments explaining direct-bind vs search-and-bind modes.
|
||||||
|
- **`config.development.yaml`**: Added disabled LDAP section with direct-bind template.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **`AGENTS.md`**: Updated Authentication & Security section to document both OIDC and LDAP providers, their config keys, routes, identity matching, and login page configuration.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- LDAP authentication is a **synchronous POST** flow (no browser redirects), unlike OIDC which uses authorization code redirects. The user submits credentials to `POST /auth/ldap/login` and receives JWT tokens directly.
|
||||||
|
- Identity deduplication uses `server_url + dn` as the composite key (stored in `attributes.ldap`), analogous to OIDC's `issuer + sub`.
|
||||||
|
- Login name collision avoidance uses the same SHA-256 fallback pattern as OIDC (`ldap:<24-hex-chars>`).
|
||||||
|
- The `ldap3` crate connection is driven asynchronously on the Tokio runtime via `ldap3::drive!(conn)`.
|
||||||
|
- STARTTLS and TLS certificate verification skip are configurable per-deployment.
|
||||||
Reference in New Issue
Block a user