Commit f0d7e148 authored by Daniele Venzano's avatar Daniele Venzano

Port the rest API and web interfaces to a cookie-based auth system, add CORS headers

parent 274c94e4
......@@ -219,10 +219,10 @@ class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Regular expression matching correct constant names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|([A-Z][A-Za-z]*Type))$
const-rgx=(([A-Z_][A-Za-z0-9_]*)|(__.*__)|log|([A-Z][A-Za-z]*Type))$
# Naming hint for constant names
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|([A-Z][A-Za-z]*Type))$
const-name-hint=(([A-Z_][A-Za-z0-9_]*)|(__.*__)|log|([A-Z][A-Za-z]*Type))$
# Regular expression matching correct attribute names
attr-rgx=([a-z_][a-z0-9_]{2,30}|id)$
......@@ -254,7 +254,7 @@ notes=FIXME,TODO
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
max-args=7
# Argument names that match this expression will be ignored. Default to name
# with leading underscore
......
language: python
python:
- "3.4"
- "3.5"
install:
- pip install -r requirements.txt
- pip install -r requirements_tests.txt
script:
- pylint *.py zoe_*
- doc8 docs/
after_script:
- mypy -s *.py zoe_*
......@@ -5,6 +5,9 @@
* Add Jenkinsfile with test pipeline
* Update moment.js third-party library
* Docker TLS support
* Add CORS headers to the Rest API
* Add a login endpoint with cookie-based request authentication
* Add LDAP SASL authentication
## Version 0.10.2
......
# Copyright (c) 2016, Quang-Nhat Hoang-Xuan
#
# 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.
"""LDAP authentication module."""
import logging
try:
import ldap
import ldap.sasl
except ImportError:
ldap = None
LDAP_AVAILABLE = False
else:
LDAP_AVAILABLE = True
import zoe_api.auth.base
import zoe_api.exceptions
from zoe_lib.config import get_conf
log = logging.getLogger(__name__)
class LDAPSASLAuthenticator(zoe_api.auth.base.BaseAuthenticator):
"""A simple LDAP authenticator."""
def __init__(self):
self.connection = ldap.initialize(get_conf().ldap_server_uri)
self.base_dn = get_conf().ldap_base_dn
self.connection.protocol_version = ldap.VERSION3
self.sasl_auth = ldap.sasl.sasl({}, 'GSSAPI')
def auth(self, username, password):
"""Authenticate the user or raise an exception."""
search_filter = "uid=" + username
uid = None
role = 'guest'
try:
self.connection.sasl_interactive_bind_s('', self.sasl_auth)
result = self.connection.search_s(self.base_dn, ldap.SCOPE_SUBTREE, search_filter)
if len(result) == 0:
raise zoe_api.exceptions.ZoeAuthException('Unknown user or wrong password.')
user_dict = result[0][1]
uid = username
gid_numbers = [int(x) for x in user_dict['gidNumber']]
if get_conf().ldap_admin_gid in gid_numbers:
role = 'admin'
elif get_conf().ldap_user_gid in gid_numbers:
role = 'user'
elif get_conf().ldap_guest_gid in gid_numbers:
role = 'guest'
else:
log.warning('User {} has an unknown group ID ({}), using guest role'.format(username, result[0][1]['gidNumber']))
role = 'guest'
except ldap.LDAPError as ex:
if ex.args[0]['desc'] == 'Invalid credentials':
raise zoe_api.exceptions.ZoeAuthException('Unknown user or wrong password.')
else:
log.exception("LDAP exception")
zoe_api.exceptions.ZoeAuthException('LDAP error.')
finally:
self.connection.unbind_s()
return uid, role
......@@ -57,6 +57,7 @@ def zoe_web_main() -> int:
app_settings = {
'static_path': os.path.join(os.path.dirname(__file__), "web", "static"),
'template_path': os.path.join(os.path.dirname(__file__), "web", "templates"),
'cookie_secret': config.get_conf().cookie_secret,
'debug': args.debug
}
app = Application(zoe_api.web.web_init(api_endpoint) + zoe_api.rest_api.api_init(api_endpoint), **app_settings)
......
......@@ -21,9 +21,11 @@ import tornado.web
from zoe_api.rest_api.execution import ExecutionAPI, ExecutionCollectionAPI, ExecutionDeleteAPI
from zoe_api.rest_api.info import InfoAPI
from zoe_api.rest_api.userinfo import UserInfoAPI
from zoe_api.rest_api.service import ServiceAPI, ServiceLogsAPI
from zoe_api.rest_api.discovery import DiscoveryAPI
from zoe_api.rest_api.statistics import SchedulerStatsAPI
from zoe_api.rest_api.login import LoginAPI
from zoe_lib.version import ZOE_API_VERSION
......@@ -38,6 +40,8 @@ def api_init(api_endpoint) -> List[tornado.web.URLSpec]:
api_routes = [
tornado.web.url(API_PATH + r'/info', InfoAPI, route_args),
tornado.web.url(API_PATH + r'/login', LoginAPI, route_args),
tornado.web.url(API_PATH + r'/userinfo', UserInfoAPI, route_args),
tornado.web.url(API_PATH + r'/execution/([0-9]+)', ExecutionAPI, route_args),
tornado.web.url(API_PATH + r'/execution/delete/([0-9]+)', ExecutionDeleteAPI, route_args),
......
......@@ -18,7 +18,7 @@
from tornado.web import RequestHandler
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
from zoe_api.rest_api.utils import catch_exceptions
from zoe_api.rest_api.utils import catch_exceptions, manage_cors_headers
class DiscoveryAPI(RequestHandler):
......@@ -28,6 +28,15 @@ class DiscoveryAPI(RequestHandler):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self, execution_id: int, service_group: str):
"""HTTP GET method."""
......
......@@ -18,7 +18,7 @@
from tornado.web import RequestHandler
import tornado.escape
from zoe_api.rest_api.utils import catch_exceptions, get_auth
from zoe_api.rest_api.utils import catch_exceptions, get_auth, manage_cors_headers
import zoe_api.exceptions
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
......@@ -30,6 +30,15 @@ class ExecutionAPI(RequestHandler):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
def options(self, execution_id_):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self, execution_id):
"""GET a single execution by its ID."""
......
......@@ -17,7 +17,7 @@
from tornado.web import RequestHandler
from zoe_api.rest_api.utils import catch_exceptions
from zoe_api.rest_api.utils import catch_exceptions, manage_cors_headers
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
from zoe_lib.config import get_conf
......@@ -31,6 +31,15 @@ class InfoAPI(RequestHandler):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self):
"""HTTP GET method."""
......
......@@ -22,7 +22,7 @@ from tornado.web import RequestHandler
import tornado.gen
import tornado.iostream
from zoe_api.rest_api.utils import catch_exceptions, get_auth
from zoe_api.rest_api.utils import catch_exceptions, get_auth, manage_cors_headers
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
log = logging.getLogger(__name__)
......@@ -37,8 +37,18 @@ class ServiceAPI(RequestHandler):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
@catch_exceptions
def get(self, service_id) -> dict:
def options(self, service_id_):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self, service_id):
"""HTTP GET method."""
uid, role = get_auth(self)
......
......@@ -18,7 +18,7 @@
from tornado.web import RequestHandler
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
from zoe_api.rest_api.utils import catch_exceptions
from zoe_api.rest_api.utils import catch_exceptions, manage_cors_headers
class SchedulerStatsAPI(RequestHandler):
......@@ -28,6 +28,16 @@ class SchedulerStatsAPI(RequestHandler):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
@catch_exceptions
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self):
"""HTTP GET method."""
......
# Copyright (c) 2016, Quang-Nhat Hoang-Xuan
#
# 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.
"""The Info API endpoint."""
from tornado.web import RequestHandler
from zoe_api.rest_api.utils import get_auth, catch_exceptions, manage_cors_headers
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
class UserInfoAPI(RequestHandler):
"""The UserInfo API endpoint."""
def initialize(self, **kwargs):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
@catch_exceptions
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self):
"""HTTP GET method."""
uid, role = get_auth(self)
ret = {
'uid': uid,
'role': role
}
self.write(ret)
def data_received(self, chunk):
"""Not implemented as we do not use stream uploads"""
pass
......@@ -26,6 +26,7 @@ from zoe_lib.config import get_conf
from zoe_api.exceptions import ZoeRestAPIException, ZoeNotFoundException, ZoeAuthException, ZoeException
from zoe_api.auth.ldap import LDAPAuthenticator
from zoe_api.auth.file import PlainTextAuthenticator
from zoe_api.auth.ldapsasl import LDAPSASLAuthenticator
from zoe_api.auth.base import BaseAuthenticator # pylint: disable=unused-import
......@@ -68,6 +69,12 @@ def catch_exceptions(func):
def get_auth(handler: tornado.web.RequestHandler):
"""Try to authenticate a request."""
if handler.get_secure_cookie('zoe'):
cookie_val = str(handler.get_secure_cookie('zoe'))
uid, role = cookie_val[2:-1].split('.')
log.info('Authentication done using cookie')
return uid, role
auth_header = handler.request.headers.get('Authorization')
if auth_header is None or not auth_header.startswith('Basic '):
raise ZoeRestAPIException('missing or wrong authentication information', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
......@@ -79,10 +86,25 @@ def get_auth(handler: tornado.web.RequestHandler):
authenticator = PlainTextAuthenticator() # type: BaseAuthenticator
elif get_conf().auth_type == 'ldap':
authenticator = LDAPAuthenticator()
elif get_conf().auth_type == 'ldapsasl':
authenticator = LDAPSASLAuthenticator()
else:
raise ZoeException('Configuration error, unknown authentication method: {}'.format(get_conf().auth_type))
uid, role = authenticator.auth(username, password)
if uid is None:
raise ZoeRestAPIException('missing or wrong authentication information', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
log.debug('Authentication done using auth-mechanism')
return uid, role
def manage_cors_headers(handler: tornado.web.RequestHandler):
"""Set up the headers for enabling CORS."""
if handler.request.headers.get('Origin') is None:
handler.set_header("Access-Control-Allow-Origin", "*")
else:
handler.set_header("Access-Control-Allow-Origin", handler.request.headers.get('Origin'))
handler.set_header("Access-Control-Allow-Credentials", "true")
handler.set_header("Access-Control-Allow-Headers", "x-requested-with, Content-Type, origin, authorization, accept, client-security-token")
handler.set_header("Access-Control-Allow-Methods", "OPTIONS, GET, DELETE")
handler.set_header("Access-Control-Max-Age", "1000")
......@@ -27,13 +27,13 @@ from zoe_lib.version import ZOE_API_VERSION, ZOE_VERSION
def web_init(api_endpoint) -> List[tornado.web.URLSpec]:
"""Tornado init for the web interface."""
route_args = {
'api_endpoint': api_endpoint
}
web_routes = [
tornado.web.url(r'/', zoe_api.web.start.RootWeb, route_args, name='root'),
tornado.web.url(r'/user', zoe_api.web.start.HomeWeb, route_args, name='home_user'),
tornado.web.url(r'/login', zoe_api.web.start.LoginWeb, route_args, name='login'),
tornado.web.url(r'/executions/new', zoe_api.web.executions.ExecutionDefineWeb, route_args, name='execution_define'),
tornado.web.url(r'/executions/start', zoe_api.web.executions.ExecutionStartWeb, route_args, name='execution_start'),
......
......@@ -19,7 +19,7 @@ from random import randint
import json
from zoe_api.api_endpoint import APIEndpoint # pylint: disable=unused-import
from zoe_api.web.utils import get_auth, catch_exceptions
from zoe_api.web.utils import get_auth_login, get_auth, catch_exceptions
from zoe_api.web.custom_request_handler import ZoeRequestHandler
......@@ -36,6 +36,31 @@ class RootWeb(ZoeRequestHandler):
self.render('index.html')
class LoginWeb(ZoeRequestHandler):
"""The login web page."""
def initialize(self, **kwargs):
"""Initializes the request handler."""
super().initialize(**kwargs)
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
@catch_exceptions
def get(self):
"""Login page."""
self.render('login.html')
@catch_exceptions
def post(self):
"""Try to authenticate."""
username = self.get_argument("username", "")
password = self.get_argument("password", "")
uid, role = get_auth_login(username, password)
if not self.get_secure_cookie('zoe'):
cookie_val = uid + '.' + role
self.set_secure_cookie('zoe', cookie_val)
self.redirect(self.get_argument("next", u"/user"))
class HomeWeb(ZoeRequestHandler):
"""Handler class"""
def initialize(self, **kwargs):
......@@ -77,9 +102,4 @@ class HomeWeb(ZoeRequestHandler):
else:
template_vars['refresh'] = -1
template_vars['execution_status'] = execution['status']
# for c_id in execution['services']:
# c = cont_api.get(c_id)
# ip = list(c['ip_address'].values())[0] # FIXME how to decide which network is the right one?
# for p in c['ports']:
# template_vars['execution_urls'].append(('{}'.format(p['name']), '{}://{}:{}{}'.format(p['protocol'], ip, p['port_number'], p['path'])))
return self.render('home_guest.html', **template_vars)
<div id="main-container">
<div id="main">
<h1>
<img alt="zoe dummy login page" src="{{ static_url("img/logo.png") }}">
</h1>
<div id="login-form">
<form action="/login" method="post" id="login_form">
<fieldset>
<label for="username">Username</label>
<input autocapitalize="off" autocorrect="off" class="text-input" id="username" name="username" tabindex="1" type="text" value="">
</fieldset>
<fieldset>
<label for="password">Password</label>
<input class="text-input" id="password" name="password" tabindex="2" type="password" value="">
</fieldset>
<fieldset>
<span class="errormessage">{{errormessage}}</span>
</fieldset>
<div id="form_btn">
<input id="signin-btn" class="btn btn-blue" type="submit" value="Sign In" tabindex="3">
</div>
</form>
</div>
</div>
</div>
......@@ -15,13 +15,13 @@
"""Functions needed by the Zoe web interface."""
import base64
import logging
from zoe_lib.config import get_conf
from zoe_api.auth.base import BaseAuthenticator # pylint: disable=unused-import
from zoe_api.auth.ldap import LDAPAuthenticator
from zoe_api.auth.ldapsasl import LDAPSASLAuthenticator
from zoe_api.auth.file import PlainTextAuthenticator
import zoe_api.exceptions
from zoe_api.web.custom_request_handler import ZoeRequestHandler
......@@ -54,26 +54,18 @@ def catch_exceptions(func):
def missing_auth(handler: ZoeRequestHandler):
"""Sends a 401 response that enables basic auth"""
handler.set_status(401, 'Could not verify your access level for that URL. You have to login with proper credentials.')
handler.set_header('WWW-Authenticate', 'Basic realm="Login Required"')
handler.finish()
"""Redirect to login page."""
handler.redirect(handler.get_argument('next', u'/login'))
def get_auth(handler: ZoeRequestHandler):
"""Try to authenticate a request."""
auth_header = handler.request.headers.get('Authorization')
if auth_header is None or not auth_header.startswith('Basic '):
raise zoe_api.exceptions.ZoeAuthException
auth_decoded = base64.decodebytes(bytes(auth_header[6:], 'ascii')).decode('utf-8')
username, password = auth_decoded.split(':', 2)
def get_auth_login(username, password):
"""Authenticate username and password against the configured user store."""
if get_conf().auth_type == 'text':
authenticator = PlainTextAuthenticator() # type: BaseAuthenticator
elif get_conf().auth_type == 'ldap':
authenticator = LDAPAuthenticator()
authenticator = LDAPAuthenticator() # type: BaseAuthenticator
elif get_conf().auth_type == 'ldapsasl':
authenticator = LDAPSASLAuthenticator() # type: BaseAuthenticator
else:
raise zoe_api.exceptions.ZoeException('Configuration error, unknown authentication method: {}'.format(get_conf().auth_type))
uid, role = authenticator.auth(username, password)
......@@ -83,6 +75,18 @@ def get_auth(handler: ZoeRequestHandler):
return uid, role
def get_auth(handler: ZoeRequestHandler):
"""Try to authenticate a request."""
if handler.get_secure_cookie('zoe'):
cookie_val = str(handler.get_secure_cookie('zoe'))
uid, role = cookie_val[2:-1].split('.')
log.info('Authentication done using cookie')
return uid, role
else:
handler.redirect(handler.get_argument('next', u'/login'))
def error_page(handler: ZoeRequestHandler, error_message: str, status: int):
"""Generate an error page."""
handler.set_status(status)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment