Commit 1c40e3c4 authored by Jeremy Hylton's avatar Jeremy Hylton

Merge ZODB3-auth-branch and bump a few version numbers.

After the merge, I made several Python 2.1 compatibility changes for
the auth code.
parent 2f678b3e
......@@ -14,7 +14,7 @@
static char cPersistence_doc_string[] =
"Defines Persistent mixin class for persistent objects.\n"
"\n"
"$Id: cPersistence.c,v 1.71 2003/05/23 21:32:38 jeremy Exp $\n";
"$Id: cPersistence.c,v 1.72 2003/05/30 19:20:55 jeremy Exp $\n";
#include "cPersistence.h"
......
......@@ -90,7 +90,7 @@ process must skip such objects, rather than deactivating them.
static char cPickleCache_doc_string[] =
"Defines the PickleCache used by ZODB Connection objects.\n"
"\n"
"$Id: cPickleCache.c,v 1.84 2003/05/13 23:15:16 jeremy Exp $\n";
"$Id: cPickleCache.c,v 1.85 2003/05/30 19:20:55 jeremy Exp $\n";
#define ASSIGN(V,E) {PyObject *__e; __e=(E); Py_XDECREF(V); (V)=__e;}
#define UNLESS(E) if(!(E))
......
##############################################################################
#
# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
# Copyright (c) 2001, 2002, 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
......@@ -28,13 +28,14 @@ import types
from ZEO import ClientCache, ServerStub
from ZEO.TransactionBuffer import TransactionBuffer
from ZEO.Exceptions \
import ClientStorageError, UnrecognizedResult, ClientDisconnected
from ZEO.Exceptions import ClientStorageError, UnrecognizedResult, \
ClientDisconnected, AuthError
from ZEO.auth import get_module
from ZEO.zrpc.client import ConnectionManager
from ZODB import POSException
from ZODB.TimeStamp import TimeStamp
from zLOG import LOG, PROBLEM, INFO, BLATHER
from zLOG import LOG, PROBLEM, INFO, BLATHER, ERROR
def log2(type, msg, subsys="ZCS:%d" % os.getpid()):
LOG(subsys, type, msg)
......@@ -99,8 +100,8 @@ class ClientStorage:
min_disconnect_poll=5, max_disconnect_poll=300,
wait_for_server_on_startup=None, # deprecated alias for wait
wait=None, # defaults to 1
read_only=0, read_only_fallback=0):
read_only=0, read_only_fallback=0,
username='', password='', realm=None):
"""ClientStorage constructor.
This is typically invoked from a custom_zodb.py file.
......@@ -159,6 +160,17 @@ class ClientStorage:
writable storages are available. Defaults to false. At
most one of read_only and read_only_fallback should be
true.
username -- string with username to be used when authenticating.
These only need to be provided if you are connecting to an
authenticated server storage.
password -- string with plaintext password to be used
when authenticated.
Note that the authentication protocol is defined by the server
and is detected by the ClientStorage upon connecting (see
testConnection() and doAuth() for details).
"""
log2(INFO, "%s (pid=%d) created %s/%s for storage: %r" %
......@@ -217,6 +229,9 @@ class ClientStorage:
self._conn_is_read_only = 0
self._storage = storage
self._read_only_fallback = read_only_fallback
self._username = username
self._password = password
self._realm = realm
# _server_addr is used by sortKey()
self._server_addr = None
self._tfile = None
......@@ -347,6 +362,29 @@ class ClientStorage:
if cn is not None:
cn.pending()
def doAuth(self, protocol, stub):
if not (self._username and self._password):
raise AuthError, "empty username or password"
module = get_module(protocol)
if not module:
log2(PROBLEM, "%s: no such an auth protocol: %s" %
(self.__class__.__name__, protocol))
return
storage_class, client, db_class = module
if not client:
log2(PROBLEM,
"%s: %s isn't a valid protocol, must have a Client class" %
(self.__class__.__name__, protocol))
raise AuthError, "invalid protocol"
c = client(stub)
# Initiate authentication, returns boolean specifying whether OK
return c.start(self._username, self._realm, self._password)
def testConnection(self, conn):
"""Internal: test the given connection.
......@@ -372,6 +410,16 @@ class ClientStorage:
# XXX Check the protocol version here?
self._conn_is_read_only = 0
stub = self.StorageServerStubClass(conn)
auth = stub.getAuthProtocol()
log2(INFO, "Server authentication protocol %r" % auth)
if auth:
if self.doAuth(auth, stub):
log2(INFO, "Client authentication successful")
else:
log2(ERROR, "Authentication failed")
raise AuthError, "Authentication failed"
try:
stub.register(str(self._storage), self._is_read_only)
return 1
......@@ -416,14 +464,14 @@ class ClientStorage:
stub = self.StorageServerStubClass(conn)
self._oids = []
self._info.update(stub.get_info())
self._handle_extensions()
self.verify_cache(stub)
if not conn.is_async():
log2(INFO, "Waiting for cache verification to finish")
self._wait_sync()
self._handle_extensions()
def _handle_extensions(self):
for name in self.getExtensionMethods():
for name in self.getExtensionMethods().keys():
if not hasattr(self, name):
setattr(self, name, self._server.extensionMethod(name))
......
......@@ -24,3 +24,5 @@ class UnrecognizedResult(ClientStorageError):
class ClientDisconnected(ClientStorageError):
"""The database storage is disconnected from the storage."""
class AuthError(StorageError):
"""The client provided invalid authentication credentials."""
##############################################################################
#
# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
# Copyright (c) 2001, 2002, 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
......@@ -45,6 +45,9 @@ class StorageServer:
def get_info(self):
return self.rpc.call('get_info')
def getAuthProtocol(self):
return self.rpc.call('getAuthProtocol')
def lastTransaction(self):
# Not in protocol version 2.0.0; see __init__()
return self.rpc.call('lastTransaction')
......@@ -147,5 +150,6 @@ class ExtensionMethodWrapper:
def __init__(self, rpc, name):
self.rpc = rpc
self.name = name
def call(self, *a, **kwa):
return apply(self.rpc.call, (self.name,)+a, kwa)
return self.rpc.call(self.name, *a, **kwa)
##############################################################################
#
# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
# Copyright (c) 2001, 2002, 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
......@@ -35,6 +35,7 @@ from ZEO.monitor import StorageStats, StatsServer
from ZEO.zrpc.server import Dispatcher
from ZEO.zrpc.connection import ManagedServerConnection, Delay, MTDelay
from ZEO.zrpc.trigger import trigger
from ZEO.Exceptions import AuthError
import zLOG
from ZODB.ConflictResolution import ResolvedSerial
......@@ -62,10 +63,13 @@ class ZEOStorage:
"""Proxy to underlying storage for a single remote client."""
# Classes we instantiate. A subclass might override.
ClientStorageStubClass = ClientStub.ClientStorage
def __init__(self, server, read_only=0):
# A list of extension methods. A subclass with extra methods
# should override.
extensions = []
def __init__(self, server, read_only=0, auth_realm=None):
self.server = server
# timeout and stats will be initialized in register()
self.timeout = None
......@@ -79,6 +83,21 @@ class ZEOStorage:
self.locked = 0
self.verifying = 0
self.log_label = _label
self.authenticated = 0
self.auth_realm = auth_realm
# The authentication protocol may define extra methods.
self._extensions = {}
for func in self.extensions:
self._extensions[func.func_name] = None
def finish_auth(self, authenticated):
if not self.auth_realm:
return 1
self.authenticated = authenticated
return authenticated
def set_database(self, database):
self.database = database
def notifyConnected(self, conn):
self.connection = conn # For restart_other() below
......@@ -133,8 +152,10 @@ class ZEOStorage:
# can be removed
pass
else:
for name in fn().keys():
if not hasattr(self,name):
d = fn()
self._extensions.update(d)
for name in d.keys():
assert not hasattr(self, name)
setattr(self, name, getattr(self.storage, name))
self.lastTransaction = self.storage.lastTransaction
......@@ -159,11 +180,25 @@ class ZEOStorage:
return 0
return 1
def getAuthProtocol(self):
"""Return string specifying name of authentication module to use.
The module name should be auth_%s where %s is auth_protocol."""
protocol = self.server.auth_protocol
if not protocol or protocol == 'none':
return None
return protocol
def register(self, storage_id, read_only):
"""Select the storage that this client will use
This method must be the first one called by the client.
For authenticated storages this method will be called by the client
immediately after authentication is finished.
"""
if self.auth_realm and not self.authenticated:
raise AuthError, "Client was never authenticated with server!"
if self.storage is not None:
self.log("duplicate register() call")
raise ValueError, "duplicate register() call"
......@@ -199,12 +234,7 @@ class ZEOStorage:
}
def getExtensionMethods(self):
try:
e = self.storage.getExtensionMethods
except AttributeError:
return {}
else:
return e()
return self._extensions
def zeoLoad(self, oid):
self.stats.loads += 1
......@@ -579,7 +609,10 @@ class StorageServer:
def __init__(self, addr, storages, read_only=0,
invalidation_queue_size=100,
transaction_timeout=None,
monitor_address=None):
monitor_address=None,
auth_protocol=None,
auth_filename=None,
auth_realm=None):
"""StorageServer constructor.
This is typically invoked from the start.py script.
......@@ -621,6 +654,21 @@ class StorageServer:
should listen. If specified, a monitor server is started.
The monitor server provides server statistics in a simple
text format.
auth_protocol -- The name of the authentication protocol to use.
Examples are "digest" and "srp".
auth_filename -- The name of the password database filename.
It should be in a format compatible with the authentication
protocol used; for instance, "sha" and "srp" require different
formats.
Note that to implement an authentication protocol, a server
and client authentication mechanism must be implemented in a
auth_* module, which should be stored inside the "auth"
subdirectory. This module may also define a DatabaseClass
variable that should indicate what database should be used
by the authenticator.
"""
self.addr = addr
......@@ -635,6 +683,12 @@ class StorageServer:
for s in storages.values():
s._waiting = []
self.read_only = read_only
self.auth_protocol = auth_protocol
self.auth_filename = auth_filename
self.auth_realm = auth_realm
self.database = None
if auth_protocol:
self._setup_auth(auth_protocol)
# A list of at most invalidation_queue_size invalidations
self.invq = []
self.invq_bound = invalidation_queue_size
......@@ -657,6 +711,40 @@ class StorageServer:
else:
self.monitor = None
def _setup_auth(self, protocol):
# Can't be done in global scope, because of cyclic references
from ZEO.auth import get_module
name = self.__class__.__name__
module = get_module(protocol)
if not module:
log("%s: no such an auth protocol: %s" % (name, protocol))
return
storage_class, client, db_class = module
if not storage_class or not issubclass(storage_class, ZEOStorage):
log(("%s: %s isn't a valid protocol, must have a StorageClass" %
(name, protocol)))
self.auth_protocol = None
return
self.ZEOStorageClass = storage_class
log("%s: using auth protocol: %s" % (name, protocol))
# We create a Database instance here for use with the authenticator
# modules. Having one instance allows it to be shared between multiple
# storages, avoiding the need to bloat each with a new authenticator
# Database that would contain the same info, and also avoiding any
# possibly synchronization issues between them.
self.database = db_class(self.auth_filename)
if self.database.realm != self.auth_realm:
raise ValueError("password database realm %r "
"does not match storage realm %r"
% (self.database.realm, self.auth_realm))
def new_connection(self, sock, addr):
"""Internal: factory to create a new connection.
......@@ -664,8 +752,14 @@ class StorageServer:
whenever accept() returns a socket for a new incoming
connection.
"""
z = self.ZEOStorageClass(self, self.read_only)
c = self.ManagedServerConnectionClass(sock, addr, z, self)
if self.auth_protocol and self.database:
zstorage = self.ZEOStorageClass(self, self.read_only,
auth_realm=self.auth_realm)
zstorage.set_database(self.database)
else:
zstorage = self.ZEOStorageClass(self, self.read_only)
c = self.ManagedServerConnectionClass(sock, addr, zstorage, self)
log("new connection %s: %s" % (addr, `c`))
return c
......
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
_auth_modules = {}
def get_module(name):
if name == 'sha':
from auth_sha import StorageClass, SHAClient, Database
return StorageClass, SHAClient, Database
elif name == 'digest':
from auth_digest import StorageClass, DigestClient, DigestDatabase
return StorageClass, DigestClient, DigestDatabase
else:
return _auth_modules.get(name)
def register_module(name, storage_class, client, db):
if _auth_modules.has_key(name):
raise TypeError, "%s is already registred" % name
_auth_modules[name] = storage_class, client, db
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
"""Digest authentication for ZEO
This authentication mechanism follows the design of HTTP digest
authentication (RFC 2069). It is a simple challenge-response protocol
that does not send passwords in the clear, but does not offer strong
security. The RFC discusses many of the limitations of this kind of
protocol.
Guard the password database as if it contained plaintext passwords.
It stores the hash of a username and password. This does not expose
the plaintext password, but it is sensitive nonetheless. An attacker
with the hash can impersonate the real user. This is a limitation of
the simple digest scheme.
HTTP is a stateless protocol, and ZEO is a stateful protocol. The
security requirements are quite different as a result. The HTTP
protocol uses a nonce as a challenge. The ZEO protocol requires a
separate session key that is used for message authentication. We
generate a second nonce for this purpose; the hash of nonce and
user/realm/password is used as the session key. XXX I'm not sure if
this is a sound approach; SRP would be preferred.
"""
import base64
import os
import sha
import struct
import time
from ZEO.auth.base import Database, Client
from ZEO.StorageServer import ZEOStorage
from ZEO.Exceptions import AuthError
def get_random_bytes(n=8):
if os.path.exists("/dev/urandom"):
f = open("/dev/urandom")
s = f.read(n)
f.close()
else:
L = [chr(random.randint(0, 255)) for i in range(n)]
s = "".join(L)
return s
def hexdigest(s):
return sha.new(s).hexdigest()
class DigestDatabase(Database):
def __init__(self, filename, realm=None):
Database.__init__(self, filename, realm)
# Initialize a key used to build the nonce for a challenge.
# We need one key for the lifetime of the server, so it
# is convenient to store in on the database.
self.noncekey = get_random_bytes(8)
def _store_password(self, username, password):
dig = hexdigest("%s:%s:%s" % (username, self.realm, password))
self._users[username] = dig
def session_key(h_up, nonce):
# The hash itself is a bit too short to be a session key.
# HMAC wants a 64-byte key. We don't want to use h_up
# directly because it would never change over time. Instead
# use the hash plus part of h_up.
return sha.new("%s:%s" % (h_up, nonce)).digest() + h_up[:44]
class StorageClass(ZEOStorage):
def set_database(self, database):
assert isinstance(database, DigestDatabase)
self.database = database
self.noncekey = database.noncekey
def _get_time(self):
# Return a string representing the current time.
t = int(time.time())
return struct.pack("i", t)
def _get_nonce(self):
# RFC 2069 recommends a nonce of the form
# H(client-IP ":" time-stamp ":" private-key)
dig = sha.sha()
dig.update(str(self.connection.addr))
dig.update(self._get_time())
dig.update(self.noncekey)
return dig.hexdigest()
def auth_get_challenge(self):
"""Return realm, challenge, and nonce."""
self._challenge = self._get_nonce()
self._key_nonce = self._get_nonce()
return self.auth_realm, self._challenge, self._key_nonce
def auth_response(self, resp):
# verify client response
user, challenge, response = resp
# Since zrpc is a stateful protocol, we just store the nonce
# we sent to the client. It will need to generate a new
# nonce for a new connection anyway.
if self._challenge != challenge:
raise ValueError, "invalid challenge"
# lookup user in database
h_up = self.database.get_password(user)
# regeneration resp from user, password, and nonce
check = hexdigest("%s:%s" % (h_up, challenge))
if check == response:
self.connection.setSessionKey(session_key(h_up, self._key_nonce))
return self.finish_auth(check == response)
extensions = [auth_get_challenge, auth_response]
class DigestClient(Client):
extensions = ["auth_get_challenge", "auth_response"]
def start(self, username, realm, password):
_realm, challenge, nonce = self.stub.auth_get_challenge()
if _realm != realm:
raise AuthError("expected realm %r, got realm %r"
% (_realm, realm))
h_up = hexdigest("%s:%s:%s" % (username, realm, password))
resp_dig = hexdigest("%s:%s" % (h_up, challenge))
result = self.stub.auth_response((username, challenge, resp_dig))
if result:
return session_key(h_up, nonce)
else:
return None
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
"""Base classes for defining an authentication protocol.
Database -- abstract base class for password database
Client -- abstract base class for authentication client
"""
import os
import sha
class Client:
# Subclass should override to list the names of methods that
# will be called on the server.
extensions = []
def __init__(self, stub):
self.stub = stub
for m in self.extensions:
setattr(self.stub, m, self.stub.extensionMethod(m))
def sort(L):
"""Sort a list in-place and return it."""
L.sort()
return L
class Database:
"""Abstracts a password database.
This class is used both in the authentication process (via
get_password()) and by client scripts that manage the password
database file.
The password file is a simple, colon-separated text file mapping
usernames to password hashes. The hashes are SHA hex digests
produced from the password string.
"""
def __init__(self, filename, realm=None):
"""Creates a new Database
filename: a string containing the full pathname of
the password database file. Must be readable by the user
running ZEO. Must be writeable by any client script that
accesses the database.
realm: the realm name (a string)
"""
self._users = {}
self.filename = filename
self.realm = realm
self.load()
def save(self, fd=None):
filename = self.filename
if not fd:
fd = open(filename, 'w')
if self.realm:
print >> fd, "realm", self.realm
for username in sort(self._users.keys()):
print >> fd, "%s: %s" % (username, self._users[username])
def load(self):
filename = self.filename
if not filename:
return
if not os.path.exists(filename):
return
fd = open(filename)
L = fd.readlines()
if L[0].startswith("realm "):
line = L.pop(0).strip()
self.realm = line[len("realm "):]
for line in L:
username, hash = line.strip().split(":", 1)
self._users[username] = hash.strip()
def _store_password(self, username, password):
self._users[username] = self.hash(password)
def get_password(self, username):
"""Returns password hash for specified username.
Callers must check for LookupError, which is raised in
the case of a non-existent user specified."""
if not self._users.has_key(username):
raise LookupError, "No such user: %s" % username
return self._users[username]
def hash(self, s):
return sha.new(s).hexdigest()
def add_user(self, username, password):
if self._users.has_key(username):
raise LookupError, "User %s does already exist" % username
self._store_password(username, password)
def del_user(self, username):
if not self._users.has_key(username):
raise LookupError, "No such user: %s" % username
del self._users[username]
def change_password(self, username, password):
if not self._users.has_key(username):
raise LookupError, "No such user: %s" % username
self._store_password(username, password)
"""HMAC (Keyed-Hashing for Message Authentication) Python module.
Implements the HMAC algorithm as described by RFC 2104.
"""
def _strxor(s1, s2):
"""Utility method. XOR the two strings s1 and s2 (must have same length).
"""
return "".join(map(lambda x, y: chr(ord(x) ^ ord(y)), s1, s2))
# The size of the digests returned by HMAC depends on the underlying
# hashing module used.
digest_size = None
class HMAC:
"""RFC2104 HMAC class.
This supports the API for Cryptographic Hash Functions (PEP 247).
"""
def __init__(self, key, msg = None, digestmod = None):
"""Create a new HMAC object.
key: key for the keyed hash object.
msg: Initial input for the hash, if provided.
digestmod: A module supporting PEP 247. Defaults to the md5 module.
"""
if digestmod is None:
import md5
digestmod = md5
self.digestmod = digestmod
self.outer = digestmod.new()
self.inner = digestmod.new()
self.digest_size = digestmod.digest_size
blocksize = 64
ipad = "\x36" * blocksize
opad = "\x5C" * blocksize
if len(key) > blocksize:
key = digestmod.new(key).digest()
key = key + chr(0) * (blocksize - len(key))
self.outer.update(_strxor(key, opad))
self.inner.update(_strxor(key, ipad))
if msg is not None:
self.update(msg)
## def clear(self):
## raise NotImplementedError, "clear() method not available in HMAC."
def update(self, msg):
"""Update this hashing object with the string msg.
"""
self.inner.update(msg)
def copy(self):
"""Return a separate copy of this hashing object.
An update to this copy won't affect the original object.
"""
other = HMAC("")
other.digestmod = self.digestmod
other.inner = self.inner.copy()
other.outer = self.outer.copy()
return other
def digest(self):
"""Return the hash value of this hashing object.
This returns a string containing 8-bit data. The object is
not altered in any way by this function; you can continue
updating the object after calling this function.
"""
h = self.outer.copy()
h.update(self.inner.digest())
return h.digest()
def hexdigest(self):
"""Like digest(), but returns a string of hexadecimal digits instead.
"""
return "".join([hex(ord(x))[2:].zfill(2)
for x in tuple(self.digest())])
def new(key, msg = None, digestmod = None):
"""Create a new hashing object and return it.
key: The starting key for the hash.
msg: if available, will immediately be hashed into the object's starting
state.
You can now feed arbitrary strings into the object using its update()
method, and can ask for the hash value at any time by calling its digest()
method.
"""
return HMAC(key, msg, digestmod)
......@@ -3,7 +3,7 @@
<sectiontype name="zeo">
<description>
The content of a "ZEO" section describe operational parameters
The content of a ZEO section describe operational parameters
of a ZEO server except for the storage(s) to be served.
</description>
......@@ -71,6 +71,28 @@
</description>
</key>
<key name="authentication-protocol" required="no">
<description>
The name of the protocol used for authentication. The
only protocol provided with ZEO is "digest," but extensions
may provide other protocols.
</description>
</key>
<key name="authentication-database" required="no">
<description>
The path of the database containing authentication credentials.
</description>
</key>
<key name="authentication-realm" required="no">
<description>
The authentication realm of the server. Some authentication
schemes use a realm to identify the logic set of usernames
that are accepted by this server.
</description>
</key>
</sectiontype>
</component>
#!python
##############################################################################
#
# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
# Copyright (c) 2001, 2002, 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
......@@ -89,7 +89,12 @@ class ZEOOptionsMixin:
"t:", "timeout=", float)
self.add("monitor_address", "zeo.monitor_address", "m:", "monitor=",
self.handle_monitor_address)
self.add('auth_protocol', 'zeo.authentication_protocol',
None, 'auth-protocol=', default=None)
self.add('auth_database', 'zeo.authentication_database',
None, 'auth-database=')
self.add('auth_realm', 'zeo.authentication_realm',
None, 'auth-realm=')
class ZEOOptions(ZDOptions, ZEOOptionsMixin):
......@@ -189,7 +194,10 @@ class ZEOServer:
read_only=self.options.read_only,
invalidation_queue_size=self.options.invalidation_queue_size,
transaction_timeout=self.options.transaction_timeout,
monitor_address=self.options.monitor_address)
monitor_address=self.options.monitor_address,
auth_protocol=self.options.auth_protocol,
auth_database=self.options.auth_database,
auth_realm=self.options.auth_realm)
def loop_forever(self):
import ThreadedAsync.LoopCallback
......
......@@ -41,6 +41,8 @@ from ZODB.tests.StorageTestBase import handle_all_serials, ZERO
class TestClientStorage(ClientStorage):
test_connection = 0
def verify_cache(self, stub):
self.end_verify = threading.Event()
self.verify_result = ClientStorage.verify_cache(self, stub)
......@@ -49,6 +51,12 @@ class TestClientStorage(ClientStorage):
ClientStorage.endVerify(self)
self.end_verify.set()
def testConnection(self, conn):
try:
return ClientStorage.testConnection(self, conn)
finally:
self.test_connection = 1
class DummyDB:
def invalidate(self, *args, **kwargs):
pass
......@@ -113,29 +121,26 @@ class CommonSetupTearDown(StorageTestBase):
# port+1 is also used, so only draw even port numbers
return 'localhost', random.randrange(25000, 30000, 2)
def getConfig(self):
def getConfig(self, path, create, read_only):
raise NotImplementedError
def openClientStorage(self, cache='', cache_size=200000, wait=1,
read_only=0, read_only_fallback=0):
base = TestClientStorage(self.addr,
read_only=0, read_only_fallback=0,
username=None, password=None, realm=None):
storage = TestClientStorage(self.addr,
client=cache,
cache_size=cache_size,
wait=wait,
min_disconnect_poll=0.1,
read_only=read_only,
read_only_fallback=read_only_fallback)
storage = base
read_only_fallback=read_only_fallback,
username=username,
password=password,
realm=realm)
storage.registerDB(DummyDB(), None)
return storage
def startServer(self, create=1, index=0, read_only=0, ro_svr=0):
addr = self.addr[index]
zLOG.LOG("testZEO", zLOG.INFO,
"startServer(create=%d, index=%d, read_only=%d) @ %s" %
(create, index, read_only, addr))
path = "%s.%d" % (self.file, index)
sconf = self.getConfig(path, create, read_only)
def getServerConfig(self, addr, ro_svr):
zconf = forker.ZEOConfig(addr)
if ro_svr:
zconf.read_only = 1
......@@ -145,6 +150,16 @@ class CommonSetupTearDown(StorageTestBase):
zconf.invalidation_queue_size = self.invq
if self.timeout:
zconf.transaction_timeout = self.timeout
return zconf
def startServer(self, create=1, index=0, read_only=0, ro_svr=0):
addr = self.addr[index]
zLOG.LOG("testZEO", zLOG.INFO,
"startServer(create=%d, index=%d, read_only=%d) @ %s" %
(create, index, read_only, addr))
path = "%s.%d" % (self.file, index)
sconf = self.getConfig(path, create, read_only)
zconf = self.getServerConfig(addr, ro_svr)
zeoport, adminaddr, pid, path = forker.start_zeo_server(sconf, zconf,
addr[1],
self.keep)
......@@ -449,7 +464,6 @@ class ConnectionTests(CommonSetupTearDown):
self._storage = self.openClientStorage()
self._dostore()
# Test case for multiple storages participating in a single
# transaction. This is not really a connection test, but it needs
# about the same infrastructure (several storage servers).
......
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
"""Implements plaintext password authentication. The password is stored in
an SHA hash in the Database. The client sends over the plaintext
password, and the SHA hashing is done on the server side.
This mechanism offers *no network security at all*; the only security
is provided by not storing plaintext passwords on disk. (See the
auth_srp module for a secure mechanism)"""
import sha
from ZEO.StorageServer import ZEOStorage
from ZEO.auth import register_module
from ZEO.auth.base import Client, Database
class StorageClass(ZEOStorage):
def auth(self, username, password):
try:
dbpw = self.database.get_password(username)
except LookupError:
return 0
password = sha.new(password).hexdigest()
return self.finish_auth(dbpw == password)
class PlaintextClient(Client):
extensions = ["auth"]
def start(self, username, realm, password):
return self.stub.auth(username, password)
register_module("plaintext", StorageClass, PlaintextClient, Database)
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
"""Test suite for AuthZEO."""
import os
import tempfile
import time
import unittest
from ThreadedAsync import LoopCallback
from ZEO.ClientStorage import ClientStorage
from ZEO.StorageServer import StorageServer
from ZEO.tests.ConnectionTests import CommonSetupTearDown
from ZODB.FileStorage import FileStorage
from ZODB.tests.StorageTestBase import removefs
class AuthTest(CommonSetupTearDown):
__super_getServerConfig = CommonSetupTearDown.getServerConfig
__super_setUp = CommonSetupTearDown.setUp
__super_tearDown = CommonSetupTearDown.tearDown
realm = None
def setUp(self):
self.pwfile = tempfile.mktemp()
if self.realm:
self.pwdb = self.dbclass(self.pwfile, self.realm)
else:
self.pwdb = self.dbclass(self.pwfile)
self.pwdb.add_user("foo", "bar")
self.pwdb.save()
self.__super_setUp()
def tearDown(self):
self.__super_tearDown()
os.remove(self.pwfile)
def getConfig(self, path, create, read_only):
return "<mappingstorage 1/>"
def getServerConfig(self, addr, ro_svr):
zconf = self.__super_getServerConfig(addr, ro_svr)
zconf.authentication_protocol = self.protocol
zconf.authentication_database = self.pwfile
zconf.authentication_realm = self.realm
return zconf
def wait(self):
for i in range(25):
if self._storage.test_connection:
return
time.sleep(0.1)
self.fail("Timed out waiting for client to authenticate")
def testOK(self):
# Sleep for 0.2 seconds to give the server some time to start up
# seems to be needed before and after creating the storage
self._storage = self.openClientStorage(wait=0, username="foo",
password="bar", realm=self.realm)
self.wait()
self.assert_(self._storage._connection)
self._storage._connection.poll()
self.assert_(self._storage.is_connected())
def testNOK(self):
self._storage = self.openClientStorage(wait=0, username="foo",
password="noogie",
realm=self.realm)
self.wait()
# If the test established a connection, then it failed.
self.failIf(self._storage._connection)
class PlainTextAuth(AuthTest):
import ZEO.tests.auth_plaintext
protocol = "plaintext"
database = "authdb.sha"
dbclass = ZEO.tests.auth_plaintext.Database
realm = "Plaintext Realm"
class DigestAuth(AuthTest):
import ZEO.auth.auth_digest
protocol = "digest"
database = "authdb.digest"
dbclass = ZEO.auth.auth_digest.DigestDatabase
realm = "Digest Realm"
test_classes = [PlainTextAuth, DigestAuth]
def test_suite():
suite = unittest.TestSuite()
for klass in test_classes:
sub = unittest.makeSuite(klass)
suite.addTest(sub)
return suite
if __name__ == "__main__":
unittest.main(defaultTest='test_suite')
......@@ -13,7 +13,7 @@
##############################################################################
"""Test that the monitor produce sensible results.
$Id: testMonitor.py,v 1.5 2003/05/30 17:40:30 jeremy Exp $
$Id: testMonitor.py,v 1.6 2003/05/30 19:20:56 jeremy Exp $
"""
import socket
......
......@@ -39,8 +39,7 @@ from ZODB.tests import StorageTestBase, BasicStorage, VersionStorage, \
MTStorage, ReadOnlyStorage
from ZEO.ClientStorage import ClientStorage
from ZEO.tests import forker, Cache
from ZEO.tests import CommitLockTests, ThreadTests
from ZEO.tests import forker, Cache, CommitLockTests, ThreadTests
class DummyDB:
def invalidate(self, *args):
......
......@@ -26,9 +26,8 @@ import ThreadedAsync.LoopCallback
import ZConfig.Context
import zLOG
import ZEO.StorageServer
from ZEO.StorageServer import StorageServer
from ZEO.runzeo import ZEOOptions
from ZODB.config import storageFromURL
def cleanup(storage):
......@@ -152,6 +151,10 @@ def main():
zo.realize(["-C", configfile])
zeo_port = int(zo.address[1])
# XXX a hack
if zo.auth_protocol == "plaintext":
import ZEO.tests.auth_plaintext
# Open the config file and let ZConfig parse the data there. Then remove
# the config file, otherwise we'll leave turds.
# The rest of the args are hostname, portnum
......@@ -163,13 +166,16 @@ def main():
mon_addr = None
if zo.monitor_address:
mon_addr = zo.monitor_address.address
server = ZEO.StorageServer.StorageServer(
server = StorageServer(
zo.address,
{"1": storage},
read_only=zo.read_only,
invalidation_queue_size=zo.invalidation_queue_size,
transaction_timeout=zo.transaction_timeout,
monitor_address=mon_addr)
monitor_address=mon_addr,
auth_protocol=zo.auth_protocol,
auth_filename=zo.auth_database,
auth_realm=zo.auth_realm)
try:
log(label, 'creating the test server, keep: %s', keep)
......
#!python
##############################################################################
#
# Copyright (c) 2003 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (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
#
##############################################################################
"""Update a user's authentication tokens for a ZEO server.
usage: python zeopasswd.py [options] username [password]
-C/--configuration URL -- configuration file or URL
-d/--delete -- delete user instead of updating password
"""
import getopt
import getpass
import sys
import ZConfig
import ZEO
def usage(msg):
print msg
print __doc__
sys.exit(2)
def options(args):
"""Password-specific options loaded from regular ZEO config file."""
schema = ZConfig.loadSchema(os.path.join(os.path.dirname(ZEO.__file__),
"schema.xml"))
try:
options, args = getopt.getopt(args, "C:", ["configure="])
except getopt.error, msg:
usage(msg)
config = None
delete = False
for k, v in options:
if k == '-C' or k == '--configure':
config, nil = ZConfig.loadConfig(schema, v)
if k == '-d' or k == '--delete':
delete = True
if config is None:
usage("Must specifiy configuration file")
password = None
if delete:
if not args:
usage("Must specify username to delete")
elif len(args) > 1:
usage("Too many arguments")
username = args[0]
else:
if not args:
usage("Must specify username")
elif len(args) > 2:
usage("Too many arguments")
elif len(args) == 1:
username = args[0]
else:
username, password = args
return config.zeo, delete, username, password
def main(args=None):
options, delete, username, password = options(args)
p = options.authentication_protocol
if p is None:
usage("ZEO configuration does not specify authentication-protocol")
if p == "digest":
from ZEO.auth.auth_digest import DigestDatabase as Database
elif p == "srp":
from ZEO.auth.auth_srp import SRPDatabase as Database
if options.authentication_database is None:
usage("ZEO configuration does not specify authentication-database")
db = Database(options.authentication_database)
if delete:
db.del_user(username)
else:
if password is None:
password = getpass.getpass("Enter password: ")
db.add_user(username, password)
db.save()
if __name__ == "__main__":
main(sys.argv)
# This file is a slightly modified copy of Python 2.3's Lib/hmac.py.
# This file is under the Python Software Foundation (PSF) license.
"""HMAC (Keyed-Hashing for Message Authentication) Python module.
Implements the HMAC algorithm as described by RFC 2104.
"""
def _strxor(s1, s2):
"""Utility method. XOR the two strings s1 and s2 (must have same length).
"""
return "".join(map(lambda x, y: chr(ord(x) ^ ord(y)), s1, s2))
# The size of the digests returned by HMAC depends on the underlying
# hashing module used.
digest_size = None
class HMAC:
"""RFC2104 HMAC class.
This supports the API for Cryptographic Hash Functions (PEP 247).
"""
def __init__(self, key, msg = None, digestmod = None):
"""Create a new HMAC object.
key: key for the keyed hash object.
msg: Initial input for the hash, if provided.
digestmod: A module supporting PEP 247. Defaults to the md5 module.
"""
if digestmod is None:
import md5
digestmod = md5
self.digestmod = digestmod
self.outer = digestmod.new()
self.inner = digestmod.new()
# Python 2.1 and 2.2 differ about the correct spelling
try:
self.digest_size = digestmod.digestsize
except AttributeError:
self.digest_size = digestmod.digest_size
blocksize = 64
ipad = "\x36" * blocksize
opad = "\x5C" * blocksize
if len(key) > blocksize:
key = digestmod.new(key).digest()
key = key + chr(0) * (blocksize - len(key))
self.outer.update(_strxor(key, opad))
self.inner.update(_strxor(key, ipad))
if msg is not None:
self.update(msg)
## def clear(self):
## raise NotImplementedError, "clear() method not available in HMAC."
def update(self, msg):
"""Update this hashing object with the string msg.
"""
self.inner.update(msg)
def copy(self):
"""Return a separate copy of this hashing object.
An update to this copy won't affect the original object.
"""
other = HMAC("")
other.digestmod = self.digestmod
other.inner = self.inner.copy()
other.outer = self.outer.copy()
return other
def digest(self):
"""Return the hash value of this hashing object.
This returns a string containing 8-bit data. The object is
not altered in any way by this function; you can continue
updating the object after calling this function.
"""
h = self.outer.copy()
h.update(self.inner.digest())
return h.digest()
def hexdigest(self):
"""Like digest(), but returns a string of hexadecimal digits instead.
"""
return "".join([hex(ord(x))[2:].zfill(2)
for x in tuple(self.digest())])
def new(key, msg = None, digestmod = None):
"""Create a new hashing object and return it.
key: The starting key for the hash.
msg: if available, will immediately be hashed into the object's starting
state.
You can now feed arbitrary strings into the object using its update()
method, and can ask for the hash value at any time by calling its digest()
method.
"""
return HMAC(key, msg, digestmod)
......@@ -114,6 +114,7 @@ class Connection(smac.SizedMessageAsyncConnection):
__super_init = smac.SizedMessageAsyncConnection.__init__
__super_close = smac.SizedMessageAsyncConnection.close
__super_setSessionKey = smac.SizedMessageAsyncConnection.setSessionKey
# Protocol variables:
#
......@@ -152,12 +153,17 @@ class Connection(smac.SizedMessageAsyncConnection):
self.trigger = None
self._prepare_async()
self._map = {self._fileno: self}
# __msgid_lock guards access to msgid
# msgid_lock guards access to msgid
self.msgid_lock = threading.Lock()
# __replies_cond is used to block when a synchronous call is
# replies_cond is used to block when a synchronous call is
# waiting for a response
self.replies_cond = threading.Condition()
self.replies = {}
# waiting_for_reply is used internally to indicate whether
# a call is in progress. setting a session key is deferred
# until after the call returns.
self.waiting_for_reply = 0
self.delay_sesskey = None
self.register_object(obj)
self.handshake()
......@@ -248,8 +254,12 @@ class Connection(smac.SizedMessageAsyncConnection):
level=zLOG.DEBUG)
meth = getattr(self.obj, name)
try:
self.waiting_for_reply = 1
try:
ret = meth(*args)
finally:
self.waiting_for_reply = 0
except (SystemExit, KeyboardInterrupt):
raise
except Exception, msg:
......@@ -271,6 +281,10 @@ class Connection(smac.SizedMessageAsyncConnection):
else:
self.send_reply(msgid, ret)
if self.delay_sesskey:
self.__super_setSessionKey(self.delay_sesskey)
self.delay_sesskey = None
def handle_error(self):
if sys.exc_info()[0] == SystemExit:
raise sys.exc_info()
......@@ -318,6 +332,12 @@ class Connection(smac.SizedMessageAsyncConnection):
self.message_output(msg)
self.poll()
def setSessionKey(self, key):
if self.waiting_for_reply:
self.delay_sesskey = key
else:
self.__super_setSessionKey(key)
# The next two public methods (call and callAsync) are used by
# clients to invoke methods on remote objects
......
......@@ -11,9 +11,29 @@
# FOR A PARTICULAR PURPOSE
#
##############################################################################
"""Sized Message Async Connections."""
"""Sized Message Async Connections.
import asyncore, struct
This class extends the basic asyncore layer with a record-marking
layer. The message_output() method accepts an arbitrary sized string
as its argument. It sends over the wire the length of the string
encoded using struct.pack('>i') and the string itself. The receiver
passes the original string to message_input().
This layer also supports an optional message authentication code
(MAC). If a session key is present, it uses HMAC-SHA-1 to generate a
20-byte MAC. If a MAC is present, the high-order bit of the length
is set to 1 and the MAC immediately follows the length.
"""
import asyncore
import errno
try:
import hmac
except ImportError:
import _hmac as hmac
import sha
import socket
import struct
import threading
from types import StringType
......@@ -21,7 +41,6 @@ from ZEO.zrpc.log import log, short_repr
from ZEO.zrpc.error import DisconnectedError
import zLOG
import socket, errno
# Use the dictionary to make sure we get the minimum number of errno
# entries. We expect that EWOULDBLOCK == EAGAIN on most systems --
......@@ -45,6 +64,8 @@ del tmp_dict
# that we could pass to send() without blocking.
SEND_SIZE = 60000
MAC_BIT = 0x80000000
class SizedMessageAsyncConnection(asyncore.dispatcher):
__super_init = asyncore.dispatcher.__init__
__super_close = asyncore.dispatcher.close
......@@ -75,8 +96,13 @@ class SizedMessageAsyncConnection(asyncore.dispatcher):
self.__output_lock = threading.Lock() # Protects __output
self.__output = []
self.__closed = 0
self.__hmac = None
self.__super_init(sock, map)
def setSessionKey(self, sesskey):
log("set session key %r" % sesskey)
self.__hmac = hmac.HMAC(sesskey, digestmod=sha)
def get_addr(self):
return self.addr
......@@ -124,12 +150,16 @@ class SizedMessageAsyncConnection(asyncore.dispatcher):
inp = "".join(inp)
offset = 0
expect_mac = 0
while (offset + msg_size) <= input_len:
msg = inp[offset:offset + msg_size]
offset = offset + msg_size
if not state:
# waiting for message
msg_size = struct.unpack(">i", msg)[0]
expect_mac = msg_size & MAC_BIT
if expect_mac:
msg_size ^= MAC_BIT
msg_size += 20
state = 1
else:
msg_size = 4
......@@ -144,6 +174,17 @@ class SizedMessageAsyncConnection(asyncore.dispatcher):
# incoming call to be handled. During all this
# time, the __input_lock is held. That's a good
# thing, because it serializes incoming calls.
if expect_mac:
mac = msg[:20]
msg = msg[20:]
if self.__hmac:
self.__hmac.update(msg)
_mac = self.__hmac.digest()
if mac != _mac:
raise ValueError("MAC failed: %r != %r"
% (_mac, mac))
else:
log("Received MAC but no session key set")
self.message_input(msg)
self.__state = state
......@@ -214,6 +255,11 @@ class SizedMessageAsyncConnection(asyncore.dispatcher):
self.__output_lock.acquire()
try:
# do two separate appends to avoid copying the message string
if self.__hmac:
self.__output.append(struct.pack(">i", len(message) | MAC_BIT))
self.__hmac.update(message)
self.__output.append(self.__hmac.digest())
else:
self.__output.append(struct.pack(">i", len(message)))
if len(message) <= SEND_SIZE:
self.__output.append(message)
......
......@@ -13,7 +13,7 @@
##############################################################################
"""Database connection support
$Id: Connection.py,v 1.93 2003/05/19 15:40:58 kiko Exp $"""
$Id: Connection.py,v 1.94 2003/05/30 19:20:55 jeremy Exp $"""
from __future__ import nested_scopes
......
......@@ -115,7 +115,7 @@
# may have a back pointer to a version record or to a non-version
# record.
#
__version__='$Revision: 1.134 $'[11:-2]
__version__='$Revision: 1.135 $'[11:-2]
import base64
from cPickle import Pickler, Unpickler, loads
......
......@@ -12,7 +12,7 @@
#
##############################################################################
__version__ = '3.2a1'
__version__ = '3.2b1'
import sys
import cPersistence, Persistence
......
......@@ -14,7 +14,7 @@
static char cPersistence_doc_string[] =
"Defines Persistent mixin class for persistent objects.\n"
"\n"
"$Id: cPersistence.c,v 1.71 2003/05/23 21:32:38 jeremy Exp $\n";
"$Id: cPersistence.c,v 1.72 2003/05/30 19:20:55 jeremy Exp $\n";
#include "cPersistence.h"
......
......@@ -90,7 +90,7 @@ process must skip such objects, rather than deactivating them.
static char cPickleCache_doc_string[] =
"Defines the PickleCache used by ZODB Connection objects.\n"
"\n"
"$Id: cPickleCache.c,v 1.84 2003/05/13 23:15:16 jeremy Exp $\n";
"$Id: cPickleCache.c,v 1.85 2003/05/30 19:20:55 jeremy Exp $\n";
#define ASSIGN(V,E) {PyObject *__e; __e=(E); Py_XDECREF(V); (V)=__e;}
#define UNLESS(E) if(!(E))
......
......@@ -122,6 +122,13 @@
available. Defaults to false. At most one of read_only and
read_only_fallback should be true.
</description>
<key name="realm" required="no">
<description>
The authentication realm of the server. Some authentication
schemes use a realm to identify the logic set of usernames
that are accepted by this server.
</description>
</key>
</sectiontype>
<sectiontype name="demostorage" datatype=".DemoStorage"
......
......@@ -13,7 +13,7 @@
##############################################################################
"""Open database and storage from a configuration.
$Id: config.py,v 1.11 2003/05/23 21:31:40 jeremy Exp $"""
$Id: config.py,v 1.12 2003/05/30 19:20:55 jeremy Exp $"""
import os
import StringIO
......
......@@ -14,7 +14,7 @@
static char cPersistence_doc_string[] =
"Defines Persistent mixin class for persistent objects.\n"
"\n"
"$Id: cPersistence.c,v 1.71 2003/05/23 21:32:38 jeremy Exp $\n";
"$Id: cPersistence.c,v 1.72 2003/05/30 19:20:55 jeremy Exp $\n";
#include "cPersistence.h"
......
......@@ -90,7 +90,7 @@ process must skip such objects, rather than deactivating them.
static char cPickleCache_doc_string[] =
"Defines the PickleCache used by ZODB Connection objects.\n"
"\n"
"$Id: cPickleCache.c,v 1.84 2003/05/13 23:15:16 jeremy Exp $\n";
"$Id: cPickleCache.c,v 1.85 2003/05/30 19:20:55 jeremy Exp $\n";
#define ASSIGN(V,E) {PyObject *__e; __e=(E); Py_XDECREF(V); (V)=__e;}
#define UNLESS(E) if(!(E))
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment