Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ b459a848

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 compat
31
from ganeti import http
32

    
33
from cStringIO import StringIO
34

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

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

    
42

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

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

53
  """
54
  buf = StringIO()
55

    
56
  buf.write(scheme)
57

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

    
70
  return buf.getvalue()
71

    
72

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

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

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

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

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

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

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

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

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

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

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

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

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

    
125
    realm = self.GetAuthRealm(req)
126

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

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

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

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

    
147
    raise http.HttpUnauthorized(headers=headers)
148

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

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

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

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

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

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

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

    
186
    # Unsupported authentication scheme
187
    return False
188

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

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

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

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

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

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

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

216
    This function MUST be overridden by a subclass.
217

218
    """
219
    raise NotImplementedError()
220

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

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

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

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

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

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

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

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

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

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

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

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

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

    
276
    return False
277

    
278

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

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

    
288

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

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

294
      <username> <password> [options]
295

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

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

305
  """
306
  users = {}
307

    
308
  for line in contents.splitlines():
309
    line = line.strip()
310

    
311
    # Ignore empty lines and comments
312
    if not line or line.startswith("#"):
313
      continue
314

    
315
    parts = line.split(None, 2)
316
    if len(parts) < 2:
317
      # Invalid line
318
      continue
319

    
320
    name = parts[0]
321
    password = parts[1]
322

    
323
    # Extract options
324
    options = []
325
    if len(parts) >= 3:
326
      for part in parts[2].split(","):
327
        options.append(part.strip())
328

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

    
331
  return users