Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ 23ccba04

History | View | Annotate | Download (9.2 kB)

1
#
2
#
3

    
4
# Copyright (C) 2007, 2008 Google Inc.
5
#
6
# This program is free software; you can redistribute it and/or modify
7
# it under the terms of the GNU General Public License as published by
8
# the Free Software Foundation; either version 2 of the License, or
9
# (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful, but
12
# WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14
# General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19
# 02110-1301, USA.
20

    
21
"""HTTP authentication module.
22

23
"""
24

    
25
import logging
26
import re
27
import base64
28
import binascii
29

    
30
from ganeti import utils
31
from ganeti import http
32

    
33
from cStringIO import StringIO
34

    
35
try:
36
  from hashlib import md5
37
except ImportError:
38
  from md5 import new as md5
39

    
40

    
41
# Digest types from RFC2617
42
HTTP_BASIC_AUTH = "Basic"
43
HTTP_DIGEST_AUTH = "Digest"
44

    
45
# Not exactly as described in RFC2616, section 2.2, but good enough
46
_NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
47

    
48

    
49
def _FormatAuthHeader(scheme, params):
50
  """Formats WWW-Authentication header value as per RFC2617, section 1.2
51

52
  @type scheme: str
53
  @param scheme: Authentication scheme
54
  @type params: dict
55
  @param params: Additional parameters
56
  @rtype: str
57
  @return: Formatted header value
58

59
  """
60
  buf = StringIO()
61

    
62
  buf.write(scheme)
63

    
64
  for name, value in params.iteritems():
65
    buf.write(" ")
66
    buf.write(name)
67
    buf.write("=")
68
    if _NOQUOTE.match(value):
69
      buf.write(value)
70
    else:
71
      buf.write("\"")
72
      # TODO: Better quoting
73
      buf.write(value.replace("\"", "\\\""))
74
      buf.write("\"")
75

    
76
  return buf.getvalue()
77

    
78

    
79
class HttpServerRequestAuthentication(object):
80
  # Default authentication realm
81
  AUTH_REALM = "Unspecified"
82

    
83
  # Schemes for passwords
84
  _CLEARTEXT_SCHEME = "{CLEARTEXT}"
85
  _HA1_SCHEME = "{HA1}"
86

    
87
  def GetAuthRealm(self, req):
88
    """Returns the authentication realm for a request.
89

90
    May be overridden by a subclass, which then can return different realms for
91
    different paths.
92

93
    @type req: L{http.server._HttpServerRequest}
94
    @param req: HTTP request context
95
    @rtype: string
96
    @return: Authentication realm
97

98
    """
99
    # today we don't have per-request filtering, but we might want to
100
    # add it in the future
101
    # pylint: disable-msg=W0613
102
    return self.AUTH_REALM
103

    
104
  def AuthenticationRequired(self, req):
105
    """Determines whether authentication is required for a request.
106

107
    To enable authentication, override this function in a subclass and return
108
    C{True}. L{AUTH_REALM} must be set.
109

110
    @type req: L{http.server._HttpServerRequest}
111
    @param req: HTTP request context
112

113
    """
114
    return False
115

    
116
  def PreHandleRequest(self, req):
117
    """Called before a request is handled.
118

119
    @type req: L{http.server._HttpServerRequest}
120
    @param req: HTTP request context
121

122
    """
123
    # Authentication not required, and no credentials given?
124
    if not (self.AuthenticationRequired(req) or
125
            (req.request_headers and
126
             http.HTTP_AUTHORIZATION in req.request_headers)):
127
      return
128

    
129
    realm = self.GetAuthRealm(req)
130

    
131
    if not realm:
132
      raise AssertionError("No authentication realm")
133

    
134
    # Check "Authorization" header
135
    if self._CheckAuthorization(req):
136
      # User successfully authenticated
137
      return
138

    
139
    # Send 401 Unauthorized response
140
    params = {
141
      "realm": realm,
142
      }
143

    
144
    # TODO: Support for Digest authentication (RFC2617, section 3).
145
    # TODO: Support for more than one WWW-Authenticate header with the same
146
    # response (RFC2617, section 4.6).
147
    headers = {
148
      http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
149
      }
150

    
151
    raise http.HttpUnauthorized(headers=headers)
152

    
153
  def _CheckAuthorization(self, req):
154
    """Checks 'Authorization' header sent by client.
155

156
    @type req: L{http.server._HttpServerRequest}
157
    @param req: HTTP request context
158
    @rtype: bool
159
    @return: Whether user is allowed to execute request
160

161
    """
162
    credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
163
    if not credentials:
164
      return False
165

    
166
    # Extract scheme
167
    parts = credentials.strip().split(None, 2)
168
    if len(parts) < 1:
169
      # Missing scheme
170
      return False
171

    
172
    # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
173
    # token to identify the authentication scheme [...]"
174
    scheme = parts[0].lower()
175

    
176
    if scheme == HTTP_BASIC_AUTH.lower():
177
      # Do basic authentication
178
      if len(parts) < 2:
