diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index f9d7adceef..ddafd0e9fd 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -39,6 +39,8 @@ "allow_login_using_mobile_number", "allow_login_using_user_name", "disable_user_pass_login", + "login_with_email_link", + "login_with_email_link_expiry", "allow_error_traceback", "strip_exif_metadata_from_uploaded_images", "allow_older_web_view_links", @@ -416,11 +418,11 @@ "label": "Send document Web View link in email" }, { - "collapsible": 1, - "fieldname": "prepared_report_section", - "fieldtype": "Section Break", - "label": "Reports" - }, + "collapsible": 1, + "fieldname": "prepared_report_section", + "fieldtype": "Section Break", + "label": "Reports" + }, { "default": "Frappe", "description": "The application name will be used in the Login page.", @@ -504,12 +506,26 @@ "fieldname": "disable_user_pass_login", "fieldtype": "Check", "label": "Disable Username/Password Login" + }, + { + "default": "0", + "description": "Allow users to log in without a password, using a login link sent to their email", + "fieldname": "login_with_email_link", + "fieldtype": "Check", + "label": "Login with email link" + }, + { + "default": "10", + "depends_on": "login_with_email_link", + "fieldname": "login_with_email_link_expiry", + "fieldtype": "Int", + "label": "Login with email link expiry (in minutes)" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-11-28 17:57:05.099512", + "modified": "2022-12-20 21:45:37.651668", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/handler.py b/frappe/handler.py index 30ebe30c9d..0a25f329c7 100644 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -33,6 +33,8 @@ ALLOWED_MIMETYPES = ( "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", "text/plain", + "video/quicktime", + "video/mp4", ) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 76c99652c6..adb4df9d2a 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1321,7 +1321,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (this.list_view_settings && this.list_view_settings.disable_auto_refresh) { return; } - frappe.socketio.list_subscribe(this.doctype); + frappe.socketio.doctype_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => { if (!frappe.get_doc(data?.doctype, data?.name)?.__unsaved) { frappe.model.remove_from_locals(data.doctype, data.name); diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index 1ac875544c..792346ed87 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -129,8 +129,8 @@ frappe.socketio = { task_unsubscribe: function (task_id) { frappe.socketio.socket.emit("task_unsubscribe", task_id); }, - list_subscribe: function (doctype) { - frappe.socketio.socket.emit("list_update", doctype); + doctype_subscribe: function (doctype) { + frappe.socketio.socket.emit("doctype_subscribe", doctype); }, doc_subscribe: function (doctype, docname) { if (frappe.flags.doc_subscribe) { diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index f58b06f6cf..cbd9bebcb0 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -56,7 +56,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { if (this.list_view_settings?.disable_auto_refresh) { return; } - frappe.socketio.list_subscribe(this.doctype); + frappe.socketio.doctype_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => this.on_update(data)); } diff --git a/frappe/public/scss/login.bundle.scss b/frappe/public/scss/login.bundle.scss index 488dd4106e..b491e49a50 100644 --- a/frappe/public/scss/login.bundle.scss +++ b/frappe/public/scss/login.bundle.scss @@ -7,6 +7,7 @@ body { } .for-forgot, +.for-login-with-email-link, .for-signup, .for-email-login { display: none; @@ -14,6 +15,7 @@ body { .for-login, .for-forgot, +.for-login-with-email-link, .for-signup, .for-email-login { padding: max(10vh, 60px) 0; diff --git a/frappe/realtime.py b/frappe/realtime.py index eff3ea2b77..6283b8eb80 100644 --- a/frappe/realtime.py +++ b/frappe/realtime.py @@ -115,7 +115,7 @@ def can_subscribe_doc(doctype: str, docname: str) -> bool: @frappe.whitelist(allow_guest=True) -def can_subscribe_list(doctype: str) -> bool: +def can_subscribe_doctype(doctype: str) -> bool: from frappe.exceptions import PermissionError if not frappe.has_permission(user=frappe.session.user, doctype=doctype, ptype="read"): diff --git a/frappe/templates/emails/login_with_email_link.html b/frappe/templates/emails/login_with_email_link.html new file mode 100644 index 0000000000..144869e2e6 --- /dev/null +++ b/frappe/templates/emails/login_with_email_link.html @@ -0,0 +1,41 @@ +{% macro table(content, table_class) %} + + + + + + +
+ {{ content }} +
+{% endmacro %} + +{% macro body() %} + + + + + + + + + +
+ +
{{ _('The link will expire in {0} minutes').format(minutes) }}
+
+ +
+{% endmacro %} + +
+
+ {{ table(table(body(), 'email-body'), 'email-container') }} +
+
\ No newline at end of file diff --git a/frappe/templates/includes/login/login.js b/frappe/templates/includes/login/login.js index defbbc2975..16f78ea0c9 100644 --- a/frappe/templates/includes/login/login.js +++ b/frappe/templates/includes/login/login.js @@ -55,6 +55,25 @@ login.bind_events = function () { return false; }); + $(".form-login-with-email-link").on("submit", function (event) { + event.preventDefault(); + var args = {}; + args.cmd = "frappe.www.login.send_login_link"; + args.email = ($("#login_with_email_link_email").val() || "").trim(); + if (!args.email) { + login.set_status('{{ _("Valid Login id required.") }}', 'red'); + return false; + } + login.call(args).then(() => { + login.set_status('{{ _("Login link sent to your email") }}', 'blue'); + $("#login_with_email_link_email").val(""); + }).catch(() => { + login.set_status('{{ _("Send login link") }}', 'blue'); + }); + + return false; + }); + $(".toggle-password").click(function () { var input = $($(this).attr("toggle")); if (input.attr("type") == "password") { @@ -86,6 +105,7 @@ login.bind_events = function () { login.route = function () { var route = window.location.hash.slice(1); if (!route) route = "login"; + route = route.replaceAll("-", "_"); login[route](); } @@ -94,6 +114,7 @@ login.reset_sections = function (hide) { $("section.for-login").toggle(false); $("section.for-email-login").toggle(false); $("section.for-forgot").toggle(false); + $("section.for-login-with-email-link").toggle(false); $("section.for-signup").toggle(false); } $('section:not(.signup-disabled) .indicator').each(function () { @@ -121,10 +142,22 @@ login.steptwo = function () { login.forgot = function () { login.reset_sections(); + if ($("#login_email").val()) { + $("#forgot_email").val($("#login_email").val()); + } $(".for-forgot").toggle(true); $("#forgot_email").focus(); } +login.login_with_email_link = function () { + login.reset_sections(); + if ($("#login_email").val()) { + $("#login_with_email_link_email").val($("#login_email").val()); + } + $(".for-login-with-email-link").toggle(true); + $("#login_with_email_link_email").focus(); +} + login.signup = function () { login.reset_sections(); $(".for-signup").toggle(true); @@ -270,7 +303,7 @@ frappe.ready(function () { $(window).trigger("hashchange"); } - $(".form-signup, .form-forgot").removeClass("hide"); + $(".form-signup, .form-forgot, .form-login-with-email-link").removeClass("hide"); $(document).trigger('login_rendered'); }); diff --git a/frappe/tests/test_auth.py b/frappe/tests/test_auth.py index 403d0cf34c..fe8a290a04 100644 --- a/frappe/tests/test_auth.py +++ b/frappe/tests/test_auth.py @@ -2,11 +2,14 @@ # License: MIT. See LICENSE import time +import requests + import frappe import frappe.utils from frappe.auth import LoginAttemptTracker from frappe.frappeclient import AuthError, FrappeClient from frappe.tests.utils import FrappeTestCase +from frappe.www.login import _generate_temporary_login_link def add_user(email, password, username=None, mobile_no=None): @@ -42,6 +45,9 @@ class TestAuth(FrappeTestCase): @classmethod def tearDownClass(cls): frappe.delete_doc("User", cls.test_user_email, force=True) + frappe.local.request_ip = None + frappe.form_dict.email = None + frappe.local.response["http_status_code"] = None def set_system_settings(self, k, v): frappe.db.set_value("System Settings", "System Settings", k, v) @@ -123,6 +129,32 @@ class TestAuth(FrappeTestCase): with self.assertRaises(Exception): FrappeClient(self.HOST_NAME, self.test_user_email, self.test_user_password).get_list("ToDo") + def test_login_with_email_link(self): + + user = self.test_user_email + + # Logs in + res = requests.get(_generate_temporary_login_link(user, 10)) + self.assertEqual(res.status_code, 200) + self.assertTrue(res.cookies.get("sid")) + self.assertNotEqual(res.cookies.get("sid"), "Guest") + + # Random incorrect URL + res = requests.get(_generate_temporary_login_link(user, 10) + "aa") + self.assertEqual(res.cookies.get("sid"), "Guest") + + # POST doesn't work + res = requests.post(_generate_temporary_login_link(user, 10)) + self.assertEqual(res.status_code, 403) + + # Rate limiting + for _ in range(6): + res = requests.get(_generate_temporary_login_link(user, 10)) + if res.status_code == 417: + break + else: + self.fail("Rate limting not working") + class TestLoginAttemptTracker(FrappeTestCase): def test_account_lock(self): diff --git a/frappe/tests/test_rate_limiter.py b/frappe/tests/test_rate_limiter.py index e93bf98875..c8485d6c69 100644 --- a/frappe/tests/test_rate_limiter.py +++ b/frappe/tests/test_rate_limiter.py @@ -13,9 +13,6 @@ from frappe.utils import cint class TestRateLimiter(FrappeTestCase): - def setUp(self): - pass - def test_apply_with_limit(self): frappe.conf.rate_limit = {"window": 86400, "limit": 1} frappe.rate_limiter.apply() diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html index 67236fb26d..b5e2f8d4f8 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post.html +++ b/frappe/website/doctype/blog_post/templates/blog_post.html @@ -22,7 +22,7 @@ {%- if read_time -%}  · - {{ read_time }} min read + {{ read_time }} {{ _('min read') }} {%- endif -%} diff --git a/frappe/website/doctype/blog_post/templates/blog_post_row.html b/frappe/website/doctype/blog_post/templates/blog_post_row.html index f8494d12b0..91beeb12e9 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post_row.html +++ b/frappe/website/doctype/blog_post/templates/blog_post_row.html @@ -33,7 +33,7 @@ {{ post.full_name }}
{{ frappe.format_date(post.published_on) }} - {% if post.read_time %} · {{ post.read_time }} min read {% endif %} + {% if post.read_time %} · {{ post.read_time }} {{ _('min read') }} {% endif %}
diff --git a/frappe/www/login.html b/frappe/www/login.html index e9f2b9103a..1ac80bbee5 100644 --- a/frappe/www/login.html +++ b/frappe/www/login.html @@ -80,7 +80,7 @@
{{ logo_section() }}
- {%- if social_login -%} + {%- if social_login or login_with_email_link -%}
{{ email_login_body() }} @@ -101,6 +101,15 @@
{% endfor %}
+ {% if login_with_email_link %} + + {% endif %} {% else %} @@ -181,6 +190,38 @@ + + {% endblock %} diff --git a/frappe/www/login.py b/frappe/www/login.py index 32e4bb4344..ce65390f3c 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -7,7 +7,8 @@ from frappe import _ from frappe.auth import LoginManager from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings from frappe.integrations.oauth2_logins import decoder_compat -from frappe.utils import cint +from frappe.rate_limiter import rate_limit +from frappe.utils import cint, get_url from frappe.utils.html_utils import get_icon_html from frappe.utils.jinja import guess_is_path from frappe.utils.oauth import ( @@ -102,6 +103,8 @@ def get_context(context): context["login_label"] = f" {_('or')} ".join(login_label) + context["login_with_email_link"] = frappe.get_system_settings("login_with_email_link") + return context @@ -143,3 +146,61 @@ def login_via_token(login_token): redirect_post_login( desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User" ) + + +@frappe.whitelist(allow_guest=True) +@rate_limit(limit=5, seconds=60 * 60) +def send_login_link(email: str): + + expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10 + link = _generate_temporary_login_link(email, expiry) + + app_name = ( + frappe.get_website_settings("app_name") or frappe.get_system_settings("app_name") or _("Frappe") + ) + + subject = _("Login To {0}").format(app_name) + + frappe.sendmail( + subject=subject, + recipients=email, + template="login_with_email_link", + args={"link": link, "minutes": expiry, "app_name": app_name}, + now=True, + ) + + +def _generate_temporary_login_link(email: str, expiry: int): + assert isinstance(email, str) + + if not frappe.db.exists("User", email): + frappe.throw( + _("User with email address {0} does not exist").format(email), frappe.DoesNotExistError + ) + key = frappe.generate_hash() + frappe.cache().set_value(f"one_time_login_key:{key}", email, expires_in_sec=expiry * 60) + + return get_url(f"/api/method/frappe.www.login.login_via_key?key={key}") + + +@frappe.whitelist(allow_guest=True, methods=["GET"]) +@rate_limit(limit=5, seconds=60 * 60) +def login_via_key(key: str): + cache_key = f"one_time_login_key:{key}" + email = frappe.cache().get_value(cache_key) + + if email: + frappe.cache().delete_value(cache_key) + + frappe.local.login_manager.login_as(email) + + redirect_post_login( + desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User" + ) + else: + frappe.respond_as_web_page( + _("Not Permitted"), + _("The link you trying to login is invalid or expired."), + http_status_code=403, + indicator_color="red", + ) diff --git a/socketio.js b/socketio.js index 250f3744d1..67746ee84e 100644 --- a/socketio.js +++ b/socketio.js @@ -58,8 +58,8 @@ io.on("connection", function (socket) { socket.join(get_site_room(socket)); } - socket.on("list_update", function (doctype) { - can_subscribe_list({ + socket.on("doctype_subscribe", function (doctype) { + can_subscribe_doctype({ socket, doctype, callback: () => { @@ -286,11 +286,11 @@ function can_subscribe_doc(args) { }); } -function can_subscribe_list(args) { +function can_subscribe_doctype(args) { if (!args) return; if (!args.doctype) return; request - .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_list")) + .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_doctype")) .type("form") .query({ sid: args.socket.sid, @@ -306,7 +306,7 @@ function can_subscribe_list(args) { args.callback && args.callback(err, res); return true; } - log("ERROR (can_subscribe_list): ", err, res); + log("ERROR (can_subscribe_doctype): ", err, res); }); }