[Checkins] SVN: cipher.googlepam/trunk/ Initial import of the cipher.googlepam code.

Stephen Richter cvs-admin at zope.org
Tue Apr 24 17:26:53 UTC 2012


Log message for revision 125276:
  Initial import of the cipher.googlepam code.
  
  

Changed:
  A   cipher.googlepam/trunk/
  A   cipher.googlepam/trunk/CHANGES.txt
  A   cipher.googlepam/trunk/README.txt
  A   cipher.googlepam/trunk/bootstrap.py
  A   cipher.googlepam/trunk/buildout.cfg
  A   cipher.googlepam/trunk/setup.py
  A   cipher.googlepam/trunk/src/
  A   cipher.googlepam/trunk/src/cipher/
  A   cipher.googlepam/trunk/src/cipher/__init__.py
  A   cipher.googlepam/trunk/src/cipher/googlepam/
  A   cipher.googlepam/trunk/src/cipher/googlepam/__init__.py
  A   cipher.googlepam/trunk/src/cipher/googlepam/addusers.py
  A   cipher.googlepam/trunk/src/cipher/googlepam/googlepam.conf
  A   cipher.googlepam/trunk/src/cipher/googlepam/pam_google.py
  A   cipher.googlepam/trunk/src/cipher/googlepam/tests/
  A   cipher.googlepam/trunk/src/cipher/googlepam/tests/__init__.py
  A   cipher.googlepam/trunk/src/cipher/googlepam/tests/file-cache.conf
  A   cipher.googlepam/trunk/src/cipher/googlepam/tests/mem-cache.conf
  A   cipher.googlepam/trunk/src/cipher/googlepam/tests/test_doc.py
  A   cipher.googlepam/trunk/versions.cfg

-=-
Added: cipher.googlepam/trunk/CHANGES.txt
===================================================================
--- cipher.googlepam/trunk/CHANGES.txt	                        (rev 0)
+++ cipher.googlepam/trunk/CHANGES.txt	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,38 @@
+=======
+CHANGES
+=======
+
+1.3.0 (2012-04-24)
+------------------
+
+- Added ability to cache authentication result, since some uses, such as
+  Apache authentication can cause a lot of requests. File- and
+  memcached-based caches have been implemented and are available/configurable
+  in the configuration file.
+
+- Fully stubbed out the Google API for faster and simpler testing.
+
+- Removed all traces of Cipher's specific account details.
+
+- Changed all headers to ZPL.
+
+- The package is ready for public release.
+
+1.2.0 (2012-04-17)
+------------------
+
+- Do not fail. if the username already exists.
+
+1.1.0 (2012-04-17)
+------------------
+
+- Make the admin group configurable.
+
+
+1.0.0 (2012-04-17)
+------------------
+
+- PAM module authenticating against users in a group of a particular Google
+  domain.
+
+- Script to add all users of a group within a Google domain as system users.


Property changes on: cipher.googlepam/trunk/CHANGES.txt
___________________________________________________________________
Added: svn:eol-style
   + native

Added: cipher.googlepam/trunk/README.txt
===================================================================
--- cipher.googlepam/trunk/README.txt	                        (rev 0)
+++ cipher.googlepam/trunk/README.txt	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,102 @@
+=================
+Google PAM Module
+=================
+
+This package implements a Python PAM module to authenticate users against a
+Google domain. The following features are provided:
+
+- Select any Google domain.
+
+- Allow only users from a certain group.
+
+- A script to install all Google users as system users.
+
+- Password caching using files or memcached.
+
+- Advanced logging setup.
+
+The code was inspired by the ``python_pam.so`` examples and the
+``TracGoogleAppsAuthPlugin`` trac authentication plugin.
+
+
+Configuring Google PAM on Ubuntu 12.04 LTS
+==========================================
+
+1. Install a few required packages::
+
+     # apt-get install python-setuptools python-gdata python-bcrypt \
+                       python-memcache libpam-python
+
+2. Now install ``cipher.googlepam`` using easy install::
+
+     # easy_install cipher.googlepam
+
+3. Add all users to the system::
+
+     # add-google-users -v -d <domain> -u <admin-user> -p <admin-pwd> \
+                        -g <google-group> -a <system-admin-group>
+
+   Note: Use the ``-h`` option to discover all options.
+
+4. Create a ``/etc/pam_google.conf`` configuration file::
+
+     [googlepam]
+     domain=<domain>
+     admin-username=<admin-user>
+     admin-password=<admin-pwd>
+     group=<google-group>
+     excludes = root [<user> ...]
+     prompt = Google Password:
+     cache = file|memcache
+
+     [file-cache]
+     file = /var/lib/pam_google/user-cache
+     lifespan = 1800
+
+     [memcache-cache]
+     key-prefix = googlepam.
+     host = 127.0.0.1
+     port = 11211
+     debug = false
+     lifespan = 1800
+
+     [loggers]
+     keys = root, pam
+
+     [logger_root]
+     handlers = file
+     level = INFO
+
+     [logger_pam]
+     qualname = cipher.googlepam.PAM
+     handlers = file
+     propagate = 0
+     level = INFO
+
+     [handlers]
+     keys = file
+
+     [handler_file]
+     class = logging.handlers.RotatingFileHandler
+     args = ('/var/log/pam-google.log', 'a', 10*1024*1024, 5)
+     formatter = simple
+
+     [formatters]
+     keys = simple
+
+     [formatter_simple]
+     format = %(asctime)s %(levelname)s - %(message)s
+     datefmt = %Y-%m-%dT%H:%M:%S
+
+5. Hide contents of the config file from the curious users::
+
+     root# chmod 600 /etc/pam_google.conf
+
+6. Put the Google PAM module in a sensible location::
+
+     root# ln -s /usr/local/lib/python2.7/dist-packages/cipher.googlepam-<version>-py2.7.egg/cipher/googlepam/pam_google.py /lib/security/pam_google.py
+
+7. Enable pam_google for all authentication. Add the following rule as the
+   first rule in file ``/etc/pam.d/common-auth``::
+
+     auth    sufficient   pam_python.so /lib/security/pam_google.py -c /etc/pam_google.conf


