Index: 0.10/authform/web_ui.py
===================================================================
--- 0.10/authform/web_ui.py	(revision 0)
+++ 0.10/authform/web_ui.py	(revision 0)
@@ -0,0 +1,400 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2005 Edgewall Software
+# Copyright (C) 2003-2005 Jonas Borgstrom <jonas@edgewall.com>
+# Copyright (C) 2006 Andrew Deason <adeason@tjhsst.edu>
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+#
+# Author: Jonas Borgstrom <jonas@edgewall.com>
+# Author: Andrew Deason <adeason@tjhsst.edu>
+
+try:
+    from base64 import b64decode
+except ImportError:
+    from base64 import decodestring as b64decode
+import md5
+import re
+import sys
+import time
+import urllib2
+import base64
+
+from trac.config import BoolOption
+from trac.core import *
+from trac.web.api import IAuthenticator, IRequestHandler
+from trac.web.chrome import INavigationContributor, ITemplateProvider
+from trac.util import hex_entropy, md5crypt
+from trac.util.markup import escape, html
+
+
+class LoginFormModule(Component):
+    """Implements user authentication based on HTTP authentication provided by
+    the web-server, combined with cookies for communicating the login
+    information across the whole site. This module makes an internal request to
+    the webserver using HTTP authentication, instead of using HTTP
+    authentication directly from the user, so the user has the ability to
+    logout.
+
+    This mechanism expects that the web-server is setup so that a request to the
+    path '/login' requires authentication (just Basic authentication for now).
+    When a user attempts to login, this module attempts to use that information
+    to authenticate to the page https://trac.company.com/login, using HTTP
+    authentication. If it is successful, a 'trac_auth' cookie is stored in the
+    user's browser. This cookie is used to identify the user in subsequent
+    requests, until it is destroyed when the user logs out.
+    """
+
+    implements(IAuthenticator, INavigationContributor, IRequestHandler, ITemplateProvider)
+
+    check_ip = BoolOption('trac', 'check_auth_ip', 'true',
+         """Whether the IP address of the user should be checked for
+         authentication (''since 0.9'').""")
+
+    ignore_case = BoolOption('trac', 'ignore_auth_case', 'false',
+        """Whether case should be ignored for login names (''since 0.9'').""")
+
+    # IAuthenticator methods
+
+    def authenticate(self, req):
+        authname = None
+        if req.incookie.has_key('trac_auth'):
+            authname = self._get_name_for_cookie(req, req.incookie['trac_auth'])
+
+        if not authname:
+            return None
+
+        if self.ignore_case:
+            authname = authname.lower()
+
+        return authname
+
+    # INavigationContributor methods
+
+    def get_active_navigation_item(self, req):
+        return 'login'
+
+    def get_navigation_items(self, req):
+        if req.authname and req.authname != 'anonymous':
+            yield ('metanav', 'login', 'logged in as %s' % req.authname)
+            yield ('metanav', 'logout',
+                   html.A('Logout', href=req.href.logout()))
+        else:
+            yield ('metanav', 'login',
+                   html.A('Login', href=req.href.login()))
+
+    # IRequestHandler methods
+
+    def match_request(self, req):
+        return re.match('/(login|logout)/?', req.path_info)
+
+    def process_request(self, req):
+        if req.path_info.startswith('/login'):
+            if not self._do_login(req):
+                return 'authform.cs', None
+        elif req.path_info.startswith('/logout'):
+            self._do_logout(req)
+        self._redirect_back(req)
+
+    # ITemplateProvider methods
+    def get_templates_dirs(self):
+        """
+        Return the absolute path of the directory containing the provided
+        ClearSilver templates.
+        """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+    def get_htdocs_dirs(self):
+        """
+        Return a list of directories with static resources (such as style
+        sheets, images, etc.)
+
+        Each item in the list must be a `(prefix, abspath)` tuple. The
+        `prefix` part defines the path in the URL that requests to these
+        resources are prefixed with.
+        
+        The `abspath` is the absolute path to the directory containing the
+        resources on the local file system.
+        """
+        return []
+
+    # Internal methods
+
+    def _do_login(self, req):
+        """Log the remote user in.
+	
+	This function displays a form to the user to log themselves in, and
+	verifies the information when the user submits that form. If the
+	authentication is successful, the user name is inserted into the
+	`auth_cookie` table and a cookie identifying the user on subsequent
+	requests is sent back to the client.
+
+        If the Authenticator was created with `ignore_case` set to true, then 
+        the authentication name passed from the web form 'username' variable
+        will be converted to lower case before being used. This is to avoid
+        problems on installations authenticating against Windows which is not
+        case sensitive regarding user names and domain names
+        """
+
+	if req.args.get('username'):
+		assert req.args.get('password'), 'No password'
+
+		base = req.base_url[:req.base_url.find('/',8)]
+		login_path = self.env.config.get('authform','login_path',None)
+		if not login_path:
+			raise TracError, 'AuthForm login path not configured'
+
+		# Test authentication
+
+		try:
+			self._try_http_auth(
+				base + login_path,
+				req.args.get('username'),
+				req.args.get('password')
+			)
+		except urllib2.HTTPError, e:
+			if e.code == 404:
+				# Correct password, it's just that the login URL doesn't
+				# actually exist, no problem
+				pass
+			else:
+				# Incorrect password
+				req.hdf['title'] = 'Login Failed'
+				req.hdf['login.action'] = self.env.href() + '/login'
+				req.hdf['login.referer'] = req.args.get('ref')
+				req.hdf['login.error'] = 'Invalid username or password'
+				return None
+
+		# Successful authentication, set cookies and stuff
+	        remote_user = req.args.get('username')
+	        if self.ignore_case:
+	            remote_user = remote_user.lower()
+	
+	        assert req.authname in ('anonymous', remote_user), \
+	               'Already logged in as %s.' % req.authname
+	
+	        cookie = hex_entropy()
+	        db = self.env.get_db_cnx()
+	        cursor = db.cursor()
+	        cursor.execute("INSERT INTO auth_cookie (cookie,name,ipnr,time) "
+	                       "VALUES (%s, %s, %s, %s)", (cookie, remote_user,
+	                       req.remote_addr, int(time.time())))
+	        db.commit()
+	
+	        req.authname = remote_user
+	        req.outcookie['trac_auth'] = cookie
+	        req.outcookie['trac_auth']['path'] = self.env.href()
+        	req.redirect(req.args.get('ref') or self.env.abs_href())
+	else:
+		# No authentication information passed, display a form
+		req.hdf['title'] = 'Login'
+		req.hdf['login.action'] = self.env.href() + '/login'
+		req.hdf['login.referer'] = req.get_header('Referer')
+		return None
+
+    def _do_logout(self, req):
+        """Log the user out.
+
+        Simply deletes the corresponding record from the auth_cookie table.
+        """
+        if req.authname == 'anonymous':
+            # Not logged in
+            return
+
+        # While deleting this cookie we also take the opportunity to delete
+        # cookies older than 10 days
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("DELETE FROM auth_cookie WHERE name=%s OR time < %s",
+                       (req.authname, int(time.time()) - 86400 * 10))
+        db.commit()
+        self._expire_cookie(req)
+
+    def _try_http_auth(self, uri, user, passw):
+        authreq = urllib2.Request(uri)
+        base64string = base64.encodestring('%s:%s' % (user, passw))[:-1]
+	authheader = "Basic %s" % base64string
+	authreq.add_header("Authorization", authheader)
+	handle = urllib2.urlopen(authreq)
+
+    def _expire_cookie(self, req):
+        """Instruct the user agent to drop the auth cookie by setting the
+        "expires" property to a date in the past.
+        """
+        req.outcookie['trac_auth'] = ''
+        req.outcookie['trac_auth']['path'] = req.href()
+        req.outcookie['trac_auth']['expires'] = -10000
+
+    def _get_name_for_cookie(self, req, cookie):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        if self.check_ip:
+            cursor.execute("SELECT name FROM auth_cookie "
+                           "WHERE cookie=%s AND ipnr=%s",
+                           (cookie.value, req.remote_addr))
+        else:
+            cursor.execute("SELECT name FROM auth_cookie WHERE cookie=%s",
+                           (cookie.value,))
+        row = cursor.fetchone()
+        if not row:
+            # The cookie is invalid (or has been purged from the database), so
+            # tell the user agent to drop it as it is invalid
+            self._expire_cookie(req)
+            return None
+
+        return row[0]
+
+    def _redirect_back(self, req):
+        """Redirect the user back to the URL she came from."""
+        referer = req.get_header('Referer')
+        if referer and not referer.startswith(req.base_url):
+            # only redirect to referer if it is from the same site
+            referer = None
+        req.redirect(referer or req.abs_href())
+
+
+class HTTPAuthentication(object):
+
+    def do_auth(self, environ, start_response):
+        raise NotImplementedError
+
+
+class BasicAuthentication(HTTPAuthentication):
+
+    def __init__(self, htpasswd, realm):
+        self.hash = {}
+        self.realm = realm
+        try:
+            import crypt
+            self.crypt = crypt.crypt
+        except ImportError:
+            self.crypt = None
+        self.load(htpasswd)
+
+    def load(self, filename):
+        fd = open(filename, 'r')
+        for line in fd:
+            u, h = line.strip().split(':')
+            if '$' in h or self.crypt:
+                self.hash[u] = h
+            else:
+                print >>sys.stderr, 'Warning: cannot parse password for ' \
+                                    'user "%s" without the "crypt" module' % u
+
+        if self.hash == {}:
+            print >> sys.stderr, "Warning: found no users in file:", filename
+
+    def test(self, user, password):
+        the_hash = self.hash.get(user)
+        if the_hash is None:
+            return False
+
+        if not '$' in the_hash:
+            return self.crypt(password, the_hash[:2]) == the_hash
+
+        magic, salt = the_hash[1:].split('$')[:2]
+        magic = '$' + magic + '$'
+        return md5crypt(password, salt, magic) == the_hash
+
+    def do_auth(self, environ, start_response):
+        header = environ.get('HTTP_AUTHORIZATION')
+        if header and header.startswith('Basic'):
+            auth = b64decode(header[6:]).split(':')
+            if len(auth) == 2:
+                user, password = auth
+                if self.test(user, password):
+                    return user
+
+        start_response('401 Unauthorized',
+                       [('WWW-Authenticate', 'Basic realm="%s"'
+                         % self.realm)])('')
+
+
+class DigestAuthentication(HTTPAuthentication):
+    """A simple HTTP digest authentication implementation (RFC 2617)."""
+
+    MAX_NONCES = 100
+
+    def __init__(self, htdigest, realm):
+        self.active_nonces = []
+        self.hash = {}
+        self.realm = realm
+        self.load_htdigest(htdigest, realm)
+
+    def load_htdigest(self, filename, realm):
+        """Load account information from apache style htdigest files, only
+        users from the specified realm are used
+        """
+        fd = open(filename, 'r')
+        for line in fd.readlines():
+            u, r, a1 = line.strip().split(':')
+            if r == realm:
+                self.hash[u] = a1
+        if self.hash == {}:
+            print >> sys.stderr, "Warning: found no users in realm:", realm
+        
+    def parse_auth_header(self, authorization):
+        values = {}
+        for value in urllib2.parse_http_list(authorization):
+            n, v = value.split('=', 1)
+            if v[0] == '"' and v[-1] == '"':
+                values[n] = v[1:-1]
+            else:
+                values[n] = v
+        return values
+
+    def send_auth_request(self, environ, start_response, stale='false'):
+        """Send a digest challange to the browser. Record used nonces
+        to avoid replay attacks.
+        """
+        nonce = hex_entropy()
+        self.active_nonces.append(nonce)
+        if len(self.active_nonces) > self.MAX_NONCES:
+            self.active_nonces = self.active_nonces[-self.MAX_NONCES:]
+        start_response('401 Unauthorized',
+                       [('WWW-Authenticate',
+                        'Digest realm="%s", nonce="%s", qop="auth", stale="%s"'
+                        % (self.realm, nonce, stale))])('')
+
+    def do_auth(self, environ, start_response):
+        header = environ.get('HTTP_AUTHORIZATION')
+        if not header or not header.startswith('Digest'):
+            self.send_auth_request(environ, start_response)
+            return None
+
+        auth = self.parse_auth_header(header[7:])
+        required_keys = ['username', 'realm', 'nonce', 'uri', 'response',
+                         'nc', 'cnonce']
+        # Invalid response?
+        for key in required_keys:
+            if not auth.has_key(key):
+                self.send_auth_request(environ, start_response)
+                return None
+        # Unknown user?
+        if not self.hash.has_key(auth['username']):
+            self.send_auth_request(environ, start_response)
+            return None
+
+        kd = lambda x: md5.md5(':'.join(x)).hexdigest()
+        a1 = self.hash[auth['username']]
+        a2 = kd([environ['REQUEST_METHOD'], auth['uri']])
+        # Is the response correct?
+        correct = kd([a1, auth['nonce'], auth['nc'],
+                      auth['cnonce'], auth['qop'], a2])
+        if auth['response'] != correct:
+            self.send_auth_request(environ, start_response)
+            return None
+        # Is the nonce active, if not ask the client to use a new one
+        if not auth['nonce'] in self.active_nonces:
+            self.send_auth_request(environ, start_response, stale='true')
+            return None
+        self.active_nonces.remove(auth['nonce'])
+        return auth['username']
Index: 0.10/authform/__init__.py
===================================================================
--- 0.10/authform/__init__.py	(revision 0)
+++ 0.10/authform/__init__.py	(revision 0)
@@ -0,0 +1 @@
+from authform.web_ui import *
Index: 0.10/authform/templates/authform.cs
===================================================================
--- 0.10/authform/templates/authform.cs	(revision 0)
+++ 0.10/authform/templates/authform.cs	(revision 0)
@@ -0,0 +1,29 @@
+<?cs include "header.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="login">
+	<?cs if:login.error ?>
+		<p class="message">
+		<?cs var:login.error ?>
+		</p>
+	<?cs /if ?>
+	<form id="login" action="<?cs var:login.action ?>" method="POST">
+	<input type="hidden" name="ref" value="<?cs var:login.referer ?>" />
+	<fieldset id="properties">
+		<legend>Enter your username and password</legend>
+		<table><tr>
+			<th class="col1"><label for="username">Username:</label></th>
+			
+			<td><input type="text" name="username" id="username" class="textwidget" /></td></tr>
+			<tr><th class="col1"><label for="password">Password:</label></th>
+			<td><input type="password" name="password" id="password" class="textwidget" /></td></tr>
+		</table>
+	</fieldset>
+	<div class="buttons">
+		<input type="submit" name="login" value="Login" />&nbsp;
+		<input type="reset" name="reset" value="Clear" />
+	</div>
+	</form>
+</div>
+<?cs include "footer.cs"?>
Index: 0.10/setup.py
===================================================================
--- 0.10/setup.py	(revision 0)
+++ 0.10/setup.py	(revision 0)
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+
+from setuptools import setup
+
+setup(
+    name = 'AuthForm',
+    version = '0.1',
+    packages = ['authform'],
+    package_data = { 'authform': ['templates/*.cs' ] },
+
+    author = "Noah Kantrowitz",
+    author_email = "coderanger@yahoo.com",
+    description = "Implements form-based login against HTTP authentication",
+    license = "BSD",
+    keywords = "trac authentication form",
+    url = "http://trac-hacks.org/",
+
+    entry_points = {
+        'trac.plugins': [
+            'authform.web_ui = authform.web_ui'
+        ]
+    }
+)

Property changes on: 0.10/setup.py
___________________________________________________________________
Name: svn:executable
   + *

