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