前言
很多時(shí)候,我們需要給一些本沒有身份認(rèn)證功能的業(yè)務(wù)增加一個(gè)認(rèn)證模塊。
- 比如免費(fèi)版的 ELK,Kibana 上是沒有身份認(rèn)證的;
- 比如 0.1 版的 Open-Falcon,Dashboard 上也是沒有認(rèn)證的;
- 又或者一些本來對(duì)外公開的網(wǎng)站,突然在某些特殊的日子,在某些特殊的時(shí)間里,不希望對(duì)外公開了。。。
直接修改業(yè)務(wù)的侵入式方案通常不太容易,非侵入式的方案一般也能實(shí)現(xiàn)類似的效果,比如給他增加一個(gè)代理然后做 http basic 認(rèn)證。
這是一個(gè)好辦法,但是 http basic 認(rèn)證畢竟太簡單了,也不方便集成外部的認(rèn)證源,比如 LDAP
所以一個(gè)更靈活的方案是通過 Nginx 的 auth_request 模塊
Nginx 的 auth_request 模塊
auth_request 大抵就是在你訪問 Nginx 中受 auth_reuqest 保護(hù)的路徑時(shí),去請(qǐng)求一個(gè)特定的服務(wù)。根據(jù)這個(gè)服務(wù)返回的狀態(tài)碼,auth_request 模塊再進(jìn)行下一步的動(dòng)作,允許訪問或者重定向跳走什么的。因此我們可以在上面去定制我們所有個(gè)性化的需求。
假定我們的環(huán)境是 centos ,yum 安裝 nginx 就略了。由于通過 yum 等安裝的 nginx 默認(rèn)沒有編譯 auth_request 模塊。我們需要重新編譯一下。
先運(yùn)行 nginx -V 來獲取當(dāng)前 nginx 的編譯參數(shù)
# nginx -V
nginx version: nginx/1.14.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-16) (GCC)
built with OpenSSL 1.0.2k-fips 26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie'
先安裝一些依賴
yum -y install gcc gcc-c++ autoconf automake make
yum -y install zlib zlib-devel openssl
yum -y install openssl-devel pcre pcre-devel
yum -y install libxslt-devel
yum -y install redhat-rpm-config
yum -y install gd-devel
yum -y install perl-devel perl-ExtUtils-Embed
yum -y install geoip-devel
yum -y install gperftools-devel
然后下載 nginx 的源代碼 ,用剛才得到的編譯參數(shù),增加 --with-http_auth_request_module 參數(shù)重新編譯
# ./configure --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie' --with-http_auth_request_module
# make
# make install
再 nginx -V 看一下,已經(jīng)帶上 http_auth_request_module 了
# nginx -V
nginx version: nginx/1.14.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-28) (GCC)
built with OpenSSL 1.0.2k-fips 26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie' --with-http_auth_request_module
一個(gè)簡單 demo
nginx.inc 給了一個(gè)非常簡單的 demo,即 nginx-auth-ldap 這個(gè) repo。
整個(gè)邏輯就如下圖所示

詳細(xì)的流程圖大抵如下所示:

