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/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/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/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", + )