Property changes on: cipher.googlepam/trunk/README.txt
___________________________________________________________________
Added: svn:eol-style
   + native

Added: cipher.googlepam/trunk/bootstrap.py
===================================================================
--- cipher.googlepam/trunk/bootstrap.py	                        (rev 0)
+++ cipher.googlepam/trunk/bootstrap.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,68 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Bootstrap a buildout-based project
+
+Simply run this script in a directory containing a buildout.cfg.
+The script accepts buildout command-line options, so you can
+use the -c option to specify an alternate configuration file.
+
+$Id$
+"""
+
+import os, shutil, sys, tempfile, urllib2
+
+tmpeggs = tempfile.mkdtemp()
+
+ez = {}
+exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py'
+                     ).read() in ez
+ez['use_setuptools'](to_dir=tmpeggs, download_delay=0)
+
+import pkg_resources
+
+is_jython = sys.platform.startswith('java')
+
+if is_jython:
+    import subprocess
+
+cmd = 'from setuptools.command.easy_install import main; main()'
+if sys.platform == 'win32':
+    cmd = '"%s"' % cmd # work around spawn lamosity on windows
+
+ws = pkg_resources.working_set
+
+if is_jython:
+    assert subprocess.Popen([sys.executable] + ['-c', cmd, '-mqNxd', tmpeggs,
+    'zc.buildout'],
+    env = dict(os.environ,
+          PYTHONPATH=
+          ws.find(pkg_resources.Requirement.parse('setuptools')).location
+          ),
+    ).wait() == 0
+
+else:
+    assert os.spawnle(
+        os.P_WAIT, sys.executable, sys.executable,
+        '-c', cmd, '-mqNxd', tmpeggs, 'zc.buildout',
+        dict(os.environ,
+            PYTHONPATH=
+            ws.find(pkg_resources.Requirement.parse('setuptools')).location
+            ),
+        ) == 0
+
+ws.add_entry(tmpeggs)
+ws.require('zc.buildout')
+import zc.buildout.buildout
+zc.buildout.buildout.main(sys.argv[1:] + ['bootstrap'])
+shutil.rmtree(tmpeggs)


Property changes on: cipher.googlepam/trunk/bootstrap.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/buildout.cfg
===================================================================
--- cipher.googlepam/trunk/buildout.cfg	                        (rev 0)
+++ cipher.googlepam/trunk/buildout.cfg	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,25 @@
+[buildout]
+extends = versions.cfg
+develop = .
+parts = test python addusers
+extensions = buildout-versions
+buildout_versions_file = versions.cfg
+newest = false
+include-site-packages = false
+unzip = true
+versions = versions
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = cipher.googlepam [test]
+defaults = ['--tests-pattern', '^f?tests$$', '-v']
+
+[python]
+recipe = zc.recipe.egg
+eggs = cipher.googlepam
+interpreter = py
+
+[addusers]
+recipe = zc.recipe.egg:scripts
+eggs = cipher.googlepam
+scripts = add-google-users

Added: cipher.googlepam/trunk/setup.py
===================================================================
--- cipher.googlepam/trunk/setup.py	                        (rev 0)
+++ cipher.googlepam/trunk/setup.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,62 @@
+##############################################################################
+#
+# Copyright (c) 2012 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Package Setup
+"""
+import os
+from setuptools import setup, find_packages
+
+def read(*rnames):
+    text = open(os.path.join(os.path.dirname(__file__), *rnames)).read()
+    return unicode(text, 'utf-8').encode('ascii', 'xmlcharrefreplace')
+
+setup(
+    name='cipher.googlepam',
+    version='1.3.0',
+    description='Google PAM Module',
+    long_description=(
+        read('README.txt')
+        + '\n\n' +
+        + read('CHANGES.txt')),
+    classifiers=[
+      "Development Status :: 4 - Beta",
+      "Programming Language :: Python",
+      "Topic :: Internet",
+      "Topic :: Security",
+      "Topic :: System :: Systems Administration :: Authentication/Directory"
+      ],
+    author='Stephan Richter',
+    author_email = "stephan.richter at gmail.com",
+    url='http://pypi.python.org/pypi/cipher.googlepam',
+    keywords='pam google',
+    packages = find_packages('src'),
+    package_dir = {'':'src'},
+    namespace_packages = ['cipher'],
+    include_package_data=True,
+    zip_safe=False,
+    extras_require = dict(
+        test = (
+            'zope.testing',
+            ),
+        ),
+    install_requires=[
+          'gdata',
+          'py-bcrypt',
+          'python-memcached',
+          'setuptools',
+          ],
+    entry_points = """
+    [console_scripts]
+    add-google-users=cipher.googlepam.addusers:main
+    """
+    )


Property changes on: cipher.googlepam/trunk/setup.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/__init__.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/__init__.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/__init__.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,2 @@
+import pkg_resources
+pkg_resources.declare_namespace(__name__)


Property changes on: cipher.googlepam/trunk/src/cipher/__init__.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/googlepam/__init__.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/__init__.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/__init__.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1 @@
+# Make a package.


