From 8e6e366aa352fe2038c1f6d6a4b1684ad9e2e842 Mon Sep 17 00:00:00 2001
From: Christos Triantafyllidis <christos.triantafyllidis@gmail.com>
Date: Feb 26 2017 15:52:59 +0000
Subject: Added PAM authentication support


---

diff --git a/hub/hub.conf b/hub/hub.conf
index 8b5dd55..7178aff 100644
--- a/hub/hub.conf
+++ b/hub/hub.conf
@@ -37,6 +37,9 @@ KojiDir = /mnt/koji
 ## end SSL client certificate auth configuration
 
 
+##  PAM auth configuration ##
+# PAMService = koji
+## end PAM auth configuration ##
 
 ##  Other options  ##
 LoginCreatesUser = On
diff --git a/hub/kojixmlrpc.py b/hub/kojixmlrpc.py
index 47c1284..7e484b8 100644
--- a/hub/kojixmlrpc.py
+++ b/hub/kojixmlrpc.py
@@ -432,6 +432,8 @@ def load_config(environ):
 
         ['CheckClientIP', 'boolean', True],
 
+        ['PAMService', 'string', None],
+
         ['LoginCreatesUser', 'boolean', True],
         ['KojiWebURL', 'string', 'http://localhost.localdomain/koji'],
         ['EmailDomain', 'string', None],
diff --git a/koji/__init__.py b/koji/__init__.py
index a8730e6..148d803 100644
--- a/koji/__init__.py
+++ b/koji/__init__.py
@@ -78,6 +78,7 @@ import xmlrpclib
 import xml.sax
 import xml.sax.handler
 from xmlrpclib import loads, dumps, Fault
+from getpass import getpass
 
 PROFILE_MODULES = {}  # {module_name: module_instance}
 
@@ -1590,7 +1591,8 @@ def read_config(profile_name, user_config=None):
         'ca': '',  # FIXME: remove in next major release
         'serverca': None,
         'no_ssl_verify': False,
-        'authtype': None
+        'authtype': None,
+        'user': None
     }
 
     result = config_defaults.copy()
@@ -2023,6 +2025,10 @@ class ClientSession(object):
         self.sinfo = sinfo
 
     def login(self, opts=None):
+        if not 'user' in self.opts:
+            self.opts['user'] = raw_input('Username: ')
+        if not 'password' in self.opts:
+            self.opts['password'] = getpass('Password: ')
         sinfo = self.callMethod('login', self.opts['user'], self.opts['password'], opts)
         if not sinfo:
             return False
diff --git a/koji/auth.py b/koji/auth.py
index ef7635f..64e1cf1 100644
--- a/koji/auth.py
+++ b/koji/auth.py
@@ -27,6 +27,10 @@ import krbV
 import koji
 import cgi      #for parse_qs
 from context import context
+try:
+    import pam
+except ImportError:  # pragma: no cover
+    pam = None
 
 # 1 - load session if provided
 #       - check uri for session id
@@ -272,13 +276,28 @@ class Session(object):
 
         # check passwd
         c = context.cnx.cursor()
-        q = """SELECT id FROM users
-        WHERE name = %(user)s AND password = %(password)s"""
-        c.execute(q, locals())
-        r = c.fetchone()
-        if not r:
-            raise koji.AuthError, 'invalid username or password'
-        user_id = r[0]
+        if context.opts.get('PAMService') and pam is not None:
+            if not pam.authenticate(user, password, context.opts.get('PAMService')):
+                raise koji.AuthError, 'invalid username or password'
+            q = """SELECT id FROM users
+            WHERE name = %(user)s"""
+            c.execute(q, locals())
+            r = c.fetchone()
+            if r:
+                user_id = r[0]
+            else:
+                if context.opts.get('LoginCreatesUser'):
+                    user_id = self.createUser(user)
+                else:
+                    raise koji.AuthError, 'Unknown user: %s' % user
+        else:
+            q = """SELECT id FROM users
+            WHERE name = %(user)s AND password = %(password)s"""
+            c.execute(q, locals())
+            r = c.fetchone()
+            if not r:
+                raise koji.AuthError, 'invalid username or password'
+            user_id = r[0]
 
         self.checkLoginAllowed(user_id)
 
diff --git a/koji/server.py b/koji/server.py
index 60b29a9..5bb236a 100644
--- a/koji/server.py
+++ b/koji/server.py
@@ -35,6 +35,8 @@ class ServerError(Exception):
 class ServerRedirect(ServerError):
     """Used to handle redirects"""
 
