Commit a1d0024a authored by Daniele Venzano's avatar Daniele Venzano

Implement OAuth2 authentication (for now works only with Eurecom's GitLab instance)

parent b8ec4a36
#!/bin/bash
if [ -z $2 -o -z $1 ]; then
echo "usage: $0 <username> <fs_uid>"
exit
fi
mkdir /mnt/nfs/zoe-workspaces/prod/$1
chown $2:zoe-users /mnt/nfs/zoe-workspaces/prod/$1
chmod 700 /mnt/nfs/zoe-workspaces/prod/$1
...@@ -20,6 +20,7 @@ from typing import Union ...@@ -20,6 +20,7 @@ from typing import Union
import pam import pam
from zoe_api.auth.requests_oauth2 import EurecomGitLabClient
from zoe_api.auth.file import PlainTextAuthenticator from zoe_api.auth.file import PlainTextAuthenticator
from zoe_api.auth.ldap import LDAPAuthenticator from zoe_api.auth.ldap import LDAPAuthenticator
from zoe_lib.state import SQLManager, User from zoe_lib.state import SQLManager, User
...@@ -50,6 +51,10 @@ class BaseAuthenticator: ...@@ -50,6 +51,10 @@ class BaseAuthenticator:
return user return user
elif user.auth_source == "pam" and pam_authenticate(username, password): elif user.auth_source == "pam" and pam_authenticate(username, password):
return user return user
elif user.auth_source == "oauth2":
egitlab = EurecomGitLabClient(client_id=get_conf().oauth_client_id, client_secret=get_conf().oauth_client_secret, redirect_uri=get_conf().oauth_redirect_uri)
auth_url = egitlab.authorize_url(scope=['openid', 'read_user'], response_type='code')
else: else:
return None return None
......
""" Taken from https://github.com/maraujop/requests-oauth2 """
from .oauth2 import OAuth2
from .errors import OAuth2Error, ConfigurationError
from .services import EurecomGitLabClient
""" Taken from https://github.com/maraujop/requests-oauth2 """
class OAuth2Error(Exception):
pass
class ConfigurationError(OAuth2Error):
pass
""" Taken from https://github.com/maraujop/requests-oauth2 """
from urllib.parse import parse_qs, quote, urlencode
import requests
from .errors import ConfigurationError
class OAuth2(object):
"""Main OAuth2 class."""
client_id = None
client_secret = None
site = None
redirect_uri = None
authorization_url = '/oauth/authorize'
token_url = '/oauth/token'
revoke_url = '/oauth2/revoke'
scope_sep = None
def __init__(self, client_id=None, client_secret=None, site=None,
redirect_uri=None, authorization_url=None,
token_url=None, revoke_url=None, scope_sep=None):
"""
Initializes the hook with OAuth2 parameters
"""
if client_id is not None:
self.client_id = client_id
if client_secret is not None:
self.client_secret = client_secret
if site is not None:
self.site = site
if redirect_uri is not None:
self.redirect_uri = redirect_uri
if authorization_url is not None:
self.authorization_url = authorization_url
if token_url is not None:
self.token_url = token_url
if revoke_url is not None:
self.revoke_url = revoke_url
if scope_sep is not None:
self.scope_sep = scope_sep
def _check_configuration(self, *attrs):
"""Check that each named attr has been configured
"""
for attr in attrs:
if getattr(self, attr, None) is None:
raise ConfigurationError("{} not configured".format(attr))
def _make_request(self, url, **kwargs):
"""
Make a request to an OAuth2 endpoint
"""
response = requests.post(url, **kwargs)
try:
return response.json()
except ValueError:
pass
return parse_qs(response.content)
def authorize_url(self, scope='', **kwargs):
"""
Returns the url to redirect the user to for user consent
"""
self._check_configuration("site", "authorization_url", "redirect_uri",
"client_id")
if isinstance(scope, (list, tuple, set, frozenset)):
self._check_configuration("scope_sep")
scope = self.scope_sep.join(scope)
oauth_params = {
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'scope': scope,
}
oauth_params.update(kwargs)
return "%s%s?%s" % (self.site, quote(self.authorization_url),
urlencode(oauth_params))
def get_token(self, code, headers=None, **kwargs):
"""
Requests an access token
"""
self._check_configuration("site", "token_url", "redirect_uri",
"client_id", "client_secret")
url = "%s%s" % (self.site, quote(self.token_url))
data = {
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
}
data.update(kwargs)
return self._make_request(url, data=data, headers=headers)
def refresh_token(self, headers=None, **kwargs):
"""
Request a refreshed token
"""
self._check_configuration("site", "token_url", "client_id",
"client_secret")
url = "%s%s" % (self.site, quote(self.token_url))
data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
}
data.update(kwargs)
return self._make_request(url, data=data, headers=headers)
def revoke_token(self, token, headers=None, **kwargs):
"""
Revoke an access token
"""
self._check_configuration("site", "revoke_uri")
url = "%s%s" % (self.site, quote(self.revoke_url))
data = {'token': token}
data.update(kwargs)
return self._make_request(url, data=data, headers=headers)
""" Taken from https://github.com/maraujop/requests-oauth2 """
from . import OAuth2
class GoogleClient(OAuth2):
"""Google oauth2"""
site = "https://accounts.google.com"
authorization_url = "/o/oauth2/auth"
token_url = "/o/oauth2/token"
scope_sep = " "
class FacebookClient(OAuth2):
"""Facebook oauth2"""
site = "https://www.facebook.com/"
authorization_url = "/dialog/oauth"
token_url = "/oauth/access_token"
scope_sep = " "
class InstagramClient(OAuth2):
"""Instagram oauth2"""
site = "https://api.instagram.com"
authorization_url = "/oauth/authorize"
token_url = "/oauth/access_token"
scope_sep = " "
class EurecomGitLabClient(OAuth2):
"""GitLab oauth2"""
site = "https://gitlab.eurecom.fr"
authorization_url = "/oauth/authorize"
token_url = "/oauth/token"
userinfo_url = site + '/oauth/userinfo'
scope_sep = " "
...@@ -21,7 +21,7 @@ import tornado.web ...@@ -21,7 +21,7 @@ import tornado.web
from zoe_api.rest_api.execution import ExecutionAPI, ExecutionCollectionAPI, ExecutionDeleteAPI, ExecutionEndpointsAPI from zoe_api.rest_api.execution import ExecutionAPI, ExecutionCollectionAPI, ExecutionDeleteAPI, ExecutionEndpointsAPI
from zoe_api.rest_api.info import InfoAPI from zoe_api.rest_api.info import InfoAPI
from zoe_api.rest_api.user import UserAPI, UserCollectionAPI from zoe_api.rest_api.user import UserAPI, UserCollectionAPI, UserOAuthCallbackAPI
from zoe_api.rest_api.role import RoleAPI, RoleCollectionAPI from zoe_api.rest_api.role import RoleAPI, RoleCollectionAPI
from zoe_api.rest_api.quota import QuotaAPI, QuotaCollectionAPI from zoe_api.rest_api.quota import QuotaAPI, QuotaCollectionAPI
from zoe_api.rest_api.service import ServiceAPI, ServiceLogsAPI from zoe_api.rest_api.service import ServiceAPI, ServiceLogsAPI
...@@ -46,6 +46,7 @@ def api_init(api_endpoint) -> List[tornado.web.URLSpec]: ...@@ -46,6 +46,7 @@ def api_init(api_endpoint) -> List[tornado.web.URLSpec]:
tornado.web.url(api_path + r'/login', LoginAPI, route_args), tornado.web.url(api_path + r'/login', LoginAPI, route_args),
tornado.web.url(api_path + r'/zapp_validate', ZAppValidateAPI, route_args), tornado.web.url(api_path + r'/zapp_validate', ZAppValidateAPI, route_args),
tornado.web.url(api_path + r'/user/oauth2', UserOAuthCallbackAPI, route_args),
tornado.web.url(api_path + r'/user/([0-9]+)', UserAPI, route_args), tornado.web.url(api_path + r'/user/([0-9]+)', UserAPI, route_args),
tornado.web.url(api_path + r'/user', UserCollectionAPI, route_args), tornado.web.url(api_path + r'/user', UserCollectionAPI, route_args),
......
...@@ -15,10 +15,19 @@ ...@@ -15,10 +15,19 @@
"""The User API endpoints.""" """The User API endpoints."""
import logging
import os
import requests
import tornado.escape import tornado.escape
from zoe_api.rest_api.request_handler import ZoeAPIRequestHandler from zoe_api.rest_api.request_handler import ZoeAPIRequestHandler
from zoe_api.exceptions import ZoeException from zoe_api.exceptions import ZoeException
from zoe_api.auth.requests_oauth2 import EurecomGitLabClient
import zoe_lib.config
log = logging.getLogger(__name__)
class UserAPI(ZoeAPIRequestHandler): class UserAPI(ZoeAPIRequestHandler):
...@@ -142,3 +151,39 @@ class UserCollectionAPI(ZoeAPIRequestHandler): ...@@ -142,3 +151,39 @@ class UserCollectionAPI(ZoeAPIRequestHandler):
self.set_status(201) self.set_status(201)
self.write({'user_id': new_id}) self.write({'user_id': new_id})
class UserOAuthCallbackAPI(ZoeAPIRequestHandler):
"""The User OAUTH callback endpoint."""
def get(self):
"""Callback."""
code = self.get_argument('code', None)
if code is None:
self.redirect(self.reverse_url("login"))
return
egitlab = EurecomGitLabClient(client_id=zoe_lib.config.get_conf().oauth_client_id, client_secret=zoe_lib.config.get_conf().oauth_client_secret, redirect_uri=zoe_lib.config.get_conf().oauth_redirect_uri)
token = egitlab.get_token(code=code, grant_type="authorization_code")
r = requests.get(egitlab.userinfo_url, headers={'Authorization': 'Bearer {}'.format(token['access_token'])})
if r.status_code != 200:
self.redirect(self.reverse_url("login"))
return
data = r.json()
email = data['email']
username = data['nickname']
user = self.api_endpoint.user_by_name(username)
if user is not None:
if user.email != email:
self.api_endpoint.user_update(user, user.id, {'email': email})
else:
log.info('Creating new user {} from OAuth login'.format(username))
admin = self.api_endpoint.user_by_name('admin')
role = self.api_endpoint.role_by_name(zoe_lib.config.get_conf().oauth_role)
quota = self.api_endpoint.quota_by_name(zoe_lib.config.get_conf().oauth_quota)
self.api_endpoint.user_new(admin, username, email, role.id, quota.id, 'gitlab-eurecom', -1)
user = self.api_endpoint.user_by_name(username)
os.system('sudo {} {} {}'.format(zoe_lib.config.get_conf().oauth_create_workspace_script, user.username, user.fs_uid))
if not self.get_secure_cookie('zoe'):
cookie_val = user.username
self.set_secure_cookie('zoe', cookie_val)
self.redirect(self.get_argument("next", self.reverse_url("home_user")))
...@@ -27,6 +27,7 @@ from jinja2 import Environment, FileSystemLoader, Markup, TemplateSyntaxError ...@@ -27,6 +27,7 @@ from jinja2 import Environment, FileSystemLoader, Markup, TemplateSyntaxError
from tornado.escape import squeeze, linkify, url_escape, xhtml_escape from tornado.escape import squeeze, linkify, url_escape, xhtml_escape
import zoe_lib.config
import zoe_lib.version import zoe_lib.version
from zoe_api.custom_request_handler import ZoeRequestHandler from zoe_api.custom_request_handler import ZoeRequestHandler
from zoe_api.exceptions import ZoeAuthException from zoe_api.exceptions import ZoeAuthException
...@@ -145,7 +146,8 @@ class ZoeWebRequestHandler(ZoeRequestHandler): ...@@ -145,7 +146,8 @@ class ZoeWebRequestHandler(ZoeRequestHandler):
try: try:
user = super().get_current_user() user = super().get_current_user()
except ZoeAuthException as e: except ZoeAuthException as e:
self.render('login.jinja2', error=e.message) with_gitlab_oauth = zoe_lib.config.get_conf().oauth_client_id != ''
self.render('login.jinja2', error=e.message, with_gitlab_oauth=with_gitlab_oauth)
return None return None
return user return user
......
...@@ -20,6 +20,7 @@ import subprocess ...@@ -20,6 +20,7 @@ import subprocess
from zoe_api.web.request_handler import ZoeWebRequestHandler from zoe_api.web.request_handler import ZoeWebRequestHandler
from zoe_api.auth.base import BaseAuthenticator from zoe_api.auth.base import BaseAuthenticator
from zoe_api.auth.requests_oauth2 import EurecomGitLabClient
import zoe_lib.config import zoe_lib.config
...@@ -29,7 +30,7 @@ class RootWeb(ZoeWebRequestHandler): ...@@ -29,7 +30,7 @@ class RootWeb(ZoeWebRequestHandler):
def get(self): def get(self):
"""Home page without authentication.""" """Home page without authentication."""
self.redirect(self.reverse_url("home_user")) self.redirect(self.reverse_url("login"))
class LoginWeb(ZoeWebRequestHandler): class LoginWeb(ZoeWebRequestHandler):
...@@ -37,21 +38,31 @@ class LoginWeb(ZoeWebRequestHandler): ...@@ -37,21 +38,31 @@ class LoginWeb(ZoeWebRequestHandler):
def get(self): def get(self):
"""Login page.""" """Login page."""
self.render('login.jinja2') template_vars = {}
if zoe_lib.config.get_conf().oauth_client_id != '':
template_vars['with_gitlab_oauth'] = True
self.render('login.jinja2', **template_vars)
def post(self): def post(self):
"""Try to authenticate.""" """Try to authenticate."""
username = self.get_argument("username", "") login_type = self.get_argument('login', 'userpass')
password = self.get_argument("password", "") if login_type == 'OAUTH':
user = BaseAuthenticator().full_auth(username, password) egitlab = EurecomGitLabClient(client_id=zoe_lib.config.get_conf().oauth_client_id, client_secret=zoe_lib.config.get_conf().oauth_client_secret, redirect_uri=zoe_lib.config.get_conf().oauth_redirect_uri)
if user is None: auth_url = egitlab.authorize_url(scope=['openid', 'read_user'], response_type='code')
self.redirect(self.get_argument("next", self.reverse_url("login"))) self.redirect(auth_url)
return return
else:
username = self.get_argument("username", "")
password = self.get_argument("password", "")
user = BaseAuthenticator().full_auth(username, password)
if user is None:
self.redirect(self.reverse_url("login"))
return
if not self.get_secure_cookie('zoe'): if not self.get_secure_cookie('zoe'):
cookie_val = user.username cookie_val = user.username
self.set_secure_cookie('zoe', cookie_val) self.set_secure_cookie('zoe', cookie_val)
self.redirect(self.get_argument("next", self.reverse_url("home_user"))) self.redirect(self.get_argument("next", self.reverse_url("home_user")))
class LogoutWeb(ZoeWebRequestHandler): class LogoutWeb(ZoeWebRequestHandler):
...@@ -60,7 +71,7 @@ class LogoutWeb(ZoeWebRequestHandler): ...@@ -60,7 +71,7 @@ class LogoutWeb(ZoeWebRequestHandler):
def get(self): def get(self):
"""Login page.""" """Login page."""
self.clear_cookie('zoe') self.clear_cookie('zoe')
self.redirect(self.get_argument("next", self.reverse_url("login"))) self.redirect(self.reverse_url("login"))
class HomeWeb(ZoeWebRequestHandler): class HomeWeb(ZoeWebRequestHandler):
......
...@@ -9,6 +9,10 @@ ...@@ -9,6 +9,10 @@
<div id="loginbox"> <div id="loginbox">
<img alt="ZOE logo" src="{{ static_url("logo.png") }}"> <img alt="ZOE logo" src="{{ static_url("logo.png") }}">
{% if error %}
<p style="color: red;">{{ error }}</p>
{% endif %}
<div id="login-form"> <div id="login-form">
<form action="{{ reverse_url("login") }}" method="post" id="login_form"> <form action="{{ reverse_url("login") }}" method="post" id="login_form">
<label for="username">Username:</label> <label for="username">Username:</label>
...@@ -18,8 +22,14 @@ ...@@ -18,8 +22,14 @@
<input class="text-input" id="password" name="password" tabindex="2" type="password" value=""> <input class="text-input" id="password" name="password" tabindex="2" type="password" value="">
<div id="form_btn"> <div id="form_btn">
<input id="signin-btn" class="btn btn-blue" type="submit" value="Login" tabindex="3"> <button id="signin-btn" class="btn btn-blue" type="submit" name="login" value="userpass" tabindex="3">Login</button>
</div> </div>
{% if with_gitlab_oauth %}
Or login with:
<div class="form_btn">
<button id="signin-btn-oauth" class="btn btn-blue" type="submit" name="login" value="OAUTH" tabindex="3">Eurecom GitLab</button>
</div>
{% endif %}
</form> </form>
</div> </div>
</div> </div>
......
...@@ -37,11 +37,11 @@ ...@@ -37,11 +37,11 @@
{% endfor %} {% endfor %}
</ul> </ul>
<h4>Start-up parameters:</h4>
<form action="{{ reverse_url("zappshop_start", "") }}{{ zapp.id }}-{{ zapp.manifest_index }}" method="post" id="zapp_start_form">
<input type="hidden" name="zapp-id" value="{{ zapp.id }}-{{ zapp.manifest_index }}">
<label>Execution name:&nbsp;<input type="text" name="exec_name" value="{{ zapp.zoe_description.name }}" maxlength="16" size="18" required/></label><br/>
{% if resources_are_customizable %} {% if resources_are_customizable %}
<h4>Start-up parameters:</h4>
<form action="{{ reverse_url("zappshop_start", "") }}{{ zapp.id }}-{{ zapp.manifest_index }}" method="post" id="zapp_start_form">
<input type="hidden" name="zapp-id" value="{{ zapp.id }}-{{ zapp.manifest_index }}">
<label>Execution name:&nbsp;<input type="text" name="exec_name" value="{{ zapp.zoe_description.name }}" maxlength="16" size="18" required/></label><br/>
{% for param in zapp.parameters %} {% for param in zapp.parameters %}
<label>{{ param.readable_name }} <label>{{ param.readable_name }}
{% if param.type == "number" and "memory" in param.kind %} {% if param.type == "number" and "memory" in param.kind %}
......
...@@ -86,6 +86,13 @@ def load_configuration(test_conf=None): ...@@ -86,6 +86,13 @@ def load_configuration(test_conf=None):
argparser.add_argument('--ldap-bind-password', help='Password for the bind user', default='mysecretpassword') argparser.add_argument('--ldap-bind-password', help='Password for the bind user', default='mysecretpassword')
argparser.add_argument('--ldap-base-dn', help='LDAP base DN for users', default='ou=something,dc=any,dc=local') argparser.add_argument('--ldap-base-dn', help='LDAP base DN for users', default='ou=something,dc=any,dc=local')
argparser.add_argument('--oauth-client-id', help='OAuth2 client ID as generated by your identity provider')
argparser.add_argument('--oauth-client-secret', help='OAuth2 client secret as generated by your identity provider')
argparser.add_argument('--oauth-redirect-uri', help='Full URL of the Zoe API OAuth callback', default='https://my.zoe.com/api/v7/user/oauth')
argparser.add_argument('--oauth-role', help='Role to assign to new users authenticated via OAuth2', default='user')
argparser.add_argument('--oauth-quota', help='Quota to assign to new users authenticated via OAuth2', default='default')
argparser.add_argument('--oauth-create-workspace-script', help='Full path to a script that creates user workspace, Zoe will call using sudo and pass username and fs_id as arguments', default='/usr/local/bin/zoe_create_workspace.sh')
argparser.add_argument('--fs-group-id', type=int, help='Group ID to use for all Zoe users in workspace files', default='5001') argparser.add_argument('--fs-group-id', type=int, help='Group ID to use for all Zoe users in workspace files', default='5001')
# Proxy options # Proxy options
......
...@@ -64,7 +64,12 @@ class SQLManager: ...@@ -64,7 +64,12 @@ class SQLManager:
except psycopg2.InterfaceError: except psycopg2.InterfaceError:
self._connect() self._connect()
cur = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) cur = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute('SET search_path TO {},public'.format(self.schema)) try:
cur.execute('SET search_path TO {},public'.format(self.schema))
except psycopg2.InternalError:
self._connect()
cur = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute('SET search_path TO {},public'.format(self.schema))
return cur return cur
def commit(self): def commit(self):
......
...@@ -164,7 +164,10 @@ class UserTable(BaseTable): ...@@ -164,7 +164,10 @@ class UserTable(BaseTable):
def insert(self, username: str, email: str, auth_source: str, role_id: int, quota_id: int, fs_uid: int): def insert(self, username: str, email: str, auth_source: str, role_id: int, quota_id: int, fs_uid: int):
"""Adds a new user to the state.""" """Adds a new user to the state."""
query = self.cursor.mogrify('INSERT INTO "user" (id, username, fs_uid, email, priority, enabled, auth_source, role_id, quota_id) VALUES (DEFAULT, %s, %s, %s, DEFAULT, TRUE, %s, %s, %s) RETURNING id', (username, fs_uid, email, auth_source, role_id, quota_id)) if fs_uid == -1:
query = self.cursor.mogrify('INSERT INTO "user" (id, username, fs_uid, email, priority, enabled, auth_source, role_id, quota_id) VALUES (DEFAULT, %s, (SELECT MAX("user".fs_uid)+1 FROM "user"), %s, DEFAULT, TRUE, %s, %s, %s) RETURNING id', (username, email, auth_source, role_id, quota_id))
else:
query = self.cursor.mogrify('INSERT INTO "user" (id, username, fs_uid, email, priority, enabled, auth_source, role_id, quota_id) VALUES (DEFAULT, %s, %s, %s, DEFAULT, TRUE, %s, %s, %s) RETURNING id', (username, fs_uid, email, auth_source, role_id, quota_id))
self.cursor.execute(query) self.cursor.execute(query)
self.sql_manager.commit() self.sql_manager.commit()
return self.cursor.fetchone()[0] return self.cursor.fetchone()[0]
......
...@@ -51,11 +51,7 @@ class ZoeFSWorkspace(zoe_master.workspace.base.ZoeWorkspaceBase): ...@@ -51,11 +51,7 @@ class ZoeFSWorkspace(zoe_master.workspace.base.ZoeWorkspaceBase):
def get(self, user: User): def get(self, user: User):
"""Return a VolumeDescription for the user workspace.""" """Return a VolumeDescription for the user workspace."""
if not self.exists(user.username): if not self.exists(user.username):
try: log.warning("Workspace for user {} does not exist".format(user.username))
os.makedirs(self.get_path(user.username), 0x700)
os.chown(self.get_path(user.username), user.fs_uid, zoe_lib.config.get_conf().fs_group_id)
except OSError as e:
log.warning("Cannot create user workspace automatically: {}".format(str(e)))
else: else:
if os.stat(self.get_path(user.username)).st_uid != user.fs_uid: if os.stat(self.get_path(user.username)).st_uid != user.fs_uid:
log.warning('The user fs_uid in the database does not match the workspace owner for user {}'.format(user.username)) log.warning('The user fs_uid in the database does not match the workspace owner for user {}'.format(user.username))
......
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