From 3f85dd7ac0f2274a281dd112bc171f03500733b6 Mon Sep 17 00:00:00 2001 From: MinyazevR Date: Thu, 7 Mar 2024 03:38:21 +0300 Subject: [PATCH 1/3] First version of The first version of the LTI1.3 protocol implementation --- examples/app.py | 26 ++++ examples/config/config.json | 1 + examples/config/jwk.json | 1 + examples/config/private.key | 51 +++++++ examples/config/public.key | 14 ++ examples/controllers.py | 147 +++++++++++++++++++ mimilti/__init__.py | 0 mimilti/cache_adapter.py | 165 +++++++++++++++++++++ mimilti/config.py | 125 ++++++++++++++++ mimilti/data_storage.py | 231 +++++++++++++++++++++++++++++ mimilti/exceptions.py | 30 ++++ mimilti/grade.py | 279 ++++++++++++++++++++++++++++++++++++ mimilti/jwk.py | 122 ++++++++++++++++ mimilti/lms_client.py | 97 +++++++++++++ mimilti/lms_pool.py | 55 +++++++ mimilti/login.py | 95 ++++++++++++ mimilti/roles.py | 63 ++++++++ mimilti/utils/__init__.py | 0 mimilti/utils/jwt_utils.py | 26 ++++ mimilti/utils/keygen.py | 51 +++++++ tests/config/config.json | 13 ++ tests/config/private.key | 51 +++++++ tests/config/public.key | 14 ++ tests/test_config.py | 11 ++ tests/test_data_service.py | 129 +++++++++++++++++ tests/test_grade.py | 117 +++++++++++++++ tests/test_lti_config.py | 35 +++++ tests/test_mimisession.py | 109 ++++++++++++++ 28 files changed, 2058 insertions(+) create mode 100644 examples/app.py create mode 100644 examples/config/config.json create mode 100644 examples/config/jwk.json create mode 100644 examples/config/private.key create mode 100644 examples/config/public.key create mode 100644 examples/controllers.py create mode 100644 mimilti/__init__.py create mode 100644 mimilti/cache_adapter.py create mode 100644 mimilti/config.py create mode 100644 mimilti/data_storage.py create mode 100644 mimilti/exceptions.py create mode 100644 mimilti/grade.py create mode 100644 mimilti/jwk.py create mode 100644 mimilti/lms_client.py create mode 100644 mimilti/lms_pool.py create mode 100644 mimilti/login.py create mode 100644 mimilti/roles.py create mode 100644 mimilti/utils/__init__.py create mode 100644 mimilti/utils/jwt_utils.py create mode 100644 mimilti/utils/keygen.py create mode 100644 tests/config/config.json create mode 100644 tests/config/private.key create mode 100644 tests/config/public.key create mode 100644 tests/test_config.py create mode 100644 tests/test_data_service.py create mode 100644 tests/test_grade.py create mode 100644 tests/test_lti_config.py create mode 100644 tests/test_mimisession.py diff --git a/examples/app.py b/examples/app.py new file mode 100644 index 0000000..351ce2b --- /dev/null +++ b/examples/app.py @@ -0,0 +1,26 @@ +import os + +from flask import Flask +from controllers import login, launch, get_jwks, create_test, get_grade, set_grade + + +app = Flask(__name__) + + +app.config["SECRET_KEY"] = os.urandom(16).hex() +app.config["SESSION_COOKIE_NAME"] = "mimilti_session" +app.add_url_rule("/login", methods=["GET", "POST"], view_func=login) +app.add_url_rule("/launch", methods=["GET", "POST"], view_func=launch) +app.add_url_rule("/jwks", methods=["GET", "POST"], view_func=get_jwks) +app.add_url_rule("/set_grade", methods=["GET", "POST"], view_func=set_grade) +app.add_url_rule("/create_test", methods=["GET", "POST"], view_func=create_test) +app.add_url_rule("/get_grade", methods=["GET", "POST"], view_func=get_grade) + + +@app.route("/") +def index(): + return "" + + +if __name__ == "__main__": + app.run(port=9002) diff --git a/examples/config/config.json b/examples/config/config.json new file mode 100644 index 0000000..6d306b4 --- /dev/null +++ b/examples/config/config.json @@ -0,0 +1 @@ +{"kid": "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50", "issuers": {"http://localhost/moodle": {"login_url": "http://localhost/moodle/mod/lti/auth.php", "token_url": "http://localhost/moodle/mod/lti/token.php", "tools": [{"aud": "DS7jNSEoKQPjFCk", "jwks_endpoint": "http://localhost/moodle/mod/lti/certs.php"}, {"aud": "URw5NjQzGdD2KdE", "jwks_endpoint": "http://localhost/moodle/mod/lti/certs.php"}]}}} \ No newline at end of file diff --git a/examples/config/jwk.json b/examples/config/jwk.json new file mode 100644 index 0000000..1562d67 --- /dev/null +++ b/examples/config/jwk.json @@ -0,0 +1 @@ +{"keys": [{"e": "AQAB", "kid": "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50", "kty": "RSA", "n": "o-HxDT6GfZgI5t0ovfd6Sn8Gh0-rxJ86Deun2Nz_EI1x64aO-saigXFoXI04KzoIE-CKUg9aGVPYEhKj59lY5l4EQBkyRcs2nawKnQqQkqWDmCydkfMMgvYEQLzoQVkg-f6m2xpyfK3Q6OCh97-B8U4Tx2jiuF1OCQnsVV1-AJ0JCascxG_3dzXHjTkFARuCztb2NzT6SbKnWpeTGPjiq3uZlwESpo4-nZpvc1GYdELGmVrJ5RGmqDrNRABlaoe-3FipIHnBoD6HUTCITHN3mXCWltjEeUiqFGphDccIK8Ek6GiSUxTNcTJvsW8uDPobg-5YhbG-G-6DtgPgeM_qfolxIjpxK4qBD5NlHbL6IU9rDzf9bcUXbwedvhmNJmBH91am9H3AVeBeFdO1wK8KL9-L5o_6guHGelJk-Rn8eXlB8O-1d4ENQb963nOPS1NyOOPOdgqqfdFSPXEWfsvHC3puG-Ad3D8bexrPX2XHRWavT9yJRbTvw-pWzFFIA-a3Dz1pba4VqGpOG08rLT-BhM2XLy7lmaBssoCAniXGq913fD3Qndn8HAIVNzFJBMJfNYwP866frmMD6N3cdgk1zoQ5iu6G-HQzR_inybpbR7OiPHMQsiyu0HlJXJmt3N0qaBAAcHZym4UROO0e3fg5kqtP-jKxGXeGCkrAxNiXe1c", "alg": "RS256", "use": "sig"}]} \ No newline at end of file diff --git a/examples/config/private.key b/examples/config/private.key new file mode 100644 index 0000000..a27ac40 --- /dev/null +++ b/examples/config/private.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/examples/config/public.key b/examples/config/public.key new file mode 100644 index 0000000..7c0ea41 --- /dev/null +++ b/examples/config/public.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo+HxDT6GfZgI5t0ovfd6 +Sn8Gh0+rxJ86Deun2Nz/EI1x64aO+saigXFoXI04KzoIE+CKUg9aGVPYEhKj59lY +5l4EQBkyRcs2nawKnQqQkqWDmCydkfMMgvYEQLzoQVkg+f6m2xpyfK3Q6OCh97+B +8U4Tx2jiuF1OCQnsVV1+AJ0JCascxG/3dzXHjTkFARuCztb2NzT6SbKnWpeTGPji +q3uZlwESpo4+nZpvc1GYdELGmVrJ5RGmqDrNRABlaoe+3FipIHnBoD6HUTCITHN3 +mXCWltjEeUiqFGphDccIK8Ek6GiSUxTNcTJvsW8uDPobg+5YhbG+G+6DtgPgeM/q +folxIjpxK4qBD5NlHbL6IU9rDzf9bcUXbwedvhmNJmBH91am9H3AVeBeFdO1wK8K +L9+L5o/6guHGelJk+Rn8eXlB8O+1d4ENQb963nOPS1NyOOPOdgqqfdFSPXEWfsvH +C3puG+Ad3D8bexrPX2XHRWavT9yJRbTvw+pWzFFIA+a3Dz1pba4VqGpOG08rLT+B +hM2XLy7lmaBssoCAniXGq913fD3Qndn8HAIVNzFJBMJfNYwP866frmMD6N3cdgk1 +zoQ5iu6G+HQzR/inybpbR7OiPHMQsiyu0HlJXJmt3N0qaBAAcHZym4UROO0e3fg5 +kqtP+jKxGXeGCkrAxNiXe1cCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/examples/controllers.py b/examples/controllers.py new file mode 100644 index 0000000..bc5584d --- /dev/null +++ b/examples/controllers.py @@ -0,0 +1,147 @@ +import os +import pathlib +from datetime import datetime, timedelta + +from flask import ( + jsonify, + redirect, + request, + url_for, + send_from_directory, +) + + +from mimilti.grade import LineItem, GradeService, Progress +from mimilti.login import LtiRequestObject +from mimilti.data_storage import SessionDataStorage +from mimilti.config import Config, RsaKey +from mimilti.roles import ( + ContextInstructorRole, + RoleService, +) +from mimilti.lms_pool import LmsRequestsPool + +public_key_path = os.path.join(pathlib.Path(__file__).parent, "config/public.key") +private_key_path = os.path.join(pathlib.Path(__file__).parent, "config/private.key") +public_json_path = os.path.join(pathlib.Path(__file__).parent, "config/config.json") + +key = RsaKey(private_key_path, public_key_path) +config = Config(public_json_path, key) +LmsRequestsPool.start(config) + + +def get_lti_request_object(): + pass + + +def get_jwks(): + return send_from_directory( + os.path.join(pathlib.Path(__file__).parent, "config/"), "jwk.json" + ) + + +def login(): + if request.method == "POST": + session_service = SessionDataStorage() + try: + request_object = LtiRequestObject(request.form, session_service, config) + except Exception as e: + return jsonify({"error": str(e)}), 401 + + redirect_url = request_object.get_redirect_url() + issuer = request_object.get_issuer() + + next_url = request.args.get("next") + + session_service.iss = issuer + session_service.aud = request_object.get_client_id() + + if next_url: + session_service.save_param_to_session(next_url, "next_url") + else: + session_service.remove_param_from_session("next_url") + + return redirect(redirect_url) + + +def launch(): + if request.method == "POST": + session_data_service = SessionDataStorage() + + request_object = LtiRequestObject(request.form, session_data_service, config) + + try: + data = request_object.get_token() + session_data_service.update_params(data) + config.add_tool(session_data_service.iss, session_data_service.aud) + except Exception as e: + return jsonify({"error": str(e)}), 401 + + # you login logic + + if (next_url := session_data_service.get_param("next_url")) is None: + return redirect(url_for("index")) + + else: + session_data_service.remove_param_from_session("next_url") + return redirect(next_url) + + +def get_role_service(): + data_service = SessionDataStorage() + role_service = RoleService(data_service) + return role_service + + +@get_role_service().lti_role_accepted(ContextInstructorRole) +def create_test(): + + test_guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11" + test_label = "test ugugugu" + test_tag = "test tag" + test_maximum_score = 100 + test_start_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + test_end_time = (datetime.now() + timedelta(seconds=3600)).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + + data_service = SessionDataStorage() + grade_service = GradeService(data_service, config) + lineitems = LineItem( + id=None, + label=test_label, + score_maximum=test_maximum_score, + resource_id=test_guid, + tag=test_tag, + start_date_time=test_start_time, + end_date_time=test_end_time, + ) + + grade_service.create_or_set_lineitem(lineitems) + return "" + + +def get_grade(): + guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11" + data_service = SessionDataStorage() + grade_service = GradeService(data_service, config) + print(grade_service.get_grade(guid)) + return "" + + +def set_grade(): + data_service = SessionDataStorage() + guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11" + grade_service = GradeService(data_service, config) + progress = Progress( + score_given=50, + score_maximum=100, + activity_progress="Completed", + grading_progress="FullyGraded", + user_id=data_service.sub, + comment="comment", + timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + grade_service.set_grade(progress, guid) + return "" diff --git a/mimilti/__init__.py b/mimilti/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mimilti/cache_adapter.py b/mimilti/cache_adapter.py new file mode 100644 index 0000000..b0bb3ab --- /dev/null +++ b/mimilti/cache_adapter.py @@ -0,0 +1,165 @@ +import datetime +import functools +from collections import OrderedDict +from collections.abc import Iterable, Hashable +import requests +from collections.abc import Callable +from requests.adapters import HTTPAdapter + + +class LruCache: + """Lru Cache + + Usage:: + >>> import mimilti.cache_adapter.LruCache + >>> lru_cache = LruCache() + >>> @lru_cache.ttl_lru_cache # without expires + >>> def get_x(x: int) -> int: + >>> return x + >>> @lru_cache.ttl_lru_cache(datetime.timedelta(hours=1)) #with expires + >>> def get_y(y: int) -> int: + >>> return y + :param max_size: int: Lru Cache Max Size""" + + def __init__(self, max_size: int = 256): + self.max_size = max_size + self._cache = OrderedDict() + + def __len__(self): + return len(self._cache) + + def clear(self): + self._cache.clear() + + def ttl_lru_cache[ + T, **P + ](self, expires: datetime.timedelta | None = None) -> Callable[ + [Callable[(P.args, P.kwargs), T]], Callable[(P.args, P.kwargs), T] + ]: + """Lru Cache decorator + :param expires: datetime.timedelta | None: Result caching time. + :return: Inner decorator for cache + """ + + def wrapper_cache( + func: Callable[(P.args, P.kwargs), T] + ) -> Callable[(P.args, P.kwargs), T]: + """Inner Lru Cache decorator. + + :param func: Callable[P, T]: Callable for processing + :return: Callable[P, T]: The function to call. + """ + + # When expires = None, there will be a standard Lru Cache) + # The expires parameter is unique for each function. + + @functools.wraps(func) + def wrapped_func(*args: P.args, **kwargs: P.kwargs) -> T: + """Inner decorator for cache. + + :param args: P.args: Positional function arguments + :param kwargs: P.kwargs: Named function arguments + :return: T: result of the function execution + + """ + + # Turning function arguments into Hashable to get a result based on the value of the arguments + kwargs = dict(sorted(kwargs.items())) + kwargs_list = [] + for key in kwargs: + if isinstance(kwargs[key], Hashable): + kwargs_list.append((key, kwargs[key])) + elif isinstance(kwargs[key], Iterable): + kwargs_list.append((key, tuple(kwargs[key]))) + else: + return + kwargs_tuple = tuple(kwargs_list) + cache_key = (args, kwargs_tuple) + + value_dict = self._cache.get(cache_key, None) + + # If the time has expired and no one accesses the value, + # Then it will be deleted when it overflows, otherwise the value will be updated. + if value_dict is not None: + expires_datetime = value_dict["expires_datetime"] + if expires_datetime and datetime.datetime.now() > expires_datetime: + self._cache.pop(cache_key, None) + else: + self._cache.move_to_end(cache_key) + return value_dict["value"] + + start_time = datetime.datetime.now() + value = func(*args, **kwargs) + expires_datetime = None if expires is None else start_time + expires + dct = {"value": value, "expires_datetime": expires_datetime} + self._cache[cache_key] = dct + + if len(self._cache) > self.max_size: + self._cache.popitem(False) + + return value + + return wrapped_func + + return wrapper_cache + + +class MimiSession(requests.Session): + """A request.Session with support for caching using lru cache with ttl. + + Adds functionality for caching requests. + + Usage:: + >>> from mimilti.cache_adapter import MimiSession, CacheAdapter + >>> s = MimiSession() + >>> expires = datetime.timedelta(seconds=3600) + >>> cache_adapter = CacheAdapter() + >>> s.mount("http://localhost/moodle/mod/lti/token.php", cache_adapter) + >>> s.get('http://localhost/moodle/mod/lti/token.php') + """ + + def __init__(self): + super().__init__() + self._lru_cache = LruCache(128) + self._ttl_cache = self._lru_cache.ttl_lru_cache + + def request(self, *args, **kwargs): + + if len(args) > 1: + url = args[1] + else: + url = kwargs["url"] + + adapter = self.get_adapter(url=url) + + if isinstance(adapter, CacheAdapter): + expires = adapter.expires + return self._ttl_cache(expires)(super().request)(*args, **kwargs) + + return super().request(*args, **kwargs) + + +class CacheAdapter(HTTPAdapter): + """HTTPAdapter for caching requests + + :param expires: datetime.timedelta: Request caching time + + """ + + def __init__(self, expires: datetime.timedelta): + super().__init__() + self._expires = expires + + @property + def expires(self) -> datetime.timedelta: + """Getter for expires + + :return: datetime.timedelta: Request caching time""" + return self._expires + + @expires.setter + def expires(self, expires: datetime.timedelta) -> None: + """Setter for expires + + :param expires: datetime.timedelta: Request caching time""" + self._expires = expires diff --git a/mimilti/config.py b/mimilti/config.py new file mode 100644 index 0000000..00a01c9 --- /dev/null +++ b/mimilti/config.py @@ -0,0 +1,125 @@ +import copy +import json +from collections import defaultdict + + +class RsaKey: + + def __init__(self, private_key_path: str, public_key_path: str): + + with open(public_key_path, "rb") as public_key_file: + self._public_key = public_key_file.read() + + with open(private_key_path, "rb") as private_key_file: + self._private_key = private_key_file.read() + + @property + def public_key(self) -> bytes: + return self._public_key + + @property + def private_key(self) -> bytes: + return self._private_key + + def __eq__(self, other: "RsaKey") -> bool: + return ( + self._public_key == other.public_key + and self._private_key == other.private_key + ) + + +class ToolConfig: + + def __init__(self, config): + self._config = config + + @property + def config(self) -> dict: + return self._config + + def get_jwks_endpoint(self) -> str: + return self._config["jwks_endpoint"] + + def get_client_id(self) -> str: + return self._config["aud"] + + +class Config: + + def __init__(self, config_path: str, rsa_key: RsaKey) -> None: + self._config_path = config_path + self._config = {} + self._tools: dict[str, list[ToolConfig]] = defaultdict(list) + self._rsa_key = rsa_key + + with open(self._config_path, "r") as file: + self._config = json.load(file) + + issuers = self._config["issuers"] + + for issuer in issuers: + tools = issuers[issuer]["tools"] + + for tool in tools: + self._tools[issuer].append(ToolConfig(tool)) + + def get_tool(self, issuer: str, aud: str) -> ToolConfig | None: + + if issuer not in self.get_issuers(): + return None + + for tool in self._tools[issuer]: + if tool.get_client_id() == aud: + return tool + + return None + + def get_login_url(self, issuer: str) -> str: + return self._config["issuers"][issuer]["login_url"] + + def get_token_url(self, issuer: str) -> str: + return self._config["issuers"][issuer]["token_url"] + + def add_tool(self, iss: str, aud: str) -> ToolConfig | None: + + if (tool := self.get_tool(iss, aud)) is not None: + return tool + + if iss in self._tools: + tools = self._tools[iss] + exist_tool = tools[0] + exist_tool_config = exist_tool.config + exist_tool_aud = exist_tool.get_client_id() + + new_tool_config = copy.deepcopy(exist_tool_config) + for key, value in new_tool_config.items(): + if exist_tool_aud in value: + new_tool_config[key] = value.replace(exist_tool_aud, aud) + + tool = ToolConfig(new_tool_config) + tools.append(tool) + self._config["issuers"][iss]["tools"].append(new_tool_config) + with open(self._config_path, "w") as file: + file.write(json.dumps(self._config)) + + return None + + def get_keys_and_kid(self) -> tuple[RsaKey, str]: + kid = self._config["kid"] + return self._rsa_key, kid + + def is_trusted_issuer(self, issuer: str) -> bool: + return issuer in self._config["issuers"] + + def get_issuers(self) -> tuple[str]: + return tuple(self._config["issuers"]) + + def get_tools(self, issuer: str) -> list[ToolConfig]: + return self._tools[issuer] + + def generate_template_endpoint(self, issuer: str, aud: str) -> str: + if issuer in self._tools: + tools = self._tools[issuer] + exist_tool = tools[0] + exist_aud = exist_tool.get_client_id() + return exist_tool.get_jwks_endpoint().replace(exist_aud, aud) diff --git a/mimilti/data_storage.py b/mimilti/data_storage.py new file mode 100644 index 0000000..78875f6 --- /dev/null +++ b/mimilti/data_storage.py @@ -0,0 +1,231 @@ +import typing as tp +from typing import TypedDict, Sequence +from abc import abstractmethod, ABC +from flask import session + + +class DataStorage: + def __init__(self): + self._iss: str + self._aud: str + self._deployment_id: int + self._sub: int + self._roles: Sequence[str] + self._context: TypedDict( + "context", {"id": int, "label": str, "title": str, "type": str} + ) + + @property + @abstractmethod + def lineitems(self, *args, **kwargs): + pass + + @lineitems.setter + @abstractmethod + def lineitems(self, lineitems: Sequence[str]): + pass + + @property + @abstractmethod + def iss(self, *args, **kwargs): + pass + + @iss.setter + @abstractmethod + def iss(self, iss: str): + pass + + @property + @abstractmethod + def aud(self, *args, **kwargs): + pass + + @aud.setter + @abstractmethod + def aud(self, aud: str): + pass + + @property + @abstractmethod + def sub(self, *args, **kwargs): + pass + + @sub.setter + @abstractmethod + def sub(self, sub: str): + pass + + @property + @abstractmethod + def context_roles(self, *args, **kwargs): + pass + + @property + @abstractmethod + def roles(self, *args, **kwargs): + pass + + @property + @abstractmethod + def context(self, *args, **kwargs): + pass + + @staticmethod + def inner_role_handler(role: str): + if "#" in role: + _, last = role.split("#") + return last + return role + + @staticmethod + def role_handler(roles: list[str]): + new_roles = {} + + for role in roles: + role_part = DataStorage.inner_role_handler(role) + if "institution" in role: + new_roles["institution"] = role_part + if "membership" in role: + new_roles["context"] = role_part + if "system" in role: + new_roles["system"] = role_part + return new_roles + + @property + def main_context_role_name(self): + context_roles = self.context_roles + if "Administrator" in context_roles: + return "Administrator" + if "Instructor" in context_roles: + return "Instructor" + if "Mentor" in context_roles: + return "Mentor" + if "Learner" in context_roles: + return "Learner" + + +class SessionStorage(ABC): + def __init__(self): + pass + + @abstractmethod + def session_validate(self, param: tp.Any, param_name: str) -> bool: + pass + + @abstractmethod + def get_param(self, param_name: str) -> str | None: + pass + + @abstractmethod + def save_param_to_session(self, param: tp.Any, param_name: str) -> None: + pass + + @abstractmethod + def remove_param_from_session(self, param_name: str) -> None: + pass + + +class SessionDataStorage(DataStorage, SessionStorage): + def __init__(self, lti_session=session): + super().__init__() + self._session = lti_session + + def session_validate(self, param: tp.Any, param_name: str) -> bool: + return ( + False + if param_name not in self._session + else self._session[param_name] == param + ) + + def get_param(self, param_name: str) -> str | None: + return self._session.get(param_name, None) + + def save_param_to_session(self, param: tp.Any, param_name: str) -> None: + self._session[param_name] = param + + def remove_param_from_session(self, param_name: str) -> None: + self._session.pop(param_name, None) + + @property + def lineitems(self): + return self._session.get("lineitems", None) + + @lineitems.setter + def lineitems(self, lineitems: Sequence[str]): + self._session["lineitems"] = lineitems + + @property + def iss(self): + return self._session.get("iss", None) + + @iss.setter + def iss(self, iss: str): + self._session["iss"] = iss + + @property + def aud(self): + return self._session.get("aud", None) + + @aud.setter + def aud(self, aud: str): + self._session["aud"] = aud + + @property + def sub(self): + return self._session.get("sub", None) + + @sub.setter + def sub(self, sub: str): + self._session["sub"] = sub + + @property + def roles(self, *args, **kwargs): + return self._session.get("roles", None) + + @property + def context_roles(self): + roles = self._session.get("roles", None) + if roles is None: + return None + return roles.get("context", None) + + @property + def context(self): + return self._session.get("context", None) + + def update_params(self, data) -> None: + if data is not None: + self._init_params(data) + + def _init_params(self, data): + if "iss" in data: + self._session["iss"] = data.get("iss", None) + + if "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" in data: + self._session["lineitems"] = data[ + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" + ]["lineitems"] + + if "aud" in data: + self._session["aud"] = data["aud"] + + if "https://purl.imsglobal.org/spec/lti/claim/deployment_id" in data: + self._session["deployment_id"] = data[ + "https://purl.imsglobal.org/spec/lti/claim/deployment_id" + ] + + if "sub" in data: + self._session["sub"] = data.get("sub", None) + + if "https://purl.imsglobal.org/spec/lti/claim/roles" in data: + self._session["roles"] = self.role_handler( + data["https://purl.imsglobal.org/spec/lti/claim/roles"] + ) + + if "https://purl.imsglobal.org/spec/lti/claim/context" in data: + self._session["context"] = data.get( + "https://purl.imsglobal.org/spec/lti/claim/context", None + ) + + for x, y in data.items(): + self.save_param_to_session(y, x) diff --git a/mimilti/exceptions.py b/mimilti/exceptions.py new file mode 100644 index 0000000..bcb8a9c --- /dev/null +++ b/mimilti/exceptions.py @@ -0,0 +1,30 @@ +class RequestIssuerNotTrustedError(Exception): + def __init__(self, issuer: str): + self.issuer = issuer + + def __str__(self): + return f"LtiRequestIssuerError: {self.issuer} is not trusted issuer" + + +class RequestNonceError(Exception): + + def __str__(self): + return "LtiRequestNonceError: invalid jwt nonce" + + +class RequestTargetUriError(Exception): + + def __str__(self): + return "LtiRequestTargetUriError: Missing target_link_uri parameter" + + +class RequestNonValidTokenError(Exception): + + def __str__(self): + return "LtiRequestNonValidTokenError: Non valid jwt token" + + +class RequestStateError(Exception): + + def __str__(self): + return "LtiRequestStateError: Invalid session state" diff --git a/mimilti/grade.py b/mimilti/grade.py new file mode 100644 index 0000000..17927da --- /dev/null +++ b/mimilti/grade.py @@ -0,0 +1,279 @@ +import dataclasses +import datetime +from marshmallow import Schema, fields, post_load + +from mimilti.lms_client import LMSClient +from dataclasses import dataclass +from mimilti.data_storage import DataStorage +from mimilti.config import Config + + +@dataclass +class LineItem: + id: str | None = None + label: str | None = None + score_maximum: int | None = None + resource_id: str | None = None + tag: str | None = None + resource_link_id: str | None = None + lti_link_id: str | None = None + start_date_time: str | None = None + end_date_time: str | None = None + + +class LineItemSchema(Schema): + id = fields.String(data_key="id") + label = fields.String(data_key="label") + score_maximum = fields.Integer(data_key="scoreMaximum") + resource_id = fields.String(data_key="resourceId") + tag = fields.String(data_key="tag") + resource_link_id = fields.String(data_key="resourceLinkId") + lti_link_id = fields.String(data_key="ltiLinkId") + start_date_time = fields.String(data_key="startDateTime") + end_date_time = fields.String(data_key="endDateTime") + + @post_load + def make_lineitem(self, data, **kwargs): + _ = kwargs + return LineItem(**data) + + +class ProgressSchema(Schema): + score_given = fields.Number(data_key="scoreGiven") + score_maximum = fields.Number(data_key="scoreMaximum") + activity_progress = fields.String(data_key="activityProgress") + grading_progress = fields.String(data_key="gradingProgress") + user_id = fields.String(data_key="userId") + comment = fields.String(data_key="comment") + timestamp = fields.String(data_key="timestamp") + + @post_load + def make_progress(self, data, **kwargs): + _ = kwargs + return Progress(**data) + + +@dataclass +class Progress: + score_given: int | None = None + score_maximum: int | None = None + activity_progress: str | None = None + grading_progress: str | None = None + user_id: str | None = None + comment: str | None = None + timestamp: str | None = None + + +@dataclass +class Grade: + id: int | None = None + user_id: int | None = None + result_score: str | None = None + result_maximum: str | None = None + score_of: int | None = None + timestamp: str | None = None + + +class GradeSchema(Schema): + id = fields.String(data_key="id") + user_id = fields.String(data_key="userId") + result_score = fields.Number(data_key="resultScore") + result_maximum = fields.Number(data_key="resultMaximum") + score_of = fields.String(data_key="scoreOf") + timestamp = fields.String(data_key="timestamp") + + @post_load + def make_grade(self, data, **kwargs): + _ = kwargs + return Grade(**data) + + +class GradeService: + def __init__(self, data_service: DataStorage, config: Config): + self._lms_client = LMSClient(data_service, config) + self._data_service = data_service + self._scopes = [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + ] + self._lineitem_schema = LineItemSchema() + self._lineitems: list[LineItem] = [] + self._grade_schema = GradeSchema() + self._progress_schema = ProgressSchema() + self._grades: list[Grade] = [] + self._lineitem_expires = datetime.timedelta(seconds=0) + self._grade_expires = datetime.timedelta(seconds=0) + + @property + def client(self): + return self._lms_client + + @property + def lineitems(self): + return self._lineitems + + @lineitems.setter + def lineitems(self, lineitems: list[LineItem]): + self._lineitems = lineitems + + def refresh_lineitems(self): + lineitems_url = self._data_service.lineitems + + if lineitems_url is None: + return None + + response = self._lms_client.send_request_to_lms( + self._scopes, + lineitems_url, + accept="application/vnd.ims.lis.v2.lineitemcontainer+json", + content_type="application/json", + request_type="GET", + need_token=True, + ) + + if response is None: + return None + + lineitems = LMSClient.get_json(response) + + self._lineitems = [ + self._lineitem_schema.load(lineitem) for lineitem in lineitems + ] + + return self._lineitems + + def find_lineitem_index_by_resource_id(self, resource_id: str) -> int | None: + for index, lineitem in enumerate(self._lineitems): + if lineitem.resource_id == resource_id: + return index + return None + + def find_lineitems_by_tag(self, tag) -> list[LineItem]: + return [lineitem for lineitem in self._lineitems if lineitem.tag == tag] + + @staticmethod + def get_payload(data, schema: Schema) -> str: + data = {x: y for x, y in data.items() if y is not None} + return schema.dumps(data) + + def create_or_set_lineitem(self, lineitem: LineItem) -> LineItem: + target_index = self.find_lineitem_index_by_resource_id(lineitem.resource_id) + + is_new_lineitem = True + + url = self._data_service.lineitems + + if target_index is None: + self._lineitems = self.refresh_lineitems() + + target_index = self.find_lineitem_index_by_resource_id(lineitem.resource_id) + + if target_index is not None: + target: LineItem = self._lineitems[target_index] + lineitem.resource_id = target.resource_id + lineitem.resource_label = target.resource_link_id + lineitem.tag = target.tag + lineitem.lti_link_id = target.lti_link_id + lineitem.id = target.id + else: + is_new_lineitem = True + + if is_new_lineitem: + + data = GradeService.get_payload( + dataclasses.asdict(lineitem), self._lineitem_schema + ) + + _ = self._lms_client.send_request_to_lms( + scopes=self._scopes, + url=url, + accept="application/vnd.ims.lis.v2.lineitem+json", + content_type="application/vnd.ims.lis.v2.lineitem+json", + request_type="POST", + data=data, + need_token=True, + ) + + self._lineitems.append(lineitem) + return lineitem + + self._lineitems[target_index] = lineitem + return lineitem + + def find_grade_index_by_score_of(self, score_of: str) -> int | None: + for index, grade in enumerate(self._grades): + if grade.score_of == score_of: + return index + + def get_grade(self, lineitem_id: str): + index = self.find_lineitem_index_by_resource_id(lineitem_id) + + if index is None: + return None + + lineitem = self._lineitems[index] + url = lineitem.id + + index = self.find_grade_index_by_score_of(url) + + if index is not None: + return self._grades[index] + + url = GradeService._moodle_url_handler(url, "results") + + response = self._lms_client.send_request_to_lms( + self._scopes, + url, + accept="application/vnd.ims.lis.v2.resultcontainer+json", + content_type="application/json", + request_type="GET", + need_token=True, + ) + + grades = LMSClient.get_json(response) + for grade in grades: + self._grades.append(self._grade_schema.load(grade)) + + return self._grades + + # 'http://127.0.0.1/moodle/mod/lti/services.php/2/lineitems?type_id=4' -> + # 'http://127.0.0.1/moodle/mod/lti/services.php/2/lineitems/scores?type_id=4' + @staticmethod + def _moodle_url_handler(url: str, end: str) -> str: + if "?" in url: + url, url_end = url.split("?") + end += "?" + url_end + url += "" if url[-1] == "/" else "/" + return url + end + + def set_grade( + self, + progress: Progress, + lineitem_id: str, + ): + index = self.find_lineitem_index_by_resource_id(lineitem_id) + + if index is None: + return None + + lineitem = self._lineitems[index] + url = lineitem.id + url = GradeService._moodle_url_handler(url, "scores") + + data = GradeService.get_payload( + dataclasses.asdict(progress), self._progress_schema + ) + + _ = self._lms_client.send_request_to_lms( + self._scopes, + url, + accept="application/json", + content_type="application/vnd.ims.lis.v1.score+json", + request_type="POST", + data=data, + need_token=True, + ) + + return progress diff --git a/mimilti/jwk.py b/mimilti/jwk.py new file mode 100644 index 0000000..f6f2b2a --- /dev/null +++ b/mimilti/jwk.py @@ -0,0 +1,122 @@ +import dataclasses +from base64 import b64decode, urlsafe_b64decode +from datetime import timedelta + +import jwt +from Crypto.PublicKey.RSA import construct +from marshmallow import Schema, fields, post_load + +from mimilti.cache_adapter import LruCache +from mimilti.lms_client import LMSClient +from mimilti.lms_pool import LmsRequestsPool +from mimilti.config import Config + + +@dataclasses.dataclass +class Key: + kty: str + alg: str | None + use: str + public_key: bytes + + +class KeySchema(Schema): + kty = fields.String() + alg = fields.Raw() + use = fields.Raw() + e = fields.Raw() + n = fields.Raw() + kid = fields.Raw() + public_key = fields.Raw() + + @post_load + def make_user(self, data, **kwargs): + _ = kwargs + return Key( + kty=data["kty"], + alg=data["alg"], + use=data["use"], + public_key=data["public_key"], + ) + + +class LmsJwkClient: + KeySchema = KeySchema() + + result_expires_time = LmsRequestsPool.default_jwks_endpoint_expires_time - timedelta(seconds=20) + + def __init__(self, data_service, config: Config): + self._data_service = data_service + self._lms_client = LMSClient(data_service, config) + self._keys = {} + self._config = config + + @staticmethod + @LruCache(max_size=32).ttl_lru_cache(expires=result_expires_time) + def _generate_public_key(e: str, n: str, kty: str = "RSA") -> bytes: + + if kty == "RSA": + e = int.from_bytes(b64decode(e + "====")) + n = int.from_bytes(urlsafe_b64decode(n + "====")) + public_key = construct((n, e)).public_key().exportKey() + return public_key + + def _get_key_from_jwks_endpoint(self, kid: str, endpoint: str) -> Key: + response = self._lms_client.send_request_to_lms( + scopes=None, + url=endpoint, + accept="application/json", + content_type="application/json", + request_type="GET", + data=None, + need_token=False, + ) + return self._get_public_key(response, kid) + + def _get_public_key(self, response, kid) -> Key: + keys_dict = self._lms_client.get_json(response) + keys = keys_dict.get("keys", None) + for key in keys: + public_key = self._generate_public_key(key["e"], key["n"], key["kty"]) + key["public_key"] = public_key + + key = LmsJwkClient.KeySchema.load(key) + + self._keys[kid] = key + + return self._get_public_key_with_kid(kid) + + def _get_public_key_with_kid(self, kid) -> Key: + + if kid not in self._keys: + raise Exception("Key not found") + + return self._keys[kid] + + def decode_jwt_token(self, id_token: str): + headers = jwt.get_unverified_header(id_token) + + kid = headers["kid"] + endpoint = self._config.generate_template_endpoint( + self._data_service.iss, self._data_service.aud + ) + key = self._get_key_from_jwks_endpoint(kid, endpoint) + + if key.kty != "RSA": + raise Exception("Algorithm not supported") + + if key.alg is None: + raise Exception("Key is not signed") + + algorithms = [key.alg] + + data = jwt.decode( + id_token, + key.public_key, + algorithms=algorithms, + audience=self._data_service.aud, + verify_signature=True, + verify_aud=True, + ) + + return data diff --git a/mimilti/lms_client.py b/mimilti/lms_client.py new file mode 100644 index 0000000..7c5242b --- /dev/null +++ b/mimilti/lms_client.py @@ -0,0 +1,97 @@ +import typing as tp + +from mimilti.lms_pool import LmsRequestsPool +from mimilti.config import Config +from mimilti.data_storage import DataStorage +from mimilti.utils.jwt_utils import encode_jwt, get_jwt_claim + + +class LMSClient: + def __init__(self, data_service: DataStorage, config: Config): + self._data_service = data_service + self._config = config + self._tool = config.get_tool(self._data_service.iss, self._data_service.aud) + self._access_token = {"access_token": "", "scope": ""} + + def get_current_lms(self): + return self._data_service.iss + + def get_current_tool(self): + return self._tool + + @staticmethod + def _get_auth_param(scope, client_assertion): + return { + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": client_assertion, + "scope": scope, + } + + def _get_token_scopes(self): + return set(self._access_token["scope"].split(" ")) + + def get_access_token(self, scopes: tp.Sequence[str]): + + client_id = self._tool.get_client_id() + auth_url = self._config.get_token_url(self._data_service.iss) + + claims = get_jwt_claim(client_id, client_id, auth_url) + rsa_key, kid = self._config.get_keys_and_kid() + jwt_encode = encode_jwt(claims, rsa_key.private_key, {"kid": kid}) + auth_param = self._get_auth_param(" ".join(scopes), jwt_encode) + response = self.send_request_to_lms( + scopes=None, + url=auth_url, + accept="application/json", + content_type="application/x-www-form-urlencoded", + request_type="POST", + data=auth_param, + ) + + self._access_token = self.get_json(response) + return self._access_token + + def send_request_to_lms( + self, + scopes: tp.Sequence[str] | None, + url: str, + accept: str, + content_type: str, + request_type: str, + data: None | dict | str = None, + need_token: bool = False, + ): + headers = { + "Accept": accept, + "Content-Type": content_type, + } + + if need_token and (scopes is None or len(scopes) <= 0): + return None + if need_token: + if set(scopes) <= self._get_token_scopes(): + access_token = self._access_token["access_token"] + else: + self._access_token = self.get_access_token(scopes) + access_token = self._access_token["access_token"] + + headers["Authorization"] = "Bearer " + access_token + + session = LmsRequestsPool.session + + match request_type: + case "GET": + response = session.get(url, headers=headers) + case "POST": + response = session.post(url, data=data, headers=headers) + case _: + raise Exception(f"Method not supported: {request_type}") + + response.raise_for_status() + + return response + + @staticmethod + def get_json(response): + return response.json() diff --git a/mimilti/lms_pool.py b/mimilti/lms_pool.py new file mode 100644 index 0000000..f9a3b71 --- /dev/null +++ b/mimilti/lms_pool.py @@ -0,0 +1,55 @@ +import datetime +from requests.exceptions import InvalidSchema + +from mimilti.cache_adapter import CacheAdapter, MimiSession +from mimilti.config import Config + + +class LmsRequestsPool: + # A limited number of sessions to increase performance + # (to reuse the basic TCP connection when connecting to each host), + # while each lms creates its own session (this does not make sense now, + # but it is useful for more flexible + # configuration of session parameters for each lms in the future) + + issuers = {} + session = MimiSession() + + default_token_expires_time = datetime.timedelta(minutes=30) + default_jwks_endpoint_expires_time = datetime.timedelta(hours=6) + + @classmethod + def start(cls, config: Config): + cls.issuers = config.get_issuers() + + # for iss, session in sessions.items(): + # session.mount(LtiConfig.get_token_url(iss), CacheAdapter(expires=3600)) + # session.mount(LtiConfig.get_jwks_endpoint(iss), CacheAdapter(expires=3600 * 5)) + + # Caching requests for access tokens and jwks endpoint + for issuer in cls.issuers: + # it is different for each lms, so far it is the standard value for moodle + + for tool in config.get_tools(issuer): + cls.session.mount( + config.get_token_url(issuer), + CacheAdapter(cls.default_token_expires_time), + ) + cls.session.mount( + tool.get_jwks_endpoint(), + CacheAdapter(cls.default_jwks_endpoint_expires_time), + ) + + pass + + @classmethod + def _refresh_session(cls, issuer: str): + pass + + @classmethod + def get_adapter(cls, url): + try: + adapter = LmsRequestsPool.session.get_adapter(url) + except InvalidSchema: + return None + return adapter diff --git a/mimilti/login.py b/mimilti/login.py new file mode 100644 index 0000000..0c1e411 --- /dev/null +++ b/mimilti/login.py @@ -0,0 +1,95 @@ +from urllib.parse import urlencode + +from mimilti.config import Config +from mimilti.jwk import LmsJwkClient +from mimilti.utils.jwt_utils import generate_nonce, generate_state +from mimilti.data_storage import SessionStorage +from mimilti.exceptions import ( + RequestIssuerNotTrustedError, + RequestStateError, + RequestNonceError, +) + + +class LtiRequestObject: + def __init__(self, request_data, session_service: SessionStorage, config: Config): + self._session_service = session_service + self._iss = request_data.get("iss", None) + if self._iss is not None and not config.is_trusted_issuer(self._iss): + raise RequestIssuerNotTrustedError(self._iss) + self._config = config + self._target_link_uri = request_data.get("target_link_uri", None) + self._login_hint = request_data.get("login_hint", None) + self._lti_message_hint = request_data.get("lti_message_hint", None) + self._client_id = request_data.get("client_id", None) + self._lti_deployment_id = request_data.get("lti_deployment_id", None) + self._state = request_data.get("state", None) + self._token = request_data.get("id_token", None) + self._jwk_client = LmsJwkClient(self._session_service, self._config) + + @staticmethod + def is_lti_request(request_data: dict): + return ( + "iss" in request_data + and "target_link_uri" in request_data + and "login_hint" in request_data + and "lti_message_hint" in request_data + and "client_id" in request_data + and "lti_deployment_id" in request_data + ) + + def get_target_link_uri(self) -> str | None: + return self._target_link_uri + + def get_issuer(self) -> str | None: + return self._iss + + def get_client_id(self) -> str | None: + return self._client_id + + def _get_auth_params(self) -> dict[str, str]: + nonce = generate_nonce() + self._session_service.save_param_to_session(nonce, "lti-nonce") + + state = generate_state() + self._session_service.save_param_to_session(state, "lti-state") + + # https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + return { + "scope": "openid", + "response_type": "id_token", + "client_id": self._client_id, + "redirect_uri": self._target_link_uri, + "login_hint": self._login_hint, + "lti_message_hint": self._lti_message_hint, + "state": state, + "response_mode": "form_post", + "nonce": nonce, + } + + def get_redirect_url(self) -> str: + base_auth_url = self._config.get_login_url(self._iss) + param_str = urlencode(self._get_auth_params()) + return base_auth_url + "?" + param_str + + def get_token(self) -> str: + state_validate_result = self._session_service.session_validate( + self._state, "lti-state" + ) + self._session_service.remove_param_from_session("lti-state") + + if not state_validate_result: + raise RequestStateError + + data = self._jwk_client.decode_jwt_token(self._token) + + nonce_validate_result = self._session_service.session_validate( + data["nonce"], "lti-nonce" + ) + + self._session_service.remove_param_from_session("lti-nonce") + + if not nonce_validate_result: + raise RequestNonceError + + return data diff --git a/mimilti/roles.py b/mimilti/roles.py new file mode 100644 index 0000000..6b9609c --- /dev/null +++ b/mimilti/roles.py @@ -0,0 +1,63 @@ +import functools +from typing import Callable +from abc import abstractmethod +from mimilti.data_storage import DataStorage + + +class Role(object): + privileges = tuple() + + @abstractmethod + def has_privileges(self, role_name): + pass + + +class ContextRole(Role): + privileges = tuple() + + @abstractmethod + def has_privileges(self, role_name): + pass + + +class ContextAdminRole(ContextRole): + privileges = ("Administrator",) + + def has_privileges(self, role_name): + return role_name in self.privileges + + +class ContextInstructorRole(ContextRole): + privileges = ContextAdminRole.privileges + ("Instructor",) + + def has_privileges(self, role_name): + return role_name in self.privileges + + +class LearnerRole(ContextRole): + privileges = ContextInstructorRole.privileges + ("Learner",) + + def has_privileges(self, role_name): + return role_name in self.has_privileges + + +class RoleService[R: Role]: + def __init__(self, data_service: DataStorage) -> None: + self._data_service = data_service + + def user_has_role_privileges(self, role: type[R]) -> bool: + return role().has_privileges(self._data_service.main_context_role_name) + + def lti_role_accepted[ + T, **P + ](self, role: type[R]) -> Callable[[Callable[P, T]], Callable[P, T]]: + def wraps(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + if not self.user_has_role_privileges(role): + return "You do not have permission to this action" + return func(*args, **kwargs) + + return wrapper + + return wraps diff --git a/mimilti/utils/__init__.py b/mimilti/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mimilti/utils/jwt_utils.py b/mimilti/utils/jwt_utils.py new file mode 100644 index 0000000..a6caa15 --- /dev/null +++ b/mimilti/utils/jwt_utils.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime + +import jwt + + +def generate_state() -> str: + return str(uuid.uuid4()) + + +def generate_nonce() -> str: + return uuid.uuid4().hex + uuid.uuid1().hex + + +def get_jwt_claim(iss: str, sub: str, aud: str) -> dict[str, str | int]: + return { + "iss": iss, + "sub": sub, + "aud": aud, + "iat": int(datetime.now().timestamp()), + "exp": int(datetime.now().timestamp()) + 60, + } + + +def encode_jwt(claims, private_key, headers) -> str: + return jwt.encode(claims, private_key, "RS256", headers) diff --git a/mimilti/utils/keygen.py b/mimilti/utils/keygen.py new file mode 100644 index 0000000..d689fd4 --- /dev/null +++ b/mimilti/utils/keygen.py @@ -0,0 +1,51 @@ +import os +import json +import click +from jwcrypto.jwk import JWK +from Crypto.PublicKey import RSA + + +def generate_keys(key_directory_path: str, jwk_directory_path: str) -> None: + os.makedirs(key_directory_path, exist_ok=True) + os.makedirs(jwk_directory_path, exist_ok=True) + + key = RSA.generate(4096) + + public_key = key.public_key().exportKey() + private_key = key.exportKey() + + public_key_path = os.path.join(key_directory_path, "public.key") + private_key_path = os.path.join(key_directory_path, "private.key") + + with open(public_key_path, "wb") as public, open(private_key_path, "wb") as private: + public.write(public_key) + private.write(private_key) + + jwk_obj = JWK.from_pem(public_key) + public_jwk = json.loads(jwk_obj.export_public()) + public_jwk["alg"] = "RS256" + public_jwk["use"] = "sig" + + public_json_path = os.path.join(jwk_directory_path, "jwk.json") + with open(public_json_path, "w") as file: + file.write(json.dumps(public_jwk)) + + +@click.command() +@click.option( + "--path", help="The path to the directory where to save the keys", required=True +) +@click.option( + "--jwk-path", + help="The path to the directory where to save json web key", + required=True, +) +def key_generator(path, jwk_path) -> None: + click.echo("Generating keys...") + generate_keys(path, jwk_path) + click.echo("Rsa key saved to {}".format(path)) + click.echo("JWK saved to {}".format(jwk_path)) + + +if __name__ == "__main__": + key_generator() diff --git a/tests/config/config.json b/tests/config/config.json new file mode 100644 index 0000000..dee5979 --- /dev/null +++ b/tests/config/config.json @@ -0,0 +1,13 @@ +{ + "kid": "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50", + "issuers": { + "http://localhost/moodle": { + "login_url": "http://localhost/moodle/mod/lti/auth.php", + "token_url": "http://localhost/moodle/mod/lti/token.php", + "tools": [{ + "aud": "asdasdasdfrfrfrfrfrfrfrfrre", + "jwks_endpoint": "http://localhost/moodle/mod/lti/certs.php"} + ] + } + } +} diff --git a/tests/config/private.key b/tests/config/private.key new file mode 100644 index 0000000..a27ac40 --- /dev/null +++ b/tests/config/private.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tests/config/public.key b/tests/config/public.key new file mode 100644 index 0000000..7c0ea41 --- /dev/null +++ b/tests/config/public.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo+HxDT6GfZgI5t0ovfd6 +Sn8Gh0+rxJ86Deun2Nz/EI1x64aO+saigXFoXI04KzoIE+CKUg9aGVPYEhKj59lY +5l4EQBkyRcs2nawKnQqQkqWDmCydkfMMgvYEQLzoQVkg+f6m2xpyfK3Q6OCh97+B +8U4Tx2jiuF1OCQnsVV1+AJ0JCascxG/3dzXHjTkFARuCztb2NzT6SbKnWpeTGPji +q3uZlwESpo4+nZpvc1GYdELGmVrJ5RGmqDrNRABlaoe+3FipIHnBoD6HUTCITHN3 +mXCWltjEeUiqFGphDccIK8Ek6GiSUxTNcTJvsW8uDPobg+5YhbG+G+6DtgPgeM/q +folxIjpxK4qBD5NlHbL6IU9rDzf9bcUXbwedvhmNJmBH91am9H3AVeBeFdO1wK8K +L9+L5o/6guHGelJk+Rn8eXlB8O+1d4ENQb963nOPS1NyOOPOdgqqfdFSPXEWfsvH +C3puG+Ad3D8bexrPX2XHRWavT9yJRbTvw+pWzFFIA+a3Dz1pba4VqGpOG08rLT+B +hM2XLy7lmaBssoCAniXGq913fD3Qndn8HAIVNzFJBMJfNYwP866frmMD6N3cdgk1 +zoQ5iu6G+HQzR/inybpbR7OiPHMQsiyu0HlJXJmt3N0qaBAAcHZym4UROO0e3fg5 +kqtP+jKxGXeGCkrAxNiXe1cCAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b6612e5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,11 @@ +import os +import pathlib + +from mimilti.config import RsaKey + + +config_folder = os.path.join(pathlib.Path(__file__).parent, "config") +rsa_public_path = os.path.join(config_folder, "public.key") +rsa_private_path = os.path.join(config_folder, "private.key") +config_path = os.path.join(config_folder, "config.json") +rsa_key = RsaKey(rsa_private_path, rsa_public_path) diff --git a/tests/test_data_service.py b/tests/test_data_service.py new file mode 100644 index 0000000..d5f287c --- /dev/null +++ b/tests/test_data_service.py @@ -0,0 +1,129 @@ +from mimilti.data_storage import SessionDataStorage + + +def test_lti_data_service(): + data_service = SessionDataStorage(lti_session={}) + + data_service.save_param_to_session(123, "param") + assert data_service.get_param("param") == 123 + assert data_service.session_validate(123, "param") + data_service.remove_param_from_session("param") + assert data_service.get_param("param") is None + + lti_data = { + "nonce": "519e0358a32b4fb9aeebadf2190cbaa181e21178cd2711ee900e752d331c1a83", + "iat": 1708128006, + "exp": 1708128066, + "iss": "http://localhost/moodle", + "aud": "qwewqwqeqwewqewq", + "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "4", + "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://127.0.0.1:9002/launch", + "sub": "2", + "https://purl.imsglobal.org/spec/lti/claim/lis": { + "person_sourcedid": "", + "course_section_sourcedid": "", + }, + "https://purl.imsglobal.org/spec/lti/claim/roles": [ + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator", + "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor", + "http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator", + ], + "https://purl.imsglobal.org/spec/lti/claim/context": { + "id": "2", + "label": "aaaa", + "title": "Aaaa", + "type": ["CourseSection"], + }, + "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest", + "https://purl.imsglobal.org/spec/lti/claim/resource_link": { + "title": "ad", + "description": "", + "id": "7", + }, + "https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome": { + "lis_result_sourcedid": '{"data":{"instanceid":"7","userid":"2","typeid":"4","launchid":295466899},' + '"hash":"4c8230bb7113f6410fc9664451b01ee0915b012e235d21323aaf8cf2081cd694"}', + "lis_outcome_service_url": "http://localhost/moodle/mod/lti/service.php", + }, + "given_name": "Admin", + "family_name": "User", + "name": "Admin User", + "https://purl.imsglobal.org/spec/lti/claim/ext": { + "user_username": "admin", + "lms": "moodle-2", + }, + "email": "eeuriset@gmail.com", + "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": { + "locale": "en", + "document_target": "window", + "return_url": "http://localhost/moodle/mod/lti/return.php?course=2&launch_container=4&instanceid=7" + "&sesskey=lcaBrFzpIX", + }, + "https://purl.imsglobal.org/spec/lti/claim/tool_platform": { + "product_family_code": "moodle", + "version": "2023100903", + "guid": "localhost", + "name": "moodle", + "description": "mooodle", + }, + "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0", + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { + "scope": [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/score", + ], + "lineitems": "http://localhost/moodle/mod/lti/services.php/2/lineitems?type_id=4", + "lineitem": "http://localhost/moodle/mod/lti/services.php/2/lineitems/11/lineitem?type_id=4", + }, + "https://purl.imsglobal.org/spec/lti/claim/custom": { + "context_memberships_url": "http://localhost/moodle/mod/lti/services.php/CourseSection/2/bindings/4" + "/memberships", + "system_setting_url": "http://localhost/moodle/mod/lti/services.php/tool/4/custom", + "context_setting_url": "http://localhost/moodle/mod/lti/services.php/CourseSection" + "/2/bindings/tool/4/custom", + "link_setting_url": "http://localhost/moodle/mod/lti/services.php/links/{link_id}/custom", + }, + "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice": { + "context_memberships_url": "http://localhost/moodle/mod/lti/services.php/CourseSection/2/bindings/4" + "/memberships", + "service_versions": ["1.0", "2.0"], + }, + } + data_service.update_params(lti_data) + assert data_service.iss == "http://localhost/moodle" + assert ( + data_service.lineitems + == "http://localhost/moodle/mod/lti/services.php/2/lineitems?type_id=4" + ) + + assert data_service.aud == "qwewqwqeqwewqewq" + data_service.aud = "bebra" + assert data_service.aud == "bebra" + + assert data_service.sub == "2" + data_service.sub = 3 + assert data_service.sub == 3 + + assert data_service.roles == { + "context": "Instructor", + "institution": "Administrator", + "system": "Administrator", + } + + assert data_service.context == { + "id": "2", + "label": "aaaa", + "title": "Aaaa", + "type": ["CourseSection"], + } + + data_service.update_params({"roles": {"context": "Administrator"}}) + assert data_service.main_context_role_name == "Administrator" + + data_service.update_params({"roles": {"context": "Instructor"}}) + assert data_service.main_context_role_name == "Instructor" + + data_service.update_params({"roles": {"context": "Learner"}}) + assert data_service.main_context_role_name == "Learner" diff --git a/tests/test_grade.py b/tests/test_grade.py new file mode 100644 index 0000000..9e7ab70 --- /dev/null +++ b/tests/test_grade.py @@ -0,0 +1,117 @@ +import datetime +import json + +from mimilti.config import Config +from mimilti.grade import GradeService +from mimilti.grade import LineItem, LineItemSchema +from mimilti.data_storage import SessionDataStorage +from test_config import rsa_key, config_path + +config = Config(config_path, rsa_key) + + +class FakeLMSClient: + def __init__(self, value): + self._data_service = SessionDataStorage( + lti_session={ + "lineitems": "", + "iss": "http://localhost/moodle", + "aud": "asdasdasdfrfrfrfrfrfrfrfrre", + } + ) + self.value = value + self._grade_service = GradeService(self._data_service, config) + + def json(self): + return self.value + + @property + def lms_client(self): + return self._grade_service.client + + @property + def grade_service(self): + return self._grade_service + + +def test_fake_refresh_lineitems(mocker): + time = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + lineitems1 = LineItem( + id="fake_id1", + label="fake_label1", + score_maximum=100, + resource_id="fake_rid1", + tag="fake_tag1", + resource_link_id="fake_rlid1", + lti_link_id="1", + start_date_time=time, + end_date_time=time, + ) + + lineitem_schema = LineItemSchema() + + value = [json.loads(lineitem_schema.dumps(lineitems1))] + fake_client = FakeLMSClient(value) + mocker.patch.object( + fake_client.lms_client, "send_request_to_lms", return_value=fake_client + ) + assert [lineitems1] == fake_client.grade_service.refresh_lineitems() + assert [lineitems1] == fake_client.grade_service.lineitems + + +def test_find_lineitem_by_resource_id(mocker): + time = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + lineitem1 = LineItem( + id="fake_id1", + label="fake_label1", + score_maximum=100, + resource_id="fake_rid1", + tag="fake_tag1", + resource_link_id="fake_rlid1", + lti_link_id="1", + start_date_time=time, + end_date_time=time, + ) + + lineitem2 = LineItem( + id="fake_id2", + label="fake_label2", + score_maximum=100, + resource_id="fake_rid2", + tag="fake_tag1", + resource_link_id="fake_rlid2", + lti_link_id="2", + start_date_time=time, + end_date_time=time, + ) + + lineitem_schema = LineItemSchema() + value = [ + json.loads(lineitem_schema.dumps(lineitem1)), + json.loads(lineitem_schema.dumps(lineitem2)), + ] + + fake_client = FakeLMSClient(value) + mocker.patch.object( + fake_client.lms_client, "send_request_to_lms", return_value=fake_client + ) + fake_client.grade_service.refresh_lineitems() + + lineitem_index_by_resource_id = ( + fake_client.grade_service.find_lineitem_index_by_resource_id("fake_rid1") + ) + nonexistent_lineitem_index_by_resource_id = ( + fake_client.grade_service.find_lineitem_index_by_resource_id("123123") + ) + + lineitems_by_tag = fake_client.grade_service.find_lineitems_by_tag("fake_tag1") + nonexistent_lineitem_by_tag = fake_client.grade_service.find_lineitems_by_tag( + "123123" + ) + + assert ( + lineitem1 == fake_client.grade_service.lineitems[lineitem_index_by_resource_id] + ) + assert nonexistent_lineitem_index_by_resource_id is None + assert lineitems_by_tag == [lineitem1, lineitem2] + assert nonexistent_lineitem_by_tag == [] diff --git a/tests/test_lti_config.py b/tests/test_lti_config.py new file mode 100644 index 0000000..52fb219 --- /dev/null +++ b/tests/test_lti_config.py @@ -0,0 +1,35 @@ +from mimilti.config import Config + +from test_config import rsa_key, config_path + + +def get_issuers(self) -> str: + return self._config.keys() + + +def test_config(): + config = Config(config_path, rsa_key) + + iss = "http://localhost/moodle" + + kid = "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50" + config_rsa_key, config_kid = config.get_keys_and_kid() + + assert rsa_key == config_rsa_key + assert kid == config_kid + + # aud not defined + non_existent_aud = "non_existent_aud" + tool = config.get_tool(iss, non_existent_aud) + + assert tool is None + + tool = config.get_tool(iss, "asdasdasdfrfrfrfrfrfrfrfrre") + + assert config.get_login_url(iss) == "http://localhost/moodle/mod/lti/auth.php" + assert config.get_token_url(iss) == "http://localhost/moodle/mod/lti/token.php" + assert tool.get_jwks_endpoint() == "http://localhost/moodle/mod/lti/certs.php" + assert tool.get_client_id() == "asdasdasdfrfrfrfrfrfrfrfrre" + + assert config.is_trusted_issuer(iss) + assert config.get_issuers() == ("http://localhost/moodle",) diff --git a/tests/test_mimisession.py b/tests/test_mimisession.py new file mode 100644 index 0000000..e80154d --- /dev/null +++ b/tests/test_mimisession.py @@ -0,0 +1,109 @@ +import datetime +import time +from unittest.mock import patch +import requests +from mimilti.cache_adapter import MimiSession, CacheAdapter, LruCache + + +def get_unique_session(): + from random import randbytes + + unique_response = requests.Response() + unique_response._content = randbytes(12) + unique_response.status_code = 200 + + return unique_response + + +@patch.object(requests.Session, "request") +def test_cache_send_request(mock_method): + mimisession = MimiSession() + mock_method.side_effect = lambda *args, **kwargs: get_unique_session() + + expires = datetime.timedelta(seconds=1) + mimisession.mount("https://8.8.8.8", CacheAdapter(expires=expires)) + response = mimisession.get("https://8.8.8.8") + + for i in range(1): + assert response is mimisession.get("https://8.8.8.8") + + time.sleep(expires.total_seconds()) + + new_response = mimisession.get("https://8.8.8.8") + assert response is not mimisession.get("https://8.8.8.8") + + assert new_response is mimisession.get("https://8.8.8.8") + + +def test_cache_adapter_prefix(): + mimisession = MimiSession() + + expires = datetime.timedelta(seconds=3) + adapter = CacheAdapter(expires=expires) + + mimisession.mount("https://8.8.8.8", adapter) + + google_response = mimisession.get("https://8.8.8.8") + google_bebra_response = mimisession.get("https://8.8.8.8/bebra") + + assert google_response is not google_bebra_response + + data = {"1": 1, "2": 2} + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + second_google_response = mimisession.post( + "https://8.8.8.8", data=data, headers=headers + ) + assert second_google_response is not google_response + + third_google_response = mimisession.post( + "https://8.8.8.8", headers=headers, data=data + ) + assert third_google_response is second_google_response + + del headers["Accept"] + fourth_google_response = mimisession.post( + "https://8.8.8.8", headers=headers, data=data + ) + assert fourth_google_response is not third_google_response + + +def test_lru_cache_size(): + max_size = 5 + cache = LruCache(max_size=max_size) + assert len(cache) == 0 + + @cache.ttl_lru_cache() + def numbers(n: int) -> int: + return n + + for i in range(5): + _ = numbers(i) + assert len(cache) == i + 1 + + _ = numbers(6) + _ = numbers(7) + _ = numbers(8) + + assert len(cache) == 5 + + cache.clear() + + def x2(x: int) -> int: + return 2 * x + + seconds = 1 + x2 = cache.ttl_lru_cache(datetime.timedelta(seconds=seconds))(x2) + x2(1) + assert len(cache) == 1 + x2(2) + assert len(cache) == 2 + time.sleep(seconds) + x2(1), x2(2) + assert len(cache) == 2 + cache.clear() + assert len(cache) == 0 From ffe16c4019ef133c56b3743ecc834ed11fc32247 Mon Sep 17 00:00:00 2001 From: MinyazevR Date: Sun, 10 Mar 2024 18:11:11 +0300 Subject: [PATCH 2/3] Added a class for easier grading --- examples/app.py | 19 +++++++++++- examples/controllers.py | 65 ++++++++++++++++++++++++++++++++--------- mimilti/jwk.py | 5 +++- mimilti/lms_pool.py | 1 + 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/examples/app.py b/examples/app.py index 351ce2b..4510552 100644 --- a/examples/app.py +++ b/examples/app.py @@ -1,7 +1,16 @@ import os from flask import Flask -from controllers import login, launch, get_jwks, create_test, get_grade, set_grade +from controllers import ( + login, + launch, + get_jwks, + create_test, + get_grade, + set_grade, + get_current_grade, + set_grade_with_current_lineitem, +) app = Flask(__name__) @@ -15,6 +24,14 @@ app.add_url_rule("/set_grade", methods=["GET", "POST"], view_func=set_grade) app.add_url_rule("/create_test", methods=["GET", "POST"], view_func=create_test) app.add_url_rule("/get_grade", methods=["GET", "POST"], view_func=get_grade) +app.add_url_rule( + "/get_current_grade", methods=["GET", "POST"], view_func=get_current_grade +) +app.add_url_rule( + "/set_grade_with_current_lineitem", + methods=["GET", "POST"], + view_func=set_grade_with_current_lineitem, +) @app.route("/") diff --git a/examples/controllers.py b/examples/controllers.py index bc5584d..0b48d50 100644 --- a/examples/controllers.py +++ b/examples/controllers.py @@ -1,5 +1,7 @@ +import functools import os import pathlib +from collections.abc import Callable from datetime import datetime, timedelta from flask import ( @@ -11,7 +13,7 @@ ) -from mimilti.grade import LineItem, GradeService, Progress +from mimilti.grade import LineItem, GradeService, Progress, CompletedFullyGradedProgress from mimilti.login import LtiRequestObject from mimilti.data_storage import SessionDataStorage from mimilti.config import Config, RsaKey @@ -30,8 +32,29 @@ LmsRequestsPool.start(config) -def get_lti_request_object(): - pass +def once[ + T, **P +](func: Callable[(P.args, P.kwargs), T]) -> Callable[(P.args, P.kwargs), T]: + is_called = False + result = None + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + nonlocal is_called + nonlocal result + if not is_called: + is_called = True + result = func(*args, **kwargs) + return func(*args, **kwargs) + else: + return result + + return wrapper + + +@once +def get_grade_service(): + return GradeService(SessionDataStorage(), config, refresh=True) def get_jwks(): @@ -80,7 +103,7 @@ def launch(): # you login logic if (next_url := session_data_service.get_param("next_url")) is None: - return redirect(url_for("index")) + return redirect(url_for("create_test")) else: session_data_service.remove_param_from_session("next_url") @@ -118,30 +141,44 @@ def create_test(): ) grade_service.create_or_set_lineitem(lineitems) - return "" + return redirect(url_for("get_grade")) def get_grade(): guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11" - data_service = SessionDataStorage() - grade_service = GradeService(data_service, config) + grade_service = get_grade_service() print(grade_service.get_grade(guid)) return "" +def get_current_grade(): + grade_service = get_grade_service() + print(grade_service.get_grade()) + return "" + + def set_grade(): - data_service = SessionDataStorage() guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11" - grade_service = GradeService(data_service, config) - progress = Progress( - score_given=50, + grade_service = get_grade_service() + progress = CompletedFullyGradedProgress( + score_given=60, score_maximum=100, - activity_progress="Completed", - grading_progress="FullyGraded", - user_id=data_service.sub, comment="comment", timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), ) grade_service.set_grade(progress, guid) return "" + + +def set_grade_with_current_lineitem(): + grade_service = get_grade_service() + progress = CompletedFullyGradedProgress( + score_given=60, + score_maximum=100, + comment="comment", + timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + grade_service.set_grade(progress) + return "" diff --git a/mimilti/jwk.py b/mimilti/jwk.py index f6f2b2a..a8ea31a 100644 --- a/mimilti/jwk.py +++ b/mimilti/jwk.py @@ -43,7 +43,10 @@ def make_user(self, data, **kwargs): class LmsJwkClient: KeySchema = KeySchema() - result_expires_time = LmsRequestsPool.default_jwks_endpoint_expires_time - timedelta(seconds=20) + # The magic constant is 5 to ensure the relevance of the results + result_expires_time = ( + LmsRequestsPool.default_jwks_endpoint_expires_time - timedelta(seconds=5) + ) def __init__(self, data_service, config: Config): self._data_service = data_service diff --git a/mimilti/lms_pool.py b/mimilti/lms_pool.py index f9a3b71..60fb398 100644 --- a/mimilti/lms_pool.py +++ b/mimilti/lms_pool.py @@ -15,6 +15,7 @@ class LmsRequestsPool: issuers = {} session = MimiSession() + # So far, the values are selected for fun, a more flexible solution is to configure depending on a specific lms default_token_expires_time = datetime.timedelta(minutes=30) default_jwks_endpoint_expires_time = datetime.timedelta(hours=6) From 4c17e4bfded189bb6053c127b5beb0fac70fb493 Mon Sep 17 00:00:00 2001 From: MinyazevR <89993880+MinyazevR@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:12:56 +0300 Subject: [PATCH 3/3] Fix once decorator --- examples/controllers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/controllers.py b/examples/controllers.py index 0b48d50..d84e827 100644 --- a/examples/controllers.py +++ b/examples/controllers.py @@ -45,7 +45,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: if not is_called: is_called = True result = func(*args, **kwargs) - return func(*args, **kwargs) + return result else: return result