Index: /TracAccountManager/0.10/README
===================================================================
--- /TracAccountManager/0.10/README	(revision 2)
+++ /TracAccountManager/0.10/README	(revision 2)
@@ -0,0 +1,29 @@
+= TracAccountManager =
+
+A user account management plugin for Trac.
+
+== Installation ==
+ 1. Run: python setup.py bdist_egg
+ 2. If necessary create a folder called "plugins" in your Trac environment.
+ 3. Copy the .egg file from the dist folder created by step 1 into the "plugins"
+    directory of your Trac environment.
+
+== Configuration ==
+Add one of the following sections to trac.ini to manage an Apache htpasswd or
+htdigest file.
+
+=== Htpasswd ===
+{{{
+[account-manager]
+password_format = htpasswd
+password_file = /path/to/trac.htpasswd
+}}}
+
+=== Htdigest ===
+{{{
+[account-manager]
+password_format = htdigest
+password_file = /path/to/trac.htdigest
+htdigest_realm = TracDigestRealm
+}}}
+
Index: /TracAccountManager/0.10/acct_mgr/admin.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/admin.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/admin.py	(revision 2)
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+import inspect
+
+from trac.core import *
+from trac.config import Option
+from trac.perm import PermissionSystem
+from trac.util import sorted
+from trac.util.datefmt import format_datetime
+from trac.web.chrome import ITemplateProvider
+from webadmin.web_ui import IAdminPageProvider
+
+from acct_mgr.api import AccountManager
+from acct_mgr.web_ui import _create_user
+
+def _getoptions(cls):
+    if isinstance(cls, Component):
+        cls = cls.__class__
+    return [(name, value) for name, value in inspect.getmembers(cls)
+            if isinstance(value, Option)]
+
+class AccountManagerAdminPage(Component):
+
+    implements(IAdminPageProvider)
+
+    def __init__(self):
+        self.account_manager = AccountManager(self.env)
+
+    # IAdminPageProvider
+    def get_admin_pages(self, req):
+        if req.perm.has_permission('TRAC_ADMIN'):
+            yield ('accounts', 'Comptes', 'config', 'Configuration')
+            yield ('accounts', 'Comptes', 'users', 'Utilisateurs')
+
+    def process_admin_request(self, req, cat, page, path_info):
+        if page == 'config':
+            return self._do_config(req)
+        elif page == 'users':
+            return self._do_users(req)
+
+    def _do_config(self, req):
+        if req.method == 'POST':
+            selected_class = req.args.get('selected')
+            self.config.set('account-manager', 'password_store', selected_class)
+            selected = self.account_manager.password_store
+            for attr, option in _getoptions(selected):
+                newvalue = req.args.get('%s.%s' % (selected_class, attr))
+                if newvalue is not None:
+                    self.config.set(option.section, option.name, newvalue)
+                    self.config.save()
+        try:
+            selected = self.account_manager.password_store
+        except AttributeError:
+            selected = None
+        sections = [
+            {'name': store.__class__.__name__,
+             'classname': store.__class__.__name__,
+             'selected': store is selected,
+             'options': [
+                {'label': attr,
+                 'name': '%s.%s' % (store.__class__.__name__, attr),
+                 'value': option.__get__(store, store),
+                }
+                for attr, option in _getoptions(store)
+             ],
+            } for store in self.account_manager.stores
+        ]
+        sections = sorted(sections, key=lambda i: i['name'])
+        req.hdf['sections'] = sections
+        return 'admin_accountsconfig.cs', None
+
+    def _do_users(self, req):
+        perm = PermissionSystem(self.env)
+        listing_enabled = self.account_manager.supports('get_users')
+        create_enabled = self.account_manager.supports('set_password')
+        delete_enabled = self.account_manager.supports('delete_user')
+
+        req.hdf['listing_enabled'] = listing_enabled
+        req.hdf['create_enabled'] = create_enabled
+        req.hdf['delete_enabled'] = delete_enabled
+
+        if req.method == 'POST':
+            if req.args.get('add'):
+                if create_enabled:
+                    try:
+                        _create_user(req, self.env, check_permissions=False)
+                    except TracError, e:
+                        req.hdf['registration.error'] = e.message
+                else:
+                    req.hdf['registration_error'] = u'L\'espace de stockage des ' \
+                                                    u'mots de passe ne supporte ' \
+                                                    u'pas la création des utilisateurs'
+            elif req.args.get('remove'):
+                if delete_enabled:
+                    sel = req.args.get('sel')
+                    sel = isinstance(sel, list) and sel or [sel]
+                    for account in sel:
+                        self.account_manager.delete_user(account)
+                else:
+                    req.hdf['deletion_error'] = u'L\'espace de stockage des ' \
+                                                u'mots de passe ne supporte ' \
+                                                u'pas la suppression des utilisateurs'
+        if listing_enabled:
+            accounts = {}
+            for username in self.account_manager.get_users():
+                accounts[username] = {'username': username}
+
+            for username, name, email in self.env.get_known_users():
+                account = accounts.get(username)
+                if account:
+                    account['name'] = name
+                    account['email'] = email
+
+            db = self.env.get_db_cnx()
+            cursor = db.cursor()
+            cursor.execute("SELECT sid,last_visit FROM session WHERE authenticated=1")
+            for username, last_visit in cursor:
+                account = accounts.get(username)
+                if account and last_visit:
+                    account['last_visit'] = format_datetime(last_visit)
+
+            req.hdf['accounts'] = sorted(accounts.itervalues(),
+                                         key=lambda acct: acct['username'])
+
+        return 'admin_users.cs', None
+
+    # ITemplateProvider
+
+    def get_htdocs_dirs(self):
+        """Return the absolute path of a directory containing additional
+        static resources (such as images, style sheets, etc).
+        """
+        return []
+
+    def get_templates_dirs(self):
+        """Return the absolute path of the directory containing the provided
+        ClearSilver templates.
+        """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
Index: /TracAccountManager/0.10/acct_mgr/api.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/api.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/api.py	(revision 2)
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from trac.core import *
+from trac.config import Option, ExtensionOption
+
+class IPasswordStore(Interface):
+    """An interface for Components that provide a storage method for users and
+    passwords.
+    """
+
+    def config_key(self):
+        """
+        '''Deprecated''': new implementations of this interface are not required
+        to implement this method, since the prefered way to configure the
+        `IPasswordStore` implemenation is by using its class name in
+        the `password_store` option.
+
+        Returns a string used to identify this implementation in the config.
+        This password storage implementation will be used if the value of
+        the config property "account-manager.password_format" matches.
+        """
+
+    def get_users(self):
+        """Returns an iterable of the known usernames
+        """
+
+    def has_user(self, user):
+        """Returns whether the user account exists.
+        """
+
+    def set_password(self, user, password):
+        """Sets the password for the user.  This should create the user account
+        if it doesn't already exist.
+        Returns True if a new account was created, False if an existing account
+        was updated.
+        """
+
+    def check_password(self, user, password):
+        """Checks if the password is valid for the user.
+        """
+
+    def delete_user(self, user):
+        """Deletes the user account.
+        Returns True if the account existed and was deleted, False otherwise.
+        """
+
+class IAccountChangeListener(Interface):
+    """An interface for receiving account change events.
+    """
+
+    def user_created(self, user, password):
+        """New user
+        """
+
+    def user_password_changed(self, user, password):
+        """Password changed
+        """
+
+    def user_deleted(self, user):
+        """User deleted
+        """
+
+class AccountManager(Component):
+    """The AccountManager component handles all user account management methods
+    provided by the IPasswordStore interface.
+
+    The methods will be handled by the underlying password storage
+    implementation set in trac.ini with the "account-manager.password_format"
+    setting.
+    """
+
+    implements(IAccountChangeListener)
+
+    _password_store = ExtensionOption('account-manager', 'password_store',
+                                      IPasswordStore)
+    _password_format = Option('account-manager', 'password_format')
+    stores = ExtensionPoint(IPasswordStore)
+    change_listeners = ExtensionPoint(IAccountChangeListener)
+
+    # Public API
+
+    def get_users(self):
+        return self.password_store.get_users()
+
+    def has_user(self, user):
+        return self.password_store.has_user(user)
+
+    def set_password(self, user, password):
+        if self.password_store.set_password(user, password):
+            self._notify('created', user, password)
+        else:
+            self._notify('password_changed', user, password)
+
+    def check_password(self, user, password):
+        return self.password_store.check_password(user, password)
+
+    def delete_user(self, user):
+        if self.password_store.delete_user(user):
+            self._notify('deleted', user)
+
+    def supports(self, operation):
+        try:
+            store = self.password_store
+        except AttributeError:
+            return False
+        else:
+            return hasattr(store, operation)
+
+    def password_store(self):
+        try:
+            return self._password_store
+        except AttributeError:
+            # fall back on old "password_format" option
+            fmt = self._password_format
+            for store in self.stores:
+                config_key = getattr(store, 'config_key', None)
+                if config_key is None:
+                    continue
+                if config_key() == fmt:
+                    return store
+            # if the "password_format" is not set re-raise the AttributeError
+            raise
+    password_store = property(password_store)
+
+    def _notify(self, func, *args):
+        func = 'user_' + func
+        for l in self.change_listeners:
+            getattr(l, func)(*args)
+
+    # IAccountChangeListener methods
+
+    def user_created(self, user, password):
+        self.log.info('Created new user: %s' % user)
+
+    def user_password_changed(self, user, password):
+        self.log.info('Updated password for user: %s' % user)
+
+    def user_deleted(self, user):
+        self.log.info('Deleted user: %s' % user)
+
Index: /TracAccountManager/0.10/acct_mgr/db.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/db.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/db.py	(revision 2)
@@ -0,0 +1,90 @@
+# -*- coding: utf8 -*-
+#
+# Copyright (C) 2007 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from trac.core import *
+from trac.config import ExtensionOption
+
+from api import IPasswordStore
+from pwhash import IPasswordHashMethod
+
+class SessionStore(Component):
+    implements(IPasswordStore)
+
+    hash_method = ExtensionOption('account-manager', 'hash_method',
+                                  IPasswordHashMethod, 'HtDigestHashMethod')
+
+    def get_users(self):
+        """Returns an iterable of the known usernames
+        """
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT DISTINCT sid FROM session_attribute "
+                       "WHERE authenticated=1 AND name='password'")
+        for sid, in cursor:
+            yield sid
+ 
+    def has_user(self, user):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT * FROM session_attribute "
+                       "WHERE authenticated=1 AND name='password' "
+                       "AND sid=%s", (user,))
+        for row in cursor:
+            return True
+        return False
+
+    def set_password(self, user, password):
+        """Sets the password for the user.  This should create the user account
+        if it doesn't already exist.
+        Returns True if a new account was created, False if an existing account
+        was updated.
+        """
+        hash = self.hash_method.generate_hash(user, password)
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("UPDATE session_attribute "
+                       "SET value=%s "
+                       "WHERE authenticated=1 AND name='password' "
+                       "AND sid=%s", (hash, user))
+        if cursor.rowcount > 0:
+            return False # updated existing password
+        cursor.execute("INSERT INTO session_attribute "
+                       "(sid,authenticated,name,value) "
+                       "VALUES (%s,1,'password',%s)",
+                       (user, hash))
+        return True
+
+    def check_password(self, user, password):
+        """Checks if the password is valid for the user.
+        """
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT value FROM session_attribute "
+                       "WHERE authenticated=1 AND name='password' "
+                       "AND sid=%s", (user,))
+        for hash, in cursor:
+            return self.hash_method.check_hash(user, password, hash)
+        return False
+
+    def delete_user(self, user):
+        """Deletes the user account.
+        Returns True if the account existed and was deleted, False otherwise.
+        """
+        if not self.has_user(user):
+            return False
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("DELETE FROM session_attribute "
+                       "WHERE authenticated=1 AND name='password' "
+                       "AND sid=%s", (user,))
+        # TODO cursor.rowcount doesn't seem to get # deleted
+        # is there another way to get count instead of using has_user?
+        return True
Index: /TracAccountManager/0.10/acct_mgr/htfile.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/htfile.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/htfile.py	(revision 2)
@@ -0,0 +1,189 @@
+# -*- coding: utf8 -*-
+#
+# Copyright (C) 2005,2006,2007 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from __future__ import generators
+
+import errno
+import os.path
+import fileinput
+
+from trac.core import *
+from trac.config import Option
+
+from api import IPasswordStore
+from pwhash import htpasswd, htdigest
+
+class _RelativePathOption(Option):
+
+    def __get__(self, instance, owner):
+        if instance is None:
+            return self
+        path = super(_RelativePathOption, self).__get__(instance, owner)
+        return os.path.normpath(os.path.join(instance.env.path, path))
+
+
+class AbstractPasswordFileStore(Component):
+    """Base class for managing password files such as Apache's htpasswd and
+    htdigest formats.
+
+    See the concrete sub-classes for usage information.
+    """
+
+    filename = _RelativePathOption('account-manager', 'password_file')
+
+    def has_user(self, user):
+        return user in self.get_users()
+
+    def get_users(self):
+        filename = self.filename
+        if not os.path.exists(filename):
+            return []
+        return self._get_users(filename)
+
+    def set_password(self, user, password):
+        user = user.encode('utf-8')
+        password = password.encode('utf-8')
+        return not self._update_file(self.prefix(user),
+                                     self.userline(user, password))
+
+    def delete_user(self, user):
+        user = user.encode('utf-8')
+        return self._update_file(self.prefix(user), None)
+
+    def check_password(self, user, password):
+        filename = self.filename
+        if not os.path.exists(filename):
+            return False
+        user = user.encode('utf-8')
+        password = password.encode('utf-8')
+        prefix = self.prefix(user)
+        fd = file(filename)
+        try:
+            for line in fd:
+                if line.startswith(prefix):
+                    return self._check_userline(user, password,
+                                                line[len(prefix):].rstrip('\n'))
+        finally:
+            fd.close()
+        return False
+
+    def _update_file(self, prefix, userline):
+        """If `userline` is empty the line starting with `prefix` is
+        removed from the user file.  Otherwise the line starting with `prefix`
+        is updated to `userline`.  If no line starts with `prefix` the
+        `userline` is appended to the file.
+
+        Returns `True` if a line matching `prefix` was updated,
+        `False` otherwise.
+        """
+        filename = self.filename
+        matched = False
+        try:
+            for line in fileinput.input(str(filename), inplace=True):
+                if line.startswith(prefix):
+                    if not matched and userline:
+                        print userline
+                    matched = True
+                else:
+                    print line,
+        except EnvironmentError, e:
+            if e.errno == errno.ENOENT:
+                pass # ignore when file doesn't exist and create it below
+            elif e.errno == errno.EACCES:
+                raise TracError(u'Le fichier des mots de passe ne peut être '
+                                u'mis à jour. Trac a besoin des accés en lecture'
+                                u' et écriture aussi bien sur ce fichier que '
+                                u'sur son dossier parent.')
+            else:
+                raise
+        if not matched and userline:
+            f = open(filename, 'a')
+            try:
+                print >>f, userline
+            finally:
+                f.close()
+        return matched
+
+
+class HtPasswdStore(AbstractPasswordFileStore):
+    """Manages user accounts stored in Apache's htpasswd format.
+
+    To use this implementation add the following configuration section to
+    trac.ini:
+    {{{
+    [account-manager]
+    password_store = HtPasswdStore
+    password_file = /path/to/trac.htpasswd
+    }}}
+    """
+
+    implements(IPasswordStore)
+
+    def config_key(self):
+        return 'htpasswd'
+
+    def prefix(self, user):
+        return user + ':'
+
+    def userline(self, user, password):
+        return self.prefix(user) + htpasswd(password)
+
+    def _check_userline(self, user, password, suffix):
+        return suffix == htpasswd(password, suffix)
+
+    def _get_users(self, filename):
+        f = open(filename)
+        for line in f:
+            user = line.split(':', 1)[0]
+            if user:
+                yield user.decode('utf-8')
+
+
+class HtDigestStore(AbstractPasswordFileStore):
+    """Manages user accounts stored in Apache's htdigest format.
+
+    To use this implementation add the following configuration section to
+    trac.ini:
+    {{{
+    [account-manager]
+    password_store = HtDigestStore
+    password_file = /path/to/trac.htdigest
+    htdigest_realm = TracDigestRealm
+    }}}
+    """
+
+
+    implements(IPasswordStore)
+
+    realm = Option('account-manager', 'htdigest_realm')
+
+    def config_key(self):
+        return 'htdigest'
+
+    def prefix(self, user):
+        return '%s:%s:' % (user, self.realm)
+
+    def userline(self, user, password):
+        return self.prefix(user) + htdigest(user, self.realm, password)
+
+    def _check_userline(self, user, password, suffix):
+        return suffix == htdigest(user, self.realm, password)
+
+    def _get_users(self, filename):
+        _realm = self.realm
+        f = open(filename)
+        for line in f:
+            args = line.split(':')[:2]
+            if len(args) == 2:
+                user, realm = args
+                if realm == _realm and user:
+                    yield user.decode('utf-8')
+
Index: /TracAccountManager/0.10/acct_mgr/http.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/http.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/http.py	(revision 2)
@@ -0,0 +1,35 @@
+# -*- coding: utf8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from urllib2 import build_opener, HTTPBasicAuthHandler, \
+                    HTTPDigestAuthHandler, HTTPPasswordMgrWithDefaultRealm
+
+from trac.core import *
+from trac.config import Option
+
+from api import IPasswordStore
+
+class HttpAuthStore(Component):
+    implements(IPasswordStore)
+
+    auth_url = Option('account-manager', 'authentication_url')
+
+    def check_password(self, user, password):
+        mgr = HTTPPasswordMgrWithDefaultRealm()
+        mgr.add_password(None, self.auth_url, user, password)
+        try:
+            build_opener(HTTPBasicAuthHandler(mgr),
+                         HTTPDigestAuthHandler(mgr)).open(self.auth_url)
+        except IOError:
+            return False
+        else:
+            return True
+
Index: /TracAccountManager/0.10/acct_mgr/md5crypt.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/md5crypt.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/md5crypt.py	(revision 2)
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+#
+# Based on FreeBSD src/lib/libcrypt/crypt.c 1.2
+# http://www.freebsd.org/cgi/cvsweb.cgi/~checkout~/src/lib/libcrypt/crypt.c?rev=1.2&content-type=text/plain
+
+# Original license:
+# * "THE BEER-WARE LICENSE" (Revision 42):
+# * <phk@login.dknet.dk> wrote this file.  As long as you retain this notice you
+# * can do whatever you want with this stuff. If we meet some day, and you think
+# * this stuff is worth it, you can buy me a beer in return.   Poul-Henning Kamp
+
+# This port adds no further stipulations.  I forfeit any copyright interest.
+
+import md5
+
+def md5crypt(password, salt, magic='$1$'):
+    # /* The password first, since that is what is most unknown */ /* Then our magic string */ /* Then the raw salt */
+    m = md5.new()
+    m.update(password + magic + salt)
+
+    # /* Then just as many characters of the MD5(pw,salt,pw) */
+    mixin = md5.md5(password + salt + password).digest()
+    for i in range(0, len(password)):
+        m.update(mixin[i % 16])
+
+    # /* Then something really weird... */
+    # Also really broken, as far as I can tell.  -m
+    i = len(password)
+    while i:
+        if i & 1:
+            m.update('\x00')
+        else:
+            m.update(password[0])
+        i >>= 1
+
+    final = m.digest()
+
+    # /* and now, just to make sure things don't run too fast */
+    for i in range(1000):
+        m2 = md5.md5()
+        if i & 1:
+            m2.update(password)
+        else:
+            m2.update(final)
+
+        if i % 3:
+            m2.update(salt)
+
+        if i % 7:
+            m2.update(password)
+
+        if i & 1:
+            m2.update(final)
+        else:
+            m2.update(password)
+
+        final = m2.digest()
+
+    # This is the bit that uses to64() in the original code.
+
+    itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+
+    rearranged = ''
+    for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)):
+        v = ord(final[a]) << 16 | ord(final[b]) << 8 | ord(final[c])
+        for i in range(4):
+            rearranged += itoa64[v & 0x3f]; v >>= 6
+
+    v = ord(final[11])
+    for i in range(2):
+        rearranged += itoa64[v & 0x3f]; v >>= 6
+
+    return magic + salt + '$' + rearranged
+
+if __name__ == '__main__':
+
+    def test(clear_password, the_hash):
+        magic, salt = the_hash[1:].split('$')[:2]
+        magic = '$' + magic + '$'
+        return md5crypt(clear_password, salt, magic) == the_hash
+
+    test_cases = (
+        (' ', '$1$yiiZbNIH$YiCsHZjcTkYd31wkgW8JF.'),
+        ('pass', '$1$YeNsbWdH$wvOF8JdqsoiLix754LTW90'),
+        ('____fifteen____', '$1$s9lUWACI$Kk1jtIVVdmT01p0z3b/hw1'),
+        ('____sixteen_____', '$1$dL3xbVZI$kkgqhCanLdxODGq14g/tW1'),
+        ('____seventeen____', '$1$NaH5na7J$j7y8Iss0hcRbu3kzoJs5V.'),
+        ('__________thirty-three___________', '$1$HO7Q6vzJ$yGwp2wbL5D7eOVzOmxpsy.'),
+        ('apache', '$apr1$J.w5a/..$IW9y6DR0oO/ADuhlMF5/X1')
+    )
+
+    for clearpw, hashpw in test_cases:
+        if test(clearpw, hashpw):
+            print '%s: pass' % clearpw
+        else:
+            print '%s: FAIL' % clearpw
Index: /TracAccountManager/0.10/acct_mgr/pwhash.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/pwhash.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/pwhash.py	(revision 2)
@@ -0,0 +1,101 @@
+# -*- coding: utf8 -*-
+#
+# Copyright (C) 2007 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from binascii import hexlify
+import md5, sha
+
+from trac.core import *
+from trac.config import Option
+
+from md5crypt import md5crypt
+
+class IPasswordHashMethod(Interface):
+    def generate_hash(user, password):
+        pass
+
+    def test_hash(user, password, hash):
+        pass
+
+
+class HtPasswdHashMethod(Component):
+    implements(IPasswordHashMethod)
+
+    def generate_hash(self, user, password):
+        password = password.encode('utf-8')
+        return htpasswd(password)
+
+    def check_hash(self, user, password, hash):
+        password = password.encode('utf-8')
+        return hash == htpasswd(password, hash)
+
+
+class HtDigestHashMethod(Component):
+    implements(IPasswordHashMethod)
+
+    realm = Option('account-manager', 'htdigest_realm')
+
+    def generate_hash(self, user, password):
+        user,password,realm = _encode(user, password, self.realm)
+        return ':'.join([realm, htdigest(user, realm, password)])
+
+    def check_hash(self, user, password, hash):
+        user,password,realm = _encode(user, password, self.realm)
+        return hash == self.generate_hash(user, password)
+
+
+def _encode(*args):
+    return [a.encode('utf-8') for a in args]
+
+# check for the availability of the "crypt" module for checking passwords on
+# Unix-like platforms
+# MD5 is still used when adding/updating passwords
+try:
+    from crypt import crypt
+except ImportError:
+    crypt = None
+
+# os.urandom was added in Python 2.4
+# try to fall back on reading from /dev/urandom on older Python versions
+try:
+    from os import urandom
+except ImportError:
+    from random import randrange
+    def urandom(n):
+        return ''.join([chr(randrange(256)) for _ in xrange(n)])
+
+def salt():
+    s = ''
+    v = long(hexlify(urandom(4)), 16)
+    itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+    for i in range(8):
+        s += itoa64[v & 0x3f]; v >>= 6
+    return s
+
+def htpasswd(password, salt_=None):
+# TODO need unit test of generating new hash
+    if salt_ is None:
+        salt_ = salt()
+        if crypt is None:
+            salt_ = '$apr1$' + salt_
+    if salt_.startswith('$apr1$'):
+        return md5crypt(password, salt_[6:].split('$')[0], '$apr1$')
+    elif salt_.startswith('{SHA}'):
+        return '{SHA}' + sha.new(password).digest().encode('base64')[:-1]
+    elif crypt is None:
+        # crypt passwords are only supported on Unix-like systems
+        raise NotImplementedError(u'Le module "crypt" n\'est pas disponible '
+                                  u'sur cette plateforme.')
+    else:
+        return crypt(password, salt_)
+
+def htdigest(user, realm, password):
+    p = ':'.join([user, realm, password])
+    return md5.new(p).hexdigest()
Index: /TracAccountManager/0.10/acct_mgr/templates/account.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/account.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/account.cs	(revision 2)
@@ -0,0 +1,78 @@
+<?cs include "header.cs"?>
+<?cs include "macros.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="register">
+
+ <h1>Mon Compte</h1>
+
+ <p>
+ Gestion de votre compte utilisateur.
+ </p>
+
+ <?cs if account.error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:account.error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <?cs if account.message ?>
+ <p><?cs var:account.message ?></p>
+ <?cs /if ?>
+
+ <h2>Modification du mot de passe</h2>
+ <?cs if account.save_error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:account.save_error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <form method="post" action="">
+  <input type="hidden" name="action" value="change_password" />
+  <div>
+   <label for="old_password">Ancien mot de passe :</label>
+   <input type="password" id="old_password" name="old_password"
+          class="textwidget" size="20" />
+  </div>
+  <div>
+   <label for="password">Nouveau mot de passe :</label>
+   <input type="password" id="password" name="password" class="textwidget"
+          size="20" />
+  </div>
+  <div>
+   <label for="password_confirm">Confirmation du mot de passe :</label>
+   <input type="password" id="password_confirm" name="password_confirm"
+          class="textwidget" size="20" />
+  </div>
+  <input type="submit" value="Modifier le mot de passe" />
+ </form>
+
+ <?cs if:delete_enabled ?>
+ <hr />
+
+ <h2>Suppression de compte</h2>
+ <?cs if account.delete_error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:account.delete_error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <form method="post" action=""
+       onsubmit="return confirm('Etes vous sûre de vouloir effacer votre compte ?');">
+  <input type="hidden" name="action" value="delete" />
+  <div>
+   <label for="password">Mot de passe:</label>
+   <input type="password" id="password" name="password" class="textwidget"
+          size="20" />
+  </div>
+  <input type="submit" value="Effacer le compte" />
+ </form>
+ <?cs /if ?>
+
+</div>
+
+<?cs include:"footer.cs"?>
Index: /TracAccountManager/0.10/acct_mgr/templates/admin_accountsconfig.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/admin_accountsconfig.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/admin_accountsconfig.cs	(revision 2)
@@ -0,0 +1,27 @@
+<h2>Comptes: Configuration</h2>
+
+<form id="accountsconfig" class="mod" method="post">
+<?cs each:section = sections ?>
+    <fieldset>
+        <legend>
+            <label>
+                <input type="radio" name="selected" value="<?cs var:section.classname ?>"
+                       <?cs if:section.selected ?>checked="checked"<?cs /if ?> />
+                <?cs var:section.name ?>
+            </label>
+        </legend>
+
+        <?cs each:option = section.options ?>
+            <div class="field">
+                <label><?cs alt:translation[option.label] ?><?cs var:option.label ?><?cs /alt ?>:
+                    <input type="text" name="<?cs var:option.name ?>" value="<?cs var:option.value ?>"
+                           class="textwidget" />
+                </label>
+            </div>
+        <?cs /each ?>
+    </fieldset>
+<?cs /each ?>
+<div class="buttons">
+    <input type="submit" name="save" value="Sauver" />
+</div>
+</form>
Index: /TracAccountManager/0.10/acct_mgr/templates/admin_users.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/admin_users.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/admin_users.cs	(revision 2)
@@ -0,0 +1,67 @@
+<h2>Gestion des comptes utilisateurs</h2>
+
+<?cs if:create_enabled ?>
+<form id="addaccount" class="addnew" method="post">
+ <fieldset>
+  <?cs if registration.error ?>
+  <div class="system-message"><p><?cs var:registration.error ?></p></div>
+  <?cs /if ?>
+
+  <legend>Ajouter un compte :</legend>
+  <div class="field">
+   <label>Nom d'utilisateur : <input type="text" name="user" class="textwidget" /></label>
+  </div>
+  <div class="field">
+   <label>Mot de passe : <input type="password" name="password" class="textwidget" /></label>
+  </div>
+  <div class="field">
+   <label>Confirmation du mot de passe : <input type="password" name="password_confirm" class="textwidget" /></label>
+  </div>
+  <div class="field">
+   <label>Nom: <input type="text" name="name" class="textwidget" /></label>
+  </div>
+  <div class="field">
+   <label>Courriel: <input type="text" name="email" class="textwidget" /></label>
+  </div>
+  <p class="help">Ajouter un nouveau compte utilisateur.</p>
+  <div class="buttons">
+   <input type="submit" name="add" value=" Ajouter ">
+  </div>
+ </fieldset>
+</form>
+<?cs /if ?>
+
+<?cs if:!listing_enabled ?>
+<div class="system-message">
+    <p>Cette espace de stockage ne supporte pas le listage des utilisateurs</p>
+</div>
+<?cs else ?>
+<form method="post">
+ <?cs if:deletion_error ?><div class="system-message"><p><?cs var:deletion_error ?></p></div><?cs /if ?>
+ <table class="listing" id="accountlist">
+  <thead>
+   <tr>
+    <?cs if:delete_enabled ?><th class="sel">&nbsp;</th><?cs /if ?>
+    <th>Compte</th><th>Nom</th><th>Courriel</th><th>Derni&egrave;re connexion</th>
+   </tr>
+  </thead><tbody><?cs
+  each:account = accounts ?>
+   <tr>
+    <?cs if:delete_enabled ?>
+     <td><input type="checkbox" name="sel" value="<?cs var:account.username ?>" /></td>
+    <?cs /if ?>
+    <td><?cs var:account.username ?></td>
+    <td><?cs var:account.name ?></td>
+    <td><?cs var:account.email ?></td>
+    <td><?cs var:account.last_visit ?></td>
+   </tr><?cs
+  /each ?></tbody>
+ </table>
+ <?cs if:delete_enabled ?>
+ <div class="buttons">
+  <input type="submit" name="remove" value="Supprimer les comptes s&eacute;lectionn&eacute;s" />
+ </div>
+ <?cs /if ?>
+</form>
+<?cs /if ?>
+
Index: /TracAccountManager/0.10/acct_mgr/templates/login.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/login.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/login.cs	(revision 2)
@@ -0,0 +1,36 @@
+<?cs include "header.cs"?>
+<?cs include "macros.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="login">
+
+ <h1>Connexion</h1>
+
+ <?cs if login.error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:login.error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <form method="post" action="">
+  <input type="hidden" name="referer" value="<?cs var:referer ?>" />
+  <div>
+   <label for="user">Nom d'utilisateur :</label>
+   <input type="text" id="user" name="user" class="textwidget" size="20" />
+  </div>
+  <div>
+   <label for="password">Mot de passe :</label>
+   <input type="password" id="password" name="password" class="textwidget" size="20" />
+  </div>
+  <input type="submit" value="Se connecter" />
+
+  <?cs if trac.href.reset_password ?>
+  <p><a href="<?cs var:trac.href.reset_password ?>">Mot de passe oubli&eacute; ?</a></p>
+  <?cs /if ?>
+ </form>
+
+</div>
+
+<?cs include:"footer.cs"?>
Index: /TracAccountManager/0.10/acct_mgr/templates/register.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/register.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/register.cs	(revision 2)
@@ -0,0 +1,60 @@
+<?cs include "header.cs"?>
+<?cs include "macros.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="register">
+
+ <h1>Cr&eacute;er un compte</h1>
+
+ <?cs if registration.error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:registration.error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <form method="post" action="">
+  <fieldset>
+   <legend>Requis</legend>
+   <div>
+    <input type="hidden" name="action" value="create" />
+    <label>Nom d'utilisateur :
+     <input type="text" name="user" class="textwidget" size="20" />
+    </label>
+   </div>
+   <div>
+    <label>Mot de passe :
+     <input type="password" name="password" class="textwidget" size="20" />
+    </label>
+   </div>
+   <div>
+    <label>Confirmation du mot de passe :
+     <input type="password" name="password_confirm"
+            class="textwidget" size="20" />
+    </label>
+   </div>
+  </fieldset>
+  <fieldset>
+   <legend>Facultatif</legend>
+   <div>
+    <label>Nom :
+     <input type="text" name="name" class="textwidget" size="20" />
+    </label>
+   </div>
+   <div>
+    <label>Courriel :
+     <input type="text" name="email" class="textwidget" size="20" />
+    </label>
+    <?cs if reset_password_enabled ?>
+    <p>Fournir votre courriel vous autorisera &agrave; r&eacute;-initialiser
+    votre mot de passe en cas d'oubli.</p>
+    <?cs /if ?>
+   </div>
+  </fieldset>
+  <input type="submit" value="Cr&eacute;er le compte" />
+ </form>
+
+</div>
+
+<?cs include:"footer.cs"?>
Index: /TracAccountManager/0.10/acct_mgr/templates/reset_password.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/reset_password.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/reset_password.cs	(revision 2)
@@ -0,0 +1,57 @@
+<?cs include "header.cs"?>
+<?cs include "macros.cs"?>
+
+<div id="ctxtnav" class="nav"></div>
+
+<div id="content" class="register">
+
+ <h1>R&eacute;g&eacute;n&eacute;ration du mot de passe</h1>
+
+ <?cs if reset.logged_in ?>
+ <div class="system-message">
+  <h2>D&eacute;j&agrave; connect&eacute;</h2>
+  <p>
+   Vous &ecirc;tes d&eacute;j&agrave; connect&eacute;. Si vous avez besoin de changer votre mot de passe,
+   vous pouvez utiliser la page <a href="<?cs var:account_href ?>">Mon Compte</a>.
+  </p>
+ </div>
+ <?cs elif reset.sent_to_email ?>
+ <p>Un nouveau mot de passe vous a &eacute;t&eacute; envoy&eacute; &agrave; l'adresse <?cs var:reset.sent_to_email ?>.</p>
+ <?cs else ?>
+ <p>
+ Si vous avez oubli&eacute; votre mot de passe, entrez ci-dessous votre nom d'utilisateur et votre courriel
+ et un nouveau mot de passe vous sera envoy&eacute;.
+ </p>
+
+ <?cs if reset.error ?>
+ <div class="system-message">
+  <h2>Erreur</h2>
+  <p><?cs var:reset.error ?></p>
+ </div>
+ <?cs /if ?>
+
+ <?cs if reset.message ?>
+ <p><?cs var:reset.message ?></p>
+ <?cs /if ?>
+
+ <form method="post" action="">
+  <div>
+   <label>
+    Nom d'utilisateur :
+    <input type="text" name="username" class="textwidget" size="20" />
+   </label>
+  </div>
+  <div>
+   <label>
+    Courriel :
+    <input type="text" name="email" class="textwidget" size="20" />
+   </label>
+  </div>
+  <input type="submit" value="R&eacute;g&eacute;n&eacute;rer le mot de passe" />
+ </form>
+ <?cs /if ?>
+
+</div>
+
+<?cs include:"footer.cs"?>
+
Index: /TracAccountManager/0.10/acct_mgr/templates/reset_password_email.cs
===================================================================
--- /TracAccountManager/0.10/acct_mgr/templates/reset_password_email.cs	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/templates/reset_password_email.cs	(revision 2)
@@ -0,0 +1,12 @@
+Votre mot de passe Trac a &eacute;t&eacute; r&eacute;g&eacute;n&eacute;r&eacute;.
+
+Voici les informations de votre compte :
+
+Lien de connexion : <<?cs var:login.link ?>>
+Nom d'utilisateur : <?cs var:account.username ?>
+Mot de passe : <?cs var:account.password ?>
+
+--
+<?cs var:project.name ?> <<?cs var:project.url ?>>
+<?cs var:project.descr ?>
+
Index: /TracAccountManager/0.10/acct_mgr/tests/__init__.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/tests/__init__.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/tests/__init__.py	(revision 2)
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+import doctest
+import unittest
+
+def suite():
+    from acct_mgr.tests import htfile, db
+    suite = unittest.TestSuite()
+    suite.addTest(htfile.suite())
+    suite.addTest(db.suite())
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /TracAccountManager/0.10/acct_mgr/tests/db.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/tests/db.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/tests/db.py	(revision 2)
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2007 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+import os.path
+import shutil
+import tempfile
+import unittest
+
+from trac.test import EnvironmentStub, Mock
+
+from acct_mgr.db import SessionStore
+
+class _BaseTestCase(unittest.TestCase):
+    def setUp(self):
+        #self.basedir = os.path.realpath(tempfile.mkdtemp())
+        self.env = EnvironmentStub()
+        self.env.config.set('account-manager', 'password_store',
+                            'SessionStore')
+        self.store = SessionStore(self.env)
+        #self.env.path = os.path.join(self.basedir, 'trac-tempenv')
+        #os.mkdir(self.env.path)
+
+    def test_get_users(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.executemany("INSERT INTO session_attribute "
+                       "(sid,authenticated,name,value) "
+                       "VALUES (%s,1,'password',%s)",
+                       [('a', 'a'),
+                        ('b', 'b'),
+                        ('c', 'c')])
+        self.assertEqual(['a', 'b', 'c'], list(self.store.get_users()))
+
+    def test_has_user(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO session_attribute "
+                       "(sid,authenticated,name,value) "
+                       "VALUES (%s,1,'password',%s)",
+                       ('bar', 'bar'))
+
+        self.assertFalse(self.store.has_user('foo'))
+        self.assertTrue(self.store.has_user('bar'))
+
+    def test_create_user(self):
+        self.assertFalse(self.store.has_user('foo'))
+        self.store.set_password('foo', 'password')
+        self.assertTrue(self.store.has_user('foo'))
+ 
+    def test_update_password(self):
+        self.store.set_password('foo', 'pass1')
+        self.assertFalse(self.store.check_password('foo', 'pass2'))
+        self.store.set_password('foo', 'pass2')
+        self.assertTrue(self.store.check_password('foo', 'pass2'))
+
+    def test_delete_user(self):
+        self.store.set_password('foo', 'password')
+        self.assertTrue(self.store.has_user('foo'))
+        self.assertTrue(self.store.delete_user('foo'))
+        self.assertFalse(self.store.has_user('foo'))
+
+    def test_delete_nonexistant_user(self):
+        self.assertFalse(self.store.has_user('foo'))
+        self.assertFalse(self.store.delete_user('foo'))
+
+
+class HtDigestTestCase(_BaseTestCase):
+    def setUp(self):
+        _BaseTestCase.setUp(self)
+        self.env.config.set('account-manager', 'hash_method',
+                            'HtDigestHashMethod')
+        self.env.config.set('account-manager', 'htdigest_realm',
+                            'TestRealm')
+
+
+class HtPasswdTestCase(_BaseTestCase):
+    def setUp(self):
+        _BaseTestCase.setUp(self)
+        self.env.config.set('account-manager', 'hash_method',
+                            'HtPasswdHashMethod')
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(HtDigestTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(HtPasswdTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
+
+
Index: /TracAccountManager/0.10/acct_mgr/tests/htfile.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/tests/htfile.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/tests/htfile.py	(revision 2)
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+import os.path
+import shutil
+import tempfile
+import unittest
+
+from trac.test import EnvironmentStub, Mock
+
+from acct_mgr.htfile import HtDigestStore, HtPasswdStore
+
+class _BaseTestCase(unittest.TestCase):
+    def setUp(self):
+        self.basedir = os.path.realpath(tempfile.mkdtemp())
+        self.env = EnvironmentStub()
+        self.env.path = os.path.join(self.basedir, 'trac-tempenv')
+        os.mkdir(self.env.path)
+
+    def tearDown(self):
+        shutil.rmtree(self.basedir)
+
+    def _create_file(self, *path, **kw):
+        filename = os.path.join(self.basedir, *path)
+        dirname = os.path.dirname(filename)
+        if not os.path.exists(dirname):
+            os.makedirs(dirname)
+        fd = file(filename, 'w')
+        content = kw.get('content')
+        if content is not None:
+            fd.write(content)
+        fd.close()
+        return filename
+
+    def _do_password_test(self, filename, content):
+        filename = self._create_file(filename, content=content)
+        self.env.config.set('account-manager', 'password_file', filename)
+        self.assertTrue(self.store.check_password('user', 'password'))
+
+
+class HtDigestTestCase(_BaseTestCase):
+    def setUp(self):
+        _BaseTestCase.setUp(self)
+        self.env.config.set('account-manager', 'password_store',
+                            'HtDigestStore')
+        self.env.config.set('account-manager', 'htdigest_realm',
+                            'TestRealm')
+        self.store = HtDigestStore(self.env)
+
+    def test_userline(self):
+        self.assertEqual(self.store.userline('user', 'password'),
+                         'user:TestRealm:752b304cc7cf011d69ee9b79e2cd0866')
+
+    def test_file(self):
+        self._do_password_test('test_file', 
+                               'user:TestRealm:752b304cc7cf011d69ee9b79e2cd0866')
+
+class HtPasswdTestCase(_BaseTestCase):
+    def setUp(self):
+        _BaseTestCase.setUp(self)
+        self.env.config.set('account-manager', 'password_store',
+                            'HtPasswdStore')
+        self.store = HtPasswdStore(self.env)
+
+    def test_md5(self):
+        self._do_password_test('test_md5',
+                               'user:$apr1$xW/09...$fb150dT95SoL1HwXtHS/I0\n')
+
+    def test_crypt(self):
+        self._do_password_test('test_crypt', 'user:QdQ/xnl2v877c\n')
+
+    def test_sha(self):
+        self._do_password_test('test_sha',
+                               'user:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=\n')
+
+    def test_no_trailing_newline(self):
+        self._do_password_test('test_no_trailing_newline',
+                               'user:$apr1$xW/09...$fb150dT95SoL1HwXtHS/I0')
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(HtDigestTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(HtPasswdTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
+
Index: /TracAccountManager/0.10/acct_mgr/web_ui.py
===================================================================
--- /TracAccountManager/0.10/acct_mgr/web_ui.py	(revision 2)
+++ /TracAccountManager/0.10/acct_mgr/web_ui.py	(revision 2)
@@ -0,0 +1,416 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005 Matthew Good <trac@matt-good.net>
+#
+# "THE BEER-WARE LICENSE" (Revision 42):
+# <trac@matt-good.net> wrote this file.  As long as you retain this notice you
+# can do whatever you want with this stuff. If we meet some day, and you think
+# this stuff is worth it, you can buy me a beer in return.   Matthew Good
+#
+# Author: Matthew Good <trac@matt-good.net>
+
+from __future__ import generators
+
+import random
+import string
+
+from trac import perm, util
+from trac.core import *
+from trac.config import IntOption
+from trac.notification import NotificationSystem, NotifyEmail
+from trac.web import auth
+from trac.web.api import IAuthenticator
+from trac.web.main import IRequestHandler
+from trac.web.chrome import INavigationContributor, ITemplateProvider
+from trac.util import Markup
+
+from api import AccountManager
+
+def _create_user(req, env, check_permissions=True):
+    mgr = AccountManager(env)
+
+    user = req.args.get('user')
+    if not user:
+        raise TracError(u'Le nom d\'utilisateur ne peut être vide.')
+
+    if mgr.has_user(user):
+        raise TracError(u'Un autre coompte avec ce nom existe déjà.')
+
+    if check_permissions:
+        # disallow registration of accounts which have existing permissions
+        permission_system = perm.PermissionSystem(env)
+        if permission_system.get_user_permissions(user) != \
+           permission_system.get_user_permissions('authenticated'):
+            raise TracError(u'Un autre coompte avec ce nom existe déjà.')
+
+    password = req.args.get('password')
+    if not password:
+        raise TracError(u'Le mot de passe ne peut pas être vide.')
+
+    if password != req.args.get('password_confirm'):
+        raise TracError(u'Le mot de passe doit correspondre.')
+
+    mgr.set_password(user, password)
+
+    db = env.get_db_cnx()
+    cursor = db.cursor()
+    cursor.execute("SELECT count(*) FROM session "
+                   "WHERE sid=%s AND authenticated=1",
+                   (user,))
+    exists, = cursor.fetchone()
+    if not exists:
+        cursor.execute("INSERT INTO session "
+                       "(sid, authenticated, last_visit) "
+                       "VALUES (%s, 1, 0)",
+                       (user,))
+
+    for key in ('name', 'email'):
+        value = req.args.get(key)
+        if not value:
+            continue
+        cursor.execute("UPDATE session_attribute SET value=%s "
+                       "WHERE name=%s AND sid=%s AND authenticated=1",
+                       (value, key, user))
+        if not cursor.rowcount:
+            cursor.execute("INSERT INTO session_attribute "
+                           "(sid,authenticated,name,value) "
+                           "VALUES (%s,1,%s,%s)",
+                           (user, key, value))
+    db.commit()
+
+
+class PasswordResetNotification(NotifyEmail):
+    template_name = 'reset_password_email.cs'
+    _username = None
+
+    def get_recipients(self, resid):
+        return ([resid],[])
+
+    def get_smtp_address(self, addr):
+        """Overrides `get_smtp_address` in order to prevent CCing users
+        other than the user whose password is being reset.
+        """
+        if addr == self._username:
+            return NotifyEmail.get_smtp_address(self, addr)
+        else:
+            return None
+
+    def notify(self, username, password):
+        # save the username for use in `get_smtp_address`
+        self._username = username
+        self.hdf['account.username'] = username
+        self.hdf['account.password'] = password
+        self.hdf['login.link'] = self.env.abs_href.login()
+
+        projname = self.config.get('project', 'name')
+        subject = u'[%s] Ré-initialisation du mot de passe pour l\'utilisateur: %s' % (projname, username)
+
+        NotifyEmail.notify(self, username, subject)
+
+
+class AccountModule(Component):
+    """Allows users to change their password, reset their password if they've
+    forgotten it, or delete their account.  The settings for the AccountManager
+    module must be set in trac.ini in order to use this.
+    """
+
+    implements(INavigationContributor, IRequestHandler, ITemplateProvider)
+
+    _password_chars = string.ascii_letters + string.digits
+    password_length = IntOption('account-manager', 'generated_password_length', 8,
+                                u'Longueur des mots de passe aléatoirement générés,'
+                                u' créés à l\'occasion de la ré-initialisation du '
+                                u'mot de passe d\'un compte.')
+
+    def __init__(self):
+        self._write_check(log=True)
+
+    def _write_check(self, log=False):
+        writable = AccountManager(self.env).supports('set_password')
+        if not writable and log:
+            self.log.warn('AccountModule is disabled because the password '
+                          'store does not support writing.')
+        return writable
+
+    #INavigationContributor methods
+    def get_active_navigation_item(self, req):
+        if req.path_info == '/account':
+            return 'account'
+        elif req.path_info == '/reset_password':
+            return 'reset_password'
+
+    def get_navigation_items(self, req):
+        if not self._write_check():
+            return
+        if req.authname != 'anonymous':
+            yield 'metanav', 'account', Markup(u'<a href="%s">Mon Compte</a>',
+                                               (req.href.account()))
+        elif self.reset_password_enabled and not LoginModule(self.env).enabled:
+            yield 'metanav', 'reset_password', Markup(u'<a href="%s">Mot de passe oublié ?</a>',
+                                                      (req.href.reset_password()))
+
+    # IRequestHandler methods
+    def match_request(self, req):
+        return (req.path_info in ('/account', '/reset_password')
+                and self._write_check(log=True))
+
+    def process_request(self, req):
+        if req.path_info == '/account':
+            self._do_account(req)
+            return 'account.cs', None
+        elif req.path_info == '/reset_password':
+            self._do_reset_password(req)
+            return 'reset_password.cs', None
+
+    def reset_password_enabled(self):
+        return (self.env.is_component_enabled(AccountModule)
+                and NotificationSystem(self.env).smtp_enabled
+                and self._write_check())
+    reset_password_enabled = property(reset_password_enabled)
+
+    def _do_account(self, req):
+        if req.authname == 'anonymous':
+            req.redirect(self.env.href.wiki())
+        action = req.args.get('action')
+        delete_enabled = AccountManager(self.env).supports('delete_user')
+        req.hdf['delete_enabled'] = delete_enabled
+        if req.method == 'POST':
+            if action == 'change_password':
+                self._do_change_password(req)
+            elif action == 'delete':
+                self._do_delete(req)
+
+    def _do_reset_password(self, req):
+        if req.authname != 'anonymous':
+            req.hdf['reset.logged_in'] = True
+            req.hdf['account_href'] = req.href.account()
+            return
+        if req.method == 'POST':
+            username = req.args.get('username')
+            email = req.args.get('email')
+            if not username:
+                req.hdf['reset.error'] = u'Nom d\'utilisateur requis'
+                return
+            if not email:
+                req.hdf['reset.error'] = u'Courriel requis'
+                return
+
+            notifier = PasswordResetNotification(self.env)
+
+            if email != notifier.email_map.get(username):
+                req.hdf['reset.error'] = u'Le nom d\'utilisateur et le courriel ' \
+                                         u'ne correspondent pas à un compte connu.'
+                return
+
+            new_password = self._random_password()
+            notifier.notify(username, new_password)
+            AccountManager(self.env).set_password(username, new_password)
+            req.hdf['reset.sent_to_email'] = email
+
+    def _random_password(self):
+        return ''.join([random.choice(self._password_chars)
+                        for _ in xrange(self.password_length)])
+
+    def _do_change_password(self, req):
+        user = req.authname
+        mgr = AccountManager(self.env)
+        old_password = req.args.get('old_password')
+        if not old_password:
+            req.hdf['account.save_error'] = u'L\'ancien mot de passe ne peut pas être vide.'
+            return
+        if not mgr.check_password(user, old_password):
+            req.hdf['account.save_error'] = u'L\'ancien mot de passe n\'est pas correct.'
+            return
+
+        password = req.args.get('password')
+        if not password:
+            req.hdf['account.save_error'] = u'Le mot de passe ne peut pas être vide.'
+            return
+
+        if password != req.args.get('password_confirm'):
+            req.hdf['account.save_error'] = u'Les mots de passe doivent correspondre.'
+            return
+
+        mgr.set_password(user, password)
+        req.hdf['account.message'] = u'Mot de passe mis à jour avec succés.'
+
+    def _do_delete(self, req):
+        user = req.authname
+        mgr = AccountManager(self.env)
+        password = req.args.get('password')
+        if not password:
+            req.hdf['account.delete_error'] = u'Le mot de passe ne peut pas être vide.'
+            return
+        if not mgr.check_password(user, password):
+            req.hdf['account.delete_error'] = u'Le mot de passe n\'est pas correct.'
+            return
+
+        mgr.delete_user(user)
+        req.redirect(self.env.href.logout())
+
+    # ITemplateProvider
+
+    def get_htdocs_dirs(self):
+        """Return the absolute path of a directory containing additional
+        static resources (such as images, style sheets, etc).
+        """
+        return []
+
+    def get_templates_dirs(self):
+        """Return the absolute path of the directory containing the provided
+        ClearSilver templates.
+        """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+
+class RegistrationModule(Component):
+    """Provides users the ability to register a new account.
+    Requires configuration of the AccountManager module in trac.ini.
+    """
+
+    implements(INavigationContributor, IRequestHandler, ITemplateProvider)
+
+    def __init__(self):
+        self._enable_check(log=True)
+
+    def _enable_check(self, log=False):
+        writable = AccountManager(self.env).supports('set_password')
+        ignore_case = auth.LoginModule(self.env).ignore_case
+        if log:
+            if not writable:
+                self.log.warn('RegistrationModule is disabled because the '
+                              'password store does not support writing.')
+            if ignore_case:
+                self.log.warn('RegistrationModule is disabled because '
+                              'ignore_auth_case is enabled in trac.ini.  '
+                              'This setting needs disabled to support '
+                              'registration.')
+        return writable and not ignore_case
+
+    #INavigationContributor methods
+
+    def get_active_navigation_item(self, req):
+        return 'register'
+
+    def get_navigation_items(self, req):
+        if not self._enable_check():
+            return
+        if req.authname == 'anonymous':
+            yield 'metanav', 'register', Markup(u'<a href="%s">S\'enregistrer</a>',
+                                                (self.env.href.register()))
+
+    # IRequestHandler methods
+
+    def match_request(self, req):
+        return req.path_info == '/register' and self._enable_check(log=True)
+
+    def process_request(self, req):
+        if req.authname != 'anonymous':
+            req.redirect(self.env.href.account())
+        action = req.args.get('action')
+        if req.method == 'POST' and action == 'create':
+            try:
+                _create_user(req, self.env)
+            except TracError, e:
+                req.hdf['registration.error'] = e.message
+            else:
+                req.redirect(self.env.href.login())
+        req.hdf['reset_password_enabled'] = \
+            (self.env.is_component_enabled(AccountModule)
+             and NotificationSystem(self.env).smtp_enabled)
+
+        return 'register.cs', None
+
+
+    # ITemplateProvider
+
+    def get_htdocs_dirs(self):
+        """Return the absolute path of a directory containing additional
+        static resources (such as images, style sheets, etc).
+        """
+        return []
+
+    def get_templates_dirs(self):
+        """Return the absolute path of the directory containing the provided
+        ClearSilver templates.
+        """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+
+def if_enabled(func):
+    def wrap(self, *args, **kwds):
+        if not self.enabled:
+            return None
+        return func(self, *args, **kwds)
+    return wrap
+
+
+class LoginModule(auth.LoginModule):
+
+    implements(ITemplateProvider)
+
+    def authenticate(self, req):
+        if req.method == 'POST' and req.path_info.startswith('/login'):
+            req.environ['REMOTE_USER'] = self._remote_user(req)
+        return auth.LoginModule.authenticate(self, req)
+    authenticate = if_enabled(authenticate)
+
+    match_request = if_enabled(auth.LoginModule.match_request)
+
+    def process_request(self, req):
+        if req.path_info.startswith('/login') and req.authname == 'anonymous':
+            req.hdf['referer'] = self._referer(req)
+            if AccountModule(self.env).reset_password_enabled:
+                req.hdf['trac.href.reset_password'] = req.href.reset_password()
+            if req.method == 'POST':
+                req.hdf['login.error'] = u'Utilisateur ou mot de passe invalide'
+            return 'login.cs', None
+        return auth.LoginModule.process_request(self, req)
+
+    def _do_login(self, req):
+        if not req.remote_user:
+            req.redirect(self.env.abs_href())
+        return auth.LoginModule._do_login(self, req)
+
+    def _remote_user(self, req):
+        user = req.args.get('user')
+        password = req.args.get('password')
+        if not user or not password:
+            return None
+        if AccountManager(self.env).check_password(user, password):
+            return user
+        return None
+
+    def _redirect_back(self, req):
+        """Redirect the user back to the URL she came from."""
+        referer = self._referer(req)
+        if referer and not referer.startswith(req.base_url):
+            # don't redirect to external sites
+            referer = None
+        req.redirect(referer or self.env.abs_href())
+
+    def _referer(self, req):
+        return req.args.get('referer') or req.get_header('Referer')
+
+    def enabled(self):
+        # Users should disable the built-in authentication to use this one
+        return not self.env.is_component_enabled(auth.LoginModule)
+    enabled = property(enabled)
+
+    # ITemplateProvider
+
+    def get_htdocs_dirs(self):
+        """Return the absolute path of a directory containing additional
+        static resources (such as images, style sheets, etc).
+        """
+        return []
+
+    def get_templates_dirs(self):
+        """Return the absolute path of the directory containing the provided
+        ClearSilver templates.
+        """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
Index: /TracAccountManager/0.10/contrib/sessionstore_convert.py
===================================================================
--- /TracAccountManager/0.10/contrib/sessionstore_convert.py	(revision 2)
+++ /TracAccountManager/0.10/contrib/sessionstore_convert.py	(revision 2)
@@ -0,0 +1,36 @@
+import os
+import sys
+
+from trac.env import Environment
+from acct_mgr.api import AccountManager
+from acct_mgr.htfile import HtPasswdStore, HtDigestStore
+from acct_mgr.pwhash import HtPasswdHashMethod, HtDigestHashMethod
+
+env = Environment(sys.argv[1])
+
+store = AccountManager(env).password_store
+if isinstance(store, HtPasswdStore):
+    env.config.set('account-manager', 'hash_method', 'HtPasswdHashMethod')
+    prefix = ''
+elif isinstance(store, HtDigestStore):
+    env.config.set('account-manager', 'hash_method', 'HtDigestHashMethod')
+    prefix = store.realm + ':'
+else:
+    print >>sys.stderr, 'Unsupported password store:', store.__class__.__name__
+    sys.exit(1)
+
+password_file = os.path.join(env.path, env.config.get('account-manager',
+                                                      'password_file'))
+hashes = [line.strip().split(':', 1) for line in open(password_file)]
+hashes = [(u,p) for u,p in hashes if p.startswith(prefix)]
+if hashes:
+    db = env.get_db_cnx()
+    cursor = db.cursor()
+    cursor.executemany("INSERT INTO session_attribute "
+                       "(sid,authenticated,name,value) "
+                       "VALUES (%s,1,'password',%s)",
+                       hashes)
+    db.commit()
+
+env.config.set('account-manager', 'password_store', 'SessionStore')
+env.config.save()
Index: /TracAccountManager/0.10/setup.cfg
===================================================================
--- /TracAccountManager/0.10/setup.cfg	(revision 2)
+++ /TracAccountManager/0.10/setup.cfg	(revision 2)
@@ -0,0 +1,4 @@
+[egg_info]
+tag_build = dev
+tag_svn_revision = true
+
Index: /TracAccountManager/0.10/setup.py
===================================================================
--- /TracAccountManager/0.10/setup.py	(revision 2)
+++ /TracAccountManager/0.10/setup.py	(revision 2)
@@ -0,0 +1,40 @@
+#!/usr/bin/env python
+
+from setuptools import setup
+
+setup(
+    name = 'TracAccountManager',
+    version = '0.1.3',
+    author = 'Matthew Good',
+    author_email = 'trac@matt-good.net',
+    url = 'http://trac-hacks.org/wiki/AccountManagerPlugin',
+    description = u'Plugin de gestion de compte utilisateur pour Trac',
+
+    license = '''
+"THE BEER-WARE LICENSE" (Revision 42):
+<trac@matt-good.net> wrote this file.  As long as you retain this notice you
+can do whatever you want with this stuff. If we meet some day, and you think
+this stuff is worth it, you can buy me a beer in return.   Matthew Good''',
+
+    zip_safe=True,
+    packages=['acct_mgr'],
+    package_data={'acct_mgr': ['templates/*.cs']},
+
+    install_requires = [
+        'TracWebAdmin',
+    ],
+
+    entry_points = {
+        'trac.plugins': [
+            'acct_mgr.web_ui = acct_mgr.web_ui',
+            'acct_mgr.htfile = acct_mgr.htfile',
+            'acct_mgr.http = acct_mgr.http',
+            'acct_mgr.api = acct_mgr.api',
+            'acct_mgr.admin = acct_mgr.admin',
+            'acct_mgr.db = acct_mgr.db',
+            'acct_mgr.pwhash = acct_mgr.pwhash',
+        ]
+    },
+
+    test_suite = 'acct_mgr.tests.suite',
+)
Index: /TracAccountManager/processus.txt
===================================================================
--- /TracAccountManager/processus.txt	(revision 2)
+++ /TracAccountManager/processus.txt	(revision 2)
@@ -0,0 +1,16 @@
+ * Contrôle de la version avec :
+$ svn info http://trac-hacks.org/svn/accountmanagerplugin/0.10
+...
+Révision de la dernière modification : 2548
+...
+
+ * Export de la version:
+$ svn export http://trac-hacks.org/svn/accountmanagerplugin/0.10
+
+ * Mise à jour du code source
+
+ * Compilation du plugin:
+$ python setup.py bdist_egg
+
+ * Copie du fichier egg généré en indiquant la révision utilisée:
+$ cp dist/TracAccountManager-0.1.3dev-py2.5.egg ../TracAccountManager-0.1.3dev_r2548-py2.5.egg
