From dfd5651dbea225d7b6a4470b6f4a3720d0e684fc Mon Sep 17 00:00:00 2001 From: Revant Nandgaonkar Date: Mon, 26 Apr 2021 18:42:39 +0530 Subject: [PATCH] refactor: improve oauthlib implementation implement openid provider implement PKCE improve errors (cherry picked from commit 96d6971ee45dfcbf9393568019ceeda631aba74e) --- .../doctype/event_producer/event_producer.py | 3 +- .../oauth_authorization_code.json | 340 ++++------- .../oauth_bearer_token.json | 349 +++-------- frappe/integrations/oauth2.py | 229 ++++---- frappe/oauth.py | 544 ++++++++++++------ frappe/tests/test_oauth20.py | 3 +- frappe/utils/__init__.py | 10 +- 7 files changed, 673 insertions(+), 805 deletions(-) diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py index e43b4d131c..26b6d5dde5 100644 --- a/frappe/event_streaming/doctype/event_producer/event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/event_producer.py @@ -15,7 +15,6 @@ from frappe.utils.background_jobs import get_jobs from frappe.utils.data import get_url, get_link_to_form from frappe.utils.password import get_decrypted_password from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.integrations.oauth2 import validate_url class EventProducer(Document): @@ -56,7 +55,7 @@ class EventProducer(Document): self.reload() def check_url(self): - if not validate_url(self.producer_url): + if not frappe.utils.validate_url(self.producer_url): frappe.throw(_('Invalid URL')) # remove '/' from the end of the url like http://test_site.com/ diff --git a/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.json b/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.json index 13150f6cb3..2cd21bcaf4 100644 --- a/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.json +++ b/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.json @@ -1,256 +1,112 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:authorization_code", - "beta": 0, - "creation": "2016-08-24 14:12:13.647159", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "field:authorization_code", + "creation": "2016-08-24 14:12:13.647159", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "client", + "user", + "scopes", + "authorization_code", + "expiration_time", + "redirect_uri_bound_to_authorization_code", + "validity", + "nonce", + "code_challenge", + "code_challenge_method" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "client", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Client", - "length": 0, - "no_copy": 0, - "options": "OAuth Client", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "client", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Client", + "options": "OAuth Client", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "user", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "User", - "length": 0, - "no_copy": 0, - "options": "User", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "scopes", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Scopes", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "scopes", + "fieldtype": "Text", + "label": "Scopes", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "authorization_code", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Authorization Code", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "authorization_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Authorization Code", + "read_only": 1, + "unique": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "expiration_time", - "fieldtype": "Datetime", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Expiration time", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "expiration_time", + "fieldtype": "Datetime", + "label": "Expiration time", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "redirect_uri_bound_to_authorization_code", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Redirect URI Bound To Auth Code", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "redirect_uri_bound_to_authorization_code", + "fieldtype": "Data", + "label": "Redirect URI Bound To Auth Code", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "validity", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Validity", - "length": 0, - "no_copy": 0, - "options": "Valid\nInvalid", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "validity", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Validity", + "options": "Valid\nInvalid", + "read_only": 1 + }, + { + "fieldname": "nonce", + "fieldtype": "Data", + "label": "nonce", + "read_only": 1 + }, + { + "fieldname": "code_challenge", + "fieldtype": "Data", + "label": "Code Challenge", + "read_only": 1 + }, + { + "fieldname": "code_challenge_method", + "fieldtype": "Select", + "label": "Code challenge method", + "options": "\ns256\nplain", + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-03-08 14:40:04.113884", - "modified_by": "Administrator", - "module": "Integrations", - "name": "OAuth Authorization Code", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2021-04-26 07:23:02.980612", + "modified_by": "Administrator", + "module": "Integrations", + "name": "OAuth Authorization Code", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "is_custom": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.json b/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.json index aec5320ccc..083f1c9c54 100644 --- a/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.json +++ b/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.json @@ -1,283 +1,96 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:access_token", - "beta": 0, - "creation": "2016-08-24 14:10:17.471264", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "field:access_token", + "creation": "2016-08-24 14:10:17.471264", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "client", + "user", + "scopes", + "access_token", + "refresh_token", + "expiration_time", + "expires_in", + "status" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "client", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Client", - "length": 0, - "no_copy": 0, - "options": "OAuth Client", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "client", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Client", + "options": "OAuth Client", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "user", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "User", - "length": 0, - "no_copy": 0, - "options": "User", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "scopes", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Scopes", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "scopes", + "fieldtype": "Text", + "label": "Scopes", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "access_token", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Access Token", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "access_token", + "fieldtype": "Data", + "label": "Access Token", + "read_only": 1, + "unique": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "refresh_token", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Refresh Token", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "refresh_token", + "fieldtype": "Data", + "label": "Refresh Token", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "expiration_time", - "fieldtype": "Datetime", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Expiration time", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "expiration_time", + "fieldtype": "Datetime", + "label": "Expiration time", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "expires_in", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Expires In", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "expires_in", + "fieldtype": "Int", + "label": "Expires In", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Status", - "length": 0, - "no_copy": 0, - "options": "Active\nRevoked", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "options": "Active\nRevoked", + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-03-08 14:40:04.209039", - "modified_by": "Administrator", - "module": "Integrations", - "name": "OAuth Bearer Token", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2021-04-26 06:40:34.922441", + "modified_by": "Administrator", + "module": "Integrations", + "name": "OAuth Bearer Token", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "is_custom": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 3ebaaffcff..40df6210aa 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,195 +1,176 @@ -from __future__ import unicode_literals - -import hashlib import json -from urllib.parse import quote, urlencode, urlparse - -import jwt +from urllib.parse import quote, urlencode from oauthlib.oauth2 import FatalClientError, OAuth2Error +from oauthlib.openid.connect.core.endpoints.pre_configured import ( + Server as WebApplicationServer, +) import frappe -from frappe import _ -from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer -from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings +from frappe.oauth import OAuthWebRequestValidator, generate_json_error_response +from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import ( + get_oauth_settings, +) + def get_oauth_server(): - if not getattr(frappe.local, 'oauth_server', None): + if not getattr(frappe.local, "oauth_server", None): oauth_validator = OAuthWebRequestValidator() frappe.local.oauth_server = WebApplicationServer(oauth_validator) return frappe.local.oauth_server + def sanitize_kwargs(param_kwargs): """Remove 'data' and 'cmd' keys, if present.""" arguments = param_kwargs - arguments.pop('data', None) - arguments.pop('cmd', None) + arguments.pop("data", None) + arguments.pop("cmd", None) return arguments + @frappe.whitelist() def approve(*args, **kwargs): r = frappe.request try: - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( - r.url, - r.method, - r.get_data(), - r.headers + ( + scopes, + frappe.flags.oauth_credentials, + ) = get_oauth_server().validate_authorization_request( + r.url, r.method, r.get_data(), r.headers ) headers, body, status = get_oauth_server().create_authorization_response( - uri=frappe.flags.oauth_credentials['redirect_uri'], + uri=frappe.flags.oauth_credentials["redirect_uri"], body=r.get_data(), headers=r.headers, scopes=scopes, - credentials=frappe.flags.oauth_credentials + credentials=frappe.flags.oauth_credentials, ) - uri = headers.get('Location', None) + uri = headers.get("Location", None) frappe.local.response["type"] = "redirect" frappe.local.response["location"] = uri + return + + except (FatalClientError, OAuth2Error) as e: + return generate_json_error_response(e) - except FatalClientError as e: - return e - except OAuth2Error as e: - return e @frappe.whitelist(allow_guest=True) def authorize(**kwargs): - success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs)) + success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params( + sanitize_kwargs(kwargs) + ) failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" - if frappe.session.user == 'Guest': - #Force login, redirect to preauth again. + if frappe.session.user == "Guest": + # Force login, redirect to preauth again. frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url}) + frappe.local.response["location"] = "/login?" + encode_params( + {"redirect-to": frappe.request.url} + ) else: try: r = frappe.request - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( - r.url, - r.method, - r.get_data(), - r.headers + ( + scopes, + frappe.flags.oauth_credentials, + ) = get_oauth_server().validate_authorization_request( + r.url, r.method, r.get_data(), r.headers ) - skip_auth = frappe.db.get_value("OAuth Client", frappe.flags.oauth_credentials['client_id'], "skip_authorization") - unrevoked_tokens = frappe.get_all("OAuth Bearer Token", filters={"status":"Active"}) + skip_auth = frappe.db.get_value( + "OAuth Client", + frappe.flags.oauth_credentials["client_id"], + "skip_authorization", + ) + unrevoked_tokens = frappe.get_all( + "OAuth Bearer Token", filters={"status": "Active"} + ) - if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens): + if skip_auth or ( + get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens + ): frappe.local.response["type"] = "redirect" frappe.local.response["location"] = success_url else: - #Show Allow/Deny screen. - response_html_params = frappe._dict({ - "client_id": frappe.db.get_value("OAuth Client", kwargs['client_id'], "app_name"), - "success_url": success_url, - "failure_url": failure_url, - "details": scopes - }) - resp_html = frappe.render_template("templates/includes/oauth_confirmation.html", response_html_params) + # Show Allow/Deny screen. + response_html_params = frappe._dict( + { + "client_id": frappe.db.get_value( + "OAuth Client", kwargs["client_id"], "app_name" + ), + "success_url": success_url, + "failure_url": failure_url, + "details": scopes, + } + ) + resp_html = frappe.render_template( + "templates/includes/oauth_confirmation.html", response_html_params + ) frappe.respond_as_web_page("Confirm Access", resp_html) - except FatalClientError as e: - return e - except OAuth2Error as e: - return e + except (FatalClientError, OAuth2Error) as e: + return generate_json_error_response(e) + @frappe.whitelist(allow_guest=True) def get_token(*args, **kwargs): - #Check whether frappe server URL is set - frappe_server_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None - if not frappe_server_url: - frappe.throw(_("Please set Base URL in Social Login Key for Frappe")) - try: r = frappe.request headers, body, status = get_oauth_server().create_token_response( - r.url, - r.method, - r.form, - r.headers, - frappe.flags.oauth_credentials + r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials ) - out = frappe._dict(json.loads(body)) - if not out.error and "openid" in out.scope: - token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user") - token_client = frappe.db.get_value("OAuth Bearer Token", out.access_token, "client") - client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret") - if token_user in ["Guest", "Administrator"]: - frappe.throw(_("Logged in as Guest or Administrator")) + body = frappe._dict(json.loads(body)) - id_token_header = { - "typ":"jwt", - "alg":"HS256" - } - id_token = { - "aud": token_client, - "exp": int((frappe.db.get_value("OAuth Bearer Token", out.access_token, "expiration_time") - frappe.utils.datetime.datetime(1970, 1, 1)).total_seconds()), - "sub": frappe.db.get_value("User Social Login", {"parent":token_user, "provider": "frappe"}, "userid"), - "iss": frappe_server_url, - "at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256) - } + if body.error: + frappe.local.response = body + frappe.local.response["http_status_code"] = 400 + return - id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) - out.update({"id_token": frappe.safe_decode(id_token_encoded)}) + frappe.local.response = body + return - frappe.local.response = out - - except FatalClientError as e: - return e + except (FatalClientError, OAuth2Error) as e: + return generate_json_error_response(e) @frappe.whitelist(allow_guest=True) def revoke_token(*args, **kwargs): - r = frappe.request - headers, body, status = get_oauth_server().create_revocation_response( - r.url, - headers=r.headers, - body=r.form, - http_method=r.method - ) + try: + r = frappe.request + headers, body, status = get_oauth_server().create_revocation_response( + r.url, + headers=r.headers, + body=r.form, + http_method=r.method, + ) + except (FatalClientError, OAuth2Error): + pass + + # status_code must be 200 + frappe.local.response = frappe._dict({}) + frappe.local.response["http_status_code"] = status or 200 + return - frappe.local.response['http_status_code'] = status - if status == 200: - return "success" - else: - return "bad request" @frappe.whitelist() def openid_profile(*args, **kwargs): - picture = None - first_name, last_name, avatar, name = frappe.db.get_value("User", frappe.session.user, ["first_name", "last_name", "user_image", "name"]) - frappe_userid = frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid") - request_url = urlparse(frappe.request.url) - base_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None - - if avatar: - if validate_url(avatar): - picture = avatar - elif base_url: - picture = base_url + '/' + avatar - else: - picture = request_url.scheme + "://" + request_url.netloc + avatar - - user_profile = frappe._dict({ - "sub": frappe_userid, - "name": " ".join(filter(None, [first_name, last_name])), - "given_name": first_name, - "family_name": last_name, - "email": name, - "picture": picture - }) - - frappe.local.response = user_profile - -def validate_url(url_string): try: - result = urlparse(url_string) - return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] - except: - return False + r = frappe.request + headers, body, status = get_oauth_server().create_userinfo_response( + r.url, + headers=r.headers, + body=r.form, + ) + body = frappe._dict(json.loads(body)) + frappe.local.response = body + return + + except (FatalClientError, OAuth2Error) as e: + return generate_json_error_response(e) + def encode_params(params): """ diff --git a/frappe/oauth.py b/frappe/oauth.py index 09af5ad809..dcb57b3063 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -1,65 +1,16 @@ -from __future__ import print_function, unicode_literals -import frappe import pytz +import jwt +import hashlib +import re +import base64 +import datetime -from frappe import _ -from frappe.auth import LoginManager from http import cookies -from oauthlib.oauth2.rfc6749.tokens import BearerToken -from oauthlib.oauth2.rfc6749.grant_types import AuthorizationCodeGrant, ImplicitGrant, ResourceOwnerPasswordCredentialsGrant, ClientCredentialsGrant, RefreshTokenGrant -from oauthlib.oauth2 import RequestValidator -from oauthlib.oauth2.rfc6749.endpoints.authorization import AuthorizationEndpoint -from oauthlib.oauth2.rfc6749.endpoints.token import TokenEndpoint -from oauthlib.oauth2.rfc6749.endpoints.resource import ResourceEndpoint -from oauthlib.oauth2.rfc6749.endpoints.revocation import RevocationEndpoint -from oauthlib.common import Request -from six.moves.urllib.parse import unquote +from oauthlib.openid import RequestValidator +from urllib.parse import urlparse, unquote -def get_url_delimiter(separator_character=" "): - return separator_character - -class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, - RevocationEndpoint): - - """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" - - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): - """Construct a new web application server. - - :param request_validator: An implementation of - oauthlib.oauth2.RequestValidator. - :param token_expires_in: An int or a function to generate a token - expiration offset (in seconds) given a - oauthlib.common.Request object. - :param token_generator: A function to generate a token from a request. - :param refresh_token_generator: A function to generate a token from a - request for the refresh token. - :param kwargs: Extra parameters to pass to authorization-, - token-, resource-, and revocation-endpoint constructors. - """ - implicit_grant = ImplicitGrant(request_validator) - auth_grant = AuthorizationCodeGrant(request_validator) - refresh_grant = RefreshTokenGrant(request_validator) - resource_owner_password_credentials_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) - bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': auth_grant, - 'token': implicit_grant - }, - default_token_type=bearer) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': auth_grant, - 'refresh_token': refresh_grant, - 'password': resource_owner_password_credentials_grant - }, - default_token_type=bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': bearer}) - RevocationEndpoint.__init__(self, request_validator) +import frappe +from frappe.auth import LoginManager class OAuthWebRequestValidator(RequestValidator): @@ -67,7 +18,7 @@ class OAuthWebRequestValidator(RequestValidator): # Pre- and post-authorization. def validate_client_id(self, client_id, request, *args, **kwargs): # Simple validity check, does client exist? Not banned? - cli_id = frappe.db.get_value("OAuth Client",{ "name":client_id }) + cli_id = frappe.db.get_value("OAuth Client", {"name": client_id}) if cli_id: request.client = frappe.get_doc("OAuth Client", client_id).as_dict() return True @@ -78,7 +29,9 @@ class OAuthWebRequestValidator(RequestValidator): # Is the client allowed to use the supplied redirect_uri? i.e. has # the client previously registered this EXACT redirect uri. - redirect_uris = frappe.db.get_value("OAuth Client", client_id, 'redirect_uris').split(get_url_delimiter()) + redirect_uris = frappe.db.get_value( + "OAuth Client", client_id, "redirect_uris" + ).split(get_url_delimiter()) if redirect_uri in redirect_uris: return True @@ -89,7 +42,9 @@ class OAuthWebRequestValidator(RequestValidator): # The redirect used if none has been supplied. # Prefer your clients to pre register a redirect uri rather than # supplying one on each authorization request. - redirect_uri = frappe.db.get_value("OAuth Client", client_id, 'default_redirect_uri') + redirect_uri = frappe.db.get_value( + "OAuth Client", client_id, "default_redirect_uri" + ) return redirect_uri def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): @@ -101,19 +56,23 @@ class OAuthWebRequestValidator(RequestValidator): # Scopes a client will authorize for if none are supplied in the # authorization request. scopes = get_client_scopes(client_id) - request.scopes = scopes #Apparently this is possible. + request.scopes = scopes # Apparently this is possible. return scopes - def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): - # Clients should only be allowed to use one type of response type, the - # one associated with their one allowed grant type. - # In this case it must be "code". - allowed_response_types = [client.response_type.lower(), - "code token", "code id_token", "code token id_token", - "code+token", "code+id_token", "code+token id_token"] - - return (response_type in allowed_response_types) + def validate_response_type( + self, client_id, response_type, client, request, *args, **kwargs + ): + allowed_response_types = [ + # From OAuth Client response_type field + client.response_type.lower(), + # OIDC + "id_token", + "id_token token", + "code id_token", + "code token id_token", + ] + return response_type in allowed_response_types # Post-authorization @@ -121,38 +80,69 @@ class OAuthWebRequestValidator(RequestValidator): cookie_dict = get_cookie_dict_from_headers(request) - oac = frappe.new_doc('OAuth Authorization Code') + oac = frappe.new_doc("OAuth Authorization Code") oac.scopes = get_url_delimiter().join(request.scopes) oac.redirect_uri_bound_to_authorization_code = request.redirect_uri oac.client = client_id - oac.user = unquote(cookie_dict['user_id'].value) - oac.authorization_code = code['code'] + oac.user = unquote(cookie_dict["user_id"].value) + oac.authorization_code = code["code"] + + if request.nonce: + oac.nonce = request.nonce + + if request.code_challenge and request.code_challenge_method: + oac.code_challenge = request.code_challenge + oac.code_challenge_method = request.code_challenge_method.lower() + oac.save(ignore_permissions=True) frappe.db.commit() def authenticate_client(self, request, *args, **kwargs): - #Get ClientID in URL + # Get ClientID in URL if request.client_id: oc = frappe.get_doc("OAuth Client", request.client_id) else: - #Extract token, instantiate OAuth Bearer Token and use clientid from there. + # Extract token, instantiate OAuth Bearer Token and use clientid from there. if "refresh_token" in frappe.form_dict: - oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", {"refresh_token": frappe.form_dict["refresh_token"]}, 'client')) + oc = frappe.get_doc( + "OAuth Client", + frappe.db.get_value( + "OAuth Bearer Token", + {"refresh_token": frappe.form_dict["refresh_token"]}, + "client", + ), + ) elif "token" in frappe.form_dict: - oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], 'client')) + oc = frappe.get_doc( + "OAuth Client", + frappe.db.get_value( + "OAuth Bearer Token", frappe.form_dict["token"], "client" + ), + ) else: - oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.get_request_header("Authorization").split(" ")[1], 'client')) + oc = frappe.get_doc( + "OAuth Client", + frappe.db.get_value( + "OAuth Bearer Token", + frappe.get_request_header("Authorization").split(" ")[1], + "client", + ), + ) try: request.client = request.client or oc.as_dict() except Exception as e: - print("Failed body authentication: Application %s does not exist".format(cid=request.client_id)) + return generate_json_error_response(e) cookie_dict = get_cookie_dict_from_headers(request) - user_id = unquote(cookie_dict.get('user_id').value) if 'user_id' in cookie_dict else "Guest" + user_id = ( + unquote(cookie_dict.get("user_id").value) + if "user_id" in cookie_dict + else "Guest" + ) return frappe.session.user == user_id def authenticate_client_id(self, client_id, request, *args, **kwargs): - cli_id = frappe.db.get_value('OAuth Client', client_id, 'name') + cli_id = frappe.db.get_value("OAuth Client", client_id, "name") if not cli_id: # Don't allow public (non-authenticated) clients return False @@ -164,28 +154,66 @@ class OAuthWebRequestValidator(RequestValidator): # Validate the code belongs to the client. Add associated scopes, # state and user to request.scopes and request.user. - validcodes = frappe.get_all("OAuth Authorization Code", filters={"client": client_id, "validity": "Valid"}) + validcodes = frappe.get_all( + "OAuth Authorization Code", + filters={"client": client_id, "validity": "Valid"}, + ) checkcodes = [] for vcode in validcodes: checkcodes.append(vcode["name"]) if code in checkcodes: - request.scopes = frappe.db.get_value("OAuth Authorization Code", code, 'scopes').split(get_url_delimiter()) - request.user = frappe.db.get_value("OAuth Authorization Code", code, 'user') - return True - else: - return False + request.scopes = frappe.db.get_value( + "OAuth Authorization Code", code, "scopes" + ).split(get_url_delimiter()) + request.user = frappe.db.get_value("OAuth Authorization Code", code, "user") + code_challenge_method = frappe.db.get_value( + "OAuth Authorization Code", code, "code_challenge_method" + ) + code_challenge = frappe.db.get_value( + "OAuth Authorization Code", code, "code_challenge" + ) - def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): - saved_redirect_uri = frappe.db.get_value('OAuth Client', client_id, 'default_redirect_uri') + if code_challenge and not request.code_verifier: + if frappe.db.exists("OAuth Authorization Code", code): + frappe.delete_doc( + "OAuth Authorization Code", code, ignore_permissions=True + ) + frappe.db.commit() + return False + + if code_challenge_method == "s256": + m = hashlib.sha256() + m.update(bytes(request.code_verifier, "utf-8")) + code_verifier = base64.b64encode(m.digest()).decode("utf-8") + code_verifier = re.sub(r"\+", "-", code_verifier) + code_verifier = re.sub(r"\/", "_", code_verifier) + code_verifier = re.sub(r"=", "", code_verifier) + return code_challenge == code_verifier + + elif code_challenge_method == "plain": + return code_challenge == request.code_verifier + + return True + + return False + + def confirm_redirect_uri( + self, client_id, code, redirect_uri, client, *args, **kwargs + ): + saved_redirect_uri = frappe.db.get_value( + "OAuth Client", client_id, "default_redirect_uri" + ) return saved_redirect_uri == redirect_uri - def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + def validate_grant_type( + self, client_id, grant_type, client, request, *args, **kwargs + ): # Clients should only be allowed to use one type of grant. # In this case, it must be "authorization_code" or "refresh_token" - return (grant_type in ["authorization_code", "refresh_token", "password"]) + return grant_type in ["authorization_code", "refresh_token", "password"] def save_bearer_token(self, token, request, *args, **kwargs): # Remember to associate it with request.scopes, request.user and @@ -195,19 +223,29 @@ class OAuthWebRequestValidator(RequestValidator): # access_token to now + expires_in seconds. otoken = frappe.new_doc("OAuth Bearer Token") - otoken.client = request.client['name'] + otoken.client = request.client["name"] try: - otoken.user = request.user if request.user else frappe.db.get_value("OAuth Bearer Token", {"refresh_token":request.body.get("refresh_token")}, "user") + otoken.user = ( + request.user + if request.user + else frappe.db.get_value( + "OAuth Bearer Token", + {"refresh_token": request.body.get("refresh_token")}, + "user", + ) + ) except Exception as e: otoken.user = frappe.session.user otoken.scopes = get_url_delimiter().join(request.scopes) - otoken.access_token = token['access_token'] - otoken.refresh_token = token.get('refresh_token') - otoken.expires_in = token['expires_in'] + otoken.access_token = token["access_token"] + otoken.refresh_token = token.get("refresh_token") + otoken.expires_in = token["expires_in"] otoken.save(ignore_permissions=True) frappe.db.commit() - default_redirect_uri = frappe.db.get_value("OAuth Client", request.client['name'], "default_redirect_uri") + default_redirect_uri = frappe.db.get_value( + "OAuth Client", request.client["name"], "default_redirect_uri" + ) return default_redirect_uri def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): @@ -222,24 +260,35 @@ class OAuthWebRequestValidator(RequestValidator): def validate_bearer_token(self, token, scopes, request): # Remember to check expiration and scope membership otoken = frappe.get_doc("OAuth Bearer Token", token) - token_expiration_local = otoken.expiration_time.replace(tzinfo=pytz.timezone(frappe.utils.get_time_zone())) + token_expiration_local = otoken.expiration_time.replace( + tzinfo=pytz.timezone(frappe.utils.get_time_zone()) + ) token_expiration_utc = token_expiration_local.astimezone(pytz.utc) - is_token_valid = (frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) < token_expiration_utc) \ - and otoken.status != "Revoked" - client_scopes = frappe.db.get_value("OAuth Client", otoken.client, 'scopes').split(get_url_delimiter()) + is_token_valid = ( + frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + < token_expiration_utc + ) and otoken.status != "Revoked" + client_scopes = frappe.db.get_value( + "OAuth Client", otoken.client, "scopes" + ).split(get_url_delimiter()) are_scopes_valid = True for scp in scopes: - are_scopes_valid = are_scopes_valid and True if scp in client_scopes else False + are_scopes_valid = ( + are_scopes_valid and True if scp in client_scopes else False + ) return is_token_valid and are_scopes_valid # Token refresh request + def get_original_scopes(self, refresh_token, request, *args, **kwargs): # Obtain the token associated with the given refresh_token and # return its scopes, these will be passed on to the refreshed # access token if the client did not specify a scope during the # request. - obearer_token = frappe.get_doc("OAuth Bearer Token", {"refresh_token": refresh_token}) + obearer_token = frappe.get_doc( + "OAuth Bearer Token", {"refresh_token": refresh_token} + ) return obearer_token.scopes def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -250,36 +299,44 @@ class OAuthWebRequestValidator(RequestValidator): :param request: The HTTP Request (oauthlib.common.Request) Method is used by: - - Revocation Endpoint + - Revocation Endpoint """ otoken = None if token_type_hint == "access_token": - otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') + otoken = frappe.db.set_value( + "OAuth Bearer Token", token, "status", "Revoked" + ) elif token_type_hint == "refresh_token": - otoken = frappe.db.set_value("OAuth Bearer Token", {"refresh_token": token}, 'status', 'Revoked') + otoken = frappe.db.set_value( + "OAuth Bearer Token", {"refresh_token": token}, "status", "Revoked" + ) else: - otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') + otoken = frappe.db.set_value( + "OAuth Bearer Token", token, "status", "Revoked" + ) frappe.db.commit() def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): - # """Ensure the Bearer token is valid and authorized access to scopes. + """Ensure the Bearer token is valid and authorized access to scopes. - # OBS! The request.user attribute should be set to the resource owner - # associated with this refresh token. + OBS! The request.user attribute should be set to the resource owner + associated with this refresh token. - # :param refresh_token: Unicode refresh token - # :param client: Client object set by you, see authenticate_client. - # :param request: The HTTP Request (oauthlib.common.Request) - # :rtype: True or False + :param refresh_token: Unicode refresh token + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False - # Method is used by: - # - Authorization Code Grant (indirectly by issuing refresh tokens) - # - Resource Owner Password Credentials Grant (also indirectly) - # - Refresh Token Grant - # """ + Method is used by: + - Authorization Code Grant (indirectly by issuing refresh tokens) + - Resource Owner Password Credentials Grant (also indirectly) + - Refresh Token Grant + """ - otoken = frappe.get_doc("OAuth Bearer Token", {"refresh_token": refresh_token, "status": "Active"}) + otoken = frappe.get_doc( + "OAuth Bearer Token", {"refresh_token": refresh_token, "status": "Active"} + ) if not otoken: return False @@ -287,36 +344,84 @@ class OAuthWebRequestValidator(RequestValidator): return True # OpenID Connect - def get_id_token(self, token, token_handler, request): - """ - In the OpenID Connect workflows when an ID Token is requested this method is called. - Subclasses should implement the construction, signing and optional encryption of the - ID Token as described in the OpenID Connect spec. - In addition to the standard OAuth2 request properties, the request may also contain - these OIDC specific properties which are useful to this method: + def finalize_id_token(self, id_token, token, token_handler, request): + # Check whether frappe server URL is set + frappe_server_url = ( + frappe.db.get_value("Social Login Key", "frappe", "base_url") or request.uri + ) - - nonce, if workflow is implicit or hybrid and it was provided - - claims, if provided to the original Authorization Code request + id_token_header = {"typ": "jwt", "alg": "HS256"} - The token parameter is a dict which may contain an ``access_token`` entry, in which - case the resulting ID Token *should* include a calculated ``at_hash`` claim. + user = frappe.get_doc( + "User", + frappe.session.user, + ) - Similarly, when the request parameter has a ``code`` property defined, the ID Token - *should* include a calculated ``c_hash`` claim. + if request.nonce: + id_token["nonce"] = request.nonce - http://openid.net/specs/openid-connect-core-1_0.html (sections `3.1.3.6`_, `3.2.2.10`_, `3.3.2.11`_) + if "openid" in request.scopes: + userinfo = get_userinfo(user, request) + id_token.update(userinfo) - .. _`3.1.3.6`: http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken - .. _`3.2.2.10`: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken - .. _`3.3.2.11`: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken + id_token_encoded = jwt.encode( + payload=id_token, + key=request.client.client_secret, + algorithm="HS256", + headers=id_token_header, + ) - :param token: A Bearer token dict - :param token_handler: the token handler (BearerToken class) - :param request: the HTTP Request (oauthlib.common.Request) - :return: The ID Token (a JWS signed JWT) - """ - # the request.scope should be used by the get_id_token() method to determine which claims to include in the resulting id_token + return frappe.safe_decode(id_token_encoded) + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + if frappe.get_value("OAuth Authorization Code", code, "validity") == "Valid": + return frappe.get_value("OAuth Authorization Code", code, "nonce") + + return None + + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + scope = frappe.get_value("OAuth Client", client_id, "scopes") + if not scope: + scope = [] + else: + scope = scope.split(get_url_delimiter()) + + return scope + + def get_jwt_bearer_token(self, token, token_handler, request): + now = datetime.datetime.now() + id_token = dict( + aud=token.client_id, + iat=round(now.timestamp()), + at_hash=calculate_at_hash(token.access_token, hashlib.sha256), + ) + return self.finalize_id_token(id_token, token, token_handler, request) + + def get_userinfo_claims(self, request): + user = frappe.get_doc("User", frappe.session.user) + userinfo = get_userinfo(user, request) + return userinfo + + def validate_id_token(self, token, scopes, request): + try: + id_token = frappe.get_doc("OAuth Bearer Token", token) + if id_token.status == "Active": + return True + except Exception: + return False + + return False + + def validate_jwt_bearer_token(self, token, scopes, request): + try: + jwt = frappe.get_doc("OAuth Bearer Token", token) + if jwt.status == "Active": + return True + except Exception: + return False + + return False def validate_silent_authorization(self, request): """Ensure the logged in user has authorized silent OpenID authorization. @@ -328,9 +433,9 @@ class OAuthWebRequestValidator(RequestValidator): :rtype: True or False Method is used by: - - OpenIDConnectAuthCode - - OpenIDConnectImplicit - - OpenIDConnectHybrid + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid """ if request.prompt == "login": False @@ -351,9 +456,9 @@ class OAuthWebRequestValidator(RequestValidator): :rtype: True or False Method is used by: - - OpenIDConnectAuthCode - - OpenIDConnectImplicit - - OpenIDConnectHybrid + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid """ if frappe.session.user == "Guest" or request.prompt.lower() == "login": return False @@ -373,32 +478,77 @@ class OAuthWebRequestValidator(RequestValidator): :rtype: True or False Method is used by: - - OpenIDConnectAuthCode - - OpenIDConnectImplicit - - OpenIDConnectHybrid + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid """ - if id_token_hint and id_token_hint == frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid"): + if id_token_hint: + try: + user = None + payload = jwt.decode( + id_token_hint, + options={ + "verify_signature": False, + "verify_aud": False, + }, + ) + client_id, client_secret = frappe.get_value( + "OAuth Client", + payload.get("aud"), + ["client_id", "client_secret"], + ) + + if payload.get("sub") and client_id and client_secret: + user = frappe.db.get_value( + "User Social Login", + {"userid": payload.get("sub"), "provider": "frappe"}, + "parent", + ) + user = frappe.get_doc("User", user) + verified_payload = jwt.decode( + id_token_hint, + key=client_secret, + audience=client_id, + algorithm="HS256", + options={ + "verify_exp": False, + }, + ) + + if verified_payload: + return user.name == frappe.session.user + + except Exception as e: + return False + + elif frappe.session.user != "Guest": return True - else: - return False + + return False def validate_user(self, username, password, client, request, *args, **kwargs): """Ensure the username and password is valid. - Method is used by: - - Resource Owner Password Credentials Grant - """ + Method is used by: + - Resource Owner Password Credentials Grant + """ login_manager = LoginManager() login_manager.authenticate(username, password) + + if login_manager.user == "Guest": + return False + request.user = login_manager.user return True + def get_cookie_dict_from_headers(r): cookie = cookies.BaseCookie() - if r.headers.get('Cookie'): - cookie.load(r.headers.get('Cookie')) + if r.headers.get("Cookie"): + cookie.load(r.headers.get("Cookie")) return cookie + def calculate_at_hash(access_token, hash_alg): """Helper method for calculating an access token hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken @@ -409,21 +559,25 @@ def calculate_at_hash(access_token, hash_alg): then take the left-most 128 bits and base64url encode them. The at_hash value is a case sensitive string. Args: - access_token (str): An access token string. - hash_alg (callable): A callable returning a hash object, e.g. hashlib.sha256 + access_token (str): An access token string. + hash_alg (callable): A callable returning a hash object, e.g. hashlib.sha256 """ - hash_digest = hash_alg(access_token.encode('utf-8')).digest() + hash_digest = hash_alg(access_token.encode("utf-8")).digest() cut_at = int(len(hash_digest) / 2) truncated = hash_digest[:cut_at] from jwt.utils import base64url_encode + at_hash = base64url_encode(truncated) - return at_hash.decode('utf-8') + return at_hash.decode("utf-8") + def delete_oauth2_data(): # Delete Invalid Authorization Code and Revoked Token commit_code, commit_token = False, False - code_list = frappe.get_all("OAuth Authorization Code", filters={"validity":"Invalid"}) - token_list = frappe.get_all("OAuth Bearer Token", filters={"status":"Revoked"}) + code_list = frappe.get_all( + "OAuth Authorization Code", filters={"validity": "Invalid"} + ) + token_list = frappe.get_all("OAuth Bearer Token", filters={"status": "Revoked"}) if len(code_list) > 0: commit_code = True if len(token_list) > 0: @@ -439,3 +593,59 @@ def delete_oauth2_data(): def get_client_scopes(client_id): scopes_string = frappe.db.get_value("OAuth Client", client_id, "scopes") return scopes_string.split() + + +def get_userinfo(user, request): + picture = None + frappe_server_url = ( + frappe.db.get_value("Social Login Key", "frappe", "base_url") or None + ) + + request_url = urlparse(request.uri) + + if user.user_image: + if frappe.utils.validate_url(user.user_image): + picture = user.user_image + elif frappe_server_url: + picture = frappe_server_url + "/" + user.user_image + else: + picture = request_url.scheme + "://" + request_url.netloc + user.user_image + + userinfo = frappe._dict( + { + "sub": frappe.db.get_value( + "User Social Login", + {"parent": user.name, "provider": "frappe"}, + "userid", + ), + "name": " ".join(filter(None, [user.first_name, user.last_name])), + "given_name": user.first_name, + "family_name": user.last_name, + "email": user.email, + "picture": picture, + "roles": frappe.get_roles(user.name), + } + ) + + userinfo["iss"] = frappe_server_url or request.uri + + return userinfo + + +def get_url_delimiter(separator_character=" "): + return separator_character + + +def generate_json_error_response(e): + if not e: + e = frappe._dict({}) + + frappe.local.response = frappe._dict( + { + "description": getattr(e, "description", "Internal Server Error"), + "status_code": getattr(e, "status_code", 500), + "error": getattr(e, "error", "internal_server_error"), + } + ) + frappe.local.response["http_status_code"] = getattr(e, "status_code", 500) + return diff --git a/frappe/tests/test_oauth20.py b/frappe/tests/test_oauth20.py index e2213145b7..b5293da4d5 100644 --- a/frappe/tests/test_oauth20.py +++ b/frappe/tests/test_oauth20.py @@ -71,7 +71,8 @@ class TestOAuth20(unittest.TestCase): "grant_type": "authorization_code", "code": auth_code, "redirect_uri": self.redirect_uri, - "client_id": self.client_id + "client_id": self.client_id, + "scope": self.scope, }) ) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 251a095343..bd0be0457d 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -19,7 +19,7 @@ from gzip import GzipFile from typing import Generator, Iterable from six import string_types, text_type -from six.moves.urllib.parse import quote +from six.moves.urllib.parse import quote, urlparse from werkzeug.test import Client import frappe @@ -813,3 +813,11 @@ def groupby_metric(iterable: typing.Dict[str, list], key: str): for item in items: records.setdefault(item[key], {}).setdefault(category, []).append(item) return records + +def validate_url(url_string): + try: + result = urlparse(url_string) + return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] + except: + return False +