客戶端發(fā)送 HTTP 請(qǐng)求,以獲取 Nginx 上反向代理的受保護(hù)資源。
Nginx 的
auth_request模塊 將請(qǐng)求轉(zhuǎn)發(fā)給ldap-auth這個(gè)服務(wù)(對(duì)應(yīng) nginx-ldap-auth-daemon.py),首次肯定會(huì)給個(gè) 401 .Nginx 將請(qǐng)求轉(zhuǎn)發(fā)給
http:// backend / login,后者對(duì)應(yīng)于這里的后端服務(wù)。它將原始請(qǐng)求的uri寫入X-Target,以便于后面跳轉(zhuǎn)。后端服務(wù)向客戶端發(fā)送登錄表單(表單在
demo代碼中定義)。根據(jù) error_page 的配置,Nginx 將登錄表單的 http 狀態(tài)碼返回 200。用戶填寫表單上的用戶名和密碼字段并單擊登錄按鈕,從向
/ login發(fā)起POST請(qǐng)求,Nginx 將其轉(zhuǎn)發(fā)到后端的服務(wù)上。后端服務(wù)把用戶名密碼以 base64 方式寫入 cookie。
客戶端重新發(fā)送其原始請(qǐng)求(來自步驟1),現(xiàn)在有 cookie 了 。Nginx 將請(qǐng)求轉(zhuǎn)發(fā)給
ldap-auth服務(wù)(如步驟2所示)。ldap-auth服務(wù)解碼 cookie,然后做 LDAP 認(rèn)證。-
下一個(gè)操作取決于 LDAP 認(rèn)證是否成功:
- 如果認(rèn)證成功,則
ldap-auth服務(wù)給 Nginx 返回狀態(tài)碼 200。Nginx 從后端服務(wù)中請(qǐng)求資源。在demo里,后端服務(wù)返回以下文本:Hello, world! Requested URL: URL - 如果認(rèn)證失敗,
ldap-auth服務(wù)會(huì)返回 401 。Nginx 再次將請(qǐng)求轉(zhuǎn)發(fā)給后端服務(wù)的Login(如步驟3),并重復(fù)該過程。
- 如果認(rèn)證成功,則
Demo 測(cè)試
先安裝下依賴
yum install python-ldap
然后把 repo clone 下來
#git clone https://github.com/nginxinc/nginx-ldap-auth.git
# ls
backend-sample-app.py Dockerfile nginx-ldap-auth.conf nginx-ldap-auth-daemon-ctl.sh nginx-ldap-auth.default nginx-ldap-auth.service rpm
debian LICENSE nginx-ldap-auth-daemon-ctl-rh.sh nginx-ldap-auth-daemon.py nginx-ldap-auth.logrotate README.md
這其中 nginx-ldap-auth.conf 是 Nginx 的配置范例,直接 copy 過去即可
# cp nginx-ldap-auth.conf /etc/nginx/nginx.conf
Nginx 的配置文件如下,做了些精簡,加了中文注釋。
error_log logs/error.log debug;
# 這里把日志放在 nginx 目錄下,所以要么改掉要么在 nginx 目錄下建個(gè) log 目錄
events { }
http {
# cache 路徑和大小
proxy_cache_path cache/ keys_zone=auth_cache:10m;
# 將要被 nginx auth_request 保護(hù)的 backend
# 在這個(gè) demo 里是 backend-sample-app.py.
upstream backend {
server 127.0.0.1:9000;
}
# nginx 服務(wù)起在 8081 上
server {
listen 8081;
# 這個(gè)路徑被 auth_request 保護(hù)了, 401 重定向到 login 上
location / {
auth_request /auth-proxy;
# redirect 401 to login form
error_page 401 =200 /login;
proxy_pass http://backend/;
}
# 這里是我們認(rèn)證的頁面
location /login {
proxy_pass http://backend/login;
# 這個(gè) X-Target 是給認(rèn)證完以后重定向的
proxy_set_header X-Target $request_uri;
}
# 這是用做 auth_request 請(qǐng)求的路徑
location = /auth-proxy {
internal;
# 提供 ldap 認(rèn)證服務(wù)的 auth-proxy backend
# 這個(gè) demo 里是 nginx-ldap-auth-daemon.py.
proxy_pass http://127.0.0.1:8888;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_cache auth_cache;
proxy_cache_valid 200 10m;
# cookie 會(huì)加在這里
proxy_cache_key "$http_authorization$cookie_nginxauth";
# ldap 的地址
proxy_set_header X-Ldap-URL "ldap://ldap.example.org";
# 是否開啟 starttls
# 注意 starttls 不能和 tls,也就是 ldaps 同時(shí)開啟
#proxy_set_header X-Ldap-Starttls "true";
# ldap 的 BaseDN
proxy_set_header X-Ldap-BaseDN "dc=example,dc=org";
# ldap 的 binddn,也就是有查詢權(quán)限的賬號(hào)
proxy_set_header X-Ldap-BindDN "cn=manager,dc=example,dc=org";
# binddn 的密碼
proxy_set_header X-Ldap-BindPass "password";
# cookie 的名字和值
proxy_set_header X-CookieName "nginxauth";
proxy_set_header Cookie nginxauth=$cookie_nginxauth;
# ldap 的 searchFilter,就是拿哪個(gè)字段作為認(rèn)證的用戶名
proxy_set_header X-Ldap-Template "(uid=%(username)s)";
}
}
}
然后分別執(zhí)行 ./nginx-ldap-auth-daemon.py 和 ./backend-sample-app.py 即可。
訪問 Nginx 的 8081 端口,可以看到他能夠重定向到 backend 上去做認(rèn)證了。


