fix packaging for f37 (3/n)
[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 from hashlib import sha1 as sha
10 import hmac
11 import time
12 import os
13
14 from PLC.Faults import *
15 from PLC.Parameter import Parameter, Mixed
16 from PLC.Persons import Persons
17 from PLC.Nodes import Node, Nodes
18 from PLC.Interfaces import Interface, Interfaces
19 from PLC.Sessions import Session, Sessions
20 from PLC.Peers import Peer, Peers
21 from PLC.Keys import Keys
22 from PLC.Boot import notify_owners
23 from PLC.Logger import logger
24
25 class Auth(Parameter):
26     """
27     Base class for all API authentication methods, as well as a class
28     that can be used to represent all supported API authentication
29     methods.
30     """
31
32     def __init__(self, auth=None):
33         if auth is None:
34             auth = {'AuthMethod': Parameter(str, "Authentication method to use",
35                                             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(list(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
55 class GPGAuth(Auth):
56     """
57     Proposed PlanetLab federation authentication structure.
58     """
59
60     def __init__(self):
61         Auth.__init__(self, {
62             'AuthMethod': Parameter(str, "Authentication method to use, always 'gpg'", optional=False),
63             'name': Parameter(str, "Peer or user name", optional=False),
64             'signature': Parameter(str, "Message signature", optional=False)
65         })
66
67     def check(self, method, auth, *args):
68         try:
69             peers = Peers(method.api, [auth['name']])
70             if peers:
71                 if 'peer' not in method.roles:
72                     raise PLCAuthenticationFailure(
73                         "GPGAuth: Not allowed to call method, missing 'peer' role")
74
75                 method.caller = peer = peers[0]
76                 gpg_keys = [peer['key']]
77             else:
78                 persons = Persons(
79                     method.api, {'email': auth['name'], 'enabled': True, 'peer_id': None})
80                 if not persons:
81                     raise PLCAuthenticationFailure(
82                         "GPGAuth: No such user '%s'" % auth['name'])
83
84                 method.caller = person = persons[0]
85                 if not set(person['roles']).intersection(method.roles):
86                     raise PLCAuthenticationFailure(
87                         "GPGAuth: Not allowed to call method, missing role")
88
89                 keys = Keys(
90                     method.api, {'key_id': person['key_ids'], 'key_type': "gpg", 'peer_id': None})
91                 gpg_keys = [key['key'] for key in keys]
92
93             if not gpg_keys:
94                 raise PLCAuthenticationFailure(
95                     "GPGAuth: No GPG key on record for peer or user '%s'" % auth['name'])
96
97             for gpg_key in gpg_keys:
98                 try:
99                     from PLC.GPG import gpg_verify
100                     gpg_verify(args, gpg_key, auth['signature'], method.name)
101                     return
102                 except PLCAuthenticationFailure as fault:
103                     pass
104
105             raise fault
106
107         except PLCAuthenticationFailure as fault:
108             # XXX Send e-mail
109             raise fault
110
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 'session' in auth
129
130         # Get session record
131         sessions = Sessions(method.api, [auth['session']], expires=None)
132         if not sessions:
133             raise PLCAuthenticationFailure("SessionAuth: No such session")
134         session = sessions[0]
135
136         try:
137             if session['node_id'] is not None:
138                 nodes = Nodes(
139                     method.api, {'node_id': session['node_id'], 'peer_id': None})
140                 if not nodes:
141                     raise PLCAuthenticationFailure("SessionAuth: No such node")
142                 node = nodes[0]
143
144                 if 'node' not in method.roles:
145                     # using PermissionDenied rather than AuthenticationFailure here because
146                     # if that fails we don't want to delete the session..
147                     raise PLCPermissionDenied(
148                         "SessionAuth: Not allowed to call method %s, missing 'node' role" % method.name)
149
150                 method.caller = node
151
152             elif session['person_id'] is not None and session['expires'] > time.time():
153                 persons = Persons(method.api, {
154                                   'person_id': session['person_id'], 'enabled': True, 'peer_id': None})
155                 if not persons:
156                     raise PLCAuthenticationFailure(
157                         "SessionAuth: No such enabled account")
158                 person = persons[0]
159
160                 if not set(person['roles']).intersection(method.roles):
161                     method_message = "method %s has roles [%s]" % (
162                         method.name, ','.join(method.roles))
163                     person_message = "caller %s has roles [%s]" % (
164                         person['email'], ','.join(person['roles']))
165                     # not PLCAuthenticationFailure b/c that would end the session..
166                     raise PLCPermissionDenied(
167                         "SessionAuth: missing role, %s -- %s" % (method_message, person_message))
168
169                 method.caller = person
170
171             else:
172                 raise PLCAuthenticationFailure("SessionAuth: Invalid session")
173
174         except PLCAuthenticationFailure as fault:
175             session.delete()
176             raise fault
177
178
179 class BootAuth(Auth):
180     """
181     PlanetLab version 3.x node authentication structure. Used by the
182     Boot Manager to make authenticated calls to the API based on a
183     unique node key or boot nonce value.
184     """
185
186     def __init__(self):
187         Auth.__init__(self, {
188             'AuthMethod': Parameter(str, "Authentication method to use, always 'hmac'", optional=False),
189             'node_id': Parameter(int, "Node identifier", optional=False),
190             'value': Parameter(str, "HMAC of node key and method call", optional=False)
191         })
192
193     def canonicalize(self, args):
194         values = []
195
196         for arg in args:
197             if isinstance(arg, list) or isinstance(arg, tuple):
198                 # The old implementation did not recursively handle
199                 # lists of lists. But neither did the old API itself.
200                 values += self.canonicalize(arg)
201             elif isinstance(arg, dict):
202                 # Yes, the comments in the old implementation are
203                 # misleading. Keys of dicts are not included in the
204                 # hash.
205                 values += self.canonicalize(list(arg.values()))
206             else:
207                 # We use unicode() instead of str().
208                 values.append(str(arg))
209
210         return values
211
212     def check(self, method, auth, *args):
213         # Method.type_check() should have checked that all of the
214         # mandatory fields were present.
215         assert 'node_id' in auth
216
217         if 'node' not in method.roles:
218             raise PLCAuthenticationFailure(
219                 "BootAuth: Not allowed to call method, missing 'node' role")
220
221         try:
222             nodes = Nodes(
223                 method.api, {'node_id': auth['node_id'], 'peer_id': None})
224             if not nodes:
225                 raise PLCAuthenticationFailure("BootAuth: No such node")
226             node = nodes[0]
227
228             # Jan 2011 : removing support for old boot CDs
229             if node['key']:
230                 key = node['key']
231             else:
232                 raise PLCAuthenticationFailure("BootAuth: No node key")
233
234             # Yes, this is the "canonicalization" method used.
235             args = self.canonicalize(args)
236             args.sort()
237             msg = "[" + "".join(args) + "]"
238
239             digest = hmac.new(key.encode('utf-8'), msg.encode('utf-8'), sha).hexdigest()
240
241             if digest != auth['value']:
242                 raise PLCAuthenticationFailure(
243                     "BootAuth: Call could not be authenticated")
244
245             method.caller = node
246
247         except PLCAuthenticationFailure as fault:
248             if nodes:
249                 notify_owners(method, node, 'authfail',
250                               include_pis=True, include_techs=True, fault=fault)
251             raise fault
252
253
254 class AnonymousAuth(Auth):
255     """
256     PlanetLab version 3.x anonymous authentication structure.
257     """
258
259     def __init__(self):
260         Auth.__init__(self, {
261             'AuthMethod': Parameter(str, "Authentication method to use, always 'anonymous'", False),
262         })
263
264     def check(self, method, auth, *args):
265         if 'anonymous' not in method.roles:
266             raise PLCAuthenticationFailure(
267                 "AnonymousAuth: method cannot be called anonymously")
268
269         method.caller = None
270
271
272 class PasswordAuth(Auth):
273     """
274     PlanetLab version 3.x password authentication structure.
275     """
276
277     def __init__(self):
278         Auth.__init__(self, {
279             'AuthMethod': Parameter(str, "Authentication method to use, always 'password' or 'capability'", optional=False),
280             'Username': Parameter(str, "PlanetLab username, typically an e-mail address", optional=False),
281             'AuthString': Parameter(str, "Authentication string, typically a password", optional=False),
282         })
283
284     def check(self, method, auth, *args):
285         # Method.type_check() should have checked that all of the
286         # mandatory fields were present.
287         assert 'Username' in auth
288
289         # Get record (must be enabled)
290         normalized = auth['Username'].lower()
291         persons = Persons(method.api, {
292                           'email': normalized, 'enabled': True, 'peer_id': None})
293         if len(persons) != 1:
294             logger.info(f"PasswordAuth failed with {normalized}, got {len(persons)} matches")
295             raise PLCAuthenticationFailure("PasswordAuth: No such account")
296
297         person = persons[0]
298
299         if auth['Username'] == method.api.config.PLC_API_MAINTENANCE_USER:
300             # "Capability" authentication, whatever the hell that was
301             # supposed to mean. It really means, login as the special
302             # "maintenance user" using password authentication. Can
303             # only be used on particular machines (those in a list).
304             sources = method.api.config.PLC_API_MAINTENANCE_SOURCES.split()
305             if method.source is not None and method.source[0] not in sources:
306                 raise PLCAuthenticationFailure(
307                     "PasswordAuth: Not allowed to login to maintenance account")
308
309             # Not sure why this is not stored in the DB
310             password = method.api.config.PLC_API_MAINTENANCE_PASSWORD
311
312             if auth['AuthString'] != password:
313                 raise PLCAuthenticationFailure(
314                     "PasswordAuth: Maintenance account password verification failed")
315         else:
316             # Compare encrypted plaintext against encrypted password stored in the DB
317             plaintext = auth['AuthString']
318             password = person['password']
319
320             # Protect against blank passwords in the DB
321             if password is None or password[:12] == "" or \
322                crypt.crypt(plaintext, password[:12]) != password:
323                 raise PLCAuthenticationFailure(
324                     "PasswordAuth: Password verification failed")
325
326         if not set(person['roles']).intersection(method.roles):
327             method_message = "method %s has roles [%s]" % (
328                 method.name, ','.join(method.roles))
329             person_message = "caller %s has roles [%s]" % (
330                 person['email'], ','.join(person['roles']))
331             raise PLCAuthenticationFailure(
332                 "PasswordAuth: missing role, %s -- %s" % (method_message, person_message))
333
334         method.caller = person
335
336
337 auth_methods = {'session': SessionAuth,
338                 'password': PasswordAuth,
339                 'capability': PasswordAuth,
340                 'gpg': GPGAuth,
341                 'hmac': BootAuth,
342                 'hmac_dummybox': BootAuth,
343                 'anonymous': AnonymousAuth}
344
345 path = os.path.dirname(__file__) + "/Auth.d"
346 try:
347     extensions = os.listdir(path)
348 except OSError as e:
349     extensions = []
350 for extension in extensions:
351     if extension.startswith("."):
352         continue
353     if not extension.endswith(".py"):
354         continue
355     exec(compile(open("%s/%s" % (path, extension)).read(),
356                  "%s/%s" % (path, extension), 'exec'))
357 del extensions