Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ bfbd12f7

History | View | Annotate | Download (9.4 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 compat
31
from ganeti import http
32
from ganeti import utils
33

    
34
from cStringIO import StringIO
35

    
36
# Digest types from RFC2617
37
HTTP_BASIC_AUTH = "Basic"
38
HTTP_DIGEST_AUTH = "Digest"
39

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

    
43

    
44
def _FormatAuthHeader(scheme, params):
45
  """Formats WWW-Authentication header value as per RFC2617, section 1.2
46

47
  @type scheme: str
48
  @param scheme: Authentication scheme
49
  @type params: dict
50
  @param params: Additional parameters
51
  @rtype: str
52
  @return: Formatted header value
53

54
  """
55
  buf = StringIO()
56

    
57
  buf.write(scheme)
58

    
59
  for name, value in params.iteritems():
60
    buf.write(" ")
61
    buf.write(name)
62
    buf.write("=")
63
    if _NOQUOTE.match(value):
64
      buf.write(value)
65
    else:
66
      buf.write("\"")
67
      # TODO: Better quoting
68
      buf.write(value.replace("\"", "\\\""))
69
      buf.write("\"")
70

    
71
  return buf.getvalue()
72

    
73

    
74
class HttpServerRequestAuthentication(object):
75
  # Default authentication realm
76
  AUTH_REALM = "Unspecified"
77

    
78
  # Schemes for passwords
79
  _CLEARTEXT_SCHEME = "{CLEARTEXT}"
80
  _HA1_SCHEME = "{HA1}"
81

    
82
  def GetAuthRealm(self, req):
83
    """Returns the authentication realm for a request.
84

85
    May be overridden by a subclass, which then can return different realms for
86
    different paths.
87

88
    @type req: L{http.server._HttpServerRequest}
89
    @param req: HTTP request context
90
    @rtype: string
91
    @return: Authentication realm
92

93
    """
94
    # today we don't have per-request filtering, but we might want to
95
    # add it in the future
96
    # pylint: disable=W0613
97
    return self.AUTH_REALM
98

    
99
  def AuthenticationRequired(self, req):
100
    """Determines whether authentication is required for a request.
101

102
    To enable authentication, override this function in a subclass and return
103
    C{True}. L{AUTH_REALM} must be set.
104

105
    @type req: L{http.server._HttpServerRequest}
106
    @param req: HTTP request context
107

108
    """
109
    # Unused argument, method could be a function
110
    # pylint: disable=W0613,R0201
111
    return False
112

    
113
  def PreHandleRequest(self, req):
114
    """Called before a request is handled.
115

116
    @type req: L{http.server._HttpServerRequest}
117
    @param req: HTTP request context
118

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

    
126
    realm = self.GetAuthRealm(req)
127

    
128
    if not realm:
129
      raise AssertionError("No authentication realm")
130

    
131
    # Check "Authorization" header
132
    if self._CheckAuthorization(req):
133
      # User successfully authenticated
134
      return
135

    
136
    # Send 401 Unauthorized response
137
    params = {
138
      "realm": realm,
139
      }
140

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

    
148
    raise http.HttpUnauthorized(headers=headers)
149

    
150
  def _CheckAuthorization(self, req):
151
    """Checks 'Authorization' header sent by client.
152

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

158
    """
159
    credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
160
    if not credentials:
161
      return False
162

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

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

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

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

    
187
    # Unsupported authentication scheme
188
    return False
189

    
190
  def _CheckBasicAuthorization(self, req, in_data):
191
    """Checks credentials sent for basic authentication.
192

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

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

    
207
    if ":" not in creds:
208
      return False
209

    
210
    (user, password) = creds.split(":", 1)
211

    
212
    return self.Authenticate(req, user, password)
213

    
214
  def Authenticate(self, req, user, password):
215
    """Checks the password for a user.
216

217
    This function MUST be overridden by a subclass.
218

219
    """
220
    raise NotImplementedError()
221

    
222
  def VerifyBasicAuthPassword(self, req, username, password, expected):
223
    """Checks the password for basic authentication.
224

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

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

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

    
244
    # Check again, just to be sure
245
    if not expected.startswith("{"):
246
      raise AssertionError("Invalid scheme")
247

    
248
    scheme_end_idx = expected.find("}", 1)
249

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

    
255
    scheme = expected[:scheme_end_idx + 1].upper()
256
    expected_password = expected[scheme_end_idx + 1:]
257

    
258
    # Good old plain text password
259
    if scheme == self._CLEARTEXT_SCHEME:
260
      return password == expected_password
261

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

    
269
      expha1 = compat.md5_hash()
270
      expha1.update("%s:%s:%s" % (username, realm, password))
271

    
272
      return (expected_password.lower() == expha1.hexdigest().lower())
273

    
274
    logging.warning("Unknown scheme '%s' in password for user '%s'",
275
                    scheme, username)
276

    
277
    return False
278

    
279

    
280
class PasswordFileUser(object):
281
  """Data structure for users from password file.
282

283
  """
284
  def __init__(self, name, password, options):
285
    self.name = name
286
    self.password = password
287
    self.options = options
288

    
289

    
290
def ParsePasswordFile(contents):
291
  """Parses the contents of a password file.
292

293
  Lines in the password file are of the following format::
294

295
      <username> <password> [options]
296

297
  Fields are separated by whitespace. Username and password are mandatory,
298
  options are optional and separated by comma (','). Empty lines and comments
299
  ('#') are ignored.
300

301
  @type contents: str
302
  @param contents: Contents of password file
303
  @rtype: dict
304
  @return: Dictionary containing L{PasswordFileUser} instances
305

306
  """
307
  users = {}
308

    
309
  for line in utils.FilterEmptyLinesAndComments(contents):
310
    parts = line.split(None, 2)
311
    if len(parts) < 2:
312
      # Invalid line
313
      # TODO: Return line number from FilterEmptyLinesAndComments
314
      logging.warning("Ignoring non-comment line with less than two fields")
315
      continue
316

    
317
    name = parts[0]
318
    password = parts[1]
319

    
320
    # Extract options
321
    options = []
322
    if len(parts) >= 3:
323
      for part in parts[2].split(","):
324
        options.append(part.strip())
325
    else:
326
      logging.warning("Ignoring values for user '%s': %s", name, parts[3:])
327

    
328
    users[name] = PasswordFileUser(name, password, options)
329

    
330
  return users