[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