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