Skip to content

Commit 1904eeb

Browse files
committed
Implement returning LDAP attribs to Nginx. Bump version 0.2.0
1 parent 2f3f08e commit 1904eeb

File tree

9 files changed

+125
-39
lines changed

9 files changed

+125
-39
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ldap_authz_proxy"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55

66
description = "LDAP authorization proxy for authenticated HTTP users"

README.md

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ A helper that allows Nginx to lookup from Active Directory / LDAP
77
if a user is authorized to access some resource, _after_ said user
88
has been authenticated by some other means (Kerberos, Basic auth, Token, ...).
99

10+
Optionally, it can also return user attributes (such as name, email, etc) to Nginx
11+
in HTTP headers.
12+
1013
Technically it's a small HTTP server that reads usernames from request headers
1114
and performs configured LDAP queries with them, returning status 200 if query
12-
succeeded or 403 if it failed; an HTTP->LDAP proxy of sorts.
15+
succeeded or 403 if it failed; an HTTP<>LDAP proxy of sorts.
1316
Nginx can auth against such a thing with the ´auth_request`
1417
directive. Results are cached for a configurable amount of time.
1518

@@ -19,15 +22,15 @@ The server is configured with an INI file, such as:
1922

2023
```ini
2124
[default]
22-
ldap_server_url = ldap://dc1.example.test
25+
ldap_server_url = ldap://dc1.example.test:389
2326
ldap_conn_timeout = 10.0
2427
ldap_bind_dn = CN=service,CN=Users,DC=example,DC=test
2528
ldap_bind_password = password123
2629
ldap_search_base = DC=example,DC=test
2730

28-
ldap_cache_size = 1024
31+
ldap_return_attribs = displayName, givenName, sn, mail
2932
ldap_cache_time = 30
30-
33+
ldap_cache_size = 512
3134
username_http_header = X-Ldap-Authz-Username
3235

3336
[users]
@@ -46,6 +49,27 @@ that is tested against HTTP requests. If it matches, `ldap_query` from that sect
4649
is executed after replacing `%USERNAME%` with the username from `username_http_header` HTTP header.
4750
If the LDAP query succeeds, server returns status 200, otherwise 403.
4851

52+
The `ldap_return_attribs`, if not empty, specifies a comma-separated list of LDAP
53+
attributes to return to Nginx in HTTP headers. The header names are prefixed with
54+
`X-Ldap-Authz-Res-`, so for example `displayName` attribute is returned in
55+
`X-Ldap-Authz-Res-displayName` header. Use `ldap_return_attribs = *` to return all
56+
attributes (mainly useful for debugging).
57+
58+
If LDAP query returns multiple results, the first one is used. To see all results,
59+
use `--debug` option to write them to log.
60+
61+
## Cache
62+
63+
The server uses a simple in-memory cache to avoid performing the same LDAP queries
64+
over and over again. Cache size is limited to `ldap_cache_size` entries, and
65+
entries are removed in LRU order. Cache time is `ldap_cache_time` seconds.
66+
One cache entry is created for each unique username, so ldap_cache_size should
67+
be large enough to accommodate all users that might be accessing the server simultaneously.
68+
A cache entry takes probably about 1kB of RAM, unless you requested all LDAP attributes.
69+
70+
Technically, each config section gets its own cache, so you can have different cache sizes and
71+
retention times for different sections.
72+
4973
## Building
5074

5175
The server is written in Rust and can be built with `cargo build --release`.
@@ -107,10 +131,11 @@ This is the recommended way to install it when applicable.
107131

108132
Use `./run-tests.sh` to execute test suite. It requires `docker compose`
109133
and `curl`. The script performs an end-to-end integratiot test with a
110-
real LDAP server (Active Directory in this case, using Samba) and an
111-
Nginx reverse proxy. It spins up necessary containers, and then performs
112-
Curl HTTP requests against Nginx, comparing their HTTP response status codes to
113-
expected values.
134+
real Active Directory server and an Nginx reverse proxy.
135+
136+
It spins up necessary containers, sets up example users, and then performs
137+
Curl HTTP requests against Nginx, comparing their HTTP response status codes
138+
and headers to expected values.
114139

115140
## Nginx configuration
116141

@@ -120,8 +145,8 @@ with the Basic method and then authorized with this server using _auth_request_
120145
### Kerberos
121146

122147
This software was originally developed for Active Directory auth using
123-
Nginx, so here's a complementary real-world example on how to authenticate users against AD with
124-
Kerberos (spnego-http-auth-nginx-module) and to then authorize them using
148+
Nginx, so here's a complementary example on how to authenticate some API users
149+
against AD with Kerberos (spnego-http-auth-nginx-module) and to then authorize them using
125150
_ldap_authz_proxy_:
126151

127152
```nginx
@@ -140,7 +165,8 @@ server {
140165
auth_gss_force_realm on;
141166
auth_gss_service_name HTTP/www.example.com;
142167
143-
auth_request /authz_all;
168+
auth_request /authz_all;
169+
auth_request_set $display_name $upstream_http_x_ldap_res_displayname;
144170
145171
location = /authz_all {
146172
internal;
@@ -150,10 +176,15 @@ server {
150176
proxy_set_header X-Ldap-Authz-Username $remote_user;
151177
}
152178
153-
location / {
154-
root /var/www/;
155-
index index.html;
156-
try_files $uri $uri/ =404;
179+
location /api {
180+
proxy_pass http://127.0.0.1:8095/api;
181+
182+
# Pass authenticated username to backend
183+
proxy_set_header X-Remote-User-Id $remote_user;
184+
proxy_set_header X-Remote-User-Name $display_name;
185+
186+
proxy_set_header X-Real-IP $remote_addr;
187+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
157188
}
158189
}
159190
```

debian/changelog

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
ldap_authz_proxy (0.2-1) unstable; urgency=low
2+
3+
* New feature: return LDAP attributes in response headers
4+
5+
-- Jarno Elonen <[email protected]> Sat, 28 Jan 2023 23:08:00 +0000
6+
17
ldap_authz_proxy (0.1-1) unstable; urgency=low
28

39
* First release

example.ini

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[default]
2-
ldap_server_url = ldap://dc1.example.test
2+
ldap_server_url = ldap://dc1.example.test:389
33
ldap_conn_timeout = 10.0
44
ldap_bind_dn = CN=service,CN=Users,DC=example,DC=test
55
ldap_bind_password = password123
66
ldap_search_base = DC=example,DC=test
77

8-
ldap_cache_size = 1024
8+
ldap_return_attribs = displayName, givenName, sn, mail
99
ldap_cache_time = 30
10-
10+
ldap_cache_size = 512
1111
username_http_header = X-Ldap-Authz-Username
1212

1313
[users]

