Mercurial > genshi > mirror
comparison examples/trac/trac/web/auth.py @ 39:93b4dcbafd7b trunk
Copy Trac to main branch.
author | cmlenz |
---|---|
date | Mon, 03 Jul 2006 18:53:27 +0000 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
38:ee669cb9cccc | 39:93b4dcbafd7b |
---|---|
1 # -*- coding: utf-8 -*- | |
2 # | |
3 # Copyright (C) 2003-2005 Edgewall Software | |
4 # Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com> | |
5 # All rights reserved. | |
6 # | |
7 # This software is licensed as described in the file COPYING, which | |
8 # you should have received as part of this distribution. The terms | |
9 # are also available at http://trac.edgewall.com/license.html. | |
10 # | |
11 # This software consists of voluntary contributions made by many | |
12 # individuals. For the exact contribution history, see the revision | |
13 # history and logs, available at http://projects.edgewall.com/trac/. | |
14 # | |
15 # Author: Jonas Borgström <jonas@edgewall.com> | |
16 | |
17 try: | |
18 from base64 import b64decode | |
19 except ImportError: | |
20 from base64 import decodestring as b64decode | |
21 import md5 | |
22 import re | |
23 import sys | |
24 import time | |
25 import urllib2 | |
26 | |
27 from trac.config import BoolOption | |
28 from trac.core import * | |
29 from trac.web.api import IAuthenticator, IRequestHandler | |
30 from trac.web.chrome import INavigationContributor | |
31 from trac.util import hex_entropy, md5crypt | |
32 from trac.util.markup import escape, html | |
33 | |
34 | |
35 class LoginModule(Component): | |
36 """Implements user authentication based on HTTP authentication provided by | |
37 the web-server, combined with cookies for communicating the login | |
38 information across the whole site. | |
39 | |
40 This mechanism expects that the web-server is setup so that a request to the | |
41 path '/login' requires authentication (such as Basic or Digest). The login | |
42 name is then stored in the database and associated with a unique key that | |
43 gets passed back to the user agent using the 'trac_auth' cookie. This cookie | |
44 is used to identify the user in subsequent requests to non-protected | |
45 resources. | |
46 """ | |
47 | |
48 implements(IAuthenticator, INavigationContributor, IRequestHandler) | |
49 | |
50 check_ip = BoolOption('trac', 'check_auth_ip', 'true', | |
51 """Whether the IP address of the user should be checked for | |
52 authentication (''since 0.9'').""") | |
53 | |
54 ignore_case = BoolOption('trac', 'ignore_auth_case', 'false', | |
55 """Whether case should be ignored for login names (''since 0.9'').""") | |
56 | |
57 # IAuthenticator methods | |
58 | |
59 def authenticate(self, req): | |
60 authname = None | |
61 if req.remote_user: | |
62 authname = req.remote_user | |
63 elif req.incookie.has_key('trac_auth'): | |
64 authname = self._get_name_for_cookie(req, req.incookie['trac_auth']) | |
65 | |
66 if not authname: | |
67 return None | |
68 | |
69 if self.ignore_case: | |
70 authname = authname.lower() | |
71 | |
72 return authname | |
73 | |
74 # INavigationContributor methods | |
75 | |
76 def get_active_navigation_item(self, req): | |
77 return 'login' | |
78 | |
79 def get_navigation_items(self, req): | |
80 if req.authname and req.authname != 'anonymous': | |
81 yield ('metanav', 'login', 'logged in as %s' % req.authname) | |
82 yield ('metanav', 'logout', | |
83 html.A('Logout', href=req.href.logout())) | |
84 else: | |
85 yield ('metanav', 'login', | |
86 html.A('Login', href=req.href.login())) | |
87 | |
88 # IRequestHandler methods | |
89 | |
90 def match_request(self, req): | |
91 return re.match('/(login|logout)/?', req.path_info) | |
92 | |
93 def process_request(self, req): | |
94 if req.path_info.startswith('/login'): | |
95 self._do_login(req) | |
96 elif req.path_info.startswith('/logout'): | |
97 self._do_logout(req) | |
98 self._redirect_back(req) | |
99 | |
100 # Internal methods | |
101 | |
102 def _do_login(self, req): | |
103 """Log the remote user in. | |
104 | |
105 This function expects to be called when the remote user name is | |
106 available. The user name is inserted into the `auth_cookie` table and a | |
107 cookie identifying the user on subsequent requests is sent back to the | |
108 client. | |
109 | |
110 If the Authenticator was created with `ignore_case` set to true, then | |
111 the authentication name passed from the web server in req.remote_user | |
112 will be converted to lower case before being used. This is to avoid | |
113 problems on installations authenticating against Windows which is not | |
114 case sensitive regarding user names and domain names | |
115 """ | |
116 assert req.remote_user, 'Authentication information not available.' | |
117 | |
118 remote_user = req.remote_user | |
119 if self.ignore_case: | |
120 remote_user = remote_user.lower() | |
121 | |
122 assert req.authname in ('anonymous', remote_user), \ | |
123 'Already logged in as %s.' % req.authname | |
124 | |
125 cookie = hex_entropy() | |
126 db = self.env.get_db_cnx() | |
127 cursor = db.cursor() | |
128 cursor.execute("INSERT INTO auth_cookie (cookie,name,ipnr,time) " | |
129 "VALUES (%s, %s, %s, %s)", (cookie, remote_user, | |
130 req.remote_addr, int(time.time()))) | |
131 db.commit() | |
132 | |
133 req.authname = remote_user | |
134 req.outcookie['trac_auth'] = cookie | |
135 req.outcookie['trac_auth']['path'] = req.href() | |
136 | |
137 def _do_logout(self, req): | |
138 """Log the user out. | |
139 | |
140 Simply deletes the corresponding record from the auth_cookie table. | |
141 """ | |
142 if req.authname == 'anonymous': | |
143 # Not logged in | |
144 return | |
145 | |
146 # While deleting this cookie we also take the opportunity to delete | |
147 # cookies older than 10 days | |
148 db = self.env.get_db_cnx() | |
149 cursor = db.cursor() | |
150 cursor.execute("DELETE FROM auth_cookie WHERE name=%s OR time < %s", | |
151 (req.authname, int(time.time()) - 86400 * 10)) | |
152 db.commit() | |
153 self._expire_cookie(req) | |
154 | |
155 def _expire_cookie(self, req): | |
156 """Instruct the user agent to drop the auth cookie by setting the | |
157 "expires" property to a date in the past. | |
158 """ | |
159 req.outcookie['trac_auth'] = '' | |
160 req.outcookie['trac_auth']['path'] = req.href() | |
161 req.outcookie['trac_auth']['expires'] = -10000 | |
162 | |
163 def _get_name_for_cookie(self, req, cookie): | |
164 db = self.env.get_db_cnx() | |
165 cursor = db.cursor() | |
166 if self.check_ip: | |
167 cursor.execute("SELECT name FROM auth_cookie " | |
168 "WHERE cookie=%s AND ipnr=%s", | |
169 (cookie.value, req.remote_addr)) | |
170 else: | |
171 cursor.execute("SELECT name FROM auth_cookie WHERE cookie=%s", | |
172 (cookie.value,)) | |
173 row = cursor.fetchone() | |
174 if not row: | |
175 # The cookie is invalid (or has been purged from the database), so | |
176 # tell the user agent to drop it as it is invalid | |
177 self._expire_cookie(req) | |
178 return None | |
179 | |
180 return row[0] | |
181 | |
182 def _redirect_back(self, req): | |
183 """Redirect the user back to the URL she came from.""" | |
184 referer = req.get_header('Referer') | |
185 if referer and not referer.startswith(req.base_url): | |
186 # only redirect to referer if it is from the same site | |
187 referer = None | |
188 req.redirect(referer or req.abs_href()) | |
189 | |
190 | |
191 class HTTPAuthentication(object): | |
192 | |
193 def do_auth(self, environ, start_response): | |
194 raise NotImplementedError | |
195 | |
196 | |
197 class BasicAuthentication(HTTPAuthentication): | |
198 | |
199 def __init__(self, htpasswd, realm): | |
200 self.hash = {} | |
201 self.realm = realm | |
202 try: | |
203 import crypt | |
204 self.crypt = crypt.crypt | |
205 except ImportError: | |
206 self.crypt = None | |
207 self.load(htpasswd) | |
208 | |
209 def load(self, filename): | |
210 fd = open(filename, 'r') | |
211 for line in fd: | |
212 u, h = line.strip().split(':') | |
213 if '$' in h or self.crypt: | |
214 self.hash[u] = h | |
215 else: | |
216 print >>sys.stderr, 'Warning: cannot parse password for ' \ | |
217 'user "%s" without the "crypt" module' % u | |
218 | |
219 if self.hash == {}: | |
220 print >> sys.stderr, "Warning: found no users in file:", filename | |
221 | |
222 def test(self, user, password): | |
223 the_hash = self.hash.get(user) | |
224 if the_hash is None: | |
225 return False | |
226 | |
227 if not '$' in the_hash: | |
228 return self.crypt(password, the_hash[:2]) == the_hash | |
229 | |
230 magic, salt = the_hash[1:].split('$')[:2] | |
231 magic = '$' + magic + '$' | |
232 return md5crypt(password, salt, magic) == the_hash | |
233 | |
234 def do_auth(self, environ, start_response): | |
235 header = environ.get('HTTP_AUTHORIZATION') | |
236 if header and header.startswith('Basic'): | |
237 auth = b64decode(header[6:]).split(':') | |
238 if len(auth) == 2: | |
239 user, password = auth | |
240 if self.test(user, password): | |
241 return user | |
242 | |
243 start_response('401 Unauthorized', | |
244 [('WWW-Authenticate', 'Basic realm="%s"' | |
245 % self.realm)])('') | |
246 | |
247 | |
248 class DigestAuthentication(HTTPAuthentication): | |
249 """A simple HTTP digest authentication implementation (RFC 2617).""" | |
250 | |
251 MAX_NONCES = 100 | |
252 | |
253 def __init__(self, htdigest, realm): | |
254 self.active_nonces = [] | |
255 self.hash = {} | |
256 self.realm = realm | |
257 self.load_htdigest(htdigest, realm) | |
258 | |
259 def load_htdigest(self, filename, realm): | |
260 """Load account information from apache style htdigest files, only | |
261 users from the specified realm are used | |
262 """ | |
263 fd = open(filename, 'r') | |
264 for line in fd.readlines(): | |
265 u, r, a1 = line.strip().split(':') | |
266 if r == realm: | |
267 self.hash[u] = a1 | |
268 if self.hash == {}: | |
269 print >> sys.stderr, "Warning: found no users in realm:", realm | |
270 | |
271 def parse_auth_header(self, authorization): | |
272 values = {} | |
273 for value in urllib2.parse_http_list(authorization): | |
274 n, v = value.split('=', 1) | |
275 if v[0] == '"' and v[-1] == '"': | |
276 values[n] = v[1:-1] | |
277 else: | |
278 values[n] = v | |
279 return values | |
280 | |
281 def send_auth_request(self, environ, start_response, stale='false'): | |
282 """Send a digest challange to the browser. Record used nonces | |
283 to avoid replay attacks. | |
284 """ | |
285 nonce = hex_entropy() | |
286 self.active_nonces.append(nonce) | |
287 if len(self.active_nonces) > self.MAX_NONCES: | |
288 self.active_nonces = self.active_nonces[-self.MAX_NONCES:] | |
289 start_response('401 Unauthorized', | |
290 [('WWW-Authenticate', | |
291 'Digest realm="%s", nonce="%s", qop="auth", stale="%s"' | |
292 % (self.realm, nonce, stale))])('') | |
293 | |
294 def do_auth(self, environ, start_response): | |
295 header = environ.get('HTTP_AUTHORIZATION') | |
296 if not header or not header.startswith('Digest'): | |
297 self.send_auth_request(environ, start_response) | |
298 return None | |
299 | |
300 auth = self.parse_auth_header(header[7:]) | |
301 required_keys = ['username', 'realm', 'nonce', 'uri', 'response', | |
302 'nc', 'cnonce'] | |
303 # Invalid response? | |
304 for key in required_keys: | |
305 if not auth.has_key(key): | |
306 self.send_auth_request(environ, start_response) | |
307 return None | |
308 # Unknown user? | |
309 if not self.hash.has_key(auth['username']): | |
310 self.send_auth_request(environ, start_response) | |
311 return None | |
312 | |
313 kd = lambda x: md5.md5(':'.join(x)).hexdigest() | |
314 a1 = self.hash[auth['username']] | |
315 a2 = kd([environ['REQUEST_METHOD'], auth['uri']]) | |
316 # Is the response correct? | |
317 correct = kd([a1, auth['nonce'], auth['nc'], | |
318 auth['cnonce'], auth['qop'], a2]) | |
319 if auth['response'] != correct: | |
320 self.send_auth_request(environ, start_response) | |
321 return None | |
322 # Is the nonce active, if not ask the client to use a new one | |
323 if not auth['nonce'] in self.active_nonces: | |
324 self.send_auth_request(environ, start_response, stale='true') | |
325 return None | |
326 self.active_nonces.remove(auth['nonce']) | |
327 return auth['username'] |