Skip to content

Commit a8a473a

Browse files
committed
Added X-Forwarded-Role header
1 parent 0c2ce12 commit a8a473a

File tree

4 files changed

+133
-74
lines changed

4 files changed

+133
-74
lines changed

Dockerfile

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,15 @@ ENV LDAPAUTHD_LOGLEVEL=INFO \
2424
LDAPAUTHD_IP=0.0.0.0 \
2525
LDAPAUTHD_PORT=80 \
2626
LDAPAUTHD_REALM=Authorization\ required \
27-
LDAP_LOGLEVEL=BASIC \
28-
LDAP_ATTRIBUTES='{"cn": "X-Forwarded-FullName", "mail": "X-Forwarded-Email", "sAMAccountName": "X-Forwarded-User"}' \
29-
LDAP_ALLOWEDUSERS= \
30-
LDAP_ALLOWEDGROUPS= \
27+
LDAP_LOGLEVEL=ERROR \
3128
LDAP_BASEDN=ou=Company,dc=example,dc=org \
3229
LDAP_BINDDN=cn=bind user,dc=example,dc=org \
3330
LDAP_BINDPW=password \
34-
LDAP_BACKENDS=dc01 \
35-
LDAP_DC01_HOST=dc01.example.org \
36-
LDAP_DC01_PORT=636 \
37-
LDAP_DC01_SSL=True \
38-
LDAP_DC01_SSL_VALIDATE=True
31+
LDAP_ATTRIBUTES='{"cn": "X-Forwarded-FullName", "mail": "X-Forwarded-Email", "sAMAccountName": "X-Forwarded-User"}' \
32+
LDAP_ALLOWEDUSERS= \
33+
LDAP_ALLOWEDGROUPS= \
34+
LDAP_ROLEHEADER=X-Forwarded-Role \
35+
LDAP_BACKENDS=
3936

4037
EXPOSE 80
4138

README.md

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,58 @@
33
This is a simple HTTP server which allows you to authenticate against ldap with a HTTP GET request. This daemon is designed to run behind a reverse proxy (haproxy, nginx, apache2, ...).
44

