Edgewall Software

source: trunk/trac/web/auth.py

Last change on this file was 17500, checked in by Ryan J Ollos, 9 months ago

1.5.3dev: Update copyright year

  • Property svn:eol-style set to native
File size: 18.1 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2021 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 https://trac.edgewall.org/wiki/TracLicense.
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 https://trac.edgewall.org/log/.
14#
15# Author: Jonas Borgström <jonas@edgewall.com>
16
17from abc import ABCMeta, abstractmethod
18from base64 import b64decode, b64encode
19from hashlib import md5, sha1
20import os
21import re
22import sys
23import urllib.parse
24import urllib.request
25
26from trac.config import BoolOption, IntOption, Option
27from trac.core import *
28from trac.web.api import IAuthenticator, IRequestHandler
29from trac.web.chrome import Chrome, INavigationContributor
30from trac.util import hex_entropy, md5crypt
31from trac.util.compat import crypt
32from trac.util.concurrency import threading
33from trac.util.datefmt import time_now
34from trac.util.html import tag
35from trac.util.translation import _, tag_
36
37
38class LoginModule(Component):
39 """User authentication manager.
40
41 This component implements user authentication based on HTTP
42 authentication provided by the web-server, combined with cookies
43 for communicating the login information across the whole site.
44
45 This mechanism expects that the web-server is setup so that a
46 request to the path '/login' requires authentication (such as
47 Basic or Digest). The login name is then stored in the database
48 and associated with a unique key that gets passed back to the user
49 agent using the 'trac_auth' cookie. This cookie is used to
50 identify the user in subsequent requests to non-protected
51 resources.
52 """
53
54 implements(IAuthenticator, INavigationContributor, IRequestHandler)
55
56 is_valid_default_handler = False
57
58 check_ip = BoolOption('trac', 'check_auth_ip', 'false',
59 """Whether the IP address of the user should be checked for
60 authentication.""")
61
62 ignore_case = BoolOption('trac', 'ignore_auth_case', 'false',
63 """Whether login names should be converted to lower case.""")
64
65 auth_cookie_domain = Option('trac', 'auth_cookie_domain', '',
66 """Auth cookie domain attribute.
67
68 The auth cookie can be shared among multiple subdomains
69 by setting the value to the domain. (//since 1.2//)
70 """)
71
72 auth_cookie_lifetime = IntOption('trac', 'auth_cookie_lifetime', 0,
73 """Lifetime of the authentication cookie, in seconds.
74
75 This value determines how long the browser will cache
76 authentication information, and therefore, after how much
77 inactivity a user will have to log in again. The value
78 of 0 makes the cookie expire at the end of the browsing
79 session.
80 """)
81
82 auth_cookie_path = Option('trac', 'auth_cookie_path', '',
83 """Path for the authentication cookie. Set this to the common
84 base path of several Trac instances if you want them to share
85 the cookie.
86 """)
87
88 # IAuthenticator methods
89
90 def authenticate(self, req):
91 authname = None
92 if req.remote_user:
93 authname = req.remote_user
94 elif 'trac_auth' in req.incookie:
95 authname = self._get_name_for_cookie(req,
96 req.incookie['trac_auth'])
97
98 if not authname:
99 return None
100
101 if self.ignore_case:
102 authname = authname.lower()
103
104 return authname
105
106 # INavigationContributor methods
107
108 def get_active_navigation_item(self, req):
109 return 'login'
110
111 def get_navigation_items(self, req):
112 if req.is_authenticated:
113 yield ('metanav', 'login',
114 tag_("logged in as %(user)s",
115 user=Chrome(self.env).authorinfo(req, req.authname)))
116 yield ('metanav', 'logout',
117 tag.form(
118 tag.div(
119 tag.button(_("Logout"), name='logout',
120 type='submit'),
121 tag.input(type='hidden', name='__FORM_TOKEN',
122 value=req.form_token)
123 ),
124 action=req.href.logout(), method='post',
125 id='logout', class_='trac-logout'))
126 else:
127 yield ('metanav', 'login',
128 tag.a(_("Login"), href=req.href.login()))
129
130 # IRequestHandler methods
131
132 def match_request(self, req):
133 return re.match('/(login|logout)/?$', req.path_info)
134
135 def process_request(self, req):
136 if req.path_info.startswith('/login'):
137 self._do_login(req)
138 elif req.path_info.startswith('/logout'):
139 self._do_logout(req)
140 self._redirect_back(req)
141
142 # Internal methods
143
144 def _do_login(self, req):
145 """Log the remote user in.
146
147 This function expects to be called when the remote user name
148 is available. The user name is inserted into the `auth_cookie`
149 table and a cookie identifying the user on subsequent requests
150 is sent back to the client.
151
152 If the Authenticator was created with `ignore_case` set to
153 true, then the authentication name passed from the web server
154 in req.remote_user will be converted to lower case before
155 being used. This is to avoid problems on installations
156 authenticating against Windows which is not case sensitive
157 regarding user names and domain names
158 """
159 if not req.remote_user:
160 # TRANSLATOR: ... refer to the 'installation documentation'. (link)
161 inst_doc = tag.a(_("installation documentation"),
162 title=_("Configuring Authentication"),
163 href=req.href.wiki('TracInstall') +
164 "#ConfiguringAuthentication")
165 raise TracError(tag_("Authentication information not available. "
166 "Please refer to the %(inst_doc)s.",
167 inst_doc=inst_doc))
168 remote_user = req.remote_user
169 if self.ignore_case:
170 remote_user = remote_user.lower()
171
172 if req.authname not in ('anonymous', remote_user):
173 raise TracError(_("Already logged in as %(user)s.",
174 user=req.authname))
175
176 with self.env.db_transaction as db:
177 # Delete cookies older than 10 days
178 db("DELETE FROM auth_cookie WHERE time < %s",
179 (int(time_now()) - 86400 * 10,))
180 # Insert a new cookie if we haven't already got one
181 cookie = None
182 trac_auth = req.incookie.get('trac_auth')
183 if trac_auth is not None:
184 name = self._cookie_to_name(req, trac_auth)
185 cookie = trac_auth.value if name == remote_user else None
186 if cookie is None:
187 cookie = hex_entropy()
188 db("""
189 INSERT INTO auth_cookie (cookie, name, ipnr, time)
190 VALUES (%s, %s, %s, %s)
191 """, (cookie, remote_user, req.remote_addr,
192 int(time_now())))
193 req.authname = remote_user
194 req.outcookie['trac_auth'] = cookie
195 if self.auth_cookie_domain:
196 req.outcookie['trac_auth']['domain'] = self.auth_cookie_domain
197 req.outcookie['trac_auth']['path'] = self.auth_cookie_path \
198 or req.base_path or '/'
199 if self.env.secure_cookies:
200 req.outcookie['trac_auth']['secure'] = True
201 req.outcookie['trac_auth']['httponly'] = True
202 if self.auth_cookie_lifetime > 0:
203 req.outcookie['trac_auth']['expires'] = self.auth_cookie_lifetime
204
205 def _do_logout(self, req):
206 """Log the user out.
207
208 Simply deletes the corresponding record from the auth_cookie
209 table.
210 """
211 if req.method != 'POST' or not req.is_authenticated:
212 return
213
214 if 'trac_auth' in req.incookie:
215 self.env.db_transaction("DELETE FROM auth_cookie WHERE cookie=%s",
216 (req.incookie['trac_auth'].value,))
217 else:
218 self.env.db_transaction("DELETE FROM auth_cookie WHERE name=%s",
219 (req.authname,))
220 self._expire_cookie(req)
221 custom_redirect = self.config['metanav'].get('logout.redirect')
222 if custom_redirect:
223 if not re.match(r'https?:|/', custom_redirect):
224 custom_redirect = req.href(custom_redirect)
225 req.redirect(custom_redirect)
226
227 def _expire_cookie(self, req):
228 """Instruct the user agent to drop the auth cookie by setting
229 the "expires" property to a date in the past.
230 """
231 req.outcookie['trac_auth'] = ''
232 if self.auth_cookie_domain:
233 req.outcookie['trac_auth']['domain'] = self.auth_cookie_domain
234 req.outcookie['trac_auth']['path'] = self.auth_cookie_path \
235 or req.base_path or '/'
236 req.outcookie['trac_auth']['expires'] = -10000
237 if self.env.secure_cookies:
238 req.outcookie['trac_auth']['secure'] = True
239 req.outcookie['trac_auth']['httponly'] = True
240
241 def _cookie_to_name(self, req, cookie):
242 # This is separated from _get_name_for_cookie(), because the
243 # latter is overridden in AccountManager.
244 if self.check_ip:
245 sql = "SELECT name FROM auth_cookie WHERE cookie=%s AND ipnr=%s"
246 args = (cookie.value, req.remote_addr)
247 else:
248 sql = "SELECT name FROM auth_cookie WHERE cookie=%s"
249 args = (cookie.value,)
250 for name, in self.env.db_query(sql, args):
251 return name
252
253 def _get_name_for_cookie(self, req, cookie):
254 name = self._cookie_to_name(req, cookie)
255 if name is None:
256 # The cookie is invalid (or has been purged from the
257 # database), so tell the user agent to drop it as it is
258 # invalid
259 self._expire_cookie(req)
260 return name
261
262 def _redirect_back(self, req):
263 """Redirect the user back to the URL she came from."""
264 referer = self._referer(req)
265 if referer:
266 if not referer.startswith(('http://', 'https://')):
267 # Make URL absolute
268 scheme, host = urllib.parse.urlparse(req.base_url)[:2]
269 referer = urllib.parse.urlunparse((scheme, host, referer, None,
270 None, None))
271 pos = req.base_url.find(':')
272 base_scheme = req.base_url[:pos]
273 base_noscheme = req.base_url[pos:]
274 base_noscheme_norm = base_noscheme.rstrip('/')
275 referer_noscheme = referer[referer.find(':'):]
276 # only redirect to referer if it is from the same site
277 if referer_noscheme == base_noscheme or \
278 referer_noscheme.startswith(base_noscheme_norm + '/'):
279 # avoid redirect loops
280 if referer_noscheme.rstrip('/') != \
281 base_noscheme_norm + req.path_info.rstrip('/'):
282 req.redirect(base_scheme + referer_noscheme)
283 req.redirect(req.abs_href())
284
285 def _referer(self, req):
286 return req.args.get('referer') or req.get_header('Referer')
287
288
289class HTTPAuthentication(object, metaclass=ABCMeta):
290
291 @abstractmethod
292 def do_auth(self, environ, start_response):
293 pass
294
295
296class PasswordFileAuthentication(HTTPAuthentication):
297 def __init__(self, filename):
298 self.filename = filename
299 self.mtime = os.stat(filename).st_mtime
300 self.load(self.filename)
301 self._lock = threading.Lock()
302
303 def check_reload(self):
304 with self._lock:
305 mtime = os.stat(self.filename).st_mtime
306 if mtime != self.mtime:
307 self.mtime = mtime
308 self.load(self.filename)
309
310
311class BasicAuthentication(PasswordFileAuthentication):
312
313 def __init__(self, htpasswd, realm):
314 # FIXME pass a logger
315 self.realm = realm
316 self.crypt = crypt
317 self.hash = {}
318 PasswordFileAuthentication.__init__(self, htpasswd)
319
320 def load(self, filename):
321 # FIXME use a logger
322 self.hash = {}
323 with open(filename, encoding='utf-8') as fd:
324 for line in fd:
325 line = line.split('#')[0].strip()
326 if not line:
327 continue
328 try:
329 u, h = line.split(':')[:2]
330 except ValueError:
331 print("Warning: invalid password line in %s: %s"
332 % (filename, line), file=sys.stderr)
333 continue
334 if '$' in h or h.startswith('{SHA}') or self.crypt:
335 self.hash[u] = h
336 else:
337 print('Warning: cannot parse password for user "%s" '
338 'without the "crypt" module. Install the passlib '
339 'package from PyPI' % u, file=sys.stderr)
340
341 if self.hash == {}:
342 print("Warning: found no users in file:", filename,
343 file=sys.stderr)
344
345 def test(self, user, password):
346 self.check_reload()
347 the_hash = self.hash.get(user)
348 if the_hash is None:
349 return False
350
351 if the_hash.startswith('{SHA}'):
352 return str(b64encode(sha1(password.encode('utf-8')).digest()),
353 'ascii') == the_hash[5:]
354
355 if '$' not in the_hash:
356 return self.crypt(password, the_hash[:2]) == the_hash
357
358 magic, salt = the_hash[1:].split('$')[:2]
359 magic = '$' + magic + '$'
360 return md5crypt(password, salt, magic) == the_hash
361
362 def do_auth(self, environ, start_response):
363 header = environ.get('HTTP_AUTHORIZATION')
364 if header and header.startswith('Basic'):
365 auth = str(b64decode(header[6:]), 'utf-8').split(':')
366 if len(auth) == 2:
367 user, password = auth
368 if self.test(user, password):
369 return user
370
371 headers = [('WWW-Authenticate', 'Basic realm="%s"' % self.realm),
372 ('Content-Length', '0')]
373 write = start_response('401 Unauthorized', headers)
374 write(b'')
375
376
377class DigestAuthentication(PasswordFileAuthentication):
378 """A simple HTTP digest authentication implementation
379 (:rfc:`2617`)."""
380
381 MAX_NONCES = 100
382
383 def __init__(self, htdigest, realm):
384 # FIXME pass a logger
385 self.active_nonces = []
386 self.realm = realm
387 self.hash = {}
388 PasswordFileAuthentication.__init__(self, htdigest)
389
390 def load(self, filename):
391 """Load account information from apache style htdigest files,
392 only users from the specified realm are used
393 """
394 # FIXME use a logger
395 self.hash = {}
396 with open(filename, encoding='utf-8') as fd:
397 for line in fd:
398 line = line.split('#')[0].strip()
399 if not line:
400 continue
401 try:
402 u, r, a1 = line.split(':')[:3]
403 except ValueError:
404 print("Warning: invalid digest line in %s: %s"
405 % (filename, line), file=sys.stderr)
406 continue
407 if r == self.realm:
408 self.hash[u] = a1
409 if self.hash == {}:
410 print("Warning: found no users in realm:", self.realm,
411 file=sys.stderr)
412
413 def parse_auth_header(self, authorization):
414 values = {}
415 for value in urllib.request.parse_http_list(authorization):
416 n, v = value.split('=', 1)
417 if v[0] == '"' and v[-1] == '"':
418 values[n] = v[1:-1]
419 else:
420 values[n] = v
421 return values
422
423 def send_auth_request(self, environ, start_response, stale='false'):
424 """Send a digest challange to the browser. Record used nonces
425 to avoid replay attacks.
426 """
427 nonce = hex_entropy()
428 self.active_nonces.append(nonce)
429 if len(self.active_nonces) > self.MAX_NONCES:
430 self.active_nonces = self.active_nonces[-self.MAX_NONCES:]
431 headers = [('WWW-Authenticate',
432 'Digest realm="%s", nonce="%s", qop="auth", stale="%s"' %
433 (self.realm, nonce, stale)),
434 ('Content-Length', '0')]
435 write = start_response('401 Unauthorized', headers)
436 write(b'')
437
438 def do_auth(self, environ, start_response):
439 header = environ.get('HTTP_AUTHORIZATION')
440 if not header or not header.startswith('Digest'):
441 self.send_auth_request(environ, start_response)
442 return None
443
444 auth = self.parse_auth_header(header[7:])
445 required_keys = ['username', 'realm', 'nonce', 'uri', 'response',
446 'nc', 'cnonce']
447 # Invalid response?
448 for key in required_keys:
449 if key not in auth:
450 self.send_auth_request(environ, start_response)
451 return None
452 # Unknown user?
453 self.check_reload()
454 if auth['username'] not in self.hash:
455 self.send_auth_request(environ, start_response)
456 return None
457
458 kd = lambda x: md5(b':'.join(v.encode('utf-8') for v in x)).hexdigest()
459 a1 = self.hash[auth['username']]
460 a2 = kd([environ['REQUEST_METHOD'], auth['uri']])
461 # Is the response correct?
462 correct = kd([a1, auth['nonce'], auth['nc'],
463 auth['cnonce'], auth['qop'], a2])
464 if auth['response'] != correct:
465 self.send_auth_request(environ, start_response)
466 return None
467 # Is the nonce active, if not ask the client to use a new one
468 if not auth['nonce'] in self.active_nonces:
469 self.send_auth_request(environ, start_response, stale='true')
470 return None
471 self.active_nonces.remove(auth['nonce'])
472 return auth['username']
Note: See TracBrowser for help on using the repository browser.