From 21a68af1aa3a8d3f5a929862a90feb1b261ae9d0 Mon Sep 17 00:00:00 2001 From: Vladimir Homutov Date: Wed, 1 Apr 2015 13:02:48 +0300 Subject: [PATCH] initial version of daemons and configuration --- backend-sample-app.py | 135 ++++++++++++++++++++ nginx-ldap-auth-daemon-ctl.sh | 17 +++ nginx-ldap-auth-daemon.py | 223 ++++++++++++++++++++++++++++++++++ nginx-ldap-auth.conf | 70 +++++++++++ 4 files changed, 445 insertions(+) create mode 100755 backend-sample-app.py create mode 100755 nginx-ldap-auth-daemon-ctl.sh create mode 100755 nginx-ldap-auth-daemon.py create mode 100644 nginx-ldap-auth.conf diff --git a/backend-sample-app.py b/backend-sample-app.py new file mode 100755 index 0000000..32fc910 --- /dev/null +++ b/backend-sample-app.py @@ -0,0 +1,135 @@ +#!/bin/sh +''''which python2 >/dev/null && exec python2 "$0" "$@" # ''' +''''which python >/dev/null && exec python "$0" "$@" # ''' + +# Example of an application working on port 9000 +# To interact with nginx-ldap-auth-daemon this application +# 1) accepts GET requests on /login and responds with a login form +# 2) accepts POST requests on /login, sets a cookie, and responds with redirect + +import sys, os, signal, base64, Cookie, cgi, urlparse +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler + +Listen = ('localhost', 9000) + +import threading +from SocketServer import ThreadingMixIn +class AuthHTTPServer(ThreadingMixIn, HTTPServer): + pass + +class AppHandler(BaseHTTPRequestHandler): + + 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') + + + # 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=""" + + + + + Auth form example + + +
+ + + + + + + +
Username:
Password:
+ +
+ +""" + + self.send_response(200) + self.end_headers() + self.wfile.write(html.replace('TARGET', target)) + + + # processes posted form and sets the cookie with login/password + def do_POST(self): + + # prepare arguments for cgi module to read posted form + env = {'REQUEST_METHOD':'POST', + 'CONTENT_TYPE': self.headers['Content-Type'],} + + # read the form contents + form = cgi.FieldStorage(fp = self.rfile, headers = self.headers, + environ = env) + + # extract required fields + 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 + + self.log_error('some form fields are not provided') + self.auth_form(target) + + + def log_message(self, format, *args): + if len(self.client_address) > 0: + addr = BaseHTTPRequestHandler.address_string(self) + else: + addr = "-" + + sys.stdout.write("%s - - [%s] %s\n" % (addr, + self.log_date_time_string(), format % args)) + + def log_error(self, format, *args): + self.log_message(format, *args) + + +def exit_handler(signal, frame): + sys.exit(0) + +if __name__ == '__main__': + server = AuthHTTPServer(Listen, AppHandler) + signal.signal(signal.SIGINT, exit_handler) + server.serve_forever() diff --git a/nginx-ldap-auth-daemon-ctl.sh b/nginx-ldap-auth-daemon-ctl.sh new file mode 100755 index 0000000..c79a4a5 --- /dev/null +++ b/nginx-ldap-auth-daemon-ctl.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +CMD=./ngx-ldap-auth-daemon.py +PIDFILE=./ngx-ldap-auth-daemon.pid +LOGFILE=./ngx-ldap-auth-daemon.log + +case $1 in + "start") + start-stop-daemon -S -x $CMD -b -m -p $PIDFILE -1 $LOGFILE + ;; + "stop") + start-stop-daemon -K -p $PIDFILE + ;; + *) + echo "Usage: $0 " + ;; +esac diff --git a/nginx-ldap-auth-daemon.py b/nginx-ldap-auth-daemon.py new file mode 100755 index 0000000..8de118a --- /dev/null +++ b/nginx-ldap-auth-daemon.py @@ -0,0 +1,223 @@ +#!/bin/sh +''''which python2 >/dev/null && exec python2 "$0" "$@" # ''' +''''which python >/dev/null && exec python "$0" "$@" # ''' + +import sys, os, signal, base64, ldap, Cookie +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler + +Listen = ('localhost', 8888) +#Listen = "/tmp/auth.sock" # uncomment unix sockets section below to use + +# ----------------------------------------------------------------------------- +# Different request processing models: select one +# ----------------------------------------------------------------------------- +# requests are processed in separate thread +import threading +from SocketServer import ThreadingMixIn +class AuthHTTPServer(ThreadingMixIn, HTTPServer): + pass +# ----------------------------------------------------------------------------- +# requests are processed in separate process +#from SocketServer import ForkingMixIn +#class AuthHTTPServer(ForkingMixIn, HTTPServer): +# pass +# ----------------------------------------------------------------------------- +# unix sockets +#import threading +#from SocketServer import ThreadingUnixStreamServer +#class AuthHTTPServer(ThreadingUnixStreamServer, HTTPServer): +# pass +# ----------------------------------------------------------------------------- + +class AuthHandler(BaseHTTPRequestHandler): + + # returns True if request processed and response sent, otherwise False + # sets ctx['user'] and ctx['pass'] for authentication + def do_GET(self): + + ctx = self.ctx + + ctx['action'] = 'input parameters check' + for k, v in self.get_params().items(): + ctx[k] = self.headers.get(v[0], v[1]) + if ctx[k] == None: + self.auth_failed(ctx, 'required "%s" header is not passed' % k) + return True + + ctx['action'] = 'performing authoriazation' + 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 login/password from cookie %s" % + ctx['cookiename']) + else: + self.log_message("using login/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() + + return True + + ctx['action'] = 'decoding credentials' + + try: + auth_decoded = base64.b64decode(auth_header[6:]) + user, passwd = auth_decoded.split(':', 2) + + except: + self.auth_failed(ctx) + return True + + ctx['user'] = user + ctx['pass'] = passwd + + # continue request processing + return False + + def get_cookie(self, name): + cookies = self.headers.get('Cookie') + if cookies: + authcookie = Cookie.BaseCookie(cookies).get(name) + if authcookie: + return authcookie.value + else: + return None + else: + return None + + + # Logs the error and completes the request with apropriate status + def auth_failed(self, ctx, errmsg = None): + + msg = 'Error while ' + ctx['action'] + if errmsg: + msg += ': ' + errmsg + + ex, value, trace = sys.exc_info() + + if ex != None: + msg += ": " + str(value) + + if ctx.get('url'): + msg += ', server="%s"' % ctx['url'] + + if ctx.get('user'): + msg += ', login="%s"' % ctx['user'] + + self.log_error(msg) + self.send_response(403) + self.end_headers() + + def get_params(self): + return {} + + def log_message(self, format, *args): + if len(self.client_address) > 0: + addr = BaseHTTPRequestHandler.address_string(self) + else: + addr = "-" + + sys.stdout.write("%s - %s [%s] %s\n" % (addr, self.ctx['user'], + self.log_date_time_string(), format % args)) + + def log_error(self, format, *args): + self.log_message(format, *args) + + +# verifies user/password against LDAP server +class LDAPAuthHandler(AuthHandler): + + # Parameters to put into self.ctx from the HTTP header of auth request + def get_params(self): + return { + # parameter header default + 'realm': ('X-Ldap-Realm', 'Restricted'), + 'url': ('X-Ldap-URL', None), + 'basedn': ('X-Ldap-BaseDN', None), + 'template': ('X-Ldap-Template', '(cn=%(username)s)'), + 'binddn': ('X-Ldap-BindDN', 'cn=anonymous'), + 'bindpasswd': ('X-Ldap-BindPass', ''), + 'cookiename': ('X-CookieName', '') + } + + # GET handler for the authentication request + def do_GET(self): + + ctx = dict() + self.ctx = ctx + + ctx['action'] = 'initializing basic auth handler' + ctx['user'] = '-' + + if AuthHandler.do_GET(self): + # request already processed + return + + ctx['action'] = 'empty password check' + if not ctx['pass']: + self.auth_failed(ctx, 'attempt to use empty password') + return + + try: + ctx['action'] = 'initializing LDAP connection' + ldap_obj = ldap.initialize(ctx['url']); + + # 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 + self.send_response(200) + self.end_headers() + + except: + self.auth_failed(ctx) + +def exit_handler(signal, frame): + global Listen + + if isinstance(Listen, basestring): + try: + os.unlink(Listen) + except: + ex, value, trace = sys.exc_info() + sys.stderr.write('Failed to remove socket "%s": %s\n' % + (Listen, str(value))) + sys.exit(0) + +if __name__ == '__main__': + server = AuthHTTPServer(Listen, LDAPAuthHandler) + signal.signal(signal.SIGINT, exit_handler) + server.serve_forever() diff --git a/nginx-ldap-auth.conf b/nginx-ldap-auth.conf new file mode 100644 index 0000000..2e2aa4c --- /dev/null +++ b/nginx-ldap-auth.conf @@ -0,0 +1,70 @@ +error_log logs/error.log debug; + +events { } + +http { + + proxy_cache_path cache/ keys_zone=auth_cache:10m; + + upstream backend { + server 127.0.0.1:9000; + } + + server { + + listen 127.0.0.1:8080; + + server_name localhost; + + location / { + auth_request /auth-proxy; + + # redirect 401 and 403 to login form + error_page 401 =200 /login; + error_page 403 =200 /login; + + proxy_pass http://backend/; + } + + location /login { + proxy_pass http://backend/login; + # login service will return a redirect for user to original URI + # and set cookie for auth daemon + proxy_set_header X-TARGET $request_uri; + } + + location = /auth-proxy { + internal; + # authorization daemon listens here + proxy_pass http://127.0.0.1:8888; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + #proxy_set_header X-Ldap-URL "ldaps://example.com:636"; + #proxy_set_header X-Ldap-BaseDN "ou=Users,dc=test,dc=local"; + + # user to search in directory, default is 'cn=anonymous' + #proxy_set_header X-Ldap-BindDN "cn=root,dc=test,dc=local"; + # and password, default is no password + #proxy_set_header X-Ldap-BindPass "secret"; + + # Template to search for users: 'username' will be replaced + # default is for OpenLDAP: + # proxy_set_header X-Ldap-Template "(cn=%(username)s)"; + # this one works for MS Active Directory + # proxy_set_header X-Ldap-Template "(SAMAccountName=%(username)s)"; + + # realm to present during basic auth, default is 'Restricted' + #proxy_set_header X-Ldap-Realm "PrivateArea"; + + # if form is used, pass cookie and its name + proxy_set_header X-CookieName "nginxauth"; + proxy_set_header Cookie nginxauth=$cookie_nginxauth; + + proxy_cache auth_cache; + # note that cookie is added to cache key + #proxy_cache_key "$http_authorization$cookie_nginxauth"; + #proxy_cache_valid 200 403 10m; + } + } +}