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 @@
+ {% 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",
+ )