| 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 |
|
|---|
| 17 | from abc import ABCMeta, abstractmethod
|
|---|
| 18 | from base64 import b64decode, b64encode
|
|---|
| 19 | from hashlib import md5, sha1
|
|---|
| 20 | import os
|
|---|
| 21 | import re
|
|---|
| 22 | import sys
|
|---|
| 23 | import urllib.parse
|
|---|
| 24 | import urllib.request
|
|---|
| 25 |
|
|---|
| 26 | from trac.config import BoolOption, IntOption, Option
|
|---|
| 27 | from trac.core import *
|
|---|
| 28 | from trac.web.api import IAuthenticator, IRequestHandler
|
|---|
| 29 | from trac.web.chrome import Chrome, INavigationContributor
|
|---|
| 30 | from trac.util import hex_entropy, md5crypt
|
|---|
| 31 | from trac.util.compat import crypt
|
|---|
| 32 | from trac.util.concurrency import threading
|
|---|
| 33 | from trac.util.datefmt import time_now
|
|---|
| 34 | from trac.util.html import tag
|
|---|
| 35 | from trac.util.translation import _, tag_
|
|---|
| 36 |
|
|---|
| 37 |
|
|---|
| 38 | class 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 |
|
|---|
| 289 | class HTTPAuthentication(object, metaclass=ABCMeta):
|
|---|
| 290 |
|
|---|
| 291 | @abstractmethod
|
|---|
| 292 | def do_auth(self, environ, start_response):
|
|---|
| 293 | pass
|
|---|
| 294 |
|
|---|
| 295 |
|
|---|
| 296 | class 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 |
|
|---|
| 311 | class 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 |
|
|---|
| 377 | class 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']
|
|---|