Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ 6873a52a

History | View | Annotate | Download (9.3 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
    # Unused argument, method could be a function
115
    # pylint: disable-msg=W0613,R0201
116
    return False
117

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

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

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

    
131
    realm = self.GetAuthRealm(req)
132

    
133
    if not realm:
134
      raise AssertionError("No authentication realm")
135

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

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

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

    
153
    raise http.HttpUnauthorized(headers=headers)
154

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

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

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

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

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

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

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

    
192
    # Unsupported authentication scheme
193
    return False
194

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

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

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

    
212
    if ":" not in creds:
213
      return False
214

    
215
    (user, password) = creds.split(":", 1)
216

    
217
    return self.Authenticate(req, user, password)
218

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

222
    This function MUST be overridden by a subclass.
223

224
    """
225
    raise NotImplementedError()
226

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

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

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

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

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

    
253
    scheme_end_idx = expected.find("}", 1)
254

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

    
260
    scheme = expected[:scheme_end_idx + 1].upper()
261
    expected_password = expected[scheme_end_idx + 1:]
262

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

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

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

    
277
      return (expected_password.lower() == expha1.hexdigest().lower())
278

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

    
282
    return False
283

    
284

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

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

    
294

    
295
def ReadPasswordFile(file_name):
296
  """Reads a password file.
297

298
  Lines in the password file are of the following format::
299

300
      <username> <password> [options]
301

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

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

311
  """
312
  users = {}
313

    
314
  for line in utils.ReadFile(file_name).splitlines():
315
    line = line.strip()
316

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

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

    
326
    name = parts[0]
327
    password = parts[1]
328

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

    
335
    users[name] = PasswordFileUser(name, password, options)
336

    
337
  return users