1 # -*- coding: utf-8 -*-
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
7 # http://www.apache.org/licenses/LICENSE-2.0
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
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
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
23 __author__
= "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
24 __date__
= "$27-jul-2018 23:59:59$"
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
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
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.
51 periodin_db_pruning
= 60*30 # for the internal backend only. every 30 minutes expired tokens will be pruned
55 Authenticator initializer. Setup the initial state of the object,
56 while it waits for the config dictionary and database initialization.
61 self
.tokens_cache
= dict()
62 self
.next_db_prune_time
= 0 # time when next cleaning of expired tokens must be done
64 self
.logger
= logging
.getLogger("nbi.authenticator")
66 def start(self
, config
):
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.
72 :param config: dictionary containing the relevant parameters for this object.
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"])
85 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
86 .format(config
["database"]["driver"]))
88 if config
["authentication"]["backend"] == "keystone":
89 self
.backend
= AuthconnKeystone(self
.config
["authentication"])
90 elif config
["authentication"]["backend"] == "internal":
91 self
._internal
_tokens
_prune
()
93 raise AuthException("Unknown authentication backend: {}"
94 .format(config
["authentication"]["backend"]))
95 except Exception as e
:
96 raise AuthException(str(e
))
101 self
.db
.db_disconnect()
102 except DbException
as e
:
103 raise AuthException(str(e
), http_code
=e
.http_code
)
105 def init_db(self
, target_version
='1.1'):
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.
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.
119 # 1. Get token Authorization bearer
120 auth
= cherrypy
.request
.headers
.get("Authorization")
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]
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
138 user_passwd
= standard_b64decode(user_passwd64
).decode()
139 user
, _
, passwd
= user_passwd
.partition(":")
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
)
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
)
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
))
161 def new_token(self
, session
, indata
, remote
):
162 if self
.config
["authentication"]["backend"] == "internal":
163 return self
._internal
_new
_token
(session
, indata
, remote
)
165 if indata
.get("username"):
166 token
, projects
= self
.backend
.authenticate_with_user_password(
167 indata
.get("username"), indata
.get("password"))
169 token
, projects
= self
.backend
.authenticate_with_token(
170 session
.get("id"), indata
.get("project_id"))
172 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
173 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
181 project_id
= projects
[0]
183 if project_id
== "admin":
186 session_admin
= reduce(lambda x
, y
: x
or (True if y
== "admin" else False),
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
202 new_session
["remote_host"] = remote
.name
204 new_session
["remote_host"] = remote
.ip
206 # TODO: check if this can be avoided. Backend may provide enough information
207 self
.tokens_cache
[token
] = new_session
209 return deepcopy(new_session
)
211 def get_token_list(self
, session
):
212 if self
.config
["authentication"]["backend"] == "internal":
213 return self
._internal
_get
_token
_list
(session
)
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"]]
219 def get_token(self
, session
, token
):
220 if self
.config
["authentication"]["backend"] == "internal":
221 return self
._internal
_get
_token
(session
, token
)
223 # TODO: check if this can be avoided. Backend may provide enough information
224 token_value
= self
.tokens_cache
.get(token
)
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
)
231 def del_token(self
, token
):
232 if self
.config
["authentication"]["backend"] == "internal":
233 return self
._internal
_del
_token
(token
)
236 self
.backend
.revoke_token(token
)
237 del self
.tokens_cache
[token
]
238 return "token '{}' deleted".format(token
)
240 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
242 def _internal_authorize(self
, token_id
):
245 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
246 # try to get from cache first
248 session
= self
.tokens_cache
.get(token_id
)
249 if session
and session
["expires"] < now
:
250 del self
.tokens_cache
[token_id
]
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
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
)
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"]}
275 def _internal_new_token(self
, session
, indata
, remote
):
279 # Try using username/password
280 if indata
.get("username"):
281 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
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"]:
289 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
291 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
293 user_content
= user_rows
[0]
295 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
297 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
298 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
308 project_id
= user_content
["projects"][0]
309 if project_id
== "admin":
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
}
318 new_session
["remote_host"] = remote
.name
320 new_session
["remote_host"] = remote
.ip
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
)
328 def _internal_get_token_list(self
, session
):
330 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
333 def _internal_get_token(self
, session
, token_id
):
334 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
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
)
341 def _internal_del_token(self
, token_id
):
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
)
352 def _internal_tokens_prune(self
, now
=None):
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