diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index e21a1f7ac6..5338cf8844 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -144,8 +144,8 @@ jobs: DB: ${{ matrix.DB }} TYPE: ${{ matrix.TYPE }} - - name: Coverage - if: matrix.TYPE == 'server' + - name: Coverage - Pull Request + if: matrix.TYPE == 'server' && github.event_name == 'pull_request' run: | cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} cd ${GITHUB_WORKSPACE} @@ -156,3 +156,17 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} COVERALLS_SERVICE_NAME: github + + - name: Coverage - Push + if: matrix.TYPE == 'server' && github.event_name == 'push' + run: | + cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} + cd ${GITHUB_WORKSPACE} + pip install coveralls==2.2.0 + pip install coverage==4.5.4 + coveralls --service=github-actions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + COVERALLS_SERVICE_NAME: github-actions + diff --git a/frappe/__init__.py b/frappe/__init__.py index 1a495d8ce1..5aa2bcf436 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -10,11 +10,10 @@ be used to build database driven apps. Read the documentation: https://frappeframework.com/docs """ -from __future__ import unicode_literals, print_function -from six import iteritems, binary_type, text_type, string_types, PY2 +from six import iteritems, binary_type, text_type, string_types from werkzeug.local import Local, release_local -import os, sys, importlib, inspect, json +import os, sys, importlib, inspect, json, warnings import typing from past.builtins import cmp import click @@ -40,6 +39,8 @@ __title__ = "Frappe Framework" local = Local() controllers = {} +warnings.simplefilter('always', DeprecationWarning) +warnings.simplefilter('always', PendingDeprecationWarning) class _dict(dict): """dict like object that exposes keys as attributes""" diff --git a/frappe/app.py b/frappe/app.py index 794d0f18af..ac588c0945 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -294,6 +294,7 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No _sites_path = sites_path from werkzeug.serving import run_simple + patch_werkzeug_reloader() if profile: application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls')) @@ -324,3 +325,23 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No use_debugger=not in_test_env, use_evalex=not in_test_env, threaded=not no_threading) + +def patch_werkzeug_reloader(): + """ + This function monkey patches Werkzeug reloader to ignore reloading files in + the __pycache__ directory. + + To be deprecated when upgrading to Werkzeug 2. + """ + + from werkzeug._reloader import WatchdogReloaderLoop + + trigger_reload = WatchdogReloaderLoop.trigger_reload + + def custom_trigger_reload(self, filename): + if os.path.basename(os.path.dirname(filename)) == "__pycache__": + return + + return trigger_reload(self, filename) + + WatchdogReloaderLoop.trigger_reload = custom_trigger_reload diff --git a/frappe/boot.py b/frappe/boot.py index 65a07b15e5..0dfcb8d1b4 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -42,8 +42,6 @@ def get_bootinfo(): bootinfo.user_info = get_user_info() bootinfo.sid = frappe.session['sid'] - bootinfo.user_groups = frappe.get_all('User Group', pluck="name") - bootinfo.modules = {} bootinfo.module_list = [] load_desktop_data(bootinfo) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 022fe5f22d..22a063651c 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -203,10 +203,13 @@ def install_app(context, apps): @click.command("list-apps") +@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") @pass_context -def list_apps(context): +def list_apps(context, format): "List apps in site" + summary_dict = {} + def fix_whitespaces(text): if site == context.sites[-1]: text = text.rstrip() @@ -235,18 +238,23 @@ def list_apps(context): ] applications_summary = "\n".join(installed_applications) summary = f"{site_title}\n{applications_summary}\n" + summary_dict[site] = [app.app_name for app in apps] else: - applications_summary = "\n".join(frappe.get_installed_apps()) + installed_applications = frappe.get_installed_apps() + applications_summary = "\n".join(installed_applications) summary = f"{site_title}\n{applications_summary}\n" + summary_dict[site] = installed_applications summary = fix_whitespaces(summary) - if applications_summary and summary: + if format == "text" and applications_summary and summary: print(summary) frappe.destroy() + if format == "json": + click.echo(frappe.as_json(summary_dict)) @click.command('add-system-manager') @click.argument('email') @@ -548,7 +556,7 @@ def move(dest_dir, site): site_dump_exists = os.path.exists(final_new_path) count = int(count or 0) + 1 - os.rename(old_path, final_new_path) + shutil.move(old_path, final_new_path) frappe.destroy() return final_new_path diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index a203c8c6d9..a9272df170 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -96,22 +96,54 @@ def destroy_all_sessions(context, reason=None): raise SiteNotSpecifiedError @click.command('show-config') +@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text") @pass_context -def show_config(context): - "print configuration file" - print("\t\033[92m{:<50}\033[0m \033[92m{:<15}\033[0m".format('Config','Value')) - sites_path = os.path.join(frappe.utils.get_bench_path(), 'sites') - site_path = context.sites[0] - configuration = frappe.get_site_config(sites_path=sites_path, site_path=site_path) - print_config(configuration) +def show_config(context, format): + "Print configuration file to STDOUT in speified format" + if not context.sites: + raise SiteNotSpecifiedError + + sites_config = {} + sites_path = os.getcwd() + + from frappe.utils.commands import render_table + + def transform_config(config, prefix=None): + prefix = f"{prefix}." if prefix else "" + site_config = [] + + for conf, value in config.items(): + if isinstance(value, dict): + site_config += transform_config(value, prefix=f"{prefix}{conf}") + else: + log_value = json.dumps(value) if isinstance(value, list) else value + site_config += [[f"{prefix}{conf}", log_value]] + + return site_config + + for site in context.sites: + frappe.init(site) + + if len(context.sites) != 1 and format == "text": + if context.sites.index(site) != 0: + click.echo() + click.secho(f"Site {site}", fg="yellow") -def print_config(config): - for conf, value in config.items(): - if isinstance(value, dict): - print_config(value) - else: - print("\t{:<50} {:<15}".format(conf, value)) + configuration = frappe.get_site_config(sites_path=sites_path, site_path=site) + + if format == "text": + data = transform_config(configuration) + data.insert(0, ['Config','Value']) + render_table(data) + + if format == "json": + sites_config[site] = configuration + + frappe.destroy() + + if format == "json": + click.echo(frappe.as_json(sites_config)) @click.command('reset-perms') @@ -470,6 +502,7 @@ def console(context): locals()[app] = __import__(app) except ModuleNotFoundError: failed_to_import.append(app) + all_apps.remove(app) print("Apps in this namespace:\n{}".format(", ".join(all_apps))) if failed_to_import: @@ -652,20 +685,27 @@ def make_app(destination, app_name): @click.command('set-config') @click.argument('key') @click.argument('value') -@click.option('-g', '--global', 'global_', is_flag = True, default = False, help = 'Set Global Site Config') -@click.option('--as-dict', is_flag=True, default=False) +@click.option('-g', '--global', 'global_', is_flag=True, default=False, help='Set value in bench config') +@click.option('-p', '--parse', is_flag=True, default=False, help='Evaluate as Python Object') +@click.option('--as-dict', is_flag=True, default=False, help='Legacy: Evaluate as Python Object') @pass_context -def set_config(context, key, value, global_ = False, as_dict=False): +def set_config(context, key, value, global_=False, parse=False, as_dict=False): "Insert/Update a value in site_config.json" from frappe.installer import update_site_config - import ast + if as_dict: + from frappe.utils.commands import warn + warn("--as-dict will be deprecated in v14. Use --parse instead", category=PendingDeprecationWarning) + parse = as_dict + + if parse: + import ast value = ast.literal_eval(value) if global_: - sites_path = os.getcwd() # big assumption. + sites_path = os.getcwd() common_site_config_path = os.path.join(sites_path, 'common_site_config.json') - update_site_config(key, value, validate = False, site_config_path = common_site_config_path) + update_site_config(key, value, validate=False, site_config_path=common_site_config_path) else: for site in context.sites: frappe.init(site=site) @@ -722,50 +762,6 @@ def rebuild_global_search(context, static_pages=False): if not context.sites: raise SiteNotSpecifiedError -@click.command('auto-deploy') -@click.argument('app') -@click.option('--migrate', is_flag=True, default=False, help='Migrate after pulling') -@click.option('--restart', is_flag=True, default=False, help='Restart after migration') -@click.option('--remote', default='upstream', help='Remote, default is "upstream"') -@pass_context -def auto_deploy(context, app, migrate=False, restart=False, remote='upstream'): - '''Pull and migrate sites that have new version''' - from frappe.utils.gitutils import get_app_branch - from frappe.utils import get_sites - - branch = get_app_branch(app) - app_path = frappe.get_app_path(app) - - # fetch - subprocess.check_output(['git', 'fetch', remote, branch], cwd = app_path) - - # get diff - if subprocess.check_output(['git', 'diff', '{0}..{1}/{0}'.format(branch, remote)], cwd = app_path): - print('Updates found for {0}'.format(app)) - if app=='frappe': - # run bench update - import shlex - subprocess.check_output(shlex.split('bench update --no-backup'), cwd = '..') - else: - updated = False - subprocess.check_output(['git', 'pull', '--rebase', remote, branch], - cwd = app_path) - # find all sites with that app - for site in get_sites(): - frappe.init(site) - if app in frappe.get_installed_apps(): - print('Updating {0}'.format(site)) - updated = True - subprocess.check_output(['bench', '--site', site, 'clear-cache'], cwd = '..') - if migrate: - subprocess.check_output(['bench', '--site', site, 'migrate'], cwd = '..') - frappe.destroy() - - if updated or restart: - subprocess.check_output(['bench', 'restart'], cwd = '..') - else: - print('No Updates') - commands = [ build, diff --git a/frappe/core/doctype/doctype/boilerplate/controller._py b/frappe/core/doctype/doctype/boilerplate/controller._py index 583bd30908..6db99def55 100644 --- a/frappe/core/doctype/doctype/boilerplate/controller._py +++ b/frappe/core/doctype/doctype/boilerplate/controller._py @@ -1,8 +1,6 @@ -# -*- coding: utf-8 -*- # Copyright (c) {year}, {app_publisher} and contributors # For license information, please see license.txt -from __future__ import unicode_literals # import frappe {base_class_import} diff --git a/frappe/core/doctype/doctype/boilerplate/test_controller._py b/frappe/core/doctype/doctype/boilerplate/test_controller._py index 8ed08ae15a..5f4150ce9b 100644 --- a/frappe/core/doctype/doctype/boilerplate/test_controller._py +++ b/frappe/core/doctype/doctype/boilerplate/test_controller._py @@ -1,7 +1,5 @@ -# -*- coding: utf-8 -*- # Copyright (c) {year}, {app_publisher} and Contributors # See license.txt -from __future__ import unicode_literals # import frappe import unittest diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3588cc553a..6eef5a4023 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -83,12 +83,61 @@ class DocType(Document): if not self.is_new(): self.before_update = frappe.get_doc('DocType', self.name) self.setup_fields_to_fetch() + self.validate_field_name_conflicts() check_email_append_to(self) if self.default_print_format and not self.custom: frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) + if frappe.conf.get('developer_mode'): + self.owner = 'Administrator' + self.modified_by = 'Administrator' + + def validate_field_name_conflicts(self): + """Check if field names dont conflict with controller properties and methods""" + core_doctypes = [ + "Custom DocPerm", + "DocPerm", + "Custom Field", + "Customize Form Field", + "DocField", + ] + + if self.name in core_doctypes: + return + + from frappe.model.base_document import get_controller + + try: + controller = get_controller(self.name) + except ImportError: + controller = Document + + available_objects = {x for x in dir(controller) if isinstance(x, str)} + property_set = { + x for x in available_objects if isinstance(getattr(controller, x, None), property) + } + method_set = { + x for x in available_objects if x not in property_set and callable(getattr(controller, x, None)) + } + + for docfield in self.get("fields") or []: + conflict_type = None + field = docfield.fieldname + field_label = docfield.label or docfield.fieldname + + if docfield.fieldname in method_set: + conflict_type = "controller method" + if docfield.fieldname in property_set: + conflict_type = "class property" + + if conflict_type: + frappe.throw( + _("Fieldname '{0}' conflicting with a {1} of the name {2} in {3}") + .format(field_label, conflict_type, field, self.name) + ) + def after_insert(self): # clear user cache so that on the next reload this doctype is included in boot clear_user_cache(frappe.session.user) @@ -1174,11 +1223,19 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): else: raise -def check_if_fieldname_conflicts_with_methods(doctype, fieldname): - doc = frappe.get_doc({"doctype": doctype}) - method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))] +def check_fieldname_conflicts(doctype, fieldname): + """Checks if fieldname conflicts with methods or properties""" - if fieldname in method_list: + doc = frappe.get_doc({"doctype": doctype}) + available_objects = [x for x in dir(doc) if isinstance(x, str)] + property_list = [ + x for x in available_objects if isinstance(getattr(type(doc), x, None), property) + ] + method_list = [ + x for x in available_objects if x not in property_list and callable(getattr(doc, x)) + ] + + if fieldname in method_list + property_list: frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) def clear_linked_doctype_cache(): diff --git a/frappe/core/doctype/report/boilerplate/controller.py b/frappe/core/doctype/report/boilerplate/controller.py index 55c01e4f75..b8e9cb7467 100644 --- a/frappe/core/doctype/report/boilerplate/controller.py +++ b/frappe/core/doctype/report/boilerplate/controller.py @@ -1,7 +1,6 @@ # Copyright (c) 2013, {app_publisher} and contributors # For license information, please see license.txt -from __future__ import unicode_literals # import frappe def execute(filters=None): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 0462de8643..a4d13a57e0 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -56,6 +56,7 @@ class User(Document): def after_insert(self): create_notification_settings(self.name) + frappe.cache().delete_key('users_for_mentions') def validate(self): self.check_demo() @@ -129,6 +130,9 @@ class User(Document): if self.time_zone: frappe.defaults.set_default("time_zone", self.time_zone, self.name) + if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'): + frappe.cache().delete_key('users_for_mentions') + def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" return self.name == frappe.session.user @@ -389,6 +393,9 @@ class User(Document): # delete notification settings frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) + if self.get('allow_in_mentions'): + frappe.cache().delete_key('users_for_mentions') + def before_rename(self, old_name, new_name, merge=False): self.check_demo() diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py index 64bffa06d0..b1d0fede4c 100644 --- a/frappe/core/doctype/user_group/user_group.py +++ b/frappe/core/doctype/user_group/user_group.py @@ -9,7 +9,7 @@ import frappe class UserGroup(Document): def after_insert(self): - frappe.publish_realtime('user_group_added', self.name) + frappe.cache().delete_key('user_groups') def on_trash(self): - frappe.publish_realtime('user_group_deleted', self.name) + frappe.cache().delete_key('user_groups') diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js index b75ea6a41c..fdca93e8b9 100644 --- a/frappe/core/page/recorder/recorder.js +++ b/frappe/core/page/recorder/recorder.js @@ -1,7 +1,7 @@ frappe.pages['recorder'].on_page_load = function(wrapper) { frappe.ui.make_app_page({ parent: wrapper, - title: 'Recorder', + title: __('Recorder'), single_column: true, card_layout: true }); diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index fb49aa5da0..39aff8b4a7 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -64,8 +64,8 @@ class CustomField(Document): self.translatable = 0 if not self.flags.ignore_validate: - from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods - check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname) + from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts + check_fieldname_conflicts(self.dt, self.fieldname) def on_update(self): if not frappe.flags.in_setup_wizard: diff --git a/frappe/desk/page/backups/backups.css b/frappe/desk/page/backups/backups.css index 13f093e0b1..32ccb88c37 100644 --- a/frappe/desk/page/backups/backups.css +++ b/frappe/desk/page/backups/backups.css @@ -5,6 +5,7 @@ .download-backup-card { display: block; text-decoration: none; + margin-bottom: var(--margin-lg); } .download-backup-card:hover { diff --git a/frappe/desk/page/backups/backups.js b/frappe/desk/page/backups/backups.js index c82407c6bd..337ad33f43 100644 --- a/frappe/desk/page/backups/backups.js +++ b/frappe/desk/page/backups/backups.js @@ -1,7 +1,7 @@ frappe.pages['backups'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, - title: 'Download Backups', + title: __('Download Backups'), single_column: true }); diff --git a/frappe/desk/page/translation_tool/translation_tool.js b/frappe/desk/page/translation_tool/translation_tool.js index b3f0c032e3..13f68e647a 100644 --- a/frappe/desk/page/translation_tool/translation_tool.js +++ b/frappe/desk/page/translation_tool/translation_tool.js @@ -1,7 +1,7 @@ frappe.pages['translation-tool'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, - title: 'Translation Tool', + title: __('Translation Tool'), single_column: true, card_layout: true, }); diff --git a/frappe/desk/page/user_profile/user_profile.html b/frappe/desk/page/user_profile/user_profile.html index 911ccc702d..f134441b74 100644 --- a/frappe/desk/page/user_profile/user_profile.html +++ b/frappe/desk/page/user_profile/user_profile.html @@ -8,7 +8,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -19,7 +19,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -30,7 +30,7 @@
- No Data to Show + {%=__("No Data to Show") %}
@@ -41,4 +41,4 @@ - \ No newline at end of file + diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 9589507ca6..befaf7b01f 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -377,10 +377,17 @@ def handle_duration_fieldtype_values(result, columns): if fieldtype == "Duration": for entry in range(0, len(result)): - val_in_seconds = result[entry][i] - if val_in_seconds: - duration_val = format_duration(val_in_seconds) - result[entry][i] = duration_val + row = result[entry] + if isinstance(row, dict): + val_in_seconds = row[col.fieldname] + if val_in_seconds: + duration_val = format_duration(val_in_seconds) + row[col.fieldname] = duration_val + else: + val_in_seconds = row[i] + if val_in_seconds: + duration_val = format_duration(val_in_seconds) + row[i] = duration_val return result diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 6181261fc2..3c9109eca9 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -221,3 +221,37 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): return [] return fn(**kwargs) + + +@frappe.whitelist() +def get_names_for_mentions(search_term): + users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions) + user_groups = frappe.cache().get_value('user_groups', get_user_groups) + + filtered_mentions = [] + for mention_data in users_for_mentions + user_groups: + if search_term.lower() not in mention_data.value.lower(): + continue + + mention_data['link'] = frappe.utils.get_url_to_form( + 'User Group' if mention_data.get('is_group') else 'User Profile', + mention_data['id'] + ) + + filtered_mentions.append(mention_data) + + return sorted(filtered_mentions, key=lambda d: d['value']) + +def get_users_for_mentions(): + return frappe.get_all('User', + fields=['name as id', 'full_name as value'], + filters={ + 'name': ['not in', ('Administrator', 'Guest')], + 'allowed_in_mentions': True, + 'user_type': 'System User', + }) + +def get_user_groups(): + return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={ + 'is_group': True + }) 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..5f0393e008 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,202 +1,257 @@ -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, + get_server_url, + get_userinfo, +) +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 + +def encode_params(params): + """ + Encode a dict of params into a query string. + + Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as + `%20` instead of as `+`. This is needed because oauthlib cannot handle `+` + as a whitespace. + """ + return urlencode(params, quote_via=quote) + + @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")) - - 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) - } + body = frappe._dict(json.loads(body)) - 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)}) + if body.error: + frappe.local.response = body + frappe.local.response["http_status_code"] = 400 + return - frappe.local.response = out + frappe.local.response = body + return - 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 -def encode_params(params): - """ - Encode a dict of params into a query string. + except (FatalClientError, OAuth2Error) as e: + return generate_json_error_response(e) - Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as - `%20` instead of as `+`. This is needed because oauthlib cannot handle `+` - as a whitespace. - """ - return urlencode(params, quote_via=quote) + +@frappe.whitelist(allow_guest=True) +def openid_configuration(): + frappe_server_url = get_server_url() + frappe.local.response = frappe._dict( + { + "issuer": frappe_server_url, + "authorization_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.authorize", + "token_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.get_token", + "userinfo_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.openid_profile", + "revocation_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.revoke_token", + "introspection_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.introspect_token", + "response_types_supported": [ + "code", + "token", + "code id_token", + "code token id_token", + "id_token", + "id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["HS256"], + } + ) + + +@frappe.whitelist(allow_guest=True) +def introspect_token(token=None, token_type_hint=None): + if token_type_hint not in ["access_token", "refresh_token"]: + token_type_hint = "access_token" + try: + bearer_token = None + if token_type_hint == "access_token": + bearer_token = frappe.get_doc("OAuth Bearer Token", {"access_token": token}) + elif token_type_hint == "refresh_token": + bearer_token = frappe.get_doc( + "OAuth Bearer Token", {"refresh_token": token} + ) + + client = frappe.get_doc("OAuth Client", bearer_token.client) + + token_response = frappe._dict( + { + "client_id": client.client_id, + "trusted_client": client.skip_authorization, + "active": bearer_token.status == "Active", + "exp": round(bearer_token.expiration_time.timestamp()), + "scope": bearer_token.scopes, + } + ) + + if "openid" in bearer_token.scopes: + sub = frappe.get_value( + "User Social Login", + {"provider": "frappe", "parent": bearer_token.user}, + "userid", + ) + + if sub: + token_response.update({"sub": sub}) + user = frappe.get_doc("User", bearer_token.user) + userinfo = get_userinfo(user) + token_response.update(userinfo) + + frappe.local.response = token_response + + except Exception: + frappe.local.response = frappe._dict({"active": False}) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 983511f7a4..b63ce9dd44 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -34,8 +34,9 @@ def get_controller(doctype): from frappe.model.document import Document from frappe.utils.nestedset import NestedSet - module_name, custom = frappe.db.get_value("DocType", doctype, ("module", "custom"), cache=True) \ - or ["Core", False] + module_name, custom = frappe.db.get_value( + "DocType", doctype, ("module", "custom"), cache=True + ) or ["Core", False] if custom: if frappe.db.field_exists("DocType", "is_tree"): diff --git a/frappe/model/document.py b/frappe/model/document.py index 4169919091..623916597e 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1347,6 +1347,22 @@ class Document(BaseDocument): from frappe.desk.doctype.tag.tag import DocTags return DocTags(self.doctype).get_tags(self.name).split(",")[1:] + def __repr__(self): + name = self.name or "unsaved" + doctype = self.__class__.__name__ + + docstatus = f" docstatus={self.docstatus}" if self.docstatus else "" + parent = f" parent={self.parent}" if self.parent else "" + + return f"<{doctype}: {name}{docstatus}{parent}>" + + def __str__(self): + name = self.name or "unsaved" + doctype = self.__class__.__name__ + + return f"{doctype}({name})" + + def execute_action(doctype, name, action, **kwargs): """Execute an action on a document (called by background worker)""" doc = frappe.get_doc(doctype, name) diff --git a/frappe/oauth.py b/frappe/oauth.py index 09af5ad809..076ce2d2be 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 - -def get_url_delimiter(separator_character=" "): - return separator_character +from oauthlib.openid import RequestValidator +from urllib.parse import urlparse, unquote -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,72 @@ 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') + 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" + ) + + 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 - else: - 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 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" + ) + + redirect_uris = frappe.db.get_value("OAuth Client", client_id, "redirect_uris") + + if redirect_uris: + redirect_uris = redirect_uris.split(get_url_delimiter()) + return redirect_uri in redirect_uris 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 +229,30 @@ 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") - except Exception as e: + 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: 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 +267,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 +306,38 @@ 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') + 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') + frappe.db.set_value( + "OAuth Bearer Token", {"refresh_token": token}, "status", "Revoked" + ) else: - otoken = frappe.db.set_value("OAuth Bearer Token", token, 'status', 'Revoked') + 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 +345,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 + id_token_header = {"typ": "jwt", "alg": "HS256"} - - nonce, if workflow is implicit or hybrid and it was provided - - claims, if provided to the original Authorization Code request + user = frappe.get_doc( + "User", + frappe.session.user, + ) - 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. + if request.nonce: + id_token["nonce"] = request.nonce - Similarly, when the request parameter has a ``code`` property defined, the ID Token - *should* include a calculated ``c_hash`` claim. + userinfo = get_userinfo(user) - http://openid.net/specs/openid-connect-core-1_0.html (sections `3.1.3.6`_, `3.2.2.10`_, `3.3.2.11`_) + if userinfo.get("iss"): + id_token["iss"] = userinfo.get("iss") - .. _`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 + if "openid" in request.scopes: + id_token.update(userinfo) - :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 + id_token_encoded = jwt.encode( + payload=id_token, + key=request.client.client_secret, + algorithm="HS256", + headers=id_token_header, + ) + + 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) + 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 +434,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 +457,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 +479,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: + 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 +560,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 +594,58 @@ 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): + picture = None + frappe_server_url = get_server_url() + + if user.user_image: + if frappe.utils.validate_url(user.user_image): + picture = user.user_image + else: + picture = frappe_server_url + "/" + 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), + "iss": frappe_server_url, + } + ) + + 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 + + +def get_server_url(): + request_url = urlparse(frappe.request.url) + request_url = f"{request_url.scheme}://{request_url.netloc}" + return frappe.get_value("Social Login Key", "frappe", "base_url") or request_url diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index c093a73689..984d9ab91c 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -114,8 +114,6 @@ frappe.Application = Class.extend({ dialog.get_close_btn().toggle(false); }); - this.setup_user_group_listeners(); - // listen to build errors this.setup_build_error_listener(); @@ -476,14 +474,19 @@ frappe.Application = Class.extend({ $('').appendTo("head"); }, trigger_primary_action: function() { - if(window.cur_dialog && cur_dialog.display) { - // trigger primary - cur_dialog.get_primary_btn().trigger("click"); - } else if(cur_frm && cur_frm.page.btn_primary.is(':visible')) { - cur_frm.page.btn_primary.trigger('click'); - } else if(frappe.container.page.save_action) { - frappe.container.page.save_action(); - } + // to trigger change event on active input before triggering primary action + $(document.activeElement).blur(); + // wait for possible JS validations triggered after blur (it might change primary button) + setTimeout(() => { + if (window.cur_dialog && cur_dialog.display) { + // trigger primary + cur_dialog.get_primary_btn().trigger("click"); + } else if (cur_frm && cur_frm.page.btn_primary.is(':visible')) { + cur_frm.page.btn_primary.trigger('click'); + } else if (frappe.container.page.save_action) { + frappe.container.page.save_action(); + } + }, 100); }, set_rtl: function() { @@ -593,15 +596,6 @@ frappe.Application = Class.extend({ } }, - setup_user_group_listeners() { - frappe.realtime.on('user_group_added', (user_group) => { - frappe.boot.user_groups && frappe.boot.user_groups.push(user_group); - }); - frappe.realtime.on('user_group_deleted', (user_group) => { - frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group); - }); - }, - setup_energy_point_listeners() { frappe.realtime.on('energy_point_alert', (message) => { frappe.show_alert(message); diff --git a/frappe/public/js/frappe/dom.js b/frappe/public/js/frappe/dom.js index db9407ed53..2769e9061d 100644 --- a/frappe/public/js/frappe/dom.js +++ b/frappe/public/js/frappe/dom.js @@ -319,7 +319,7 @@ frappe.get_data_pill = (label, target_id=null, remove_action=null, image=null) = frappe.get_modal = function(title, content) { return $(`