blob: a734b467d3f9fd090143fb9f85d7885f00068c7e [file] [log] [blame]
Eduardo Sousa819d34c2018-07-31 01:20:02 +01001# -*- coding: utf-8 -*-
2
3"""
4Authenticator is responsible for authenticating the users,
5create the tokens unscoped and scoped, retrieve the role
6list inside the projects that they are inserted
7"""
8
9__author__ = "Eduardo Sousa <esousa@whitestack.com>"
10__date__ = "$27-jul-2018 23:59:59$"
11
Eduardo Sousad1b525d2018-10-04 04:24:18 +010012import cherrypy
Eduardo Sousa819d34c2018-07-31 01:20:02 +010013import logging
Eduardo Sousa2f988212018-07-26 01:04:11 +010014from base64 import standard_b64decode
Eduardo Sousa819d34c2018-07-31 01:20:02 +010015from copy import deepcopy
16from functools import reduce
Eduardo Sousad1b525d2018-10-04 04:24:18 +010017from hashlib import sha256
Eduardo Sousa2f988212018-07-26 01:04:11 +010018from http import HTTPStatus
Eduardo Sousad1b525d2018-10-04 04:24:18 +010019from random import choice as random_choice
Eduardo Sousa819d34c2018-07-31 01:20:02 +010020from time import time
Eduardo Sousa2f988212018-07-26 01:04:11 +010021
Eduardo Sousa819d34c2018-07-31 01:20:02 +010022from authconn import AuthException
23from authconn_keystone import AuthconnKeystone
Eduardo Sousad1b525d2018-10-04 04:24:18 +010024from osm_common import dbmongo
25from osm_common import dbmemory
26from osm_common.dbbase import DbException
Eduardo Sousa2f988212018-07-26 01:04:11 +010027
Eduardo Sousa2f988212018-07-26 01:04:11 +010028
Eduardo Sousa819d34c2018-07-31 01:20:02 +010029class Authenticator:
30 """
31 This class should hold all the mechanisms for User Authentication and
32 Authorization. Initially it should support Openstack Keystone as a
33 backend through a plugin model where more backends can be added and a
34 RBAC model to manage permissions on operations.
35 """
Eduardo Sousa2f988212018-07-26 01:04:11 +010036
Eduardo Sousad1b525d2018-10-04 04:24:18 +010037 def __init__(self):
Eduardo Sousa819d34c2018-07-31 01:20:02 +010038 """
39 Authenticator initializer. Setup the initial state of the object,
40 while it waits for the config dictionary and database initialization.
Eduardo Sousa819d34c2018-07-31 01:20:02 +010041 """
Eduardo Sousa819d34c2018-07-31 01:20:02 +010042 self.backend = None
43 self.config = None
44 self.db = None
45 self.tokens = dict()
46 self.logger = logging.getLogger("nbi.authenticator")
47
48 def start(self, config):
49 """
50 Method to configure the Authenticator object. This method should be called
51 after object creation. It is responsible by initializing the selected backend,
52 as well as the initialization of the database connection.
53
54 :param config: dictionary containing the relevant parameters for this object.
55 """
56 self.config = config
57
58 try:
59 if not self.backend:
60 if config["authentication"]["backend"] == "keystone":
61 self.backend = AuthconnKeystone(self.config["authentication"])
62 elif config["authentication"]["backend"] == "internal":
63 pass
64 else:
Eduardo Sousad1b525d2018-10-04 04:24:18 +010065 raise AuthException("Unknown authentication backend: {}"
66 .format(config["authentication"]["backend"]))
Eduardo Sousa819d34c2018-07-31 01:20:02 +010067 if not self.db:
Eduardo Sousad1b525d2018-10-04 04:24:18 +010068 if config["database"]["driver"] == "mongo":
69 self.db = dbmongo.DbMongo()
70 self.db.db_connect(config["database"])
71 elif config["database"]["driver"] == "memory":
72 self.db = dbmemory.DbMemory()
73 self.db.db_connect(config["database"])
74 else:
75 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
76 .format(config["database"]["driver"]))
Eduardo Sousa819d34c2018-07-31 01:20:02 +010077 except Exception as e:
78 raise AuthException(str(e))
79
Eduardo Sousad1b525d2018-10-04 04:24:18 +010080 def stop(self):
81 try:
82 if self.db:
83 self.db.db_disconnect()
84 except DbException as e:
85 raise AuthException(str(e), http_code=e.http_code)
86
tiernod985a8d2018-10-19 14:12:28 +020087 def init_db(self, target_version='1.1'):
Eduardo Sousa819d34c2018-07-31 01:20:02 +010088 """
tiernod985a8d2018-10-19 14:12:28 +020089 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 +010090 and insert the predefined mappings between roles and permissions.
91
92 :param target_version: schema version that should be present in the database.
93 :return: None if OK, exception if error or version is different.
94 """
95 pass
96
Eduardo Sousa2f988212018-07-26 01:04:11 +010097 def authorize(self):
98 token = None
99 user_passwd64 = None
100 try:
101 # 1. Get token Authorization bearer
102 auth = cherrypy.request.headers.get("Authorization")
103 if auth:
104 auth_list = auth.split(" ")
105 if auth_list[0].lower() == "bearer":
106 token = auth_list[-1]
107 elif auth_list[0].lower() == "basic":
108 user_passwd64 = auth_list[-1]
109 if not token:
110 if cherrypy.session.get("Authorization"):
111 # 2. Try using session before request a new token. If not, basic authentication will generate
112 token = cherrypy.session.get("Authorization")
113 if token == "logout":
114 token = None # force Unauthorized response to insert user pasword again
115 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
116 # 3. Get new token from user password
117 user = None
118 passwd = None
119 try:
120 user_passwd = standard_b64decode(user_passwd64).decode()
121 user, _, passwd = user_passwd.partition(":")
122 except Exception:
123 pass
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100124 outdata = self.new_token(None, {"username": user, "password": passwd})
Eduardo Sousa2f988212018-07-26 01:04:11 +0100125 token = outdata["id"]
126 cherrypy.session['Authorization'] = token
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100127 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100128 return self._internal_authorize(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100129 else:
130 try:
131 self.backend.validate_token(token)
132 return self.tokens[token]
133 except AuthException:
134 self.del_token(token)
135 raise
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100136 except AuthException as e:
Eduardo Sousa2f988212018-07-26 01:04:11 +0100137 if cherrypy.session.get('Authorization'):
138 del cherrypy.session['Authorization']
139 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100140 raise AuthException(str(e))
Eduardo Sousa2f988212018-07-26 01:04:11 +0100141
142 def new_token(self, session, indata, remote):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100143 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100144 return self._internal_new_token(session, indata, remote)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100145 else:
146 if indata.get("username"):
147 token, projects = self.backend.authenticate_with_user_password(
148 indata.get("username"), indata.get("password"))
149 elif session:
150 token, projects = self.backend.authenticate_with_token(
151 session.get("id"), indata.get("project_id"))
152 else:
153 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
154 http_code=HTTPStatus.UNAUTHORIZED)
155
156 if indata.get("project_id"):
157 project_id = indata.get("project_id")
158 if project_id not in projects:
159 raise AuthException("Project {} not allowed for this user".format(project_id),
160 http_code=HTTPStatus.UNAUTHORIZED)
161 else:
162 project_id = projects[0]
163
164 if project_id == "admin":
165 session_admin = True
166 else:
167 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
168 projects, False)
169
170 now = time()
171 new_session = {
172 "_id": token,
173 "id": token,
174 "issued_at": now,
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100175 "expires": now + 3600,
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100176 "project_id": project_id,
177 "username": indata.get("username") if not session else session.get("username"),
178 "remote_port": remote.port,
179 "admin": session_admin
180 }
181
182 if remote.name:
183 new_session["remote_host"] = remote.name
184 elif remote.ip:
185 new_session["remote_host"] = remote.ip
186
187 self.tokens[token] = new_session
188
189 return deepcopy(new_session)
Eduardo Sousa2f988212018-07-26 01:04:11 +0100190
191 def get_token_list(self, session):
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100192 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100193 return self._internal_get_token_list(session)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100194 else:
195 return [deepcopy(token) for token in self.tokens.values()
196 if token["username"] == session["username"]]
Eduardo Sousa2f988212018-07-26 01:04:11 +0100197
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100198 def get_token(self, session, token):
199 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100200 return self._internal_get_token(session, token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100201 else:
202 token_value = self.tokens.get(token)
203 if not token_value:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100204 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100205 if token_value["username"] != session["username"] and not session["admin"]:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100206 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100207 return token_value
Eduardo Sousa2f988212018-07-26 01:04:11 +0100208
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100209 def del_token(self, token):
210 if self.config["authentication"]["backend"] == "internal":
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100211 return self._internal_del_token(token)
Eduardo Sousa819d34c2018-07-31 01:20:02 +0100212 else:
213 try:
214 self.backend.revoke_token(token)
215 del self.tokens[token]
216 return "token '{}' deleted".format(token)
217 except KeyError:
Eduardo Sousad1b525d2018-10-04 04:24:18 +0100218 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
219
220 def _internal_authorize(self, token):
221 try:
222 if not token:
223 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
224 if token not in self.tokens:
225 raise AuthException("Invalid token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
226 session = self.tokens[token]
227 now = time()
228 if session["expires"] < now:
229 del self.tokens[token]
230 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
231 return session
232 except AuthException:
233 if self.config["global"].get("test.user_not_authorized"):
234 return {"id": "fake-token-id-for-test",
235 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
236 "username": self.config["global"]["test.user_not_authorized"]}
237 else:
238 raise
239
240 def _internal_new_token(self, session, indata, remote):
241 now = time()
242 user_content = None
243
244 # Try using username/password
245 if indata.get("username"):
246 user_rows = self.db.get_list("users", {"username": indata.get("username")})
247 user_content = None
248 if user_rows:
249 user_content = user_rows[0]
250 salt = user_content["_admin"]["salt"]
251 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
252 if shadow_password != user_content["password"]:
253 user_content = None
254 if not user_content:
255 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
256 elif session:
257 user_rows = self.db.get_list("users", {"username": session["username"]})
258 if user_rows:
259 user_content = user_rows[0]
260 else:
261 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
262 else:
263 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
264 http_code=HTTPStatus.UNAUTHORIZED)
265
266 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
267 for _ in range(0, 32))
268 if indata.get("project_id"):
269 project_id = indata.get("project_id")
270 if project_id not in user_content["projects"]:
271 raise AuthException("project {} not allowed for this user"
272 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
273 else:
274 project_id = user_content["projects"][0]
275 if project_id == "admin":
276 session_admin = True
277 else:
278 project = self.db.get_one("projects", {"_id": project_id})
279 session_admin = project.get("admin", False)
280 new_session = {"issued_at": now, "expires": now + 3600,
281 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
282 "remote_port": remote.port, "admin": session_admin}
283 if remote.name:
284 new_session["remote_host"] = remote.name
285 elif remote.ip:
286 new_session["remote_host"] = remote.ip
287
288 self.tokens[token_id] = new_session
289 return deepcopy(new_session)
290
291 def _internal_get_token_list(self, session):
292 token_list = []
293 for token_id, token_value in self.tokens.items():
294 if token_value["username"] == session["username"]:
295 token_list.append(deepcopy(token_value))
296 return token_list
297
298 def _internal_get_token(self, session, token_id):
299 token_value = self.tokens.get(token_id)
300 if not token_value:
301 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
302 if token_value["username"] != session["username"] and not session["admin"]:
303 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
304 return token_value
305
306 def _internal_del_token(self, token_id):
307 try:
308 del self.tokens[token_id]
309 return "token '{}' deleted".format(token_id)
310 except KeyError:
311 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)