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