From 26f834bca068bd2d273d39fa1144342bf7cb731c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Tue, 14 Nov 2023 16:54:08 -0300 Subject: [PATCH 01/13] Implement restapi services to handle authentication flow --- news/38.feature | 1 + src/pas/plugins/oidc/__init__.py | 1 + src/pas/plugins/oidc/browser/view.py | 240 ++++------------ src/pas/plugins/oidc/configure.zcml | 12 +- src/pas/plugins/oidc/plugins.py | 13 +- src/pas/plugins/oidc/services/__init__.py | 0 src/pas/plugins/oidc/services/configure.zcml | 11 + .../plugins/oidc/services/login/__init__.py | 0 .../oidc/services/login/configure.zcml | 15 + src/pas/plugins/oidc/services/login/get.py | 37 +++ .../plugins/oidc/services/oidc/__init__.py | 0 .../plugins/oidc/services/oidc/configure.zcml | 31 +++ src/pas/plugins/oidc/services/oidc/oidc.py | 259 ++++++++++++++++++ src/pas/plugins/oidc/session.py | 39 +++ src/pas/plugins/oidc/setuphandlers.py | 2 +- src/pas/plugins/oidc/testing.py | 7 + src/pas/plugins/oidc/utils.py | 164 ++++++++++- tests/conftest.py | 35 +++ tests/functional/conftest.py | 66 ----- tests/functional/test_functional.py | 2 +- tests/keycloak/import/plone-test-realm.json | 2 +- tests/plugin/test_plugin.py | 2 +- tests/services/conftest.py | 104 +++++++ tests/services/test_services_login_get.py | 28 ++ tests/services/test_services_oidc_get.py | 44 +++ tests/services/test_services_oidc_post.py | 64 +++++ tests/setup/test_setup_install.py | 4 +- tests/setup/test_setup_uninstall.py | 2 +- 28 files changed, 903 insertions(+), 282 deletions(-) create mode 100644 news/38.feature create mode 100644 src/pas/plugins/oidc/services/__init__.py create mode 100644 src/pas/plugins/oidc/services/configure.zcml create mode 100644 src/pas/plugins/oidc/services/login/__init__.py create mode 100644 src/pas/plugins/oidc/services/login/configure.zcml create mode 100644 src/pas/plugins/oidc/services/login/get.py create mode 100644 src/pas/plugins/oidc/services/oidc/__init__.py create mode 100644 src/pas/plugins/oidc/services/oidc/configure.zcml create mode 100644 src/pas/plugins/oidc/services/oidc/oidc.py create mode 100644 src/pas/plugins/oidc/session.py create mode 100644 tests/services/conftest.py create mode 100644 tests/services/test_services_login_get.py create mode 100644 tests/services/test_services_oidc_get.py create mode 100644 tests/services/test_services_oidc_post.py 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/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..d56a25b --- /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..60799ab --- /dev/null +++ b/src/pas/plugins/oidc/services/oidc/oidc.py @@ -0,0 +1,259 @@ +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 plone import api +from plone.protect.interfaces import IDisableCSRFProtection +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from Products.PluggableAuthService.interfaces.plugins import IAuthenticationPlugin +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 = None + _data = None + + def publishTraverse(self, request, name): + # Store the first path segment as the provider + request["TraversalRequestNameStack"] = [] + self.provider_id = name + return self + + @property + def json_body(self): + if not self._data: + self._data = json_body(self.request) + return self._data + + @property + def plugin(self): + 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): + 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 = self.provider_id or "oidc" + plugin = self.plugin + if not (plugin and provider == "oidc"): + return self._provider_not_found(provider) + + try: + client = self.context.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).""" + + _pas = None + + def check_permission(self): + return True + + def _get_pas(self): + """Get the acl_users tool. + + :returns: ACL tool. + """ + if not self._pas: + self._pas = api.portal.get_tool("acl_users") + return self._pas + + def _get_jwt_plugin(self): + """Get the JWT authentication plugin. + + :returns: JWT Authentication plugin. + """ + pas = self._get_pas() + plugins = pas._getOb("plugins") + authenticators = plugins.listPlugins(IAuthenticationPlugin) + plugin = None + for id_, authenticator in authenticators: + if authenticator.meta_type == "JWT Authentication Plugin": + plugin = authenticator + break + return plugin + + def _annotate_transaction(self, action, user): + """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 or "oidc" + 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..b1fe6cc 100644 --- a/src/pas/plugins/oidc/utils.py +++ b/src/pas/plugins/oidc/utils.py @@ -1,13 +1,18 @@ +from hashlib import sha256 +from oic import rndstr 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 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 def boolean_string_ser(val, sformat=None, lev=0): @@ -31,7 +36,7 @@ def boolean_string_deser(val, sformat=None, lev=0): ) -class CustomOpenIDNonBooleanSchema(OpenIDSchema): +class CustomOpenIDNonBooleanSchema(message.OpenIDSchema): c_param = { "sub": SINGLE_REQUIRED_STRING, "name": SINGLE_OPTIONAL_STRING, @@ -51,8 +56,149 @@ class CustomOpenIDNonBooleanSchema(OpenIDSchema): "locale": SINGLE_OPTIONAL_STRING, "phone_number": SINGLE_OPTIONAL_STRING, "phone_number_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING, - "address": OPTIONAL_ADDRESS, + "address": message.OPTIONAL_ADDRESS, "updated_at": SINGLE_OPTIONAL_INT, - "_claim_names": OPTIONAL_MESSAGE, - "_claim_sources": OPTIONAL_MESSAGE, + "_claim_names": message.OPTIONAL_MESSAGE, + "_claim_sources": message.OPTIONAL_MESSAGE, + } + + +def url_cleanup(url: str) -> str: + """Clean up redirection url.""" + # Volto frontend mapping exception + if url.endswith("/api") or url.endswith("/++api++"): + url = "/".join(url.split("/")[-1]) + 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. + 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"]) + + user_info = client.do_user_info_request(state=state) + else: + user_info = 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 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..64baa33 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=30.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-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..faf75a4 --- /dev/null +++ b/tests/services/test_services_oidc_get.py @@ -0,0 +1,44 @@ +from urllib.parse import parse_qsl +from urllib.parse import urlparse + +import pytest + + +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 diff --git a/tests/services/test_services_oidc_post.py b/tests/services/test_services_oidc_post.py new file mode 100644 index 0000000..2d714e9 --- /dev/null +++ b/tests/services/test_services_oidc_post.py @@ -0,0 +1,64 @@ +from plone import api + +import pytest + + +@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 + + 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(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" 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() From 3b95cabf838d687f30e16f103852ed1f89aef83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 08:37:18 -0300 Subject: [PATCH 02/13] Increase timeout during keycloak service fixture initialization --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 64baa33..0cf25d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ def keycloak_service(docker_ip, docker_services): 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) + timeout=50.0, pause=0.1, check=lambda: is_responsive(url) ) return url From d448d23feba25f3d577d443c239e9a169611dba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 08:54:36 -0300 Subject: [PATCH 03/13] Improve utils.url_cleanup --- src/pas/plugins/oidc/utils.py | 13 +++++++++++-- tests/utils/test_utils_str.py | 34 ++++++++++++++++++++++++++++++++++ tests/utils/test_utils_url.py | 25 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/utils/test_utils_str.py create mode 100644 tests/utils/test_utils_url.py diff --git a/src/pas/plugins/oidc/utils.py b/src/pas/plugins/oidc/utils.py index b1fe6cc..3513ff8 100644 --- a/src/pas/plugins/oidc/utils.py +++ b/src/pas/plugins/oidc/utils.py @@ -13,6 +13,7 @@ from typing import Union import base64 +import re def boolean_string_ser(val, sformat=None, lev=0): @@ -63,11 +64,19 @@ class CustomOpenIDNonBooleanSchema(message.OpenIDSchema): } +_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 - if url.endswith("/api") or url.endswith("/++api++"): - url = "/".join(url.split("/")[-1]) + for search, replace in _URL_MAPPING: + match = re.search(search, url) + if match: + url = re.sub(search, replace, url) return url diff --git a/tests/utils/test_utils_str.py b/tests/utils/test_utils_str.py new file mode 100644 index 0000000..875107f --- /dev/null +++ b/tests/utils/test_utils_str.py @@ -0,0 +1,34 @@ +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..fa0c3ab --- /dev/null +++ b/tests/utils/test_utils_url.py @@ -0,0 +1,25 @@ +from pas.plugins.oidc import utils + +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 From 3d751684adc13c041056562bdfdef82f77f8d248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 08:59:56 -0300 Subject: [PATCH 04/13] Tests for utils.boolean_string_ser and boolean_string_deser --- tests/utils/test_utils_str.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_utils_str.py b/tests/utils/test_utils_str.py index 875107f..22a94fb 100644 --- a/tests/utils/test_utils_str.py +++ b/tests/utils/test_utils_str.py @@ -13,12 +13,13 @@ class TestUtilsBooleanSer: (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", @@ -27,7 +28,7 @@ class TestUtilsBooleanDeSer: ("true", True), ("false", False), (True, True), - ] + ], ) def test_boolean_string_deser(self, value, expected): func = utils.boolean_string_deser From fe476e6fbe2540db2172b24a7fd4b9aac3b06795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 09:09:20 -0300 Subject: [PATCH 05/13] Add tests for utils.process_came_from --- tests/utils/test_utils_url.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/utils/test_utils_url.py b/tests/utils/test_utils_url.py index fa0c3ab..320c397 100644 --- a/tests/utils/test_utils_url.py +++ b/tests/utils/test_utils_url.py @@ -1,4 +1,5 @@ from pas.plugins.oidc import utils +from plone import api import pytest @@ -23,3 +24,30 @@ class TestUtilsURL: 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 From 0b022ed731cdaf983619b7da13a721facd4f9a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 09:32:02 -0300 Subject: [PATCH 06/13] Tests for authorization flow helpers in utils --- tests/utils/test_utils_flow.py | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/utils/test_utils_flow.py 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) From 12af8142a01c9023e3bb5b7fa7aad558ae3008cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 10:51:54 -0300 Subject: [PATCH 07/13] Tests for logout endpoint --- .meta.toml | 2 +- pyproject.toml | 2 +- .../plugins/oidc/services/oidc/configure.zcml | 2 +- src/pas/plugins/oidc/services/oidc/oidc.py | 52 +++++-------------- tests/services/test_services_oidc_get.py | 38 ++++++++++++++ 5 files changed, 55 insertions(+), 41 deletions(-) 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/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/services/oidc/configure.zcml b/src/pas/plugins/oidc/services/oidc/configure.zcml index d56a25b..af61d5c 100644 --- a/src/pas/plugins/oidc/services/oidc/configure.zcml +++ b/src/pas/plugins/oidc/services/oidc/configure.zcml @@ -17,7 +17,7 @@ factory=".oidc.LogoutGet" for="Products.CMFPlone.interfaces.IPloneSiteRoot" permission="zope.Public" - name="@logout" + name="@logout-oidc" /> dict: if not self._data: self._data = json_body(self.request) return self._data @property - def plugin(self): + def plugin(self) -> OIDCPlugin: if not self._plugin: try: self._plugin = utils.get_plugin() @@ -65,7 +67,7 @@ def _provider_not_found(self, provider: str) -> dict: class Get(LoginOIDC): """Provide information to start the OIDC flow.""" - def check_permission(self): + def check_permission(self) -> bool: return True def reply(self) -> dict: @@ -119,13 +121,13 @@ def reply(self) -> dict: :returns: URL and session information. """ - provider = self.provider_id or "oidc" + provider = "oidc" plugin = self.plugin if not (plugin and provider == "oidc"): return self._provider_not_found(provider) try: - client = self.context.get_oauth2_client() + client = plugin.get_oauth2_client() except OAuth2ConnectionException: self.request.response.setStatus(500) return { @@ -163,36 +165,10 @@ def reply(self) -> dict: class Post(LoginOIDC): """Handles OIDC login and returns a JSON web token (JWT).""" - _pas = None - - def check_permission(self): + def check_permission(self) -> bool: return True - def _get_pas(self): - """Get the acl_users tool. - - :returns: ACL tool. - """ - if not self._pas: - self._pas = api.portal.get_tool("acl_users") - return self._pas - - def _get_jwt_plugin(self): - """Get the JWT authentication plugin. - - :returns: JWT Authentication plugin. - """ - pas = self._get_pas() - plugins = pas._getOb("plugins") - authenticators = plugins.listPlugins(IAuthenticationPlugin) - plugin = None - for id_, authenticator in authenticators: - if authenticator.meta_type == "JWT Authentication Plugin": - plugin = authenticator - break - return plugin - - def _annotate_transaction(self, action, user): + def _annotate_transaction(self, action: str, user: MemberData): """Add a note to the current transaction.""" try: # Get the current transaction @@ -214,7 +190,7 @@ def reply(self) -> dict: :returns: Token information. """ - provider = self.provider_id or "oidc" + provider = self.provider_id plugin = self.plugin if not (plugin and provider == "oidc"): return self._provider_not_found(provider) diff --git a/tests/services/test_services_oidc_get.py b/tests/services/test_services_oidc_get.py index faf75a4..04173c4 100644 --- a/tests/services/test_services_oidc_get.py +++ b/tests/services/test_services_oidc_get.py @@ -1,7 +1,9 @@ +from pas.plugins.oidc import PACKAGE_NAME from urllib.parse import parse_qsl from urllib.parse import urlparse import pytest +import transaction class TestServiceOIDCGet: @@ -42,3 +44,39 @@ def test_login_oidc_next_url(self): 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) From 1ad9ea5809705e70a5b4453b61a1eaf0ca25aa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 13:01:17 -0300 Subject: [PATCH 08/13] Add Makefile targets to manage Keycloak --- Makefile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 From b4cd20483c859b03f9ae1e080d60ae56a44428d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 13:01:43 -0300 Subject: [PATCH 09/13] Handle issue with missing scope --- src/pas/plugins/oidc/utils.py | 55 ++++++++++++----------- tests/keycloak/import/plone-realm.json | 2 +- tests/services/test_services_oidc_post.py | 30 ++++++++++++- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/pas/plugins/oidc/utils.py b/src/pas/plugins/oidc/utils.py index 3513ff8..4102a96 100644 --- a/src/pas/plugins/oidc/utils.py +++ b/src/pas/plugins/oidc/utils.py @@ -1,9 +1,6 @@ from hashlib import sha256 from oic import rndstr -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.exception import RequestError from oic.oic import message from pas.plugins.oidc import logger from pas.plugins.oidc import PLUGIN_ID @@ -32,33 +29,33 @@ 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(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": message.OPTIONAL_ADDRESS, - "updated_at": SINGLE_OPTIONAL_INT, + "updated_at": message.SINGLE_OPTIONAL_INT, "_claim_names": message.OPTIONAL_MESSAGE, "_claim_sources": message.OPTIONAL_MESSAGE, } @@ -177,6 +174,7 @@ def get_user_info(client, state, args) -> Union[message.OpenIDSchema, dict]: 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 @@ -187,11 +185,14 @@ def get_user_info(client, state, args) -> Union[message.OpenIDSchema, dict]: # userinfo = client.do_user_info_request(state=aresp["state"], user_info_schema=CustomOpenIDNonBooleanSchema) # else: # userinfo = client.do_user_info_request(state=aresp["state"]) - - user_info = client.do_user_info_request(state=state) - else: - user_info = resp.to_dict().get("id_token", {}) - + 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))): 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/services/test_services_oidc_post.py b/tests/services/test_services_oidc_post.py index 2d714e9..043976b 100644 --- a/tests/services/test_services_oidc_post.py +++ b/tests/services/test_services_oidc_post.py @@ -1,6 +1,7 @@ from plone import api import pytest +import transaction @pytest.fixture() @@ -19,6 +20,17 @@ class TestServiceOIDCPost: 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" @@ -47,7 +59,7 @@ def test_login_oidc_post_success(self, keycloak_login): assert data["next_url"] == api.portal.get().absolute_url() assert "token" in data - def test_login_oidc_post_failure(self, keycloak_login, wrong_url): + 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() @@ -62,3 +74,19 @@ def test_login_oidc_post_failure(self, keycloak_login, wrong_url): 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" From eae6f62917b2a81aab82df3e3cd5d905b83d1487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Thu, 16 Nov 2023 16:16:52 -0300 Subject: [PATCH 10/13] Update DEVELOP.md --- DEVELOP.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/DEVELOP.md b/DEVELOP.md index fe80117..ed7d503 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -7,6 +7,36 @@ Then install the dependencies and a development instance using: ```bash make build ``` + +### 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 @@ -34,3 +64,9 @@ 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 +``` From 31eda3963eb0bfb88232433e64dcc2d4ed5b5c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 20 Nov 2023 19:25:06 -0300 Subject: [PATCH 11/13] Update documentation --- DEVELOP.md | 72 ---------------- README.md | 226 +++++++++++++++++++++++++++++++------------------- docs/icon.png | Bin 0 -> 108520 bytes 3 files changed, 142 insertions(+), 156 deletions(-) delete mode 100644 DEVELOP.md create mode 100644 docs/icon.png diff --git a/DEVELOP.md b/DEVELOP.md deleted file mode 100644 index ed7d503..0000000 --- a/DEVELOP.md +++ /dev/null @@ -1,72 +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 -``` - -### 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 -``` diff --git a/README.md b/README.md index 5ccaa60..a3a7799 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
logo
+
logo

