9fe95fd5f1f14ddf93e75819873aa083e2129de5
[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                     # using PermissionDenied rather than AuthenticationFailure here because
136                     # if that fails we don't want to delete the session..
137                     raise PLCPermissionDenied, "SessionAuth: Not allowed to call method %s, missing 'node' role"%method.name
138
139                 method.caller = node
140
141             elif session['person_id'] is not None and session['expires'] > time.time():
142                 persons = Persons(method.api, {'person_id': session['person_id'], 'enabled': True, 'peer_id': None})
143                 if not persons:
144                     raise PLCAuthenticationFailure, "SessionAuth: No such enabled account"
145                 person = persons[0]
146
147                 if not set(person['roles']).intersection(method.roles):
148                     method_message="method %s has roles [%s]"%(method.name,','.join(method.roles))
149                     person_message="caller %s has roles [%s]"%(person['email'],','.join(person['roles']))
150                     # not PLCAuthenticationFailure b/c that would end the session..
151                     raise PLCPermissionDenied, "SessionAuth: missing role, %s -- %s"%(method_message,person_message)
152
153                 method.caller = person
154
155             else:
156                 raise PLCAuthenticationFailure, "SessionAuth: Invalid session"
157
158         except PLCAuthenticationFailure, fault:
159             session.delete()
160             raise fault
161
162 class BootAuth(Auth):
163     """
164     PlanetLab version 3.x node authentication structure. Used by the
165     Boot Manager to make authenticated calls to the API based on a
166     unique node key or boot nonce value.
167
168     The original parameter serialization code did not define the byte
169     encoding of strings, or the string encoding of all other types. We
170     define the byte encoding to be UTF-8, and the string encoding of
171     all other types to be however Python version 2.3 unicode() encodes
172     them.
173     """
174
175     def __init__(self):
176         Auth.__init__(self, {
177             'AuthMethod': Parameter(str, "Authentication method to use, always 'hmac'", optional = False),
178             'node_id': Parameter(int, "Node identifier", optional = False),
179             'value': Parameter(str, "HMAC of node key and method call", optional = False)
180             })
181
182     def canonicalize(self, args):
183         values = []
184
185         for arg in args:
186             if isinstance(arg, list) or isinstance(arg, tuple):
187                 # The old implementation did not recursively handle
188                 # lists of lists. But neither did the old API itself.
189                 values += self.canonicalize(arg)
190             elif isinstance(arg, dict):
191                 # Yes, the comments in the old implementation are
192                 # misleading. Keys of dicts are not included in the
193                 # hash.
194                 values += self.canonicalize(arg.values())
195             else:
196                 # We use unicode() instead of str().
197                 values.append(unicode(arg))
198
199         return values
200
201     def check(self, method, auth, *args):
202         # Method.type_check() should have checked that all of the
203         # mandatory fields were present.
204         assert auth.has_key('node_id')
205
206         if 'node' not in method.roles:
207             raise PLCAuthenticationFailure, "BootAuth: Not allowed to call method, missing 'node' role"
208
209         try:
210             nodes = Nodes(method.api, {'node_id': auth['node_id'], 'peer_id': None})
211             if not nodes:
212                 raise PLCAuthenticationFailure, "BootAuth: No such node"
213             node = nodes[0]
214
215             # Jan 2011 : removing support for old boot CDs
216             if node['key']:
217                 key = node['key']
218             else:
219                 write_debug_line("BootAuth.check: could not get key")
220                 raise PLCAuthenticationFailure, "BootAuth: No node key"
221
222             # Yes, this is the "canonicalization" method used.
223             args = self.canonicalize(args)
224             args.sort()
225             msg = "[" + "".join(args) + "]"
226
227             # We encode in UTF-8 before calculating the HMAC, which is
228             # an 8-bit algorithm.
229             # python 2.6 insists on receiving a 'str' as opposed to a 'unicode'
230             digest = hmac.new(str(key), msg.encode('utf-8'), sha).hexdigest()
231
232             if digest != auth['value']:
233                 raise PLCAuthenticationFailure, "BootAuth: Call could not be authenticated"
234
235             method.caller = node
236
237         except PLCAuthenticationFailure, fault:
238             if nodes:
239                 notify_owners(method, node, 'authfail', include_pis = True, include_techs = True, fault = fault)
240             raise fault
241
242 class AnonymousAuth(Auth):
243     """
244     PlanetLab version 3.x anonymous authentication structure.
245     """
246
247     def __init__(self):
248         Auth.__init__(self, {
249             'AuthMethod': Parameter(str, "Authentication method to use, always 'anonymous'", False),
250             })
251
252     def check(self, method, auth, *args):
253         if 'anonymous' not in method.roles:
254             raise PLCAuthenticationFailure, "AnonymousAuth: method cannot be called anonymously"
255
256         method.caller = None
257
258 class PasswordAuth(Auth):
259     """
260     PlanetLab version 3.x password authentication structure.
261     """
262
263     def __init__(self):
264         Auth.__init__(self, {
265             'AuthMethod': Parameter(str, "Authentication method to use, always 'password' or 'capability'", optional = False),
266             'Username': Parameter(str, "PlanetLab username, typically an e-mail address", optional = False),
267             'AuthString': Parameter(str, "Authentication string, typically a password", optional = False),
268             })
269
270     def check(self, method, auth, *args):
271         # Method.type_check() should have checked that all of the
272         # mandatory fields were present.
273         assert auth.has_key('Username')
274
275         # Get record (must be enabled)
276         persons = Persons(method.api, {'email': auth['Username'].lower(), 'enabled': True, 'peer_id': None})
277         if len(persons) != 1:
278             raise PLCAuthenticationFailure, "PasswordAuth: No such account"
279
280         person = persons[0]
281
282         if auth['Username'] == method.api.config.PLC_API_MAINTENANCE_USER:
283             # "Capability" authentication, whatever the hell that was
284             # supposed to mean. It really means, login as the special
285             # "maintenance user" using password authentication. Can
286             # only be used on particular machines (those in a list).
287             sources = method.api.config.PLC_API_MAINTENANCE_SOURCES.split()
288             if method.source is not None and method.source[0] not in sources:
289                 raise PLCAuthenticationFailure, "PasswordAuth: Not allowed to login to maintenance account"
290
291             # Not sure why this is not stored in the DB
292             password = method.api.config.PLC_API_MAINTENANCE_PASSWORD
293
294             if auth['AuthString'] != password:
295                 raise PLCAuthenticationFailure, "PasswordAuth: Maintenance account password verification failed"
296         else:
297             # Compare encrypted plaintext against encrypted password stored in the DB
298             plaintext = auth['AuthString'].encode(method.api.encoding)
299             password = person['password']
300
301             # Protect against blank passwords in the DB
302             if password is None or password[:12] == "" or \
303                crypt.crypt(plaintext, password[:12]) != password:
304                 raise PLCAuthenticationFailure, "PasswordAuth: Password verification failed"
305
306         if not set(person['roles']).intersection(method.roles):
307             method_message="method %s has roles [%s]"%(method.name,','.join(method.roles))
308             person_message="caller %s has roles [%s]"%(person['email'],','.join(person['roles']))
309             raise PLCAuthenticationFailure, "PasswordAuth: missing role, %s -- %s"%(method_message,person_message)
310
311         method.caller = person
312
313 auth_methods = {'session': SessionAuth,
314                 'password': PasswordAuth,
315                 'capability': PasswordAuth,
316                 'gpg': GPGAuth,
317                 'hmac': BootAuth,
318                 'hmac_dummybox': BootAuth,
319                 'anonymous': AnonymousAuth}
320
321 path = os.path.dirname(__file__) + "/Auth.d"
322 try:
323     extensions = os.listdir(path)
324 except OSError, e:
325     extensions = []
326 for extension in extensions:
327     if extension.startswith("."):
328         continue
329     if not extension.endswith(".py"):
330         continue
331     execfile("%s/%s" % (path, extension))
332 del extensions