Property changes on: cipher.googlepam/trunk/src/cipher/googlepam/__init__.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/googlepam/addusers.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/addusers.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/addusers.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,164 @@
+##############################################################################
+#
+# Copyright (c) 2012 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Add Google users to system."""
+import logging
+import optparse
+import subprocess
+import sys
+
+from gdata.apps.groups.service import GroupsService
+from gdata.apps.service import AppsService, AppsForYourDomainException
+from gdata.service import BadAuthentication, CaptchaRequired
+
+parser = optparse.OptionParser()
+parser.usage = '%prog [options]'
+
+log = logging.getLogger("add-google-users")
+
+ADDUSER_CMD = ('adduser --firstuid 2000 --disabled-password '
+               '--gecos "%(full_name)s" %(user_name)s')
+ADDADMIN_CMD = 'usermod -a -G %(admin-group)s %(user_name)s'
+
+class CMDError(Exception):
+    pass
+
+def do(cmd, cwd=None, capture_output=True, dry_run=False):
+    if capture_output:
+        stdout = stderr = subprocess.PIPE
+    else:
+        stdout = stderr = None
+    log.debug('Starting: %s' %cmd)
+    if dry_run:
+        return
+    p = subprocess.Popen(
+        cmd, stdout=stdout, stderr=stderr,
+        shell=True, cwd=cwd)
+    stdout, stderr = p.communicate()
+    if stdout is None:
+        stdout = "See output above"
+    if stderr is None:
+        stderr = "See output above"
+    if p.returncode != 0:
+        log.error(u'An error occurred while running command: %s' %cmd)
+        log.error('Error Output: \n%s' % stderr)
+        raise CMDError(p.returncode, stdout+'\n'+stderr)
+
+    log.debug('Result:\n%s' %stdout)
+    return stdout
+
+def setupLogging(level=logging.INFO):
+    log.setLevel(level)
+
+    handler = logging.StreamHandler(sys.stdout)
+    formatter = logging.Formatter('%(levelname)s - %(message)s')
+    handler.setFormatter(formatter)
+    log.addHandler(handler)
+
+def addusers(options):
+    # 1. Get a full list of all users to be added.
+    log.info('Getting members of group: %s', options.group)
+    groups_srv = GroupsService(
+        domain=options.domain,
+        email=options.user+'@'+options.domain,
+        password=options.password
+        )
+    groups_srv.ProgrammaticLogin()
+    members_feed = groups_srv.RetrieveAllMembers(options.group, False)
+    emails = [user_dict['memberId']
+             for user_dict in members_feed]
+    log.info('Found members: %s',
+             ', '.join(email.split('@')[0] for email in emails))
+    # 2. Now we get all the meta-data associated with the user.
+    apps_srv = AppsService(
+        domain=options.domain,
+        email=options.user+'@'+options.domain,
+        password=options.password
+        )
+    apps_srv.ProgrammaticLogin()
+    users = []
+    for email in emails:
+        entry = apps_srv.RetrieveUser(email.split('@')[0])
+        users.append({
+            'full_name': '%s %s' %(entry.name.given_name,
+                                   entry.name.family_name),
+            'user_name': entry.login.user_name,
+            'admin-group': options.admin_group,
+            })
+        log.debug('Found user data: %r', users[-1])
+    # 3. Create a new user account for each account.
+    for user in users:
+        try:
+            do(options.command %user, dry_run=options.dry_run)
+        except CMDError, err:
+            # We do not want to fail, if the user already exists.
+            if err.args[0] != 1:
+                raise
+        do(ADDADMIN_CMD %user, dry_run=options.dry_run)
+
+parser.add_option(
+    '-d', '--domain', action='store', dest='domain',
+    help='The Google domain in which the users belong.')
+
+parser.add_option(
+    '-u', '--admin-user', action='store', dest='user',
+    help='The username of the Google admin user.')
+
+parser.add_option(
+    '-p', '--admin-password', action='store', dest='password',
+    help='The password of the Google admin user.')
+
+parser.add_option(
+    '-g', '--group', action='store',
+    dest='group', default='security',
+    help='The group all users belong to.')
+
+parser.add_option(
+    '-a', '--admin-group', action='store',
+    dest='admin_group', default='admin',
+    help='The group to which the user will be added.')
+
+parser.add_option(
+    '-c', '--command', action='store',
+    dest='command', default=ADDUSER_CMD,
+    help='The command used to create the user.')
+
+parser.add_option(
+    '--dry-run', action='store_true',
+    dest='dry_run', default=False,
+    help='A flag, when set, does not execute commands.')
+
+parser.add_option(
+    "-q","--quiet", action="store_true",
+    dest="quiet", default=False,
+    help="When specified, no messages are displayed.")
+
+parser.add_option(
+    "-v","--verbose", action="store_true",
+    dest="verbose", default=False,
+    help="When specified, debug information is created.")
+
+def main(args=None):
+    if args is None:
+        args = sys.argv[1:]
+
+    options, args = parser.parse_args(args)
+
+    # Set up logging.
+    setupLogging()
+    if options.verbose:
+        log.setLevel(logging.DEBUG)
+    if options.quiet:
+        log.setLevel(logging.FATAL)
+
+    addusers(options)


Property changes on: cipher.googlepam/trunk/src/cipher/googlepam/addusers.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/googlepam/googlepam.conf
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/googlepam.conf	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/googlepam.conf	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,47 @@
+[googlepam]
+domain=example.com
+admin-username=admin
+admin-password=good-pwd
+group=group1
+excludes = root
+prompt = Cipher Password:
+#cache = memcache
+
+[file-cache]
+file = user-cache
+lifespan = 1800
+
+[memcache-cache]
+key-prefix = googlepam.
+host = 127.0.0.1
+port = 11211
+debug = true
+lifespan = 1800
+
+[loggers]
+keys = root, pam
+
+[logger_root]
+handlers = stdout
+level = INFO
+
+[logger_pam]
+qualname = cipher.googlepam.PAM
+handlers = stdout
+propagate = 0
+level = INFO
+
+[handlers]
+keys = stdout
+
+[handler_stdout]
+class = StreamHandler
+args = (sys.stdout,)
+level = INFO
+formatter = simple
+
+[formatters]
+keys = simple
+
+[formatter_simple]
+format = %(levelname)s - %(message)s

