blob: 4bc4628218c0b19f1bc061504b9ab301c47fe7a0 [file] [log] [blame]
Eduardo Sousa819d34c2018-07-31 01:20:02 +01001# -*- coding: utf-8 -*-
2
tierno0ea204e2019-01-25 14:16:24 +00003# 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
Eduardo Sousa819d34c2018-07-31 01:20:02 +010017"""
18Authenticator is responsible for authenticating the users,
19create the tokens unscoped and scoped, retrieve the role
20list inside the projects that they are inserted
21"""
22
tierno0ea204e2019-01-25 14:16:24 +000023__author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
Eduardo Sousa819d34c2018-07-31 01:20:02 +010024__date__ = "$27-jul-2018 23:59:59$"
25
Eduardo Sousad1b525d2018-10-04 04:24:18 +010026import cherrypy
Eduardo Sousa819d34c2018-07-31 01:20:02 +010027import logging
Eduardo Sousa2f988212018-07-26 01:04:11 +010028from base64 import standard_b64decode
Eduardo Sousa819d34c2018-07-31 01:20:02 +010029from copy import deepcopy
30from functools import reduce
Eduardo Sousad1b525d2018-10-04 04:24:18 +010031from hashlib import sha256
Eduardo Sousa2f988212018-07-26 01:04:11 +010032from http import HTTPStatus
Eduardo Sousad1b525d2018-10-04 04:24:18 +010033from random import choice as random_choice
Eduardo Sousa819d34c2018-07-31 01:20:02 +010034from time import time
Eduardo Sousa2f988212018-07-26 01:04:11 +010035
Eduardo Sousa819d34c2018-07-31 01:20:02 +010036from authconn import AuthException
37from authconn_keystone import AuthconnKeystone
Eduardo Sousad1b525d2018-10-04 04:24:18 +010038from osm_common import dbmongo
39from osm_common import dbmemory
40from osm_common.dbbase import DbException
Eduardo Sousa2f988212018-07-26 01:04:11 +010041
Eduardo Sousa2f988212018-07-26 01:04:11 +010042
Eduardo Sousa819d34c2018-07-31 01:20:02 +010043class 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 """
Eduardo Sousa2f988212018-07-26 01:04:11 +010050
tierno0ea204e2019-01-25 14:16:24 +000051 periodin_db_pruning = 60*30 # for the internal backend only. every 30 minutes expired tokens will be pruned
52
Eduardo Sousad1b525d2018-10-04 04:24:18 +010053 def __init__(self):
Eduardo Sousa819d34c2018-07-31 01:20:02 +010054 """
55 Authenticator initializer. Setup the initial state of the object,
56 while it waits for the config dictionary and database initialization.
Eduardo Sousa819d34c2018-07-31 01:20:02 +010057 """
Eduardo Sousa819d34c2018-07-31 01:20:02 +010058 self.backend = None
59 self.config = None
60 self.db = None
tierno0ea204e2019-01-25 14:16:24 +000061 self.tokens_cache = dict()
62 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
63
Eduardo Sousa819d34c2018-07-31 01:20:02 +010064 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:
Eduardo Sousa819d34c2018-07-31 01:20:02 +010077 if not self.db:
Eduardo Sousad1b525d2018-10-04 04:24:18 +010078 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"]))
tierno0ea204e2019-01-25 14:16:24 +000087 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"]))
Eduardo Sousa819d34c2018-07-31 01:20:02 +010095 except Exception as e:
96 raise AuthException(str(e))
97
Eduardo Sousad1b525d2018-10-04 04:24:18 +010098 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
tiernod985a8d2018-10-19 14:12:28 +0200105 def init_db(self, target_version='1.1'):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100106 """
tiernod985a8d2018-10-19 14:12:28 +0200107 Check if the database has been initialized, with at least one user. If not, create an adthe required tables
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100108 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
Eduardo Sousa2f988212018-07-26 01:04:11 +0100115 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":
tierno0ea204e2019-01-25 14:16:24 +0000132 token = None # force Unauthorized response to insert user password again
Eduardo Sousa2f988212018-07-26 01:04:11 +0100133 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
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100142 outdata = self.new_token(None, {"username": user, "password": passwd})
Eduardo Sousa2f988212018-07-26 01:04:11 +0100143 token = outdata["id"]
144 cherrypy.session['Authorization'] = token
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100145 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100146 return self._internal_authorize(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100147 else:
148 try:
149 self.backend.validate_token(token)
tierno0ea204e2019-01-25 14:16:24 +0000150 # TODO: check if this can be avoided. Backend may provide enough information
151 return self.tokens_cache[token]
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100152 except AuthException:
153 self.del_token(token)
154 raise
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100155 except AuthException as e:
Eduardo Sousa2f988212018-07-26 01:04:11 +0100156 if cherrypy.session.get('Authorization'):
157 del cherrypy.session['Authorization']
158 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100159 raise AuthException(str(e))
Eduardo Sousa2f988212018-07-26 01:04:11 +0100160
161 def new_token(self, session, indata, remote):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100162 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100163 return self._internal_new_token(session, indata, remote)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100164 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,
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100194 "expires": now + 3600,
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100195 "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
tierno0ea204e2019-01-25 14:16:24 +0000206 # TODO: check if this can be avoided. Backend may provide enough information
207 self.tokens_cache[token] = new_session
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100208
209 return deepcopy(new_session)
Eduardo Sousa2f988212018-07-26 01:04:11 +0100210
211 def get_token_list(self, session):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100212 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100213 return self._internal_get_token_list(session)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100214 else:
tierno0ea204e2019-01-25 14:16:24 +0000215 # TODO: check if this can be avoided. Backend may provide enough information
216 return [deepcopy(token) for token in self.tokens_cache.values()
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100217 if token["username"] == session["username"]]
Eduardo Sousa2f988212018-07-26 01:04:11 +0100218
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100219 def get_token(self, session, token):
220 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100221 return self._internal_get_token(session, token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100222 else:
tierno0ea204e2019-01-25 14:16:24 +0000223 # TODO: check if this can be avoided. Backend may provide enough information
224 token_value = self.tokens_cache.get(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100225 if not token_value:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100226 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100227 if token_value["username"] != session["username"] and not session["admin"]:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100228 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100229 return token_value
Eduardo Sousa2f988212018-07-26 01:04:11 +0100230
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100231 def del_token(self, token):
232 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100233 return self._internal_del_token(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100234 else:
235 try:
236 self.backend.revoke_token(token)
tierno0ea204e2019-01-25 14:16:24 +0000237 del self.tokens_cache[token]
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100238 return "token '{}' deleted".format(token)
239 except KeyError:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100240 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
241
tierno0ea204e2019-01-25 14:16:24 +0000242 def _internal_authorize(self, token_id):
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100243 try:
tierno0ea204e2019-01-25 14:16:24 +0000244 if not token_id:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100245 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
tierno0ea204e2019-01-25 14:16:24 +0000246 # try to get from cache first
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100247 now = time()
tierno0ea204e2019-01-25 14:16:24 +0000248 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})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100257 if session["expires"] < now:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100258 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
tierno0ea204e2019-01-25 14:16:24 +0000259 self.tokens_cache[token_id] = session
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100260 return session
tierno0ea204e2019-01-25 14:16:24 +0000261 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
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100267 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")})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100282 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
tierno0ea204e2019-01-25 14:16:24 +0000322 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)
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100326 return deepcopy(new_session)
327
328 def _internal_get_token_list(self, session):
tierno0ea204e2019-01-25 14:16:24 +0000329 now = time()
330 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100331 return token_list
332
333 def _internal_get_token(self, session, token_id):
tierno0ea204e2019-01-25 14:16:24 +0000334 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100335 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:
tierno0ea204e2019-01-25 14:16:24 +0000343 self.tokens_cache.pop(token_id, None)
344 self.db.del_one("tokens", {"_id": token_id})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100345 return "token '{}' deleted".format(token_id)
tierno0ea204e2019-01-25 14:16:24 +0000346 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