diff --git a/etc/storyboard.conf.sample b/etc/storyboard.conf.sample index 1825bd3f..59279b81 100644 --- a/etc/storyboard.conf.sample +++ b/etc/storyboard.conf.sample @@ -33,6 +33,9 @@ lock_path = $state_path/lock # Port the bind the API server to # bind_port = 8080 +# OpenId Authentication endpoint +# openid_url = https://login.launchpad.net/+openid + [database] # This line MUST be changed to actually run storyboard # Example: diff --git a/requirements.txt b/requirements.txt index bb5f7503..e79bfaed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,12 @@ pbr>=0.5.21,<1.0 alembic>=0.4.1 Babel>=0.9.6 iso8601>=0.1.8 +oauthlib>=0.6 oslo.config>=1.2.0 pecan>=0.2.0 python-openid PyYAML>=3.1.0 +requests>=1.1 six>=1.4.1 SQLAlchemy>=0.8,<=0.8.99 WSME>=0.5b6 diff --git a/storyboard/api/auth/__init__.py b/storyboard/api/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/storyboard/api/auth/auth_controller.py b/storyboard/api/auth/auth_controller.py new file mode 100644 index 00000000..ed4d8b25 --- /dev/null +++ b/storyboard/api/auth/auth_controller.py @@ -0,0 +1,192 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from oauthlib.oauth2 import RequestValidator +from oauthlib.oauth2 import WebApplicationServer +from oslo.config import cfg + +#Todo(nkonovalov): make storage configurable +from storyboard.api.auth.memory_storage import MemoryTokenStorage +from storyboard.db import api as db_api + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +class SkeletonValidator(RequestValidator): + """This is oauth skeleton for handling all kind of validations and storage + manipulations. + + As it is and OAuth2, not OpenId-connect, some methods are not required to + be implemented. + + Scope parameter validation is skipped as it is not a part of OpenId-connect + protocol. + + """ + + def validate_client_id(self, client_id, request, *args, **kwargs): + """Check that a valid client is connecting + + """ + + # Let's think about valid clients later + return True + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, + **kwargs): + """Check that the client is allowed to redirect using the given + redirect_uri. + + """ + + #todo(nkonovalov): check an uri based on CONF.domain + return True + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + return request.sb_redirect_uri + + def validate_scopes(self, client_id, scopes, client, request, *args, + **kwargs): + """Scopes are not supported in OpenId-connect + The "user" value is hardcoded here to fill the difference between + the protocols. + + """ + + return scopes == "user" + + def get_default_scopes(self, client_id, request, *args, **kwargs): + """Scopes a client will authorize for if none are supplied in the + authorization request. + + """ + + return ["user"] + + def validate_response_type(self, client_id, response_type, client, request, + *args, **kwargs): + """Clients should only be allowed to use one type of response type, the + one associated with their one allowed grant type. + In this case it must be "code". + + """ + + return response_type == "code" + + # Post-authorization + + def save_authorization_code(self, client_id, code, request, *args, + **kwargs): + """Save the code to the storage and remove the state as it is persisted + in the "code" argument + """ + + openid = request._params["openid.claimed_id"] + email = request._params["openid.sreg.email"] + fullname = request._params["openid.sreg.fullname"] + + user = db_api.user_get_by_openid(openid) + + if not user: + user = db_api.user_create({"openid": openid, + "fullname": fullname, + "email": email}) + else: + user = db_api.user_update(user.id, {"fullname": fullname, + "email": email}) + + TOKEN_STORAGE.save_authorization_code(code, user_id=user.id) + + # Token request + + def authenticate_client(self, request, *args, **kwargs): + """Skip the authentication here. It is handled through an OpenId client + The parameters are set to match th OAuth protocol. + + """ + + setattr(request, "client", type("Object", (object,), {})()) + setattr(request.client, "client_id", "1") + return True + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + """Validate the code belongs to the client.""" + + return TOKEN_STORAGE.check_authorization_code(code) + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, + *args, **kwargs): + """Check that the client is allowed to redirect using the given + redirect_uri. + + """ + + #todo(nkonovalov): check an uri based on CONF.domain + return True + + def validate_grant_type(self, client_id, grant_type, client, request, + *args, **kwargs): + """Clients should only be allowed to use one type of grant. + In this case, it must be "authorization_code" or "refresh_token" + + """ + + return (grant_type == "authorization_code" + or grant_type == "refresh_token") + + def save_bearer_token(self, token, request, *args, **kwargs): + """Save all token information to the storage.""" + + code = request._params["code"] + code_info = TOKEN_STORAGE.get_authorization_code_info(code) + user_id = code_info.user_id + + TOKEN_STORAGE.save_token(access_token=token["access_token"], + expires_in=token["expires_in"], + refresh_token=token["refresh_token"], + user_id=user_id) + + def invalidate_authorization_code(self, client_id, code, request, *args, + **kwargs): + """Authorization codes are use once, invalidate it when a token has + been acquired. + + """ + + TOKEN_STORAGE.invalidate_authorization_code(code) + + # Protected resource request + + def validate_bearer_token(self, token, scopes, request): + """The check will be performed in a separate middleware.""" + + pass + + # Token refresh request + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + """Scopes a client will authorize for if none are supplied in the + authorization request. + + """ + return ["user"] + + +validator = SkeletonValidator() +SERVER = WebApplicationServer(validator) + +TOKEN_STORAGE = MemoryTokenStorage() diff --git a/storyboard/api/auth/memory_storage.py b/storyboard/api/auth/memory_storage.py new file mode 100644 index 00000000..758d6ef8 --- /dev/null +++ b/storyboard/api/auth/memory_storage.py @@ -0,0 +1,91 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from storyboard.api.auth import storage + + +class Token(object): + def __init__(self, access_token, refresh_token, expires_in, user_id): + self.access_token = access_token + self.refresh_token = refresh_token + self.expires_in = expires_in + self.expires_at = datetime.datetime.now() + \ + datetime.timedelta(seconds=expires_in) + self.user_id = user_id + + +class AuthorizationCode(object): + def __init__(self, code, user_id): + self.code = code + self.user_id = user_id + + +class MemoryTokenStorage(storage.StorageBase): + + def __init__(self): + self.token_set = set([]) + self.auth_code_set = set([]) + self.state_set = set([]) + + def save_token(self, access_token, expires_in, refresh_token, user_id): + token_info = Token(access_token=access_token, + expires_in=expires_in, + refresh_token=refresh_token, + user_id=user_id) + + self.token_set.add(token_info) + + def save_authorization_code(self, authorization_code, user_id): + self.auth_code_set.add(AuthorizationCode(authorization_code, user_id)) + + def check_authorization_code(self, code): + code_entry = None + for entry in self.auth_code_set: + if entry.code["code"] == code: + code_entry = entry + break + + if not code_entry: + return False + + return True + + def get_authorization_code_info(self, code): + for entry in self.auth_code_set: + if entry.code["code"] == code: + return entry + + return None + + def invalidate_authorization_code(self, code): + code_entry = None + for entry in self.auth_code_set: + if entry.code["code"] == code: + code_entry = entry + break + + self.auth_code_set.remove(code_entry) + + def save_state(self, state): + self.state_set.add(state) + + def check_remove_state(self, state): + if state in self.state_set: + self.state_set.remove(state) + return True + + return False diff --git a/storyboard/api/auth/openid_client.py b/storyboard/api/auth/openid_client.py new file mode 100644 index 00000000..d22460c1 --- /dev/null +++ b/storyboard/api/auth/openid_client.py @@ -0,0 +1,117 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from oslo.config import cfg +import requests + +from storyboard.api.auth import utils + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +OPENID_OPTS = [ + cfg.StrOpt('openid_url', + default='https://login.launchpad.net/+openid', + help='OpenId Authentication endpoint') +] + +CONF.register_opts(OPENID_OPTS) + + +class OpenIdClient(object): + + def send_openid_redirect(self, request, response): + redirect_location = CONF.openid_url + response.status_code = 303 + + return_params = { + "scope": str(request.params.get("scope")), + "state": str(request.params.get("state")), + "client_id": str(request.params.get("client_id")), + "response_type": str(request.params.get("response_type")), + "sb_redirect_uri": str(request.params.get("redirect_uri")) + } + + #TODO(krotscheck): URI base should be fully inferred from the request. + # assuming that the API is hosted at /api isn't good. + return_to_url = request.host_url + "/api/v1/openid/authorize_return?" \ + + utils.join_params(return_params, encode=True) + + openid_params = { + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.mode": "checkid_setup", + + "openid.claimed_id": "http://specs.openid.net/auth/2.0/" + "identifier_select", + "openid.identity": "http://specs.openid.net/auth/2.0/" + "identifier_select", + + "openid.realm": request.host_url, + "openid.return_to": return_to_url, + + "openid.ns.sreg": "http://openid.net/sreg/1.0", + "openid.sreg.required": "fullname,email", + + "openid.ns.ext2": "http://openid.net/srv/ax/1.0", + "openid.ext2.mode": "fetch_request", + "openid.ext2.type.FirstName": "http://schema.openid.net/" + "namePerson/first", + "openid.ext2.type.LastName": "http://schema.openid.net/" + "namePerson/last", + "openid.ext2.type.Email": "http://schema.openid.net/contact/email", + "openid.ext2.required": "FirstName,LastName,Email" + } + joined_params = utils.join_params(openid_params) + + redirect_location = redirect_location + '?' + joined_params + response.headers["Location"] = redirect_location + + return response + + def verify_openid(self, request, response): + verify_params = dict(request.params.copy()) + verify_params["openid.mode"] = "check_authentication" + + verify_response = requests.post(CONF.openid_url, data=verify_params) + verify_data_tokens = verify_response.content.split() + verify_dict = dict((token.split(":")[0], token.split(":")[1]) + for token in verify_data_tokens) + + if (verify_response.status_code / 100 != 2 + or verify_dict['is_valid'] != 'true'): + response.status_code = 401 # Unauthorized + return False + + return True + + def create_association(self, op_location): + # Let's skip it for MVP at least + query_dict = { + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.mode": "associate", + "openid.assoc_type": "HMAC-SHA256", + "openid.session_type": "no-encryption" + } + assoc_data = requests.post(op_location, data=query_dict).content + + data_tokens = assoc_data.split() + data_dict = dict((token.split(":")[0], token.split(":")[1]) + for token in data_tokens) + + return data_dict["assoc_handle"] + +client = OpenIdClient() diff --git a/storyboard/api/auth/storage.py b/storyboard/api/auth/storage.py new file mode 100644 index 00000000..6a6f7bff --- /dev/null +++ b/storyboard/api/auth/storage.py @@ -0,0 +1,51 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + + +class StorageBase(object): + + @abc.abstractmethod + def save_state(self, state): + pass + + @abc.abstractmethod + def check_remove_state(self, state): + pass + + @abc.abstractmethod + def save_authorization_code(self, authorization_code, user_id): + pass + + @abc.abstractmethod + def check_authorization_code(self, code): + pass + + @abc.abstractmethod + def get_authorization_code_info(self, code): + pass + + @abc.abstractmethod + def invalidate_authorization_code(self, code): + pass + + @abc.abstractmethod + def save_token(self, access_token, expires_in, refresh_token, user_id): + pass + + @abc.abstractmethod + def remove_token(self, token): + pass diff --git a/storyboard/api/auth/utils.py b/storyboard/api/auth/utils.py new file mode 100644 index 00000000..57f11edf --- /dev/null +++ b/storyboard/api/auth/utils.py @@ -0,0 +1,24 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import six +import urllib + + +def join_params(params, encode=True): + return '&'.join( + ["%s=%s" % (urllib.quote(key, safe='') if encode else key, + urllib.quote(val, safe='') if encode else val) + for key, val in six.iteritems(params)]) diff --git a/storyboard/api/v1/auth.py b/storyboard/api/v1/auth.py new file mode 100644 index 00000000..141b7a23 --- /dev/null +++ b/storyboard/api/v1/auth.py @@ -0,0 +1,119 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging +import six + +from oslo.config import cfg +import pecan +from pecan import request +from pecan import response +from pecan import rest + +from storyboard.api.auth.auth_controller import SERVER +from storyboard.api.auth.auth_controller import TOKEN_STORAGE +from storyboard.api.auth.openid_client import client as openid_client +from storyboard.api.auth import utils + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +AUTH_OPTS = [ +] +CONF.register_opts(AUTH_OPTS) + + +class AuthController(rest.RestController): + _custom_actions = { + "authorize": ["GET"], + "authorize_return": ["GET"], + "token": ["POST"], + } + + @pecan.expose() + def authorize(self): + """Authorization code request.""" + + return openid_client.send_openid_redirect(request, response) + + @pecan.expose() + def authorize_return(self): + """Authorization code redirect endpoint. + At this point the server verifies an OpenId and retrieves user's + e-mail and full name from request + + The client may already use both the e-mail and the fullname in the + templates, even though there was no token request so far. + + """ + + if not openid_client.verify_openid(request, response): + # The verify call will set unauthorized code + return response + + headers, body, code = SERVER.create_authorization_response( + uri=request.url, + http_method=request.method, + body=request.body, + scopes=request.params.get("scope"), + headers=request.headers) + + response.headers = dict((str(k), str(v)) + for k, v in headers.iteritems()) + response.status_code = code + response.body = body or '' + + return response + + @pecan.expose() + def client_redirect(self): + """This method is required to perform a hash-bang url redirect.""" + + response.status_code = 303 + + params = dict((str(k), str(v)) + for k, v in six.iteritems(request.params)) + joined_params = utils.join_params(params) + response.headers["Location"] = "".join([request.host_url, "/", + CONF.auth_token_url, "?", + joined_params]) + + return response + + @pecan.expose() + def token(self): + """Access token endpoint.""" + + auth_code = request.params.get("code") + code_info = TOKEN_STORAGE.get_authorization_code_info(auth_code) + + headers, body, code = SERVER.create_token_response( + uri=request.url, + http_method=request.method, + body=request.body, + headers=request.headers) + + response.headers = dict((str(k), str(v)) + for k, v in headers.iteritems()) + response.status_code = code + + json_body = json.loads(body) + json_body.update({ + 'id_token': code_info.user_id + }) + + response.body = json.dumps(json_body) + return response diff --git a/storyboard/api/v1/users.py b/storyboard/api/v1/users.py index eaaf20ab..2687b6c1 100644 --- a/storyboard/api/v1/users.py +++ b/storyboard/api/v1/users.py @@ -29,15 +29,15 @@ class UsersController(rest.RestController): users = wsme_models.User.get_all() return users - @wsme_pecan.wsexpose(wsme_models.User, unicode) - def get_one(self, username): + @wsme_pecan.wsexpose(wsme_models.User, int) + def get_one(self, user_id): """Retrieve details about one user. - :param username: unique name to identify the user. + :param user_id: The unique id of this user """ - user = wsme_models.User.get(username=username) + user = wsme_models.User.get(id=user_id) if not user: - raise ClientSideError("User %s not found" % username, + raise ClientSideError("User %s not found" % user_id, status_code=404) return user @@ -52,14 +52,14 @@ class UsersController(rest.RestController): raise ClientSideError("Could not create User") return created_user - @wsme_pecan.wsexpose(wsme_models.User, unicode, wsme_models.User) - def put(self, username, user): + @wsme_pecan.wsexpose(wsme_models.User, int, wsme_models.User) + def put(self, user_id, user): """Modify this user. - :param username: unique name to identify the user. + :param user_id: unique id to identify the user. :param user: a user within the request body. """ - updated_user = wsme_models.User.update("username", username, user) + updated_user = wsme_models.User.update("id", user_id, user) if not updated_user: - raise ClientSideError("Could not update user %s" % username) + raise ClientSideError("Could not update user %s" % user_id) return updated_user diff --git a/storyboard/api/v1/v1_controller.py b/storyboard/api/v1/v1_controller.py index 29fc4199..314d23a2 100644 --- a/storyboard/api/v1/v1_controller.py +++ b/storyboard/api/v1/v1_controller.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from storyboard.api.v1.auth import AuthController from storyboard.api.v1.project_groups import ProjectGroupsController from storyboard.api.v1.projects import ProjectsController from storyboard.api.v1.stories import StoriesController @@ -29,3 +30,5 @@ class V1Controller(object): users = UsersController() stories = StoriesController() tasks = TasksController() + + openid = AuthController() diff --git a/storyboard/db/api.py b/storyboard/db/api.py index 5fe05147..654495d6 100644 --- a/storyboard/db/api.py +++ b/storyboard/db/api.py @@ -82,7 +82,46 @@ def _entity_update(kls, entity_id, values): return entity -# BEGIN Projects +## BEGIN Users + +def user_get(user_id): + return _entity_get(models.User, user_id) + + +def user_get_by_openid(openid): + query = model_query(models.User, get_session()) + return query.filter_by(openid=openid).first() + + +def user_create(values): + user = models.User() + user.update(values.copy()) + + session = get_session() + with session.begin(): + try: + user.save(session=session) + except db_exc.DBDuplicateEntry as e: + raise exc.DuplicateEntry("Duplicate entry for User: %s" + % e.columns) + + return user + + +def user_update(user_id, values): + session = get_session() + + with session.begin(): + user = _entity_get(models.User, user_id) + if user is None: + raise exc.NotFound("User %s not found" % user_id) + + user.update(values.copy()) + + return user + + +## BEGIN Projects def project_get(project_id): return _entity_get(models.Project, project_id) diff --git a/storyboard/openstack/common/log.py b/storyboard/openstack/common/log.py index ed3a88db..b26ed191 100644 --- a/storyboard/openstack/common/log.py +++ b/storyboard/openstack/common/log.py @@ -532,12 +532,7 @@ def _setup_logging_from_conf(): else: handler.setFormatter(ContextFormatter(datefmt=datefmt)) - if CONF.debug: - log_root.setLevel(logging.DEBUG) - elif CONF.verbose: - log_root.setLevel(logging.INFO) - else: - log_root.setLevel(logging.WARNING) + log_root.setLevel(logging.DEBUG) for pair in CONF.default_log_levels: mod, _sep, level_name = pair.partition('=')