diff --git a/.meta.toml b/.meta.toml index 41a9f49..e52317c 100644 --- a/.meta.toml +++ b/.meta.toml @@ -10,7 +10,7 @@ codespell_skip = "*.min.js,*.pot,*.po,*.yaml,*.json" codespell_ignores = "vew" dependencies_ignores = "['plone.restapi', 'plone.volto', 'zestreleaser.towncrier', 'zest.releaser', 'pytest', 'pytest-cov', 'pytest-plone', 'pytest-docker', 'pytest-vcr', 'pytest-mock', 'gocept.pytestlayer', 'requests-mock', 'vcrpy']" dependencies_mappings = [ - "Plone = ['Products.CMFPlone', 'Products.CMFCore', 'Products.GenericSetup', 'Products.PluggableAuthService']", + "Plone = ['Products.CMFPlone', 'Products.CMFCore', 'Products.GenericSetup', 'Products.PluggableAuthService', 'Products.PlonePAS']", ] check_manifest_ignores = """ "news/*", diff --git a/DEVELOP.md b/DEVELOP.md deleted file mode 100644 index fe80117..0000000 --- a/DEVELOP.md +++ /dev/null @@ -1,36 +0,0 @@ -## Local Development - -You need a working `python` environment (system, `virtualenv`, `pyenv`, etc) version 3.8 or superior. - -Then install the dependencies and a development instance using: - -```bash -make build -``` -### Update translations - -```bash -make i18n -``` - -### Format codebase - -```bash -make format -``` - -### Run tests - -Testing of this package is done with [`pytest`](https://docs.pytest.org/) and [`tox`](https://tox.wiki/). - -Run all tests with: - -```bash -make test -``` - -Run all tests but stop on the first error and open a `pdb` session: - -```bash -./bin/tox -e test -- -x --pdb -``` diff --git a/Makefile b/Makefile index c719aa5..6bf30b0 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ RESET=`tput sgr0` YELLOW=`tput setaf 3` BACKEND_FOLDER=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +COMPOSE_FOLDER=${BACKEND_FOLDER}/tests DOCS_DIR=${BACKEND_FOLDER}/docs # Python checks @@ -128,6 +129,22 @@ test: bin/tox ## run tests test-coverage: bin/tox ## run tests bin/tox -e coverage +# Keycloak +.PHONY: keycloak-start +keycloak-start: ## Start Keycloak stack + @echo "$(GREEN)==> Start keycloak stack$(RESET)" + @docker compose -f $(COMPOSE_FOLDER)/docker-compose.yml up -d + +.PHONY: keycloak-status +keycloak-status: ## Check Keycloak stack status + @echo "$(GREEN)==> Check Keycloak stack status$(RESET)" + @docker compose -f $(COMPOSE_FOLDER)/docker-compose.yml ps + +.PHONY: keycloak-stop +keycloak-stop: ## Stop Keycloak stack + @echo "$(GREEN)==> Stop Keycloak stack$(RESET)" + @docker compose -f $(COMPOSE_FOLDER)/docker-compose.yml down + # Docs bin/sphinx-build: bin/pip bin/pip install -r requirements-docs.txt diff --git a/README.md b/README.md index f201d0d..777438b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
logo
+
logo

pas.plugins.oidc

@@ -74,12 +74,21 @@ Pay attention to the customization of `User info property used as userid` field, ### Login and Logout URLs +#### Default UI (Volto) + +When using this plugin with a [Volto frontend](https://6.docs.plone.org/volto/index.html), please install [@plone-collective/volto-authomatic](https://github.com/collective/volto-authomatic) add-on on your frontend project. + +* **Login URL**: ``/login +* **Logout URL**: ``/logout + +Also, on the OpenID provider, configure the Redirect URL as **``/login_oidc/oidc**. + + +#### Classic UI + When using this plugin with *Plone 6 Classic UI* the standard URLs used for login (`http://localhost:8080/Plone/login`) and logout (`http://localhost:8080/Plone/logout`) will not trigger the usage of the plugin. -When using this plugin with a [Volto frontend](https://6.docs.plone.org/volto/index.html) the standard URLs for login (`http://localhost:3000/login`) -and logout (`http://localhost:3000/logout`) will not trigger the usage of the plugin. - To login into a site using the OIDC provider, you will need to change those login URLs to the following: * **Login URL**: /``/acl_users/``/login @@ -91,99 +100,50 @@ To login into a site using the OIDC provider, you will need to change those logi * `oidc pas plugin id`: is the id you gave to the OIDC plugin when you created it inside the Plone PAS administration panel. If you just used the default configuration and installed this plugin using Plone's Add-on Control Panel, this id will be `oidc`. -When using Volto as a frontend, you need to expose those login and logout URLs somehow to make the login and logout process work. - - ### Example setup with Keycloak -##### Setup Keycloak as server +The `pas.plugins.oidc` repository has a working setup for a `Keycloak` development server using `Docker` and `Docker Compose`. To use it, in a terminal, run the command: -Please refer to the [Keycloak documentation](https://www.keycloak.org/documentation>) for up to date instructions. -Specifically, here we will use a Docker image, so follow the instructions on how to [get started with Keycloak on Docker](https://www.keycloak.org/getting-started/getting-started-docker). +#### Start-up + +```bash +make keycloak-start +``` This does **not** give you a production setup, but it is fine for local development. -**Note:** Keycloak runs on port `8080` by default. Plone uses the same port. When you are reading this, you probably know how to let Plone use a different port. -So let's indeed let Keycloak use its preferred port. At the moment of writing, this is how you start a Keycloak container: +This command will use the [`docker-compose.yml`](./tests/docker-compose.yml) file available in the `tests` directory. -```shell -docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:19.0.3 start-dev -``` -The plugin can be used with legacy (deprecated) Keycloak `redirect_uri` parameter. To use this you need to enable the option -in the plugin configuration. To test that you can run the Keycloak server with the `--spi-login-protocol-openid-connect-legacy-logout-redirect-uri=true` -option: +#### Manage Keycloak -```shell -docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:19.0.3 start-dev --spi-login-protocol-openid-connect-legacy-logout-redirect-uri=true -``` +After start up, Keycloak will be accessible on [http://127.0.0.1:8180](http://127.0.0.1:8180), and you can manage it with the following credentials: -**Note:** when you exit this container, it still exists and you can restart it so you don't lose your configuration. -With `docker ps -a` figure out the name of the container and then use `docker container start -ai `. - -Follow the Keycloak Docker documentation further: - -* Open the [Keycloak Admin Console](http://localhost:8080/admin), make sure you are logged in as `admin`. -* Click the word `master` in the top-left corner, then click `Create Realm`. -* Enter *plone* in the `Realm name` field. -* Click `Create`. -* Click the word `master` in the top-left corner, then click `plone`. -* Click `Manage` -> `Users` in the left-hand menu. -* Click `Create new user`. -* Remember to set a password for this user in the `Credentials` tab. -* Open a different browser and check that you can login to [Keycloak Account Console](http://localhost:8080/realms/plone/account) with this user. - -In the original browser, follow the steps for securing your first app. -But we will be using different settings for Plone. -And when last I checked, the actual UI differed from the documentation. - -So: -* Open the [Keycloak Admin Console](http://localhost:8080/admin), make sure you are logged in as `admin`. -* Click the word `master` in the top-left corner, then click `plone`. -* Click `Manage` -> `Clients` in the left-hand menu. -* Click `Create client`: - * `Client type`: *OpenID Connect* - * `Client ID`: *plone* - * Turn `Always display in console` to `On`, *Useful for testing*. - * Click `Next` and click `Save`. -* Now you can fill in the `Settings` -> `Access settings`. We will assume Plone runs on port `8081`: - * `Root URL`: `http://localhost:8081/Plone/` - * `Home URL`: `http://localhost:8081/Plone/` - * `Valid redirect URIs`: `http://localhost:8081/Plone*` - **Tip:** Leave the rest at the defaults, unless you know what you are doing. -* Now you can fill in the `Settings` -> `Capability config`. - * Turn `Client authentication` to `On`. This defines the type of the OIDC client. When it's ON, the - OIDC type is set to confidential access type. When it's OFF, it is set to public access type. - * Click `Save`. -* Now you can access `Credentials` -> `Client secret` and click on the clipboard icon to copy it. This will - be necessary to configure the plugin in Plone. +* **username**: admin +* **password**: admin -**Keycloak is ready done configured!** +#### Realms -#### Setup Plone as a client +There are two realms configured `plone` and `plone-test`. The later is used in automated tests, while the former should be used for your development environment. -* In your Zope instance configuration, make sure Plone runs on port 8081. -* Make sure [pas.plugins.oidc` is installed with `pip `_ or `Buildout](https://www.buildout.org/). -* Start Plone and create a Plone site with id Plone. -* In the Add-ons control panel, install `pas.plugins.oidc`. -* In the ZMI go to the plugin properties at http://localhost:8081/Plone/acl_users/oidc/manage_propertiesForm -* Set these properties: - * `OIDC/Oauth2 Issuer`: http://localhost:8080/realms/plone/ - * `Client ID`: *plone* (**Warning:** This property must match the `Client ID` you have set in Keycloak.) - * `Client secret`: *••••••••••••••••••••••••••••••••* (**Warning:** This property must match the `Client secret` you have get in Keycloak.) - * `Use deprecated redirect_uri for logout url(/Plone/acl_users/oidc/logout)` checked. Use this if you need to run old versions of Keycloak. - * `Open ID scopes to request to the server`: this depends on which version of Keycloak you are using, and which scopes are available there. - In recent Keycloak versions, you *must* include `openid` as scope. - Suggestion is to use `openid` and `profile`. - * **Tip:** Leave the rest at the defaults, unless you know what you are doing. - * Click `Save`. +The `plone` realm ships with an user that has the following credentials: -**Plone is ready done configured!** +* username: **user** +* password: **12345678** -See this screenshot: +And, to configure the oidc plugins, please use: -.. image:: docs/screenshot-settings.png +* client id: **plone** +* client secret: **12345678** -**Warning:** +#### Stop Keycloak + +To stop a running `Keycloak` (needed when running tests), use: + +```bash +make keycloak-stop +``` + +#### Warning Attention, before Keycloak 18, the parameter for logout was `redirect_uri` and it has been deprecated since version 18. But the Keycloak server can run with the `redirect_uri` if needed, it is possible to use the plugin with the legacy `redirect_uri` @@ -205,6 +165,34 @@ So, for Keycloak, it does not matter if we use the default or legacy mode if the * The plugin will work only if the `Use deprecated redirect_uri for logout url(/Plone/acl_users/oidc/logout)` option is un-checked at the plugin properties at http://localhost:8081/Plone/acl_users/oidc/manage_propertiesForm. +#### Additional Documentation + +Please refer to the [Keycloak documentation](https://www.keycloak.org/documentation>) for up to date instructions. +Specifically, here we will use a Docker image, so follow the instructions on how to [get started with Keycloak on Docker](https://www.keycloak.org/getting-started/getting-started-docker). + +#### Setup Plone as a client + +* Make sure **pas.plugins.oidc** is installed. +* Start Plone and create a Plone site with id Plone. +* In the Add-ons control panel, install `pas.plugins.oidc`. +* In the ZMI go to the plugin properties at http://localhost:8081/Plone/acl_users/oidc/manage_propertiesForm +* Set these properties: + * `OIDC/Oauth2 Issuer`: http://127.0.0.1:8081/realms/plone/ + * `Client ID`: *plone* (**Warning:** This property must match the `Client ID` you have set in Keycloak.) + * `Client secret`: *12345678* (**Warning:** This property must match the `Client secret` you have get in Keycloak.) + * `Use deprecated redirect_uri for logout url` checked. Use this if you need to run old versions of Keycloak. + * `Open ID scopes to request to the server`: this depends on which version of Keycloak you are using, and which scopes are available there. + In recent Keycloak versions, you *must* include `openid` as scope. + Suggestion is to use `openid` and `profile`. + * **Tip:** Leave the rest at the defaults, unless you know what you are doing. + * Click `Save`. + +**Plone is ready done configured!** + +See this screenshot: + +.. image:: docs/screenshot-settings.png + #### Login Go to the other browser, or logout as admin from [Keycloak Admin Console](http://localhost:8080/admin). @@ -222,7 +210,9 @@ Currently, the Plone logout form is unchanged. Instead, for testing go to the logout page of the plugin: http://localhost:8081/Plone/acl_users/oidc/logout, this will take you to Keycloak to logout, and then return to the post-logout redirect URL. -## Usage of sessions in the login process +## Technical Decisions + +### Usage of sessions in the login process This plugin uses sessions during the login process to identify the user while he goes to the OIDC provider and comes back from there. @@ -237,13 +227,13 @@ The plugin has 2 ways of working with sessions: - Use the cookie-based session management: if the `Use Zope session data manager` option in the plugin configuration is disabled, the plugin will use a Cookie to save that information in the client's browser. -## Settings in environment variables +### Settings in environment variables Optionally, instead of editing your OIDC provider settings through the ZMI, you can use [collective.regenv](https://pypi.org/project/collective.regenv/) and provide a `YAML` file with your settings. This is very useful if you have different settings in different environments and you do not want to edit the settings each time you move the contents. -## Varnish +### Varnish Optionally, if you are using the [Varnish caching server](https://6.docs.plone.org/glossary.html#term-Varnish) in front of Plone, you may see this plugin only partially working. Especially the `came_from` parameter may be ignored. @@ -259,6 +249,79 @@ Check what the current default is in the buildout recipe, and update it: - Issue Tracker: https://github.com/collective/pas.plugins.oidc/issues - Source Code: https://github.com/collective/pas.plugins.oidc +### Local Development Setup + +You need a working `python` environment (system, `virtualenv`, `pyenv`, etc) version 3.8 or superior. + +Then install the dependencies and a development instance using: + +```bash +make install +``` + +### Start Local Server + +Start Plone, on port 8080, with the command: + +```bash +make start +``` + +#### Keycloak + +The `pas.plugins.oidc` repository has a working setup for a `Keycloak` development server using `Docker` and `Docker Compose`. To use it, in a terminal, run the command: + +```bash +make keycloak-start +``` + +There are two realms configured `plone` and `plone-test`. The later is used in automated tests, while the former should be used for your development environment. + +The `plone` realm ships with an user that has the following credentials: + +* username: **user** +* password: **12345678** + +To stop a running `Keycloak` (needed when running tests), use: + +```bash +make keycloak-stop +``` + +### Update translations + +```bash +make i18n +``` + +### Format codebase + +```bash +make format +``` + +### Run tests + +Testing of this package is done with [`pytest`](https://docs.pytest.org/) and [`tox`](https://tox.wiki/). + +Run all tests with: + +```bash +make test +``` + +Run all tests but stop on the first error and open a `pdb` session: + +```bash +./bin/tox -e test -- -x --pdb +``` + +Run tests named `TestServiceOIDCPost`: + +```bash +./bin/tox -e test -- -k TestServiceOIDCPost +``` + ## References * Blog post: https://www.codesyntax.com/en/blog/log-in-in-plone-using-your-google-workspace-account diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000..79c687f Binary files /dev/null and b/docs/icon.png differ diff --git a/news/38.feature b/news/38.feature new file mode 100644 index 0000000..35c5a66 --- /dev/null +++ b/news/38.feature @@ -0,0 +1 @@ +Implement restapi services to handle authentication flow [@ericof] diff --git a/pyproject.toml b/pyproject.toml index 799155d..851cf49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,7 +123,7 @@ Zope = [ ] python-dateutil = ['dateutil'] ignore-packages = ['plone.restapi', 'plone.volto', 'zestreleaser.towncrier', 'zest.releaser', 'pytest', 'pytest-cov', 'pytest-plone', 'pytest-docker', 'pytest-vcr', 'pytest-mock', 'gocept.pytestlayer', 'requests-mock', 'vcrpy'] -Plone = ['Products.CMFPlone', 'Products.CMFCore', 'Products.GenericSetup', 'Products.PluggableAuthService'] +Plone = ['Products.CMFPlone', 'Products.CMFCore', 'Products.GenericSetup', 'Products.PluggableAuthService', 'Products.PlonePAS'] ## # Add extra configuration options in .meta.toml: diff --git a/src/pas/plugins/oidc/__init__.py b/src/pas/plugins/oidc/__init__.py index b5264aa..8a56a07 100644 --- a/src/pas/plugins/oidc/__init__.py +++ b/src/pas/plugins/oidc/__init__.py @@ -7,6 +7,7 @@ PACKAGE_NAME = "pas.plugins.oidc" +PLUGIN_ID = "oidc" _ = MessageFactory(PACKAGE_NAME) diff --git a/src/pas/plugins/oidc/browser/view.py b/src/pas/plugins/oidc/browser/view.py index 0afdeec..91868d6 100644 --- a/src/pas/plugins/oidc/browser/view.py +++ b/src/pas/plugins/oidc/browser/view.py @@ -1,58 +1,15 @@ -from hashlib import sha256 -from oic import rndstr -from oic.oic.message import AccessTokenResponse -from oic.oic.message import AuthorizationResponse from oic.oic.message import EndSessionRequest from oic.oic.message import IdToken -from oic.oic.message import OpenIDSchema from pas.plugins.oidc import _ from pas.plugins.oidc import logger +from pas.plugins.oidc import utils from pas.plugins.oidc.plugins import OAuth2ConnectionException -from pas.plugins.oidc.utils import SINGLE_OPTIONAL_BOOLEAN_AS_STRING +from pas.plugins.oidc.session import Session from plone import api -from Products.CMFCore.utils import getToolByName from Products.Five.browser import BrowserView from urllib.parse import quote from zExceptions import Unauthorized -import base64 -import json - - -class Session: - session_cookie_name = "__ac_session" - _session = {} - - def __init__(self, request, use_session_data_manager=False): - self.request = request - self.use_session_data_manager = use_session_data_manager - if self.use_session_data_manager: - sdm = api.portal.get_tool("session_data_manager") - self._session = sdm.getSessionData(create=True) - else: - data = self.request.cookies.get(self.session_cookie_name) or {} - if data: - data = json.loads(base64.b64decode(data)) - self._session = data - - def set(self, name, value): - if self.use_session_data_manager: - self._session.set(name, value) - else: - if self.get(name) != value: - self._session[name] = value - self.request.response.setCookie( - self.session_cookie_name, - base64.b64encode(json.dumps(self._session).encode("utf-8")), - ) - - def get(self, name): - # if self.use_session_data_manager: - return self._session.get(name) - - def __repr__(self): - return repr(self._session) - class RequireLoginView(BrowserView): """Our version of the require-login view from Plone. @@ -79,74 +36,43 @@ def __call__(self): class LoginView(BrowserView): - def __call__(self): - session = Session( - self.request, - use_session_data_manager=self.context.getProperty( - "use_session_data_manager" - ), - ) - # state is used to keep track of responses to outstanding requests (state). - # nonce is a string value used to associate a Client session with an ID Token, and to mitigate replay attacks. - session.set("state", rndstr()) - session.set("nonce", rndstr()) - came_from = self.request.get("came_from") - if came_from: - session.set("came_from", came_from) + def _internal_redirect_location(self, session: Session) -> str: + came_from = session.get("came_from") + portal_url = api.portal.get_tool("portal_url") + if not (came_from and portal_url.isURLInPortal(came_from)): + came_from = api.portal.get().absolute_url() + return came_from + def __call__(self): + session = utils.initialize_session(self.context, self.request) + args = utils.authorization_flow_args(self.context, session) + error_msg = "" try: client = self.context.get_oauth2_client() except OAuth2ConnectionException: - portal_url = api.portal.get_tool("portal_url") - if came_from and portal_url.isURLInPortal(came_from): - self.request.response.redirect(came_from) - else: - self.request.response.redirect(api.portal.get().absolute_url()) - - # https://pyoidc.readthedocs.io/en/latest/examples/rp.html#authorization-code-flow - args = { - "client_id": self.context.getProperty("client_id"), - "response_type": "code", - "scope": self.context.get_scopes(), - "state": session.get("state"), - "nonce": session.get("nonce"), - "redirect_uri": self.context.get_redirect_uris(), - } - if self.context.getProperty("use_pkce"): - # Build a random string of 43 to 128 characters - # and send it in the request as a base64-encoded urlsafe string of the sha256 hash of that string - session.set("verifier", rndstr(128)) - args["code_challenge"] = self.get_code_challenge(session.get("verifier")) - args["code_challenge_method"] = "S256" - - try: - auth_req = client.construct_AuthorizationRequest(request_args=args) - login_url = auth_req.request(client.authorization_endpoint) - except Exception as e: - logger.error(e) - api.portal.show_message( - _("There was an error during the login process. Please try" " again.") - ) - portal_url = api.portal.get_tool("portal_url") - if came_from and portal_url.isURLInPortal(came_from): - self.request.response.redirect(came_from) + client = None + error_msg = _("There was an error getting the oauth2 client.") + if client: + try: + auth_req = client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(client.authorization_endpoint) + except Exception as e: + logger.error(e) + error_msg = _( + "There was an error during the login process. Please try again." + ) else: - self.request.response.redirect(api.portal.get().absolute_url()) - - return + self.request.response.setHeader( + "Cache-Control", "no-cache, must-revalidate" + ) + self.request.response.redirect(login_url) - self.request.response.setHeader("Cache-Control", "no-cache, must-revalidate") - self.request.response.redirect(login_url) + if error_msg: + api.portal.show_message(error_msg) + redirect_location = self._internal_redirect_location(session) + self.request.response.redirect(redirect_location) return - def get_code_challenge(self, value): - """build a sha256 hash of the base64 encoded value of value - be careful: this should be url-safe base64 and we should also remove the trailing '=' - See https://www.stefaanlippens.net/oauth-code-flow-pkce.html#PKCE-code-verifier-and-challenge - """ - hash_code = sha256(value.encode("utf-8")).digest() - return base64.urlsafe_b64encode(hash_code).decode("utf-8").replace("=", "") - class LogoutView(BrowserView): def __call__(self): @@ -163,11 +89,7 @@ def __call__(self): # https://github.com/keycloak/keycloak-documentation/blob/master/securing_apps/topics/oidc/java/logout.adoc # session.set('end_session_state', rndstr()) - redirect_uri = api.portal.get().absolute_url() - - # Volto frontend mapping exception - if redirect_uri.endswith("/api"): - redirect_uri = redirect_uri[:-4] + redirect_uri = utils.url_cleanup(api.portal.get().absolute_url()) if self.context.getProperty("use_deprecated_redirect_uri_for_logout"): args = { @@ -179,7 +101,7 @@ def __call__(self): "client_id": self.context.getProperty("client_id"), } - pas = getToolByName(self.context, "acl_users") + pas = api.portal.get_tool("acl_users") auth_cookie_name = pas.credentials_cookie_auth.cookie_name # end_req = client.construct_EndSessionRequest(request_args=args) @@ -195,97 +117,29 @@ def __call__(self): class CallbackView(BrowserView): def __call__(self): - response = self.request.environ["QUERY_STRING"] - session = Session( - self.request, - use_session_data_manager=self.context.getProperty( - "use_session_data_manager" - ), - ) + session = utils.load_existing_session(self.context, self.request) client = self.context.get_oauth2_client() - aresp = client.parse_response( - AuthorizationResponse, info=response, sformat="urlencoded" + qs = self.request.environ["QUERY_STRING"] + args, state = utils.parse_authorization_response( + self.context, qs, client, session ) - if aresp["state"] != session.get("state"): - logger.error( - "invalid OAuth2 state response:%s != session:%s", - aresp.get("state"), - session.get("state"), - ) - # TODO: need to double check before removing the comment below - # raise ValueError("invalid OAuth2 state") - - args = { - "code": aresp["code"], - "redirect_uri": self.context.get_redirect_uris(), - } - - if self.context.getProperty("use_pkce"): - args["code_verifier"] = session.get("verifier") - if self.context.getProperty("use_modified_openid_schema"): IdToken.c_param.update( { - "email_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING, - "phone_number_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING, + "email_verified": utils.SINGLE_OPTIONAL_BOOLEAN_AS_STRING, + "phone_number_verified": utils.SINGLE_OPTIONAL_BOOLEAN_AS_STRING, } ) # The response you get back is an instance of an AccessTokenResponse # or again possibly an ErrorResponse instance. - resp = client.do_access_token_request( - state=aresp["state"], - request_args=args, - authn_method="client_secret_basic", - ) - - if isinstance(resp, AccessTokenResponse): - # If it's an AccessTokenResponse the information in the response will be stored in the - # client instance with state as the key for future use. - if client.userinfo_endpoint: - # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo - - # XXX: Not completely sure if this is even needed - # We do not have a OpenID connect provider with userinfo endpoint - # enabled and with the weird treatment of boolean values, so we cannot test this - # if self.context.getProperty("use_modified_openid_schema"): - # userinfo = client.do_user_info_request(state=aresp["state"], user_info_schema=CustomOpenIDNonBooleanSchema) - # else: - # userinfo = client.do_user_info_request(state=aresp["state"]) - - userinfo = client.do_user_info_request(state=aresp["state"]) - else: - userinfo = resp.to_dict().get("id_token", {}) - - # userinfo in an instance of OpenIDSchema or ErrorResponse - # It could also be dict, if there is no userinfo_endpoint - if userinfo and isinstance(userinfo, (OpenIDSchema, dict)): - self.context.rememberIdentity(userinfo) - self.request.response.setHeader( - "Cache-Control", "no-cache, must-revalidate" - ) - self.request.response.redirect(self.return_url(session=session)) - return - else: - logger.error( - "authentication failed invalid response %s %s", resp, userinfo - ) - raise Unauthorized() + user_info = utils.get_user_info(client, state, args) + if user_info: + self.context.rememberIdentity(user_info) + self.request.response.setHeader( + "Cache-Control", "no-cache, must-revalidate" + ) + return_url = utils.process_came_from(session, self.request.get("came_from")) + self.request.response.redirect(return_url) else: - logger.error("authentication failed %s", resp) raise Unauthorized() - - def return_url(self, session=None): - came_from = self.request.get("came_from") - if not came_from and session: - came_from = session.get("came_from") - - portal_url = api.portal.get_tool("portal_url") - if not (came_from and portal_url.isURLInPortal(came_from)): - came_from = api.portal.get().absolute_url() - - # Volto frontend mapping exception - if came_from.endswith("/api"): - came_from = came_from[:-4] - - return came_from diff --git a/src/pas/plugins/oidc/configure.zcml b/src/pas/plugins/oidc/configure.zcml index 76751ba..931a26c 100644 --- a/src/pas/plugins/oidc/configure.zcml +++ b/src/pas/plugins/oidc/configure.zcml @@ -7,6 +7,11 @@ i18n_domain="pas.plugins.oidc" > + + - - - + List[str]: + response = [] + portal_url = api.portal.get().absolute_url() + for uri in uris: + if uri.startswith("/"): + uri = f"{portal_url}{uri}" + response.append(safe_text(uri)) + return response + + class OAuth2ConnectionException(Exception): """Exception raised when there are OAuth2 Connection Exceptions""" @@ -312,7 +323,7 @@ def get_oauth2_client(self): def get_redirect_uris(self): redirect_uris = self.getProperty("redirect_uris") if redirect_uris: - return [safe_text(uri) for uri in redirect_uris if uri] + return format_redirect_uris(redirect_uris) return [ f"{self.absolute_url()}/callback", ] diff --git a/src/pas/plugins/oidc/services/__init__.py b/src/pas/plugins/oidc/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pas/plugins/oidc/services/configure.zcml b/src/pas/plugins/oidc/services/configure.zcml new file mode 100644 index 0000000..2743ecb --- /dev/null +++ b/src/pas/plugins/oidc/services/configure.zcml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/pas/plugins/oidc/services/login/__init__.py b/src/pas/plugins/oidc/services/login/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pas/plugins/oidc/services/login/configure.zcml b/src/pas/plugins/oidc/services/login/configure.zcml new file mode 100644 index 0000000..690d12e --- /dev/null +++ b/src/pas/plugins/oidc/services/login/configure.zcml @@ -0,0 +1,15 @@ + + + + + + diff --git a/src/pas/plugins/oidc/services/login/get.py b/src/pas/plugins/oidc/services/login/get.py new file mode 100644 index 0000000..347929c --- /dev/null +++ b/src/pas/plugins/oidc/services/login/get.py @@ -0,0 +1,37 @@ +from plone import api +from plone.restapi.services import Service +from typing import Dict +from typing import List + + +class Get(Service): + """List available login options for the site.""" + + def check_permission(self): + return True + + @staticmethod + def list_login_providers() -> List[Dict]: + """List all configured login providers. + + This should be moved to plone.restapi and be extendable. + :returns: List of login options. + """ + portal_url = api.portal.get().absolute_url() + plugins = [ + { + "id": "oidc", + "plugin": "oidc", + "url": f"{portal_url}/@login-oidc/oidc", + "title": "OIDC Authentication", + } + ] + return plugins + + def reply(self) -> Dict[str, List[Dict]]: + """List login options available for the site. + + :returns: Login options information. + """ + providers = self.list_login_providers() + return {"options": providers} diff --git a/src/pas/plugins/oidc/services/oidc/__init__.py b/src/pas/plugins/oidc/services/oidc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pas/plugins/oidc/services/oidc/configure.zcml b/src/pas/plugins/oidc/services/oidc/configure.zcml new file mode 100644 index 0000000..af61d5c --- /dev/null +++ b/src/pas/plugins/oidc/services/oidc/configure.zcml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/src/pas/plugins/oidc/services/oidc/oidc.py b/src/pas/plugins/oidc/services/oidc/oidc.py new file mode 100644 index 0000000..b08c09d --- /dev/null +++ b/src/pas/plugins/oidc/services/oidc/oidc.py @@ -0,0 +1,235 @@ +from oic.oic.message import EndSessionRequest +from oic.oic.message import IdToken +from pas.plugins.oidc import _ +from pas.plugins.oidc import logger +from pas.plugins.oidc import utils +from pas.plugins.oidc.plugins import OAuth2ConnectionException +from pas.plugins.oidc.plugins import OIDCPlugin +from plone import api +from plone.protect.interfaces import IDisableCSRFProtection +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from Products.PlonePAS.tools.memberdata import MemberData +from transaction.interfaces import NoTransaction +from zope.interface import alsoProvides +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse + +import transaction + + +@implementer(IPublishTraverse) +class LoginOIDC(Service): + """Base class for OIDC login.""" + + _plugin: OIDCPlugin = None + _data: dict = None + provider_id: str = "oidc" + + def publishTraverse(self, request, name: str): + # Store the first path segment as the provider + request["TraversalRequestNameStack"] = [] + self.provider_id = name + return self + + @property + def json_body(self) -> dict: + if not self._data: + self._data = json_body(self.request) + return self._data + + @property + def plugin(self) -> OIDCPlugin: + if not self._plugin: + try: + self._plugin = utils.get_plugin() + except AttributeError: + # Plugin not installed yet + self._plugin = None + return self._plugin + + def _provider_not_found(self, provider: str) -> dict: + """Return 404 status code for a provider not found.""" + self.request.response.setStatus(404) + message = ( + f"Provider {provider} is not available." + if provider + else "Provider was not informed." + ) + return { + "error": { + "type": "Provider not found", + "message": message, + } + } + + +class Get(LoginOIDC): + """Provide information to start the OIDC flow.""" + + def check_permission(self) -> bool: + return True + + def reply(self) -> dict: + """Generate URL and session information to be used by the frontend. + + :returns: URL and session information. + """ + provider = self.provider_id + plugin = self.plugin + if not (plugin and provider == "oidc"): + return self._provider_not_found(provider) + + session = utils.initialize_session(plugin, self.request) + args = utils.authorization_flow_args(plugin, session) + try: + client = plugin.get_oauth2_client() + except OAuth2ConnectionException: + self.request.response.setStatus(500) + return { + "error": { + "type": "Configuration error", + "message": _("Provider is not properly configured."), + } + } + try: + auth_req = client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(client.authorization_endpoint) + except Exception as e: + logger.error(e) + self.request.response.setStatus(500) + return { + "error": { + "type": "Runtime error", + "message": _( + "There was an error during the login process. Please try again." + ), + } + } + else: + return { + "next_url": login_url, + "came_from": session.get("came_from"), + } + + +class LogoutGet(LoginOIDC): + """Logout a user.""" + + def reply(self) -> dict: + """Generate URL and session information to be used by the frontend. + + :returns: URL and session information. + """ + provider = "oidc" + plugin = self.plugin + if not (plugin and provider == "oidc"): + return self._provider_not_found(provider) + + try: + client = plugin.get_oauth2_client() + except OAuth2ConnectionException: + self.request.response.setStatus(500) + return { + "error": { + "type": "Configuration error", + "message": _("Provider is not properly configured."), + } + } + redirect_uri = utils.url_cleanup(api.portal.get().absolute_url()) + + if plugin.getProperty("use_deprecated_redirect_uri_for_logout"): + args = { + "redirect_uri": redirect_uri, + } + else: + args = { + "post_logout_redirect_uri": redirect_uri, + "client_id": plugin.getProperty("client_id"), + } + + pas = api.portal.get_tool("acl_users") + auth_cookie_name = pas.credentials_cookie_auth.cookie_name + + # end_req = client.construct_EndSessionRequest(request_args=args) + end_req = EndSessionRequest(**args) + logout_url = end_req.request(client.end_session_endpoint) + self.request.response.expireCookie(auth_cookie_name, path="/") + self.request.response.expireCookie("auth_token", path="/") + return { + "next_url": logout_url, + "came_from": redirect_uri, + } + + +class Post(LoginOIDC): + """Handles OIDC login and returns a JSON web token (JWT).""" + + def check_permission(self) -> bool: + return True + + def _annotate_transaction(self, action: str, user: MemberData): + """Add a note to the current transaction.""" + try: + # Get the current transaction + tx = transaction.get() + except NoTransaction: + return None + # Set user on the transaction + tx.setUser(user.getUser()) + user_info = user.getProperty("fullname") or user.getUserName() + msg = "" + if action == "login": + msg = f"(Logged in {user_info})" + elif action == "add_identity": + msg = f"(Added new identity to user {user_info})" + tx.note(msg) + + def reply(self) -> dict: + """Process callback, authenticate the user and return a JWT Token. + + :returns: Token information. + """ + provider = self.provider_id + plugin = self.plugin + if not (plugin and provider == "oidc"): + return self._provider_not_found(provider) + + session = utils.load_existing_session(plugin, self.request) + client = plugin.get_oauth2_client() + data = self.json_body + qs = data.get("qs", "") + qs = qs[1:] if qs.startswith("?") else qs + args, state = utils.parse_authorization_response(plugin, qs, client, session) + if plugin.getProperty("use_modified_openid_schema"): + IdToken.c_param.update( + { + "email_verified": utils.SINGLE_OPTIONAL_BOOLEAN_AS_STRING, + "phone_number_verified": utils.SINGLE_OPTIONAL_BOOLEAN_AS_STRING, + } + ) + + # The response you get back is an instance of an AccessTokenResponse + # or again possibly an ErrorResponse instance. + user_info = utils.get_user_info(client, state, args) + if user_info: + alsoProvides(self.request, IDisableCSRFProtection) + action = "login" + plugin.rememberIdentity(user_info) + user_id = user_info["sub"] + user = api.user.get(userid=user_id) + token = self.request.response.cookies.get("auth_token", {}).get("value") + # Make sure we are not setting cookies here + # as it will break the authentication mechanism with JWT tokens + self.request.response.cookies = {} + self._annotate_transaction(action, user=user) + return_url = utils.process_came_from(session) + return {"token": token, "next_url": return_url} + else: + self.request.response.setStatus(401) + return { + "error": { + "type": "Authentication Error", + "message": "There was an issue authenticating this user", + } + } diff --git a/src/pas/plugins/oidc/session.py b/src/pas/plugins/oidc/session.py new file mode 100644 index 0000000..3fba8d5 --- /dev/null +++ b/src/pas/plugins/oidc/session.py @@ -0,0 +1,39 @@ +from plone import api + +import base64 +import json + + +class Session: + session_cookie_name: str = "__ac_session" + _session: dict + + def __init__(self, request, use_session_data_manager=False): + self.request = request + self.use_session_data_manager = use_session_data_manager + if self.use_session_data_manager: + sdm = api.portal.get_tool("session_data_manager") + self._session = sdm.getSessionData(create=True) + else: + data = self.request.cookies.get(self.session_cookie_name) or {} + if data: + data = json.loads(base64.b64decode(data)) + self._session = data + + def set(self, name, value): + if self.use_session_data_manager: + self._session.set(name, value) + else: + if self.get(name) != value: + self._session[name] = value + self.request.response.setCookie( + self.session_cookie_name, + base64.b64encode(json.dumps(self._session).encode("utf-8")), + ) + + def get(self, name): + # if self.use_session_data_manager: + return self._session.get(name) + + def __repr__(self): + return repr(self._session) diff --git a/src/pas/plugins/oidc/setuphandlers.py b/src/pas/plugins/oidc/setuphandlers.py index 2cf91a2..a584dd3 100644 --- a/src/pas/plugins/oidc/setuphandlers.py +++ b/src/pas/plugins/oidc/setuphandlers.py @@ -1,6 +1,6 @@ from pas.plugins.oidc import logger +from pas.plugins.oidc import PLUGIN_ID from pas.plugins.oidc.plugins import OIDCPlugin -from pas.plugins.oidc.utils import PLUGIN_ID from plone import api from Products.CMFPlone.interfaces import INonInstallable from zope.interface import implementer diff --git a/src/pas/plugins/oidc/testing.py b/src/pas/plugins/oidc/testing.py index ce60e41..614589d 100644 --- a/src/pas/plugins/oidc/testing.py +++ b/src/pas/plugins/oidc/testing.py @@ -3,6 +3,7 @@ from plone.app.testing import FunctionalTesting from plone.app.testing import IntegrationTesting from plone.app.testing import PloneSandboxLayer +from plone.testing.zope import WSGI_SERVER_FIXTURE import pas.plugins.oidc @@ -17,6 +18,7 @@ def setUpZope(self, app, configurationContext): self.loadZCML(package=pas.plugins.oidc) def setUpPloneSite(self, portal): + applyProfile(portal, "plone.restapi:default") applyProfile(portal, "pas.plugins.oidc:default") @@ -33,3 +35,8 @@ def setUpPloneSite(self, portal): bases=(FIXTURE,), name="PasPluginsOidcLayer:FunctionalTesting", ) + +RESTAPI_TESTING = FunctionalTesting( + bases=(FIXTURE, WSGI_SERVER_FIXTURE), + name="PasPluginsOidcLayer:RestAPITesting", +) diff --git a/src/pas/plugins/oidc/utils.py b/src/pas/plugins/oidc/utils.py index a8a88a3..4102a96 100644 --- a/src/pas/plugins/oidc/utils.py +++ b/src/pas/plugins/oidc/utils.py @@ -1,13 +1,16 @@ -from oic.oauth2.message import ParamDefinition -from oic.oauth2.message import SINGLE_OPTIONAL_INT -from oic.oauth2.message import SINGLE_OPTIONAL_STRING -from oic.oauth2.message import SINGLE_REQUIRED_STRING -from oic.oic.message import OpenIDSchema -from oic.oic.message import OPTIONAL_ADDRESS -from oic.oic.message import OPTIONAL_MESSAGE +from hashlib import sha256 +from oic import rndstr +from oic.exception import RequestError +from oic.oic import message +from pas.plugins.oidc import logger +from pas.plugins.oidc import PLUGIN_ID +from pas.plugins.oidc import plugins +from pas.plugins.oidc.session import Session +from plone import api +from typing import Union - -PLUGIN_ID = "oidc" +import base64 +import re def boolean_string_ser(val, sformat=None, lev=0): @@ -26,33 +29,186 @@ def boolean_string_deser(val, sformat=None, lev=0): # value type, required, serializer, deserializer, null value allowed -SINGLE_OPTIONAL_BOOLEAN_AS_STRING = ParamDefinition( +SINGLE_OPTIONAL_BOOLEAN_AS_STRING = message.ParamDefinition( str, False, boolean_string_ser, boolean_string_deser, False ) -class CustomOpenIDNonBooleanSchema(OpenIDSchema): +class CustomOpenIDNonBooleanSchema(message.OpenIDSchema): c_param = { - "sub": SINGLE_REQUIRED_STRING, - "name": SINGLE_OPTIONAL_STRING, - "given_name": SINGLE_OPTIONAL_STRING, - "family_name": SINGLE_OPTIONAL_STRING, - "middle_name": SINGLE_OPTIONAL_STRING, - "nickname": SINGLE_OPTIONAL_STRING, - "preferred_username": SINGLE_OPTIONAL_STRING, - "profile": SINGLE_OPTIONAL_STRING, - "picture": SINGLE_OPTIONAL_STRING, - "website": SINGLE_OPTIONAL_STRING, - "email": SINGLE_OPTIONAL_STRING, + "sub": message.SINGLE_REQUIRED_STRING, + "name": message.SINGLE_OPTIONAL_STRING, + "given_name": message.SINGLE_OPTIONAL_STRING, + "family_name": message.SINGLE_OPTIONAL_STRING, + "middle_name": message.SINGLE_OPTIONAL_STRING, + "nickname": message.SINGLE_OPTIONAL_STRING, + "preferred_username": message.SINGLE_OPTIONAL_STRING, + "profile": message.SINGLE_OPTIONAL_STRING, + "picture": message.SINGLE_OPTIONAL_STRING, + "website": message.SINGLE_OPTIONAL_STRING, + "email": message.SINGLE_OPTIONAL_STRING, "email_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING, - "gender": SINGLE_OPTIONAL_STRING, - "birthdate": SINGLE_OPTIONAL_STRING, - "zoneinfo": SINGLE_OPTIONAL_STRING, - "locale": SINGLE_OPTIONAL_STRING, - "phone_number": SINGLE_OPTIONAL_STRING, + "gender": message.SINGLE_OPTIONAL_STRING, + "birthdate": message.SINGLE_OPTIONAL_STRING, + "zoneinfo": message.SINGLE_OPTIONAL_STRING, + "locale": message.SINGLE_OPTIONAL_STRING, + "phone_number": message.SINGLE_OPTIONAL_STRING, "phone_number_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING, - "address": OPTIONAL_ADDRESS, - "updated_at": SINGLE_OPTIONAL_INT, - "_claim_names": OPTIONAL_MESSAGE, - "_claim_sources": OPTIONAL_MESSAGE, + "address": message.OPTIONAL_ADDRESS, + "updated_at": message.SINGLE_OPTIONAL_INT, + "_claim_names": message.OPTIONAL_MESSAGE, + "_claim_sources": message.OPTIONAL_MESSAGE, } + + +_URL_MAPPING = ( + (r"(.*)/api($|/.*)", r"\1\2"), + (r"(.*)/\+\+api\+\+($|/.*)", r"\1\2"), +) + + +def url_cleanup(url: str) -> str: + """Clean up redirection url.""" + # Volto frontend mapping exception + for search, replace in _URL_MAPPING: + match = re.search(search, url) + if match: + url = re.sub(search, replace, url) + return url + + +def get_plugin() -> plugins.OIDCPlugin: + """Return the OIDC plugin for the current portal.""" + pas = api.portal.get_tool("acl_users") + return getattr(pas, PLUGIN_ID) + + +# Flow: Start +def initialize_session(plugin: plugins.OIDCPlugin, request) -> Session: + """Initialize a Session.""" + use_session_data_manager: bool = plugin.getProperty("use_session_data_manager") + use_pkce: bool = plugin.getProperty("use_pkce") + session = Session(request, use_session_data_manager) + # state is used to keep track of responses to outstanding requests (state). + # nonce is a string value used to associate a Client session with an ID Token, and to mitigate replay attacks. + session.set("state", rndstr()) + session.set("nonce", rndstr()) + came_from = request.get("came_from") + if came_from: + session.set("came_from", came_from) + if use_pkce: + session.set("verifier", rndstr(128)) + return session + + +def pkce_code_verifier_challenge(value: str) -> str: + """Build a sha256 hash of the base64 encoded value of value + + Be careful: this should be url-safe base64 and we should also remove the trailing '=' + See https://www.stefaanlippens.net/oauth-code-flow-pkce.html#PKCE-code-verifier-and-challenge + """ + hash_code = sha256(value.encode("utf-8")).digest() + return base64.urlsafe_b64encode(hash_code).decode("utf-8").replace("=", "") + + +def authorization_flow_args(plugin: plugins.OIDCPlugin, session: Session) -> dict: + """Return the arguments used for the authorization flow.""" + # https://pyoidc.readthedocs.io/en/latest/examples/rp.html#authorization-code-flow + args = { + "client_id": plugin.getProperty("client_id"), + "response_type": "code", + "scope": plugin.get_scopes(), + "state": session.get("state"), + "nonce": session.get("nonce"), + "redirect_uri": plugin.get_redirect_uris(), + } + if plugin.getProperty("use_pkce"): + # Build a random string of 43 to 128 characters + # and send it in the request as a base64-encoded urlsafe string of the sha256 hash of that string + args["code_challenge"] = pkce_code_verifier_challenge(session.get("verifier")) + args["code_challenge_method"] = "S256" + return args + + +# Flow: Process +def load_existing_session(plugin: plugins.OIDCPlugin, request) -> Session: + use_session_data_manager: bool = plugin.getProperty("use_session_data_manager") + session = Session(request, use_session_data_manager) + return session + + +def parse_authorization_response( + plugin: plugins.OIDCPlugin, qs: str, client, session: Session +) -> tuple: + """Parse a flow response and return arguments for client calls.""" + use_pkce: bool = plugin.getProperty("use_pkce") + aresp = client.parse_response( + message.AuthorizationResponse, info=qs, sformat="urlencoded" + ) + aresp_state = aresp["state"] + session_state = session.get("state") + if aresp_state != session_state: + logger.error( + f"Invalid OAuth2 state response: {aresp_state}" f"session: {session_state}" + ) + # TODO: need to double check before removing the comment below + # raise ValueError("invalid OAuth2 state") + + args = { + "code": aresp["code"], + "redirect_uri": plugin.get_redirect_uris(), + } + + if use_pkce: + args["code_verifier"] = session.get("verifier") + return args, aresp["state"] + + +def get_user_info(client, state, args) -> Union[message.OpenIDSchema, dict]: + resp = client.do_access_token_request( + state=state, + request_args=args, + authn_method="client_secret_basic", + ) + user_info = {} + if isinstance(resp, message.AccessTokenResponse): + # If it's an AccessTokenResponse the information in the response will be stored in the + # client instance with state as the key for future use. + user_info = resp.to_dict().get("id_token", {}) + if client.userinfo_endpoint: + # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + # XXX: Not completely sure if this is even needed + # We do not have a OpenID connect provider with userinfo endpoint + # enabled and with the weird treatment of boolean values, so we cannot test this + # if self.context.getProperty("use_modified_openid_schema"): + # userinfo = client.do_user_info_request(state=aresp["state"], user_info_schema=CustomOpenIDNonBooleanSchema) + # else: + # userinfo = client.do_user_info_request(state=aresp["state"]) + try: + user_info = client.do_user_info_request(state=state) + except RequestError as exc: + logger.error( + "Authentication failed, probably missing openid scope", + exc_info=exc, + ) + user_info = {} + # userinfo in an instance of OpenIDSchema or ErrorResponse + # It could also be dict, if there is no userinfo_endpoint + if not (user_info and isinstance(user_info, (message.OpenIDSchema, dict))): + logger.error(f"Authentication failed, invalid response {resp} {user_info}") + user_info = {} + elif isinstance(resp, message.TokenErrorResponse): + logger.error(f"Token error response: {resp.to_json()}") + else: + logger.error(f"Authentication failed {resp}") + return user_info + + +def process_came_from(session: Session, came_from: str = "") -> str: + if not came_from: + came_from = session.get("came_from") + portal_url = api.portal.get_tool("portal_url") + if not (came_from and portal_url.isURLInPortal(came_from)): + came_from = api.portal.get().absolute_url() + return url_cleanup(came_from) diff --git a/tests/conftest.py b/tests/conftest.py index 3e1ab11..0cf25d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ from pas.plugins.oidc.testing import FUNCTIONAL_TESTING from pas.plugins.oidc.testing import INTEGRATION_TESTING +from pas.plugins.oidc.testing import RESTAPI_TESTING from pathlib import Path from pytest_plone import fixtures_factory +from requests.exceptions import ConnectionError import pytest +import requests pytest_plugins = ["pytest_plone"] @@ -14,6 +17,7 @@ ( (INTEGRATION_TESTING, "integration"), (FUNCTIONAL_TESTING, "functional"), + (RESTAPI_TESTING, "restapi"), ) ) ) @@ -25,6 +29,37 @@ def docker_compose_file(pytestconfig): return Path(str(pytestconfig.rootdir)).resolve() / "tests" / "docker-compose.yml" +def is_responsive(url: str) -> bool: + try: + response = requests.get(url) + if response.status_code == 200: + return True + except ConnectionError: + return False + + +@pytest.fixture(scope="session") +def keycloak_service(docker_ip, docker_services): + """Ensure that keycloak service is up and responsive.""" + # `port_for` takes a container port and returns the corresponding host port + port = docker_services.port_for("keycloak", 8080) + url = f"http://{docker_ip}:{port}" + docker_services.wait_until_responsive( + timeout=50.0, pause=0.1, check=lambda: is_responsive(url) + ) + return url + + +@pytest.fixture(scope="session") +def keycloak(keycloak_service): + return { + "issuer": f"{keycloak_service}/realms/plone-test", + "client_id": "plone", + "client_secret": "12345678", # nosec B105 + "scope": ("openid", "profile", "email"), + } + + @pytest.fixture def wait_for(): def func(thread): diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 161979d..67787a8 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -3,52 +3,18 @@ from plone.app.testing import SITE_OWNER_PASSWORD from plone.app.testing import TEST_USER_NAME from plone.app.testing import TEST_USER_PASSWORD -from plone.restapi.testing import RelativeSession from plone.testing.zope import Browser -from requests.exceptions import ConnectionError from zope.component.hooks import setSite import pytest -import requests import transaction -def is_responsive(url: str) -> bool: - try: - response = requests.get(url) - if response.status_code == 200: - return True - except ConnectionError: - return False - - -@pytest.fixture(scope="session") -def keycloak_service(docker_ip, docker_services): - """Ensure that keycloak service is up and responsive.""" - # `port_for` takes a container port and returns the corresponding host port - port = docker_services.port_for("keycloak", 8080) - url = f"http://{docker_ip}:{port}" - docker_services.wait_until_responsive( - timeout=30.0, pause=0.1, check=lambda: is_responsive(url) - ) - return url - - @pytest.fixture() def app(functional): return functional["app"] -@pytest.fixture(scope="session") -def keycloak(keycloak_service): - return { - "issuer": f"{keycloak_service}/realms/plone-test", - "client_id": "plone", - "client_secret": "12345678", # nosec B105 - "scope": ("openid", "profile", "email"), - } - - @pytest.fixture() def portal(functional, keycloak): portal = functional["portal"] @@ -72,38 +38,6 @@ def http_request(functional): return functional["request"] -@pytest.fixture() -def request_api_factory(portal): - def factory(): - url = portal.absolute_url() - api_session = RelativeSession(url) - api_session.headers.update({"Accept": "application/json"}) - return api_session - - return factory - - -@pytest.fixture() -def api_anon_request(request_api_factory): - return request_api_factory() - - -@pytest.fixture() -def api_user_request(request_api_factory): - request = request_api_factory() - request.auth = (TEST_USER_NAME, TEST_USER_PASSWORD) - yield request - request.auth = () - - -@pytest.fixture() -def api_manager_request(request_api_factory): - request = request_api_factory() - request.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) - yield request - request.auth = () - - @pytest.fixture() def browser_factory(app): def factory(): diff --git a/tests/functional/test_functional.py b/tests/functional/test_functional.py index d98f50d..2197f67 100644 --- a/tests/functional/test_functional.py +++ b/tests/functional/test_functional.py @@ -1,4 +1,4 @@ -from pas.plugins.oidc.utils import PLUGIN_ID +from pas.plugins.oidc import PLUGIN_ID from plone import api from urllib.parse import quote diff --git a/tests/keycloak/import/plone-realm.json b/tests/keycloak/import/plone-realm.json index 7eca1ef..270792f 100644 --- a/tests/keycloak/import/plone-realm.json +++ b/tests/keycloak/import/plone-realm.json @@ -540,7 +540,7 @@ "alwaysDisplayInConsole" : true, "clientAuthenticatorType" : "client-secret", "secret" : "12345678", - "redirectUris" : [ "http://localhost:8080/Plone/*" ], + "redirectUris" : [ "http://localhost:8080/Plone/*", "*" ], "webOrigins" : [ "http://localhost:8080/Plone/" ], "notBefore" : 0, "bearerOnly" : false, diff --git a/tests/keycloak/import/plone-test-realm.json b/tests/keycloak/import/plone-test-realm.json index 5beb0b0..5a7f455 100644 --- a/tests/keycloak/import/plone-test-realm.json +++ b/tests/keycloak/import/plone-test-realm.json @@ -546,7 +546,7 @@ "alwaysDisplayInConsole" : true, "clientAuthenticatorType" : "client-secret", "secret" : "12345678", - "redirectUris" : [ "http://nohost/plone/*" ], + "redirectUris" : [ "http://nohost/plone/*", "*" ], "webOrigins" : [ "http://nohost/plone/" ], "notBefore" : 0, "bearerOnly" : false, diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 218a681..df4adc8 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -1,6 +1,6 @@ from base64 import b64decode from oic.oic.message import OpenIDSchema -from pas.plugins.oidc.utils import PLUGIN_ID +from pas.plugins.oidc import PLUGIN_ID from plone import api from plone.session.tktauth import splitTicket diff --git a/tests/services/conftest.py b/tests/services/conftest.py new file mode 100644 index 0000000..95b162a --- /dev/null +++ b/tests/services/conftest.py @@ -0,0 +1,104 @@ +from bs4 import BeautifulSoup +from plone import api +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.restapi.testing import RelativeSession +from urllib.parse import urlparse +from zope.component.hooks import setSite + +import pytest +import requests +import transaction + + +@pytest.fixture(scope="session") +def keycloak(keycloak_service): + return { + "issuer": f"{keycloak_service}/realms/plone-test", + "client_id": "plone", + "client_secret": "12345678", # nosec B105 + "scope": ("openid", "profile", "email"), + "redirect_uris": ("/login_oidc/oidc",), + "create_restapi_ticket": True, + } + + +@pytest.fixture() +def app(restapi): + return restapi["app"] + + +@pytest.fixture() +def portal(restapi, keycloak): + portal = restapi["portal"] + setSite(portal) + plugin = portal.acl_users.oidc + with api.env.adopt_roles(["Manager", "Member"]): + for key, value in keycloak.items(): + setattr(plugin, key, value) + transaction.commit() + yield portal + with api.env.adopt_roles(["Manager", "Member"]): + for key, value in keycloak.items(): + if key != "scope": + value = "" + setattr(plugin, key, value) + transaction.commit() + + +@pytest.fixture() +def http_request(restapi): + return restapi["request"] + + +@pytest.fixture() +def request_api_factory(portal): + def factory(): + url = portal.absolute_url() + api_session = RelativeSession(f"{url}/++api++") + return api_session + + return factory + + +@pytest.fixture() +def api_anon_request(request_api_factory): + return request_api_factory() + + +@pytest.fixture() +def api_user_request(request_api_factory): + request = request_api_factory() + request.auth = (TEST_USER_NAME, TEST_USER_PASSWORD) + yield request + request.auth = () + + +@pytest.fixture() +def api_manager_request(request_api_factory): + request = request_api_factory() + request.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + yield request + request.auth = () + + +@pytest.fixture() +def keycloak_login(): + def func(url: str): + session = requests.Session() + resp = session.get(url) + soup = BeautifulSoup(resp.content) + data = { + "username": TEST_USER_NAME, + "password": TEST_USER_PASSWORD, + "credentialId": "", + } + next_url = soup.find("form", attrs={"id": "kc-form-login"})["action"] + resp = session.post(next_url, data=data, allow_redirects=False) + location = resp.headers["Location"] + qs = urlparse(location).query + return qs + + return func diff --git a/tests/services/test_services_login_get.py b/tests/services/test_services_login_get.py new file mode 100644 index 0000000..b33a63e --- /dev/null +++ b/tests/services/test_services_login_get.py @@ -0,0 +1,28 @@ +import pytest + + +class TestServiceLoginGet: + @pytest.fixture(autouse=True) + def _initialize(self, api_anon_request): + self.api_session = api_anon_request + + def test_login_get_available(self): + response = self.api_session.get("@login") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + + @pytest.mark.parametrize( + "idx, key, expected", + [ + [0, "id", "oidc"], + [0, "plugin", "oidc"], + [0, "url", "/@login-oidc/oidc"], + [0, "title", "OIDC Authentication"], + ], + ) + def test_login_get_options(self, idx: int, key: str, expected: str): + response = self.api_session.get("@login") + data = response.json() + options = data["options"] + assert expected in options[idx][key] diff --git a/tests/services/test_services_oidc_get.py b/tests/services/test_services_oidc_get.py new file mode 100644 index 0000000..04173c4 --- /dev/null +++ b/tests/services/test_services_oidc_get.py @@ -0,0 +1,82 @@ +from pas.plugins.oidc import PACKAGE_NAME +from urllib.parse import parse_qsl +from urllib.parse import urlparse + +import pytest +import transaction + + +class TestServiceOIDCGet: + endpoint: str = "@login-oidc/oidc" + + @pytest.fixture(autouse=True) + def _initialize(self, api_anon_request): + self.api_session = api_anon_request + + def test_login_oidc_get_available(self): + response = self.api_session.get(self.endpoint) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + + @pytest.mark.parametrize( + "key", + [ + "came_from", + "next_url", + ], + ) + def test_login_oidc_response_keys(self, key: str): + response = self.api_session.get(self.endpoint) + data = response.json() + assert key in data + + def test_login_oidc_next_url(self): + response = self.api_session.get(self.endpoint) + data = response.json() + next_url = data["next_url"] + url_parts = urlparse(next_url) + assert url_parts.netloc == "127.0.0.1:8180" + qs = dict(parse_qsl(url_parts.query)) + assert qs["client_id"] == "plone" + assert qs["response_type"] == "code" + assert qs["scope"] == "openid profile email" + assert qs["redirect_uri"].endswith("/plone/login_oidc/oidc") + assert "state" in qs + assert "nonce" in qs + + +class TestServiceOIDCGetFailure: + endpoint: str = "@login-oidc/oidc" + + @pytest.fixture(autouse=True) + def _initialize(self, api_anon_request, installer): + installer.uninstall_product(PACKAGE_NAME) + self.api_session = api_anon_request + transaction.commit() + + def test_login_oidc_not_found(self): + response = self.api_session.get(self.endpoint) + assert response.status_code == 404 + data = response.json() + assert isinstance(data, dict) + + +class TestServiceOIDCLogout: + endpoint: str = "@logout-oidc" + + @pytest.fixture(autouse=True) + def _initialize(self, api_anon_request, keycloak_login): + login_endpoint = "@login-oidc/oidc" + self.api_session = api_anon_request + response = self.api_session.get(login_endpoint) + data = response.json() + next_url = data["next_url"] + qs = keycloak_login(next_url) + self.api_session.post(login_endpoint, json={"qs": qs}) + + def test_logout(self): + response = self.api_session.get(self.endpoint) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) diff --git a/tests/services/test_services_oidc_post.py b/tests/services/test_services_oidc_post.py new file mode 100644 index 0000000..043976b --- /dev/null +++ b/tests/services/test_services_oidc_post.py @@ -0,0 +1,92 @@ +from plone import api + +import pytest +import transaction + + +@pytest.fixture() +def wrong_url(): + def func(url: str): + url = url.replace("localhost", "wrong.localhost") + return url + + return func + + +class TestServiceOIDCPost: + endpoint: str = "@login-oidc/oidc" + + @pytest.fixture(autouse=True) + def _initialize(self, api_anon_request): + self.api_session = api_anon_request + + @pytest.fixture() + def bad_scope(self, portal): + plugin = portal.acl_users.oidc + original_scope = plugin.scope + scope = [item for item in original_scope if item != "openid"] + plugin.scope = scope + transaction.commit() + yield scope + plugin.scope = original_scope + transaction.commit() + + def test_login_oidc_post_wrong_traverse(self): + """Pointing to a wrong traversal should raise a 404.""" + url = f"{self.endpoint}-wrong" + response = self.api_session.post(url, json={"qs": "foo=bar"}) + assert response.status_code == 404 + data = response.json() + assert isinstance(data, dict) + assert data["error"]["type"] == "Provider not found" + assert data["error"]["message"] == "Provider oidc-wrong is not available." + + def test_login_oidc_post_success(self, keycloak_login): + """We need to follow the whole flow.""" + # First get the response from out GET endpoint + response = self.api_session.get(self.endpoint) + data = response.json() + next_url = data["next_url"] + # Authenticate on keycloak with the url generated by + # the GET endpoint + qs = keycloak_login(next_url) + # Now we do a POST request to our endpoint, passing the + # returned querystring in the payload + response = self.api_session.post(self.endpoint, json={"qs": qs}) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + assert data["next_url"] == api.portal.get().absolute_url() + assert "token" in data + + def test_login_oidc_post_failure_redirect(self, keycloak_login, wrong_url): + """Invalid data on the flow could lead to errors.""" + response = self.api_session.get(self.endpoint) + data = response.json() + # Modifying the return url will break the flow + next_url = wrong_url(data["next_url"]) + qs = keycloak_login(next_url) + # Now we do a POST request to our endpoint, passing the + # returned querystring in the payload + response = self.api_session.post(self.endpoint, json={"qs": qs}) + assert response.status_code == 401 + data = response.json() + assert isinstance(data, dict) + assert data["error"]["type"] == "Authentication Error" + assert data["error"]["message"] == "There was an issue authenticating this user" + + def test_login_oidc_post_failure_missing_scope(self, keycloak_login, bad_scope): + """Invalid scope lead to error in callback.""" + response = self.api_session.get(self.endpoint) + data = response.json() + # Modifying the return url will break the flow + next_url = data["next_url"] + qs = keycloak_login(next_url) + # Now we do a POST request to our endpoint, passing the + # returned querystring in the payload + response = self.api_session.post(self.endpoint, json={"qs": qs}) + assert response.status_code == 401 + data = response.json() + assert isinstance(data, dict) + assert data["error"]["type"] == "Authentication Error" + assert data["error"]["message"] == "There was an issue authenticating this user" diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py index 06f2513..9c79e6e 100644 --- a/tests/setup/test_setup_install.py +++ b/tests/setup/test_setup_install.py @@ -24,15 +24,15 @@ def test_browserlayer(self, browser_layers): def test_plugin_added(self): """Test if plugin is added to acl_users.""" - from pas.plugins.oidc.utils import PLUGIN_ID + from pas.plugins.oidc import PLUGIN_ID pas = api.portal.get_tool("acl_users") assert PLUGIN_ID in pas.objectIds() def test_plugin_is_oidc(self): """Test if we have the correct plugin.""" + from pas.plugins.oidc import PLUGIN_ID from pas.plugins.oidc.plugins import OIDCPlugin - from pas.plugins.oidc.utils import PLUGIN_ID pas = api.portal.get_tool("acl_users") plugin = getattr(pas, PLUGIN_ID) diff --git a/tests/setup/test_setup_uninstall.py b/tests/setup/test_setup_uninstall.py index 9413f34..1b9c904 100644 --- a/tests/setup/test_setup_uninstall.py +++ b/tests/setup/test_setup_uninstall.py @@ -21,7 +21,7 @@ def test_browserlayer(self, browser_layers): def test_plugin_removed(self, portal): """Test if plugin is removed to acl_users.""" - from pas.plugins.oidc.utils import PLUGIN_ID + from pas.plugins.oidc import PLUGIN_ID pas = api.portal.get_tool("acl_users") assert PLUGIN_ID not in pas.objectIds() diff --git a/tests/utils/test_utils_flow.py b/tests/utils/test_utils_flow.py new file mode 100644 index 0000000..9dc56f2 --- /dev/null +++ b/tests/utils/test_utils_flow.py @@ -0,0 +1,70 @@ +from pas.plugins.oidc import utils +from pas.plugins.oidc.session import Session + +import os +import pytest + + +class TestUtilsFlowStart: + @pytest.fixture(autouse=True) + def _initialize(self, portal, http_request): + self.portal = portal + self.http_request = http_request + self.plugin = utils.get_plugin() + + @pytest.fixture() + def session_factory(self): + def func(): + return utils.initialize_session(self.plugin, self.http_request) + + return func + + def test_initialize_session_default(self): + func = utils.initialize_session + session = func(self.plugin, self.http_request) + assert isinstance(session, Session) + assert isinstance(session.get("state"), str) + assert isinstance(session.get("nonce"), str) + # No came_from in the request + assert session.get("came_from") is None + # By default we do not use pkce + assert session.get("verifier") is None + + def test_initialize_session_came_from(self): + func = utils.initialize_session + came_from = f"{self.portal.absolute_url()}/a-page" + self.http_request.set("came_from", came_from) + session = func(self.plugin, self.http_request) + assert session.get("came_from") == came_from + + def test_initialize_session_verifier(self): + func = utils.initialize_session + self.plugin.use_pkce = True + session = func(self.plugin, self.http_request) + assert isinstance(session.get("verifier"), str) + + def test_pkce_code_verifier_challenge(self): + func = utils.pkce_code_verifier_challenge + value = str(os.urandom(40)) + result = func(value) + assert isinstance(result, str) + assert "=" not in result + + def test_authorization_flow_args(self, session_factory): + func = utils.authorization_flow_args + result = func(self.plugin, session_factory()) + assert isinstance(result, dict) + assert result["client_id"] == self.plugin.client_id + assert result["response_type"] == "code" + assert result["scope"] == ["profile", "email", "phone"] + assert isinstance(result["state"], str) + assert isinstance(result["nonce"], str) + assert "code_challenge" not in result + assert "code_challenge_method" not in result + + def test_authorization_flow_args_pkce(self, session_factory): + self.plugin.use_pkce = True + func = utils.authorization_flow_args + result = func(self.plugin, session_factory()) + assert isinstance(result["code_challenge"], str) + assert isinstance(result["code_challenge_method"], str) diff --git a/tests/utils/test_utils_str.py b/tests/utils/test_utils_str.py new file mode 100644 index 0000000..22a94fb --- /dev/null +++ b/tests/utils/test_utils_str.py @@ -0,0 +1,35 @@ +from pas.plugins.oidc import utils + +import pytest + + +class TestUtilsBooleanSer: + @pytest.mark.parametrize( + "value,expected", + [ + (0, False), + ("0", True), + ("", False), + (1, True), + (False, False), + (True, True), + ], + ) + def test_boolean_string_ser(self, value, expected): + func = utils.boolean_string_ser + assert func(value) is expected + + +class TestUtilsBooleanDeSer: + @pytest.mark.parametrize( + "value,expected", + [ + (False, False), + ("true", True), + ("false", False), + (True, True), + ], + ) + def test_boolean_string_deser(self, value, expected): + func = utils.boolean_string_deser + assert func(value) is expected diff --git a/tests/utils/test_utils_url.py b/tests/utils/test_utils_url.py new file mode 100644 index 0000000..320c397 --- /dev/null +++ b/tests/utils/test_utils_url.py @@ -0,0 +1,53 @@ +from pas.plugins.oidc import utils +from plone import api + +import pytest + + +class TestUtilsURL: + @pytest.mark.parametrize( + "url,expected", + [ + ("http://plone.org/foo/bar", "http://plone.org/foo/bar"), + ("http://plone.org/++api++", "http://plone.org"), + ( + "http://plone.org/++api++/login-oidc/oidc", + "http://plone.org/login-oidc/oidc", + ), + ("http://plone.org/api", "http://plone.org"), + ( + "http://plone.org/api/login-oidc/oidc", + "http://plone.org/login-oidc/oidc", + ), + ], + ) + def test_url_cleanup(self, url, expected): + func = utils.url_cleanup + assert func(url) == expected + + +class TestUtilsProcessCameFrom: + @pytest.fixture(autouse=True) + def _initialize(self, portal): + from pas.plugins.oidc.session import Session + + request = api.env.getRequest() + session = Session(request, False) + session.set("came_from", f"{portal.absolute_url()}/a-page") + self.portal = portal + self.session = session + + def test_process_came_from_session(self): + func = utils.process_came_from + assert func(self.session) == f"{self.portal.absolute_url()}/a-page" + + def test_process_came_from_param(self): + func = utils.process_came_from + came_from = f"{self.portal.absolute_url()}/a-file" + assert func(self.session, came_from) == came_from + + def test_process_came_from_param_with_external_url(self): + func = utils.process_came_from + portal_url = self.portal.absolute_url() + came_from = "https://plone.org/" + assert func(self.session, came_from) == portal_url