diff --git a/cornflow-server/Dockerfile b/cornflow-server/Dockerfile index 79e31140..567b8245 100644 --- a/cornflow-server/Dockerfile +++ b/cornflow-server/Dockerfile @@ -9,7 +9,7 @@ ENV DEBIAN_FRONTEND noninteractive ENV TERM linux # CORNFLOW vars -ARG CORNFLOW_VERSION=1.0.8 +ARG CORNFLOW_VERSION=1.0.9 # install linux pkg RUN apt update -y && apt-get install -y --no-install-recommends \ diff --git a/cornflow-server/changelog.rst b/cornflow-server/changelog.rst index c6fc41fe..e82f112c 100644 --- a/cornflow-server/changelog.rst +++ b/cornflow-server/changelog.rst @@ -1,3 +1,13 @@ +version 1.0.9 +-------------- + +- released: 2023-12-27 +- description: added new authentication for BI endpoints where the token does not expire +- changelog: + - Added new auth method. + - Added new token generation that can be used only through the cli. + - Added new token decodification that doe snot check for expiry date on token. + version 1.0.8 -------------- diff --git a/cornflow-server/cornflow/cli/__init__.py b/cornflow-server/cornflow/cli/__init__.py index 75046784..6cfb47ad 100644 --- a/cornflow-server/cornflow/cli/__init__.py +++ b/cornflow-server/cornflow/cli/__init__.py @@ -3,6 +3,7 @@ """ import click + from cornflow.cli.actions import actions from cornflow.cli.config import config from cornflow.cli.migrations import migrations diff --git a/cornflow-server/cornflow/cli/actions.py b/cornflow-server/cornflow/cli/actions.py index c30315a0..5bbb6e34 100644 --- a/cornflow-server/cornflow/cli/actions.py +++ b/cornflow-server/cornflow/cli/actions.py @@ -1,6 +1,5 @@ -import os - import click + from cornflow.cli.utils import get_app from cornflow.commands import register_actions_command from .arguments import verbose diff --git a/cornflow-server/cornflow/cli/users.py b/cornflow-server/cornflow/cli/users.py index 68dc5ef0..38e5eb12 100644 --- a/cornflow-server/cornflow/cli/users.py +++ b/cornflow-server/cornflow/cli/users.py @@ -1,8 +1,15 @@ import click + from cornflow.cli.arguments import username, password, email, verbose from cornflow.cli.utils import get_app from cornflow.commands import create_user_with_role -from cornflow.shared.const import SERVICE_ROLE +from cornflow.models import UserModel +from cornflow.shared.authentication.auth import BIAuth +from cornflow.shared.const import SERVICE_ROLE, VIEWER_ROLE +from cornflow.shared.exceptions import ( + ObjectDoesNotExist, + NoPermission, +) @click.group(name="users", help="Commands to manage the users") @@ -26,7 +33,45 @@ def create(): def create_service_user(username, password, email, verbose): app = get_app() with app.app_context(): - create_user_with_role( username, email, password, "service user", SERVICE_ROLE, verbose=verbose ) + + +@create.command(name="viewer", help="Create a viewer user") +@username +@password +@email +@verbose +def create_viewer_user(username, password, email, verbose): + app = get_app() + with app.app_context(): + create_user_with_role( + username, email, password, "viewer user", VIEWER_ROLE, verbose=verbose + ) + + +@create.command( + name="token", + help="Creates a token for a user that is never going to expire. This token can only be used on BI endpoints", +) +@click.option( + "--idx", "-i", type=int, help="The id of the user to generate the token for" +) +@username +@password +def create_unexpiring_token(idx, username, password): + app = get_app() + with app.app_context(): + user = UserModel.get_one_object(id=idx) + asking_user = UserModel.get_one_user_by_username(username) + + if not asking_user.check_hash(password) or not asking_user.is_service_user(): + raise NoPermission("The asking user has no permissions to generate tokens") + + if not user: + raise ObjectDoesNotExist("User does not exist") + + token = BIAuth.generate_token(idx) + click.echo(token) + return True diff --git a/cornflow-server/cornflow/config.py b/cornflow-server/cornflow/config.py index 81efbd5b..ad3c0c89 100644 --- a/cornflow-server/cornflow/config.py +++ b/cornflow-server/cornflow/config.py @@ -6,7 +6,8 @@ class DefaultConfig(object): SERVICE_NAME = os.getenv("SERVICE_NAME", "Cornflow") - SECRET_KEY = os.getenv("SECRET_KEY") + SECRET_TOKEN_KEY = os.getenv("SECRET_KEY") + SECRET_BI_KEY = os.getenv("SECRET_BI_KEY") SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///cornflow.db") AIRFLOW_URL = os.getenv("AIRFLOW_URL") AIRFLOW_USER = os.getenv("AIRFLOW_USER") @@ -91,7 +92,8 @@ class Testing(DefaultConfig): DEBUG = False TESTING = True PROPAGATE_EXCEPTIONS = True - SECRET_KEY = "TESTINGSECRETKEY" + SECRET_TOKEN_KEY = "TESTINGSECRETKEY" + SECRET_BI_KEY = "THISISANOTHERKEY" SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///cornflow_test.db") AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://localhost:8080") PRESERVE_CONTEXT_ON_EXCEPTION = False diff --git a/cornflow-server/cornflow/shared/authentication/auth.py b/cornflow-server/cornflow/shared/authentication/auth.py index 584041f0..1fac35f3 100644 --- a/cornflow-server/cornflow/shared/authentication/auth.py +++ b/cornflow-server/cornflow/shared/authentication/auth.py @@ -99,8 +99,7 @@ def generate_token(user_id: int = None) -> str: if user_id is None: err = "The user id passed to generate the token is not valid." raise InvalidUsage( - err, - log_txt="Error while trying to generate token. " + err + err, log_txt="Error while trying to generate token. " + err ) payload = { @@ -109,7 +108,9 @@ def generate_token(user_id: int = None) -> str: "sub": user_id, } - return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm="HS256") + return jwt.encode( + payload, current_app.config["SECRET_TOKEN_KEY"], algorithm="HS256" + ) @staticmethod def decode_token(token: str = None) -> dict: @@ -123,27 +124,26 @@ def decode_token(token: str = None) -> dict: if token is None: err = "The provided token is not valid." raise InvalidUsage( - err, - log_txt="Error while trying to decode token. " + err + err, log_txt="Error while trying to decode token. " + err ) try: payload = jwt.decode( - token, current_app.config["SECRET_KEY"], algorithms="HS256" + token, current_app.config["SECRET_TOKEN_KEY"], algorithms="HS256" ) return {"user_id": payload["sub"]} except jwt.ExpiredSignatureError: raise InvalidCredentials( "The token has expired, please login again", - log_txt="Error while trying to decode token. The token has expired." + log_txt="Error while trying to decode token. The token has expired.", ) except jwt.InvalidTokenError: raise InvalidCredentials( "Invalid token, please try again with a new token", - log_txt="Error while trying to decode token. The token is invalid." + log_txt="Error while trying to decode token. The token is invalid.", ) def validate_oid_token( - self, token: str, client_id: str, tenant_id: str, issuer: str, provider: int + self, token: str, client_id: str, tenant_id: str, issuer: str, provider: int ) -> dict: """ This method takes a token issued by an OID provider, the relevant information about the OID provider @@ -172,12 +172,12 @@ def validate_oid_token( except jwt.ExpiredSignatureError: raise InvalidCredentials( "The token has expired, please login again", - log_txt="Error while trying to validate a token. The token has expired. " + log_txt="Error while trying to validate a token. The token has expired.", ) except jwt.InvalidTokenError: raise InvalidCredentials( "Invalid token, please try again with a new token", - log_txt="Error while trying to validate a token. The token is not valid. " + log_txt="Error while trying to validate a token. The token is not valid.", ) @staticmethod @@ -191,12 +191,14 @@ def get_token_from_header(headers: Headers = None) -> str: :rtype: str """ if headers is None: - raise InvalidUsage(log_txt="Error while trying to get a token from header. The header is invalid.") + raise InvalidUsage( + log_txt="Error while trying to get a token from header. The header is invalid." + ) if "Authorization" not in headers: raise InvalidCredentials( "Auth token is not available", - log_txt="Error while trying to get a token from header. The auth token is not available." + log_txt="Error while trying to get a token from header. The auth token is not available.", ) auth_header = headers.get("Authorization") if not auth_header: @@ -206,8 +208,7 @@ def get_token_from_header(headers: Headers = None) -> str: except Exception as e: err = f"The authorization header has a bad syntax: {e}" raise InvalidCredentials( - err, - log_txt=f"Error while trying to get a token from header. " + err + err, log_txt=f"Error while trying to get a token from header. " + err ) def get_user_from_header(self, headers: Headers = None) -> UserModel: @@ -222,8 +223,7 @@ def get_user_from_header(self, headers: Headers = None) -> UserModel: if headers is None: err = "Headers are missing from the request. Authentication was not possible to perform." raise InvalidUsage( - err, - log_txt="Error while trying to get user from header. " + err + err, log_txt="Error while trying to get user from header. " + err ) token = self.get_token_from_header(headers) data = self.decode_token(token) @@ -232,8 +232,7 @@ def get_user_from_header(self, headers: Headers = None) -> UserModel: if user is None: err = "User does not exist, invalid token." raise ObjectDoesNotExist( - err, - log_txt="Error while trying to get user from header. " + err + err, log_txt="Error while trying to get user from header. " + err ) return user @@ -460,3 +459,57 @@ def _get_public_key(self, token: str, tenant_id: str, provider: int): kid = self._get_key_id(token) jwk = self._get_jwk(kid, tenant_id, provider) return self._rsa_pem_from_jwk(jwk) + + +class BIAuth(Auth): + def __init__(self, user_model=UserModel): + super().__init__(user_model) + + @staticmethod + def decode_token(token: str = None) -> dict: + """ + Decodes a given JSON Web token and extracts the sub from it to give it back. + + :param str token: the given JSON Web Token + :return: the sub field of the token as the user_id + :rtype: dict + """ + if token is None: + err = "The provided token is not valid." + raise InvalidUsage( + err, log_txt="Error while trying to decode token. " + err + ) + try: + payload = jwt.decode( + token, current_app.config["SECRET_BI_KEY"], algorithms="HS256" + ) + return {"user_id": payload["sub"]} + except jwt.InvalidTokenError: + raise InvalidCredentials( + "Invalid token, please try again with a new token", + log_txt="Error while trying to decode token. The token is invalid.", + ) + + @staticmethod + def generate_token(user_id: int = None) -> str: + """ + Generates a token given a user_id with a duration of one day + + :param int user_id: user code to be encoded in the token to identify the user afterward. + :return: the generated token + :rtype: str + """ + if user_id is None: + err = "The user id passed to generate the token is not valid." + raise InvalidUsage( + err, log_txt="Error while trying to generate token. " + err + ) + + payload = { + "iat": datetime.utcnow(), + "sub": user_id, + } + + return jwt.encode( + payload, current_app.config["SECRET_BI_KEY"], algorithm="HS256" + ) diff --git a/cornflow-server/cornflow/tests/custom_test_case.py b/cornflow-server/cornflow/tests/custom_test_case.py index 3255f034..dc36fa04 100644 --- a/cornflow-server/cornflow/tests/custom_test_case.py +++ b/cornflow-server/cornflow/tests/custom_test_case.py @@ -101,7 +101,6 @@ def create_user(self, data): @staticmethod def assign_role(user_id, role_id): - if UserRoleModel.check_if_role_assigned(user_id, role_id): user_role = UserRoleModel.query.filter_by( user_id=user_id, role_id=role_id @@ -288,7 +287,6 @@ def patch_row( self.assertEqual(payload_to_check["solution"], row.json["solution"]) def delete_row(self, url): - response = self.client.delete( url, follow_redirects=True, headers=self.get_header_with_auth(self.token) ) @@ -733,7 +731,7 @@ def test_token(self): self.assertEqual(str, type(self.response.json["token"])) decoded_token = jwt.decode( self.response.json["token"], - current_app.config["SECRET_KEY"], + current_app.config["SECRET_TOKEN_KEY"], algorithms="HS256", ) diff --git a/cornflow-server/cornflow/tests/unit/test_cli.py b/cornflow-server/cornflow/tests/unit/test_cli.py index ba82442d..6f1314c3 100644 --- a/cornflow-server/cornflow/tests/unit/test_cli.py +++ b/cornflow-server/cornflow/tests/unit/test_cli.py @@ -2,17 +2,19 @@ import os from click.testing import CliRunner +from flask_testing import TestCase + from cornflow.app import create_app from cornflow.cli import cli -from cornflow.models import UserModel from cornflow.models import ( ActionModel, RoleModel, ViewModel, PermissionViewRoleModel, ) +from cornflow.models import UserModel from cornflow.shared import db -from flask_testing import TestCase +from cornflow.shared.exceptions import NoPermission, ObjectDoesNotExist class CLITests(TestCase): @@ -208,6 +210,7 @@ def test_service_user_help(self): def test_service_user_command(self): runner = CliRunner() + self.test_roles_init_command() result = runner.invoke( cli, [ @@ -222,7 +225,144 @@ def test_service_user_command(self): "test@test.org", ], ) - self.assertEqual(result.exit_code, 1) + self.assertEqual(result.exit_code, 0) user = UserModel.get_one_user_by_email("test@test.org") self.assertEqual(user.username, "test") self.assertEqual(user.email, "test@test.org") + self.assertEqual(user.roles, {4: "service"}) + self.assertTrue(user.is_service_user()) + + def test_viewer_user_command(self): + runner = CliRunner() + self.test_roles_init_command() + result = runner.invoke( + cli, + [ + "users", + "create", + "viewer", + "-u", + "test", + "-p", + "testPassword1!", + "-e", + "test@test.org", + ], + ) + + self.assertEqual(result.exit_code, 0) + user = UserModel.get_one_user_by_email("test@test.org") + self.assertEqual(user.username, "test") + self.assertEqual(user.email, "test@test.org") + self.assertEqual(user.roles, {1: "viewer"}) + self.assertFalse(user.is_service_user()) + + def test_generate_token(self): + runner = CliRunner() + + self.test_roles_init_command() + + result = runner.invoke( + cli, + [ + "users", + "create", + "viewer", + "-u", + "viewer_user", + "-p", + "testPassword1!", + "-e", + "viewer@test.org", + ], + ) + + self.assertEqual(result.exit_code, 0) + + user_id = UserModel.get_one_user_by_username("viewer_user").id + + result = runner.invoke( + cli, + [ + "users", + "create", + "service", + "-u", + "test", + "-p", + "testPassword1!", + "-e", + "test@test.org", + ], + ) + + self.assertEqual(result.exit_code, 0) + + result = runner.invoke( + cli, + [ + "users", + "create", + "token", + "-i", + user_id, + "-u", + "test", + "-p", + "testPassword1!", + ], + ) + + self.assertIn("ey", result.output) + + result = runner.invoke( + cli, + [ + "users", + "create", + "token", + "-i", + user_id, + "-u", + "test", + "-p", + "Otherpassword", + ], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIsInstance(result.exception, NoPermission) + + result = runner.invoke( + cli, + [ + "users", + "create", + "token", + "-i", + user_id, + "-u", + "viewer_user", + "-p", + "testPassword1!", + ], + ) + + self.assertIsInstance(result.exception, NoPermission) + + result = runner.invoke( + cli, + [ + "users", + "create", + "token", + "-i", + 100, + "-u", + "test", + "-p", + "testPassword1!", + ], + ) + + self.assertIsInstance(result.exception, ObjectDoesNotExist) diff --git a/cornflow-server/cornflow/tests/unit/test_token.py b/cornflow-server/cornflow/tests/unit/test_token.py index 63750a1e..97b7f547 100644 --- a/cornflow-server/cornflow/tests/unit/test_token.py +++ b/cornflow-server/cornflow/tests/unit/test_token.py @@ -1,16 +1,16 @@ """ Unit test for the token endpoint """ +import json -# Import from libraries from flask import current_app -import json -# Import from internal modules from cornflow.models import UserModel from cornflow.shared import db -from cornflow.tests.custom_test_case import CheckTokenTestCase +from cornflow.shared.authentication.auth import BIAuth, Auth +from cornflow.shared.exceptions import InvalidUsage from cornflow.tests.const import LOGIN_URL +from cornflow.tests.custom_test_case import CheckTokenTestCase, CustomTestCase class TestCheckToken(CheckTokenTestCase.TokenEndpoint): @@ -64,3 +64,34 @@ def test_old_token(self): self.get_check_token() self.assertEqual(200, self.response.status_code) self.assertEqual(0, self.response.json["valid"]) + + +class TestUnexpiringToken(CustomTestCase): + def test_token_unexpiring(self): + auth = BIAuth() + + token = auth.generate_token(1) + + response = auth.decode_token(token) + self.assertEqual(response, {"user_id": 1}) + + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDM1OTI1OTIsInN1YiI6MX0.Plvmi02FMfZOTn6bxArELEmDeyuP-2X794c5VtAFgCg" + + response = auth.decode_token(token) + self.assertEqual(response, {"user_id": 1}) + + def test_user_not_valid(self): + auth = BIAuth() + self.assertRaises(InvalidUsage, auth.generate_token, None) + + auth = Auth() + self.assertRaises(InvalidUsage, auth.generate_token, None) + + def test_token_not_valid(self): + auth = BIAuth() + self.assertRaises(InvalidUsage, auth.decode_token, None) + token = "" + self.assertRaises(InvalidUsage, auth.decode_token, token) + + auth = Auth() + self.assertRaises(InvalidUsage, auth.decode_token, None) diff --git a/cornflow-server/setup.py b/cornflow-server/setup.py index 9cb3f0b2..dbb93c5d 100644 --- a/cornflow-server/setup.py +++ b/cornflow-server/setup.py @@ -9,7 +9,7 @@ setuptools.setup( name="cornflow", - version="1.0.8", + version="1.0.9", author="baobab soluciones", author_email="cornflow@baobabsoluciones.es", description="Cornflow is an open source multi-solver optimization server with a REST API built using flask.",