+class NotAuthorized(ServerError):
+    """Used to handle unauthorized"""
 
 class WSGIWrapper(object):
     """A very thin wsgi compat layer for mod_python
diff --git a/www/conf/kojiweb.conf b/www/conf/kojiweb.conf
index 807ef23..c6000a9 100644
--- a/www/conf/kojiweb.conf
+++ b/www/conf/kojiweb.conf
@@ -54,6 +54,11 @@ Alias /koji "/usr/share/koji-web/scripts/wsgi_publisher.py"
 #     SSLOptions +StdEnvVars
 # </Location>
 
+# uncomment this to enable authentication via BasicAuth
+# <Location /koji/login>
+#     WSGIPassAuthorization On
+# </Location>
+
 Alias /koji-static/ "/usr/share/koji-web/static/"
 
 <Directory "/usr/share/koji-web/static/">
diff --git a/www/conf/web.conf b/www/conf/web.conf
index 2ec0959..9fca15d 100644
--- a/www/conf/web.conf
+++ b/www/conf/web.conf
@@ -17,6 +17,9 @@ KojiFilesURL = http://server.example.com/kojifiles
 # WebCert = /etc/kojiweb/kojiweb.crt
 # KojiHubCA = /etc/kojiweb/kojihubca.crt
 
+# BasicAuth authentication options
+# BasicAuthRealm = Koji
+
 LoginTimeout = 72
 
 # This must be changed and uncommented before deployment
diff --git a/www/kojiweb/index.py b/www/kojiweb/index.py
index 7f4e4dd..60eb4be 100644
--- a/www/kojiweb/index.py
+++ b/www/kojiweb/index.py
@@ -31,7 +31,7 @@ import logging
 import time
 import koji
 import kojiweb.util
-from koji.server import ServerRedirect
+from koji.server import ServerRedirect, NotAuthorized
 from kojiweb.util import _initValues
 from kojiweb.util import _genHTML
 from kojiweb.util import _getValidTokens
@@ -251,6 +251,22 @@ def login(environ, page=None):
 
         username = principal
         authlogger.info('Successful Kerberos authentication by %s', username)
+    elif options['BasicAuthRealm']:
+        if environ['wsgi.url_scheme'] != 'https':
+            dest = 'login'
+            if page:
+                dest = dest + '?page=' + page
+            _redirectBack(environ, dest, forceSSL=True)
+            return
+
+        http_authorization = environ.get('HTTP_AUTHORIZATION')
+        if not http_authorization:
+            raise NotAuthorized
+        session.opts['user'], session.opts['password'] = http_authorization.split(' ')[1].decode('base64').split(':')
+        if not session.login():
+            raise koji.AuthError, 'could not login %s using those credentials' % http_username
+        username = session.opts['user']
+        authlogger.info('Successful BasicAuth authentication by %s', username)
     else:
         raise koji.AuthError, 'KojiWeb is incorrectly configured for authentication, contact the system administrator'
 
diff --git a/www/kojiweb/wsgi_publisher.py b/www/kojiweb/wsgi_publisher.py
index 6fd7f04..3dc77b2 100644
--- a/www/kojiweb/wsgi_publisher.py
+++ b/www/kojiweb/wsgi_publisher.py
@@ -30,7 +30,7 @@ import sys
 import traceback
 
 from ConfigParser import RawConfigParser
-from koji.server import WSGIWrapper, ServerError, ServerRedirect
+from koji.server import WSGIWrapper, ServerError, ServerRedirect, NotAuthorized
 from koji.util import dslice
 
 
@@ -80,6 +80,8 @@ class Dispatcher(object):
         ['WebCert', 'string', None],
         ['KojiHubCA', 'string', '/etc/kojiweb/kojihubca.crt'],
 
+        ['BasicAuthRealm', 'string', None],
+
         ['PythonDebug', 'boolean', False],
 
         ['LoginTimeout', 'integer', 72],
@@ -403,6 +405,10 @@ class Dispatcher(object):
             result, headers = self.error_page(environ, message=msg, err=False)
             start_response(status, headers)
             return result
+        except NotAuthorized:
+            status = "401 Not Authorized"
+            start_response(status, [('WWW-Authenticate', 'Basic realm="%s"' % self.options['BasicAuthRealm'])])
+            return '401 Not Authorized'
         except Exception:
             tb_str = ''.join(traceback.format_exception(*sys.exc_info()))
             self.logger.error(tb_str)