55
- [ldapauthd](#ldapauthd)
6-
- [Usage](#usage)
7-
- [Installation](#installation)
8-
- [Local](#local)
9-
- [Docker](#docker)
10-
- [Configuration](#configuration)
11-
- [Curl example](#curl-example)
6+
- [Usage](#usage)
7+
- [Examples](#examples)
8+
- [Curl](#curl)
9+
- [Traefik](#traefik)
10+
- [Installation](#installation)
11+
- [Local](#local)
12+
- [Docker](#docker)
13+
- [Configuration](#configuration)
14+
- [Examples](#examples-1)
15+
- [LDAP_ALLOWEDUSERS](#ldapallowedusers)
16+
- [LDAP_ALLOWEDGROUPS](#ldapallowedgroups)
1217
- [Special Thanks](#special-thanks)
1318

14-
## Usage
19+
# Usage
1520

1621
To authenticate against this daemon you only need to fire a GET request with base64 encoded **Authentication** HTTP header.
1722

18-
### Installation
23+
## Examples
1924

20-
#### Local
25+
### Curl
26+
27+
`$ curl -v --user 'username:password' localhost`
28+
29+
### Traefik
30+
31+
```yaml
32+
version: "3.7"
33+
services:
34+
traefik:
35+
image: traefik
36+
network:
37+
- internal
38+
[...]
39+
auth:
40+
image: g0dscookie/ldapauthd
41+
network:
42+
- internal
43+
[...]
44+
backend:
45+
image: mybackend
46+
network:
47+
- internal
48+
deploy:
49+
labels:
50+
traefik.enable: "true"
51+
traefik.frontend.auth.forward.address: "http://auth"
52+
traefik.frontend.auth.forward.authResponseHeaders: "X-Forwarded-FullName,X-Forwarded-User,X-Forwarded-Email,X-Forwarded-Role"
53+
```
54+
55+
# Installation
56+
57+
## Local
2158
2259
```sh
2360
git clone https://github.com/g0dsCookie/ldapauthd.git
@@ -27,11 +64,11 @@ pip install -r requirements.txt
2764

2865
Now you may run with `./ldapauthd.py` but I highly recommend reading [Configuration](#configuration).
2966

30-
#### Docker
67+
## Docker
3168

3269
Docker image **g0dscookie/ldapauthd** is available. See **docker-compose.yml** for configuration and usage of this container.
3370

34-
### Configuration
71+
# Configuration
3572

3673
Configuration for this daemon is read from the current environment. Available configuration parameters are:
3774

@@ -43,10 +80,11 @@ Configuration for this daemon is read from the current environment. Available co
4380
| LDAPAUTHD_IP | IP address the daemon should listen on. | 0.0.0.0 |
4481
| LDAPAUTHD_PORT | Port the daemon should listen on. | 80 |
4582
| LDAPAUTHD_REALM | String to set in WWW-Authenticate | Authorization required |
46-
| LDAP_LOGLEVEL | https://ldap3.readthedocs.io/logging.html#logging-detail-level | BASIC |
83+
| LDAP_LOGLEVEL | https://ldap3.readthedocs.io/logging.html#logging-detail-level | ERROR |
4784
| LDAP_ATTRIBUTES | Attributes to get from ldap and report to client | {"cn": "X-Forwarded-FullName", "mail": "X-Forwarded-Email", "sAMAccountName": "X-Forwarded-User"} |
48-
| LDAP_ALLOWEDUSERS | Allow specific users. Others will be denied | |
49-
| LDAP_ALLOWEDGROUPS | Allow specific groups. Others will be denied | |
85+
| LDAP_ALLOWEDUSERS | Allow specific users. Will be matched with given username | |
86+
| LDAP_ALLOWEDGROUPS | Allow specific groups. Will be matched with full group dn | |
87+
| LDAP_ROLEHEADER | The header name where the associated role should be stored | |
5088
| LDAP_BASEDN | Base DN every search request will be based on. | |
5189
| LDAP_BINDDN | Bind user to use for querying your ldap server. | |
5290
| LDAP_BINDPW | Bind users password. | |
@@ -56,9 +94,25 @@ Configuration for this daemon is read from the current environment. Available co
5694
| LDAP_\<NAME\>_SSL | Use SSL for ldap connection. | True |
5795
| LDAP_\<NAME\>_SSL_VALIDATE | Verify remote SSL certificate. | True |
5896

59-
#### Curl example
97+
## Examples
6098

61-
`$ curl -v --user 'username:password' localhost`
99+
### LDAP_ALLOWEDUSERS
100+
101+
Used to allow specific users and assign specific roles to them. Always overwrites **LDAP_ALLOWEDGROUPS**.
102+
103+
Users are matched case-insensitive.
104+
105+
`LDAP_ALLOWEDUSERS={"username": "admin", "foobar": "nobody"}`
106+
107+
### LDAP_ALLOWEDGROUPS
108+
109+
Used to allow groups and assign appropriate role to the user. May be overwritten by **LDAP_ALLOWEDUSERS**.
110+
111+
First matched group will be used to allow access and assign the role.
112+
113+
Groups are matched case-insensitive.
114+
115+
`LDAP_ALLOWEDGROUPS={"cn=admins,dc=example,dc=org": "admin", "cn=domain users,dc=example,dc=org": "users"}`
62116

63117
# Special Thanks
64118

docker-compose.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ services:
1717
#- LDAPAUTHD_PORT=80
1818
# String to set in WWW-Authenticate
1919
#- LDAPAUTHD_REALM=Authorization required
20-
# Response HTTP-Header for full username.
21-
#- LDAPAUTHD_FORWARD_USER=X-Forwarded-User
22-
# Response HTTP-Header for email.
23-
#- LDAPAUTHD_FORWARD_EMAIL=X-Forwarded-Email
20+
# https://ldap3.readthedocs.io/logging.html#logging-detail-level
21+
#- LDAP_LOGLEVEL=ERROR
22+
# Attributes to get from ldap and report to client
23+
#- LDAP_ATTRIBUTES
24+
# Allow specific users. Will be matched with given username
25+
#- LDAP_ALLOWEDUSERS
26+
# Allow specific groups. Will be matched with full group dn
27+
#- LDAP_ALLOWEDGROUPS
28+
# The header name where the associated role should be stored
29+
#- LDAP_ROLEHEADER
2430
# Base DN every search request will be based on.
2531
#- LDAP_BASEDN=ou=Company,dc=example,dc=org
2632
# Bind user to use for querying your ldap server.

ldapauthd.py

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,16 @@ def check_auth(username, passwd):
5252
cfg = config["ldap"]
5353
allowusers = cfg["allowedUsers"]
5454
allowgroups = cfg["allowedGroups"]
55+
attributes = {}
56+
allowed = not bool(allowusers or allowgroups)
5557

56-
if allowusers and username.lower() not in allowusers:
57-
# we don't need to ask ldap if the user will be rejected anyway
58-
log.debug("User %s not in allowed users [%s]", username, ", ".join(allowusers))
59-
return False
58+
if not allowed and allowusers:
59+
username_lower = username.lower()
60+
log.debug("Checking if user %s is explicitly allowed...", username)
61+
if username_lower in allowusers:
62+
attributes[cfg["roleHeader"]] = allowusers[username_lower]
63+
log.debug("User %s is explicitly allowed, Role %s will be assigned", username, allowusers[username_lower])
64+
allowed = True
6065

6166
# fetch user info
6267
with ldap3.Connection(cfg["backends"], user=cfg["binddn"], password=cfg["bindpw"]) as conn:
@@ -72,11 +77,17 @@ def check_auth(username, passwd):
7277
return False
7378
user = conn.entries[0]
7479

75-
if allowgroups and not in_group(allowgroups, user.memberOf):
76-
# we don't need to authenticate the user if the user is not
77-
# a member of one of the groups
78-
log.debug("User %s is not member of any group from [%s]",
79-
user.entry_dn, ", ".join(allowgroups))
80+
if not allowed and allowgroups:
81+
log.debug("Checking if user %s is allowed by group...", username)
82+
for user_group in [x.lower() for x in user.memberOf]:
83+
if user_group in allowgroups:
84+
attributes[cfg["roleHeader"]] = allowgroups[user_group]
85+
log.debug("User %s is allowed, Role %s will be assigned", username, allowgroups[user_group])
86+
allowed = True
87+
break
88+
89+
if not allowed:
90+
log.debug("User %s is not member of any allowed group and not explicitly allowed.", user.entry_dn)
8091
return False
8192

8293
# check users password
@@ -85,10 +96,10 @@ def check_auth(username, passwd):
8596
log.debug("Could not bind to ldap with user %s: %s | %s",
8697
user.entry_dn, conn.result["description"], conn.result["message"])
8798
return False
88-
89-
attributes = {}
99+
90100
for attr_name, header_name in cfg["attributes"].items():
91101
attributes[header_name] = user[attr_name]
102+
92103
# Return user informations for latter use
93104
return attributes
94105

@@ -152,30 +163,13 @@ def load_backend_config(name):
152163

153164
def get_ldap_srv(backend_cfg):
154165
if backend_cfg["ssl"]:
155-
156166
tls = ldap3.Tls(validate=ssl.CERT_REQUIRED if backend_cfg["ssl_validate"] else ssl.CERT_NONE,
157167
version=ssl.PROTOCOL_TLSv1)
158168
return ldap3.Server(host=backend_cfg["host"], port=backend_cfg["port"], use_ssl=True, tls=tls, get_info=False)
159169
else:
160170
return ldap3.Server(host=backend_cfg["host"], port=backend_cfg["port"], use_ssl=False, get_info=False)
161171

162172

163-
def populate_groups():
164-
cfg = config["ldap"]
165-
if not cfg["allowedGroups"]:
166-
log.debug("No groups to lookup")
167-
return
168-
169-
groups = []
170-
for group_name in cfg["allowedGroups"]:
171-
with ldap3.Connection(cfg["backends"], user=cfg["binddn"], password=cfg["bindpw"]) as conn:
172-
if not conn.search(cfg["basedn"], "(&(objectClass=group)(cn=%s))" % group_name, search_scope=ldap3.SUBTREE) or len(conn.entries) == 0:
173-
log.error("Could not find group %s", group_name)
174-
continue
175-
groups.append(conn.entries[0].entry_dn)
176-
log.debug("Found groups [%s]", " | ".join(groups))
177-
cfg["allowedGroups"] = groups
178-
179173
ldap3_level_to_detail = {
180174
"OFF": ldap3.utils.log.OFF,
181175
"ERROR": ldap3.utils.log.ERROR,
@@ -185,12 +179,26 @@ def populate_groups():
185179
"EXTENDED": ldap3.utils.log.EXTENDED,
186180
}
187181

182+
188183
def ldap3_level_name_to_detail(level_name):
189184
if level_name in ldap3_level_to_detail:
190185
return ldap3_level_to_detail[level_name]
191186
raise ValueError("unknown detail level")
192187

193188

189+
def load_json_env(name, env_default=None, default=None):
190+
try:
191+
data = os.getenv(name, env_default)
192+
return json.loads(data) if data else default
193+
except json.decoder.JSONDecodeError as err:
194+
log.error("Failed to load %s: %s", name, err)
195+
sys.exit(2)
196+
197+
198+
def to_lower_dict(data):
199+
return {k.lower():v for k, v in data.items()} if data else data
200+
201+
194202
def read_env():
195203
global config
196204
config = {
@@ -203,14 +211,17 @@ def read_env():
203211
"realm": os.getenv("LDAPAUTHD_REALM", "Authorization required"),
204212
},
205213
"ldap": {
206-
"loglevel": os.getenv("LDAP_LOGLEVEL", "BASIC"),
214+
"loglevel": os.getenv("LDAP_LOGLEVEL", "ERROR"),
207215
"backends": ldap3.ServerPool(None, ldap3.ROUND_ROBIN, active=True, exhaust=False),
208-
"allowedUsers": os.getenv("LDAP_ALLOWEDUSERS", "").lower().split(","),
209-
"allowedGroups": os.getenv("LDAP_ALLOWEDGROUPS", "").split(","),
216+
"allowedUsers": to_lower_dict(load_json_env("LDAP_ALLOWEDUSERS")),
217+
"allowedGroups": to_lower_dict(load_json_env("LDAP_ALLOWEDGROUPS")),
210218
"basedn": os.getenv("LDAP_BASEDN"),
211219
"binddn": os.getenv("LDAP_BINDDN"),
212220
"bindpw": os.getenv("LDAP_BINDPW"),
213-
"attributes": {},
221+
"attributes": load_json_env("LDAP_ATTRIBUTES",
222+
env_default='{"cn": "X-Forwarded-FullName", "mail": "X-Forwarded-Email", "sAMAccountName": "X-Forwarded-User"}',
223+
default={}),
224+
"roleHeader": os.getenv("LDAP_ROLEHEADER", "X-Forwarded-Role"),
214225
}
215226
}
216227
log.setLevel(config["ldapauthd"]["loglevel"])
@@ -221,33 +232,24 @@ def read_env():
221232
log.error("Invalid loglevel for LDAP_LOGLEVEL: %s. Possible values are %s", config["ldap"]["loglevel"], ", ".join(ldap3_level_to_detail.keys()))
222233
sys.exit(2)
223234

224-
try:
225-
data = os.getenv("LDAP_ATTRIBUTES", '{"cn": "X-Forwarded-FullName", "mail": "X-Forwarded-Email", "sAMAccountName": "X-Forwarded-User"}')
226-
config["ldap"]["attributes"] = json.loads(data) if data else {}
227-
except json.decoder.JSONDecodeError as err:
228-
log.error("Failed to load LDAP_ATTRIBUTES: %s", err)
229-
sys.exit(2)
230-
231235
for key, item in {"basedn": "LDAP_BASEDN",
232236
"binddn": "LDAP_BINDDN",
233237
"bindpw": "LDAP_BINDPW"}.items():
234238
if key not in config["ldap"] or not config["ldap"][key]:
235239
log.error("%s not defined.", item)
236240
sys.exit(2)
237241

238-
if len(config["ldap"]["allowedUsers"]) == 1 and not config["ldap"]["allowedUsers"][0]:
239-
config["ldap"]["allowedUsers"] = None
240-
if len(config["ldap"]["allowedGroups"]) == 1 and not config["ldap"]["allowedGroups"][0]:
241-
config["ldap"]["allowedGroups"] = None
242-
243242
backends = os.getenv("LDAP_BACKENDS", "").split(",")
244243
if len(backends) > 1:
245244
config["ldap"]["backends"] = ldap3.ServerPool([get_ldap_srv(load_backend_config(x)) for x in backends],
246245
ldap3.ROUND_ROBIN, active=True, exhaust=False)
247246
else:
248247
config["ldap"]["backends"] = get_ldap_srv(load_backend_config(backends[0]))
249248

250-
populate_groups()
249+
if config["ldap"]["allowedUsers"]:
250+
log.debug("Users explicitly allowed to authenticate: %s", ", ".join(config["ldap"]["allowedUsers"].keys()))
251+
if config["ldap"]["allowedGroups"]:
252+
log.debug("Groups allowed to authenticate: %s", ", ".join(config["ldap"]["allowedGroups"].keys()))
251253

252254

253255
if __name__ == "__main__":

0 commit comments

Comments
 (0)