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