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