39
|
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']
|