Statistics
| Branch: | Tag: | Revision:

root / lib / http / auth.py @ bf9bd8dd

History | View | Annotate | Download (8.8 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
    return self.AUTH_REALM
101

    
102
  def PreHandleRequest(self, req):
103
    """Called before a request is handled.
104

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

108
    """
109
    realm = self.GetAuthRealm(req)
110

    
111
    # Authentication not required, and no credentials given?
112
    if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
113
      return
114

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

    
119
    # Check "Authorization" header
120
    if self._CheckAuthorization(req):
121
      # User successfully authenticated
122
      return
123

    
124
    # Send 401 Unauthorized response
125
    params = {
126
      "realm": realm,
127
      }
128

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

    
136
    raise http.HttpUnauthorized(headers=headers)
137

    
138
  def _CheckAuthorization(self, req):
139
    """Checks 'Authorization' header sent by client.
140

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

146
    """
147
    credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
148
    if not credentials:
149
      return False
150

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

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

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

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

    
175
    # Unsupported authentication scheme
176
    return False
177

    
178
  def _CheckBasicAuthorization(self, req, in_data):
179
    """Checks credentials sent for basic authentication.
180

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

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

    
195
    if ":" not in creds:
196
      return False
197

    
198
    (user, password) = creds.split(":", 1)
199

    
200
    return self.Authenticate(req, user, password)
201

    
202
  def Authenticate(self, req, user, password):
203
    """Checks the password for a user.
204

205
    This function MUST be overridden by a subclass.
206

207
    """
208
    raise NotImplementedError()
209

    
210
  def VerifyBasicAuthPassword(self, req, username, password, expected):
211
    """Checks the password for basic authentication.
212

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

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

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

    
232
    # Check again, just to be sure
233
    if not expected.startswith("{"):
234
      raise AssertionError("Invalid scheme")
235

    
236
    scheme_end_idx = expected.find("}", 1)
237

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

    
243
    scheme = expected[:scheme_end_idx + 1].upper()
244
    expected_password = expected[scheme_end_idx + 1:]
245

    
246
    # Good old plain text password
247
    if scheme == self._CLEARTEXT_SCHEME:
248
      return password == expected_password
249

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

    
257
      expha1 = md5()
258
      expha1.update("%s:%s:%s" % (username, realm, password))
259

    
260
      return (expected_password.lower() == expha1.hexdigest().lower())
261

    
262
    logging.warning("Unknown scheme '%s' in password for user '%s'",
263
                    scheme, username)
264

    
265
    return False
266

    
267

    
268
class PasswordFileUser(object):
269
  """Data structure for users from password file.
270

271
  """
272
  def __init__(self, name, password, options):
273
    self.name = name
274
    self.password = password
275
    self.options = options
276

    
277

    
278
def ReadPasswordFile(file_name):
279
  """Reads a password file.
280

281
  Lines in the password file are of the following format::
282

283
      <username> <password> [options]
284

285
  Fields are separated by whitespace. Username and password are mandatory,
286
  options are optional and separated by comma (','). Empty lines and comments
287
  ('#') are ignored.
288

289
  @type file_name: str
290
  @param file_name: Path to password file
291
  @rtype: dict
292
  @return: Dictionary containing L{PasswordFileUser} instances
293

294
  """
295
  users = {}
296

    
297
  for line in utils.ReadFile(file_name).splitlines():
298
    line = line.strip()
299

    
300
    # Ignore empty lines and comments
301
    if not line or line.startswith("#"):
302
      continue
303

    
304
    parts = line.split(None, 2)
305
    if len(parts) < 2:
306
      # Invalid line
307
      continue
308

    
309
    name = parts[0]
310
    password = parts[1]
311

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

    
318
    users[name] = PasswordFileUser(name, password, options)
319

    
320
  return users