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 @@
@@ -19,7 +19,7 @@
@@ -30,7 +30,7 @@
@@ -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 $(`