From eb9a1620516934d2a17963d9018d82aa407c7e31 Mon Sep 17 00:00:00 2001 From: leticia Date: Mon, 1 Jul 2019 14:12:14 +0200 Subject: [PATCH] api: add JWT required endpoints Closes #77 Signed-off-by: leticia --- docs/openapi.json | 68 +++++++++++++++++ reana_server/config.py | 10 +++ reana_server/factory.py | 4 +- reana_server/rest/users.py | 92 ++++++++++++++++++++++- reana_server/rest/workflows.py | 132 ++++++++++++++++++++++++--------- setup.py | 3 +- tests/conftest.py | 5 +- 7 files changed, 276 insertions(+), 38 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 3a708640..59fbfdc1 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -7,6 +7,74 @@ }, "parameters": {}, "paths": { + "/api/auth": { + "post": { + "description": "This resource looks for an user with the provided information (email, id) and, if there is such user, creates a JWT cookie on the user browser.", + "operationId": "auth_user", + "parameters": [ + { + "description": "Required. The email of the user.", + "in": "query", + "name": "email", + "required": true, + "type": "string" + }, + { + "description": "Required. API key of the admin.", + "in": "query", + "name": "password", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "User authenticate successfully. Returns the JWT set cookie headers and a successful login boolean.", + "examples": { + "application/json": { + "login": true + } + }, + "schema": { + "properties": { + "login": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "401": { + "description": "Request failed. The data provided could authenticate the user.", + "examples": { + "application/json": { + "message": "Couldn't authenticate." + } + }, + "schema": { + "properties": { + "login": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "500": { + "description": "Request failed. Internal server error.", + "examples": { + "application/json": { + "message": "Internal server error." + } + } + } + }, + "summary": "Authenticates the user with the provided information." + } + }, "/api/ping": { "get": { "description": "Ping the server.", diff --git a/reana_server/config.py b/reana_server/config.py index 849eed54..c946d67f 100644 --- a/reana_server/config.py +++ b/reana_server/config.py @@ -20,3 +20,13 @@ ADMIN_USER_ID = "00000000-0000-0000-0000-000000000000" SHARED_VOLUME_PATH = os.getenv('SHARED_VOLUME_PATH', '/var/reana') + +JWT_SECRET_KEY = 'hyper secret key' + +JWT_TOKEN_LOCATION = ['cookies'] + +JWT_COOKIE_SECURE = False + +JWT_ACCESS_COOKIE_PATH = '/' + +JWT_COOKIE_CSRF_PROTECT = True diff --git a/reana_server/factory.py b/reana_server/factory.py index f93cdec0..2943e4f9 100644 --- a/reana_server/factory.py +++ b/reana_server/factory.py @@ -12,6 +12,7 @@ from flask import Flask from flask_cors import CORS +from flask_jwt_extended import JWTManager from reana_commons.config import REANA_LOG_FORMAT, REANA_LOG_LEVEL from reana_db.database import Session @@ -34,5 +35,6 @@ def create_app(): app.register_blueprint(secrets.blueprint, url_prefix='/api') app.session = Session - CORS(app) + jwt = JWTManager(app) + CORS(app, supports_credentials=True) return app diff --git a/reana_server/rest/users.py b/reana_server/rest/users.py index 1a2c85b1..455d535b 100644 --- a/reana_server/rest/users.py +++ b/reana_server/rest/users.py @@ -9,10 +9,14 @@ """Reana-Server User Endpoints.""" import logging +import json import traceback from flask import Blueprint, jsonify, request - +from flask_jwt_extended import ( + create_access_token, create_refresh_token, + set_access_cookies, set_refresh_cookies +) from reana_server.utils import _create_user, _get_users blueprint = Blueprint('users', __name__) @@ -200,3 +204,89 @@ def create_user(): # noqa except Exception as e: logging.error(traceback.format_exc()) return jsonify({"message": str(e)}), 500 + + +@blueprint.route('/auth', methods=['POST']) +def auth_user(): + r"""Endpoint to authenticate users. + + --- + post: + summary: Authenticates the user with the provided information. + description: >- + This resource looks for an user with the provided + information (email, id) and, if there is such user, creates + a JWT cookie on the user browser. + operationId: auth_user + produces: + - application/json + parameters: + - name: email + in: query + description: Required. The email of the user. + required: true + type: string + - name: password + in: query + description: Required. API key of the admin. + required: true + type: string + responses: + 200: + description: >- + User authenticate successfully. Returns the JWT set cookie + headers and a successful login boolean. + schema: + type: object + properties: + login: + type: boolean + examples: + application/json: + { + "login": true + } + 401: + description: >- + Request failed. The data provided could authenticate + the user. + schema: + type: object + properties: + login: + type: boolean + examples: + application/json: + { + "message": "Couldn't authenticate." + } + 500: + description: >- + Request failed. Internal server error. + examples: + application/json: + { + "message": "Internal server error." + } + + """ + try: + data = json.loads(request.data.decode('utf-8')) + user_email = data['username'] + reana_access_token = data['password'] + users = _get_users(None, user_email, None, reana_access_token) + if users: + user = users[0] + access_token = create_access_token(identity=user.id_) + refresh_token = create_refresh_token(identity=user.id_) + + response = jsonify({'login': True}) + set_access_cookies(response, access_token) + set_refresh_cookies(response, refresh_token) + return response, 200 + else: + response = jsonify({'message': "Could not authenticate user."}) + return response, 401 + except Exception as e: + logging.error(traceback.format_exc()) + return jsonify({"message": str(e)}), 500 diff --git a/reana_server/rest/workflows.py b/reana_server/rest/workflows.py index f382b421..2a4384ad 100644 --- a/reana_server/rest/workflows.py +++ b/reana_server/rest/workflows.py @@ -17,6 +17,7 @@ from flask import Blueprint from flask import current_app as app from flask import jsonify, request, send_file +from flask_jwt_extended import jwt_optional, get_jwt_identity from reana_commons.config import INTERACTIVE_SESSION_TYPES from reana_commons.utils import get_workspace_disk_usage from reana_db.database import Session @@ -32,6 +33,7 @@ @blueprint.route('/workflows', methods=['GET']) +@jwt_optional def get_workflows(): # noqa r"""Get all current workflows in REANA. @@ -148,15 +150,17 @@ def get_workflows(): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ type = request.args.get('type', 'batch') verbose = request.args.get('verbose', False) response, http_response = current_rwc_api_client.api.\ get_workflows( - user=str(user.id_), + user=str(user), type=type, verbose=bool(verbose)).result() - return jsonify(response), http_response.status_code except HTTPError as e: logging.error(traceback.format_exc()) @@ -170,6 +174,7 @@ def get_workflows(): # noqa @blueprint.route('/workflows', methods=['POST']) +@jwt_optional def create_workflow(): # noqa r"""Create a workflow. @@ -260,7 +265,10 @@ def create_workflow(): # noqa Request failed. Not implemented. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if request.json: # validate against schema reana_spec_file = request.json @@ -287,7 +295,7 @@ def create_workflow(): # noqa response, http_response = current_rwc_api_client.api.\ create_workflow( workflow=workflow_dict, - user=str(user.id_)).result() + user=str(user)).result() return jsonify(response), http_response.status_code except HTTPError as e: @@ -305,6 +313,7 @@ def create_workflow(): # noqa @blueprint.route('/workflows//logs', methods=['GET']) +@jwt_optional def get_workflow_logs(workflow_id_or_name): # noqa r"""Get workflow logs. @@ -384,14 +393,17 @@ def get_workflow_logs(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ get_workflow_logs( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name).result() return jsonify(response), http_response.status_code @@ -407,6 +419,7 @@ def get_workflow_logs(workflow_id_or_name): # noqa @blueprint.route('/workflows//status', methods=['GET']) +@jwt_optional def get_workflow_status(workflow_id_or_name): # noqa r"""Get workflow status. @@ -503,14 +516,17 @@ def get_workflow_status(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ get_workflow_status( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name).result() return jsonify(response), http_response.status_code @@ -526,6 +542,7 @@ def get_workflow_status(workflow_id_or_name): # noqa @blueprint.route('/workflows//start', methods=['POST']) +@jwt_optional def start_workflow(workflow_id_or_name): # noqa r"""Start workflow. --- @@ -635,13 +652,16 @@ def start_workflow(workflow_id_or_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") parameters = request.json workflow = _get_workflow_with_uuid_or_name( - workflow_id_or_name, str(user.id_)) + workflow_id_or_name, str(user)) if workflow.status != WorkflowStatus.created: raise ValueError("Workflow cannot be started again.") Workflow.update_workflow_status(Session, workflow.id_, @@ -655,7 +675,7 @@ def start_workflow(workflow_id_or_name): # noqa 'workflow_id': workflow_id_or_name, 'workflow_name': workflow_id_or_name, 'status': WorkflowStatus.queued.name, - 'user': str(user.id_)} + 'user': str(user)} return jsonify(response), 200 except HTTPError as e: logging.error(traceback.format_exc()) @@ -669,6 +689,7 @@ def start_workflow(workflow_id_or_name): # noqa @blueprint.route('/workflows//status', methods=['PUT']) +@jwt_optional def set_workflow_status(workflow_id_or_name): # noqa r"""Set workflow status. --- @@ -783,7 +804,10 @@ def set_workflow_status(workflow_id_or_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") @@ -791,7 +815,7 @@ def set_workflow_status(workflow_id_or_name): # noqa parameters = request.json response, http_response = current_rwc_api_client.api.\ set_workflow_status( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, status=status, parameters=parameters).result() @@ -810,6 +834,7 @@ def set_workflow_status(workflow_id_or_name): # noqa @blueprint.route('/workflows//workspace', methods=['POST']) +@jwt_optional def upload_file(workflow_id_or_name): # noqa r"""Upload file to workspace. @@ -886,7 +911,10 @@ def upload_file(workflow_id_or_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") @@ -894,7 +922,7 @@ def upload_file(workflow_id_or_name): # noqa file_ = request.files['file_content'].stream.read() response, http_response = current_rwc_api_client.api.\ upload_file( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, file_content=file_, file_name=request.args['file_name']).result() @@ -917,6 +945,7 @@ def upload_file(workflow_id_or_name): # noqa @blueprint.route( '/workflows//workspace/', methods=['GET']) +@jwt_optional def download_file(workflow_id_or_name, file_name): # noqa r"""Download a file from the workspace. @@ -982,14 +1011,17 @@ def download_file(workflow_id_or_name, file_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ download_file( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, file_name=file_name).result() @@ -1011,6 +1043,7 @@ def download_file(workflow_id_or_name, file_name): # noqa @blueprint.route( '/workflows//workspace/', methods=['DELETE']) +@jwt_optional def delete_file(workflow_id_or_name, file_name): # noqa r"""Delete a file from the workspace. @@ -1073,14 +1106,17 @@ def delete_file(workflow_id_or_name, file_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ delete_file( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, file_name=file_name).result() @@ -1098,6 +1134,7 @@ def delete_file(workflow_id_or_name, file_name): # noqa @blueprint.route('/workflows//workspace', methods=['GET']) +@jwt_optional def get_files(workflow_id_or_name): # noqa r"""List all files contained in a workspace. @@ -1168,14 +1205,17 @@ def get_files(workflow_id_or_name): # noqa } """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ get_files( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name).result() return jsonify(http_response.json()), http_response.status_code @@ -1192,6 +1232,7 @@ def get_files(workflow_id_or_name): # noqa @blueprint.route('/workflows//parameters', methods=['GET']) +@jwt_optional def get_workflow_parameters(workflow_id_or_name): # noqa r"""Get workflow input parameters. @@ -1274,14 +1315,17 @@ def get_workflow_parameters(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ get_workflow_parameters( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name).result() return jsonify(response), http_response.status_code @@ -1298,6 +1342,7 @@ def get_workflow_parameters(workflow_id_or_name): # noqa @blueprint.route('/workflows//diff/' '', methods=['GET']) +@jwt_optional def get_workflow_diff(workflow_id_or_name_a, workflow_id_or_name_b): # noqa r"""Get differences between two workflows. @@ -1391,7 +1436,10 @@ def get_workflow_diff(workflow_id_or_name_a, workflow_id_or_name_b): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ brief = request.args.get('brief', False) brief = True if brief == 'true' else False context_lines = request.args.get('context_lines', 5) @@ -1400,7 +1448,7 @@ def get_workflow_diff(workflow_id_or_name_a, workflow_id_or_name_b): # noqa response, http_response = current_rwc_api_client.api. \ get_workflow_diff( - user=str(user.id_), + user=str(user), brief=brief, context_lines=context_lines, workflow_id_or_name_a=workflow_id_or_name_a, @@ -1421,6 +1469,7 @@ def get_workflow_diff(workflow_id_or_name_a, workflow_id_or_name_b): # noqa @blueprint.route('/workflows//open/' '', methods=['POST']) +@jwt_optional def open_interactive_session(workflow_id_or_name, interactive_session_type): # noqa r"""Start an interactive session inside the workflow workspace. @@ -1510,7 +1559,10 @@ def open_interactive_session(workflow_id_or_name, Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if interactive_session_type not in INTERACTIVE_SESSION_TYPES: return jsonify({ "message": "Interactive session type {0} not found, try " @@ -1522,7 +1574,7 @@ def open_interactive_session(workflow_id_or_name, response, http_response = current_rwc_api_client.api.\ open_interactive_session( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, interactive_session_type=interactive_session_type, interactive_session_configuration=request.json or {}).result() @@ -1544,6 +1596,7 @@ def open_interactive_session(workflow_id_or_name, @blueprint.route('/workflows//close/', methods=['POST']) +@jwt_optional def close_interactive_session(workflow_id_or_name): # noqa r"""Close an interactive workflow session. @@ -1614,12 +1667,15 @@ def close_interactive_session(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise KeyError("workflow_id_or_name is not supplied") response, http_response = current_rwc_api_client.api.\ close_interactive_session( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name).result() return jsonify(response), http_response.status_code @@ -1639,6 +1695,7 @@ def close_interactive_session(workflow_id_or_name): # noqa @blueprint.route('/workflows/move_files/', methods=['PUT']) +@jwt_optional def move_files(workflow_id_or_name): # noqa r"""Move files within workspace. --- @@ -1726,7 +1783,10 @@ def move_files(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ if not workflow_id_or_name: raise ValueError("workflow_id_or_name is not supplied") @@ -1734,7 +1794,7 @@ def move_files(workflow_id_or_name): # noqa target = request.args.get('target') response, http_response = current_rwc_api_client.api.\ move_files( - user=str(user.id_), + user=str(user), workflow_id_or_name=workflow_id_or_name, source=source, target=target).result() @@ -1753,6 +1813,7 @@ def move_files(workflow_id_or_name): # noqa @blueprint.route('/workflows//disk_usage', methods=['GET']) +@jwt_optional def get_workflow_disk_usage(workflow_id_or_name): # noqa r"""Get workflow disk usage. @@ -1848,7 +1909,10 @@ def get_workflow_disk_usage(workflow_id_or_name): # noqa Request failed. Internal controller error. """ try: - user = get_user_from_token(request.args.get('access_token')) + user = get_jwt_identity() + if not user: + user = get_user_from_token( + request.args.get('access_token')).id_ parameters = request.json or {} if not workflow_id_or_name: @@ -1867,7 +1931,7 @@ def get_workflow_disk_usage(workflow_id_or_name): # noqa response = {'workflow_id': workflow.id_, 'workflow_name': workflow.name, - 'user': str(user.id_), + 'user': str(user), 'disk_usage_info': disk_usage_info} return jsonify(response), 200 diff --git a/setup.py b/setup.py index 07951fe0..ee927889 100644 --- a/setup.py +++ b/setup.py @@ -48,9 +48,10 @@ 'Flask>=0.11', 'fs>=2.0', 'flask-cors>=3.0.6', + 'flask-jwt-extended>=3.19.0', 'marshmallow>=2.13', 'pyOpenSSL==17.5.0', - 'reana-commons[kubernetes]>=0.6.0.dev20190703,<0.7.0', + 'reana-commons[kubernetes]>=0.6.0.dev20190704,<0.7.0', 'reana-db>=0.5.0,<0.6.0', 'requests==2.20.0', 'rfc3987==1.3.7', diff --git a/tests/conftest.py b/tests/conftest.py index 96ca0adb..49bc9670 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ import pytest from flask import Flask +from flask_jwt_extended import JWTManager from mock import Mock from reana_commons.api_client import BaseAPIClient from reana_db.database import Session @@ -31,7 +32,7 @@ def base_app(): """Flask application fixture.""" config_mapping = { 'AVAILABLE_WORKFLOW_ENGINES': 'serial', - 'SERVER_NAME': 'localhost:5000', + 'SERVER_NAME': 'api.localhost:5000', 'SECRET_KEY': 'SECRET_KEY', 'TESTING': True, 'SHARED_VOLUME_PATH': '/tmp/test', @@ -41,7 +42,9 @@ def base_app(): } app = Flask(__name__) app.config.from_mapping(config_mapping) + app.config['JWT_SECRET_KEY'] = "hyper secret key" app.secret_key = "hyper secret key" + JWTManager(app) # Register API routes from reana_server.rest import ping, workflows, users # noqa