| | 1 | # -*- coding: iso8859-1 -*- |
| | 2 | # |
| | 3 | # Copyright (C) 2005 Matthew Good <trac@matt-good.net> |
| | 4 | # |
| | 5 | # Trac is free software; you can redistribute it and/or |
| | 6 | # modify it under the terms of the GNU General Public License as |
| | 7 | # published by the Free Software Foundation; either version 2 of the |
| | 8 | # License, or (at your option) any later version. |
| | 9 | # |
| | 10 | # Trac is distributed in the hope that it will be useful, |
| | 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| | 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| | 13 | # General Public License for more details. |
| | 14 | # |
| | 15 | # You should have received a copy of the GNU General Public License |
| | 16 | # along with this program; if not, write to the Free Software |
| | 17 | # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. |
| | 18 | # |
| | 19 | # Author: Matthew Good <trac@matt-good.net> |
| | 20 | |
| | 21 | from __future__ import generators |
| | 22 | |
| | 23 | import os.path |
| | 24 | import fileinput |
| | 25 | import inspect |
| | 26 | import md5 |
| | 27 | from binascii import hexlify |
| | 28 | |
| | 29 | from trac import perm, util |
| | 30 | from trac.util import switch |
| | 31 | from trac.core import * |
| | 32 | from trac.web.chrome import INavigationContributor |
| | 33 | from trac.web.main import IRequestHandler |
| | 34 | from trac.md5crypt import md5crypt |
| | 35 | |
| | 36 | class IPasswordStore(Interface): |
| | 37 | def config_key(self): |
| | 38 | ''' |
| | 39 | Returns a string used to identify this implementation in the config. |
| | 40 | This password storage implementation will be used if the value of |
| | 41 | the config property "account-management.password_format" matches. |
| | 42 | ''' |
| | 43 | |
| | 44 | def get_users(self): |
| | 45 | ''' |
| | 46 | Returns an iterable of the known usernames |
| | 47 | ''' |
| | 48 | |
| | 49 | def has_user(self, user): |
| | 50 | ''' |
| | 51 | Returns whether the user account exists. |
| | 52 | ''' |
| | 53 | |
| | 54 | def set_password(self, user, password): |
| | 55 | ''' |
| | 56 | Sets the password for the user. This should create the user account |
| | 57 | if it doesn't already exist. |
| | 58 | ''' |
| | 59 | |
| | 60 | def delete_user(self, user): |
| | 61 | ''' |
| | 62 | Deletes the user account. |
| | 63 | ''' |
| | 64 | |
| | 65 | # os.urandom was added in Python 2.4 |
| | 66 | # try to fall back on reading from /dev/urandom on older Python versions |
| | 67 | try: |
| | 68 | from os import urandom |
| | 69 | except ImportError: |
| | 70 | def urandom(n): |
| | 71 | return open('/dev/urandom').read(n) |
| | 72 | |
| | 73 | class AccountModule(Component): |
| | 74 | |
| | 75 | implements(INavigationContributor, IRequestHandler) |
| | 76 | |
| | 77 | #INavigationContributor methods |
| | 78 | def get_active_navigation_item(self, req): |
| | 79 | return 'account' |
| | 80 | |
| | 81 | def get_navigation_items(self, req): |
| | 82 | if not self.is_enabled(): |
| | 83 | return |
| | 84 | if req.authname != 'anonymous': |
| | 85 | yield 'metanav', 'account', '<a href="%s">My Account</a>' \ |
| | 86 | % (self.env.href.account()) |
| | 87 | |
| | 88 | # IRequestHandler methods |
| | 89 | def match_request(self, req): |
| | 90 | return self.is_enabled() and req.path_info == '/account' |
| | 91 | |
| | 92 | def process_request(self, req): |
| | 93 | if req.authname == 'anonymous': |
| | 94 | req.redirect(self.env.href.wiki()) |
| | 95 | action = req.args.get('action') |
| | 96 | if req.method == 'POST': |
| | 97 | for case in switch(action): |
| | 98 | if case('change_password'): |
| | 99 | self._do_change_password(req) |
| | 100 | break |
| | 101 | elif case('delete'): |
| | 102 | self._do_delete(req) |
| | 103 | break |
| | 104 | return 'account.cs', None |
| | 105 | |
| | 106 | def _do_change_password(self, req): |
| | 107 | user = req.authname |
| | 108 | password = req.args.get('password') |
| | 109 | if not password: |
| | 110 | req.hdf['account.error'] = 'Password cannot be empty.' |
| | 111 | return |
| | 112 | |
| | 113 | if password != req.args.get('password_confirm'): |
| | 114 | req.hdf['account.error'] = 'The passwords must match.' |
| | 115 | return |
| | 116 | |
| | 117 | AccountManager(self.env).set_password(user, password) |
| | 118 | req.hdf['account.message'] = 'Password successfully updated.' |
| | 119 | |
| | 120 | def _do_delete(self, req): |
| | 121 | user = req.authname |
| | 122 | AccountManager(self.env).delete_user(user) |
| | 123 | req.redirect(self.env.href.logout()) |
| | 124 | |
| | 125 | def is_enabled(self): |
| | 126 | return self.config.get('account-management', |
| | 127 | 'enabled').lower() in util.TRUE |
| | 128 | |
| | 129 | |
| | 130 | class RegistrationModule(Component): |
| | 131 | |
| | 132 | implements(INavigationContributor, IRequestHandler) |
| | 133 | |
| | 134 | #INavigationContributor methods |
| | 135 | def get_active_navigation_item(self, req): |
| | 136 | return 'register' |
| | 137 | |
| | 138 | def get_navigation_items(self, req): |
| | 139 | if not self.is_enabled(): |
| | 140 | return |
| | 141 | if req.authname == 'anonymous': |
| | 142 | yield 'metanav', 'register', '<a href="%s">Register</a>' \ |
| | 143 | % (self.env.href.register()) |
| | 144 | # IRequestHandler methods |
| | 145 | |
| | 146 | def match_request(self, req): |
| | 147 | return self.is_enabled() and req.path_info == '/register' |
| | 148 | |
| | 149 | def process_request(self, req): |
| | 150 | if req.authname != 'anonymous': |
| | 151 | req.redirect(self.env.href.account()) |
| | 152 | action = req.args.get('action') |
| | 153 | if req.method == 'POST' and action == 'create': |
| | 154 | self._do_create(req) |
| | 155 | return 'register.cs', None |
| | 156 | |
| | 157 | def is_enabled(self): |
| | 158 | return self.config.get('account-management', |
| | 159 | 'enabled').lower() in util.TRUE and \ |
| | 160 | self.config.get('account-management', |
| | 161 | 'registration_enabled').lower() in util.TRUE |
| | 162 | |
| | 163 | def _do_create(self, req): |
| | 164 | mgr = AccountManager(self.env) |
| | 165 | |
| | 166 | user = req.args.get('user') |
| | 167 | if mgr.has_user(user): |
| | 168 | req.hdf['registration.error'] = \ |
| | 169 | 'Another account with that name already exists.' |
| | 170 | return |
| | 171 | |
| | 172 | password = req.args.get('password') |
| | 173 | if not password: |
| | 174 | req.hdf['registration.error'] = 'Password cannot be empty.' |
| | 175 | return |
| | 176 | |
| | 177 | if password != req.args.get('password_confirm'): |
| | 178 | req.hdf['registration.error'] = 'The passwords must match.' |
| | 179 | return |
| | 180 | |
| | 181 | mgr.set_password(user, password) |
| | 182 | req.redirect(self.env.href.login()) |
| | 183 | |
| | 184 | class AccountManager(Component): |
| | 185 | |
| | 186 | stores = ExtensionPoint(IPasswordStore) |
| | 187 | |
| | 188 | def __init__(self): |
| | 189 | self.store_map = {} |
| | 190 | for s in self.stores: |
| | 191 | self.store_map[s.config_key()] = s |
| | 192 | |
| | 193 | def _dispatch(self, func): |
| | 194 | return getattr(self._get_store(), func) |
| | 195 | |
| | 196 | def _get_store(self): |
| | 197 | fmt = self.config.get('account-management', 'password_format') |
| | 198 | return self.store_map[fmt] |
| | 199 | |
| | 200 | class DispatchProperty(object): |
| | 201 | def __init__(self, name, fget): |
| | 202 | self.name = name |
| | 203 | self.fget = fget |
| | 204 | |
| | 205 | def __get__(self, obj, objtype=None): |
| | 206 | if obj is None: |
| | 207 | return self |
| | 208 | return self.fget(obj, self.name) |
| | 209 | |
| | 210 | # Add the IPasswordStore methods to the AccountManager to dispatch to the |
| | 211 | # active implementation |
| | 212 | for func, v in inspect.getmembers(IPasswordStore, inspect.ismethod): |
| | 213 | setattr(AccountManager, func, DispatchProperty(func, AccountManager._dispatch)) |
| | 214 | |
| | 215 | class AbstractPasswordFileStore(Component): |
| | 216 | ''' |
| | 217 | Abstract class to use as a base for Apache's htpasswd and htdigest style |
| | 218 | password file formats |
| | 219 | ''' |
| | 220 | |
| | 221 | def has_user(self, user): |
| | 222 | return user in self.get_users() |
| | 223 | |
| | 224 | def get_users(self): |
| | 225 | if not os.path.exists(self._get_filename()): |
| | 226 | return [] |
| | 227 | return self._get_users(self._get_filename()) |
| | 228 | |
| | 229 | def set_password(self, user, password): |
| | 230 | self._update_file(self.prefix(user), self.userline(user, password)) |
| | 231 | |
| | 232 | def delete_user(self, user): |
| | 233 | self._update_file(self.prefix(user), None) |
| | 234 | |
| | 235 | def _get_filename(self): |
| | 236 | return self.config.get('account-management', 'password_file') |
| | 237 | |
| | 238 | def _update_file(self, prefix, userline): |
| | 239 | filename = self._get_filename() |
| | 240 | written = False |
| | 241 | if os.path.exists(filename): |
| | 242 | for line in fileinput.input(filename, inplace=True): |
| | 243 | if not line.startswith(prefix): |
| | 244 | print line, |
| | 245 | elif not written and userline: |
| | 246 | print userline |
| | 247 | written = True |
| | 248 | if not written and userline: |
| | 249 | f = open(filename, 'a') |
| | 250 | print >>f, userline |
| | 251 | f.close() |
| | 252 | |
| | 253 | |
| | 254 | def salt(): |
| | 255 | s = '' |
| | 256 | v = long(hexlify(urandom(4)), 16) |
| | 257 | itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' |
| | 258 | for i in range(8): |
| | 259 | s += itoa64[v & 0x3f]; v >>= 6 |
| | 260 | return s |
| | 261 | |
| | 262 | |
| | 263 | class HtPasswdStore(AbstractPasswordFileStore): |
| | 264 | |
| | 265 | implements(IPasswordStore) |
| | 266 | |
| | 267 | def config_key(self): |
| | 268 | return 'htpasswd' |
| | 269 | |
| | 270 | def prefix(self, user): |
| | 271 | return user + ':' |
| | 272 | |
| | 273 | def userline(self, user, password): |
| | 274 | return self.prefix(user) + md5crypt(password, salt(), |
| | 275 | '$apr1$') |
| | 276 | |
| | 277 | def _get_users(self, filename): |
| | 278 | f = open(filename) |
| | 279 | for line in f: |
| | 280 | user = line.split(':', 1)[0] |
| | 281 | if user: |
| | 282 | yield user |
| | 283 | |
| | 284 | |
| | 285 | class HtDigestStore(AbstractPasswordFileStore): |
| | 286 | |
| | 287 | implements(IPasswordStore) |
| | 288 | |
| | 289 | def __init__(self): |
| | 290 | self.realm = self.config.get('account-management', 'htdigest_realm') |
| | 291 | |
| | 292 | def config_key(self): |
| | 293 | return 'htdigest' |
| | 294 | |
| | 295 | def prefix(self, user): |
| | 296 | return '%s:%s:' % (user, self.realm) |
| | 297 | |
| | 298 | def userline(self, user, password): |
| | 299 | p = self.prefix(user) |
| | 300 | return p + md5.new(p + password).hexdigest() |
| | 301 | |
| | 302 | def _get_users(self, filename): |
| | 303 | f = open(filename) |
| | 304 | for line in f: |
| | 305 | args = line.split(':')[:2] |
| | 306 | if len(args) == 2: |
| | 307 | user, realm = args |
| | 308 | if realm == self.realm and user: |
| | 309 | yield user |
| | 310 | |