From 1904eeb1816807ba1e971c5b951e35a8d3d64332 Mon Sep 17 00:00:00 2001 From: Jarno Elonen Date: Sun, 29 Jan 2023 22:18:52 +0200 Subject: [PATCH] Implement returning LDAP attribs to Nginx. Bump version 0.2.0 --- Cargo.toml | 2 +- README.md | 61 +++++++++++++++++++++++++++---------- debian/changelog | 6 ++++ example.ini | 6 ++-- run-tests.sh | 10 ++++--- src/config.rs | 4 ++- src/main.rs | 66 +++++++++++++++++++++++++++++++++-------- test/docker-compose.yml | 4 +-- test/nginx-site.conf | 5 ++++ 9 files changed, 125 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2a08762..6b8ab10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ldap_authz_proxy" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "LDAP authorization proxy for authenticated HTTP users" diff --git a/README.md b/README.md index d0804f0..89daf17 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ A helper that allows Nginx to lookup from Active Directory / LDAP if a user is authorized to access some resource, _after_ said user has been authenticated by some other means (Kerberos, Basic auth, Token, ...). +Optionally, it can also return user attributes (such as name, email, etc) to Nginx +in HTTP headers. + Technically it's a small HTTP server that reads usernames from request headers and performs configured LDAP queries with them, returning status 200 if query -succeeded or 403 if it failed; an HTTP->LDAP proxy of sorts. +succeeded or 403 if it failed; an HTTP<>LDAP proxy of sorts. Nginx can auth against such a thing with the ´auth_request` directive. Results are cached for a configurable amount of time. @@ -19,15 +22,15 @@ The server is configured with an INI file, such as: ```ini [default] -ldap_server_url = ldap://dc1.example.test +ldap_server_url = ldap://dc1.example.test:389 ldap_conn_timeout = 10.0 ldap_bind_dn = CN=service,CN=Users,DC=example,DC=test ldap_bind_password = password123 ldap_search_base = DC=example,DC=test -ldap_cache_size = 1024 +ldap_return_attribs = displayName, givenName, sn, mail ldap_cache_time = 30 - +ldap_cache_size = 512 username_http_header = X-Ldap-Authz-Username [users] @@ -46,6 +49,27 @@ that is tested against HTTP requests. If it matches, `ldap_query` from that sect is executed after replacing `%USERNAME%` with the username from `username_http_header` HTTP header. If the LDAP query succeeds, server returns status 200, otherwise 403. +The `ldap_return_attribs`, if not empty, specifies a comma-separated list of LDAP +attributes to return to Nginx in HTTP headers. The header names are prefixed with +`X-Ldap-Authz-Res-`, so for example `displayName` attribute is returned in +`X-Ldap-Authz-Res-displayName` header. Use `ldap_return_attribs = *` to return all +attributes (mainly useful for debugging). + +If LDAP query returns multiple results, the first one is used. To see all results, +use `--debug` option to write them to log. + +## Cache + +The server uses a simple in-memory cache to avoid performing the same LDAP queries +over and over again. Cache size is limited to `ldap_cache_size` entries, and +entries are removed in LRU order. Cache time is `ldap_cache_time` seconds. +One cache entry is created for each unique username, so ldap_cache_size should +be large enough to accommodate all users that might be accessing the server simultaneously. +A cache entry takes probably about 1kB of RAM, unless you requested all LDAP attributes. + +Technically, each config section gets its own cache, so you can have different cache sizes and +retention times for different sections. + ## Building 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. Use `./run-tests.sh` to execute test suite. It requires `docker compose` and `curl`. The script performs an end-to-end integratiot test with a -real LDAP server (Active Directory in this case, using Samba) and an -Nginx reverse proxy. It spins up necessary containers, and then performs -Curl HTTP requests against Nginx, comparing their HTTP response status codes to -expected values. +real Active Directory server and an Nginx reverse proxy. + +It spins up necessary containers, sets up example users, and then performs +Curl HTTP requests against Nginx, comparing their HTTP response status codes +and headers to expected values. ## Nginx configuration @@ -120,8 +145,8 @@ with the Basic method and then authorized with this server using _auth_request_ ### Kerberos This software was originally developed for Active Directory auth using -Nginx, so here's a complementary real-world example on how to authenticate users against AD with -Kerberos (spnego-http-auth-nginx-module) and to then authorize them using +Nginx, so here's a complementary example on how to authenticate some API users +against AD with Kerberos (spnego-http-auth-nginx-module) and to then authorize them using _ldap_authz_proxy_: ```nginx @@ -140,7 +165,8 @@ server { auth_gss_force_realm on; auth_gss_service_name HTTP/www.example.com; - auth_request /authz_all; + auth_request /authz_all; + auth_request_set $display_name $upstream_http_x_ldap_res_displayname; location = /authz_all { internal; @@ -150,10 +176,15 @@ server { proxy_set_header X-Ldap-Authz-Username $remote_user; } - location / { - root /var/www/; - index index.html; - try_files $uri $uri/ =404; + location /api { + proxy_pass http://127.0.0.1:8095/api; + + # Pass authenticated username to backend + proxy_set_header X-Remote-User-Id $remote_user; + proxy_set_header X-Remote-User-Name $display_name; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ``` diff --git a/debian/changelog b/debian/changelog index 9802b2b..811ed0f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +ldap_authz_proxy (0.2-1) unstable; urgency=low + + * New feature: return LDAP attributes in response headers + + -- Jarno Elonen Sat, 28 Jan 2023 23:08:00 +0000 + ldap_authz_proxy (0.1-1) unstable; urgency=low * First release diff --git a/example.ini b/example.ini index caa18cd..036499b 100644 --- a/example.ini +++ b/example.ini @@ -1,13 +1,13 @@ [default] -ldap_server_url = ldap://dc1.example.test +ldap_server_url = ldap://dc1.example.test:389 ldap_conn_timeout = 10.0 ldap_bind_dn = CN=service,CN=Users,DC=example,DC=test ldap_bind_password = password123 ldap_search_base = DC=example,DC=test -ldap_cache_size = 1024 +ldap_return_attribs = displayName, givenName, sn, mail ldap_cache_time = 30 - +ldap_cache_size = 512 username_http_header = X-Ldap-Authz-Username [users] diff --git a/run-tests.sh b/run-tests.sh index 96c60aa..41b4c3c 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -47,8 +47,10 @@ echo "---------------------------------------------" function request() { FOLDER="$1" CREDS="$2" - RES=$(curl --write-out '%{http_code}' --silent --output /dev/null http://127.0.0.1:8090/$FOLDER/ -u "$CREDS") - echo "$RES" + RES=$(curl -s http://127.0.0.1:8090/$FOLDER/ -u "$CREDS" -I) + HTTP_CODE=$(grep HTTP <<< """$RES""" | awk '{print $2}' | tr -d '\r\n') + DISPLAY_NAME=$(grep 'X-Display-Name' <<< """$RES""" | sed 's/^.*: //' | tr -d '\r\n') || true + echo "${HTTP_CODE}${DISPLAY_NAME}" } function test() { @@ -64,12 +66,12 @@ function test() { } function do_tests() { - test "user-page" "alice:alice" "200" + test "user-page" "alice:alice" "200Alice Alison" test "admin-page" "alice:alice" "200" test "user-page" "alice:BADPASSWORD" "401" test "admin-page" "alice:BADPASSWORD" "401" - test "user-page" "bob:bob" "200" + test "user-page" "bob:bob" "200Bob Bobrikov" test "admin-page" "bob:bob" "403" test "user-page" "bob:BADPASSWORD" "401" test "admin-page" "bob:BADPASSWORD" "401" diff --git a/src/config.rs b/src/config.rs index f9bb2d4..ad077a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,7 @@ pub(crate) struct ConfigSection { pub(crate) ldap_bind_password: String, pub(crate) ldap_search_base: String, pub(crate) ldap_query: String, + pub(crate) ldap_return_attribs: Vec, pub(crate) ldap_cache_size: usize, pub(crate) ldap_cache_time: u32, @@ -48,7 +49,7 @@ pub(crate) fn parse_config(config_file: &str) -> Result, Erro for section_name in config.sections() { let mut sect_map = map.get(section_name.as_str()).unwrap().clone(); - 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"]; + 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"]; for (key, _) in sect_map.iter() { if !VALID_KEYS.contains(&key.as_str()) { 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, Erro ldap_bind_password: sect_map.get("ldap_bind_password").ok_or(err_fn("ldap_bind_password"))?.as_ref().unwrap().clone(), ldap_search_base: sect_map.get("ldap_search_base").ok_or(err_fn("ldap_search_base"))?.as_ref().unwrap().clone(), ldap_query: sect_map.get("ldap_query").ok_or(err_fn("ldap_query"))?.as_ref().unwrap().clone(), + 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(), ldap_cache_size: sect_map.get("ldap_cache_size").ok_or(err_fn("ldap_cache_size"))?.as_ref().unwrap().clone().parse()?, ldap_cache_time: sect_map.get("ldap_cache_time").ok_or(err_fn("ldap_cache_time"))?.as_ref().unwrap().clone().parse()?, diff --git a/src/main.rs b/src/main.rs index 2e6d921..8da4b12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::str::FromStr; use std::sync::Arc; use config::ConfigSection; +use hyper::header::HeaderName; +use hyper::http::HeaderValue; use hyper::service::service_fn; use hyper::{Request, Response, Body, StatusCode}; use tokio::net::TcpListener; @@ -38,7 +41,8 @@ Options: "#; type Sha256Hash = sha2::digest::generic_array::GenericArray; -type LdapCache = LruCache; // +type LdapSearchRes = Option>; // HashMap or None if not found +type LdapCache = LruCache; struct ReqContext { config: Vec, @@ -46,7 +50,7 @@ struct ReqContext { } struct LdapAnswer { - found: bool, + ldap_res: LdapSearchRes, cached: bool, } @@ -64,9 +68,9 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc res, @@ -94,13 +98,28 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc 0 { + tracing::debug!("Skipped additional result row #{}: {:?}", row_i, se); + } else { + tracing::debug!("First result row: {:?}", se); + for (key, vals) in se.attrs { + let vals_comb = vals.iter().map(|v| v.as_str()).collect::>().join(", "); + attribs.insert(key, vals_comb); + } + } + } + + // Update cache and return + let ldap_res = if attribs.is_empty() { None } else { Some(attribs) }; + cache.lock().await.insert(cache_key, ldap_res.clone()); + Ok(LdapAnswer { ldap_res, cached: false }) } @@ -156,9 +175,30 @@ async fn http_handler(req: Request, ctx: Arc) -> Result { let span = span.record("cached", &la.cached); - return if la.found { + return if let Some(ldap_res) = la.ldap_res { span.in_scope(|| { tracing::info!("User authorized Ok"); }); - Ok(Response::new(Body::from("200 OK - LDAP result found"))) + let mut resp = Response::new(Body::from("200 OK - LDAP result found")); + + // Store LDAP result attributes to response HTTP headers + for (key, val) in ldap_res.iter() { + let hname = match HeaderName::from_str(format!("X-LDAP-RES-{}", key).as_str()) { + Ok(hname) => hname, + Err(_) => { + span.in_scope(|| { tracing::error!("Invalid LDAP result key: {}", key); }); + return Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Body::from("Invalid LDAP result key")) + } + }; + let hval = match HeaderValue::from_str(val.as_str()) { + Ok(hval) => hval, + Err(_) => { + span.in_scope(|| { tracing::error!("Invalid LDAP result value: {}", val); }); + return Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Body::from("Invalid LDAP result value")) + } + }; + span.in_scope(|| { tracing::debug!("Adding result HTTP header: {:?} = {:?}", hname, hval); }); + resp.headers_mut().insert(hname, hval); + } + Ok(resp) } else { span.in_scope(|| { tracing::info!("User REJECTED"); }); Response::builder().status(StatusCode::FORBIDDEN).body(Body::from(format!("403 Forbidden - Empty LDAP result for user '{}'", username))) diff --git a/test/docker-compose.yml b/test/docker-compose.yml index 5c6c324..dc241dd 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -35,8 +35,8 @@ services: samba-tool domain passwordsettings set --max-pwd-age=0 # CN=service,CN=Users,DC=example,DC=test samba-tool user create service password123 --use-username-as-cn - samba-tool user create alice password123 --use-username-as-cn - samba-tool user create bob password123 --use-username-as-cn + samba-tool user create alice password123 --use-username-as-cn --given-name=Alice --surname=Alison --mail-address="alice@pp.example.test" + samba-tool user create bob password123 --use-username-as-cn --given-name=Bob --surname=Bobrikov --mail-address="bob@pp.example.test" # CN=ACL_Users,CN=Users,DC=example,DC=test samba-tool group add ACL_Users samba-tool group add ACL_Admins diff --git a/test/nginx-site.conf b/test/nginx-site.conf index 54ecae3..b3621b4 100644 --- a/test/nginx-site.conf +++ b/test/nginx-site.conf @@ -26,9 +26,14 @@ server { auth_basic_user_file /var/www/html/.htpasswd; auth_request /authz_users; + auth_request_set $display_name $upstream_http_x_ldap_res_displayname; + auth_request_set $email $upstream_http_x_ldap_res_mail; alias /var/www/html; index index.nginx-debian.html; + + add_header X-Display-Name $display_name; + add_header X-Email $email; } location /admin-page { satisfy all;