blob: f0e00b990e28cdedc663a38dc15d6d7e3ba65c99 [file] [log] [blame]
Eduardo Sousa819d34c2018-07-31 01:20:02 +01001# -*- coding: utf-8 -*-
2
Eduardo Sousad795f872019-02-05 16:05:53 +00003# Copyright 2018 Whitestack, LLC
4# Copyright 2018 Telefonica S.A.
tierno0ea204e2019-01-25 14:16:24 +00005#
Eduardo Sousad795f872019-02-05 16:05:53 +00006# 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
tierno0ea204e2019-01-25 14:16:24 +000011#
12# Unless required by applicable law or agreed to in writing, software
Eduardo Sousad795f872019-02-05 16:05:53 +000013# 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##
tierno0ea204e2019-01-25 14:16:24 +000021
22
Eduardo Sousa819d34c2018-07-31 01:20:02 +010023"""
24Authenticator is responsible for authenticating the users,
25create the tokens unscoped and scoped, retrieve the role
26list inside the projects that they are inserted
27"""
28
tierno0ea204e2019-01-25 14:16:24 +000029__author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
Eduardo Sousa819d34c2018-07-31 01:20:02 +010030__date__ = "$27-jul-2018 23:59:59$"
31
Eduardo Sousad1b525d2018-10-04 04:24:18 +010032import cherrypy
Eduardo Sousa819d34c2018-07-31 01:20:02 +010033import logging
Eduardo Sousa2f988212018-07-26 01:04:11 +010034from base64 import standard_b64decode
Eduardo Sousa819d34c2018-07-31 01:20:02 +010035from copy import deepcopy
36from functools import reduce
Eduardo Sousad1b525d2018-10-04 04:24:18 +010037from hashlib import sha256
Eduardo Sousa2f988212018-07-26 01:04:11 +010038from http import HTTPStatus
Eduardo Sousad1b525d2018-10-04 04:24:18 +010039from random import choice as random_choice
Eduardo Sousa819d34c2018-07-31 01:20:02 +010040from time import time
Eduardo Sousa2f988212018-07-26 01:04:11 +010041
Eduardo Sousa819d34c2018-07-31 01:20:02 +010042from authconn import AuthException
43from authconn_keystone import AuthconnKeystone
Eduardo Sousad1b525d2018-10-04 04:24:18 +010044from osm_common import dbmongo
45from osm_common import dbmemory
46from osm_common.dbbase import DbException
Eduardo Sousa2f988212018-07-26 01:04:11 +010047
Eduardo Sousa2f988212018-07-26 01:04:11 +010048
Eduardo Sousa819d34c2018-07-31 01:20:02 +010049class 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 """
Eduardo Sousa2f988212018-07-26 01:04:11 +010056
tierno0ea204e2019-01-25 14:16:24 +000057 periodin_db_pruning = 60*30 # for the internal backend only. every 30 minutes expired tokens will be pruned
58
Eduardo Sousad1b525d2018-10-04 04:24:18 +010059 def __init__(self):
Eduardo Sousa819d34c2018-07-31 01:20:02 +010060 """
61 Authenticator initializer. Setup the initial state of the object,
62 while it waits for the config dictionary and database initialization.
Eduardo Sousa819d34c2018-07-31 01:20:02 +010063 """
Eduardo Sousa819d34c2018-07-31 01:20:02 +010064 self.backend = None
65 self.config = None
66 self.db = None
tierno0ea204e2019-01-25 14:16:24 +000067 self.tokens_cache = dict()
68 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
69
Eduardo Sousa819d34c2018-07-31 01:20:02 +010070 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:
Eduardo Sousa819d34c2018-07-31 01:20:02 +010083 if not self.db:
Eduardo Sousad1b525d2018-10-04 04:24:18 +010084 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"]))
tierno0ea204e2019-01-25 14:16:24 +000093 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"]))
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100101 except Exception as e:
102 raise AuthException(str(e))
103
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100104 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
tiernod985a8d2018-10-19 14:12:28 +0200111 def init_db(self, target_version='1.1'):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100112 """
tiernod985a8d2018-10-19 14:12:28 +0200113 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 +0100114 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
Eduardo Sousa2f988212018-07-26 01:04:11 +0100121 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":
tierno0ea204e2019-01-25 14:16:24 +0000138 token = None # force Unauthorized response to insert user password again
Eduardo Sousa2f988212018-07-26 01:04:11 +0100139 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
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100148 outdata = self.new_token(None, {"username": user, "password": passwd})
Eduardo Sousa2f988212018-07-26 01:04:11 +0100149 token = outdata["id"]
150 cherrypy.session['Authorization'] = token
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100151 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100152 return self._internal_authorize(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100153 else:
154 try:
155 self.backend.validate_token(token)
tierno0ea204e2019-01-25 14:16:24 +0000156 # TODO: check if this can be avoided. Backend may provide enough information
157 return self.tokens_cache[token]
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100158 except AuthException:
159 self.del_token(token)
160 raise
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100161 except AuthException as e:
Eduardo Sousa2f988212018-07-26 01:04:11 +0100162 if cherrypy.session.get('Authorization'):
163 del cherrypy.session['Authorization']
164 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100165 raise AuthException(str(e))
Eduardo Sousa2f988212018-07-26 01:04:11 +0100166
167 def new_token(self, session, indata, remote):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100168 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100169 return self._internal_new_token(session, indata, remote)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100170 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,
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100200 "expires": now + 3600,
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100201 "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
tierno0ea204e2019-01-25 14:16:24 +0000212 # TODO: check if this can be avoided. Backend may provide enough information
213 self.tokens_cache[token] = new_session
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100214
215 return deepcopy(new_session)
Eduardo Sousa2f988212018-07-26 01:04:11 +0100216
217 def get_token_list(self, session):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100218 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100219 return self._internal_get_token_list(session)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100220 else:
tierno0ea204e2019-01-25 14:16:24 +0000221 # TODO: check if this can be avoided. Backend may provide enough information
222 return [deepcopy(token) for token in self.tokens_cache.values()
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100223 if token["username"] == session["username"]]
Eduardo Sousa2f988212018-07-26 01:04:11 +0100224
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100225 def get_token(self, session, token):
226 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100227 return self._internal_get_token(session, token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100228 else:
tierno0ea204e2019-01-25 14:16:24 +0000229 # TODO: check if this can be avoided. Backend may provide enough information
230 token_value = self.tokens_cache.get(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100231 if not token_value:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100232 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100233 if token_value["username"] != session["username"] and not session["admin"]:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100234 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100235 return token_value
Eduardo Sousa2f988212018-07-26 01:04:11 +0100236
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100237 def del_token(self, token):
238 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100239 return self._internal_del_token(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100240 else:
241 try:
242 self.backend.revoke_token(token)
tierno0ea204e2019-01-25 14:16:24 +0000243 del self.tokens_cache[token]
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100244 return "token '{}' deleted".format(token)
245 except KeyError:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100246 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
247
tierno0ea204e2019-01-25 14:16:24 +0000248 def _internal_authorize(self, token_id):
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100249 try:
tierno0ea204e2019-01-25 14:16:24 +0000250 if not token_id:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100251 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
tierno0ea204e2019-01-25 14:16:24 +0000252 # try to get from cache first
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100253 now = time()
tierno0ea204e2019-01-25 14:16:24 +0000254 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})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100263 if session["expires"] < now:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100264 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
tierno0ea204e2019-01-25 14:16:24 +0000265 self.tokens_cache[token_id] = session
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100266 return session
tierno0ea204e2019-01-25 14:16:24 +0000267 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
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100273 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")})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100288 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
tierno0ea204e2019-01-25 14:16:24 +0000328 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)
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100332 return deepcopy(new_session)
333
334 def _internal_get_token_list(self, session):
tierno0ea204e2019-01-25 14:16:24 +0000335 now = time()
336 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100337 return token_list
338
339 def _internal_get_token(self, session, token_id):
tierno0ea204e2019-01-25 14:16:24 +0000340 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100341 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:
tierno0ea204e2019-01-25 14:16:24 +0000349 self.tokens_cache.pop(token_id, None)
350 self.db.del_one("tokens", {"_id": token_id})
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100351 return "token '{}' deleted".format(token_id)
tierno0ea204e2019-01-25 14:16:24 +0000352 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