日志
# ./nginx-ldap-auth-daemon.py
Start listening on localhost:8888...
localhost.localdomain - - [06/Jun/2018 09:18:31] using username/password from authorization header
localhost.localdomain - - [06/Jun/2018 09:18:31] "GET /auth-proxy HTTP/1.0" 401 -
localhost.localdomain - - [06/Jun/2018 09:18:35] using username/password from authorization header
localhost.localdomain - - [06/Jun/2018 09:18:35] "GET /auth-proxy HTTP/1.0" 401 -
localhost.localdomain - - [06/Jun/2018 09:18:43] using username/password from cookie nginxauth
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] searching on server "ldap://202.120.83.219" with base dn "dc=ecnu,dc=edu,dc=cn" with filter "(uid=20150073)"
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] Auth OK for user "20150073"
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] "GET /auth-proxy HTTP/1.0" 200 -
# ./backend-sample-app.py
localhost.localdomain - - [06/Jun/2018 09:18:31] "GET /login HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:35] "GET /login HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "POST /login HTTP/1.0" 302 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "GET / HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "GET /favicon.ico HTTP/1.0" 200 -
代碼分析
整個(gè) demo 除了 python-ldap 外沒有其他的依賴。它的 http 服務(wù)使用的是 HTTPServer 模塊。
先來看 backend-sample-app.py,它是我們這個(gè) demo 里的 backend。
首先是路由:
def do_GET(self):
url = urlparse.urlparse(self.path)
if url.path.startswith("/login"):
return self.auth_form()
self.send_response(200)
self.end_headers()
self.wfile.write('Hello, world! Requested URL: ' + self.path + '\n')
可以看到,它維護(hù)了兩個(gè)路由。請(qǐng)求 /login 就跳認(rèn)證頁,否則就輸出 Hello, world!
然后看看這個(gè)表單:
# send login form html
def auth_form(self, target = None):
# try to get target location from header
if target == None:
target = self.headers.get('X-Target')
# form cannot be generated if target is unknown
if target == None:
self.log_error('target url is not passed')
self.send_response(500)
return
html="""
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv=Content-Type content="text/html;charset=UTF-8">
<title>Auth form example</title>
</head>
<body>
<form action="/login" method="post">
<table>
<tr>
<td>Username: <input type="text" name="username"/></td>
<tr>
<td>Password: <input type="text" name="password"/></td>
<tr>
<td><input type="submit" value="Login"></td>
</table>
<input type="hidden" name="target" value="TARGET">
</form>
</body>
</html>"""
self.send_response(200)
self.end_headers()
self.wfile.write(html.replace('TARGET', target))
認(rèn)證表單本身很簡單,可以看到它把 http 頭中的 X-Target 取了出來以隱藏的方式重新從表單里提交了上來。這個(gè) X-Target 字段后面會(huì)用于認(rèn)證后的重定向回原始請(qǐng)求頁面。
看看拿到提交的數(shù)據(jù)后做了什么:
user = form.getvalue('username')
passwd = form.getvalue('password')
target = form.getvalue('target')
if user != None and passwd != None and target != None:
# form is filled, set the cookie and redirect to target
# so that auth daemon will be able to use information from cookie
self.send_response(302)
# WARNING WARNING WARNING
#
# base64 is just an example method that allows to pack data into
# a cookie. You definitely want to perform some encryption here
# and share a key with auth daemon that extracts this information
#
# WARNING WARNING WARNING
enc = base64.b64encode(user + ':' + passwd)
self.send_header('Set-Cookie', 'nginxauth=' + enc + '; httponly')
self.send_header('Location', target)
self.end_headers()
return
這里寫 cookie 了,把用戶名和密碼以 ; 號(hào)連接后做 base64 寫入 cookie。(注意這很不安全!勿在生產(chǎn)環(huán)境中這樣寫)。然后寫 Location 里寫入 target 的值,來實(shí)現(xiàn)重定向跳回。
很粗暴吧,畢竟只是 demo 而已。
然后我們看 nginx-ldap-auth-daemon.py。這里負(fù)責(zé)對(duì) nginx auth_request 的響應(yīng)。
沒有 cookie 或者 cookie 不對(duì),回 401:
auth_header = self.headers.get('Authorization')
auth_cookie = self.get_cookie(ctx['cookiename'])
if auth_cookie != None and auth_cookie != '':
auth_header = "Basic " + auth_cookie
self.log_message("using username/password from cookie %s" %
ctx['cookiename'])
else:
self.log_message("using username/password from authorization header")
if auth_header is None or not auth_header.lower().startswith('basic '):
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="' + ctx['realm'] + '"')
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
cookie 正確的,base64 解碼拆出用戶名和密碼來:
try:
auth_decoded = base64.b64decode(auth_header[6:])
user, passwd = auth_decoded.split(':', 1)
except:
self.auth_failed(ctx)
return True
ctx['user'] = user
ctx['pass'] = passwd
然后拿著用戶名密碼,和 http 頭里的 ldap 配置信息去做 ldap 認(rèn)證。這部分代碼挺長的,摘取一小段:
ldap_obj = ldap.initialize(ctx['url']);
# Python-ldap module documentation advises to always
# explicitely set the LDAP version to use after running
# initialize() and recommends using LDAPv3. (LDAPv2 is
# deprecated since 2003 as per RFC3494)
#
# Also, the STARTTLS extension requires the
# use of LDAPv3 (RFC2830).
ldap_obj.protocol_version=ldap.VERSION3
# Establish a STARTTLS connection if required by the
# headers.
if ctx['starttls'] == 'true':
ldap_obj.start_tls_s()
# See http://www.python-ldap.org/faq.shtml
# uncomment, if required
# ldap_obj.set_option(ldap.OPT_REFERRALS, 0)
ctx['action'] = 'binding as search user'
ldap_obj.bind_s(ctx['binddn'], ctx['bindpasswd'], ldap.AUTH_SIMPLE)
ctx['action'] = 'preparing search filter'
searchfilter = ctx['template'] % { 'username': ctx['user'] }
self.log_message(('searching on server "%s" with base dn ' + \
'"%s" with filter "%s"') %
(ctx['url'], ctx['basedn'], searchfilter))
ctx['action'] = 'running search query'
results = ldap_obj.search_s(ctx['basedn'], ldap.SCOPE_SUBTREE,
searchfilter, ['objectclass'], 1)
ctx['action'] = 'verifying search query results'
if len(results) < 1:
self.auth_failed(ctx, 'no objects found')
return
ctx['action'] = 'binding as an existing user'
ldap_dn = results[0][0]
ctx['action'] += ' "%s"' % ldap_dn
ldap_obj.bind_s(ldap_dn, ctx['pass'], ldap.AUTH_SIMPLE)
self.log_message('Auth OK for user "%s"' % (ctx['user']))
# Successfully authenticated user
self.send_response(200)
基本就是一個(gè) ldap 認(rèn)證的標(biāo)準(zhǔn)過程。
- 拿
binddn和bindpasswd先做一次 bind 獲得查詢權(quán)限。 - 拿用戶名和
searchFilter去查詢,拿到用戶的dn。 - 拿這個(gè)
dn再于用戶密碼做一次 bind,進(jìn)行 ldap 認(rèn)證校驗(yàn)。
生產(chǎn)環(huán)境
這個(gè) demo 的代碼肯定沒法直接應(yīng)用在生產(chǎn)環(huán)境中,HTTPServer 適不適合做生產(chǎn)環(huán)境暫且不提,把用戶名和密碼直接寫進(jìn) cookie 單這一條恐怕就沒辦法接受。即便像注釋里所說的共享個(gè)密鑰做諸如 AES 之類的加密,也感覺不是太很舒服。
其實(shí)這種需求,我們平時(shí)都是通過 session 來做的嘛。用戶認(rèn)證后記錄一個(gè) session,把 session 寫到 cookie 里。下次用戶再上來查 session 表里 session 沒有過期就可以直接放行了。
而且我們還需要疊加一些特殊的策略,比如某些 IP 地址不認(rèn)證直接放行,根據(jù)時(shí)間段選擇是否需要開啟認(rèn)證。這些都需要 session 來支持。
這部分,我們留到下回再說吧。
參考文獻(xiàn)
nginx-ldap-auth
nginx-plus-authenticate-users