179
        raise http.HttpBadRequest(message=("Basic authentication requires"
180
                                           " credentials"))
181
      return self._CheckBasicAuthorization(req, parts[1])
182

    
183
    elif scheme == HTTP_DIGEST_AUTH.lower():
184
      # TODO: Implement digest authentication
185
      # RFC2617, section 3.3: "Note that the HTTP server does not actually need
186
      # to know the user's cleartext password. As long as H(A1) is available to
187
      # the server, the validity of an Authorization header may be verified."
188
      pass
189

    
190
    # Unsupported authentication scheme
191
    return False
192

    
193
  def _CheckBasicAuthorization(self, req, in_data):
194
    """Checks credentials sent for basic authentication.
195

196
    @type req: L{http.server._HttpServerRequest}
197
    @param req: HTTP request context
198
    @type in_data: str
199
    @param in_data: Username and password encoded as Base64
200
    @rtype: bool
201
    @return: Whether user is allowed to execute request
202

203
    """
204
    try:
205
      creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
206
    except (TypeError, binascii.Error, UnicodeError):
207
      logging.exception("Error when decoding Basic authentication credentials")
208
      return False
209

    
210
    if ":" not in creds:
211
      return False
212

    
213
    (user, password) = creds.split(":", 1)
214

    
215
    return self.Authenticate(req, user, password)
216

    
217
  def Authenticate(self, req, user, password):
218
    """Checks the password for a user.
219

220
    This function MUST be overridden by a subclass.
221

222
    """
223
    raise NotImplementedError()
224

    
225
  def VerifyBasicAuthPassword(self, req, username, password, expected):
226
    """Checks the password for basic authentication.
227

228
    As long as they don't start with an opening brace ("E{lb}"), old passwords
229
    are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
230
    consists of the username, the authentication realm and the actual password.
231

232
    @type req: L{http.server._HttpServerRequest}
233
    @param req: HTTP request context
234
    @type username: string
235
    @param username: Username from HTTP headers
236
    @type password: string
237
    @param password: Password from HTTP headers
238
    @type expected: string
239
    @param expected: Expected password with optional scheme prefix (e.g. from
240
                     users file)
241

242
    """
243
    # Backwards compatibility for old-style passwords without a scheme
244
    if not expected.startswith("{"):
245
      expected = self._CLEARTEXT_SCHEME + expected
246

    
247
    # Check again, just to be sure
248
    if not expected.startswith("{"):
249
      raise AssertionError("Invalid scheme")
250

    
251
    scheme_end_idx = expected.find("}", 1)
252

    
253
    # Ensure scheme has a length of at least one character
254
    if scheme_end_idx <= 1:
255
      logging.warning("Invalid scheme in password for user '%s'", username)
256
      return False
257

    
258
    scheme = expected[:scheme_end_idx + 1].upper()
259
    expected_password = expected[scheme_end_idx + 1:]
260

    
261
    # Good old plain text password
262
    if scheme == self._CLEARTEXT_SCHEME:
263
      return password == expected_password
264

    
265
    # H(A1) as described in RFC2617
266
    if scheme == self._HA1_SCHEME:
267
      realm = self.GetAuthRealm(req)
268
      if not realm:
269
        # There can not be a valid password for this case
270
        raise AssertionError("No authentication realm")
271

    
272
      expha1 = md5()
273
      expha1.update("%s:%s:%s" % (username, realm, password))
274

    
275
      return (expected_password.lower() == expha1.hexdigest().lower())
276

    
277
    logging.warning("Unknown scheme '%s' in password for user '%s'",
278
                    scheme, username)
279

    
280
    return False
281

    
282

    
283
class PasswordFileUser(object):
284
  """Data structure for users from password file.
285

286
  """
287
  def __init__(self, name, password, options):
288
    self.name = name
289
    self.password = password
290
    self.options = options
291

    
292

    
293
def ReadPasswordFile(file_name):
294
  """Reads a password file.
295

296
  Lines in the password file are of the following format::
297

298
      <username> <password> [options]
299

300
  Fields are separated by whitespace. Username and password are mandatory,
301
  options are optional and separated by comma (','). Empty lines and comments
302
  ('#') are ignored.
303

304
  @type file_name: str
305
  @param file_name: Path to password file
306
  @rtype: dict
307
  @return: Dictionary containing L{PasswordFileUser} instances
308

309
  """
310
  users = {}
311

    
312
  for line in utils.ReadFile(file_name).splitlines():
313
    line = line.strip()
314

    
315
    # Ignore empty lines and comments
316
    if not line or line.startswith("#"):
317
      continue
318

    
319
    parts = line.split(None, 2)
320
    if len(parts) < 2:
321
      # Invalid line
322
      continue
323

    
324
    name = parts[0]
325
    password = parts[1]
326

    
327
    # Extract options
328
    options = []
329
    if len(parts) >= 3:
330
      for part in parts[2].split(","):
331
        options.append(part.strip())
332

    
333
    users[name] = PasswordFileUser(name, password, options)
334

    
335
  return users