Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ 2d54e29c

History | View | Annotate | Download (8.9 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 = None
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. Returning "None" means no authentication is needed for a
92
    request.
93

94
    @type req: L{http.server._HttpServerRequest}
95
    @param req: HTTP request context
96
    @rtype: str or None
97
    @return: Authentication realm
98

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

    
105
  def PreHandleRequest(self, req):
106
    """Called before a request is handled.
107

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

111
    """
112
    realm = self.GetAuthRealm(req)
113

    
114
    # Authentication not required, and no credentials given?
115
    if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
116
      return
117

    
118
    if realm is None: # in case we don't require auth but someone
119
                      # passed the crendentials anyway
120
      realm = "Unspecified"
121

    
122
    # Check "Authorization" header
123
    if self._CheckAuthorization(req):
124
      # User successfully authenticated
125
      return
126

    
127
    # Send 401 Unauthorized response
128
    params = {
129
      "realm": realm,
130
      }
131

    
132
    # TODO: Support for Digest authentication (RFC2617, section 3).
133
    # TODO: Support for more than one WWW-Authenticate header with the same
134
    # response (RFC2617, section 4.6).
135
    headers = {
136
      http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
137
      }
138

    
139
    raise http.HttpUnauthorized(headers=headers)
140

    
141
  def _CheckAuthorization(self, req):
142
    """Checks 'Authorization' header sent by client.
143

144
    @type req: L{http.server._HttpServerRequest}
145
    @param req: HTTP request context
146
    @rtype: bool
147
    @return: Whether user is allowed to execute request
148

149
    """
150
    credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
151
    if not credentials:
152
      return False
153

    
154
    # Extract scheme
155
    parts = credentials.strip().split(None, 2)
156
    if len(parts) < 1:
157
      # Missing scheme
158
      return False
159

    
160
    # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
161
    # token to identify the authentication scheme [...]"
162
    scheme = parts[0].lower()
163

    
164
    if scheme == HTTP_BASIC_AUTH.lower():
165
      # Do basic authentication
166
      if len(parts) < 2:
167
        raise http.HttpBadRequest(message=("Basic authentication requires"
168
                                           " credentials"))
169
      return self._CheckBasicAuthorization(req, parts[1])
170

    
171
    elif scheme == HTTP_DIGEST_AUTH.lower():
172
      # TODO: Implement digest authentication
173
      # RFC2617, section 3.3: "Note that the HTTP server does not actually need
174
      # to know the user's cleartext password. As long as H(A1) is available to
175
      # the server, the validity of an Authorization header may be verified."
176
      pass
177

    
178
    # Unsupported authentication scheme
179
    return False
180

    
181
  def _CheckBasicAuthorization(self, req, in_data):
182
    """Checks credentials sent for basic authentication.
183

184
    @type req: L{http.server._HttpServerRequest}
185
    @param req: HTTP request context
186
    @type in_data: str
187
    @param in_data: Username and password encoded as Base64
188
    @rtype: bool
189
    @return: Whether user is allowed to execute request
190

191
    """
192
    try:
193
      creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
194
    except (TypeError, binascii.Error, UnicodeError):
195
      logging.exception("Error when decoding Basic authentication credentials")
196
      return False
197

    
198
    if ":" not in creds:
199
      return False
200

    
201
    (user, password) = creds.split(":", 1)
202

    
203
    return self.Authenticate(req, user, password)
204

    
205
  def Authenticate(self, req, user, password):
206
    """Checks the password for a user.
207

208
    This function MUST be overridden by a subclass.
209

210
    """
211
    raise NotImplementedError()
212

    
213
  def VerifyBasicAuthPassword(self, req, username, password, expected):
214
    """Checks the password for basic authentication.
215

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

220
    @type req: L{http.server._HttpServerRequest}
221
    @param req: HTTP request context
222
    @type username: string
223
    @param username: Username from HTTP headers
224
    @type password: string
225
    @param password: Password from HTTP headers
226
    @type expected: string
227
    @param expected: Expected password with optional scheme prefix (e.g. from
228
                     users file)
229

230
    """
231
    # Backwards compatibility for old-style passwords without a scheme
232
    if not expected.startswith("{"):
233
      expected = self._CLEARTEXT_SCHEME + expected
234

    
235
    # Check again, just to be sure
236
    if not expected.startswith("{"):
237
      raise AssertionError("Invalid scheme")
238

    
239
    scheme_end_idx = expected.find("}", 1)
240

    
241
    # Ensure scheme has a length of at least one character
242
    if scheme_end_idx <= 1:
243
      logging.warning("Invalid scheme in password for user '%s'", username)
244
      return False
245

    
246
    scheme = expected[:scheme_end_idx + 1].upper()
247
    expected_password = expected[scheme_end_idx + 1:]
248

    
249
    # Good old plain text password
250
    if scheme == self._CLEARTEXT_SCHEME:
251
      return password == expected_password
252

    
253
    # H(A1) as described in RFC2617
254
    if scheme == self._HA1_SCHEME:
255
      realm = self.GetAuthRealm(req)
256
      if not realm:
257
        # There can not be a valid password for this case
258
        return False
259

    
260
      expha1 = md5()
261
      expha1.update("%s:%s:%s" % (username, realm, password))
262

    
263
      return (expected_password.lower() == expha1.hexdigest().lower())
264

    
265
    logging.warning("Unknown scheme '%s' in password for user '%s'",
266
                    scheme, username)
267

    
268
    return False
269

    
270

    
271
class PasswordFileUser(object):
272
  """Data structure for users from password file.
273

274
  """
275
  def __init__(self, name, password, options):
276
    self.name = name
277
    self.password = password
278
    self.options = options
279

    
280

    
281
def ReadPasswordFile(file_name):
282
  """Reads a password file.
283

284
  Lines in the password file are of the following format::
285

286
      <username> <password> [options]
287

288
  Fields are separated by whitespace. Username and password are mandatory,
289
  options are optional and separated by comma (','). Empty lines and comments
290
  ('#') are ignored.
291

292
  @type file_name: str
293
  @param file_name: Path to password file
294
  @rtype: dict
295
  @return: Dictionary containing L{PasswordFileUser} instances
296

297
  """
298
  users = {}
299

    
300
  for line in utils.ReadFile(file_name).splitlines():
301
    line = line.strip()
302

    
303
    # Ignore empty lines and comments
304
    if not line or line.startswith("#"):
305
      continue
306

    
307
    parts = line.split(None, 2)
308
    if len(parts) < 2:
309
      # Invalid line
310
      continue
311

    
312
    name = parts[0]
313
    password = parts[1]
314

    
315
    # Extract options
316
    options = []
317
    if len(parts) >= 3:
318
      for part in parts[2].split(","):
319
        options.append(part.strip())
320

    
321
    users[name] = PasswordFileUser(name, password, options)
322

    
323
  return users