run-tests.sh

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ echo "---------------------------------------------"
4747
function request() {
4848
FOLDER="$1"
4949
CREDS="$2"
50-
RES=$(curl --write-out '%{http_code}' --silent --output /dev/null http://127.0.0.1:8090/$FOLDER/ -u "$CREDS")
51-
echo "$RES"
50+
RES=$(curl -s http://127.0.0.1:8090/$FOLDER/ -u "$CREDS" -I)
51+
HTTP_CODE=$(grep HTTP <<< """$RES""" | awk '{print $2}' | tr -d '\r\n')
52+
DISPLAY_NAME=$(grep 'X-Display-Name' <<< """$RES""" | sed 's/^.*: //' | tr -d '\r\n') || true
53+
echo "${HTTP_CODE}${DISPLAY_NAME}"
5254
}
5355

5456
function test() {
@@ -64,12 +66,12 @@ function test() {
6466
}
6567

6668
function do_tests() {
67-
test "user-page" "alice:alice" "200"
69+
test "user-page" "alice:alice" "200Alice Alison"
6870
test "admin-page" "alice:alice" "200"
6971
test "user-page" "alice:BADPASSWORD" "401"
7072
test "admin-page" "alice:BADPASSWORD" "401"
7173

72-
test "user-page" "bob:bob" "200"
74+
test "user-page" "bob:bob" "200Bob Bobrikov"
7375
test "admin-page" "bob:bob" "403"
7476
test "user-page" "bob:BADPASSWORD" "401"
7577
test "admin-page" "bob:BADPASSWORD" "401"

src/config.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(crate) struct ConfigSection {
1616
pub(crate) ldap_bind_password: String,
1717
pub(crate) ldap_search_base: String,
1818
pub(crate) ldap_query: String,
19+
pub(crate) ldap_return_attribs: Vec<String>,
1920

2021
pub(crate) ldap_cache_size: usize,
2122
pub(crate) ldap_cache_time: u32,
@@ -48,7 +49,7 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro
4849
for section_name in config.sections() {
4950
let mut sect_map = map.get(section_name.as_str()).unwrap().clone();
5051

51-
const VALID_KEYS: [&str; 10] = ["ldap_server_url", "ldap_conn_timeout", "ldap_bind_dn", "ldap_bind_password", "ldap_search_base", "ldap_query", "ldap_cache_time", "ldap_cache_size", "username_http_header", "http_path"];
52+
const VALID_KEYS: [&str; 11] = ["ldap_server_url", "ldap_conn_timeout", "ldap_bind_dn", "ldap_bind_password", "ldap_search_base", "ldap_query", "ldap_return_attribs", "ldap_cache_time", "ldap_cache_size", "username_http_header", "http_path"];
5253
for (key, _) in sect_map.iter() {
5354
if !VALID_KEYS.contains(&key.as_str()) {
5455
bail!("Invalid key '{}' in section '{}'. Valid ones are: {}", key, section_name, VALID_KEYS.join(", "));
@@ -82,6 +83,7 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, Erro
8283
ldap_bind_password: sect_map.get("ldap_bind_password").ok_or(err_fn("ldap_bind_password"))?.as_ref().unwrap().clone(),
8384
ldap_search_base: sect_map.get("ldap_search_base").ok_or(err_fn("ldap_search_base"))?.as_ref().unwrap().clone(),
8485
ldap_query: sect_map.get("ldap_query").ok_or(err_fn("ldap_query"))?.as_ref().unwrap().clone(),
86+
ldap_return_attribs: sect_map.get("ldap_return_attribs").ok_or(err_fn("ldap_return_attribs"))?.as_ref().unwrap().clone().split(",").map(|s| s.trim().to_string()).collect(),
8587

8688
ldap_cache_size: sect_map.get("ldap_cache_size").ok_or(err_fn("ldap_cache_size"))?.as_ref().unwrap().clone().parse()?,
8789
ldap_cache_time: sect_map.get("ldap_cache_time").ok_or(err_fn("ldap_cache_time"))?.as_ref().unwrap().clone().parse()?,

src/main.rs

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use std::collections::HashMap;
22
use std::net::SocketAddr;
3+
use std::str::FromStr;
34
use std::sync::Arc;
45
use config::ConfigSection;
6+
use hyper::header::HeaderName;
7+
use hyper::http::HeaderValue;
58
use hyper::service::service_fn;
69
use hyper::{Request, Response, Body, StatusCode};
710
use tokio::net::TcpListener;
@@ -38,15 +41,16 @@ Options:
3841
"#;
3942

4043
type Sha256Hash = sha2::digest::generic_array::GenericArray<u8, sha2::digest::generic_array::typenum::U32>;
41-
type LdapCache = LruCache<Sha256Hash, bool>; // <cache_key, found>
44+
type LdapSearchRes = Option<HashMap<String, String>>; // HashMap<attr_name, attr_value> or None if not found
45+
type LdapCache = LruCache<Sha256Hash, LdapSearchRes>;
4246

4347
struct ReqContext {
4448
config: Vec<ConfigSection>,
4549
cache: HashMap<String, Arc<Mutex<LdapCache>>>, // <section_name, cache>
4650
}
4751

4852
struct LdapAnswer {
49-
found: bool,
53+
ldap_res: LdapSearchRes,
5054
cached: bool,
5155
}
5256

@@ -64,9 +68,9 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
6468
let mut hasher = Sha256::new();
6569
hasher.update(format!("{}:{}", conf.ldap_server_url, query));
6670
let cache_key = hasher.finalize();
67-
if let Some(found) = cache.lock().await.get(&cache_key) {
71+
if let Some(res) = cache.lock().await.get(&cache_key) {
6872
tracing::debug!("Cache hit. Skipping LDAP.");
69-
return Ok(LdapAnswer { found: *found, cached: true });
73+
return Ok(LdapAnswer { ldap_res: res.clone(), cached: true });
7074
} else {
7175
tracing::debug!("Not cached. Performing real query.");
7276
}
@@ -85,7 +89,7 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
8589
conf.ldap_search_base.as_str(),
8690
Scope::Subtree,
8791
query.as_str(),
88-
vec!["l"]
92+
&conf.ldap_return_attribs
8993
).await?.success()
9094
{
9195
Ok(res) => res,
@@ -94,13 +98,28 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
9498
return Err(e)
9599
}
96100
};
97-
let found = !&rs.is_empty();
98-
for entry in rs {
99-
tracing::debug!("LDAP result: {:?}", SearchEntry::construct(entry));
100-
}
101101
ldap.unbind().await?;
102-
cache.lock().await.insert(cache_key, found);
103-
Ok(LdapAnswer { found, cached: false })
102+
103+
// Store first row in a HashMap and log all other rows
104+
let row_i = 0;
105+
let mut attribs = HashMap::new();
106+
for row in rs {
107+
let se = SearchEntry::construct(row);
108+
if row_i > 0 {
109+
tracing::debug!("Skipped additional result row #{}: {:?}", row_i, se);
110+
} else {
111+
tracing::debug!("First result row: {:?}", se);
112+
for (key, vals) in se.attrs {
113+
let vals_comb = vals.iter().map(|v| v.as_str()).collect::<Vec<&str>>().join(", ");
114+
attribs.insert(key, vals_comb);
115+
}
116+
}
117+
}
118+
119+
// Update cache and return
120+
let ldap_res = if attribs.is_empty() { None } else { Some(attribs) };
121+
cache.lock().await.insert(cache_key, ldap_res.clone());
122+
Ok(LdapAnswer { ldap_res, cached: false })
104123
}
105124

106125

@@ -156,9 +175,30 @@ async fn http_handler(req: Request<Body>, ctx: Arc<ReqContext>) -> Result<Respon
156175
},
157176
Ok(la) => {
158177
let span = span.record("cached", &la.cached);
159-
return if la.found {
178+
return if let Some(ldap_res) = la.ldap_res {
160179
span.in_scope(|| { tracing::info!("User authorized Ok"); });
161-
Ok(Response::new(Body::from("200 OK - LDAP result found")))
180+
let mut resp = Response::new(Body::from("200 OK - LDAP result found"));
181+
182+
// Store LDAP result attributes to response HTTP headers
183+
for (key, val) in ldap_res.iter() {
184+
let hname = match HeaderName::from_str(format!("X-LDAP-RES-{}", key).as_str()) {
185+
Ok(hname) => hname,
186+
Err(_) => {
187+
span.in_scope(|| { tracing::error!("Invalid LDAP result key: {}", key); });
188+
return Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Body::from("Invalid LDAP result key"))
189+
}
190+
};
191+
let hval = match HeaderValue::from_str(val.as_str()) {
192+
Ok(hval) => hval,
193+
Err(_) => {
194+
span.in_scope(|| { tracing::error!("Invalid LDAP result value: {}", val); });
195+
return Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Body::from("Invalid LDAP result value"))
196+
}
197+
};
198+
span.in_scope(|| { tracing::debug!("Adding result HTTP header: {:?} = {:?}", hname, hval); });
199+
resp.headers_mut().insert(hname, hval);
200+
}
201+
Ok(resp)
162202
} else {
163203
span.in_scope(|| { tracing::info!("User REJECTED"); });
164204
Response::builder().status(StatusCode::FORBIDDEN).body(Body::from(format!("403 Forbidden - Empty LDAP result for user '{}'", username)))

test/docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ services:
3535
samba-tool domain passwordsettings set --max-pwd-age=0
3636
# CN=service,CN=Users,DC=example,DC=test
3737
samba-tool user create service password123 --use-username-as-cn
38-
samba-tool user create alice password123 --use-username-as-cn
39-
samba-tool user create bob password123 --use-username-as-cn
38+
samba-tool user create alice password123 --use-username-as-cn --given-name=Alice --surname=Alison --mail-address="[email protected]"
39+
samba-tool user create bob password123 --use-username-as-cn --given-name=Bob --surname=Bobrikov --mail-address="[email protected]"
4040
# CN=ACL_Users,CN=Users,DC=example,DC=test
4141
samba-tool group add ACL_Users
4242
samba-tool group add ACL_Admins

test/nginx-site.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ server {
2626
auth_basic_user_file /var/www/html/.htpasswd;
2727

2828
auth_request /authz_users;
29+
auth_request_set $display_name $upstream_http_x_ldap_res_displayname;
30+
auth_request_set $email $upstream_http_x_ldap_res_mail;
2931

3032
alias /var/www/html;
3133
index index.nginx-debian.html;
34+
35+
add_header X-Display-Name $display_name;
36+
add_header X-Email $email;
3237
}
3338
location /admin-page {
3439
satisfy all;

0 commit comments

Comments
 (0)