From 3f54ed4a1882bbdf942bcd5452a1451f4f117d8c Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:19:21 +0500 Subject: [PATCH] feat: add mitxpro-social-auth (#376) * feat: add mitxpro-social-auth * fix: github CI * fix: test * fix: precommit errors * fix: Update and rename README.md to README.rst * fix: issues * fix: issues * fix: issues * fix: fixed install dependencies issue --- .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 4 +- poetry.lock | 91 ++++++++++++++++++++++- pyproject.toml | 1 + run_devstack_integration_tests.sh | 1 + src/ol_social_auth/BUILD | 21 ++++++ src/ol_social_auth/LICENSE | 28 +++++++ src/ol_social_auth/README.rst | 52 +++++++++++++ src/ol_social_auth/__init__.py | 0 src/ol_social_auth/backends.py | 63 ++++++++++++++++ src/ol_social_auth/tests/__init__.py | 0 src/ol_social_auth/tests/backends_test.py | 78 +++++++++++++++++++ src/ol_social_auth/tests/conftest.py | 11 +++ src/openedx_companion_auth/LICENSE | 3 +- 14 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/ol_social_auth/BUILD create mode 100644 src/ol_social_auth/LICENSE create mode 100644 src/ol_social_auth/README.rst create mode 100644 src/ol_social_auth/__init__.py create mode 100644 src/ol_social_auth/backends.py create mode 100644 src/ol_social_auth/tests/__init__.py create mode 100644 src/ol_social_auth/tests/backends_test.py create mode 100644 src/ol_social_auth/tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29103363..fcd0e055 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: DIRECTORY="tutor" DEV="tutor_dev" fi - EDX_WORKSPACE=$PWD/.. docker-compose -f /home/runner/.local/share/$DIRECTORY/env/local/docker-compose.yml -f /home/runner/.local/share/$DIRECTORY/env/dev/docker-compose.yml --project-name $DEV run -v $PWD/../open-edx-plugins:/open-edx-plugins lms /open-edx-plugins/run_devstack_integration_tests.sh + EDX_WORKSPACE=$PWD/.. docker compose -f /home/runner/.local/share/$DIRECTORY/env/local/docker-compose.yml -f /home/runner/.local/share/$DIRECTORY/env/dev/docker-compose.yml --project-name $DEV run -v $PWD/../open-edx-plugins:/open-edx-plugins lms /open-edx-plugins/run_devstack_integration_tests.sh - name: Upload coverage to CodeCov uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5793186..da966a91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,14 +45,14 @@ repos: - id: ruff args: [--extend-ignore=D1, --fix] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.2 hooks: - id: mypy additional_dependencies: - types-pytz - types-requests - - types-pkg_resources - types-python-dateutil + - types-setuptools - repo: https://github.com/rhysd/actionlint rev: v1.7.1 hooks: diff --git a/poetry.lock b/poetry.lock index b21a64aa..bf42a20e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -469,6 +469,17 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "deprecated" version = "1.2.14" @@ -1342,6 +1353,22 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "opentelemetry-api" version = "1.26.0" @@ -1893,6 +1920,24 @@ files = [ {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, ] +[[package]] +name = "python3-openid" +version = "3.2.0" +description = "OpenID support for modern servers and consumers." +optional = false +python-versions = "*" +files = [ + {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, + {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, +] + +[package.dependencies] +defusedxml = "*" + +[package.extras] +mysql = ["mysql-connector-python"] +postgresql = ["psycopg2"] + [[package]] name = "pytz" version = "2024.1" @@ -2015,6 +2060,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -2313,6 +2376,32 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] +[[package]] +name = "social-auth-core" +version = "4.5.4" +description = "Python social authentication made simple." +optional = false +python-versions = ">=3.8" +files = [ + {file = "social-auth-core-4.5.4.tar.gz", hash = "sha256:d3dbeb0999ffd0e68aa4bd73f2ac698a18133fd11b3fc890e1366f18c8889fac"}, + {file = "social_auth_core-4.5.4-py3-none-any.whl", hash = "sha256:33cf970a623c442376f9d4a86fb187579e4438649daa5b5be993d05e74d7b2db"}, +] + +[package.dependencies] +cryptography = ">=1.4" +defusedxml = ">=0.5.0rc1" +oauthlib = ">=1.0.3" +PyJWT = ">=2.7.0" +python3-openid = ">=3.0.10" +requests = ">=2.9.1" +requests-oauthlib = ">=0.6.1" + +[package.extras] +all = ["cryptography (>=2.1.1)", "python3-saml (>=1.5.0)"] +allpy3 = ["cryptography (>=2.1.1)", "python3-saml (>=1.5.0)"] +azuread = ["cryptography (>=2.1.1)"] +saml = ["python3-saml (>=1.5.0)"] + [[package]] name = "sqlparse" version = "0.5.0" @@ -2608,4 +2697,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "a23ed4102f9675c5523082575dfdcfdd8766324ff22e5e500fb22342da827cbd" +content-hash = "a905be86024acd93929c928a4919fa03831a91a979deb5a1ab9f7c46ed729946" diff --git a/pyproject.toml b/pyproject.toml index d947c619..7f966f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ gitpython = "^3.1.37" python-json-logger = "^2.0.2" sentry-sdk = "^2.0.0" XBlock = "*" +social-auth-core = "^4.5.4" opentelemetry-distro = "*" opentelemetry-instrumentation-django = "*" opentelemetry-exporter-richconsole = "*" diff --git a/run_devstack_integration_tests.sh b/run_devstack_integration_tests.sh index 9562d897..e060b050 100755 --- a/run_devstack_integration_tests.sh +++ b/run_devstack_integration_tests.sh @@ -21,6 +21,7 @@ cd /open-edx-plugins # Installing test dependencies pip install pytest-mock==3.14.0 +pip install responses==0.25.3 # Plugins that may affect the tests of other plugins. # e.g. openedx-companion-auth adds a redirect to the authentication diff --git a/src/ol_social_auth/BUILD b/src/ol_social_auth/BUILD new file mode 100644 index 00000000..7e0f415d --- /dev/null +++ b/src/ol_social_auth/BUILD @@ -0,0 +1,21 @@ +python_sources( + name="ol_social_auth_source", + dependencies=["//:external_dependencies#social-auth-core"] +) + +python_distribution( + name="ol_social_auth_package", + dependencies=[ + ":ol_social_auth_source", + ], + provides=setup_py( + name="ol-social-auth", + version="0.1.0", + description="An Open edX plugin implementing MIT social auth backend", + license="BSD-3-Clause", + author="MIT Office of Digital Learning", + include_package_data=True, + zip_safe=False, + keywords="Python edx" + ), +) diff --git a/src/ol_social_auth/LICENSE b/src/ol_social_auth/LICENSE new file mode 100644 index 00000000..83284fb7 --- /dev/null +++ b/src/ol_social_auth/LICENSE @@ -0,0 +1,28 @@ +Copyright (C) 2022 MIT Open Learning + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/ol_social_auth/README.rst b/src/ol_social_auth/README.rst new file mode 100644 index 00000000..ac239dd4 --- /dev/null +++ b/src/ol_social_auth/README.rst @@ -0,0 +1,52 @@ +Open Learning Social Auth +======================= + +An Open edX plugin implementing MIT social auth backend + +Version Compatibility +--------------------- + +Compatible with all edx releases + +Installing The Plugin +--------------------- + +You can install this plugin into any Open edX instance by using any of the following methods: + +**Option 1: Install from PyPI** + +.. code-block:: + + # If running devstack in docker, first open a shell in LMS (make lms-shell) + + pip install ol-social-auth + + +**Option 2: Build the package locally and install it** + +Follow these steps in a terminal on your machine: + +1. Navigate to ``open-edx-plugins`` directory +2. If you haven't done so already, run ``./pants build`` +3. Run ``./pants package ::``. This will create a "dist" directory inside "open-edx-plugins" directory with ".whl" & ".tar.gz" format packages for all plugins in the src directory +4. Move/copy any of the ".whl" or ".tar.gz" files for this plugin that were generated in the above step to the machine/container running Open edX (NOTE: If running devstack via Docker, you can use ``docker cp`` to copy these files into your LMS or CMS containers) +5. Run a shell in the machine/container running Open edX, and install this plugin using pip + + +``Note``: In some cases you might need to restart edx-platform after installing the plugin to reflect the changes. + +Configurations +-------------- +This section outlines the steps for integrating your application with ol-social-auth for various deployment scenarios. Please refer to the corresponding documentation for detailed instructions. + +* **MITxPRO:** To configure ol-social-auth with MITxPRO, follow the comprehensive guide available `here `_ +* **MITxOnline:** For integration with MITxOnline, detailed instructions can be found in the official documentation `here `_ +* **MITxOnline with Tutor:** If you're using MITxOnline with Tutor for development purposes, specific configuration steps are outlined in the `documentation `_ + + +How to use +---------- +Make sure to properly configure the plugin following the links in the above "Configurations" section before use. +* Install the plugin in the lms following the installation steps above. +* Verify that you are not logged in on edx-platform. +* Create a new user in your MIT application and verify that a corresponding user is successfully created on the edX platform. diff --git a/src/ol_social_auth/__init__.py b/src/ol_social_auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ol_social_auth/backends.py b/src/ol_social_auth/backends.py new file mode 100644 index 00000000..03eef615 --- /dev/null +++ b/src/ol_social_auth/backends.py @@ -0,0 +1,63 @@ +"""MIT xPro social auth backend""" + +from social_core.backends.oauth import BaseOAuth2 + + +class MITxProOAuth2(BaseOAuth2): + """MIT xPro social auth backend""" + + name = "mitxpro-oauth2" + + ID_KEY = "username" + REQUIRES_EMAIL_VALIDATION = False + + ACCESS_TOKEN_METHOD = "POST" # noqa: S105 + + # at a minimum we need to be able to read the user + DEFAULT_SCOPE = ["user:read"] + + def authorization_url(self): + """Provides authorization_url from settings""" # noqa: D401 + return self.setting("AUTHORIZATION_URL") + + def access_token_url(self): + """Provides access_token_url from settings""" # noqa: D401 + return self.setting("ACCESS_TOKEN_URL") + + def api_root(self): + """Returns the API root as configured""" # noqa: D401 + root = self.setting("API_ROOT") + + if root and root[-1] != "/": + root = f"{root}/" + + return root + + def auth_html(self): # pragma: no cover + """No html for this provider""" + # NOTE: this is only here to stop the pylint warning about this abstract + # method not being overridden without disabling it for the entire class + return "" + + def api_url(self, path): + """ + Returns the full api url given a relative path + + Args: + path (str): relative api path + """ # noqa: D401 + return f"{self.api_root()}{path}" + + def get_user_details(self, response): + """Return user details from xPro account""" + return { + "username": response.get("username"), + "email": response.get("email", ""), + "name": response.get("name", ""), + } + + def user_data(self, access_token, *args, **kwargs): # noqa: ARG002 + """Loads user data from xpro""" # noqa: D401 + url = self.api_url("api/users/me") + headers = {"Authorization": f"Bearer {access_token}"} + return self.get_json(url, headers=headers) diff --git a/src/ol_social_auth/tests/__init__.py b/src/ol_social_auth/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ol_social_auth/tests/backends_test.py b/src/ol_social_auth/tests/backends_test.py new file mode 100644 index 00000000..04944a7e --- /dev/null +++ b/src/ol_social_auth/tests/backends_test.py @@ -0,0 +1,78 @@ +"""Tests for our backend""" + +from urllib.parse import urljoin + +import pytest +from ol_social_auth.backends import MITxProOAuth2 + +# pylint: disable=redefined-outer-name + + +@pytest.fixture() +def strategy(mocker): + """Mock strategy""" + return mocker.Mock() + + +@pytest.fixture() +def backend(strategy): + """MITxProOAuth2 backend fixture""" + return MITxProOAuth2(strategy) + + +@pytest.mark.parametrize( + "response, expected", # noqa: PT006 + [ + ( + {"username": "abc123", "email": "user@example.com", "name": "Jane Doe"}, + {"username": "abc123", "email": "user@example.com", "name": "Jane Doe"}, + ), + ({"username": "abc123"}, {"username": "abc123", "email": "", "name": ""}), + ], +) +def test_get_user_details(backend, response, expected): + """Test that get_user_details produces expected results""" + assert backend.get_user_details(response) == expected # noqa: S101 + + +def test_user_data(backend, strategy, mocked_responses): + """Tests that the backend makes a correct appropriate request""" + access_token = "user_token" # noqa: S105 + api_root = "http://xpro.example.com/" + response = {"username": "abc123", "email": "user@example.com", "name": "Jane Doe"} + + mocked_responses.add( + mocked_responses.GET, urljoin(api_root, "/api/users/me"), json=response + ) + settings = {"API_ROOT": api_root} + + def _setting(name, *, backend, default=None): # pylint: disable=unused-argument # noqa: ARG001 + """Dummy setting func""" # noqa: D401 + return settings.get(name, default) + + strategy.setting.side_effect = _setting + + assert backend.user_data(access_token) == response # noqa: S101 + + request, _ = mocked_responses.calls[0] + + assert request.headers["Authorization"] == "Bearer user_token" # noqa: S101 + strategy.setting.assert_any_call("API_ROOT", default=None, backend=backend) + + +def test_authorization_url(backend, strategy): + """Test authorization_url()""" + strategy.setting.return_value = "abc" + assert backend.authorization_url() == "abc" # noqa: S101 + strategy.setting.assert_called_once_with( + "AUTHORIZATION_URL", default=None, backend=backend + ) + + +def test_access_token_url(backend, strategy): + """Test access_token_url()""" + strategy.setting.return_value = "abc" + assert backend.access_token_url() == "abc" # noqa: S101 + strategy.setting.assert_called_once_with( + "ACCESS_TOKEN_URL", default=None, backend=backend + ) diff --git a/src/ol_social_auth/tests/conftest.py b/src/ol_social_auth/tests/conftest.py new file mode 100644 index 00000000..520e55c3 --- /dev/null +++ b/src/ol_social_auth/tests/conftest.py @@ -0,0 +1,11 @@ +"""Common test configuration""" + +import pytest +import responses + + +@pytest.fixture() +def mocked_responses(): + """Mock requests responses""" + with responses.RequestsMock() as rsps: + yield rsps diff --git a/src/openedx_companion_auth/LICENSE b/src/openedx_companion_auth/LICENSE index 7ab0bf72..83284fb7 100644 --- a/src/openedx_companion_auth/LICENSE +++ b/src/openedx_companion_auth/LICENSE @@ -1,6 +1,5 @@ -BSD 3-Clause License +Copyright (C) 2022 MIT Open Learning -Copyright (c) 2017, MIT Office of Digital Learning All rights reserved. Redistribution and use in source and binary forms, with or without