1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
//! LDAP bind verification on [`AuthnService`].
use super::{AuthnService, outcomes::FactorOutcome};
use crate::authn::{
error::AuthnError,
event::{AuthEventBuilder, AuthEventType},
factor::{FactorConfig, FactorCredential, FactorKind},
ids::{TenantId, UserId},
store::{FactorStore, IdentityStore},
};
use crate::session::extractor::AuthSession;
impl<I, F> AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
/// Verify a password credential via LDAP simple bind.
///
/// Called from `verify_factor` when the current factor is `LdapBind`.
/// The user submits a `FactorCredential::Password`: same input as local
/// password auth, but verified against the directory instead of a stored hash.
#[tracing::instrument(skip(self, credential, config, session))]
pub(crate) async fn verify_ldap_factor(
&self,
credential: &FactorCredential,
config: &FactorConfig,
user_id: &UserId,
tenant_id: &TenantId,
session: &AuthSession,
) -> Result<FactorOutcome, AuthnError<I::Error>> {
let ldap = match &self.ldap {
Some(l) => l,
None => {
tracing::error!("LdapBind factor configured but no LdapProvider attached");
return Ok(FactorOutcome::InvalidCredential);
}
};
// Extract the password from the credential.
let password: &str = match credential {
FactorCredential::Password(p) => p.as_ref(),
_ => return Ok(FactorOutcome::InvalidCredential),
};
// Reject oversized passwords before the network call.
if password.len() > crate::validation::MAX_PASSWORD_BYTES {
return Ok(FactorOutcome::InvalidCredential);
}
// Look up the user's identifier for the bind DN template and group filter.
let user = self
.identity
.get_user(user_id)
.await
.map_err(AuthnError::Store)?
.ok_or(AuthnError::NoFlow)?;
// Determine the bind DN: per-user override or provider template.
let bind_dn = match config {
FactorConfig::LdapBind(cfg) => {
if let Some(ref dn) = cfg.bind_dn {
// Validate the override has RFC 4514 DN structure: at least one
// RDN component (`key=value`), reasonable length, and only DN-safe
// characters (letters, digits, common DN punctuation). Rejects
// wildcards, parentheses, semicolons, and other chars that could
// alter LDAP behavior.
if dn.is_empty()
|| dn.len() > 1024
|| !dn.bytes().all(|b| {
matches!(b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
| b' ' | b'.' | b'-' | b'_' | b'@'
| b'=' | b',' | b'+' | b'\\' | b'#'
| b'"' | b':'
)
})
// Structural check: must contain at least one `=` (key=value).
|| !dn.contains('=')
{
tracing::warn!(user_id = %user_id, "invalid LDAP bind_dn override rejected");
return Ok(FactorOutcome::InvalidCredential);
}
dn.clone()
} else {
ldap.build_bind_dn(&user.identifier)
}
}
_ => return Ok(FactorOutcome::InvalidCredential),
};
self.metrics.factor_attempt();
// Attempt the LDAP bind. Pass the clean identifier for group search.
match ldap.verify_bind(&user.identifier, &bind_dn, password).await {
Ok(_result) => {
self.metrics.factor_success();
// Reset failed attempts on successful bind.
session
.advance_factor(&FactorKind::LdapBind, self.clock.now())
.await;
self.emit_audit(
AuthEventBuilder::success(AuthEventType::FactorVerified)
.attributed_to(user_id, tenant_id)
.with_factor(FactorKind::LdapBind),
)
.await;
self.complete_factor_step(user_id, tenant_id, session).await
}
Err(axess_factors::ldap::LdapError::InvalidCredentials) => {
// Route through the canonical failure helper so LDAP
// matches the audit ordering (audit BEFORE counter increment)
// and store-error muting. Earlier this arm hand-rolled
// the same logic with the audit and counter calls swapped.
self.record_factor_failure(user_id, tenant_id, &FactorKind::LdapBind, session)
.await
}
Err(e) => {
// Log the full error server-side for diagnostics; return a
// generic message to callers to avoid leaking LDAP server
// hostnames, connection details, or schema information.
tracing::error!(error = %e, bind_dn = %bind_dn, "LDAP bind failed (non-credential error)");
self.metrics.factor_failure();
Err(AuthnError::ExternalService(
"external authentication unavailable".to_string(),
))
}
}
}
}