Auth controller

Endpoints added.
OpenId support added.
Memory storage added for tokens and authorization codes.

Tbd in next commits:
* Add a middleware for token validation

Change-Id: I1805bc645428bc9301dc3447537fd9792afe781d
This commit is contained in:
Nikita Konovalov 2014-01-23 18:08:23 +04:00
parent 20aadc8701
commit 0bb5d06aa9
13 changed files with 653 additions and 17 deletions

View File

@ -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:

View File

@ -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

View File

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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)])

119
storyboard/api/v1/auth.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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('=')