Skip to content

Commit

Permalink
Implement returning LDAP attribs to Nginx. Bump version 0.2.0
Browse files Browse the repository at this point in the history
  • Loading branch information
elonen committed Jan 29, 2023
1 parent 2f3f08e commit 1904eeb
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 39 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
61 changes: 46 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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]
Expand All @@ -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`.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
}
```
Expand Down
6 changes: 6 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
ldap_authz_proxy (0.2-1) unstable; urgency=low

* New feature: return LDAP attributes in response headers

-- Jarno Elonen <[email protected]> Sat, 28 Jan 2023 23:08:00 +0000

ldap_authz_proxy (0.1-1) unstable; urgency=low

* First release
Expand Down
6 changes: 3 additions & 3 deletions example.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
10 changes: 6 additions & 4 deletions run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

pub(crate) ldap_cache_size: usize,
pub(crate) ldap_cache_time: u32,
Expand Down Expand Up @@ -48,7 +49,7 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, 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(", "));
Expand Down Expand Up @@ -82,6 +83,7 @@ pub(crate) fn parse_config(config_file: &str) -> Result<Vec<ConfigSection>, 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()?,
Expand Down
66 changes: 53 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,15 +41,16 @@ Options:
"#;

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

struct ReqContext {
config: Vec<ConfigSection>,
cache: HashMap<String, Arc<Mutex<LdapCache>>>, // <section_name, cache>
}

struct LdapAnswer {
found: bool,
ldap_res: LdapSearchRes,
cached: bool,
}

Expand All @@ -64,9 +68,9 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
let mut hasher = Sha256::new();
hasher.update(format!("{}:{}", conf.ldap_server_url, query));
let cache_key = hasher.finalize();
if let Some(found) = cache.lock().await.get(&cache_key) {
if let Some(res) = cache.lock().await.get(&cache_key) {
tracing::debug!("Cache hit. Skipping LDAP.");
return Ok(LdapAnswer { found: *found, cached: true });
return Ok(LdapAnswer { ldap_res: res.clone(), cached: true });
} else {
tracing::debug!("Not cached. Performing real query.");
}
Expand All @@ -85,7 +89,7 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
conf.ldap_search_base.as_str(),
Scope::Subtree,
query.as_str(),
vec!["l"]
&conf.ldap_return_attribs
).await?.success()
{
Ok(res) => res,
Expand All @@ -94,13 +98,28 @@ async fn ldap_check(conf: &ConfigSection, username: &str, cache: &Arc<Mutex<Ldap
return Err(e)
}
};
let found = !&rs.is_empty();
for entry in rs {
tracing::debug!("LDAP result: {:?}", SearchEntry::construct(entry));
}
ldap.unbind().await?;
cache.lock().await.insert(cache_key, found);
Ok(LdapAnswer { found, cached: false })

// Store first row in a HashMap and log all other rows
let row_i = 0;
let mut attribs = HashMap::new();
for row in rs {
let se = SearchEntry::construct(row);
if row_i > 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::<Vec<&str>>().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 })
}


Expand Down Expand Up @@ -156,9 +175,30 @@ async fn http_handler(req: Request<Body>, ctx: Arc<ReqContext>) -> Result<Respon
},
Ok(la) => {
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)))
Expand Down
4 changes: 2 additions & 2 deletions test/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"
samba-tool user create bob password123 --use-username-as-cn --given-name=Bob --surname=Bobrikov --mail-address="[email protected]"
# CN=ACL_Users,CN=Users,DC=example,DC=test
samba-tool group add ACL_Users
samba-tool group add ACL_Admins
Expand Down
5 changes: 5 additions & 0 deletions test/nginx-site.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 1904eeb

Please sign in to comment.