Added: cipher.googlepam/trunk/src/cipher/googlepam/pam_google.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/pam_google.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/pam_google.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,315 @@
+##############################################################################
+#
+# Copyright (c) 2012 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Google PAM Module
+
+# here are the per-package modules (the "Primary" block)
+auth    [success=1 default=ignore]      pam_python.so /path/to/pam_google.py
+
+"""
+import ConfigParser
+import bcrypt
+import collections
+import logging
+import logging.config
+import memcache
+import optparse
+import os
+import time
+
+from gdata.apps.groups.service import GroupsService
+from gdata.apps.service import AppsService, AppsForYourDomainException
+from gdata.service import BadAuthentication, CaptchaRequired
+
+DEFAULT_CONFIG = os.path.join(os.path.dirname(__file__), 'googlepam.conf')
+SECTION_NAME = 'googlepam'
+LOG = logging.getLogger('cipher.googlepam.PAM')
+
+parser = optparse.OptionParser()
+parser.usage = '%prog [options]'
+
+parser.add_option(
+    '-c', '--config-file', action='store',
+    dest='config_file', default=DEFAULT_CONFIG,
+    help='The file containing all configuration.')
+
+UserInfo = collections.namedtuple('UserInfo', ['created', 'pw_hash'])
+
+
+class BaseCache(object):
+
+    SECTION_NAME = 'cache'
+
+    def __init__(self, pam):
+        self.pam = pam
+        self.lifespan = self.pam.config.getint(self.SECTION_NAME, 'lifespan')
+
+    def _get_user_info(self, username):
+        raise NotImplemented
+
+    def _add_user_info(self, username, password):
+        raise NotImplemented
+
+    def _del_user_info(self, username):
+        raise NotImplemented
+
+    def register(self, username, password):
+        LOG.debug('Register cache entry: %s', username)
+        self._add_user_info(username, password)
+
+    def authenticate(self, username, password):
+        info = self._get_user_info(username)
+        if info is None:
+            return None
+        if info.created + self.lifespan < time.time():
+            LOG.info('Deleting timed out cache entry: %s', username)
+            self._del_user_info(username)
+            return None
+        return bcrypt.hashpw(password, info.pw_hash) == info.pw_hash
+
+
+class FileCache(BaseCache):
+
+    SECTION_NAME = 'file-cache'
+
+    def __init__(self, pam):
+        super(FileCache, self).__init__(pam)
+        self._filename = self.pam.config.get(self.SECTION_NAME, 'file')
+
+    def _get_user_info(self, username):
+        if not os.path.exists(self._filename):
+            return
+        with open(self._filename, 'r') as file:
+            for line in file:
+                if line.startswith(username):
+                    username, created, pw_hash = line.strip().split('::', 2)
+                    return UserInfo(float(created), pw_hash)
+        return None
+
+    def _add_user_info(self, username, password):
+        with open(self._filename, 'a') as file:
+            file.write('%s::%f::%s\n' %(
+                    username,
+                    time.time(),
+                    bcrypt.hashpw(password, bcrypt.gensalt())
+                    ))
+
+    def _del_user_info(self, username):
+        if not os.path.exists(self._filename):
+            return
+        with open(self._filename, 'r') as file:
+            lines = [line for line in file
+                     if not line.startswith(username)]
+        with open(self._filename, 'w') as file:
+            file.writelines(lines)
+
+    def clear(self):
+        os.remove(self._filename)
+
+
+class MemcacheCache(BaseCache):
+
+    SECTION_NAME = 'memcache-cache'
+
+    def __init__(self, pam):
+        super(MemcacheCache, self).__init__(pam)
+        self._client = memcache.Client(
+                ['%s:%s' %(self.pam.config.get(self.SECTION_NAME, 'host'),
+                           self.pam.config.get(self.SECTION_NAME, 'port'))],
+                debug = self.pam.config.getboolean(self.SECTION_NAME, 'debug'))
+
+    def _get_key(self, username):
+        return self.pam.config.get(self.SECTION_NAME, 'key-prefix')
+
+    def _get_user_info(self, username):
+        return self._client.get(self._get_key(username))
+
+    def _add_user_info(self, username, password):
+        self._client.set(
+            self._get_key(username),
+            UserInfo(time.time(), bcrypt.hashpw(password, bcrypt.gensalt())))
+
+    def _del_user_info(self, username):
+        self._client.delete(self._get_key(username))
+
+
+class GooglePAM(object):
+
+    password_prompt = 'Password:'
+    _cache = None
+
+    cache_classes = {
+        'file': FileCache,
+        'memcache': MemcacheCache
+        }
+
+    # Testing Hooks
+    AppsService = AppsService
+    GroupsService = GroupsService
+
+    def __init__(self, pamh, flags, argv):
+        self.pamh = pamh
+        self.flags = flags
+        self.argv = argv
+        self.initialize()
+
+    def initialize(self):
+        self.options, self.args = parser.parse_args(self.argv[1:])
+        self.config = ConfigParser.ConfigParser()
+        self.config.xform = str
+        self.config.read(self.options.config_file)
+        if self.config.has_option(SECTION_NAME, 'prompt'):
+            self.password_prompt = self.config.get(SECTION_NAME, 'prompt')
+        logging.config.fileConfig(
+            self.options.config_file, disable_existing_loggers=False)
+        if self.config.has_option(SECTION_NAME, 'cache'):
+            klass = self.cache_classes[self.config.get(SECTION_NAME, 'cache')]
+            self._cache = klass(self)
+
+    def _get_email(self, user):
+        return user+'@'+self.config.get(SECTION_NAME, 'domain')
+
+    def authenticate(self):
+        LOG.debug('Start authentication via Google PAM: %s, %s',
+                  self.flags, self.argv)
+
+        # 0. We do not authenticate exlcuded users.
+        if self.config.has_option(SECTION_NAME, 'excludes'):
+            excluded = [
+                user.strip()
+                for user in self.config.get(SECTION_NAME, 'excludes').split(',')]
+            if self.pamh.user in excluded:
+                LOG.info('User is in excluded list: %s', self.pamh.user)
+                return self.pamh.PAM_IGNORE
+
+        # 1. Get the password.
+        if self.pamh.authtok == None:
+            LOG.debug('No auth token was found. Starting conversation.')
+            msg = self.pamh.Message(
+                self.pamh.PAM_PROMPT_ECHO_OFF, self.password_prompt)
+            response = self.pamh.conversation(msg)
+            self.pamh.authtok = response.resp
+            LOG.debug('Got password: %s', self.pamh.authtok)
+
+        # 2. If we have a cache setup, try to find the answer there first.
+        if self._cache:
+            LOG.debug('Checking authentication cache: %s', self.pamh.user)
+            auth = self._cache.authenticate(self.pamh.user, self.pamh.authtok)
+            if auth == True:
+                LOG.info(
+                    'Authentication (via cache) succeeded: %s', self.pamh.user)
+                return self.pamh.PAM_SUCCESS
+            if auth == False:
+                LOG.info(
+                    'Authentication (via cache) failed: %s', self.pamh.user)
+                return self.pamh.PAM_AUTH_ERR
+
+            LOG.debug('No entry in authentication cache: %s', self.pamh.user)
+
+        # 3. If a group has been specified, check that the user is in the
+        # group, otherwise we do not even have to proceed.
+        # Note: We could do that check before asking for the password, but
+        # then we would give away the fact that the username is incorrect.
+        if self.config.has_option(SECTION_NAME, 'group'):
+            group = self.config.get(SECTION_NAME, 'group')
+            LOG.debug('Group found: %s', group)
+            service = self.GroupsService(
+                domain=self.config.get(SECTION_NAME, 'domain'),
+                email=self._get_email(
+                    self.config.get(SECTION_NAME, 'admin-username')),
+                password=self.config.get(SECTION_NAME, 'admin-password')
+                )
+            service.ProgrammaticLogin()
+            try:
+                if not service.IsMember(self.pamh.user, group):
+                    LOG.info(
+                        'User "%s" is not a member of group "%s".',
+                        self.pamh.user, group)
+                    return self.pamh.PAM_AUTH_ERR
+            except AppsForYourDomainException, err:
+                LOG.exception('Admin user has insufficient priviledges.')
+                return self.pamh.PAM_AUTH_ERR
+            LOG.debug(
+                'User "%s" is a member of group "%s".', self.pamh.user, group)
+
+        service = self.AppsService(
+            domain=self.config.get(SECTION_NAME, 'domain'),
+            email=self._get_email(
+                self.config.get(SECTION_NAME, 'admin-username')),
+            password=self.config.get(SECTION_NAME, 'admin-password')
+            )
+
+        try:
+            service.ClientLogin(
+                self._get_email(self.pamh.user),
+                self.pamh.authtok,
+                account_type='HOSTED', source='cipher-google-pam')
+
+        except BadAuthentication, e:
+            LOG.info('Authentication failed for: %s', self.pamh.user)
+            return self.pamh.PAM_AUTH_ERR
+        except CaptchaRequired, e:
+            LOG.error('Captcha Required: %s', self.pamh.user)
+            return self.pamh.PAM_AUTH_ERR
+        except:
+            LOG.exception('Unknown Exception: %s', self.pamh.user)
+            return self.pamh.PAM_AUTH_ERR
+
+        # Store the good credentials in the cache.
+        if self._cache:
+            self._cache.register(self.pamh.user, self.pamh.authtok)
+
+        LOG.info('Authentication succeeded: %s', self.pamh.user)
+        return self.pamh.PAM_SUCCESS
+
+    def setcred(self):
+        # Always return success, since it is called upon authentication
+        # success.
+        return self.pamh.PAM_SUCCESS
+
+    def acct_mgmt(self):
+        LOG.info("`acct_mgmt` is not supported.")
+        return self.pamh.PAM_SERVICE_ERR
+
+    def chauthtok(self):
+        LOG.info("`chauthtok` is not supported.")
+        return self.pamh.PAM_SERVICE_ERR
+
+    def open_session(self):
+        LOG.info("`open_session` is not supported.")
+        return self.pamh.PAM_SERVICE_ERR
+
+    def close_session(self):
+        LOG.info("`close_session` is not supported.")
+        return self.pamh.PAM_SERVICE_ERR
+
+
+# Official pam_python API.
+
+def pam_sm_authenticate(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).authenticate()
+
+def pam_sm_setcred(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).setcred()
+
+def pam_sm_acct_mgmt(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).acct_mgmt()
+
+def pam_sm_chauthtok(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).chauthtok()
+
+def pam_sm_open_session(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).open_session()
+
+def pam_sm_close_session(pamh, flags, argv):
+    return GooglePAM(pamh, flags, argv).close_session()


Property changes on: cipher.googlepam/trunk/src/cipher/googlepam/pam_google.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/googlepam/tests/__init__.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/tests/__init__.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/tests/__init__.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1 @@
+# Make a package.


Property changes on: cipher.googlepam/trunk/src/cipher/googlepam/tests/__init__.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/src/cipher/googlepam/tests/file-cache.conf
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/tests/file-cache.conf	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/tests/file-cache.conf	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,40 @@
+[googlepam]
+domain=example.com
+admin-username=admin
+admin-password=good-pwd
+group=group1
+excludes = root
+prompt = Cipher Password:
+cache = file
+
+[file-cache]
+file = ./user-cache
+lifespan = 1800
+
+[loggers]
+keys = root, pam
+
+[logger_root]
+handlers = stdout
+level = INFO
+
+[logger_pam]
+qualname = cipher.googlepam.PAM
+handlers = stdout
+propagate = 0
+level = INFO
+
+[handlers]
+keys = stdout
+
+[handler_stdout]
+class = StreamHandler
+args = (sys.stdout,)
+level = INFO
+formatter = simple
+
+[formatters]
+keys = simple
+
+[formatter_simple]
+format = %(levelname)s - %(message)s

Added: cipher.googlepam/trunk/src/cipher/googlepam/tests/mem-cache.conf
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/tests/mem-cache.conf	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/tests/mem-cache.conf	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,43 @@
+[googlepam]
+domain=example.com
+admin-username=admin
+admin-password=good-pwd
+group=group1
+excludes = root
+prompt = Cipher Password:
+cache = memcache
+
+[memcache-cache]
+key-prefix = googlepam.
+host = 127.0.0.1
+port = 11211
+debug = true
+lifespan = 1800
+
+[loggers]
+keys = root, pam
+
+[logger_root]
+handlers = stdout
+level = INFO
+
+[logger_pam]
+qualname = cipher.googlepam.PAM
+handlers = stdout
+propagate = 0
+level = INFO
+
+[handlers]
+keys = stdout
+
+[handler_stdout]
+class = StreamHandler
+args = (sys.stdout,)
+level = INFO
+formatter = simple
+
+[formatters]
+keys = simple
+
+[formatter_simple]
+format = %(levelname)s - %(message)s

Added: cipher.googlepam/trunk/src/cipher/googlepam/tests/test_doc.py
===================================================================
--- cipher.googlepam/trunk/src/cipher/googlepam/tests/test_doc.py	                        (rev 0)
+++ cipher.googlepam/trunk/src/cipher/googlepam/tests/test_doc.py	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,344 @@
+##############################################################################
+#
+# Copyright (c) 2012 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Google PAM Tests
+"""
+import doctest
+import os
+
+from cipher.googlepam import pam_google
+from gdata.apps.service import AppsForYourDomainException
+from gdata.service import BadAuthentication, CaptchaRequired
+
+HERE = os.path.dirname(__file__)
+
+class FakePamMessage(object):
+    def __init__(self, flags, prompt):
+        pass
+
+class FakePamResponse(object):
+    def __init__(self, resp):
+        self.resp = resp
+
+class FakePamHandle(object):
+
+    PAM_SUCCESS = 0
+    PAM_SERVICE_ERR = 3
+    PAM_AUTH_ERR = 9
+    PAM_USER_UNKNOWN = 13
+    PAM_CRED_UNAVAIL = 14
+    PAM_IGNORE = 25
+
+    PAM_PROMPT_ECHO_OFF = 1
+
+    collected_authtok = 'good-pwd'
+    Message = FakePamMessage
+    Response = FakePamResponse
+
+    def __init__(self, user=None, collected_authtok=None):
+        self.user = user
+        self.authtok = None
+        if collected_authtok:
+            self.collected_authtok = collected_authtok
+
+    def conversation(self, message):
+        return self.Response(self.authtok or self.collected_authtok)
+
+class FakeAppsService(object):
+
+    def __init__(self, domain, email, password):
+        self.domain = domain
+        self.email = email
+        self.password = password
+
+    def ClientLogin(self, email, password, account_type, source):
+        if email == 'user1 at example.com' and password == 'good-pwd':
+            return
+        if email == 'user2 at example.com' and password == 'good-pwd':
+            return
+        if email == 'user3 at example.com':
+            raise CaptchaRequired()
+        if email == 'error at example.com':
+            raise ValueError(email)
+        raise BadAuthentication()
+
+class FakeGroupsService(object):
+
+    def __init__(self, domain, email, password):
+        self.domain = domain
+        self.email = email
+        self.password = password
+
+    def ProgrammaticLogin(self):
+        if self.email == 'admin at example.com' and self.password == 'good-pwd':
+            return
+        if self.email == 'shady at example.com':
+            raise CaptchaRequired()
+        raise BadAuthentication()
+
+    def IsMember(self, username, group):
+        if self.email != 'admin at example.com' or username == 'notallowed':
+            raise AppsForYourDomainException(self.email)
+        if username in ('user1', 'user3', 'error') and group == 'group1':
+            return True
+        if username in ('user1', 'user2') and group == 'group2':
+            return True
+        return False
+
+
+def doctest_pam_sm_authenticate():
+    """pam_sm_authenticate(pamh, flags, argv)
+
+    First, we succeed:
+
+      >>> pam_google.pam_sm_authenticate(
+      ...     FakePamHandle('user1'),
+      ...     0,
+      ...     ['googlepam.py'])
+      INFO - Authentication succeeded: user1
+      0
+
+    Now we have a wrong username:
+
+      >>> pam_google.pam_sm_authenticate(
+      ...     FakePamHandle('user2'),
+      ...     0,
+      ...     ['googlepam.py'])
+      INFO - User "user2" is not a member of group "group1".
+      9
+
+    Some users are excluded from Google auth, such as root:
+
+      >>> pam_google.pam_sm_authenticate(
+      ...     FakePamHandle('root'),
+      ...     0,
+      ...     ['googlepam.py'])
+      INFO - User is in excluded list: root
+      25
+    """
+
+def doctest_pam_sm_setcred():
+    """pam_sm_setcred(pamh, flags, argv)
+
+    Always succeeds:
+
+      >>> pam_google.pam_sm_setcred(FakePamHandle(), 0, ['googlepam.py'])
+      0
+    """
+
+def doctest_pam_sm_acct_mgmt():
+    """pam_sm_acct_mgmt(pamh, flags, argv)
+
+    Not supported:
+
+      >>> pam_google.pam_sm_acct_mgmt(FakePamHandle(), 0, ['googlepam.py'])
+      INFO - `acct_mgmt` is not supported.
+      3
+    """
+
+def doctest_pam_sm_chauthtok():
+    """pam_sm_chauthtok(pamh, flags, argv)
+
+    Not supported:
+
+      >>> pam_google.pam_sm_chauthtok(FakePamHandle(), 0, ['googlepam.py'])
+      INFO - `chauthtok` is not supported.
+      3
+    """
+
+def doctest_pam_sm_open_session():
+    """pam_sm_open_session(pamh, flags, argv)
+
+    Not supported:
+
+      >>> pam_google.pam_sm_open_session(FakePamHandle(), 0, ['googlepam.py'])
+      INFO - `open_session` is not supported.
+      3
+    """
+
+def doctest_pam_sm_close_session():
+    """pam_sm_close_session(pamh, flags, argv)
+
+    Not supported:
+
+      >>> pam_google.pam_sm_close_session(FakePamHandle(), 0, ['googlepam.py'])
+      INFO - `close_session` is not supported.
+      3
+    """
+
+def doctest_GooglePAM_authenticate():
+    """class GooglePAM: authenticate()
+
+      >>> pam = pam_google.GooglePAM(
+      ...     FakePamHandle(), 0,
+      ...     ['script', '-c', os.path.join(HERE, 'file-cache.conf')])
+
+    This test goes through all scenarios top to bottom.
+
+    User is in exlcudes list:
+
+      >>> pam.pamh = FakePamHandle('root', 'pwd')
+      >>> pam.authenticate()
+      INFO - User is in excluded list: root
+      25
+
+    User is in the wrong group:
+
+      >>> pam.pamh = FakePamHandle('user2', 'good-pwd')
+      >>> pam.authenticate()
+      INFO - User "user2" is not a member of group "group1".
+      9
+
+    Admin has no proper credentials to look up group info:
+
+      >>> pam.pamh = FakePamHandle('notallowed', 'good-pwd')
+      >>> pam.authenticate()
+      ERROR - Admin user has insufficient priviledges.
+      Traceback (most recent call last):
+      ...
+      AppsForYourDomainException: admin at example.com
+      9
+
+    Bad Authentication:
+
+      >>> pam.pamh = FakePamHandle('user1', 'bad-pwd')
+      >>> pam.authenticate()
+      INFO - Authentication failed for: user1
+      9
+
+    Captcha Required:
+
+      >>> pam.pamh = FakePamHandle('user3', 'bad-pwd')
+      >>> pam.authenticate()
+      ERROR - Captcha Required: user3
+      9
+
+    An arbitrary error occured:
+
+      >>> pam.pamh = FakePamHandle('error', 'bad-pwd')
+      >>> pam.authenticate()
+      ERROR - Unknown Exception: error
+      Traceback (most recent call last):
+      ...
+      ValueError: error at example.com
+      9
+
+    Successful authentication:
+
+      >>> pam.pamh = FakePamHandle('user1', 'good-pwd')
+      >>> pam.authenticate()
+      INFO - Authentication succeeded: user1
+      0
+
+    Now the cache kicks in:
+
+      >>> pam.pamh = FakePamHandle('user1', 'good-pwd')
+      >>> pam.authenticate()
+      INFO - Authentication (via cache) succeeded: user1
+      0
+
+    But even with the cache, the password is checked:
+
+      >>> pam.pamh = FakePamHandle('user1', 'bad-pwd')
+      >>> pam.authenticate()
+      INFO - Authentication (via cache) failed: user1
+      9
+
+    We are back to normal authentication, when the cache value times out:
+
+      >>> pam._cache.lifespan = 0
+      >>> pam.pamh = FakePamHandle('user1', 'good-pwd')
+      >>> pam.authenticate()
+      INFO - Deleting timed out cache entry: user1
+      INFO - Authentication succeeded: user1
+      0
+
+    Clear the file cache:
+
+      >>> pam._cache.clear()
+
+    """
+
+def doctest_FileCache():
+    """class FileCache
+
+      >>> pam = pam_google.GooglePAM(
+      ...     FakePamHandle(), 0,
+      ...     ['script', '-c', os.path.join(HERE, 'file-cache.conf')])
+
+      >>> pam._cache
+      <cipher.googlepam.pam_google.FileCache object at ...>
+
+      >>> pam._cache.authenticate('user', 'pwd')
+
+      >>> pam._cache.register('user', 'pwd')
+      >>> pam._cache.authenticate('user', 'pwd')
+      True
+      >>> pam._cache.authenticate('user', 'bad')
+      False
+
+    When the cache entry times out, the cache behaves as it has no entry:
+
+      >>> pam._cache.lifespan = 0
+      >>> pam._cache.authenticate('user', 'pwd')
+      INFO - Deleting timed out cache entry: user
+
+    We can also clear the file:
+
+      >>> pam._cache.clear()
+    """
+
+def doctest_MemcacheCache():
+    """class MemcacheCache
+
+      >>> pam = pam_google.GooglePAM(
+      ...     FakePamHandle(), 0,
+      ...     ['script', '-c', os.path.join(HERE, 'mem-cache.conf')])
+
+      >>> pam._cache
+      <cipher.googlepam.pam_google.MemcacheCache object at ...>
+
+      >>> pam._cache.authenticate('user', 'pwd')
+
+      >>> pam._cache.register('user', 'pwd')
+      >>> pam._cache.authenticate('user', 'pwd')
+      True
+      >>> pam._cache.authenticate('user', 'bad')
+      False
+
+    When the cache entry times out, the cache behaves as it has no entry:
+
+      >>> pam._cache.lifespan = 0
+      >>> pam._cache.authenticate('user', 'pwd')
+      INFO - Deleting timed out cache entry: user
+
+    """
+
+def setUp(test):
+    test.orig_AppsService = pam_google.GooglePAM.AppsService
+    pam_google.GooglePAM.AppsService = FakeAppsService
+    test.orig_GroupsService = pam_google.GooglePAM.GroupsService
+    pam_google.GooglePAM.GroupsService = FakeGroupsService
+
+def tearDown(test):
+    pam_google.GooglePAM.AppsService = test.orig_AppsService
+    pam_google.GooglePAM.GroupsService = test.orig_GroupsService
+
+def test_suite():
+    return doctest.DocTestSuite(
+        setUp=setUp, tearDown=tearDown,
+        optionflags=(doctest.NORMALIZE_WHITESPACE|
+                     doctest.ELLIPSIS|
+                     doctest.REPORT_ONLY_FIRST_FAILURE)
+        )
+


