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