pas.plugins.oidc

@@ -70,12 +70,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 @@ -87,99 +96,45 @@ 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: +#### Stop Keycloak -.. image:: docs/screenshot-settings.png +To stop a running `Keycloak` (needed when running tests), use: + +```bash +make keycloak-stop +``` -**Warning:** +#### 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` @@ -201,6 +156,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`: *••••••••••••••••••••••••••••••••* (**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). @@ -218,7 +201,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. @@ -233,13 +218,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. @@ -255,6 +240,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 build +``` + +### 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 0000000000000000000000000000000000000000..79c687f2bf3319b9f72e71601ed90cb82a4e874a GIT binary patch literal 108520 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

JxQ(XG$|YI6@I0Kfj_Qwk!dU=g=W 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 UrDibvoapM(d1Rnbp=KBLKb$1=wEzGB literal 0 HcmV?d00001 From 2f04ee38b3d64b10315c2800094cecdcc0b7af30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 20 Nov 2023 19:27:16 -0300 Subject: [PATCH 12/13] Additional updates to the README file --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3a7799..a831bc4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,11 @@ The `plone` realm ships with an user that has the following credentials: * username: **user** * password: **12345678** +And, to configure the oidc plugins, please use: + +* client id: **plone** +* client secret: **12345678** + #### Stop Keycloak To stop a running `Keycloak` (needed when running tests), use: @@ -170,7 +175,7 @@ Specifically, here we will use a Docker image, so follow the instructions on how * 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`: *••••••••••••••••••••••••••••••••* (**Warning:** This property must match the `Client secret` you have get 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. From 55f037c7c406347e1ff90078c802e0a6e9b7024b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 20 Nov 2023 19:28:25 -0300 Subject: [PATCH 13/13] Fix typo in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a831bc4..4315e53 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ You need a working `python` environment (system, `virtualenv`, `pyenv`, etc) ver Then install the dependencies and a development instance using: ```bash -make build +make install ``` ### Start Local Server