Property changes on: cipher.googlepam/trunk/src/cipher/googlepam/tests/test_doc.py
___________________________________________________________________
Added: svn:keywords
   + Id

Added: cipher.googlepam/trunk/versions.cfg
===================================================================
--- cipher.googlepam/trunk/versions.cfg	                        (rev 0)
+++ cipher.googlepam/trunk/versions.cfg	2012-04-24 17:26:49 UTC (rev 125276)
@@ -0,0 +1,163 @@
+[versions]
+
+# Added by Buildout Versions at 2012-04-17 12:26:00.938463
+buildout-versions = 1.6
+z3c.recipe.scripts = 1.0.1
+zc.recipe.egg = 1.3.2
+zc.recipe.testrunner = 1.4.0
+zope.testing = 4.1.1
+
+# Required by:
+# cipher.googlepam==0.1.0
+gdata = 2.0.15
+
+# Required by:
+# cipher.googlepam==0.1.0
+# zope.exceptions==3.7.1
+# zope.interface==3.8.0
+# zope.testrunner==4.0.4
+setuptools = 0.6c12dev-r88846
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.exceptions = 3.7.1
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.interface = 3.8.0
+
+# Required by:
+# zc.recipe.testrunner==1.4.0
+zope.testrunner = 4.0.4
+
+# Added by Buildout Versions at 2012-04-17 20:58:06.045638
+buildout-versions = 1.6
+z3c.recipe.scripts = 1.0.1
+zc.recipe.egg = 1.3.2
+zc.recipe.testrunner = 1.4.0
+zope.testing = 4.1.1
+
+# Required by:
+# cipher.googlepam==0.1.0
+gdata = 2.0.15
+
+# Required by:
+# cipher.googlepam==0.1.0
+# zope.exceptions==3.7.1
+# zope.interface==3.8.0
+# zope.testrunner==4.0.4
+setuptools = 0.6c12dev-r88846
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.exceptions = 3.7.1
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.interface = 3.8.0
+
+# Required by:
+# zc.recipe.testrunner==1.4.0
+zope.testrunner = 4.0.4
+
+# Added by Buildout Versions at 2012-04-17 20:58:18.359485
+buildout-versions = 1.6
+z3c.recipe.scripts = 1.0.1
+zc.recipe.egg = 1.3.2
+zc.recipe.testrunner = 1.4.0
+zope.testing = 4.1.1
+
+# Required by:
+# cipher.googlepam==0.1.0
+gdata = 2.0.15
+
+# Required by:
+# cipher.googlepam==0.1.0
+# zope.exceptions==3.7.1
+# zope.interface==3.8.0
+# zope.testrunner==4.0.4
+setuptools = 0.6c12dev-r88846
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.exceptions = 3.7.1
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.interface = 3.8.0
+
+# Required by:
+# zc.recipe.testrunner==1.4.0
+zope.testrunner = 4.0.4
+
+# Added by Buildout Versions at 2012-04-17 20:59:06.408096
+buildout-versions = 1.6
+z3c.recipe.scripts = 1.0.1
+zc.recipe.egg = 1.3.2
+zc.recipe.testrunner = 1.4.0
+zope.testing = 4.1.1
+
+# Required by:
+# cipher.googlepam==0.1.0
+gdata = 2.0.15
+
+# Required by:
+# cipher.googlepam==0.1.0
+# zope.exceptions==3.7.1
+# zope.interface==3.8.0
+# zope.testrunner==4.0.4
+setuptools = 0.6c12dev-r88846
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.exceptions = 3.7.1
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.interface = 3.8.0
+
+# Required by:
+# zc.recipe.testrunner==1.4.0
+zope.testrunner = 4.0.4
+
+# Added by Buildout Versions at 2012-04-17 20:59:10.489975
+buildout-versions = 1.6
+z3c.recipe.scripts = 1.0.1
+zc.recipe.egg = 1.3.2
+zc.recipe.testrunner = 1.4.0
+zope.testing = 4.1.1
+
+# Required by:
+# cipher.googlepam==0.1.0
+gdata = 2.0.15
+
+# Required by:
+# cipher.googlepam==0.1.0
+# zope.exceptions==3.7.1
+# zope.interface==3.8.0
+# zope.testrunner==4.0.4
+setuptools = 0.6c12dev-r88846
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.exceptions = 3.7.1
+
+# Required by:
+# zope.testrunner==4.0.4
+zope.interface = 3.8.0
+
+# Required by:
+# zc.recipe.testrunner==1.4.0
+zope.testrunner = 4.0.4
+
+# Added by Buildout Versions at 2012-04-24 09:24:48.313678
+
+# Required by:
+# cipher.googlepam==1.2.0
+py-bcrypt = 0.2
+
+# Added by Buildout Versions at 2012-04-24 10:43:03.372309
+
+# Required by:
+# cipher.googlepam==1.2.0
+python-memcached = 1.48



More information about the checkins mailing list