f0e00b990e28cdedc663a38dc15d6d7e3ba65c99
[osm/NBI.git] / osm_nbi / auth.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Whitestack, LLC
4 # Copyright 2018 Telefonica S.A.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License"); you may
7 # not use this file except in compliance with the License. You may obtain
8 # a copy of the License at
9 #
10 # http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 # License for the specific language governing permissions and limitations
16 # under the License.
17 #
18 # For those usages not covered by the Apache License, Version 2.0 please
19 # contact: esousa@whitestack.com or alfonso.tiernosepulveda@telefonica.com
20 ##
21
22
23 """
24 Authenticator is responsible for authenticating the users,
25 create the tokens unscoped and scoped, retrieve the role
26 list inside the projects that they are inserted
27 """
28
29 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
30 __date__ = "$27-jul-2018 23:59:59$"
31
32 import cherrypy
33 import logging
34 from base64 import standard_b64decode
35 from copy import deepcopy
36 from functools import reduce
37 from hashlib import sha256
38 from http import HTTPStatus
39 from random import choice as random_choice
40 from time import time
41
42 from authconn import AuthException
43 from authconn_keystone import AuthconnKeystone
44 from osm_common import dbmongo
45 from osm_common import dbmemory
46 from osm_common.dbbase import DbException
47
48
49 class Authenticator:
50 """
51 This class should hold all the mechanisms for User Authentication and
52 Authorization. Initially it should support Openstack Keystone as a
53 backend through a plugin model where more backends can be added and a
54 RBAC model to manage permissions on operations.
55 """
56
57 periodin_db_pruning = 60*30 # for the internal backend only. every 30 minutes expired tokens will be pruned
58
59 def __init__(self):
60 """
61 Authenticator initializer. Setup the initial state of the object,
62 while it waits for the config dictionary and database initialization.
63 """
64 self.backend = None
65 self.config = None
66 self.db = None
67 self.tokens_cache = dict()
68 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
69
70 self.logger = logging.getLogger("nbi.authenticator")
71
72 def start(self, config):
73 """
74 Method to configure the Authenticator object. This method should be called
75 after object creation. It is responsible by initializing the selected backend,
76 as well as the initialization of the database connection.
77
78 :param config: dictionary containing the relevant parameters for this object.
79 """
80 self.config = config
81
82 try:
83 if not self.db:
84 if config["database"]["driver"] == "mongo":
85 self.db = dbmongo.DbMongo()
86 self.db.db_connect(config["database"])
87 elif config["database"]["driver"] == "memory":
88 self.db = dbmemory.DbMemory()
89 self.db.db_connect(config["database"])
90 else:
91 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
92 .format(config["database"]["driver"]))
93 if not self.backend:
94 if config["authentication"]["backend"] == "keystone":
95 self.backend = AuthconnKeystone(self.config["authentication"])
96 elif config["authentication"]["backend"] == "internal":
97 self._internal_tokens_prune()
98 else:
99 raise AuthException("Unknown authentication backend: {}"
100 .format(config["authentication"]["backend"]))
101 except Exception as e:
102 raise AuthException(str(e))
103
104 def stop(self):
105 try:
106 if self.db:
107 self.db.db_disconnect()
108 except DbException as e:
109 raise AuthException(str(e), http_code=e.http_code)
110
111 def init_db(self, target_version='1.1'):
112 """
113 Check if the database has been initialized, with at least one user. If not, create an adthe required tables
114 and insert the predefined mappings between roles and permissions.
115
116 :param target_version: schema version that should be present in the database.
117 :return: None if OK, exception if error or version is different.
118 """
119 pass
120
121 def authorize(self):
122 token = None
123 user_passwd64 = None
124 try:
125 # 1. Get token Authorization bearer
126 auth = cherrypy.request.headers.get("Authorization")
127 if auth:
128 auth_list = auth.split(" ")
129 if auth_list[0].lower() == "bearer":
130 token = auth_list[-1]
131 elif auth_list[0].lower() == "basic":
132 user_passwd64 = auth_list[-1]
133 if not token:
134 if cherrypy.session.get("Authorization"):
135 # 2. Try using session before request a new token. If not, basic authentication will generate
136 token = cherrypy.session.get("Authorization")
137 if token == "logout":
138 token = None # force Unauthorized response to insert user password again
139 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
140 # 3. Get new token from user password
141 user = None
142 passwd = None
143 try:
144 user_passwd = standard_b64decode(user_passwd64).decode()
145 user, _, passwd = user_passwd.partition(":")
146 except Exception:
147 pass
148 outdata = self.new_token(None, {"username": user, "password": passwd})
149 token = outdata["id"]
150 cherrypy.session['Authorization'] = token
151 if self.config["authentication"]["backend"] == "internal":
152 return self._internal_authorize(token)
153 else:
154 try:
155 self.backend.validate_token(token)
156 # TODO: check if this can be avoided. Backend may provide enough information
157 return self.tokens_cache[token]
158 except AuthException:
159 self.del_token(token)
160 raise
161 except AuthException as e:
162 if cherrypy.session.get('Authorization'):
163 del cherrypy.session['Authorization']
164 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
165 raise AuthException(str(e))
166
167 def new_token(self, session, indata, remote):
168 if self.config["authentication"]["backend"] == "internal":
169 return self._internal_new_token(session, indata, remote)
170 else:
171 if indata.get("username"):
172 token, projects = self.backend.authenticate_with_user_password(
173 indata.get("username"), indata.get("password"))
174 elif session:
175 token, projects = self.backend.authenticate_with_token(
176 session.get("id"), indata.get("project_id"))
177 else:
178 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
179 http_code=HTTPStatus.UNAUTHORIZED)
180
181 if indata.get("project_id"):
182 project_id = indata.get("project_id")
183 if project_id not in projects:
184 raise AuthException("Project {} not allowed for this user".format(project_id),
185 http_code=HTTPStatus.UNAUTHORIZED)
186 else:
187 project_id = projects[0]
188
189 if project_id == "admin":
190 session_admin = True
191 else:
192 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
193 projects, False)
194
195 now = time()
196 new_session = {
197 "_id": token,
198 "id": token,
199 "issued_at": now,
200 "expires": now + 3600,
201 "project_id": project_id,
202 "username": indata.get("username") if not session else session.get("username"),
203 "remote_port": remote.port,
204 "admin": session_admin
205 }
206
207 if remote.name:
208 new_session["remote_host"] = remote.name
209 elif remote.ip:
210 new_session["remote_host"] = remote.ip
211
212 # TODO: check if this can be avoided. Backend may provide enough information
213 self.tokens_cache[token] = new_session
214
215 return deepcopy(new_session)
216
217 def get_token_list(self, session):
218 if self.config["authentication"]["backend"] == "internal":
219 return self._internal_get_token_list(session)
220 else:
221 # TODO: check if this can be avoided. Backend may provide enough information
222 return [deepcopy(token) for token in self.tokens_cache.values()
223 if token["username"] == session["username"]]
224
225 def get_token(self, session, token):
226 if self.config["authentication"]["backend"] == "internal":
227 return self._internal_get_token(session, token)
228 else:
229 # TODO: check if this can be avoided. Backend may provide enough information
230 token_value = self.tokens_cache.get(token)
231 if not token_value:
232 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
233 if token_value["username"] != session["username"] and not session["admin"]:
234 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
235 return token_value
236
237 def del_token(self, token):
238 if self.config["authentication"]["backend"] == "internal":
239 return self._internal_del_token(token)
240 else:
241 try:
242 self.backend.revoke_token(token)
243 del self.tokens_cache[token]
244 return "token '{}' deleted".format(token)
245 except KeyError:
246 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
247
248 def _internal_authorize(self, token_id):
249 try:
250 if not token_id:
251 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
252 # try to get from cache first
253 now = time()
254 session = self.tokens_cache.get(token_id)
255 if session and session["expires"] < now:
256 del self.tokens_cache[token_id]
257 session = None
258 if session:
259 return session
260
261 # get from database if not in cache
262 session = self.db.get_one("tokens", {"_id": token_id})
263 if session["expires"] < now:
264 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
265 self.tokens_cache[token_id] = session
266 return session
267 except DbException as e:
268 if e.http_code == HTTPStatus.NOT_FOUND:
269 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
270 else:
271 raise
272
273 except AuthException:
274 if self.config["global"].get("test.user_not_authorized"):
275 return {"id": "fake-token-id-for-test",
276 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
277 "username": self.config["global"]["test.user_not_authorized"]}
278 else:
279 raise
280
281 def _internal_new_token(self, session, indata, remote):
282 now = time()
283 user_content = None
284
285 # Try using username/password
286 if indata.get("username"):
287 user_rows = self.db.get_list("users", {"username": indata.get("username")})
288 if user_rows:
289 user_content = user_rows[0]
290 salt = user_content["_admin"]["salt"]
291 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
292 if shadow_password != user_content["password"]:
293 user_content = None
294 if not user_content:
295 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
296 elif session:
297 user_rows = self.db.get_list("users", {"username": session["username"]})
298 if user_rows:
299 user_content = user_rows[0]
300 else:
301 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
302 else:
303 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
304 http_code=HTTPStatus.UNAUTHORIZED)
305
306 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
307 for _ in range(0, 32))
308 if indata.get("project_id"):
309 project_id = indata.get("project_id")
310 if project_id not in user_content["projects"]:
311 raise AuthException("project {} not allowed for this user"
312 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
313 else:
314 project_id = user_content["projects"][0]
315 if project_id == "admin":
316 session_admin = True
317 else:
318 project = self.db.get_one("projects", {"_id": project_id})
319 session_admin = project.get("admin", False)
320 new_session = {"issued_at": now, "expires": now + 3600,
321 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
322 "remote_port": remote.port, "admin": session_admin}
323 if remote.name:
324 new_session["remote_host"] = remote.name
325 elif remote.ip:
326 new_session["remote_host"] = remote.ip
327
328 self.tokens_cache[token_id] = new_session
329 self.db.create("tokens", new_session)
330 # check if database must be prune
331 self._internal_tokens_prune(now)
332 return deepcopy(new_session)
333
334 def _internal_get_token_list(self, session):
335 now = time()
336 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
337 return token_list
338
339 def _internal_get_token(self, session, token_id):
340 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
341 if not token_value:
342 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
343 if token_value["username"] != session["username"] and not session["admin"]:
344 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
345 return token_value
346
347 def _internal_del_token(self, token_id):
348 try:
349 self.tokens_cache.pop(token_id, None)
350 self.db.del_one("tokens", {"_id": token_id})
351 return "token '{}' deleted".format(token_id)
352 except DbException as e:
353 if e.http_code == HTTPStatus.NOT_FOUND:
354 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
355 else:
356 raise
357
358 def _internal_tokens_prune(self, now=None):
359 now = now or time()
360 if not self.next_db_prune_time or self.next_db_prune_time >= now:
361 self.db.del_list("tokens", {"expires.lt": now})
362 self.next_db_prune_time = self.periodin_db_pruning + now
363 self.tokens_cache.clear() # force to reload tokens from database