Make the authentication system extensible.
[plcapi.git] / PLC / Auth.py
1 #
2 # PLCAPI authentication parameters
3 #
4 # Mark Huang <mlhuang@cs.princeton.edu>
5 # Copyright (C) 2006 The Trustees of Princeton University
6 #
7 # $Id$
8 # $URL$
9 #
10
11 import crypt
12 try:
13     from hashlib import sha1 as sha
14 except ImportError:
15     import sha
16 import hmac
17 import time
18 import os
19
20 from PLC.Faults import *
21 from PLC.Parameter import Parameter, Mixed
22 from PLC.Persons import Persons
23 from PLC.Nodes import Node, Nodes
24 from PLC.Interfaces import Interface, Interfaces
25 from PLC.Sessions import Session, Sessions
26 from PLC.Peers import Peer, Peers
27 from PLC.Boot import notify_owners
28
29 auth_methods = {'session': SessionAuth,
30                 'password': PasswordAuth,
31                 'capability': PasswordAuth,
32                 'gpg': GPGAuth,
33                 'hmac': BootAuth,
34                 'hmac_dummybox': BootAuth,
35                 'anonymous': AnonymousAuth}
36
37 class Auth(Parameter):
38     """
39     Base class for all API authentication methods, as well as a class
40     that can be used to represent all supported API authentication
41     methods.
42     """
43
44     def __init__(self, auth = None):
45         if auth is None:
46             auth = {'AuthMethod': Parameter(str, "Authentication method to use", optional = False)}
47         Parameter.__init__(self, auth, "API authentication structure")
48
49     def check(self, method, auth, *args):
50         global auth_methods
51
52         # Method.type_check() should have checked that all of the
53         # mandatory fields were present.
54         assert 'AuthMethod' in auth
55
56         if auth['AuthMethod'] in auth_methods:
57             expected = auth_methods[auth['AuthMethod']]()
58         else:
59             sm = "'" + "', '".join(auth_methods.keys()) + "'"
60             raise PLCInvalidArgument("must be " + sm, "AuthMethod")
61
62         # Re-check using the specified authentication method
63         method.type_check("auth", auth, expected, (auth,) + args)
64
65 class GPGAuth(Auth):
66     """
67     Proposed PlanetLab federation authentication structure.
68     """
69
70     def __init__(self):
71         Auth.__init__(self, {
72             'AuthMethod': Parameter(str, "Authentication method to use, always 'gpg'", optional = False),
73             'name': Parameter(str, "Peer or user name", optional = False),
74             'signature': Parameter(str, "Message signature", optional = False)
75             })
76
77     def check(self, method, auth, *args):
78         try:
79             peers = Peers(method.api, [auth['name']])
80             if peers:
81                 if 'peer' not in method.roles:
82                     raise PLCAuthenticationFailure, "Not allowed to call method"
83
84                 method.caller = peer = peers[0]
85                 keys = [peer['key']]
86             else:
87                 persons = Persons(method.api, {'email': auth['name'], 'enabled': True, 'peer_id': None})
88                 if not persons:
89                     raise PLCAuthenticationFailure, "No such user '%s'" % auth['name']
90
91                 if not set(person['roles']).intersection(method.roles):
92                     raise PLCAuthenticationFailure, "Not allowed to call method"
93
94                 method.caller = person = persons[0]
95                 keys = Keys(method.api, {'key_id': person['key_ids'], 'key_type': "gpg", 'peer_id': None})
96
97             if not keys:
98                 raise PLCAuthenticationFailure, "No GPG key on record for peer or user '%s'"
99
100             for key in keys:
101                 try:
102                     from PLC.GPG import gpg_verify
103                     gpg_verify(args, key, auth['signature'], method.name)
104                     return
105                 except PLCAuthenticationFailure, fault:
106                     pass
107
108             raise fault
109
110         except PLCAuthenticationFailure, fault:
111             # XXX Send e-mail
112             raise fault
113
114 class SessionAuth(Auth):
115     """
116     Secondary authentication method. After authenticating with a
117     primary authentication method, call GetSession() to generate a
118     session key that may be used for subsequent calls.
119     """
120
121     def __init__(self):
122         Auth.__init__(self, {
123             'AuthMethod': Parameter(str, "Authentication method to use, always 'session'", optional = False),
124             'session': Parameter(str, "Session key", optional = False)
125             })
126
127     def check(self, method, auth, *args):
128         # Method.type_check() should have checked that all of the
129         # mandatory fields were present.
130         assert auth.has_key('session')
131
132         # Get session record
133         sessions = Sessions(method.api, [auth['session']], expires = None)
134         if not sessions:
135             raise PLCAuthenticationFailure, "No such session"
136         session = sessions[0]
137
138         try:
139             if session['node_id'] is not None:
140                 nodes = Nodes(method.api, {'node_id': session['node_id'], 'peer_id': None})
141                 if not nodes:
142                     raise PLCAuthenticationFailure, "No such node"
143                 node = nodes[0]
144
145                 if 'node' not in method.roles:
146                     raise PLCAuthenticationFailure, "Not allowed to call method"
147
148                 method.caller = node
149
150             elif session['person_id'] is not None and session['expires'] > time.time():
151                 persons = Persons(method.api, {'person_id': session['person_id'], 'enabled': True, 'peer_id': None})
152                 if not persons:
153                     raise PLCAuthenticationFailure, "No such account"
154                 person = persons[0]
155
156                 if not set(person['roles']).intersection(method.roles):
157                     raise PLCPermissionDenied, "Not allowed to call method"
158
159                 method.caller = persons[0]
160
161             else:
162                 raise PLCAuthenticationFailure, "Invalid session"
163
164         except PLCAuthenticationFailure, fault:
165             session.delete()
166             raise fault
167
168 class BootAuth(Auth):
169     """
170     PlanetLab version 3.x node authentication structure. Used by the
171     Boot Manager to make authenticated calls to the API based on a
172     unique node key or boot nonce value.
173
174     The original parameter serialization code did not define the byte
175     encoding of strings, or the string encoding of all other types. We
176     define the byte encoding to be UTF-8, and the string encoding of
177     all other types to be however Python version 2.3 unicode() encodes
178     them.
179     """
180
181     def __init__(self):
182         Auth.__init__(self, {
183             'AuthMethod': Parameter(str, "Authentication method to use, always 'hmac'", optional = False),
184             'node_id': Parameter(int, "Node identifier", optional = False),
185             'value': Parameter(str, "HMAC of node key and method call", optional = False)
186             })
187
188     def canonicalize(self, args):
189         values = []
190
191         for arg in args:
192             if isinstance(arg, list) or isinstance(arg, tuple):
193                 # The old implementation did not recursively handle
194                 # lists of lists. But neither did the old API itself.
195                 values += self.canonicalize(arg)
196             elif isinstance(arg, dict):
197                 # Yes, the comments in the old implementation are
198                 # misleading. Keys of dicts are not included in the
199                 # hash.
200                 values += self.canonicalize(arg.values())
201             else:
202                 # We use unicode() instead of str().
203                 values.append(unicode(arg))
204
205         return values
206
207     def check(self, method, auth, *args):
208         # Method.type_check() should have checked that all of the
209         # mandatory fields were present.
210         assert auth.has_key('node_id')
211
212         if 'node' not in method.roles:
213             raise PLCAuthenticationFailure, "Not allowed to call method"
214
215         try:
216             nodes = Nodes(method.api, {'node_id': auth['node_id'], 'peer_id': None})
217             if not nodes:
218                 raise PLCAuthenticationFailure, "No such node"
219             node = nodes[0]
220
221             if node['key']:
222                 key = node['key']
223             elif node['boot_nonce']:
224                 # Allow very old nodes that do not have a node key in
225                 # their configuration files to use their "boot nonce"
226                 # instead. The boot nonce is a random value generated
227                 # by the node itself and POSTed by the Boot CD when it
228                 # requests the Boot Manager. This is obviously not
229                 # very secure, so we only allow it to be used if the
230                 # requestor IP is the same as the IP address we have
231                 # on record for the node.
232                 key = node['boot_nonce']
233
234                 interface = None
235                 if node['interface_ids']:
236                     interfaces = Interfaces(method.api, node['interface_ids'])
237                     for interface in interfaces:
238                         if interface['is_primary']:
239                             break
240
241                 if not interface or not interface['is_primary']:
242                     raise PLCAuthenticationFailure, "No primary network interface on record"
243
244                 if method.source is None:
245                     raise PLCAuthenticationFailure, "Cannot determine IP address of requestor"
246
247                 if interface['ip'] != method.source[0]:
248                     raise PLCAuthenticationFailure, "Requestor IP %s does not match node IP %s" % \
249                           (method.source[0], interface['ip'])
250             else:
251                 raise PLCAuthenticationFailure, "No node key or boot nonce"
252
253             # Yes, this is the "canonicalization" method used.
254             args = self.canonicalize(args)
255             args.sort()
256             msg = "[" + "".join(args) + "]"
257
258             # We encode in UTF-8 before calculating the HMAC, which is
259             # an 8-bit algorithm.
260             # python 2.6 insists on receiving a 'str' as opposed to a 'unicode'
261             digest = hmac.new(str(key), msg.encode('utf-8'), sha).hexdigest()
262
263             if digest != auth['value']:
264                 raise PLCAuthenticationFailure, "Call could not be authenticated"
265
266             method.caller = node
267
268         except PLCAuthenticationFailure, fault:
269             if nodes:
270                 notify_owners(method, node, 'authfail', include_pis = True, include_techs = True, fault = fault)
271             raise fault
272
273 class AnonymousAuth(Auth):
274     """
275     PlanetLab version 3.x anonymous authentication structure.
276     """
277
278     def __init__(self):
279         Auth.__init__(self, {
280             'AuthMethod': Parameter(str, "Authentication method to use, always 'anonymous'", False),
281             })
282
283     def check(self, method, auth, *args):
284         if 'anonymous' not in method.roles:
285             raise PLCAuthenticationFailure, "Not allowed to call method anonymously"
286
287         method.caller = None
288
289 class PasswordAuth(Auth):
290     """
291     PlanetLab version 3.x password authentication structure.
292     """
293
294     def __init__(self):
295         Auth.__init__(self, {
296             'AuthMethod': Parameter(str, "Authentication method to use, always 'password' or 'capability'", optional = False),
297             'Username': Parameter(str, "PlanetLab username, typically an e-mail address", optional = False),
298             'AuthString': Parameter(str, "Authentication string, typically a password", optional = False),
299             })
300
301     def check(self, method, auth, *args):
302         # Method.type_check() should have checked that all of the
303         # mandatory fields were present.
304         assert auth.has_key('Username')
305
306         # Get record (must be enabled)
307         persons = Persons(method.api, {'email': auth['Username'].lower(), 'enabled': True, 'peer_id': None})
308         if len(persons) != 1:
309             raise PLCAuthenticationFailure, "No such account"
310
311         person = persons[0]
312
313         if auth['Username'] == method.api.config.PLC_API_MAINTENANCE_USER:
314             # "Capability" authentication, whatever the hell that was
315             # supposed to mean. It really means, login as the special
316             # "maintenance user" using password authentication. Can
317             # only be used on particular machines (those in a list).
318             sources = method.api.config.PLC_API_MAINTENANCE_SOURCES.split()
319             if method.source is not None and method.source[0] not in sources:
320                 raise PLCAuthenticationFailure, "Not allowed to login to maintenance account"
321
322             # Not sure why this is not stored in the DB
323             password = method.api.config.PLC_API_MAINTENANCE_PASSWORD
324
325             if auth['AuthString'] != password:
326                 raise PLCAuthenticationFailure, "Maintenance account password verification failed"
327         else:
328             # Compare encrypted plaintext against encrypted password stored in the DB
329             plaintext = auth['AuthString'].encode(method.api.encoding)
330             password = person['password']
331
332             # Protect against blank passwords in the DB
333             if password is None or password[:12] == "" or \
334                crypt.crypt(plaintext, password[:12]) != password:
335                 raise PLCAuthenticationFailure, "Password verification failed"
336
337         if not set(person['roles']).intersection(method.roles):
338             raise PLCAuthenticationFailure, "Not allowed to call method"
339
340         method.caller = person
341
342 path = os.path.dirname(__file__) + "/Auth.d"
343 try:
344     extensions = os.listdir(path)
345 except OSError, e:
346     extensions = []
347 for extension in extensions:
348     if extension.startswith("."):
349         continue
350     if not extension.endswith(".py"):
351         continue
352     execfile("%s/%s" % (path, extension))
353 del extension
354 del extensions